From a04fb6489bfb09df6cf2174aca0c9ad2540803ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 17:33:13 +0000 Subject: [PATCH 1/3] Fix macOS 15+ permission caching causing stale Not Granted UI and generic transcript names Two separate macOS 15+ TCC caching bugs: 1. AXIsProcessTrusted() returns a stale false even when Accessibility is granted, causing extractMeetingTitle and RosterReader to short-circuit and return nil/[]. This makes every recording get an empty title, which produces generic date-only transcript filenames. Fix: remove the AXIsProcessTrusted() pre-checks; the underlying AXUIElementCopyAttributeValue calls already return .apiDisabled when access is genuinely denied. 2. CGPreflightScreenCaptureAccess() and the CGWindowListCopyWindowInfo window-name probe both return stale false values on macOS 15+ for a fresh process whose screen recording was granted in a prior session. screenCaptureGrantedLive resets to false on every launch, so the UI shows "Not Granted" after a restart even though the grant is still in TCC. Fix: persist the confirmed-granted state to UserDefaults (same pattern already used for audioCaptureTCCGranted) and initialize from it at launch. Also update isAccessibilityGranted to use the same live AX API fallback already used by accessibilityState(). https://claude.ai/code/session_0141TjDnmekRsDDGoBu4jQyT --- Sources/HeardCore/RosterReader.swift | 6 +++--- Sources/HeardCore/Services.swift | 24 +++++++++++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) 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..b155f41 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? @@ -1148,7 +1152,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 +1275,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 { From 633e077336eb927e7cd75341f2c83c04768e1d35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 17:39:38 +0000 Subject: [PATCH 2/3] Fix remaining macOS 15+ permission caching issues in TextInjector and bundle reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextInjector.inject(): AXIsProcessTrusted() stale-false caused all dictation text injection to silently fail on macOS 15+. Added live AX API fallback (same pattern as PermissionCenter.isAccessibilityGranted) — only returns false when AXUIElementCopyAttributeValue returns .apiDisabled. TextInjector.ensureAccessibility(): same stale-false could trigger a redundant Accessibility permission prompt when the permission was already granted. Added the live fallback before showing the dialog. bundle.sh --reset: now also clears the screenCaptureTCCGranted and audioCaptureTCCGranted UserDefaults keys. Without this, --reset wiped TCC but left the cached-granted flags intact, causing the app to show permissions as Granted immediately after a full reset. setupAppAudioRecording: removed the CGPreflightScreenCaptureAccess() log warning — it fires on every meeting start on macOS 15+ even with the permission fully granted, producing false-positive noise that trains developers to ignore real errors. The actual tap failure error at AudioHardwareCreateProcessTap is the right signal. https://claude.ai/code/session_0141TjDnmekRsDDGoBu4jQyT --- Sources/HeardCore/Services.swift | 4 ---- Sources/HeardCore/TextInjector.swift | 27 +++++++++++++++++++-------- scripts/bundle.sh | 5 +++++ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/Sources/HeardCore/Services.swift b/Sources/HeardCore/Services.swift index b155f41..63ff8f9 100644 --- a/Sources/HeardCore/Services.swift +++ b/Sources/HeardCore/Services.swift @@ -654,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" 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/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..." From 8537a5c88ef708b9ab76b6c69d278bbb04f904c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 18:02:42 +0000 Subject: [PATCH 3/3] Remove separators between permission rows for visual consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four CardRows were using isLast: index == perms.count - 1, which draws a 0.5pt separator after every non-last row. Due to varying row heights (rows with the Required badge and Grant button are taller than those without), only the separator at the audioCapture→screenCapture boundary landed on a crisp pixel, making it the only visible line. Switch all permission CardRows to isLast: true so no separators are drawn, matching the consistent no-divider appearance the user sees for the other rows. https://claude.ai/code/session_0141TjDnmekRsDDGoBu4jQyT --- Sources/HeardCore/Views.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) } }