From c75a6c1e33011e550a17a7d88053847e762919d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 16:44:00 +0000 Subject: [PATCH] fix: stop SCShareableContent polling loop that spammed screen recording prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PermissionCenter.refreshAsync() was calling SCShareableContent.excludingDesktopWindows() every 3 seconds in the background polling task. On macOS, calling that API when screen recording hasn't been granted yet triggers the TCC permission dialog. On a fresh install or a non-dev machine this created an infinite loop: a new permission dialog every 3 seconds. Fix: - refreshAsync() now polls with CGPreflightScreenCaptureAccess() only. This reads the live TCC database state without triggering any dialog. The macOS 15+ per-process cache is invalidated by TCC database changes, so a grant made in System Settings appears within one polling cycle (≤3 s) without needing SCShareableContent. - Rename checkScreenCapturePermission() → checkScreenCapturePermissionLive() and add a prominent WARNING in the doc comment: this function triggers the TCC dialog and must only be called from a direct user action, never from a polling loop. - In openScreenCaptureSettings(), keep the one-shot post-grant check (it's user-initiated so it's safe) but: (a) try the fast sync path first, (b) increase the delay from 1 s to 3 s so the SCShareableContent call fires after the user has returned from System Settings, not mid-interaction. https://claude.ai/code/session_01XVfXe31wxQrYWMnQaTpAcy --- Sources/HeardCore/Services.swift | 56 +++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index 9586255..1896b47 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -1164,12 +1164,31 @@ public final class PermissionCenter: ObservableObject { // Performs the async screen-capture check then calls the sync refresh. private func refreshAsync() async { - screenCaptureGrantedLive = await checkScreenCapturePermission() + // Use CGPreflightScreenCaptureAccess() for background polling — it reads the + // live TCC database without triggering any permission dialog. + // + // IMPORTANT: do NOT use SCShareableContent here. Calling + // SCShareableContent.excludingDesktopWindows() when screen recording hasn't been + // granted yet causes macOS to show the TCC permission dialog. Running that call + // every 3 s in this loop was the source of the infinite permission-request loop + // seen on fresh installs and non-dev machines. + // + // On macOS 15+ CGPreflightScreenCaptureAccess() may return a cached value for + // the running process. That cache is invalidated by TCC database changes, so a + // grant made in System Settings is visible to the background poll within a few + // seconds. If the very first poll after launch returns false, the user will see + // the "Not Granted" UI and can click "Grant…" to trigger the one-shot live check. + screenCaptureGrantedLive = CGPreflightScreenCaptureAccess() refresh() } - // SCShareableContent bypasses CGPreflightScreenCaptureAccess() caching on macOS 15+. - private func checkScreenCapturePermission() async -> Bool { + /// One-shot live permission check via SCShareableContent, which bypasses the + /// CGPreflightScreenCaptureAccess() per-process cache on macOS 15+. + /// + /// WARNING: This call triggers the macOS TCC permission dialog if screen recording + /// has not yet been granted. It must ONLY be called in direct response to an explicit + /// user action (e.g. the "Grant…" button), never from a background polling loop. + private func checkScreenCapturePermissionLive() async -> Bool { do { _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) return true @@ -1313,12 +1332,26 @@ public final class PermissionCenter: ObservableObject { // CGRequestScreenCaptureAccess() triggers the system prompt on macOS 14; // on macOS 15+ it redirects to System Settings. Use it unconditionally. CGRequestScreenCaptureAccess() - // Re-check after a brief delay using the live SCShareableContent path so the - // UI flips to "Granted" without waiting for the next 3 s polling tick. - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + // After the user interacts with System Settings and returns to the app, do a + // one-shot live check so the UI flips to "Granted" promptly rather than waiting + // for the next background poll. The 3-second delay gives the user enough time to + // finish in System Settings before the check fires. + // + // This is safe because it is a single call triggered by the user pressing + // "Grant…" — it does not loop. The background polling loop (refreshAsync) uses + // CGPreflightScreenCaptureAccess() only and never calls this path. + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in Task { @MainActor in guard let self else { return } - self.screenCaptureGrantedLive = await self.checkScreenCapturePermission() + // Fast path: sync check first (covers most cases and avoids an + // unnecessary SCShareableContent call if the grant is already visible). + if CGPreflightScreenCaptureAccess() { + self.screenCaptureGrantedLive = true + self.refresh() + return + } + // Bypass macOS 15+ per-process TCC cache with one-shot live check. + self.screenCaptureGrantedLive = await self.checkScreenCapturePermissionLive() self.refresh() } } @@ -1337,10 +1370,11 @@ public final class PermissionCenter: ObservableObject { } private func screenCaptureState() -> PermissionState { - // screenCaptureGrantedLive is updated async every 3 s via SCShareableContent, - // which bypasses the per-process cache that CGPreflightScreenCaptureAccess() - // uses on macOS 15+. The sync check handles the window before the first async - // result arrives (e.g. permission already granted at app launch). + // screenCaptureGrantedLive is updated every 3 s via the background polling task. + // Background polling uses CGPreflightScreenCaptureAccess() to avoid triggering + // TCC prompts. A one-shot SCShareableContent check (checkScreenCapturePermissionLive) + // is used to bypass the macOS 15+ per-process cache, but only when the user + // explicitly presses the "Grant…" button — never from the polling loop. Self.screenCapturePermissionState( syncGranted: CGPreflightScreenCaptureAccess(), liveGranted: screenCaptureGrantedLive