diff --git a/Sources/KeyPathAppKit/Services/Permissions/PermissionGate.swift b/Sources/KeyPathAppKit/Services/Permissions/PermissionGate.swift index 4e39f3ce0..cf6d846b4 100644 --- a/Sources/KeyPathAppKit/Services/Permissions/PermissionGate.swift +++ b/Sources/KeyPathAppKit/Services/Permissions/PermissionGate.swift @@ -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)) } diff --git a/Sources/KeyPathAppKit/Services/Permissions/PermissionRequestService.swift b/Sources/KeyPathAppKit/Services/Permissions/PermissionRequestService.swift index a8fb93983..d4c5231a7 100644 --- a/Sources/KeyPathAppKit/Services/Permissions/PermissionRequestService.swift +++ b/Sources/KeyPathAppKit/Services/Permissions/PermissionRequestService.swift @@ -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. @@ -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() { @@ -97,13 +106,15 @@ 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 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") } @@ -111,14 +122,22 @@ public final class PermissionRequestService { } /// 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() { @@ -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") } @@ -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) } } diff --git a/Sources/KeyPathInstallationWizard/UI/Pages/WizardAccessibilityPage.swift b/Sources/KeyPathInstallationWizard/UI/Pages/WizardAccessibilityPage.swift index 0cb04389d..6a5750fe2 100644 --- a/Sources/KeyPathInstallationWizard/UI/Pages/WizardAccessibilityPage.swift +++ b/Sources/KeyPathInstallationWizard/UI/Pages/WizardAccessibilityPage.swift @@ -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 = diff --git a/Sources/KeyPathInstallationWizard/UI/Pages/WizardInputMonitoringPage.swift b/Sources/KeyPathInstallationWizard/UI/Pages/WizardInputMonitoringPage.swift index da50d54b7..2ab8c3567 100644 --- a/Sources/KeyPathInstallationWizard/UI/Pages/WizardInputMonitoringPage.swift +++ b/Sources/KeyPathInstallationWizard/UI/Pages/WizardInputMonitoringPage.swift @@ -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() diff --git a/Sources/KeyPathWizardCore/WizardServiceProtocols.swift b/Sources/KeyPathWizardCore/WizardServiceProtocols.swift index 79a3f95c6..7b35b8cc8 100644 --- a/Sources/KeyPathWizardCore/WizardServiceProtocols.swift +++ b/Sources/KeyPathWizardCore/WizardServiceProtocols.swift @@ -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 diff --git a/docs/adr/adr-001-oracle-pattern.md b/docs/adr/adr-001-oracle-pattern.md index 072287685..45f49912b 100644 --- a/docs/adr/adr-001-oracle-pattern.md +++ b/docs/adr/adr-001-oracle-pattern.md @@ -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)