import AppKit import ApplicationServices import AVFoundation import Combine /// Reads the three macOS permissions and keeps them current: on every app activation and while polling is on. @MainActor final class PermissionMonitor: ObservableObject { enum Kind: CaseIterable, Identifiable { case microphone, accessibility, inputMonitoring var id: Self { self } var title: String { switch self { case .microphone: Strings.microphone case .accessibility: Strings.accessibility case .inputMonitoring: Strings.inputMonitoring } } fileprivate var pane: String { switch self { case .microphone: "Privacy_Microphone" case .accessibility: "Privacy_Accessibility" case .inputMonitoring: "Privacy_ListenEvent" } } } @Published private(set) var microphone = false @Published private(set) var accessibility = false @Published private(set) var inputMonitoring = false private var timer: Timer? private var activation: AnyCancellable? init() { refresh() activation = NotificationCenter.default .publisher(for: NSApplication.didBecomeActiveNotification) .sink { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } } } var allGranted: Bool { microphone && accessibility && inputMonitoring } func isGranted(_ kind: Kind) -> Bool { switch kind { case .microphone: microphone case .accessibility: accessibility case .inputMonitoring: inputMonitoring } } func refresh() { microphone = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized accessibility = AXIsProcessTrusted() inputMonitoring = CGPreflightListenEventAccess() } /// Asks macOS for every permission that is still missing (each shows its own system prompt). func requestMissing() async { if !microphone { _ = await AVCaptureDevice.requestAccess(for: .audio) } if !inputMonitoring { _ = CGRequestListenEventAccess() } if !accessibility { _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) } refresh() } func openSettings(for kind: Kind) { if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(kind.pane)") { NSWorkspace.shared.open(url) } } /// Polls while a view that shows the lamps is visible, so granting a permission shows up without a click. func startPolling(every interval: TimeInterval = 2) { guard timer == nil else { return } timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } } } func stopPolling() { timer?.invalidate() timer = nil } }