From b8aec5c83db9aa5515b0f99029d3b57c7e94932a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 01:18:50 +0000 Subject: [PATCH] Fix Screen Recording permission cache never downgrading after revocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted screenCaptureTCCGranted flag seeded the in-process granted state at launch and could never be cleared, so revoking Screen Recording in System Settings while Heard wasn't running left the app showing Granted forever. Replace the boolean with ScreenCaptureGrantCache, a pure state machine: a grant cached from a previous session is trusted only for a ~30 s reconfirmation window after launch (10 poll probes), then cleared if probes keep failing. A grant confirmed live within a session remains sticky for the process lifetime, and the authoritative SCShareableContent check after the Grant… flow now clears a stale cache immediately on a definitive false. https://claude.ai/code/session_01UMbvMVowzQq3LgFpaQA8aE --- Sources/HeardCore/Services.swift | 134 +++++++++++++++---- Tests/HeardTests/PermissionCenterTests.swift | 86 ++++++++++++ handoff.md | 1 + 3 files changed, 194 insertions(+), 27 deletions(-) diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index 63ff8f9..1013ef9 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -1141,22 +1141,79 @@ public final class ModelCatalog: ObservableObject { // MARK: - Permission Center +/// Pure state machine for the cached Screen Recording grant. Exposed for unit tests. +/// +/// macOS only applies a Screen Recording revocation after the app restarts, so: +/// - a grant confirmed by a live probe this session is sticky for the rest of the +/// process lifetime (a downgrade within the same session is always stale), but +/// - a grant cached from a previous session may have been revoked while the app was +/// not running, so it is only trusted until `reconfirmBudget` consecutive failed +/// probes have elapsed (~30 s at the 3 s poll interval — enough to ride out the +/// window-list probe's transient false negatives), after which it is cleared and +/// the permission reports Not Granted. +/// If a probe succeeds after the budget ran out (the false negatives outlasted it), +/// the grant re-confirms and re-persists on that poll tick. +public struct ScreenCaptureGrantCache: Equatable { + /// What the caller must write to the persisted flag after a probe (nil = no change). + public enum PersistAction: Equatable { + case markGranted + case clearGrant + } + + public private(set) var confirmedThisSession = false + public private(set) var cachedFromPreviousSession: Bool + public private(set) var reconfirmBudget: Int + + public init(cachedFromPreviousSession: Bool, reconfirmBudget: Int = 10) { + self.cachedFromPreviousSession = cachedFromPreviousSession + self.reconfirmBudget = reconfirmBudget + } + + /// Whether the permission should currently be treated as granted. + public var isGranted: Bool { confirmedThisSession || cachedFromPreviousSession } + + /// Feed one probe result from the 3 s background poll. A `false` may be a transient + /// false negative (stale CGPreflight cache, no titled windows on screen), so it only + /// chips away at the reconfirmation budget rather than downgrading immediately. + public mutating func recordProbe(granted: Bool) -> PersistAction? { + if granted { + let firstConfirmation = !confirmedThisSession + confirmedThisSession = true + return firstConfirmation ? .markGranted : nil + } + guard !confirmedThisSession, cachedFromPreviousSession else { return nil } + reconfirmBudget -= 1 + if reconfirmBudget <= 0 { + cachedFromPreviousSession = false + return .clearGrant + } + return nil + } + + /// Feed an authoritative probe result (SCShareableContent reads the live TCC + /// database). A `false` here is definitive, so the cached grant clears immediately + /// instead of waiting out the reconfirmation budget. + public mutating func recordAuthoritativeProbe(granted: Bool) -> PersistAction? { + if granted { return recordProbe(granted: true) } + guard !confirmedThisSession, cachedFromPreviousSession else { return nil } + cachedFromPreviousSession = false + reconfirmBudget = 0 + return .clearGrant + } +} + @MainActor public final class PermissionCenter: ObservableObject { @Published public private(set) var statuses: [PermissionStatus] = [] private var refreshTask: Task? - // Once true, never reset to false within a process lifetime: revocations only take - // effect after an app restart, so a downgrade within the same session is always stale. - // Initialized from UserDefaults so a grant from a previous session is reflected on the - // next launch — CGPreflightScreenCaptureAccess() returns a stale false on macOS 15+. - private var screenCaptureGrantedLive: Bool = UserDefaults.standard.bool(forKey: "screenCaptureTCCGranted") { - didSet { - if screenCaptureGrantedLive && !oldValue { - UserDefaults.standard.set(true, forKey: "screenCaptureTCCGranted") - } - } - } + // Tracks the Screen Recording grant across sessions. A grant confirmed live this + // session is sticky for the process lifetime; a grant cached from a previous session + // (UserDefaults) is reconfirmed after launch and cleared if the user revoked the + // permission while Heard wasn't running. See ScreenCaptureGrantCache. + private var screenCaptureGrant = ScreenCaptureGrantCache( + cachedFromPreviousSession: UserDefaults.standard.bool(forKey: "screenCaptureTCCGranted") + ) // Set when the user clicks "Grant…" so the System Settings deactivation observer // knows to do a live check when they return. private var pendingScreenCaptureCheck = false @@ -1186,22 +1243,40 @@ public final class PermissionCenter: ObservableObject { // a mid-session grant; the one-shot SCShareableContent check (fired when System // Settings deactivates after a user-initiated "Grant…") handles that case. // - // 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. + // A grant confirmed this session is never downgraded: revocations only take + // effect after an app restart, so within a session a false probe can only be + // stale. A grant cached from a PREVIOUS session is different — the user may have + // revoked it while Heard wasn't running — so it must be reconfirmed after launch + // and is cleared if probes keep failing (see ScreenCaptureGrantCache). // // 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 + // on macOS 15+ (notably for ad-hoc signed builds), which would leave 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() + if !screenCaptureGrant.confirmedThisSession { + applyScreenCaptureProbe( + CGPreflightScreenCaptureAccess() || Self.screenRecordingGrantedViaWindowList() + ) } refresh() } + /// Feed a probe result into the grant cache and persist any resulting state change. + private func applyScreenCaptureProbe(_ granted: Bool, authoritative: Bool = false) { + let action = authoritative + ? screenCaptureGrant.recordAuthoritativeProbe(granted: granted) + : screenCaptureGrant.recordProbe(granted: granted) + switch action { + case .markGranted: + UserDefaults.standard.set(true, forKey: "screenCaptureTCCGranted") + case .clearGrant: + UserDefaults.standard.set(false, forKey: "screenCaptureTCCGranted") + case nil: + break + } + } + /// Authoritative, non-prompting Screen Recording check used by the background poll. /// /// Reading the window *title* (`kCGWindowName`) of a window owned by another process @@ -1281,7 +1356,7 @@ public final class PermissionCenter: ObservableObject { } public var isScreenCaptureGranted: Bool { - CGPreflightScreenCaptureAccess() || screenCaptureGrantedLive + CGPreflightScreenCaptureAccess() || screenCaptureGrant.isGranted } public func markAudioCaptureGranted() { @@ -1383,8 +1458,10 @@ public final class PermissionCenter: ObservableObject { // on macOS 15+ it redirects to System Settings. Use it unconditionally. CGRequestScreenCaptureAccess() - // Already confirmed — nothing more to do. - guard !screenCaptureGrantedLive else { return } + // Already confirmed live this session — nothing more to do. (A grant merely + // cached from a previous session still goes through the live check below, so a + // stale cache can't suppress a legitimate re-grant flow.) + guard !screenCaptureGrant.confirmedThisSession else { return } // Watch for System Settings to deactivate: that's the signal that the user has // finished with the privacy page (granted or dismissed) and returned to another @@ -1416,14 +1493,17 @@ public final class PermissionCenter: ObservableObject { guard let self else { return } // Fast path: sync check (works on macOS < 15 and after any restart). if CGPreflightScreenCaptureAccess() { - self.screenCaptureGrantedLive = true + self.applyScreenCaptureProbe(true) self.refresh() return } // Bypass macOS 15+ per-process TCC cache with one-shot SCShareableContent. // Safe here: this is user-initiated and fires exactly once per "Grant…" click, - // only after the user has left System Settings. - self.screenCaptureGrantedLive = await self.checkScreenCapturePermissionLive() + // only after the user has left System Settings. The result is authoritative, + // so a false clears any stale cached grant immediately. + self.applyScreenCaptureProbe( + await self.checkScreenCapturePermissionLive(), authoritative: true + ) self.refresh() } } @@ -1442,14 +1522,14 @@ public final class PermissionCenter: ObservableObject { } private func screenCaptureState() -> PermissionState { - // screenCaptureGrantedLive is updated every 3 s via the background polling task. + // screenCaptureGrant 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 + liveGranted: screenCaptureGrant.isGranted ) } diff --git a/Tests/HeardTests/PermissionCenterTests.swift b/Tests/HeardTests/PermissionCenterTests.swift index d84cd61..04d592f 100644 --- a/Tests/HeardTests/PermissionCenterTests.swift +++ b/Tests/HeardTests/PermissionCenterTests.swift @@ -88,4 +88,90 @@ func runPermissionCenterTests() { let state = PermissionCenter.accessibilityPermissionState(isTrusted: false, liveGranted: true) try expectEqual(state, .granted) } + + // MARK: Screen Recording grant cache (revocation while app not running) + + test("Grant cache: cached grant from previous session is trusted at launch") { + let cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true) + try expect(cache.isGranted, "cached grant should show as granted during reconfirmation") + try expect(!cache.confirmedThisSession) + } + + test("Grant cache: no cache and failed probes → never granted, no persist action") { + var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: false, reconfirmBudget: 3) + for _ in 0..<10 { + try expectEqual(cache.recordProbe(granted: false), nil) + } + try expect(!cache.isGranted) + } + + test("Grant cache: successful probe confirms, persists once, and is sticky") { + var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: false) + try expectEqual(cache.recordProbe(granted: true), .markGranted) + try expect(cache.isGranted) + // Later stale-false probes within the session must not downgrade or re-persist. + try expectEqual(cache.recordProbe(granted: false), nil) + try expectEqual(cache.recordProbe(granted: true), nil) + try expect(cache.isGranted) + } + + test("Grant cache: cached grant clears once the reconfirmation budget is exhausted") { + // The revocation-while-not-running case: user revoked in System Settings, then + // relaunched Heard. Every probe fails; after the budget runs out the cache must + // clear (and persist false) so the UI stops showing a stale "Granted". + var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 3) + try expectEqual(cache.recordProbe(granted: false), nil) + try expect(cache.isGranted, "still within the reconfirmation grace window") + try expectEqual(cache.recordProbe(granted: false), nil) + try expectEqual(cache.recordProbe(granted: false), .clearGrant) + try expect(!cache.isGranted) + // Further failed probes are quiet — no repeated UserDefaults writes. + try expectEqual(cache.recordProbe(granted: false), nil) + } + + test("Grant cache: probe success within the budget confirms and stops the countdown") { + var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 3) + try expectEqual(cache.recordProbe(granted: false), nil) + try expectEqual(cache.recordProbe(granted: true), .markGranted) + try expect(cache.confirmedThisSession) + // Sticky for the rest of the session even if later probes go stale-false. + for _ in 0..<10 { + try expectEqual(cache.recordProbe(granted: false), nil) + } + try expect(cache.isGranted) + } + + test("Grant cache: re-grant after downgrade re-confirms and re-persists") { + // False negatives outlasted the budget (e.g. no titled windows on screen for the + // whole grace window). The next successful probe must recover the granted state. + var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 1) + try expectEqual(cache.recordProbe(granted: false), .clearGrant) + try expect(!cache.isGranted) + try expectEqual(cache.recordProbe(granted: true), .markGranted) + try expect(cache.isGranted) + } + + test("Grant cache: authoritative false clears the cached grant immediately") { + // SCShareableContent reads the live TCC database, so its false is definitive — + // no reason to wait out the remaining budget. + var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 10) + try expectEqual(cache.recordAuthoritativeProbe(granted: false), .clearGrant) + try expect(!cache.isGranted) + } + + test("Grant cache: authoritative false never downgrades a session-confirmed grant") { + // Revocations only take effect after restart, so a grant confirmed this session + // outranks even an authoritative false (which can only be a mid-session revoke + // that won't apply until relaunch). + var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: false) + try expectEqual(cache.recordProbe(granted: true), .markGranted) + try expectEqual(cache.recordAuthoritativeProbe(granted: false), nil) + try expect(cache.isGranted) + } + + test("Grant cache: authoritative true confirms like a normal probe") { + var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 3) + try expectEqual(cache.recordAuthoritativeProbe(granted: true), .markGranted) + try expect(cache.confirmedThisSession) + } } diff --git a/handoff.md b/handoff.md index de63a09..acc2f28 100644 --- a/handoff.md +++ b/handoff.md @@ -32,6 +32,7 @@ The app builds cleanly with `swift build` and runs as a menu bar app on macOS 15 - **Recording self-test + one-shot recovery**: at T+2s, the monitor checks whether non-zero samples have arrived. If silent, it tears down and rebuilds the tap/aggregate/IOProc once with fresh helper-process enumeration. If the rebuild's self-test still fails, the recording is flagged `appAudioTapFailed` and the menu bar shows "Recording (mic only)". - **`stopWatching` ends the active meeting**: toggling watching off mid-meeting fires `onMeetingEnded` synchronously so the recording stops and the transcript pipeline runs. `AppModel.stopWatching` preserves the resulting `.processing` phase instead of overwriting it with `.dormant`. - **TCC permissions required**: Microphone (`NSMicrophoneUsageDescription`), System Audio Capture (`NSAudioCaptureUsageDescription`), Screen Recording (`NSScreenCaptureUsageDescription`), Accessibility (`NSAccessibilityUsageDescription`) — all four must be granted. Use `./scripts/bundle.sh --reset` to clear all four TCC grants and reinstall cleanly. +- **Screen Recording grant caching**: the grant persists in UserDefaults (`screenCaptureTCCGranted`) so it survives macOS 15's stale `CGPreflightScreenCaptureAccess()` on fresh launches. The cache is reconfirmed after launch via `ScreenCaptureGrantCache` (pure, tested state machine in `Services.swift`): a grant cached from a previous session is trusted for a ~30 s grace window (10 probes × 3 s poll) and cleared if probes keep failing — covering revocation while the app wasn't running. A grant confirmed live within a session is never downgraded (revocations only take effect after restart); an authoritative `SCShareableContent` false (post-"Grant…" check) clears the cache immediately. ### Pipeline (Fully Implemented) - Sequential job queue with stages: queued → preprocessing → transcribing → diarizing → assigning → complete