diff --git a/CLAUDE.md b/CLAUDE.md index d3f3319..59028b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,18 +9,19 @@ A native macOS app that shows video from a capture device (camera / capture card - Optional start in full screen. On by default, choice is remembered. - Localization: English and Polish (`Resources/{en,pl}.lproj`), following the system language. - Extras: per-device format (resolution/FPS), scaling modes (fit/fill/stretch), always on top, mute (⇧⌘M), Window menu (system tiling, full screen ⌃⌘F), automatic resume when a device is unplugged/replugged. +- Autostart menu: launch the app when a chosen USB device is attached (`Sources/Autostart.swift`, per-user LaunchAgent with an IOKit match on USB vendor/product ID parsed from `AVCaptureDevice.modelID`). `uninstall.sh` must remove the agent. - Out of scope (for now): launch at login, recording, screenshots, volume slider. ## Structure - `Sources/` – app code (AppKit + AVFoundation, one `AVCaptureSession`, audio via `AVCaptureAudioPreviewOutput`). The `App` class is split into extensions: - `main.swift` (entry point), `App.swift` (state, window, lifecycle), `Capture.swift` (devices, formats, hot-plug), `Menu.swift` (menus and actions), `Power.swift` (sleep prevention), `Preferences.swift` (UserDefaults-backed settings). + `main.swift` (entry point), `App.swift` (state, window, lifecycle), `Capture.swift` (devices, formats, hot-plug), `Menu.swift` (menus and actions), `Power.swift` (sleep prevention), `Autostart.swift` (launch-on-connect agent), `Preferences.swift` (UserDefaults-backed settings). - `Config/Info.plist`, `Config/SecondDisplay.entitlements`, `Config/dmg/` – version, permissions (camera, microphone; hardened runtime). - `Resources/` – `AppIcon.icon` (Icon Composer, Liquid Glass; compiled by `actool`), `{en,pl}.lproj` (`Localizable.strings`, `InfoPlist.strings`), `uninstall.sh` (copied into the bundle; removes the app, preferences, caches, TCC permissions). Bundle ID: `com.mmacierzanka.seconddisplay`. - `scripts/check-strings.sh` – en/pl translation consistency. `scripts/make-dmg-background.swift` regenerates `Config/dmg/background.png`; keep its size and icon coordinates in sync with `Config/dmg/settings.py` (dmgbuild layout, installed into `build/venv` on first `make dmg`). - `Makefile` – `make` (builds `build/SecondDisplay.app`), `run`, `check`, `install`, `uninstall`, `dmg`, `clean`, `help`. Builds are universal (arm64 + x86_64, macOS 26+). `make dmg` makes an un-notarized DMG with `Config/dmg/Read Me First.txt`. `SIGN_ID` defaults to ad-hoc (`-`). ## Conventions -- Settings live in `UserDefaults` (keys: `video`, `audio`, `keepAwake`, `startFull`, `onTop`, `gravity`, `format.`). +- Settings live in `UserDefaults` (keys: `video`, `audio`, `autostart`, `autostartName`, `keepAwake`, `startFull`, `onTop`, `gravity`, `format.`). - Add new strings through `NSLocalizedString` and to both `Localizable.strings` files. - Run `make check` before committing. Version is in `Config/Info.plist`; build number = commit count. - Commits: small, one per feature, with a `Co-Authored-By` line. diff --git a/README.md b/README.md index 9233629..3405c1a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ If you received a prebuilt copy (the `.dmg`), drag the app to Applications. It i ## Usage Menus at the top of the screen: **Video** (device, format), **Audio** (source or off), **Options** (prevent display sleep, start in full screen, always on top, mute ⇧⌘M, scaling), **Window** (tiling, full screen ⌃⌘F). +**Autostart** opens the app whenever the chosen USB capture device is plugged in (and makes it the active video source). It installs a per-user LaunchAgent (`~/Library/LaunchAgents/com.mmacierzanka.seconddisplay.autostart.plist`) matching the device's USB vendor/product ID, so only USB (UVC) devices can be chosen. Choose **Off** to remove it; the uninstall script removes it too. + ## Layout - `Sources/` – the app (AppKit + AVFoundation): `App` (state, window), `Capture` (devices, formats), `Menu`, `Power`, `Preferences`, `main` (entry point) - `Config/` – `Info.plist`, entitlements (camera, microphone), `dmg/` (instructions shipped in the disk image) diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 90f2ed3..87a1cb5 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -20,3 +20,5 @@ "Bring All to Front" = "Bring All to Front"; "No capture devices found" = "No capture devices found"; "Connect a camera or capture card and launch Second Display again." = "Connect a camera or capture card and launch Second Display again."; +"Autostart" = "Autostart"; +"(not connected)" = "(not connected)"; diff --git a/Resources/pl.lproj/Localizable.strings b/Resources/pl.lproj/Localizable.strings index 5ac5464..e9f560f 100644 --- a/Resources/pl.lproj/Localizable.strings +++ b/Resources/pl.lproj/Localizable.strings @@ -20,3 +20,5 @@ "Bring All to Front" = "Przenieś wszystko na wierzch"; "No capture devices found" = "Nie znaleziono urządzeń przechwytujących"; "Connect a camera or capture card and launch Second Display again." = "Podłącz kamerę lub urządzenie przechwytujące i uruchom Second Display ponownie."; +"Autostart" = "Autostart"; +"(not connected)" = "(niepodłączone)"; diff --git a/Resources/uninstall.sh b/Resources/uninstall.sh index 4c155d9..aaa18f7 100755 --- a/Resources/uninstall.sh +++ b/Resources/uninstall.sh @@ -6,6 +6,8 @@ APP="${0:A:h:h:h}" read -q "?Delete $APP and all its data? [y/N] " || { echo; echo Cancelled; exit 0; } echo pkill -x SecondDisplay 2>/dev/null +launchctl bootout "gui/$(id -u)/$ID.autostart" 2>/dev/null # launch-on-connect agent +rm -f ~/Library/LaunchAgents/$ID.autostart.plist defaults delete "$ID" 2>/dev/null tccutil reset All "$ID" >/dev/null 2>&1 # camera / microphone permissions L=~/Library diff --git a/Sources/App.swift b/Sources/App.swift index 7c00c97..c0b384a 100644 --- a/Sources/App.swift +++ b/Sources/App.swift @@ -8,7 +8,7 @@ final class App: NSObject, NSApplicationDelegate { var videoInput: AVCaptureDeviceInput? var audioInput: AVCaptureDeviceInput? var window: NSWindow! - let videoMenu = NSMenu(), audioMenu = NSMenu(), optionsMenu = NSMenu(), windowMenu = NSMenu() + let videoMenu = NSMenu(), audioMenu = NSMenu(), optionsMenu = NSMenu(), windowMenu = NSMenu(), autostartMenu = NSMenu() var sleepAssertion: IOPMAssertionID = 0 var keepAwake = false var layer: AVCaptureVideoPreviewLayer! diff --git a/Sources/Autostart.swift b/Sources/Autostart.swift new file mode 100644 index 0000000..434fca7 --- /dev/null +++ b/Sources/Autostart.swift @@ -0,0 +1,72 @@ +import AppKit +import AVFoundation + +// Launch-on-connect: a per-user LaunchAgent that opens the app when the chosen USB device is attached. +extension App { + static let agentLabel = "com.mmacierzanka.seconddisplay.autostart" + var agentURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents/\(Self.agentLabel).plist") + } + var autostartID: String? { defaults.string(forKey: "autostart") } + + // UVC cameras report e.g. "UVC Camera VendorID_21325 ProductID_8457" (decimal); other devices have no USB ids. + func usbIDs(_ d: AVCaptureDevice) -> (vendor: Int, product: Int)? { + func number(after key: String) -> Int? { + guard let r = d.modelID.range(of: key) else { return nil } + return Int(d.modelID[r.upperBound...].prefix(while: \.isNumber)) + } + guard let v = number(after: "VendorID_"), let p = number(after: "ProductID_") else { return nil } + return (v, p) + } + + func launchctl(_ args: String...) { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/launchctl") + p.arguments = args + p.standardError = FileHandle.nullDevice + try? p.run(); p.waitUntilExit() + } + + func setAutostart(_ d: AVCaptureDevice?) { + let domain = "gui/\(getuid())" + launchctl("bootout", "\(domain)/\(Self.agentLabel)") + guard let d = d, let ids = usbIDs(d) else { + try? FileManager.default.removeItem(at: agentURL) + defaults.removeObject(forKey: "autostart"); defaults.removeObject(forKey: "autostartName") + return + } + let plist: [String: Any] = [ + "Label": Self.agentLabel, + "ProgramArguments": ["/usr/bin/open", Bundle.main.bundlePath], + "LaunchEvents": ["com.apple.iokit.matching": ["attach": [ + "IOProviderClass": "IOUSBHostDevice", "idVendor": ids.vendor, "idProduct": ids.product, "IOMatchLaunchStream": true]]], + ] + guard let data = try? PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) else { return } + try? FileManager.default.createDirectory(at: agentURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try? data.write(to: agentURL) + defaults.set(d.uniqueID, forKey: "autostart"); defaults.set(d.localizedName, forKey: "autostartName") + launchctl("bootstrap", domain, agentURL.path) // fires at once if the device is already attached + } + + @objc func pickAutostart(_ s: NSMenuItem) { setAutostart(s.representedObject as? AVCaptureDevice) } + + func fillAutostartMenu(_ menu: NSMenu) { + menu.removeAllItems() + let off = NSMenuItem(title: NSLocalizedString("Off", comment: ""), action: #selector(pickAutostart(_:)), keyEquivalent: "") + off.target = self; off.state = autostartID == nil ? .on : .off + menu.addItem(off) + menu.addItem(.separator()) + let devices = videoDevices() + for d in devices { + let it = NSMenuItem(title: d.localizedName, action: usbIDs(d) == nil ? nil : #selector(pickAutostart(_:)), keyEquivalent: "") + it.target = self; it.representedObject = d + it.state = d.uniqueID == autostartID ? .on : .off + menu.addItem(it) + } + if let id = autostartID, !devices.contains(where: { $0.uniqueID == id }) { + let it = NSMenuItem(title: "\(defaults.string(forKey: "autostartName") ?? id) \(NSLocalizedString("(not connected)", comment: ""))", action: nil, keyEquivalent: "") + it.state = .on + menu.addItem(it) + } + } +} diff --git a/Sources/Capture.swift b/Sources/Capture.swift index 09ae12b..865936f 100644 --- a/Sources/Capture.swift +++ b/Sources/Capture.swift @@ -4,7 +4,11 @@ import AVFoundation extension App { func observeDevices() { let nc = NotificationCenter.default - nc.addObserver(forName: AVCaptureDevice.wasConnectedNotification, object: nil, queue: .main) { _ in self.restore() } + nc.addObserver(forName: AVCaptureDevice.wasConnectedNotification, object: nil, queue: .main) { n in + // the autostart device takes over the picture as soon as it appears + if let d = n.object as? AVCaptureDevice, d.hasMediaType(.video), d.uniqueID == self.autostartID { self.setVideo(d) } + self.restore() + } nc.addObserver(forName: AVCaptureDevice.wasDisconnectedNotification, object: nil, queue: .main) { n in guard let d = n.object as? AVCaptureDevice else { return } if d.uniqueID == self.videoInput?.device.uniqueID { self.swap(&self.videoInput, to: nil) } @@ -35,9 +39,9 @@ extension App { func restore() { if videoInput == nil { - if let id = defaults.string(forKey: "video"), let d = videoDevices().first(where: { $0.uniqueID == id }) { - setVideo(d) - } else if let d = videoDevices().first { setVideo(d) } + let devices = videoDevices() + let preferred = [autostartID, defaults.string(forKey: "video")].compactMap { id in devices.first { $0.uniqueID == id } } + if let d = preferred.first ?? devices.first { setVideo(d) } } if audioInput == nil, let id = defaults.string(forKey: "audio"), let d = audioDevices().first(where: { $0.uniqueID == id }) { setAudio(d) diff --git a/Sources/Menu.swift b/Sources/Menu.swift index 2ea5cec..ad1a635 100644 --- a/Sources/Menu.swift +++ b/Sources/Menu.swift @@ -6,7 +6,7 @@ extension App: NSMenuDelegate { let main = NSMenu() let appMenu = NSMenu() appMenu.addItem(NSMenuItem(title: NSLocalizedString("Quit Second Display", comment: ""), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) - for (title, sub) in [("", appMenu), (NSLocalizedString("Video", comment: ""), videoMenu), (NSLocalizedString("Audio", comment: ""), audioMenu), (NSLocalizedString("Options", comment: ""), optionsMenu), (NSLocalizedString("Window", comment: ""), windowMenu)] { + for (title, sub) in [("", appMenu), (NSLocalizedString("Video", comment: ""), videoMenu), (NSLocalizedString("Audio", comment: ""), audioMenu), (NSLocalizedString("Autostart", comment: ""), autostartMenu), (NSLocalizedString("Options", comment: ""), optionsMenu), (NSLocalizedString("Window", comment: ""), windowMenu)] { let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") sub.title = title; sub.delegate = self; item.submenu = sub main.addItem(item) @@ -56,6 +56,8 @@ extension App: NSMenuDelegate { it.state = d.uniqueID == audioInput?.device.uniqueID ? .on : .off menu.addItem(it) } + } else if menu === autostartMenu { + fillAutostartMenu(menu) } else if menu === optionsMenu { menu.removeAllItems() let k = NSMenuItem(title: NSLocalizedString("Prevent display sleep", comment: ""), action: #selector(toggleAwake), keyEquivalent: "")