Skip to content

Commit 35e4649

Browse files
execsumoclaude
andauthored
Fix Screen Recording permission cache never downgrading after revocation (#29)
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 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ce3c81 commit 35e4649

3 files changed

Lines changed: 194 additions & 27 deletions

File tree

Sources/HeardCore/Services.swift

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,22 +1242,79 @@ public final class ModelCatalog: ObservableObject {
12421242

12431243
// MARK: - Permission Center
12441244

1245+
/// Pure state machine for the cached Screen Recording grant. Exposed for unit tests.
1246+
///
1247+
/// macOS only applies a Screen Recording revocation after the app restarts, so:
1248+
/// - a grant confirmed by a live probe this session is sticky for the rest of the
1249+
/// process lifetime (a downgrade within the same session is always stale), but
1250+
/// - a grant cached from a previous session may have been revoked while the app was
1251+
/// not running, so it is only trusted until `reconfirmBudget` consecutive failed
1252+
/// probes have elapsed (~30 s at the 3 s poll interval — enough to ride out the
1253+
/// window-list probe's transient false negatives), after which it is cleared and
1254+
/// the permission reports Not Granted.
1255+
/// If a probe succeeds after the budget ran out (the false negatives outlasted it),
1256+
/// the grant re-confirms and re-persists on that poll tick.
1257+
public struct ScreenCaptureGrantCache: Equatable {
1258+
/// What the caller must write to the persisted flag after a probe (nil = no change).
1259+
public enum PersistAction: Equatable {
1260+
case markGranted
1261+
case clearGrant
1262+
}
1263+
1264+
public private(set) var confirmedThisSession = false
1265+
public private(set) var cachedFromPreviousSession: Bool
1266+
public private(set) var reconfirmBudget: Int
1267+
1268+
public init(cachedFromPreviousSession: Bool, reconfirmBudget: Int = 10) {
1269+
self.cachedFromPreviousSession = cachedFromPreviousSession
1270+
self.reconfirmBudget = reconfirmBudget
1271+
}
1272+
1273+
/// Whether the permission should currently be treated as granted.
1274+
public var isGranted: Bool { confirmedThisSession || cachedFromPreviousSession }
1275+
1276+
/// Feed one probe result from the 3 s background poll. A `false` may be a transient
1277+
/// false negative (stale CGPreflight cache, no titled windows on screen), so it only
1278+
/// chips away at the reconfirmation budget rather than downgrading immediately.
1279+
public mutating func recordProbe(granted: Bool) -> PersistAction? {
1280+
if granted {
1281+
let firstConfirmation = !confirmedThisSession
1282+
confirmedThisSession = true
1283+
return firstConfirmation ? .markGranted : nil
1284+
}
1285+
guard !confirmedThisSession, cachedFromPreviousSession else { return nil }
1286+
reconfirmBudget -= 1
1287+
if reconfirmBudget <= 0 {
1288+
cachedFromPreviousSession = false
1289+
return .clearGrant
1290+
}
1291+
return nil
1292+
}
1293+
1294+
/// Feed an authoritative probe result (SCShareableContent reads the live TCC
1295+
/// database). A `false` here is definitive, so the cached grant clears immediately
1296+
/// instead of waiting out the reconfirmation budget.
1297+
public mutating func recordAuthoritativeProbe(granted: Bool) -> PersistAction? {
1298+
if granted { return recordProbe(granted: true) }
1299+
guard !confirmedThisSession, cachedFromPreviousSession else { return nil }
1300+
cachedFromPreviousSession = false
1301+
reconfirmBudget = 0
1302+
return .clearGrant
1303+
}
1304+
}
1305+
12451306
@MainActor
12461307
public final class PermissionCenter: ObservableObject {
12471308
@Published public private(set) var statuses: [PermissionStatus] = []
12481309

12491310
private var refreshTask: Task<Void, Never>?
1250-
// Once true, never reset to false within a process lifetime: revocations only take
1251-
// effect after an app restart, so a downgrade within the same session is always stale.
1252-
// Initialized from UserDefaults so a grant from a previous session is reflected on the
1253-
// next launch — CGPreflightScreenCaptureAccess() returns a stale false on macOS 15+.
1254-
private var screenCaptureGrantedLive: Bool = UserDefaults.standard.bool(forKey: "screenCaptureTCCGranted") {
1255-
didSet {
1256-
if screenCaptureGrantedLive && !oldValue {
1257-
UserDefaults.standard.set(true, forKey: "screenCaptureTCCGranted")
1258-
}
1259-
}
1260-
}
1311+
// Tracks the Screen Recording grant across sessions. A grant confirmed live this
1312+
// session is sticky for the process lifetime; a grant cached from a previous session
1313+
// (UserDefaults) is reconfirmed after launch and cleared if the user revoked the
1314+
// permission while Heard wasn't running. See ScreenCaptureGrantCache.
1315+
private var screenCaptureGrant = ScreenCaptureGrantCache(
1316+
cachedFromPreviousSession: UserDefaults.standard.bool(forKey: "screenCaptureTCCGranted")
1317+
)
12611318
// Set when the user clicks "Grant…" so the System Settings deactivation observer
12621319
// knows to do a live check when they return.
12631320
private var pendingScreenCaptureCheck = false
@@ -1317,22 +1374,40 @@ public final class PermissionCenter: ObservableObject {
13171374
// a mid-session grant; the one-shot SCShareableContent check (fired when System
13181375
// Settings deactivates after a user-initiated "Grant…") handles that case.
13191376
//
1320-
// NEVER overwrite a confirmed true with a potentially stale false: permission
1321-
// revocations only take effect after an app restart, so if screenCaptureGrantedLive
1322-
// is already true, preserve it — the poll result can only be wrong in that direction.
1377+
// A grant confirmed this session is never downgraded: revocations only take
1378+
// effect after an app restart, so within a session a false probe can only be
1379+
// stale. A grant cached from a PREVIOUS session is different — the user may have
1380+
// revoked it while Heard wasn't running — so it must be reconfirmed after launch
1381+
// and is cleared if probes keep failing (see ScreenCaptureGrantCache).
13231382
//
13241383
// CGPreflightScreenCaptureAccess() can also return a stale false on a FRESH launch
1325-
// on macOS 15+ (notably for ad-hoc signed builds), which leaves a previously-granted
1326-
// permission showing as "Not Granted" after an app restart. Fall back to a
1384+
// on macOS 15+ (notably for ad-hoc signed builds), which would leave a previously-
1385+
// granted permission showing as "Not Granted" after an app restart. Fall back to a
13271386
// non-prompting window-list probe, which reads the live grant without ever
13281387
// triggering the TCC dialog (so it is safe in this background loop).
1329-
if !screenCaptureGrantedLive {
1330-
screenCaptureGrantedLive = CGPreflightScreenCaptureAccess()
1331-
|| Self.screenRecordingGrantedViaWindowList()
1388+
if !screenCaptureGrant.confirmedThisSession {
1389+
applyScreenCaptureProbe(
1390+
CGPreflightScreenCaptureAccess() || Self.screenRecordingGrantedViaWindowList()
1391+
)
13321392
}
13331393
refresh()
13341394
}
13351395

1396+
/// Feed a probe result into the grant cache and persist any resulting state change.
1397+
private func applyScreenCaptureProbe(_ granted: Bool, authoritative: Bool = false) {
1398+
let action = authoritative
1399+
? screenCaptureGrant.recordAuthoritativeProbe(granted: granted)
1400+
: screenCaptureGrant.recordProbe(granted: granted)
1401+
switch action {
1402+
case .markGranted:
1403+
UserDefaults.standard.set(true, forKey: "screenCaptureTCCGranted")
1404+
case .clearGrant:
1405+
UserDefaults.standard.set(false, forKey: "screenCaptureTCCGranted")
1406+
case nil:
1407+
break
1408+
}
1409+
}
1410+
13361411
/// Authoritative, non-prompting Screen Recording check used by the background poll.
13371412
///
13381413
/// Reading the window *title* (`kCGWindowName`) of a window owned by another process
@@ -1418,7 +1493,7 @@ public final class PermissionCenter: ObservableObject {
14181493
}
14191494

14201495
public var isScreenCaptureGranted: Bool {
1421-
CGPreflightScreenCaptureAccess() || screenCaptureGrantedLive
1496+
CGPreflightScreenCaptureAccess() || screenCaptureGrant.isGranted
14221497
}
14231498

14241499
public func markAudioCaptureGranted() {
@@ -1520,8 +1595,10 @@ public final class PermissionCenter: ObservableObject {
15201595
// on macOS 15+ it redirects to System Settings. Use it unconditionally.
15211596
CGRequestScreenCaptureAccess()
15221597

1523-
// Already confirmed — nothing more to do.
1524-
guard !screenCaptureGrantedLive else { return }
1598+
// Already confirmed live this session — nothing more to do. (A grant merely
1599+
// cached from a previous session still goes through the live check below, so a
1600+
// stale cache can't suppress a legitimate re-grant flow.)
1601+
guard !screenCaptureGrant.confirmedThisSession else { return }
15251602

15261603
// Watch for System Settings to deactivate: that's the signal that the user has
15271604
// finished with the privacy page (granted or dismissed) and returned to another
@@ -1553,14 +1630,17 @@ public final class PermissionCenter: ObservableObject {
15531630
guard let self else { return }
15541631
// Fast path: sync check (works on macOS < 15 and after any restart).
15551632
if CGPreflightScreenCaptureAccess() {
1556-
self.screenCaptureGrantedLive = true
1633+
self.applyScreenCaptureProbe(true)
15571634
self.refresh()
15581635
return
15591636
}
15601637
// Bypass macOS 15+ per-process TCC cache with one-shot SCShareableContent.
15611638
// Safe here: this is user-initiated and fires exactly once per "Grant…" click,
1562-
// only after the user has left System Settings.
1563-
self.screenCaptureGrantedLive = await self.checkScreenCapturePermissionLive()
1639+
// only after the user has left System Settings. The result is authoritative,
1640+
// so a false clears any stale cached grant immediately.
1641+
self.applyScreenCaptureProbe(
1642+
await self.checkScreenCapturePermissionLive(), authoritative: true
1643+
)
15641644
self.refresh()
15651645
}
15661646
}
@@ -1579,14 +1659,14 @@ public final class PermissionCenter: ObservableObject {
15791659
}
15801660

15811661
private func screenCaptureState() -> PermissionState {
1582-
// screenCaptureGrantedLive is updated every 3 s via the background polling task.
1662+
// screenCaptureGrant is updated every 3 s via the background polling task.
15831663
// Background polling uses CGPreflightScreenCaptureAccess() to avoid triggering
15841664
// TCC prompts. A one-shot SCShareableContent check (checkScreenCapturePermissionLive)
15851665
// is used to bypass the macOS 15+ per-process cache, but only when the user
15861666
// explicitly presses the "Grant…" button — never from the polling loop.
15871667
Self.screenCapturePermissionState(
15881668
syncGranted: CGPreflightScreenCaptureAccess(),
1589-
liveGranted: screenCaptureGrantedLive
1669+
liveGranted: screenCaptureGrant.isGranted
15901670
)
15911671
}
15921672

Tests/HeardTests/PermissionCenterTests.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,90 @@ func runPermissionCenterTests() {
8888
let state = PermissionCenter.accessibilityPermissionState(isTrusted: false, liveGranted: true)
8989
try expectEqual(state, .granted)
9090
}
91+
92+
// MARK: Screen Recording grant cache (revocation while app not running)
93+
94+
test("Grant cache: cached grant from previous session is trusted at launch") {
95+
let cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true)
96+
try expect(cache.isGranted, "cached grant should show as granted during reconfirmation")
97+
try expect(!cache.confirmedThisSession)
98+
}
99+
100+
test("Grant cache: no cache and failed probes → never granted, no persist action") {
101+
var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: false, reconfirmBudget: 3)
102+
for _ in 0..<10 {
103+
try expectEqual(cache.recordProbe(granted: false), nil)
104+
}
105+
try expect(!cache.isGranted)
106+
}
107+
108+
test("Grant cache: successful probe confirms, persists once, and is sticky") {
109+
var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: false)
110+
try expectEqual(cache.recordProbe(granted: true), .markGranted)
111+
try expect(cache.isGranted)
112+
// Later stale-false probes within the session must not downgrade or re-persist.
113+
try expectEqual(cache.recordProbe(granted: false), nil)
114+
try expectEqual(cache.recordProbe(granted: true), nil)
115+
try expect(cache.isGranted)
116+
}
117+
118+
test("Grant cache: cached grant clears once the reconfirmation budget is exhausted") {
119+
// The revocation-while-not-running case: user revoked in System Settings, then
120+
// relaunched Heard. Every probe fails; after the budget runs out the cache must
121+
// clear (and persist false) so the UI stops showing a stale "Granted".
122+
var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 3)
123+
try expectEqual(cache.recordProbe(granted: false), nil)
124+
try expect(cache.isGranted, "still within the reconfirmation grace window")
125+
try expectEqual(cache.recordProbe(granted: false), nil)
126+
try expectEqual(cache.recordProbe(granted: false), .clearGrant)
127+
try expect(!cache.isGranted)
128+
// Further failed probes are quiet — no repeated UserDefaults writes.
129+
try expectEqual(cache.recordProbe(granted: false), nil)
130+
}
131+
132+
test("Grant cache: probe success within the budget confirms and stops the countdown") {
133+
var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 3)
134+
try expectEqual(cache.recordProbe(granted: false), nil)
135+
try expectEqual(cache.recordProbe(granted: true), .markGranted)
136+
try expect(cache.confirmedThisSession)
137+
// Sticky for the rest of the session even if later probes go stale-false.
138+
for _ in 0..<10 {
139+
try expectEqual(cache.recordProbe(granted: false), nil)
140+
}
141+
try expect(cache.isGranted)
142+
}
143+
144+
test("Grant cache: re-grant after downgrade re-confirms and re-persists") {
145+
// False negatives outlasted the budget (e.g. no titled windows on screen for the
146+
// whole grace window). The next successful probe must recover the granted state.
147+
var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 1)
148+
try expectEqual(cache.recordProbe(granted: false), .clearGrant)
149+
try expect(!cache.isGranted)
150+
try expectEqual(cache.recordProbe(granted: true), .markGranted)
151+
try expect(cache.isGranted)
152+
}
153+
154+
test("Grant cache: authoritative false clears the cached grant immediately") {
155+
// SCShareableContent reads the live TCC database, so its false is definitive —
156+
// no reason to wait out the remaining budget.
157+
var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 10)
158+
try expectEqual(cache.recordAuthoritativeProbe(granted: false), .clearGrant)
159+
try expect(!cache.isGranted)
160+
}
161+
162+
test("Grant cache: authoritative false never downgrades a session-confirmed grant") {
163+
// Revocations only take effect after restart, so a grant confirmed this session
164+
// outranks even an authoritative false (which can only be a mid-session revoke
165+
// that won't apply until relaunch).
166+
var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: false)
167+
try expectEqual(cache.recordProbe(granted: true), .markGranted)
168+
try expectEqual(cache.recordAuthoritativeProbe(granted: false), nil)
169+
try expect(cache.isGranted)
170+
}
171+
172+
test("Grant cache: authoritative true confirms like a normal probe") {
173+
var cache = ScreenCaptureGrantCache(cachedFromPreviousSession: true, reconfirmBudget: 3)
174+
try expectEqual(cache.recordAuthoritativeProbe(granted: true), .markGranted)
175+
try expect(cache.confirmedThisSession)
176+
}
91177
}

handoff.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ All items from the post-v0.2.2 robustness review are now resolved (see `ROADMAP.
5454
- **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)".
5555
- **`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`.
5656
- **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.
57+
- **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.
5758

5859
### Pipeline (Fully Implemented)
5960
- Sequential job queue with stages: queued → preprocessing → transcribing → diarizing → assigning → complete

0 commit comments

Comments
 (0)