feat: handle denied camera and microphone permissions (#1)

This commit is contained in:
mmacierzanka authored and GitHub committed 2026-11-04 08:42:42 +01:00
commit 5cc7dec387
6 files changed
+38 -11

No files matched your search

+2 -1
View File
@@ -24,4 +24,5 @@ A native macOS app that shows video from a capture device (camera / capture card
- Settings live in `UserDefaults` (keys: `video`, `audio`, `autostart`, `autostartName`, `keepAwake`, `startFull`, `onTop`, `gravity`, `format.<id>`).
- 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.
- Work on a feature branch (`feature/<topic>`, `fix/<topic>`) and open a PR with `gh pr create`; do not commit straight to `main`.
- Commits follow [Conventional Commits](https://www.conventionalcommits.org), in English: `feat:`, `fix:`, `docs:`, `refactor:`, `build:`, `chore:` (optional scope, e.g. `feat(menu):`), imperative subject under ~72 chars. Small, one per change, with a `Co-Authored-By` line.
+3
View File
@@ -23,3 +23,6 @@
"Autostart" = "Autostart";
"(not connected)" = "(not connected)";
"About Second Display" = "About Second Display";
"Camera access denied" = "Camera access denied";
"Allow camera access for Second Display in System Settings > Privacy & Security > Camera, then launch it again." = "Allow camera access for Second Display in System Settings > Privacy & Security > Camera, then launch it again.";
"Microphone access denied. Enable it in System Settings." = "Microphone access denied. Enable it in System Settings.";
+3
View File
@@ -23,3 +23,6 @@
"Autostart" = "Autostart";
"(not connected)" = "(niepodłączone)";
"About Second Display" = "O programie Second Display";
"Camera access denied" = "Brak dostępu do kamery";
"Allow camera access for Second Display in System Settings > Privacy & Security > Camera, then launch it again." = "Zezwól Second Display na dostęp do kamery w Ustawieniach systemowych > Prywatność i ochrona > Kamera i uruchom aplikację ponownie.";
"Microphone access denied. Enable it in System Settings." = "Brak dostępu do mikrofonu. Włącz go w Ustawieniach systemowych.";
+9 -4
View File
@@ -21,6 +21,13 @@ final class App: NSObject, NSApplicationDelegate {
other.activate(); return NSApp.terminate(nil)
}
guard !videoDevices().isEmpty else { return quitNoDevices() }
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async { granted ? self.start() : self.quitCameraDenied() }
}
}
// runs once camera access is granted
private func start() {
window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 960, height: 540),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
@@ -50,10 +57,8 @@ final class App: NSObject, NSApplicationDelegate {
observeDevices()
AVCaptureDevice.requestAccess(for: .video) { _ in
AVCaptureDevice.requestAccess(for: .audio) { _ in
DispatchQueue.main.async { self.restore() }
}
AVCaptureDevice.requestAccess(for: .audio) { _ in
DispatchQueue.main.async { self.restore() }
}
}
+14 -4
View File
@@ -16,16 +16,26 @@ extension App {
}
}
func quitNoDevices() {
func quitWithError(_ title: String, _ info: String) {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = NSLocalizedString("No capture devices found", comment: "")
alert.informativeText = NSLocalizedString("Connect a camera or capture card and launch Second Display again.", comment: "")
alert.messageText = title
alert.informativeText = info
alert.addButton(withTitle: "OK")
NSApp.activate()
alert.runModal()
NSApp.terminate(nil)
}
func quitNoDevices() {
quitWithError(NSLocalizedString("No capture devices found", comment: ""),
NSLocalizedString("Connect a camera or capture card and launch Second Display again.", comment: ""))
}
func quitCameraDenied() {
quitWithError(NSLocalizedString("Camera access denied", comment: ""),
NSLocalizedString("Allow camera access for Second Display in System Settings > Privacy & Security > Camera, then launch it again.", comment: ""))
}
// .notDetermined is not blocked: the prompt is still to come
var micBlocked: Bool { [.denied, .restricted].contains(AVCaptureDevice.authorizationStatus(for: .audio)) }
// MARK: devices
func videoDevices() -> [AVCaptureDevice] {
@@ -43,7 +53,7 @@ extension App {
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 }) {
if audioInput == nil, !micBlocked, let id = defaults.string(forKey: "audio"), let d = audioDevices().first(where: { $0.uniqueID == id }) {
setAudio(d)
} // default: audio off until chosen
if !session.isRunning { session.startRunning() }
+7 -2
View File
@@ -48,12 +48,17 @@ extension App: NSMenuDelegate {
}
} else if menu === audioMenu {
menu.removeAllItems()
let off = NSMenuItem(title: NSLocalizedString("Off", comment: ""), action: #selector(pickAudio(_:)), keyEquivalent: "")
let pick = micBlocked ? nil : #selector(pickAudio(_:)) // no action = greyed out
if micBlocked {
menu.addItem(header(NSLocalizedString("Microphone access denied. Enable it in System Settings.", comment: "")))
menu.addItem(.separator())
}
let off = NSMenuItem(title: NSLocalizedString("Off", comment: ""), action: pick, keyEquivalent: "")
off.target = self; off.state = audioInput == nil ? .on : .off
menu.addItem(off)
menu.addItem(.separator())
for d in audioDevices() {
let it = NSMenuItem(title: d.localizedName, action: #selector(pickAudio(_:)), keyEquivalent: "")
let it = NSMenuItem(title: d.localizedName, action: pick, keyEquivalent: "")
it.target = self; it.representedObject = d
it.state = d.uniqueID == audioInput?.device.uniqueID ? .on : .off
menu.addItem(it)