From 8cb5a7a25819b035f812b26d405bfea2a872d10b Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Thu, 4 Jun 2026 08:21:07 +0900 Subject: [PATCH] feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications Add MacOSPermissionsService (Dart) + native MethodChannel handler (Swift) for macOS-specific permissions not covered by permission_handler: - Input Monitoring (CGPreflightListenEventAccess / CGRequestListenEventAccess) for global PTT via Event Tap - Local Network Privacy prompt (NWBrowser for _ts3._tcp, macOS 15+) - Notifications (UNUserNotificationCenter authorization) Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091 Changes: - Info.plist: add NSBonjourServices array with _ts3._tcp - macos_permissions_service.dart: Dart service with MethodChannel, ValueNotifier states, PTT capability derivation (L0Focused / L1MacOSEventTap), non-macOS short-circuit - MainFlutterWindow.swift: native handler registered as FlutterPlugin, Input Monitoring check/request/polling, NWBrowser trigger with denial detection, UNUserNotificationCenter request - main.dart: wire service into bootstrap lifecycle, listen for PTT capability changes from Input Monitoring state - macos_permissions_service_test.dart: 17 unit tests covering inbound state changes, outbound calls, lifecycle, error handling, platform behavior (179/179 full suite pass) --- apps/chanora_flutter/lib/main.dart | 42 ++ .../services/macos_permissions_service.dart | 498 +++++++++++++++++ apps/chanora_flutter/macos/Runner/Info.plist | 4 + .../macos/Runner/MainFlutterWindow.swift | 291 +++++++++- .../macos_permissions_service_test.dart | 505 ++++++++++++++++++ 5 files changed, 1339 insertions(+), 1 deletion(-) create mode 100644 apps/chanora_flutter/lib/services/macos_permissions_service.dart create mode 100644 apps/chanora_flutter/test/services/macos_permissions_service_test.dart diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 46a1254..a2278d3 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -23,6 +23,7 @@ import 'services/audio_lifecycle_service.dart'; import 'services/channel_join_error_mapper.dart'; import 'services/connection_phase_state.dart'; import 'services/ios_permissions_service.dart'; +import 'services/macos_permissions_service.dart'; import 'services/prefetch_debouncer.dart'; import 'services/snapshot_state_mapper.dart'; import 'services/ts3_server_link.dart'; @@ -378,6 +379,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { final AndroidPermissionsService _androidPermissions = AndroidPermissionsService(); final IosPermissionsService _iosPermissions = IosPermissionsService(); + // SRS-198 / SRS-297 / SRS-300: macOS Input Monitoring, Local Network, + // and Notifications permission service. On non-macOS hosts the service + // short-circuits to "granted" / "unsupported" and never wires the + // MethodChannel. + final MacOSPermissionsService _macOSPermissions = + MacOSPermissionsService(); final UiPreferencesService _uiPreferences = const UiPreferencesService(); @override @@ -399,6 +406,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _iosPermissions.recordAudioState.addListener( _onRecordAudioPermissionChanged, ); + // SRS-198 / SRS-297 / SRS-300: start macOS permission service. + // On non-macOS this is a no-op. On macOS, it checks Input + // Monitoring state and begins polling for changes so the PTT + // capability badge upgrades from L0Focused → L1MacOSEventTap + // when the user grants the permission in System Settings. + _macOSPermissions.start(); + _macOSPermissions.checkInitialStates(); + _macOSPermissions.pttCapabilityState.addListener( + _onMacOSPttCapabilityChanged, + ); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_requestRecordAudioOnStartup()); }); @@ -498,6 +515,26 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } } + /// SRS-198 / SRS-297 / SRS-300 / SDD-091: React to macOS Input + /// Monitoring state changes by updating the PTT capability level. + /// When the macOS permissions service detects that Input Monitoring + /// has been granted (via polling), it emits `L1MacOSEventTap` which + /// overrides the bridge-emitted `L0Focused` default. + void _onMacOSPttCapabilityChanged() { + final level = _macOSPermissions.pttCapabilityState.value; + // Only override if the macOS service has a resolved state + // different from the current bridge-emitted level, and only + // on macOS. + if (_isMacOS && level != _pttLevel) { + setState(() { + _pttLevel = level; + if (level == 'L1MacOSEventTap') { + _pttBackendId = 'macos-event-tap'; + } + }); + } + } + Future _clearPermissionHardMute() async { if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return; _permissionHardMuteClearInFlight = true; @@ -936,11 +973,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { _iosPermissions.recordAudioState.removeListener( _onRecordAudioPermissionChanged, ); + // SRS-198 / SRS-297: detach macOS permission listeners. + _macOSPermissions.pttCapabilityState.removeListener( + _onMacOSPttCapabilityChanged, + ); // SDD-106: detach the Kotlin -> Dart MethodChannel handler so a // late invokeMethod from the platform side cannot land on this // disposed state. _androidPermissions.stop(); _iosPermissions.stop(); + _macOSPermissions.stop(); super.dispose(); } diff --git a/apps/chanora_flutter/lib/services/macos_permissions_service.dart b/apps/chanora_flutter/lib/services/macos_permissions_service.dart new file mode 100644 index 0000000..c49a08a --- /dev/null +++ b/apps/chanora_flutter/lib/services/macos_permissions_service.dart @@ -0,0 +1,498 @@ +/// macOS permission integration for Input Monitoring, Local Network, +/// and Notifications. +/// +/// Trace: +/// - SRS-198 (Push-to-talk system permission acquisition). +/// - SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS). +/// - SysRS-166 (Desktop notifications). +/// - SDD-091 (PTT capability badge — live capability level). +/// +/// Responsibilities: +/// * Subscribe to the Swift-side `MethodChannel` +/// `app.chanora/macos_permissions` for inbound state-change +/// invocations emitted by the native handler in +/// `MainFlutterWindow.swift` (Swift → Dart). +/// * Provide imperative Dart → Swift entry points for checking and +/// requesting Input Monitoring, triggering the Local Network +/// prompt, and requesting notification authorization. +/// * Expose the latest resolved states as [ValueListenable] so UI +/// surfaces (PTT capability badge, permission banners, connection +/// error messages) can react without polling. +/// +/// ## Non-macOS short-circuit +/// +/// On Android / iOS / Linux / Windows / web, none of these macOS- +/// specific permissions exist. The MethodChannel is therefore never +/// constructed off-macOS. Each state listenable stays at its default +/// "granted" / "not needed" value so all consumers become no-ops. +/// +/// ## No global statics +/// +/// Following the pattern established by `AndroidPermissionsService` +/// (SDD-106) and `BackIntentService` (SDD-028), this class is +/// constructor-injected. The host app instantiates one instance at +/// startup and passes it through the widget tree. +library; + +import 'dart:async'; +import 'dart:developer' as developer; +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +// --------------------------------------------------------------------------- +// Channel constants +// --------------------------------------------------------------------------- + +/// MethodChannel name shared with Swift `MacOSPermissionsHandler`. +@visibleForTesting +const String macOSPermissionsChannelName = + 'app.chanora/macos_permissions'; + +// Outbound (Dart → Swift) method names. +@visibleForTesting +const String methodCheckInputMonitoring = 'checkInputMonitoring'; +@visibleForTesting +const String methodRequestInputMonitoring = 'requestInputMonitoring'; +@visibleForTesting +const String methodOpenInputMonitoringSettings = + 'openInputMonitoringSettings'; +@visibleForTesting +const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt'; +@visibleForTesting +const String methodCheckLocalNetwork = 'checkLocalNetwork'; +@visibleForTesting +const String methodRequestNotifications = 'requestNotifications'; +@visibleForTesting +const String methodCheckNotifications = 'checkNotifications'; + +// Inbound (Swift → Dart) method names. +@visibleForTesting +const String methodInputMonitoringStateChanged = + 'inputMonitoringStateChanged'; +@visibleForTesting +const String methodLocalNetworkStateChanged = 'localNetworkStateChanged'; + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +/// Discrete states for macOS-specific permissions. +enum MacOSPermissionState { + /// Permission granted. + granted, + + /// Permission denied by the user. + denied, + + /// Permission has not yet been determined (first launch before + /// any prompt, or the system returned an unexpected value). + notDetermined, + + /// No resolved state yet (cold launch before the first emission, + /// or non-macOS host before short-circuit). Consumers treat this + /// as "not yet known". + unknown, +} + +/// Local Network permission states, extended to cover macOS 14 and +/// earlier where Local Network Privacy does not exist. +enum MacOSLocalNetworkState { + /// Permission granted or the Local Network prompt was satisfied. + granted, + + /// Permission explicitly denied by the user (macOS 15+ only). + denied, + + /// No prompt shown yet. + notDetermined, + + /// Running on macOS 14 or earlier where Local Network Privacy + /// does not apply. Consumers treat this as "granted". + unsupported, + + /// No resolved state yet. + unknown, +} + +// --------------------------------------------------------------------------- +// State parsing helpers +// --------------------------------------------------------------------------- + +MacOSPermissionState _parsePermissionState(String? raw) { + switch (raw) { + case 'Granted': + return MacOSPermissionState.granted; + case 'Denied': + return MacOSPermissionState.denied; + case 'NotDetermined': + return MacOSPermissionState.notDetermined; + default: + return MacOSPermissionState.unknown; + } +} + +MacOSLocalNetworkState _parseLocalNetworkState(String? raw) { + switch (raw) { + case 'Granted': + return MacOSLocalNetworkState.granted; + case 'Denied': + return MacOSLocalNetworkState.denied; + case 'NotDetermined': + return MacOSLocalNetworkState.notDetermined; + case 'Unsupported': + return MacOSLocalNetworkState.unsupported; + default: + return MacOSLocalNetworkState.unknown; + } +} + +// --------------------------------------------------------------------------- +// PTT capability level mapping +// --------------------------------------------------------------------------- + +/// Maps the Input Monitoring state to the PTT capability level string +/// consumed by [PttCapabilityBadge]. +/// +/// Trace: SDD-091 (capability badge); desktop-ptt-architecture.md +/// (macOS Event Tap strategy). +String _pttCapabilityLevel(MacOSPermissionState inputMonitoring) { + switch (inputMonitoring) { + case MacOSPermissionState.granted: + return 'L1MacOSEventTap'; + case MacOSPermissionState.denied: + case MacOSPermissionState.notDetermined: + case MacOSPermissionState.unknown: + return 'L0Focused'; + } +} + +// --------------------------------------------------------------------------- +// MacOSPermissionsService +// --------------------------------------------------------------------------- + +/// Dart-side integration for macOS permission state. +/// +/// Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091. +class MacOSPermissionsService { + /// Construct a service bound to [channel]. Injected for testability; + /// production code uses the default channel keyed on + /// [macOSPermissionsChannelName]. + MacOSPermissionsService({MethodChannel? channel}) + : _channel = channel ?? + (_isMacOS + ? const MethodChannel(macOSPermissionsChannelName) + : null); + + /// Platform-detection seam. Web counts as non-macOS. + static bool get _isMacOS { + if (kIsWeb) return false; + return Platform.isMacOS; + } + + final MethodChannel? _channel; + bool _started = false; + + // -- Input Monitoring ----------------------------------------------------- + + final ValueNotifier _inputMonitoringState = + ValueNotifier( + // Non-macOS: granted so consumers are no-ops. + _isMacOS + ? MacOSPermissionState.unknown + : MacOSPermissionState.granted, + ); + + /// Latest known Input Monitoring permission state. + /// + /// On macOS this drives the PTT capability level: `granted` → + /// `L1MacOSEventTap` (global PTT via Event Tap); anything else → + /// `L0Focused` (focused-only PTT). + ValueListenable get inputMonitoringState => + _inputMonitoringState; + + // -- Local Network -------------------------------------------------------- + + final ValueNotifier _localNetworkState = + ValueNotifier( + _isMacOS + ? MacOSLocalNetworkState.unknown + : MacOSLocalNetworkState.unsupported, + ); + + /// Latest known Local Network permission state. + /// + /// On macOS 15+ (Sequoia) this reflects the Local Network Privacy + /// TCC permission. On macOS 14 and earlier, the value is + /// [MacOSLocalNetworkState.unsupported] (no prompt needed). + ValueListenable get localNetworkState => + _localNetworkState; + + // -- Notifications -------------------------------------------------------- + + final ValueNotifier _notificationState = + ValueNotifier( + _isMacOS + ? MacOSPermissionState.unknown + : MacOSPermissionState.granted, + ); + + /// Latest known notification authorization state. + ValueListenable get notificationState => + _notificationState; + + // -- PTT capability (derived) --------------------------------------------- + + final ValueNotifier _pttCapabilityState = + ValueNotifier( + _pttCapabilityLevel( + _isMacOS ? MacOSPermissionState.unknown : MacOSPermissionState.granted, + ), + ); + + /// Derived PTT capability level string, ready for consumption by + /// [PttCapabilityBadge]. Updates automatically when Input Monitoring + /// state changes. + /// + /// Returns `"L1MacOSEventTap"` when Input Monitoring is granted, + /// `"L0Focused"` otherwise. + ValueListenable get pttCapabilityState => _pttCapabilityState; + + // -- Lifecycle ------------------------------------------------------------ + + /// Start listening for state updates from Swift. Idempotent. + /// + /// On non-macOS this is a no-op. + /// + /// Note: this only registers the inbound handler. Call + /// [checkInitialStates] afterward to eagerly query the current + /// permission states from the native side. + void start() { + if (_started) return; + _started = true; + final ch = _channel; + if (ch == null) return; + ch.setMethodCallHandler(_handle); + } + + /// Eagerly query the current permission states from the native + /// side. Call this after [start] so the PTT capability badge + /// shows the correct level on the first frame. + /// + /// On non-macOS this is a no-op. + void checkInitialStates() { + final ch = _channel; + if (ch == null) return; + unawaited(_checkInputMonitoring()); + unawaited(_checkLocalNetwork()); + unawaited(_checkNotifications()); + } + + /// Stop listening. Idempotent. + void stop() { + if (!_started) return; + _started = false; + final ch = _channel; + if (ch == null) return; + ch.setMethodCallHandler(null); + } + + // -- Inbound handler (Swift → Dart) -------------------------------------- + + Future _handle(MethodCall call) async { + switch (call.method) { + case methodInputMonitoringStateChanged: + final args = call.arguments; + if (args is Map) { + final state = _parsePermissionState(args['state'] as String?); + _inputMonitoringState.value = state; + _pttCapabilityState.value = _pttCapabilityLevel(state); + } + case methodLocalNetworkStateChanged: + final args = call.arguments; + if (args is Map) { + _localNetworkState.value = + _parseLocalNetworkState(args['state'] as String?); + } + default: + break; + } + return null; + } + + // -- Outbound: Input Monitoring ------------------------------------------- + + /// Check the current Input Monitoring permission state without + /// triggering a system prompt. + Future _checkInputMonitoring() async { + final ch = _channel; + if (ch == null) return MacOSPermissionState.granted; + try { + final raw = + await ch.invokeMethod(methodCheckInputMonitoring); + final state = _parsePermissionState(raw); + _inputMonitoringState.value = state; + _pttCapabilityState.value = _pttCapabilityLevel(state); + return state; + } catch (e, st) { + developer.log( + 'checkInputMonitoring failed', + name: 'MacOSPermissionsService', + error: e, + stackTrace: st, + ); + return _inputMonitoringState.value; + } + } + + /// Request Input Monitoring permission. On macOS this calls + /// `CGRequestListenEventAccess()` which shows a system dialog + /// or opens System Settings (depending on macOS version). + /// + /// Returns the new state after the request. + Future requestInputMonitoring() async { + final ch = _channel; + if (ch == null) return MacOSPermissionState.granted; + try { + final raw = await ch.invokeMethod( + methodRequestInputMonitoring, + ); + final state = _parsePermissionState(raw); + _inputMonitoringState.value = state; + _pttCapabilityState.value = _pttCapabilityLevel(state); + return state; + } catch (e, st) { + developer.log( + 'requestInputMonitoring failed', + name: 'MacOSPermissionsService', + error: e, + stackTrace: st, + ); + return _inputMonitoringState.value; + } + } + + /// Open System Settings → Privacy & Security → Input Monitoring + /// so the user can manually grant the permission for the + /// permanently-denied case (TCC drag-based permission cannot be + /// programmatically granted). + Future openInputMonitoringSettings() async { + final ch = _channel; + if (ch == null) return; + try { + await ch.invokeMethod(methodOpenInputMonitoringSettings); + } catch (e, st) { + developer.log( + 'openInputMonitoringSettings failed', + name: 'MacOSPermissionsService', + error: e, + stackTrace: st, + ); + } + } + + // -- Outbound: Local Network ---------------------------------------------- + + Future _checkLocalNetwork() async { + final ch = _channel; + if (ch == null) return MacOSLocalNetworkState.unsupported; + try { + final raw = await ch.invokeMethod(methodCheckLocalNetwork); + final state = _parseLocalNetworkState(raw); + _localNetworkState.value = state; + return state; + } catch (e, st) { + developer.log( + 'checkLocalNetwork failed', + name: 'MacOSPermissionsService', + error: e, + stackTrace: st, + ); + return _localNetworkState.value; + } + } + + /// Trigger the Local Network permission prompt by starting a + /// brief `NWBrowser` scan for `_ts3._tcp`. On macOS 15+ this + /// shows the system Local Network Privacy dialog. On earlier + /// versions, this is a no-op (returns `unsupported`). + Future triggerLocalNetworkPrompt() async { + final ch = _channel; + if (ch == null) return MacOSLocalNetworkState.unsupported; + try { + final raw = await ch.invokeMethod( + methodTriggerLocalNetworkPrompt, + ); + final state = _parseLocalNetworkState(raw); + _localNetworkState.value = state; + return state; + } catch (e, st) { + developer.log( + 'triggerLocalNetworkPrompt failed', + name: 'MacOSPermissionsService', + error: e, + stackTrace: st, + ); + return _localNetworkState.value; + } + } + + // -- Outbound: Notifications ---------------------------------------------- + + Future _checkNotifications() async { + final ch = _channel; + if (ch == null) return MacOSPermissionState.granted; + try { + final raw = + await ch.invokeMethod(methodCheckNotifications); + final state = _parsePermissionState(raw); + _notificationState.value = state; + return state; + } catch (e, st) { + developer.log( + 'checkNotifications failed', + name: 'MacOSPermissionsService', + error: e, + stackTrace: st, + ); + return _notificationState.value; + } + } + + /// Request notification authorization via `UNUserNotificationCenter`. + /// Returns the new state after the system dialog resolves. + Future requestNotifications() async { + final ch = _channel; + if (ch == null) return MacOSPermissionState.granted; + try { + final raw = await ch.invokeMethod( + methodRequestNotifications, + ); + final state = _parsePermissionState(raw); + _notificationState.value = state; + return state; + } catch (e, st) { + developer.log( + 'requestNotifications failed', + name: 'MacOSPermissionsService', + error: e, + stackTrace: st, + ); + return _notificationState.value; + } + } + + // -- Cleanup -------------------------------------------------------------- + + /// Release state notifiers. Test helper; production keeps the + /// service alive for the lifetime of the app. + @visibleForTesting + void dispose() { + stop(); + _inputMonitoringState.dispose(); + _localNetworkState.dispose(); + _notificationState.dispose(); + _pttCapabilityState.dispose(); + } +} diff --git a/apps/chanora_flutter/macos/Runner/Info.plist b/apps/chanora_flutter/macos/Runner/Info.plist index 2f2d7f5..1806c0c 100644 --- a/apps/chanora_flutter/macos/Runner/Info.plist +++ b/apps/chanora_flutter/macos/Runner/Info.plist @@ -40,5 +40,9 @@ Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking. NSLocalNetworkUsageDescription Chanora needs local network access to connect to TeamSpeak-compatible voice servers. + NSBonjourServices + + _ts3._tcp + diff --git a/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift b/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift index c21442f..fc4d502 100644 --- a/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift +++ b/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift @@ -1,5 +1,291 @@ 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() { @@ -15,6 +301,9 @@ class MainFlutterWindow: NSWindow { RegisterGeneratedPlugins(registry: flutterViewController) + // Register the macOS permissions MethodChannel handler. + MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler")) + super.awakeFromNib() } -} \ No newline at end of file +} diff --git a/apps/chanora_flutter/test/services/macos_permissions_service_test.dart b/apps/chanora_flutter/test/services/macos_permissions_service_test.dart new file mode 100644 index 0000000..fb5a324 --- /dev/null +++ b/apps/chanora_flutter/test/services/macos_permissions_service_test.dart @@ -0,0 +1,505 @@ +// SWE.4 unit tests for MacOSPermissionsService — the Dart-side +// integration layer for the Swift `MacOSPermissionsHandler` +// (SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091). +// +// Requirement trace: +// Verification-plan row: SWE4-UV-XXX. +// SRS-198 (Push-to-talk system permission acquisition). +// SRS-297 / SRS-300 (Input Monitoring for global PTT on macOS). +// SysRS-166 (Desktop notifications). +// SDD-091 (PTT capability badge — live capability level). +// +// Strategy: Following the pattern in +// `android_permissions_service_test.dart`, use +// `TestDefaultBinaryMessengerBinding` to (a) capture outbound +// method invocations and (b) inject inbound state-change calls as +// if Swift had emitted them. +// +// Platform note: these tests run on macOS. The static `_isMacOS` +// check inside the service evaluates to true at field-initialization +// time, so the ValueNotifiers seed to `unknown` (not `granted`). +// This is intentional — on macOS the service must query the native +// side before it knows the real state. Tests that inject a channel +// observe `unknown` as the initial value and drive transitions from +// there. The channel-null short-circuit test documents that when +// channel is null, outbound calls are suppressed and each method +// returns its safe default. + +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chanora_flutter/services/macos_permissions_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + /// Whether the test host is actually macOS — the field initializers + /// inside `MacOSPermissionsService` use `Platform.isMacOS` (not + /// injectable), so the initial state values depend on this. + final bool hostIsMacOS = !kIsWeb && Platform.isMacOS; + + late MethodChannel channel; + late List outgoingCalls; + + /// Intercept outbound calls AFTER the service's handler is installed. + /// We store a reference so outbound invokeMethod calls can be + /// captured while inbound handlePlatformMessage calls are routed + /// to the service's handler. + Future Function(MethodCall call)? outgoingResponder; + + Future sendInputMonitoringStateChanged({ + required String state, + }) async { + const codec = StandardMethodCodec(); + final encoded = codec.encodeMethodCall( + MethodCall(methodInputMonitoringStateChanged, { + 'state': state, + }), + ); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage(channel.name, encoded, (_) {}); + } + + Future sendLocalNetworkStateChanged({ + required String state, + }) async { + const codec = StandardMethodCodec(); + final encoded = codec.encodeMethodCall( + MethodCall(methodLocalNetworkStateChanged, { + 'state': state, + }), + ); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage(channel.name, encoded, (_) {}); + } + + setUp(() { + channel = const MethodChannel(macOSPermissionsChannelName); + outgoingCalls = []; + outgoingResponder = null; + + // The mock handler captures outbound calls AND delegates inbound + // platform messages to the service handler when set. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + outgoingCalls.add(call); + final r = outgoingResponder; + if (r != null) return r(call); + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + // =========================================================================== + // Initial state + // =========================================================================== + + test( + 'SWE4-UV / SRS-297: a fresh service exposes a deterministic initial ' + 'inputMonitoringState that matches the host platform', + () { + final svc = MacOSPermissionsService(channel: channel); + if (hostIsMacOS) { + // On macOS the service hasn't queried the native side yet. + expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown); + expect(svc.pttCapabilityState.value, 'L0Focused'); + } else { + // On non-macOS the static check short-circuits to granted. + expect(svc.inputMonitoringState.value, MacOSPermissionState.granted); + expect(svc.pttCapabilityState.value, 'L1MacOSEventTap'); + } + svc.dispose(); + }, + ); + + // =========================================================================== + // Input Monitoring — inbound state changes + // =========================================================================== + + test( + 'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with ' + 'state=Granted transitions inputMonitoringState and PTT capability', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + // Drive away from the initial value first. + await sendInputMonitoringStateChanged(state: 'Denied'); + expect(svc.inputMonitoringState.value, MacOSPermissionState.denied); + expect(svc.pttCapabilityState.value, 'L0Focused'); + + var notified = 0; + void listener() => notified++; + svc.inputMonitoringState.addListener(listener); + + await sendInputMonitoringStateChanged(state: 'Granted'); + + expect(svc.inputMonitoringState.value, MacOSPermissionState.granted); + expect(svc.pttCapabilityState.value, 'L1MacOSEventTap'); + expect(notified, greaterThanOrEqualTo(1)); + + svc.inputMonitoringState.removeListener(listener); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with ' + 'state=Denied transitions to denied and L0Focused', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + await sendInputMonitoringStateChanged(state: 'Denied'); + + expect(svc.inputMonitoringState.value, MacOSPermissionState.denied); + expect(svc.pttCapabilityState.value, 'L0Focused'); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with ' + 'state=NotDetermined transitions to notDetermined and L0Focused', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + await sendInputMonitoringStateChanged(state: 'NotDetermined'); + + expect( + svc.inputMonitoringState.value, + MacOSPermissionState.notDetermined, + ); + expect(svc.pttCapabilityState.value, 'L0Focused'); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SRS-297: inbound inputMonitoringStateChanged with ' + 'malformed state parses to unknown and L0Focused', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + // Start from a known baseline. + await sendInputMonitoringStateChanged(state: 'Granted'); + expect(svc.inputMonitoringState.value, MacOSPermissionState.granted); + + await sendInputMonitoringStateChanged(state: 'Bogus'); + + expect(svc.inputMonitoringState.value, MacOSPermissionState.unknown); + expect(svc.pttCapabilityState.value, 'L0Focused'); + svc.dispose(); + }, + ); + + // =========================================================================== + // Local Network — inbound state changes + // =========================================================================== + + test( + 'SWE4-UV / SRS-300: inbound localNetworkStateChanged with ' + 'state=Granted transitions localNetworkState', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + await sendLocalNetworkStateChanged(state: 'Granted'); + + expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SRS-300: inbound localNetworkStateChanged with ' + 'state=Denied transitions localNetworkState', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + await sendLocalNetworkStateChanged(state: 'Denied'); + + expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SRS-300: inbound localNetworkStateChanged with ' + 'state=Unsupported transitions localNetworkState', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + await sendLocalNetworkStateChanged(state: 'Unsupported'); + + expect( + svc.localNetworkState.value, + MacOSLocalNetworkState.unsupported, + ); + svc.dispose(); + }, + ); + + // =========================================================================== + // Outbound method calls + // =========================================================================== + + test( + 'SWE4-UV / SRS-297: requestInputMonitoring() emits outbound ' + 'requestInputMonitoring MethodCall; returns the platform response', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + outgoingResponder = (call) async { + if (call.method == methodRequestInputMonitoring) { + return 'Granted'; + } + return null; + }; + + final result = await svc.requestInputMonitoring(); + + expect( + outgoingCalls.where((c) => c.method == methodRequestInputMonitoring), + hasLength(1), + reason: 'requestInputMonitoring must be called', + ); + expect(result, MacOSPermissionState.granted); + expect(svc.inputMonitoringState.value, MacOSPermissionState.granted); + expect(svc.pttCapabilityState.value, 'L1MacOSEventTap'); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() emits outbound ' + 'triggerLocalNetworkPrompt MethodCall; returns the platform response', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + outgoingResponder = (call) async { + if (call.method == methodTriggerLocalNetworkPrompt) { + return 'Granted'; + } + return null; + }; + + final result = await svc.triggerLocalNetworkPrompt(); + + expect( + outgoingCalls + .where((c) => c.method == methodTriggerLocalNetworkPrompt), + hasLength(1), + reason: 'triggerLocalNetworkPrompt must be called', + ); + expect(result, MacOSLocalNetworkState.granted); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SysRS-166: requestNotifications() emits outbound ' + 'requestNotifications MethodCall; returns the platform response', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + outgoingResponder = (call) async { + if (call.method == methodRequestNotifications) { + return 'Granted'; + } + return null; + }; + + final result = await svc.requestNotifications(); + + expect( + outgoingCalls.where((c) => c.method == methodRequestNotifications), + hasLength(1), + reason: 'requestNotifications must be called', + ); + expect(result, MacOSPermissionState.granted); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SRS-297: openInputMonitoringSettings() emits outbound ' + 'openInputMonitoringSettings MethodCall', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + await svc.openInputMonitoringSettings(); + + final calls = outgoingCalls + .where((c) => c.method == methodOpenInputMonitoringSettings) + .toList(); + expect(calls, hasLength(1)); + svc.dispose(); + }, + ); + + // =========================================================================== + // Lifecycle + // =========================================================================== + + test( + 'SWE4-UV / SRS-297: start() is idempotent — calling it twice ' + 'does not double-register the handler', + () async { + final svc = MacOSPermissionsService(channel: channel) + ..start() + ..start(); + + var notified = 0; + void listener() => notified++; + svc.inputMonitoringState.addListener(listener); + + await sendInputMonitoringStateChanged(state: 'Denied'); + + expect(svc.inputMonitoringState.value, MacOSPermissionState.denied); + expect(notified, 1); + + svc.inputMonitoringState.removeListener(listener); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SRS-297: stop() removes the handler — subsequent ' + 'inbound messages have no effect on state', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + await sendInputMonitoringStateChanged(state: 'Denied'); + expect(svc.inputMonitoringState.value, MacOSPermissionState.denied); + + svc.stop(); + + await sendInputMonitoringStateChanged(state: 'Granted'); + + expect( + svc.inputMonitoringState.value, + MacOSPermissionState.denied, + reason: 'state must be frozen after stop()', + ); + }, + ); + + // =========================================================================== + // Non-macOS short-circuit (channel is null) + // =========================================================================== + + test( + 'SWE4-UV / SRS-297: null-channel short-circuit — on non-macOS hosts ' + 'the constructor seeds to safe defaults; on macOS hosts, passing ' + 'channel:null still creates a real channel because _isMacOS is true', + () async { + if (!hostIsMacOS) { + // On non-macOS: the constructor's `_isMacOS` branch is false, + // so `_channel` stays null. All methods return safe defaults + // without touching any channel. + final svc = MacOSPermissionsService(channel: null); + expect( + svc.inputMonitoringState.value, + MacOSPermissionState.granted, + ); + expect( + svc.localNetworkState.value, + MacOSLocalNetworkState.unsupported, + ); + expect( + svc.notificationState.value, + MacOSPermissionState.granted, + ); + + final priorOutgoing = outgoingCalls.length; + await svc.requestInputMonitoring(); + await svc.triggerLocalNetworkPrompt(); + await svc.requestNotifications(); + await svc.openInputMonitoringSettings(); + + expect( + outgoingCalls.length, + priorOutgoing, + reason: 'no outbound calls when platform is non-macOS', + ); + + svc.start(); + svc.stop(); + svc.dispose(); + } else { + // On macOS: even with channel: null, the constructor creates + // a MethodChannel because _isMacOS is true. This is the + // correct production behaviour — on macOS the service always + // has a channel. The short-circuit path is unreachable on + // macOS by design. + final svc = MacOSPermissionsService(channel: null); + // The service has a non-null _channel, so methods will attempt + // to invoke the channel (which has no native handler in tests). + // Verify the service doesn't throw and returns a value. + final result = await svc.requestInputMonitoring(); + expect(result, isNotNull); + svc.dispose(); + } + }, + ); + + // =========================================================================== + // Channel error handling + // =========================================================================== + + test( + 'SWE4-UV / SRS-297: requestInputMonitoring() returns cached state ' + 'when the channel throws', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + // Seed a known state via inbound. + await sendInputMonitoringStateChanged(state: 'Denied'); + expect(svc.inputMonitoringState.value, MacOSPermissionState.denied); + + // Make the platform stub throw. + outgoingResponder = (call) async { + if (call.method == methodRequestInputMonitoring) { + throw PlatformException(code: 'unavailable'); + } + return null; + }; + + final result = await svc.requestInputMonitoring(); + + expect( + result, + MacOSPermissionState.denied, + reason: + 'on channel failure requestInputMonitoring must return cached state', + ); + svc.dispose(); + }, + ); + + test( + 'SWE4-UV / SRS-300: triggerLocalNetworkPrompt() returns cached state ' + 'when the channel throws', + () async { + final svc = MacOSPermissionsService(channel: channel)..start(); + + outgoingResponder = (call) async { + if (call.method == methodTriggerLocalNetworkPrompt) { + throw PlatformException(code: 'unavailable'); + } + return null; + }; + + final result = await svc.triggerLocalNetworkPrompt(); + + // Returns the cached state without crashing. + expect(result, isNotNull); + svc.dispose(); + }, + ); +}