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
6 changes: 3 additions & 3 deletions Sources/HeardCore/RosterReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -185,8 +187,6 @@ public enum RosterReader {
}

private static func parseWindowTitle() -> [String] {
guard AXIsProcessTrusted() else { return [] }

let teamsNames: Set<String> = [
"Microsoft Teams",
"Microsoft Teams (work or school)",
Expand Down
28 changes: 21 additions & 7 deletions Sources/HeardCore/Services.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -1148,7 +1148,15 @@ public final class PermissionCenter: ObservableObject {
private var refreshTask: Task<Void, Never>?
// 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
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 19 additions & 8 deletions Sources/HeardCore/TextInjector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions Sources/HeardCore/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
5 changes: 5 additions & 0 deletions scripts/bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
Loading