fix(macos): reliable Local Network permission denial detection and re-check

- Replace broad POSIX error checks (EACCES/EPERM/ENETDOWN) with the
  canonical kDNSServiceErr_PolicyDenied DNS error in the NWBrowser
  state handler, matching the pattern used by Expo, Pulse, Strongbox,
  and WLED. Detect denial in both .failed and .waiting states.

- Add checkLocalNetworkAccess(host:port:) — a read-only NWConnection
  probe (Sequel-Ace pattern) that checks
  NWPath.unsatisfiedReason == .localNetworkDenied without triggering
  a new system prompt. Useful for confirming denial against a specific
  destination before attempting to connect.

- In _onConnect, after the prompt resolves to Denied, confirm with
  checkLocalNetworkAccess against the target host. If confirmed,
  abort the connect attempt and show a non-modal snackbar with an
  'Open System Settings' action that deep-links to
  Privacy_LocalNetwork. Previously the app would proceed to connect,
  fail with PermissionDenied, and surface a redundant in-app modal.

- Drop the now-orphaned _openIosAppSettings helper and
  _iosPlatformChannel constant (the only caller was the removed
  in-app permission dialog).

- Add unit tests for checkLocalNetworkAccess covering outbound
  MethodCall arguments and state parsing for Granted/Denied.

Trace: SRS-300.
This commit is contained in:
Edison Jwa
2026-06-07 23:12:07 +09:00
parent ab0dc2ebc2
commit ad8b996376
4 changed files with 236 additions and 69 deletions
@@ -301,6 +301,61 @@ void main() {
},
);
test(
'SWE4-UV / SRS-300: checkLocalNetworkAccess() emits outbound '
'checkLocalNetworkAccess MethodCall with host and port arguments',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodCheckLocalNetworkAccess) {
return 'Denied';
}
return null;
};
final result = await svc.checkLocalNetworkAccess(
host: '192.168.1.42',
port: 9987,
);
final calls = outgoingCalls
.where((c) => c.method == methodCheckLocalNetworkAccess)
.toList();
expect(calls, hasLength(1));
final args = calls.single.arguments as Map;
expect(args['host'], '192.168.1.42');
expect(args['port'], 9987);
expect(result, MacOSLocalNetworkState.denied);
expect(svc.localNetworkState.value, MacOSLocalNetworkState.denied);
svc.dispose();
},
);
test(
'SWE4-UV / SRS-300: checkLocalNetworkAccess() parses Granted and updates '
'localNetworkState',
() async {
final svc = MacOSPermissionsService(channel: channel)..start();
outgoingResponder = (call) async {
if (call.method == methodCheckLocalNetworkAccess) {
return 'Granted';
}
return null;
};
final result = await svc.checkLocalNetworkAccess(
host: 'ts.example.com',
port: 9987,
);
expect(result, MacOSLocalNetworkState.granted);
expect(svc.localNetworkState.value, MacOSLocalNetworkState.granted);
svc.dispose();
},
);
test(
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
'requestNotifications MethodCall; returns the platform response',