From 4183ea3ca56eafaa1b7a4e33a96c6ccb82b78be1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 17:04:00 +0000 Subject: [PATCH] fix: detect already-granted Screen Recording permission after restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/HeardCore/Services.swift | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index 8e3e065..6e16996 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -1158,6 +1158,10 @@ public final class PermissionCenter: ObservableObject { refresh() // Periodically re-check permissions (catches grants made in System Settings). refreshTask = Task { [weak self] in + // Run an immediate authoritative check so an already-granted permission is + // reflected at launch rather than after the first 3 s tick — this is what + // makes a grant from a previous session stick after an app restart. + await self?.refreshAsync() while !Task.isCancelled { try? await Task.sleep(for: .seconds(3)) guard let self, !Task.isCancelled else { return } @@ -1177,12 +1181,43 @@ public final class PermissionCenter: ObservableObject { // NEVER overwrite a confirmed true with a potentially stale false: permission // revocations only take effect after an app restart, so if screenCaptureGrantedLive // is already true, preserve it — the poll result can only be wrong in that direction. + // + // CGPreflightScreenCaptureAccess() can also return a stale false on a FRESH launch + // on macOS 15+ (notably for ad-hoc signed builds), which leaves a previously-granted + // permission showing as "Not Granted" after an app restart. Fall back to a + // non-prompting window-list probe, which reads the live grant without ever + // triggering the TCC dialog (so it is safe in this background loop). if !screenCaptureGrantedLive { screenCaptureGrantedLive = CGPreflightScreenCaptureAccess() + || Self.screenRecordingGrantedViaWindowList() } refresh() } + /// Authoritative, non-prompting Screen Recording check used by the background poll. + /// + /// Reading the window *title* (`kCGWindowName`) of a window owned by another process + /// requires Screen Recording permission on macOS 10.15+. If any such title is + /// readable, the permission is granted. Unlike `SCShareableContent`, this never shows + /// the TCC prompt when permission is missing, so it is safe to call repeatedly. + /// It can yield a transient false negative if no other app currently has a titled + /// on-screen window, but the poll retries every 3 s and never downgrades a confirmed + /// grant, so the result converges to the correct value. + private nonisolated static func screenRecordingGrantedViaWindowList() -> Bool { + let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] + guard let windows = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return false + } + let ourPID = Int(ProcessInfo.processInfo.processIdentifier) + for window in windows { + guard let pid = window[kCGWindowOwnerPID as String] as? Int, pid != ourPID else { continue } + if let name = window[kCGWindowName as String] as? String, !name.isEmpty { + return true + } + } + return false + } + /// One-shot live permission check via SCShareableContent, which bypasses the /// CGPreflightScreenCaptureAccess() per-process cache on macOS 15+. ///