From f9f45adf1c676033cf6d850ebb225248385e8ff2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 15:19:41 +0000 Subject: [PATCH 1/4] Fix stale permission display for Screen Recording and Accessibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CGPreflightScreenCaptureAccess() caches its result per-process on macOS 15+, so granting Screen Recording in System Settings while the app runs never flips the UI to "Granted". Fix by polling SCShareableContent.excludingDesktopWindows every 3 s — this call goes through ScreenCaptureKit's own path and is not subject to the same per-process cache. AXIsProcessTrusted() can similarly return stale TCC data. Add a live fallback: attempt AXUIElementCopyAttributeValue on the system-wide element; only kAXErrorAPIDisabled and kAXErrorNotTrusted mean the process lacks permission, all other results (including kAXErrorNoValue for "no focused app") confirm trust. isScreenCaptureGranted and openScreenCaptureSettings are updated to use the same live check so the rest of the app (tap preflight path, status banner) is consistent with the Settings UI. --- Sources/HeardCore/Services.swift | 51 +++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index 6c3df29..fbd7c18 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -8,6 +8,7 @@ import CoreGraphics import FluidAudio import Foundation import IOKit.pwr_mgt +import ScreenCaptureKit import ServiceManagement // MARK: - Data Types @@ -1138,19 +1139,38 @@ public final class PermissionCenter: ObservableObject { @Published public private(set) var statuses: [PermissionStatus] = [] private var refreshTask: Task? + // Live result from SCShareableContent — bypasses CGPreflightScreenCaptureAccess() caching + private var screenCaptureGrantedLive: Bool = false public init() { refresh() - // Periodically re-check permissions (catches grants made in System Settings) + // Periodically re-check permissions (catches grants made in System Settings). + // Uses async checks to bypass per-process TCC caching in macOS 15+. refreshTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(3)) guard let self, !Task.isCancelled else { return } - self.refresh() + await self.refreshAsync() } } } + // Performs the async screen-capture check then calls the sync refresh. + private func refreshAsync() async { + screenCaptureGrantedLive = await checkScreenCapturePermission() + refresh() + } + + // SCShareableContent bypasses CGPreflightScreenCaptureAccess() caching on macOS 15+. + private func checkScreenCapturePermission() async -> Bool { + do { + _ = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false) + return true + } catch { + return false + } + } + public func refresh() { statuses = [ PermissionStatus( @@ -1185,7 +1205,7 @@ public final class PermissionCenter: ObservableObject { } public var isScreenCaptureGranted: Bool { - CGPreflightScreenCaptureAccess() + CGPreflightScreenCaptureAccess() || screenCaptureGrantedLive } public func markAudioCaptureGranted() { @@ -1286,9 +1306,14 @@ 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 — the grant applies asynchronously. + // 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 - Task { @MainActor in self?.refresh() } + Task { @MainActor in + guard let self else { return } + self.screenCaptureGrantedLive = await self.checkScreenCapturePermission() + self.refresh() + } } } @@ -1305,11 +1330,23 @@ public final class PermissionCenter: ObservableObject { } private func screenCaptureState() -> PermissionState { - CGPreflightScreenCaptureAccess() ? .granted : .recommended + // 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). + (CGPreflightScreenCaptureAccess() || screenCaptureGrantedLive) ? .granted : .recommended } private func accessibilityState() -> PermissionState { - AXIsProcessTrusted() ? .granted : .recommended + if AXIsProcessTrusted() { return .granted } + // AXIsProcessTrusted can return stale TCC data on macOS 15+. Confirm with a + // live AX API call: only kAXErrorAPIDisabled / kAXErrorNotTrusted mean "no + // permission" — all other results (including kAXErrorNoValue for "no focused + // app") mean the process IS trusted. + let sysWide = AXUIElementCreateSystemWide() + var value: AnyObject? + let err = AXUIElementCopyAttributeValue(sysWide, kAXFocusedApplicationAttribute as CFString, &value) + return (err != .apiDisabled && err != .notTrusted) ? .granted : .recommended } private func openSystemSettings(_ urlString: String) { From c0c2d0bc26470baef6769ccb6ff7d5c53d80811f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 16:03:51 +0000 Subject: [PATCH 2/4] Add unit tests for permission display combinatorial logic Extracts the OR-logic from screenCaptureState() and accessibilityState() into public static helpers (screenCapturePermissionState / accessibilityPermissionState) so the decision branches can be exercised without real OS API calls. Tests cover all four boolean combinations for each permission, with explicit regression cases for the macOS 15+ stale-TCC scenario: sync=false/live=true (Screen Recording) and isTrusted=false/liveGranted=true (Accessibility), which were the exact conditions that caused the "always shows not granted" bug. --- Sources/HeardCore/Services.swift | 24 +++++- Tests/HeardTests/PermissionCenterTests.swift | 91 ++++++++++++++++++++ Tests/HeardTests/TestRunner.swift | 1 + 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 Tests/HeardTests/PermissionCenterTests.swift diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index fbd7c18..5299362 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -1334,7 +1334,10 @@ public final class PermissionCenter: ObservableObject { // 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). - (CGPreflightScreenCaptureAccess() || screenCaptureGrantedLive) ? .granted : .recommended + Self.screenCapturePermissionState( + syncGranted: CGPreflightScreenCaptureAccess(), + liveGranted: screenCaptureGrantedLive + ) } private func accessibilityState() -> PermissionState { @@ -1346,7 +1349,24 @@ public final class PermissionCenter: ObservableObject { let sysWide = AXUIElementCreateSystemWide() var value: AnyObject? let err = AXUIElementCopyAttributeValue(sysWide, kAXFocusedApplicationAttribute as CFString, &value) - return (err != .apiDisabled && err != .notTrusted) ? .granted : .recommended + return Self.accessibilityPermissionState( + isTrusted: false, + liveGranted: err != .apiDisabled && err != .notTrusted + ) + } + + // MARK: - Testable state helpers + + /// Exposed for unit tests. Screen recording is granted if either the + /// (potentially cached) sync check or the live SCShareableContent check confirms it. + public static func screenCapturePermissionState(syncGranted: Bool, liveGranted: Bool) -> PermissionState { + (syncGranted || liveGranted) ? .granted : .recommended + } + + /// Exposed for unit tests. Accessibility is granted if either AXIsProcessTrusted() + /// or the live AX API fallback confirms it. + public static func accessibilityPermissionState(isTrusted: Bool, liveGranted: Bool) -> PermissionState { + (isTrusted || liveGranted) ? .granted : .recommended } private func openSystemSettings(_ urlString: String) { diff --git a/Tests/HeardTests/PermissionCenterTests.swift b/Tests/HeardTests/PermissionCenterTests.swift new file mode 100644 index 0000000..d84cd61 --- /dev/null +++ b/Tests/HeardTests/PermissionCenterTests.swift @@ -0,0 +1,91 @@ +import Foundation +import HeardCore + +// MARK: - PermissionCenter Logic Tests +// +// These tests cover the combinatorial logic that decides which PermissionState to +// display for Screen Recording and Accessibility. They cannot exercise the live +// OS APIs (CGPreflightScreenCaptureAccess, SCShareableContent, AXIsProcessTrusted) +// but they DO cover every branch of the fix for stale-TCC caching on macOS 15+: +// +// Screen Recording +// ┌──────────┬──────────┬─────────────────────────────────────────────┐ +// │ sync │ live │ expected │ scenario │ +// ├──────────┼──────────┼───────────────────┼──────────────────────────│ +// │ true │ false │ .granted │ granted at launch │ +// │ false │ true │ .granted │ granted while running ← fixed bug │ +// │ true │ true │ .granted │ both agree │ +// │ false │ false │ .recommended │ not granted │ +// └──────────┴──────────┴───────────────────┴──────────────────────────┘ +// +// Accessibility +// ┌──────────┬──────────┬───────────────────┬──────────────────────────┐ +// │ trusted │ live │ expected │ scenario │ +// ├──────────┼──────────┼───────────────────┼──────────────────────────│ +// │ true │ false │ .granted │ TCC returned live true │ +// │ false │ true │ .granted │ AX fallback confirms ← fixed bug │ +// │ true │ true │ .granted │ both agree │ +// │ false │ false │ .recommended │ not granted │ +// └──────────┴──────────┴───────────────────┴──────────────────────────┘ + +func runPermissionCenterTests() { + print("\n🔐 PermissionCenter Logic Tests") + + // MARK: Screen Recording + + test("Screen recording: sync=true, live=false → granted (app-launch path)") { + let state = PermissionCenter.screenCapturePermissionState(syncGranted: true, liveGranted: false) + try expectEqual(state, .granted) + } + + test("Screen recording: sync=false, live=true → granted (regression: granted while running)") { + // This is the case that was broken: CGPreflightScreenCaptureAccess() returned a + // stale false after the user granted in System Settings while the app was running. + let state = PermissionCenter.screenCapturePermissionState(syncGranted: false, liveGranted: true) + try expectEqual(state, .granted) + } + + test("Screen recording: sync=true, live=true → granted") { + let state = PermissionCenter.screenCapturePermissionState(syncGranted: true, liveGranted: true) + try expectEqual(state, .granted) + } + + test("Screen recording: sync=false, live=false → not granted") { + let state = PermissionCenter.screenCapturePermissionState(syncGranted: false, liveGranted: false) + try expectEqual(state, .recommended) + } + + // MARK: Accessibility + + test("Accessibility: isTrusted=true, liveGranted=false → granted (AXIsProcessTrusted path)") { + let state = PermissionCenter.accessibilityPermissionState(isTrusted: true, liveGranted: false) + try expectEqual(state, .granted) + } + + test("Accessibility: isTrusted=false, liveGranted=true → granted (regression: AX fallback confirms)") { + // This is the case that was broken: AXIsProcessTrusted() returned stale false + // while a live AX API call confirmed the process actually has permission. + let state = PermissionCenter.accessibilityPermissionState(isTrusted: false, liveGranted: true) + try expectEqual(state, .granted) + } + + test("Accessibility: isTrusted=true, liveGranted=true → granted") { + let state = PermissionCenter.accessibilityPermissionState(isTrusted: true, liveGranted: true) + try expectEqual(state, .granted) + } + + test("Accessibility: isTrusted=false, liveGranted=false → not granted") { + // Both kAXErrorAPIDisabled and kAXErrorNotTrusted produce liveGranted=false. + let state = PermissionCenter.accessibilityPermissionState(isTrusted: false, liveGranted: false) + try expectEqual(state, .recommended) + } + + test("Accessibility: kAXErrorNoValue (no focused app) still counts as live-granted") { + // kAXErrorNoValue means the process HAS permission but there is no focused element + // right now. It must not be mistaken for a permission denial. + // We simulate this by passing liveGranted=true (the caller computes this from the + // AXError: err != .apiDisabled && err != .notTrusted). + let state = PermissionCenter.accessibilityPermissionState(isTrusted: false, liveGranted: true) + try expectEqual(state, .granted) + } +} diff --git a/Tests/HeardTests/TestRunner.swift b/Tests/HeardTests/TestRunner.swift index c9dd7a9..c66078a 100644 --- a/Tests/HeardTests/TestRunner.swift +++ b/Tests/HeardTests/TestRunner.swift @@ -2003,6 +2003,7 @@ struct TestRunner { runRosterReaderTests() runRosterReaderAXTests() runSegmentDeduplicatorTests() + runPermissionCenterTests() print("\n" + String(repeating: "─", count: 50)) print("Results: \(passedTests)/\(totalTests) passed") From dc0dd92d8e6f3a0cffba39e933af8351b857d761 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 02:16:17 +0000 Subject: [PATCH 3/4] Fix Swift 6 concurrency error on permission state helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static methods on a @MainActor class inherit MainActor isolation in Swift 6, making them uncallable from non-isolated contexts (compile error). The two pure helper functions only operate on plain Bool/PermissionState values and access no actor state, so nonisolated is correct — matching the existing pattern used by MeetingDetector.isTeamsMainApp and cleanWindowTitle. --- Sources/HeardCore/Services.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index 5299362..ac21a8e 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -1359,13 +1359,13 @@ public final class PermissionCenter: ObservableObject { /// Exposed for unit tests. Screen recording is granted if either the /// (potentially cached) sync check or the live SCShareableContent check confirms it. - public static func screenCapturePermissionState(syncGranted: Bool, liveGranted: Bool) -> PermissionState { + public nonisolated static func screenCapturePermissionState(syncGranted: Bool, liveGranted: Bool) -> PermissionState { (syncGranted || liveGranted) ? .granted : .recommended } /// Exposed for unit tests. Accessibility is granted if either AXIsProcessTrusted() /// or the live AX API fallback confirms it. - public static func accessibilityPermissionState(isTrusted: Bool, liveGranted: Bool) -> PermissionState { + public nonisolated static func accessibilityPermissionState(isTrusted: Bool, liveGranted: Bool) -> PermissionState { (isTrusted || liveGranted) ? .granted : .recommended } From d610c05e065d26d7b56407ce58952ae262e47907 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 02:54:33 +0000 Subject: [PATCH 4/4] Fix compile error: AXError has no .notTrusted member MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AXError C enum does not define kAXErrorNotTrusted — the enum jumps directly from kAXErrorAPIDisabled (-25211) to kAXErrorNoValue (-25212). Removing the non-existent check; kAXErrorAPIDisabled alone correctly identifies the "process not trusted" case. https://claude.ai/code/session_01SLK18VAKHQdk37Mubvyp47 --- Sources/HeardCore/Services.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index ac21a8e..cd00992 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -1343,15 +1343,16 @@ public final class PermissionCenter: ObservableObject { private func accessibilityState() -> PermissionState { if AXIsProcessTrusted() { return .granted } // AXIsProcessTrusted can return stale TCC data on macOS 15+. Confirm with a - // live AX API call: only kAXErrorAPIDisabled / kAXErrorNotTrusted mean "no - // permission" — all other results (including kAXErrorNoValue for "no focused - // app") mean the process IS trusted. + // live AX API call: only kAXErrorAPIDisabled means "no permission" — all other + // results (including kAXErrorNoValue for "no focused app") mean the process IS + // trusted. Note: AXError has no .notTrusted member; the enum jumps directly + // from .apiDisabled to .noValue. let sysWide = AXUIElementCreateSystemWide() var value: AnyObject? let err = AXUIElementCopyAttributeValue(sysWide, kAXFocusedApplicationAttribute as CFString, &value) return Self.accessibilityPermissionState( isTrusted: false, - liveGranted: err != .apiDisabled && err != .notTrusted + liveGranted: err != .apiDisabled ) }