Skip to content

Commit 73d3a26

Browse files
danielbodartclaude
andcommitted
Poll for mic permission when denied (handle launchd restart race)
When launchd restarts capsper after a crash, a mic permission dialog from the previous launch may still be visible. The old code saw status=denied and immediately exited, causing a restart loop that spawned multiple overlapping dialogs. Now when status is denied, poll every 2s for up to 30s to give the user time to click Allow on an existing dialog. This eliminates the cascade of permission prompts on first install. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent adc0f71 commit 73d3a26

2 files changed

Lines changed: 36 additions & 18 deletions

File tree

src/platform/macos/audio.zig

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,20 +63,22 @@ pub const AudioCapture = struct {
6363
// virtual devices deliver real audio without mic permission, and the user
6464
// explicitly chose the device.
6565
const mic_status = if (target != null) @as(c_int, 3) else capsper_mic_permission_status();
66-
if (mic_status == 0) {
66+
if (mic_status != 3) {
67+
// Not yet authorized: request permission (or poll if denied).
68+
// For notDetermined (0): shows the system permission dialog and blocks.
69+
// For denied (2): polls for 30s in case a dialog from a previous
70+
// launch is still visible (launchd restarts race with user clicking Allow).
71+
// For restricted (1): polls briefly then fails.
72+
if (mic_status == 1) {
73+
log.err("Microphone access is restricted by system policy.", .{});
74+
return error.AudioInitFailed;
75+
}
6776
log.info("Requesting microphone permission...", .{});
6877
if (capsper_mic_request_permission() == 0) {
6978
log.err("Microphone permission denied.", .{});
7079
log.err("Grant access in: System Settings → Privacy & Security → Microphone", .{});
7180
return error.AudioInitFailed;
7281
}
73-
} else if (mic_status == 2) {
74-
log.err("Microphone permission denied.", .{});
75-
log.err("Grant access in: System Settings → Privacy & Security → Microphone", .{});
76-
return error.AudioInitFailed;
77-
} else if (mic_status == 1) {
78-
log.err("Microphone access is restricted by system policy.", .{});
79-
return error.AudioInitFailed;
8082
}
8183

8284
// Create pipe for passing PCM from CoreAudio thread to main thread

src/platform/macos/mic_permission.m

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,35 @@ int capsper_mic_permission_status(void) {
1212

1313
// Blocks until the user responds to the permission dialog.
1414
// Returns 1 if granted, 0 if denied.
15-
// If permission was already determined, returns immediately.
15+
// If status is notDetermined, shows the permission dialog and blocks.
16+
// If status is denied, polls for up to 30 seconds in case a permission
17+
// dialog from a previous launch is still visible (launchd restarts can
18+
// race with the user clicking "Allow").
1619
int capsper_mic_request_permission(void) {
1720
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
1821
if (status == AVAuthorizationStatusAuthorized) return 1;
19-
if (status != AVAuthorizationStatusNotDetermined) return 0;
2022

21-
__block int granted = 0;
22-
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
23-
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL g) {
24-
granted = g ? 1 : 0;
25-
dispatch_semaphore_signal(sem);
26-
}];
27-
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
28-
return granted;
23+
if (status == AVAuthorizationStatusNotDetermined) {
24+
__block int granted = 0;
25+
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
26+
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL g) {
27+
granted = g ? 1 : 0;
28+
dispatch_semaphore_signal(sem);
29+
}];
30+
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
31+
return granted;
32+
}
33+
34+
// Status is denied or restricted. Poll briefly in case the user is
35+
// responding to a mic dialog from a previous launch attempt — launchd's
36+
// KeepAlive restarts can overlap with a pending permission prompt.
37+
for (int i = 0; i < 15; i++) {
38+
[NSThread sleepForTimeInterval:2.0];
39+
if ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]
40+
== AVAuthorizationStatusAuthorized) {
41+
return 1;
42+
}
43+
}
44+
return 0;
2945
}
3046

0 commit comments

Comments
 (0)