fix: screen recording permission loop + post-grant detection#21
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 callingSCShareableContent.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 withCGPreflightScreenCaptureAccess()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()wrotescreenCaptureGrantedLive = CGPreflightScreenCaptureAccess()every 3 seconds. On macOS 15+,CGPreflightScreenCaptureAccess()is cached to the process's initial TCC state and won't returntrueafter a mid-session grant. This meant every poll tick was resetting the flag back tofalse, so the UI would never show "Granted" even if the one-shot check had correctly detected it.Fix: Only advance
screenCaptureGrantedLivefromfalse → true, never downgrade it. Permission revocations require an app restart to take effect anyway, so afalsefromCGPreflightScreenCaptureAccess()mid-session can only be stale.3. One-shot grant check fired too early (wrong timing)
openScreenCaptureSettings()was scheduling the post-grantSCShareableContentcheck 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.asyncAftertimer with anNSWorkspace.didDeactivateApplicationNotificationobserver filtered oncom.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 fromcheckScreenCapturePermission) still usesSCShareableContent— 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.AudioHardwareCreateProcessTapto 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