Skip to content

fix: screen recording permission loop + post-grant detection#21

Merged
execsumo merged 1 commit into
masterfrom
claude/screen-recording-permission-loop-5IC7f
May 26, 2026
Merged

fix: screen recording permission loop + post-grant detection#21
execsumo merged 1 commit into
masterfrom
claude/screen-recording-permission-loop-5IC7f

Conversation

@execsumo

Copy link
Copy Markdown
Owner

Problem

On a fresh install (non-dev machine), Heard was showing the macOS screen recording permission dialog on an infinite loop — a new prompt every 3 seconds from the moment the app launched.

After fixing that loop, a second issue emerged: granting the permission in System Settings wasn't being reflected in the app UI.

Root causes

1. SCShareableContent in the background polling loop (the loop itself)

PermissionCenter.refreshAsync() was calling SCShareableContent.excludingDesktopWindows() every 3 seconds. On macOS, calling that API when screen recording hasn't been granted yet triggers the TCC permission dialog. On a fresh install this created an infinite prompt loop.

Fix: refreshAsync() now polls with CGPreflightScreenCaptureAccess() only — reads the live TCC database state without triggering any dialog.

2. Poll overwriting confirmed grants (permission never sticking as "Granted")

After fixing the loop, refreshAsync() wrote screenCaptureGrantedLive = CGPreflightScreenCaptureAccess() every 3 seconds. On macOS 15+, CGPreflightScreenCaptureAccess() is cached to the process's initial TCC state and won't return true after a mid-session grant. This meant every poll tick was resetting the flag back to false, so the UI would never show "Granted" even if the one-shot check had correctly detected it.

Fix: Only advance screenCaptureGrantedLive from false → true, never downgrade it. Permission revocations require an app restart to take effect anyway, so a false from CGPreflightScreenCaptureAccess() mid-session can only be stale.

3. One-shot grant check fired too early (wrong timing)

openScreenCaptureSettings() was scheduling the post-grant SCShareableContent check 3 seconds after the user clicked "Grant…" — not after they returned from System Settings. Most users take more than 3 seconds to navigate the privacy page, so the check was firing mid-interaction and either re-triggering the TCC prompt (if not yet granted) or getting a false negative.

Fix: Replaced the DispatchQueue.asyncAfter timer with an NSWorkspace.didDeactivateApplicationNotification observer filtered on com.apple.systempreferences. The one-shot check now fires exactly when the user leaves System Settings — guaranteed to be after their interaction — and the observer removes itself immediately.

What didn't change

  • checkScreenCapturePermissionLive() (renamed from checkScreenCapturePermission) still uses SCShareableContent — it's the only way to bypass the macOS 15+ per-process TCC cache. It's safe because it's now called only once, triggered by a user action, never from a polling loop.
  • After granting screen recording, the app still needs to be quit and relaunched for AudioHardwareCreateProcessTap to succeed. That's a macOS security requirement, not something addressable in code. The UI will now correctly show "Granted" after the grant, but recording won't use the tap until after restart.

https://claude.ai/code/session_01XVfXe31wxQrYWMnQaTpAcy


Generated by Claude Code

Bug 1 (introduced in previous commit): refreshAsync() was writing
  screenCaptureGrantedLive = CGPreflightScreenCaptureAccess()
every 3 seconds. On macOS 15+, CGPreflightScreenCaptureAccess() is cached
to the process's initial TCC state and won't return true after a mid-session
grant. This meant the poll was overwriting any confirmed grant back to false
on the next tick, so the UI would never stick at 'Granted' even after the
one-shot SCShareableContent check had correctly detected it.
Fix: only advance screenCaptureGrantedLive from false → true; never downgrade
it. Permission revocations only take effect after an app restart, so a false
result from CGPreflightScreenCaptureAccess() can only be stale — not correct.

Bug 2 (pre-existing): openScreenCaptureSettings() scheduled the one-shot
SCShareableContent live check 3 seconds after the user clicked 'Grant…',
not 3 seconds after they returned from System Settings. Most users take
longer than 3 seconds to navigate the privacy page, so the check fired
mid-interaction and either re-triggered the TCC prompt (if not granted yet)
or got a false negative (System Settings still open).
Fix: replace the DispatchQueue.asyncAfter timer with an
NSWorkspace.didDeactivateApplicationNotification observer keyed on
com.apple.systempreferences. The one-shot check now fires exactly when the
user leaves System Settings — guaranteed to be after their interaction —
and the observer removes itself immediately after firing.

Additional: added pendingScreenCaptureCheck and systemPrefsObserver stored
properties; updated screenCaptureGrantedLive comment.

https://claude.ai/code/session_01XVfXe31wxQrYWMnQaTpAcy
@execsumo
execsumo merged commit 88b8202 into master May 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants