feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications (#21)
* 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) * fix(macos): keep permissions capability state live
This commit is contained in:
@@ -23,6 +23,7 @@ import 'services/audio_lifecycle_service.dart';
|
|||||||
import 'services/channel_join_error_mapper.dart';
|
import 'services/channel_join_error_mapper.dart';
|
||||||
import 'services/connection_phase_state.dart';
|
import 'services/connection_phase_state.dart';
|
||||||
import 'services/ios_permissions_service.dart';
|
import 'services/ios_permissions_service.dart';
|
||||||
|
import 'services/macos_permissions_service.dart';
|
||||||
import 'services/prefetch_debouncer.dart';
|
import 'services/prefetch_debouncer.dart';
|
||||||
import 'services/snapshot_state_mapper.dart';
|
import 'services/snapshot_state_mapper.dart';
|
||||||
import 'services/ts3_server_link.dart';
|
import 'services/ts3_server_link.dart';
|
||||||
@@ -378,6 +379,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
final AndroidPermissionsService _androidPermissions =
|
final AndroidPermissionsService _androidPermissions =
|
||||||
AndroidPermissionsService();
|
AndroidPermissionsService();
|
||||||
final IosPermissionsService _iosPermissions = IosPermissionsService();
|
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();
|
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -399,6 +406,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_iosPermissions.recordAudioState.addListener(
|
_iosPermissions.recordAudioState.addListener(
|
||||||
_onRecordAudioPermissionChanged,
|
_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.pttCapabilityState.addListener(
|
||||||
|
_onMacOSPttCapabilityChanged,
|
||||||
|
);
|
||||||
|
_macOSPermissions.checkInitialStates();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_requestRecordAudioOnStartup());
|
unawaited(_requestRecordAudioOnStartup());
|
||||||
});
|
});
|
||||||
@@ -498,6 +515,28 @@ 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';
|
||||||
|
} else if (level == 'L0Focused') {
|
||||||
|
_pttBackendId = 'focused';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _clearPermissionHardMute() async {
|
Future<void> _clearPermissionHardMute() async {
|
||||||
if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return;
|
if (!_hardMuteByPermission || _permissionHardMuteClearInFlight) return;
|
||||||
_permissionHardMuteClearInFlight = true;
|
_permissionHardMuteClearInFlight = true;
|
||||||
@@ -936,11 +975,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_iosPermissions.recordAudioState.removeListener(
|
_iosPermissions.recordAudioState.removeListener(
|
||||||
_onRecordAudioPermissionChanged,
|
_onRecordAudioPermissionChanged,
|
||||||
);
|
);
|
||||||
|
// SRS-198 / SRS-297: detach macOS permission listeners.
|
||||||
|
_macOSPermissions.pttCapabilityState.removeListener(
|
||||||
|
_onMacOSPttCapabilityChanged,
|
||||||
|
);
|
||||||
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
|
// SDD-106: detach the Kotlin -> Dart MethodChannel handler so a
|
||||||
// late invokeMethod from the platform side cannot land on this
|
// late invokeMethod from the platform side cannot land on this
|
||||||
// disposed state.
|
// disposed state.
|
||||||
_androidPermissions.stop();
|
_androidPermissions.stop();
|
||||||
_iosPermissions.stop();
|
_iosPermissions.stop();
|
||||||
|
_macOSPermissions.stop();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
/// 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<MacOSPermissionState> _inputMonitoringState =
|
||||||
|
ValueNotifier<MacOSPermissionState>(
|
||||||
|
// 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<MacOSPermissionState> get inputMonitoringState =>
|
||||||
|
_inputMonitoringState;
|
||||||
|
|
||||||
|
// -- Local Network --------------------------------------------------------
|
||||||
|
|
||||||
|
final ValueNotifier<MacOSLocalNetworkState> _localNetworkState =
|
||||||
|
ValueNotifier<MacOSLocalNetworkState>(
|
||||||
|
_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<MacOSLocalNetworkState> get localNetworkState =>
|
||||||
|
_localNetworkState;
|
||||||
|
|
||||||
|
// -- Notifications --------------------------------------------------------
|
||||||
|
|
||||||
|
final ValueNotifier<MacOSPermissionState> _notificationState =
|
||||||
|
ValueNotifier<MacOSPermissionState>(
|
||||||
|
_isMacOS
|
||||||
|
? MacOSPermissionState.unknown
|
||||||
|
: MacOSPermissionState.granted,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Latest known notification authorization state.
|
||||||
|
ValueListenable<MacOSPermissionState> get notificationState =>
|
||||||
|
_notificationState;
|
||||||
|
|
||||||
|
// -- PTT capability (derived) ---------------------------------------------
|
||||||
|
|
||||||
|
final ValueNotifier<String> _pttCapabilityState =
|
||||||
|
ValueNotifier<String>(
|
||||||
|
_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<String> 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<dynamic> _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);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case methodLocalNetworkStateChanged:
|
||||||
|
final args = call.arguments;
|
||||||
|
if (args is Map) {
|
||||||
|
_localNetworkState.value =
|
||||||
|
_parseLocalNetworkState(args['state'] as String?);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Outbound: Input Monitoring -------------------------------------------
|
||||||
|
|
||||||
|
/// Check the current Input Monitoring permission state without
|
||||||
|
/// triggering a system prompt.
|
||||||
|
Future<MacOSPermissionState> _checkInputMonitoring() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSPermissionState.granted;
|
||||||
|
try {
|
||||||
|
final raw =
|
||||||
|
await ch.invokeMethod<String>(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<MacOSPermissionState> requestInputMonitoring() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSPermissionState.granted;
|
||||||
|
try {
|
||||||
|
final raw = await ch.invokeMethod<String>(
|
||||||
|
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<void> openInputMonitoringSettings() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return;
|
||||||
|
try {
|
||||||
|
await ch.invokeMethod<void>(methodOpenInputMonitoringSettings);
|
||||||
|
} catch (e, st) {
|
||||||
|
developer.log(
|
||||||
|
'openInputMonitoringSettings failed',
|
||||||
|
name: 'MacOSPermissionsService',
|
||||||
|
error: e,
|
||||||
|
stackTrace: st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Outbound: Local Network ----------------------------------------------
|
||||||
|
|
||||||
|
Future<MacOSLocalNetworkState> _checkLocalNetwork() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSLocalNetworkState.unsupported;
|
||||||
|
try {
|
||||||
|
final raw = await ch.invokeMethod<String>(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<MacOSLocalNetworkState> triggerLocalNetworkPrompt() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSLocalNetworkState.unsupported;
|
||||||
|
try {
|
||||||
|
final raw = await ch.invokeMethod<String>(
|
||||||
|
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<MacOSPermissionState> _checkNotifications() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSPermissionState.granted;
|
||||||
|
try {
|
||||||
|
final raw =
|
||||||
|
await ch.invokeMethod<String>(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<MacOSPermissionState> requestNotifications() async {
|
||||||
|
final ch = _channel;
|
||||||
|
if (ch == null) return MacOSPermissionState.granted;
|
||||||
|
try {
|
||||||
|
final raw = await ch.invokeMethod<String>(
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,5 +40,9 @@
|
|||||||
<string>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.</string>
|
<string>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.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_ts3._tcp</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,292 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import FlutterMacOS
|
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)
|
||||||
|
|
||||||
|
// -- 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 {
|
class MainFlutterWindow: NSWindow {
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
@@ -15,6 +302,9 @@ class MainFlutterWindow: NSWindow {
|
|||||||
|
|
||||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||||
|
|
||||||
|
// Register the macOS permissions MethodChannel handler.
|
||||||
|
MacOSPermissionsHandler.register(with: flutterViewController.registrar(forPlugin: "MacOSPermissionsHandler"))
|
||||||
|
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<MethodCall> 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<Object?> Function(MethodCall call)? outgoingResponder;
|
||||||
|
|
||||||
|
Future<void> sendInputMonitoringStateChanged({
|
||||||
|
required String state,
|
||||||
|
}) async {
|
||||||
|
const codec = StandardMethodCodec();
|
||||||
|
final encoded = codec.encodeMethodCall(
|
||||||
|
MethodCall(methodInputMonitoringStateChanged, <String, dynamic>{
|
||||||
|
'state': state,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.handlePlatformMessage(channel.name, encoded, (_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sendLocalNetworkStateChanged({
|
||||||
|
required String state,
|
||||||
|
}) async {
|
||||||
|
const codec = StandardMethodCodec();
|
||||||
|
final encoded = codec.encodeMethodCall(
|
||||||
|
MethodCall(methodLocalNetworkStateChanged, <String, dynamic>{
|
||||||
|
'state': state,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.handlePlatformMessage(channel.name, encoded, (_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
channel = const MethodChannel(macOSPermissionsChannelName);
|
||||||
|
outgoingCalls = <MethodCall>[];
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user