Skip to content

Commit 4183ea3

Browse files
committed
fix: detect already-granted Screen Recording permission after restart
The background permission poll relied solely on CGPreflightScreenCaptureAccess(), which on macOS 15+ can return a stale false even on a fresh launch with the permission genuinely granted. Since the only authoritative live check ran when the user pressed "Grant…", a permission granted in a prior session showed as "Not Granted" after restarting the app — a regression from 0.2.0, where the poll used the cache-bypassing SCShareableContent path (later removed because it spammed the TCC dialog every 3s). Add a non-prompting authoritative probe via CGWindowListCopyWindowInfo (reading another process's window title requires Screen Recording permission) and use it as a fallback in the poll, plus run one check immediately at launch. This never triggers the TCC dialog, so it does not reintroduce the prompt loop. https://claude.ai/code/session_01GZZ1aHLdxmAz4KRof5XAUC
1 parent 8c14125 commit 4183ea3

1 file changed

Lines changed: 35 additions & 0 deletions

File tree

Sources/HeardCore/Services.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,6 +1158,10 @@ public final class PermissionCenter: ObservableObject {
11581158
refresh()
11591159
// Periodically re-check permissions (catches grants made in System Settings).
11601160
refreshTask = Task { [weak self] in
1161+
// Run an immediate authoritative check so an already-granted permission is
1162+
// reflected at launch rather than after the first 3 s tick — this is what
1163+
// makes a grant from a previous session stick after an app restart.
1164+
await self?.refreshAsync()
11611165
while !Task.isCancelled {
11621166
try? await Task.sleep(for: .seconds(3))
11631167
guard let self, !Task.isCancelled else { return }
@@ -1177,12 +1181,43 @@ public final class PermissionCenter: ObservableObject {
11771181
// NEVER overwrite a confirmed true with a potentially stale false: permission
11781182
// revocations only take effect after an app restart, so if screenCaptureGrantedLive
11791183
// is already true, preserve it — the poll result can only be wrong in that direction.
1184+
//
1185+
// CGPreflightScreenCaptureAccess() can also return a stale false on a FRESH launch
1186+
// on macOS 15+ (notably for ad-hoc signed builds), which leaves a previously-granted
1187+
// permission showing as "Not Granted" after an app restart. Fall back to a
1188+
// non-prompting window-list probe, which reads the live grant without ever
1189+
// triggering the TCC dialog (so it is safe in this background loop).
11801190
if !screenCaptureGrantedLive {
11811191
screenCaptureGrantedLive = CGPreflightScreenCaptureAccess()
1192+
|| Self.screenRecordingGrantedViaWindowList()
11821193
}
11831194
refresh()
11841195
}
11851196

1197+
/// Authoritative, non-prompting Screen Recording check used by the background poll.
1198+
///
1199+
/// Reading the window *title* (`kCGWindowName`) of a window owned by another process
1200+
/// requires Screen Recording permission on macOS 10.15+. If any such title is
1201+
/// readable, the permission is granted. Unlike `SCShareableContent`, this never shows
1202+
/// the TCC prompt when permission is missing, so it is safe to call repeatedly.
1203+
/// It can yield a transient false negative if no other app currently has a titled
1204+
/// on-screen window, but the poll retries every 3 s and never downgrades a confirmed
1205+
/// grant, so the result converges to the correct value.
1206+
private nonisolated static func screenRecordingGrantedViaWindowList() -> Bool {
1207+
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
1208+
guard let windows = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
1209+
return false
1210+
}
1211+
let ourPID = Int(ProcessInfo.processInfo.processIdentifier)
1212+
for window in windows {
1213+
guard let pid = window[kCGWindowOwnerPID as String] as? Int, pid != ourPID else { continue }
1214+
if let name = window[kCGWindowName as String] as? String, !name.isEmpty {
1215+
return true
1216+
}
1217+
}
1218+
return false
1219+
}
1220+
11861221
/// One-shot live permission check via SCShareableContent, which bypasses the
11871222
/// CGPreflightScreenCaptureAccess() per-process cache on macOS 15+.
11881223
///

0 commit comments

Comments
 (0)