diff --git a/CLAUDE.md b/CLAUDE.md index 59028b5..592ea72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ 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. +- 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. The agent runs the app executable itself, and `main.swift` must keep acknowledging the `com.apple.iokit.matching` event stream — otherwise launchd relaunches the job every 10 s while the device is attached. - Out of scope (for now): launch at login, recording, screenshots, volume slider. ## Structure diff --git a/Sources/App.swift b/Sources/App.swift index c0b384a..792a3f4 100644 --- a/Sources/App.swift +++ b/Sources/App.swift @@ -16,6 +16,10 @@ final class App: NSObject, NSApplicationDelegate { let defaults = UserDefaults.standard func applicationDidFinishLaunching(_ n: Notification) { + // launched by the autostart agent while already running: just bring the running one forward + if let other = NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "").first(where: { $0 != .current }) { + other.activate(); return NSApp.terminate(nil) + } guard !videoDevices().isEmpty else { return quitNoDevices() } window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 960, height: 540), styleMask: [.titled, .closable, .miniaturizable, .resizable], diff --git a/Sources/Autostart.swift b/Sources/Autostart.swift index 434fca7..fe9bdba 100644 --- a/Sources/Autostart.swift +++ b/Sources/Autostart.swift @@ -37,7 +37,8 @@ extension App { } let plist: [String: Any] = [ "Label": Self.agentLabel, - "ProgramArguments": ["/usr/bin/open", Bundle.main.bundlePath], + "ProgramArguments": [Bundle.main.executablePath ?? ""], // the job itself must be the app so it can acknowledge the event + "LimitLoadToSessionType": "Aqua", "LaunchEvents": ["com.apple.iokit.matching": ["attach": [ "IOProviderClass": "IOUSBHostDevice", "idVendor": ids.vendor, "idProduct": ids.product, "IOMatchLaunchStream": true]]], ] diff --git a/Sources/main.swift b/Sources/main.swift index 144eae4..a2c05ac 100644 --- a/Sources/main.swift +++ b/Sources/main.swift @@ -1,4 +1,8 @@ import AppKit +import XPC + +// Acknowledge launchd's USB-match events (see Autostart.swift); unacknowledged ones make launchd relaunch the job every 10 s. +xpc_set_event_stream_handler("com.apple.iokit.matching", nil) { _ in } let app = NSApplication.shared let delegate = App()