Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions Sources/HeardCore/Services.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import CoreGraphics
import FluidAudio
import Foundation
import IOKit.pwr_mgt
import ScreenCaptureKit
import ServiceManagement

// MARK: - Data Types
Expand Down Expand Up @@ -1138,19 +1139,38 @@ public final class PermissionCenter: ObservableObject {
@Published public private(set) var statuses: [PermissionStatus] = []

private var refreshTask: Task<Void, Never>?
// 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(
Expand Down Expand Up @@ -1185,7 +1205,7 @@ public final class PermissionCenter: ObservableObject {
}

public var isScreenCaptureGranted: Bool {
CGPreflightScreenCaptureAccess()
CGPreflightScreenCaptureAccess() || screenCaptureGrantedLive
}

public func markAudioCaptureGranted() {
Expand Down Expand Up @@ -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()
}
}
}

Expand All @@ -1305,11 +1330,44 @@ 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).
Self.screenCapturePermissionState(
syncGranted: CGPreflightScreenCaptureAccess(),
liveGranted: screenCaptureGrantedLive
)
}

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 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
)
}

// 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 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 nonisolated static func accessibilityPermissionState(isTrusted: Bool, liveGranted: Bool) -> PermissionState {
(isTrusted || liveGranted) ? .granted : .recommended
}

private func openSystemSettings(_ urlString: String) {
Expand Down
91 changes: 91 additions & 0 deletions Tests/HeardTests/PermissionCenterTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions Tests/HeardTests/TestRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2003,6 +2003,7 @@ struct TestRunner {
runRosterReaderTests()
runRosterReaderAXTests()
runSegmentDeduplicatorTests()
runPermissionCenterTests()

print("\n" + String(repeating: "─", count: 50))
print("Results: \(passedTests)/\(totalTests) passed")
Expand Down
Loading