Skip to content

Commit c08d1bd

Browse files
committed
fix: correct two bugs in post-grant screen recording detection
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
1 parent c75a6c1 commit c08d1bd

1 file changed

Lines changed: 52 additions & 29 deletions

File tree

Sources/HeardCore/Services.swift

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,13 +1146,17 @@ public final class PermissionCenter: ObservableObject {
11461146
@Published public private(set) var statuses: [PermissionStatus] = []
11471147

11481148
private var refreshTask: Task<Void, Never>?
1149-
// Live result from SCShareableContent — bypasses CGPreflightScreenCaptureAccess() caching
1149+
// Once true, never reset to false within a process lifetime: revocations only take
1150+
// effect after an app restart, so a downgrade within the same session is always stale.
11501151
private var screenCaptureGrantedLive: Bool = false
1152+
// Set when the user clicks "Grant…" so the System Settings deactivation observer
1153+
// knows to do a live check when they return.
1154+
private var pendingScreenCaptureCheck = false
1155+
private var systemPrefsObserver: (any NSObjectProtocol)?
11511156

11521157
public init() {
11531158
refresh()
11541159
// Periodically re-check permissions (catches grants made in System Settings).
1155-
// Uses async checks to bypass per-process TCC caching in macOS 15+.
11561160
refreshTask = Task { [weak self] in
11571161
while !Task.isCancelled {
11581162
try? await Task.sleep(for: .seconds(3))
@@ -1164,21 +1168,18 @@ public final class PermissionCenter: ObservableObject {
11641168

11651169
// Performs the async screen-capture check then calls the sync refresh.
11661170
private func refreshAsync() async {
1167-
// Use CGPreflightScreenCaptureAccess() for background polling — it reads the
1168-
// live TCC database without triggering any permission dialog.
1171+
// Background polling uses CGPreflightScreenCaptureAccess() — it reads the live
1172+
// TCC database without triggering any permission dialog. On macOS 15+ the return
1173+
// value may be cached to the process's initial state and won't flip to true after
1174+
// a mid-session grant; the one-shot SCShareableContent check (fired when System
1175+
// Settings deactivates after a user-initiated "Grant…") handles that case.
11691176
//
1170-
// IMPORTANT: do NOT use SCShareableContent here. Calling
1171-
// SCShareableContent.excludingDesktopWindows() when screen recording hasn't been
1172-
// granted yet causes macOS to show the TCC permission dialog. Running that call
1173-
// every 3 s in this loop was the source of the infinite permission-request loop
1174-
// seen on fresh installs and non-dev machines.
1175-
//
1176-
// On macOS 15+ CGPreflightScreenCaptureAccess() may return a cached value for
1177-
// the running process. That cache is invalidated by TCC database changes, so a
1178-
// grant made in System Settings is visible to the background poll within a few
1179-
// seconds. If the very first poll after launch returns false, the user will see
1180-
// the "Not Granted" UI and can click "Grant…" to trigger the one-shot live check.
1181-
screenCaptureGrantedLive = CGPreflightScreenCaptureAccess()
1177+
// NEVER overwrite a confirmed true with a potentially stale false: permission
1178+
// revocations only take effect after an app restart, so if screenCaptureGrantedLive
1179+
// is already true, preserve it — the poll result can only be wrong in that direction.
1180+
if !screenCaptureGrantedLive {
1181+
screenCaptureGrantedLive = CGPreflightScreenCaptureAccess()
1182+
}
11821183
refresh()
11831184
}
11841185

@@ -1332,25 +1333,47 @@ public final class PermissionCenter: ObservableObject {
13321333
// CGRequestScreenCaptureAccess() triggers the system prompt on macOS 14;
13331334
// on macOS 15+ it redirects to System Settings. Use it unconditionally.
13341335
CGRequestScreenCaptureAccess()
1335-
// After the user interacts with System Settings and returns to the app, do a
1336-
// one-shot live check so the UI flips to "Granted" promptly rather than waiting
1337-
// for the next background poll. The 3-second delay gives the user enough time to
1338-
// finish in System Settings before the check fires.
1339-
//
1340-
// This is safe because it is a single call triggered by the user pressing
1341-
// "Grant…" — it does not loop. The background polling loop (refreshAsync) uses
1342-
// CGPreflightScreenCaptureAccess() only and never calls this path.
1343-
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
1344-
Task { @MainActor in
1336+
1337+
// Already confirmed — nothing more to do.
1338+
guard !screenCaptureGrantedLive else { return }
1339+
1340+
// Watch for System Settings to deactivate: that's the signal that the user has
1341+
// finished with the privacy page (granted or dismissed) and returned to another
1342+
// app. We do the one-shot live SCShareableContent check at that moment rather
1343+
// than after an arbitrary delay, so it always fires AFTER the user interaction
1344+
// rather than mid-interaction (which would re-trigger the TCC prompt).
1345+
pendingScreenCaptureCheck = true
1346+
guard systemPrefsObserver == nil else { return }
1347+
systemPrefsObserver = NSWorkspace.shared.notificationCenter.addObserver(
1348+
forName: NSWorkspace.didDeactivateApplicationNotification,
1349+
object: nil,
1350+
queue: .main
1351+
) { [weak self] notification in
1352+
guard let self,
1353+
self.pendingScreenCaptureCheck,
1354+
let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
1355+
// macOS 13+: "System Settings"; earlier: "System Preferences" — both share this bundle ID
1356+
app.bundleIdentifier == "com.apple.systempreferences"
1357+
else { return }
1358+
1359+
// One-shot: remove the observer and clear the flag immediately.
1360+
self.pendingScreenCaptureCheck = false
1361+
if let obs = self.systemPrefsObserver {
1362+
NSWorkspace.shared.notificationCenter.removeObserver(obs)
1363+
self.systemPrefsObserver = nil
1364+
}
1365+
1366+
Task { @MainActor [weak self] in
13451367
guard let self else { return }
1346-
// Fast path: sync check first (covers most cases and avoids an
1347-
// unnecessary SCShareableContent call if the grant is already visible).
1368+
// Fast path: sync check (works on macOS < 15 and after any restart).
13481369
if CGPreflightScreenCaptureAccess() {
13491370
self.screenCaptureGrantedLive = true
13501371
self.refresh()
13511372
return
13521373
}
1353-
// Bypass macOS 15+ per-process TCC cache with one-shot live check.
1374+
// Bypass macOS 15+ per-process TCC cache with one-shot SCShareableContent.
1375+
// Safe here: this is user-initiated and fires exactly once per "Grant…" click,
1376+
// only after the user has left System Settings.
13541377
self.screenCaptureGrantedLive = await self.checkScreenCapturePermissionLive()
13551378
self.refresh()
13561379
}

0 commit comments

Comments
 (0)