feat: handle denied camera and microphone permissions

Quit with an error dialog when camera access is denied. When microphone
access is denied, grey out the audio menu with an explanatory note and
do not restore the saved audio device.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mmacierzankaandClaude Bot committed 2026-11-04 08:35:29 +01:00
1 parent ece587fa2d
commit d5f0f4b849
5 files changed
+36 -10

No files matched your search

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