import Cocoa import FlutterMacOS import CoreGraphics import Network import UserNotifications // --------------------------------------------------------------------------- // MacOSPermissionsHandler // // Native-side MethodChannel handler for macOS-specific permissions // that the Flutter `permission_handler` plugin does not cover: // // • Input Monitoring (CGPreflightListenEventAccess / // CGRequestListenEventAccess) for global PTT. // • Local Network (NWBrowser trigger for _ts3._tcp) so macOS 15+ // shows the Local Network Privacy prompt. // • Notifications (UNUserNotificationCenter authorization). // // Channel name: `app.chanora/macos_permissions` // // Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091. // --------------------------------------------------------------------------- /// Polling interval for Input Monitoring state changes. /// TCC does not emit a callback when the user toggles Input Monitoring /// in System Settings, so we poll at a reasonable cadence. private let kInputMonitoringPollInterval: TimeInterval = 2.0 class MacOSPermissionsHandler: NSObject, FlutterPlugin { private var channel: FlutterMethodChannel? private var inputMonitoringTimer: Timer? private var lastInputMonitoringState: String = "Unknown" // -- FlutterPlugin ------------------------------------------------------- static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel( name: "app.chanora/macos_permissions", binaryMessenger: registrar.messenger ) let instance = MacOSPermissionsHandler() instance.channel = channel registrar.addMethodCallDelegate(instance, channel: channel) } func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { // -- Input Monitoring --------------------------------------------------- case "checkInputMonitoring": result(inputMonitoringStateString()) case "requestInputMonitoring": requestInputMonitoring(result: result) case "openInputMonitoringSettings": openInputMonitoringSettings(result: result) // -- Local Network ------------------------------------------------------ case "checkLocalNetwork": checkLocalNetwork(result: result) case "triggerLocalNetworkPrompt": triggerLocalNetworkPrompt(result: result) // -- Notifications ------------------------------------------------------ case "checkNotifications": checkNotifications(result: result) case "requestNotifications": requestNotifications(result: result) default: result(FlutterMethodNotImplemented) } } // ========================================================================= // Input Monitoring // ========================================================================= /// Returns the current Input Monitoring state as a string /// consumable by the Dart side: "Granted", "Denied", "NotDetermined". private func inputMonitoringStateString() -> String { // CGPreflightListenEventAccess returns true when access is already // granted. On macOS 10.15+ it returns false when denied or not yet // determined — we cannot distinguish those two without attempting // CGRequestListenEventAccess, so we conservatively report // "NotDetermined" when preflight returns false. The Dart side // treats both "Denied" and "NotDetermined" as L0Focused. if CGPreflightListenEventAccess() { return "Granted" } return "NotDetermined" } /// Request Input Monitoring permission via /// `CGRequestListenEventAccess()`. On macOS 13+ this opens /// System Settings → Privacy & Security → Input Monitoring. private func requestInputMonitoring(result: @escaping FlutterResult) { // CGRequestListenEventAccess shows the system prompt. // It returns true if access was already granted or becomes // granted synchronously (rare). Most of the time it returns // false and the user must toggle the switch manually. let granted = CGRequestListenEventAccess() let state = granted ? "Granted" : "NotDetermined" lastInputMonitoringState = state result(state) // Start polling so we detect when the user grants in Settings. startInputMonitoringPolling() } /// Open System Settings → Privacy & Security → Input Monitoring /// so the user can manually enable the app. private func openInputMonitoringSettings(result: @escaping FlutterResult) { if let url = URL( string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" ) { NSWorkspace.shared.open(url) } result(nil) } /// Start a periodic timer that checks Input Monitoring state and /// notifies the Dart side when it changes. private func startInputMonitoringPolling() { // Don't start a second timer if one is already running. guard inputMonitoringTimer == nil else { return } lastInputMonitoringState = inputMonitoringStateString() inputMonitoringTimer = Timer.scheduledTimer( withTimeInterval: kInputMonitoringPollInterval, repeats: true ) { [weak self] _ in self?.pollInputMonitoring() } } private func pollInputMonitoring() { let current = inputMonitoringStateString() guard current != lastInputMonitoringState else { return } lastInputMonitoringState = current // Notify the Dart side via the inbound method. channel?.invokeMethod("inputMonitoringStateChanged", arguments: [ "state": current, ]) } // ========================================================================= // Local Network // ========================================================================= /// Check whether Local Network access is available. /// On macOS 14 and earlier, Local Network Privacy does not exist, /// so we report "Unsupported". On macOS 15+, we attempt a brief /// NWBrowser scan and report based on the result. private func checkLocalNetwork(result: @escaping FlutterResult) { if #available(macOS 15.0, *) { // We cannot synchronously determine the Local Network state // without actually using the network. Report "NotDetermined" // and let triggerLocalNetworkPrompt resolve the actual state. result("NotDetermined") } else { result("Unsupported") } } /// Trigger the Local Network Privacy prompt by starting a brief /// NWBrowser for `_ts3._tcp`. On macOS 15+, this causes the system /// to show the Local Network permission dialog if not already /// determined. /// /// The browser is started and stopped after a short scan window. /// State changes are reported back to Dart via /// `localNetworkStateChanged`. @available(macOS 15.0, *) private func triggerLocalNetworkPromptImpl(result: @escaping FlutterResult) { let bonjourType = "_ts3._tcp" let browserDescriptor = NWBrowser.Descriptor.bonjourWithTXTRecord( type: bonjourType, domain: nil) let browser = NWBrowser(for: browserDescriptor, using: NWParameters.tcp) var resolved = false browser.stateUpdateHandler = { [weak self] (browserState: NWBrowser.State) in switch browserState { case .ready: // Browser started successfully — local network is accessible. if !resolved { resolved = true result("Granted") self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [ "state": "Granted", ]) } browser.cancel() case .failed(let error): if !resolved { resolved = true let code = error.errorCode // POSIX permission-denied or network-down signals that // the user denied the Local Network prompt. if code == ENETDOWN || code == EACCES || code == EPERM { result("Denied") self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [ "state": "Denied", ]) } else { // Network unreachable or other transient error — // don't assume denied. result("NotDetermined") } } browser.cancel() case .waiting: // The browser is waiting for network — this is normal and // may mean the permission dialog is showing. Don't resolve // yet; wait for .ready or .failed or the timeout. break case .setup, .cancelled: break @unknown default: break } } browser.start(queue: DispatchQueue.main) // Timeout: if the browser doesn't resolve within 10 seconds, // report NotDetermined so the Dart side doesn't hang forever. DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { if !resolved { resolved = true result("NotDetermined") browser.cancel() } } } private func triggerLocalNetworkPrompt(result: @escaping FlutterResult) { if #available(macOS 15.0, *) { triggerLocalNetworkPromptImpl(result: result) } else { result("Unsupported") } } // ========================================================================= // Notifications // ========================================================================= private func checkNotifications(result: @escaping FlutterResult) { UNUserNotificationCenter.current().getNotificationSettings { settings in switch settings.authorizationStatus { case .authorized, .provisional: result("Granted") case .denied: result("Denied") case .notDetermined: result("NotDetermined") @unknown default: result("NotDetermined") } } } private func requestNotifications(result: @escaping FlutterResult) { UNUserNotificationCenter.current().requestAuthorization(options: [ .alert, .sound, .badge, ]) { granted, _ in let state = granted ? "Granted" : "Denied" result(state) } } // ========================================================================= // Cleanup // ========================================================================= deinit { inputMonitoringTimer?.invalidate() } } // --------------------------------------------------------------------------- // MainFlutterWindow // --------------------------------------------------------------------------- class MainFlutterWindow: NSWindow { override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) self.titlebarAppearsTransparent = true self.titleVisibility = .hidden self.styleMask.insert(.fullSizeContentView) self.isMovableByWindowBackground = true RegisterGeneratedPlugins(registry: flutterViewController) // Register the macOS permissions MethodChannel handler. MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler")) super.awakeFromNib() } }