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
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ final class PermissionGate {
for perm in eval.missingKeyPath {
switch perm {
case .inputMonitoring:
_ = permissionService.requestInputMonitoringPermission()
_ = await permissionService.requestInputMonitoringPermission()
case .accessibility:
_ = permissionService.requestAccessibilityPermission()
_ = await permissionService.requestAccessibilityPermission()
}
try? await Task.sleep(for: .milliseconds(400))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import AppKit
import Foundation
import IOKit.hid
import KeyPathCore
import KeyPathPermissions

/// Centralized utilities for requesting system permissions using Apple's standard APIs.
/// - Requests are debounced to avoid nagging the user.
Expand Down Expand Up @@ -74,14 +75,22 @@ public final class PermissionRequestService {
// MARK: - Public API

/// Request Input Monitoring permission using IOHIDRequestAccess().
/// - Returns: true if already granted (no prompt shown), false otherwise.
/// Permission state reads come from PermissionOracle; IOHIDRequestAccess is only used to prompt.
/// - Returns: true if the Oracle reports granted before or immediately after the prompt.
@discardableResult
public func requestInputMonitoringPermission(ignoreCooldown: Bool = false) -> Bool {
public func requestInputMonitoringPermission(ignoreCooldown: Bool = false) async -> Bool {
// Wizard context guard: block prompts outside wizard/user-initiated flows
guard wizardContextActive else {
AppLogger.shared.warn("⚠️ [PermissionRequest] Blocked IOHIDRequestAccess — not in wizard context")
return false
}

let beforePrompt = await PermissionOracle.shared.currentSnapshot()
if beforePrompt.keyPath.inputMonitoring.isReady {
AppLogger.shared.log("✅ [PermissionRequest] Input Monitoring already granted (Oracle)")
return true
}

// Foreground and cooldown guards
ensureForeground()
if !isForeground() {
Expand All @@ -97,28 +106,38 @@ public final class PermissionRequestService {
markPrompt(lastPromptIMKey)

// IOHIDRequestAccess() automatically shows the system dialog if not granted.
// Returns true if granted, false otherwise.
// Do not use its return value as permission state; refresh the Oracle instead.
AppLogger.shared.log(
"🔐 [PermissionRequest] Requesting Input Monitoring via IOHIDRequestAccess()"
)
let granted = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)
_ = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)
let refreshed = await PermissionOracle.shared.forceRefresh()
let granted = refreshed.keyPath.inputMonitoring.isReady

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use IOHIDRequestAccess when TCC is unverifiable

When KeyPath lacks Full Disk Access, the Oracle's Input Monitoring read can stay .unknown because it falls back to TCC database access and returns nil if the DB cannot be read (PermissionOracle.tccStatus(forBundleID:)). In that common wizard state, a user can accept the IOHIDRequestAccess prompt but this line derives granted only from the refreshed Oracle snapshot, so the request returns false and the Input Monitoring page keeps polling and opens System Settings even though the system prompt succeeded. Keep the Oracle read as authoritative when it can verify, but preserve the IOHIDRequestAccess result as a fallback for the unverifiable/unknown case.

Useful? React with 👍 / 👎.

if granted {
AppLogger.shared.log("✅ [PermissionRequest] Input Monitoring already granted")
AppLogger.shared.log("✅ [PermissionRequest] Input Monitoring granted after Oracle refresh")
} else {
AppLogger.shared.log("⏳ [PermissionRequest] Input Monitoring prompt likely shown")
}
return granted
}

/// Request Accessibility permission using AXIsProcessTrustedWithOptions().
/// - Returns: true if already granted (no prompt shown), false otherwise.
/// Permission state reads come from PermissionOracle; AXIsProcessTrustedWithOptions is only used to prompt.
/// - Returns: true if the Oracle reports granted before or immediately after the prompt.
@discardableResult
public func requestAccessibilityPermission(ignoreCooldown: Bool = false) -> Bool {
public func requestAccessibilityPermission(ignoreCooldown: Bool = false) async -> Bool {
// Wizard context guard: block prompts outside wizard/user-initiated flows
guard wizardContextActive else {
AppLogger.shared.warn("⚠️ [PermissionRequest] Blocked AX prompt — not in wizard context")
return false
}

let beforePrompt = await PermissionOracle.shared.currentSnapshot()
if beforePrompt.keyPath.accessibility.isReady {
AppLogger.shared.log("✅ [PermissionRequest] Accessibility already granted (Oracle)")
return true
}

// Foreground and cooldown guards
ensureForeground()
if !isForeground() {
Expand All @@ -137,9 +156,11 @@ public final class PermissionRequestService {
AppLogger.shared.log(
"🔐 [PermissionRequest] Requesting Accessibility via AXIsProcessTrustedWithOptions()"
)
let trusted = AXIsProcessTrustedWithOptions(options)
_ = AXIsProcessTrustedWithOptions(options)
let refreshed = await PermissionOracle.shared.forceRefresh()
let trusted = refreshed.keyPath.accessibility.isReady
if trusted {
AppLogger.shared.log("✅ [PermissionRequest] Accessibility already granted")
AppLogger.shared.log("✅ [PermissionRequest] Accessibility granted after Oracle refresh")
} else {
AppLogger.shared.log("⏳ [PermissionRequest] Accessibility prompt likely shown")
}
Expand All @@ -148,9 +169,9 @@ public final class PermissionRequestService {

/// Request both permissions (convenience for wizard flows).
func requestAllPermissions() async -> (inputMonitoring: Bool, accessibility: Bool) {
let im = requestInputMonitoringPermission()
let im = await requestInputMonitoringPermission()
try? await Task.sleep(for: .milliseconds(500)) // small delay
let ax = requestAccessibilityPermission()
let ax = await requestAccessibilityPermission()
return (im, ax)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -350,46 +350,46 @@ public struct WizardAccessibilityPage: View {
AppLogger.shared.log("⚠️ [WizardAccessibilityPage] permissionRequestService not configured")
return
}
let alreadyGranted = permissionRequestService.requestAccessibilityPermission(
ignoreCooldown: true
)
if alreadyGranted {
Task { await onRefresh() }
return
}
// Poll for grant (KeyPath + Kanata) using Oracle snapshot
permissionPollingTask?.cancel()
permissionPollingTask = Task { [onRefresh] in
var attempts = 0
let maxAttempts = 30
var lastKeyPathGranted: Bool?
var lastKanataGranted: Bool?
while attempts < maxAttempts {
_ = await WizardSleep.ms(1000)
attempts += 1
let snapshot = await PermissionOracle.shared.forceRefresh()
let kpGranted = snapshot.keyPath.accessibility.isReady
let kaGranted = snapshot.kanata.accessibility.isReady

// Incremental refresh: update UI when either flips, not only when both are ready
if lastKeyPathGranted != kpGranted || lastKanataGranted != kaGranted {
AppLogger.shared.log(
"🔁 [WizardAccessibilityPage] Detected permission change (AX) - KeyPath: \(kpGranted), Kanata: \(kaGranted). Refreshing UI."
)
lastKeyPathGranted = kpGranted
lastKanataGranted = kaGranted
await onRefresh()
}
Task { @MainActor in
let alreadyGranted = await permissionRequestService.requestAccessibilityPermission(
ignoreCooldown: true
)
if alreadyGranted {
await onRefresh()
return
}
// Poll for grant (KeyPath + Kanata) using Oracle snapshot
permissionPollingTask?.cancel()
permissionPollingTask = Task { [onRefresh] in
var attempts = 0
let maxAttempts = 30
var lastKeyPathGranted: Bool?
var lastKanataGranted: Bool?
while attempts < maxAttempts {
_ = await WizardSleep.ms(1000)
attempts += 1
let snapshot = await PermissionOracle.shared.forceRefresh()
let kpGranted = snapshot.keyPath.accessibility.isReady
let kaGranted = snapshot.kanata.accessibility.isReady

// Incremental refresh: update UI when either flips, not only when both are ready
if lastKeyPathGranted != kpGranted || lastKanataGranted != kaGranted {
AppLogger.shared.log(
"🔁 [WizardAccessibilityPage] Detected permission change (AX) - KeyPath: \(kpGranted), Kanata: \(kaGranted). Refreshing UI."
)
lastKeyPathGranted = kpGranted
lastKanataGranted = kaGranted
await onRefresh()
}

if kpGranted, kaGranted {
// Both ready – stop polling
return
if kpGranted, kaGranted {
// Both ready – stop polling
return
}
if Task.isCancelled { return }
}
if Task.isCancelled { return }
}
}
// Fallback: if not granted shortly, open Accessibility settings so the user can toggle
Task { @MainActor in
// Fallback: if not granted shortly, open Accessibility settings so the user can toggle
_ = await WizardSleep.ms(1500) // 1.5s
let snapshot = await PermissionOracle.shared.forceRefresh()
let granted =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,19 +421,19 @@ public struct WizardInputMonitoringPage: View {
AppLogger.shared.log("⚠️ [WizardInputMonitoringPage] permissionRequestService not configured")
return
}
let alreadyGranted = permissionRequestService.requestInputMonitoringPermission(
ignoreCooldown: true
)
if alreadyGranted {
Task { await onRefresh() }
return
}
Task { @MainActor in
let alreadyGranted = await permissionRequestService.requestInputMonitoringPermission(
ignoreCooldown: true
)
if alreadyGranted {
await onRefresh()
return
}

// Poll for grant (KeyPath + Kanata) using Oracle snapshot
startPermissionPolling(for: .inputMonitoring)
// Poll for grant (KeyPath + Kanata) using Oracle snapshot
startPermissionPolling(for: .inputMonitoring)

// Fallback: if still not granted shortly after, open System Settings panel
Task { @MainActor in
// Fallback: if still not granted shortly after, open System Settings panel
for _ in 0 ..< 6 { // ~1.5s at 250ms
_ = await WizardSleep.ms(250)
let snapshot = await PermissionOracle.shared.forceRefresh()
Expand Down
4 changes: 2 additions & 2 deletions Sources/KeyPathWizardCore/WizardServiceProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ public protocol WizardFullDiskAccessChecking: AnyObject, Sendable {

@MainActor
public protocol WizardPermissionRequesting: AnyObject, Sendable {
func requestInputMonitoringPermission(ignoreCooldown: Bool) -> Bool
func requestAccessibilityPermission(ignoreCooldown: Bool) -> Bool
func requestInputMonitoringPermission(ignoreCooldown: Bool) async -> Bool
func requestAccessibilityPermission(ignoreCooldown: Bool) async -> Bool
}

// MARK: - PermissionService Protocol
Expand Down
10 changes: 9 additions & 1 deletion docs/adr/adr-001-oracle-pattern.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,19 @@ Create `PermissionOracle` as the **single source of truth** for all permission d

```swift
// ✅ CORRECT
let status = PermissionOracle.shared.checkInputMonitoring()
let snapshot = await PermissionOracle.shared.currentSnapshot()
let status = snapshot.keyPath.inputMonitoring

// ❌ WRONG - bypassing Oracle
let status = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)
```

Permission request flows are the only exception to the "no direct permission API"
rule. `PermissionRequestService` may call prompt-triggering system APIs such as
`IOHIDRequestAccess` and `AXIsProcessTrustedWithOptions`, but those calls are
write/prompt side effects only. Any "already granted" decisions and any state
returned to callers must come from `PermissionOracle`, with a forced refresh
immediately after a prompt attempt.

## Related
- [ADR-006: Apple API Priority](adr-006-apple-api-priority.md)
Loading