diff --git a/Sources/HeardCore/RosterReader.swift b/Sources/HeardCore/RosterReader.swift index 73ed45b..9d3b60d 100644 --- a/Sources/HeardCore/RosterReader.swift +++ b/Sources/HeardCore/RosterReader.swift @@ -57,7 +57,9 @@ public enum RosterReader { /// Attempt to read participant names from the Teams roster. /// Returns an empty array if Accessibility isn't granted or the roster isn't visible. public static func readRoster(pid teamsPID: pid_t?) -> [String] { - guard AXIsProcessTrusted() else { return [] } + // Skip the AXIsProcessTrusted() pre-check: it returns a stale cached false on + // macOS 15+ even when access is granted. AX API calls return .apiDisabled on + // their own when genuinely denied, so the result is the same either way. guard let pid = teamsPID else { return parseWindowTitle() } let app = AXUIElementNode(AXUIElementCreateApplication(pid)) @@ -185,8 +187,6 @@ public enum RosterReader { } private static func parseWindowTitle() -> [String] { - guard AXIsProcessTrusted() else { return [] } - let teamsNames: Set = [ "Microsoft Teams", "Microsoft Teams (work or school)", diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index 6e16996..63ff8f9 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -342,7 +342,11 @@ public final class MeetingDetector: ObservableObject { /// Strips the trailing app-name suffix (` | Microsoft Teams`, ` - Zoom`, etc.). /// Returns nil if AX is denied, no window matches, or the title is just a placeholder. private static func extractMeetingTitle(pid: pid_t?, source: MeetingApp) -> String? { - guard AXIsProcessTrusted(), let pid else { return nil } + // Do not pre-check AXIsProcessTrusted() — it can return a stale cached false on + // macOS 15+ even when Accessibility IS granted. AXUIElementCopyAttributeValue + // returns .apiDisabled on its own when access is genuinely denied, so the guard + // below handles the not-granted case correctly without the pre-check. + guard let pid else { return nil } let app = AXUIElementCreateApplication(pid) var windowsRef: AnyObject? @@ -650,10 +654,6 @@ public final class RecordingManager: ObservableObject { } // ── Step 2: Create the process tap ──────────────────────────────────── - // Screen Recording permission is required for AudioHardwareCreateProcessTap. - if !CGPreflightScreenCaptureAccess() { - NSLog("Heard: Screen Recording permission not granted — process tap will likely fail") - } let tapDesc = CATapDescription(stereoMixdownOfProcesses: processObjectIDs) tapDesc.uuid = UUID() tapDesc.name = "Heard Tap" @@ -1148,7 +1148,15 @@ public final class PermissionCenter: ObservableObject { 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. - private var screenCaptureGrantedLive: Bool = false + // 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") + } + } + } // 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 @@ -1263,7 +1271,13 @@ public final class PermissionCenter: ObservableObject { } public var isAccessibilityGranted: Bool { - AXIsProcessTrusted() + if AXIsProcessTrusted() { return true } + // AXIsProcessTrusted can return a stale false on macOS 15+. Fall back to a live + // AX API call: only kAXErrorAPIDisabled means "no permission". + let sysWide = AXUIElementCreateSystemWide() + var value: AnyObject? + let err = AXUIElementCopyAttributeValue(sysWide, kAXFocusedApplicationAttribute as CFString, &value) + return err != .apiDisabled } public var isScreenCaptureGranted: Bool { diff --git a/Sources/HeardCore/TextInjector.swift b/Sources/HeardCore/TextInjector.swift index e78a01c..da30c9d 100644 --- a/Sources/HeardCore/TextInjector.swift +++ b/Sources/HeardCore/TextInjector.swift @@ -8,13 +8,16 @@ public enum TextInjector { /// Call this when enabling dictation so the user gets the prompt early. @discardableResult public static func ensureAccessibility() -> Bool { - let trusted = AXIsProcessTrusted() - if !trusted { - // Prompt the user with the system dialog - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary - return AXIsProcessTrustedWithOptions(options) - } - return true + if AXIsProcessTrusted() { return true } + // Live fallback: AXIsProcessTrusted() can return a stale false on macOS 15+. + // Confirm with a real AX API call before showing the permission prompt. + let sysWide = AXUIElementCreateSystemWide() + var value: AnyObject? + let err = AXUIElementCopyAttributeValue(sysWide, kAXFocusedApplicationAttribute as CFString, &value) + if err != .apiDisabled { return true } + // Genuinely not granted — show the system prompt. + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary + return AXIsProcessTrustedWithOptions(options) } /// Inject text into the currently focused app via clipboard paste. @@ -26,7 +29,15 @@ public enum TextInjector { /// paste is one Cmd+V regardless of length and works reliably; the original /// pasteboard contents are restored after a short delay. public static func inject(_ text: String) { - let trusted = AXIsProcessTrusted() + var trusted = AXIsProcessTrusted() + if !trusted { + // Live fallback: AXIsProcessTrusted() returns a stale cached false on macOS 15+ + // even when Accessibility IS granted. A real AX API call returns .apiDisabled only + // when genuinely denied — if it returns any other error (or success), access is live. + let sysWide = AXUIElementCreateSystemWide() + var value: AnyObject? + trusted = AXUIElementCopyAttributeValue(sysWide, kAXFocusedApplicationAttribute as CFString, &value) != .apiDisabled + } DebugFileLog.log("TextInjector.inject text=\"\(text)\" (len=\(text.count)) axTrusted=\(trusted)") guard trusted else { NSLog("Heard: TextInjector cannot inject text — Accessibility not granted") diff --git a/Sources/HeardCore/Views.swift b/Sources/HeardCore/Views.swift index 86ca8e0..f001e65 100644 --- a/Sources/HeardCore/Views.swift +++ b/Sources/HeardCore/Views.swift @@ -1125,8 +1125,8 @@ public struct SettingsView: View { sectionGroup("Permissions") { SettingsCard { let perms = model.permissionCenter.statuses - ForEach(Array(perms.enumerated()), id: \.offset) { index, perm in - CardRow(isLast: index == perms.count - 1) { + ForEach(Array(perms.enumerated()), id: \.offset) { _, perm in + CardRow(isLast: true) { PermissionRow(permission: perm, model: model) } } diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 618c740..aa5d48b 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -131,6 +131,11 @@ if [[ "$RESET_TCC" -eq 1 ]]; then tccutil reset ScreenCapture "$BUNDLE_ID" || true tccutil reset Accessibility "$BUNDLE_ID" || true tccutil reset AudioCapture "$BUNDLE_ID" || true + # Also clear the UserDefaults keys that persist confirmed-granted state across + # app restarts — without this, the app would still show permissions as Granted + # after a --reset, because the cached UserDefaults values survive tccutil. + defaults delete "$BUNDLE_ID" screenCaptureTCCGranted 2>/dev/null || true + defaults delete "$BUNDLE_ID" audioCaptureTCCGranted 2>/dev/null || true fi echo "==> Installing to $INSTALLED..."