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 icon +

+ # 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"