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:
@@ -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