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()) startInputMonitoringPolling() case "requestInputMonitoring": requestInputMonitoring(result: result) case "openInputMonitoringSettings": openInputMonitoringSettings(result: result) // -- Local Network ------------------------------------------------------ case "checkLocalNetwork": checkLocalNetwork(result: result) case "triggerLocalNetworkPrompt": triggerLocalNetworkPrompt(result: result) case "checkLocalNetworkAccess": guard let args = call.arguments as? [String: Any], let host = args["host"] as? String, let port = args["port"] as? Int else { result(FlutterError( code: "INVALID_ARGS", message: "checkLocalNetworkAccess requires host (String) and port (Int)", details: nil)) return } checkLocalNetworkAccess(host: host, port: port, 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 // Check for DNS policy-denied error (kDNSServiceErr_PolicyDenied = -65570). // This is the canonical signal that the user denied the Local Network prompt. if case .dns(let dnsError) = error, dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) { 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(let error): // The browser is waiting for network. If the specific DNS error // is kDNSServiceErr_PolicyDenied, the user explicitly denied // the Local Network prompt — report immediately. if case .dns(let dnsError) = error, dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) { if !resolved { resolved = true result("Denied") self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [ "state": "Denied", ]) } browser.cancel() } // Otherwise the system dialog may be showing — wait for // .ready, .failed, or the timeout. 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") } } /// Probe whether Local Network access is currently denied for a specific /// host:port by creating a short-lived NWConnection and checking /// `unsatisfiedReason == .localNetworkDenied`. This does NOT trigger a /// new system prompt — it is a read-only check. private func checkLocalNetworkAccess( host: String, port: Int, result: @escaping FlutterResult ) { if #available(macOS 15.0, *) { checkLocalNetworkAccessImpl(host: host, port: port, result: result) } else { result("Unsupported") } } @available(macOS 15.0, *) private func checkLocalNetworkAccessImpl( host: String, port: Int, result: @escaping FlutterResult ) { guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else { result("NotDetermined") return } let endpointHost = NWEndpoint.Host(host) let connection = NWConnection( host: endpointHost, port: endpointPort, using: .tcp) let queue = DispatchQueue( label: "app.chanora.macos_permissions.local_network_check") var didComplete = false func finish(_ state: String) { guard !didComplete else { return } didComplete = true connection.cancel() result(state) } connection.stateUpdateHandler = { state in switch state { case .waiting, .failed: if connection.currentPath?.unsatisfiedReason == .localNetworkDenied { finish("Denied") } else { finish("NotDetermined") } case .ready: finish("Granted") case .cancelled: finish("NotDetermined") case .setup, .preparing: break @unknown default: break } } connection.start(queue: queue) DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { if !didComplete { if connection.currentPath?.unsatisfiedReason == .localNetworkDenied { finish("Denied") } else { finish("NotDetermined") } } } } // ========================================================================= // 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")) // Register the macOS audio-lifecycle MethodChannel handler (closes the iOS/macOS asymmetry in SysRS-051). MacOSAudioLifecycle.register(with: flutterViewController.registrar(forPlugin: "MacOSAudioLifecycle")) super.awakeFromNib() } }