diff --git a/Assets/AppIcon.icns b/Assets/AppIcon.icns
new file mode 100644
index 0000000..b542d96
Binary files /dev/null and b/Assets/AppIcon.icns differ
diff --git a/Assets/AppIcon.png b/Assets/AppIcon.png
new file mode 100644
index 0000000..6a8f6e7
Binary files /dev/null and b/Assets/AppIcon.png differ
diff --git a/Info.plist b/Info.plist
index f666425..e04c10b 100644
--- a/Info.plist
+++ b/Info.plist
@@ -12,19 +12,27 @@
6.0
CFBundleName
Camera Bubble
+ CFBundleDisplayName
+ Camera Bubble
+ CFBundleIconFile
+ AppIcon
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.0
+ 1.1.0
CFBundleVersion
1
LSMinimumSystemVersion
14.0
+ LSApplicationCategoryType
+ public.app-category.video
LSUIElement
NSCameraUsageDescription
Camera Bubble shows your camera in a floating window while you record your screen.
NSHighResolutionCapable
+ NSHumanReadableCopyright
+ Copyright © 2026 Bishal Banerjee. MIT licensed.
diff --git a/README.md b/README.md
index 1125b56..ac8fe24 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+
+
+
+
# Camera Bubble
A tiny, privacy-first camera overlay for macOS. Keep your face visible in the
@@ -14,6 +18,9 @@ want.
- Mirrored front-camera view
- Manual `Start / Pause / Resume` timer
- Timer reset between takes
+- Menu-bar controls for the bubble and timer
+- Remembers window size, position, and mirroring
+- Native app icon and About panel
- Works across macOS Spaces and full-screen apps
- No account, analytics, networking, or cloud processing
- Native AppKit + AVFoundation with no third-party dependencies
@@ -60,7 +67,8 @@ Do not disable Gatekeeper globally.
3. Resize it from any edge.
4. Click **Start** when your demo begins.
5. Use **Pause/Resume** or **Reset** independently of your screen recorder.
-6. Right-click inside the bubble and choose **Quit Camera Bubble** when finished.
+6. Use the menu-bar camera icon to hide/show the bubble, control the timer,
+ toggle mirroring, open camera privacy settings, or quit.
Your screen recorder must capture the **entire display**. If it records only one
application window, macOS may omit the floating bubble.
diff --git a/Sources/CameraBubble/main.swift b/Sources/CameraBubble/main.swift
index 42b11d9..3d4b29c 100644
--- a/Sources/CameraBubble/main.swift
+++ b/Sources/CameraBubble/main.swift
@@ -2,38 +2,37 @@ import AppKit
import AVFoundation
final class CameraPreviewView: NSView {
+ var onClose: (() -> Void)?
+ var onTimerStateChange: ((Bool) -> Void)?
+
private let session = AVCaptureSession()
private var previewLayer: AVCaptureVideoPreviewLayer?
private let message = NSTextField(labelWithString: "Starting camera…")
private let timerLabel = NSTextField(labelWithString: "00:00")
private let startButton = NSButton(title: "Start", target: nil, action: nil)
private let resetButton = NSButton(title: "Reset", target: nil, action: nil)
+ private let closeButton = NSButton()
private var clock: Timer?
private var startedAt: Date?
private var accumulatedSeconds: TimeInterval = 0
+ var isTimerRunning: Bool { clock != nil }
+
+ var isMirrored = true {
+ didSet { updateMirroring() }
+ }
+
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.backgroundColor = NSColor.black.cgColor
layer?.cornerRadius = 24
layer?.masksToBounds = true
- layer?.borderColor = NSColor.white.withAlphaComponent(0.35).cgColor
+ layer?.borderColor = NSColor.white.withAlphaComponent(0.30).cgColor
layer?.borderWidth = 1
- message.textColor = .white
- message.font = .systemFont(ofSize: 14, weight: .medium)
- message.alignment = .center
- message.translatesAutoresizingMaskIntoConstraints = false
- addSubview(message)
- NSLayoutConstraint.activate([
- message.centerXAnchor.constraint(equalTo: centerXAnchor),
- message.centerYAnchor.constraint(equalTo: centerYAnchor),
- message.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 18),
- message.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -18)
- ])
-
- configureTimerControls()
+ configureMessage()
+ configureControls()
requestCamera()
}
@@ -46,13 +45,22 @@ final class CameraPreviewView: NSView {
previewLayer?.frame = bounds
}
- override func rightMouseDown(with event: NSEvent) {
- let menu = NSMenu()
- menu.addItem(withTitle: "Quit Camera Bubble", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
- NSMenu.popUpContextMenu(menu, with: event, for: self)
+ private func configureMessage() {
+ message.textColor = .white
+ message.font = .systemFont(ofSize: 14, weight: .medium)
+ message.alignment = .center
+ message.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(message)
+
+ NSLayoutConstraint.activate([
+ message.centerXAnchor.constraint(equalTo: centerXAnchor),
+ message.centerYAnchor.constraint(equalTo: centerYAnchor),
+ message.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 18),
+ message.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -18)
+ ])
}
- private func configureTimerControls() {
+ private func configureControls() {
timerLabel.font = .monospacedDigitSystemFont(ofSize: 18, weight: .semibold)
timerLabel.textColor = .white
timerLabel.alignment = .center
@@ -60,6 +68,8 @@ final class CameraPreviewView: NSView {
timerLabel.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.58).cgColor
timerLabel.layer?.cornerRadius = 9
timerLabel.translatesAutoresizingMaskIntoConstraints = false
+ timerLabel.toolTip = "Elapsed recording time"
+ addSubview(timerLabel)
for button in [startButton, resetButton] {
button.bezelStyle = .rounded
@@ -68,18 +78,37 @@ final class CameraPreviewView: NSView {
button.translatesAutoresizingMaskIntoConstraints = false
addSubview(button)
}
+
startButton.target = self
startButton.action = #selector(toggleTimer)
+ startButton.toolTip = "Start or pause the timer"
+
resetButton.target = self
resetButton.action = #selector(resetTimer)
+ resetButton.toolTip = "Reset the timer"
+
+ closeButton.image = NSImage(systemSymbolName: "xmark.circle.fill", accessibilityDescription: "Hide Camera Bubble")
+ closeButton.imageScaling = .scaleProportionallyUpOrDown
+ closeButton.bezelStyle = .inline
+ closeButton.isBordered = false
+ closeButton.contentTintColor = .white
+ closeButton.target = self
+ closeButton.action = #selector(hideBubble)
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
+ closeButton.toolTip = "Hide Camera Bubble"
+ addSubview(closeButton)
- addSubview(timerLabel)
NSLayoutConstraint.activate([
timerLabel.topAnchor.constraint(equalTo: topAnchor, constant: 12),
timerLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
timerLabel.widthAnchor.constraint(equalToConstant: 78),
timerLabel.heightAnchor.constraint(equalToConstant: 30),
+ closeButton.topAnchor.constraint(equalTo: topAnchor, constant: 11),
+ closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -11),
+ closeButton.widthAnchor.constraint(equalToConstant: 24),
+ closeButton.heightAnchor.constraint(equalToConstant: 24),
+
startButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
startButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12),
startButton.widthAnchor.constraint(equalToConstant: 64),
@@ -90,7 +119,7 @@ final class CameraPreviewView: NSView {
])
}
- @objc private func toggleTimer() {
+ @objc func toggleTimer() {
if clock == nil {
startedAt = Date()
startButton.title = "Pause"
@@ -112,15 +141,17 @@ final class CameraPreviewView: NSView {
startButton.title = "Resume"
updateTimer()
}
+ onTimerStateChange?(isTimerRunning)
}
- @objc private func resetTimer() {
+ @objc func resetTimer() {
clock?.invalidate()
clock = nil
startedAt = nil
accumulatedSeconds = 0
startButton.title = "Start"
updateTimer()
+ onTimerStateChange?(false)
}
@objc private func updateTimer() {
@@ -129,6 +160,10 @@ final class CameraPreviewView: NSView {
timerLabel.stringValue = String(format: "%02d:%02d", totalSeconds / 60, totalSeconds % 60)
}
+ @objc private func hideBubble() {
+ onClose?()
+ }
+
private func requestCamera() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
@@ -170,43 +205,67 @@ final class CameraPreviewView: NSView {
let preview = AVCaptureVideoPreviewLayer(session: session)
preview.videoGravity = .resizeAspectFill
- if let connection = preview.connection, connection.isVideoMirroringSupported {
- connection.automaticallyAdjustsVideoMirroring = false
- connection.isVideoMirrored = true
- }
layer?.insertSublayer(preview, at: 0)
previewLayer = preview
message.isHidden = true
needsLayout = true
+ updateMirroring()
DispatchQueue.global(qos: .userInitiated).async { [session] in
session.startRunning()
}
}
+
+ private func updateMirroring() {
+ guard let connection = previewLayer?.connection,
+ connection.isVideoMirroringSupported else { return }
+ connection.automaticallyAdjustsVideoMirroring = false
+ connection.isVideoMirrored = isMirrored
+ }
}
final class OverlayPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
+
+ override func cancelOperation(_ sender: Any?) {
+ orderOut(sender)
+ }
}
-final class AppDelegate: NSObject, NSApplicationDelegate {
- private var panel: OverlayPanel?
+final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
+ private var panel: OverlayPanel!
+ private var previewView: CameraPreviewView!
+ private var statusItem: NSStatusItem!
+ private var showHideItem: NSMenuItem!
+ private var timerItem: NSMenuItem!
+ private var mirrorItem: NSMenuItem!
+ private var alwaysOnTopItem: NSMenuItem!
func applicationDidFinishLaunching(_ notification: Notification) {
- let size = NSSize(width: 360, height: 240)
- let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
- let origin = NSPoint(
- x: screen.maxX - size.width - 24,
- y: screen.minY + 24
- )
+ configurePanel()
+ configureStatusItem()
+ showBubble()
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ panel.saveFrame(usingName: "CameraBubbleWindow")
+ }
+
+ private func configurePanel() {
+ let defaultSize = NSSize(width: 360, height: 240)
+ previewView = CameraPreviewView(frame: NSRect(origin: .zero, size: defaultSize))
+ previewView.isMirrored = UserDefaults.standard.object(forKey: "cameraMirrored") as? Bool ?? true
+ previewView.onClose = { [weak self] in self?.hideBubble() }
+ previewView.onTimerStateChange = { [weak self] _ in self?.updateMenuState() }
- let panel = OverlayPanel(
- contentRect: NSRect(origin: origin, size: size),
+ panel = OverlayPanel(
+ contentRect: NSRect(origin: .zero, size: defaultSize),
styleMask: [.borderless, .resizable, .nonactivatingPanel],
backing: .buffered,
defer: false
)
+ panel.delegate = self
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.isFloatingPanel = true
@@ -216,13 +275,132 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
panel.hasShadow = true
panel.isMovableByWindowBackground = true
panel.minSize = NSSize(width: 220, height: 150)
- panel.contentView = CameraPreviewView(frame: NSRect(origin: .zero, size: size))
+ panel.contentView = previewView
+ panel.setFrameAutosaveName("CameraBubbleWindow")
+
+ if !panel.setFrameUsingName("CameraBubbleWindow") {
+ let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
+ panel.setFrame(
+ NSRect(
+ x: screen.maxX - defaultSize.width - 24,
+ y: screen.minY + 24,
+ width: defaultSize.width,
+ height: defaultSize.height
+ ),
+ display: false
+ )
+ }
+ }
+
+ private func configureStatusItem() {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
+ if let button = statusItem.button {
+ let image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Camera Bubble")
+ image?.isTemplate = true
+ button.image = image
+ button.toolTip = "Camera Bubble"
+ }
+
+ let menu = NSMenu()
+ showHideItem = menu.addItem(withTitle: "Hide Camera Bubble", action: #selector(toggleBubble), keyEquivalent: "b")
+ showHideItem.target = self
+
+ timerItem = menu.addItem(withTitle: "Start Timer", action: #selector(toggleTimer), keyEquivalent: "t")
+ timerItem.target = self
+
+ let reset = menu.addItem(withTitle: "Reset Timer", action: #selector(resetTimer), keyEquivalent: "r")
+ reset.target = self
+
+ menu.addItem(.separator())
+
+ mirrorItem = menu.addItem(withTitle: "Mirror Camera", action: #selector(toggleMirror), keyEquivalent: "")
+ mirrorItem.target = self
+
+ alwaysOnTopItem = menu.addItem(withTitle: "Always on Top", action: #selector(toggleAlwaysOnTop), keyEquivalent: "")
+ alwaysOnTopItem.target = self
+
+ let settings = menu.addItem(withTitle: "Camera Privacy Settings…", action: #selector(openCameraSettings), keyEquivalent: "")
+ settings.target = self
+
+ menu.addItem(.separator())
+
+ let about = menu.addItem(withTitle: "About Camera Bubble", action: #selector(showAbout), keyEquivalent: "")
+ about.target = self
+
+ let quit = menu.addItem(withTitle: "Quit Camera Bubble", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
+ quit.target = NSApp
+
+ statusItem.menu = menu
+ updateMenuState()
+ }
+
+ @objc private func toggleBubble() {
+ panel.isVisible ? hideBubble() : showBubble()
+ }
+
+ private func showBubble() {
panel.orderFrontRegardless()
- self.panel = panel
+ updateMenuState()
+ }
+
+ private func hideBubble() {
+ panel.orderOut(nil)
+ updateMenuState()
+ }
+
+ @objc private func toggleTimer() {
+ previewView.toggleTimer()
+ }
+
+ @objc private func resetTimer() {
+ previewView.resetTimer()
+ }
+
+ @objc private func toggleMirror() {
+ previewView.isMirrored.toggle()
+ UserDefaults.standard.set(previewView.isMirrored, forKey: "cameraMirrored")
+ updateMenuState()
+ }
+
+ @objc private func toggleAlwaysOnTop() {
+ panel.level = panel.level == .floating ? .normal : .floating
+ panel.isFloatingPanel = panel.level == .floating
+ updateMenuState()
+ }
+
+ @objc private func openCameraSettings() {
+ guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") else { return }
+ NSWorkspace.shared.open(url)
+ }
+
+ @objc private func showAbout() {
+ NSApp.activate(ignoringOtherApps: true)
+ NSApp.orderFrontStandardAboutPanel(options: [
+ .applicationName: "Camera Bubble",
+ .applicationVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.1",
+ .version: "Privacy-first camera overlay for macOS",
+ .credits: NSAttributedString(string: "Built by Bishal Banerjee.\nCamera frames stay on your Mac.")
+ ])
+ }
+
+ private func updateMenuState() {
+ guard statusItem != nil else { return }
+ showHideItem?.title = panel.isVisible ? "Hide Camera Bubble" : "Show Camera Bubble"
+ timerItem?.title = previewView.isTimerRunning ? "Pause Timer" : "Start Timer"
+ mirrorItem?.state = previewView.isMirrored ? .on : .off
+ alwaysOnTopItem?.state = panel.level == .floating ? .on : .off
+ }
+
+ func windowDidMove(_ notification: Notification) {
+ panel.saveFrame(usingName: "CameraBubbleWindow")
+ }
+
+ func windowDidResize(_ notification: Notification) {
+ panel.saveFrame(usingName: "CameraBubbleWindow")
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
- true
+ false
}
}
diff --git a/scripts/build.sh b/scripts/build.sh
index ad08eca..641614a 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -6,9 +6,12 @@ BUILD_DIR="$PROJECT_DIR/build"
APP_DIR="$BUILD_DIR/Camera Bubble.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
+RESOURCES_DIR="$CONTENTS_DIR/Resources"
-mkdir -p "$MACOS_DIR"
+mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
+"$PROJECT_DIR/scripts/generate-icons.sh"
cp "$PROJECT_DIR/Info.plist" "$CONTENTS_DIR/Info.plist"
+cp "$PROJECT_DIR/Assets/AppIcon.icns" "$RESOURCES_DIR/AppIcon.icns"
swiftc \
-parse-as-library \
diff --git a/scripts/generate-icons.sh b/scripts/generate-icons.sh
new file mode 100755
index 0000000..21faed0
--- /dev/null
+++ b/scripts/generate-icons.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+set -euo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+SOURCE="$PROJECT_DIR/Assets/AppIcon.png"
+ICONSET="$PROJECT_DIR/build/AppIcon.iconset"
+OUTPUT="$PROJECT_DIR/Assets/AppIcon.icns"
+
+if [[ ! -f "$SOURCE" ]]; then
+ echo "Missing icon source: $SOURCE" >&2
+ exit 1
+fi
+
+mkdir -p "$ICONSET"
+
+make_icon() {
+ local size="$1"
+ local name="$2"
+ sips -z "$size" "$size" "$SOURCE" --out "$ICONSET/$name" >/dev/null
+}
+
+make_icon 16 icon_16x16.png
+make_icon 32 icon_16x16@2x.png
+make_icon 32 icon_32x32.png
+make_icon 64 icon_32x32@2x.png
+make_icon 128 icon_128x128.png
+make_icon 256 icon_128x128@2x.png
+make_icon 256 icon_256x256.png
+make_icon 512 icon_256x256@2x.png
+make_icon 512 icon_512x512.png
+make_icon 1024 icon_512x512@2x.png
+
+iconutil -c icns "$ICONSET" -o "$OUTPUT"
+echo "Generated: $OUTPUT"