From ad8b9963768952c2a294f90e29f08358bcb98caf Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Sat, 6 Jun 2026 15:00:50 +0900 Subject: [PATCH] fix(macos): reliable Local Network permission denial detection and re-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/chanora_flutter/lib/main.dart | 104 +++++++--------- .../services/macos_permissions_service.dart | 33 +++++ .../macos/Runner/MainFlutterWindow.swift | 113 ++++++++++++++++-- .../macos_permissions_service_test.dart | 55 +++++++++ 4 files changed, 236 insertions(+), 69 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 6732a7b..bfcd012 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -49,8 +49,6 @@ import 'package:share_plus/share_plus.dart'; bool get _isMacOS => !kIsWeb && Platform.isMacOS; -const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform'); - const Color _appSurfaceColor = Color(0xFFFFFBFE); /// Top padding for macOS to clear traffic-light buttons. @@ -624,6 +622,32 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { ); } + void _showLocalNetworkDeniedSnackBar() { + if (!mounted) return; + final l10n = AppL10n.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 8), + content: Text( + l10n.networkPermissionBody, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + action: SnackBarAction( + label: l10n.networkPermissionOpenSettings, + onPressed: () { + try { + Process.run('open', [ + 'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork', + ]); + } catch (_) {} + }, + ), + ), + ); + } + bool _handleFocusedPttKey(KeyEvent event) { final label = pttDisplayLabelForKey(event.logicalKey); final isBoundKey = @@ -1079,6 +1103,24 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { await _macOSPermissions.triggerLocalNetworkPrompt(); } + // If the prompt resolved to Denied (or was already Denied), confirm + // with a read-only NWConnection probe to the target host and show + // a snackbar directing the user to System Settings. + final resolvedState = _macOSPermissions.localNetworkState.value; + if (resolvedState == MacOSLocalNetworkState.denied) { + final targetHost = (host ?? _hostCtl.text).trim(); + final accessState = await _macOSPermissions.checkLocalNetworkAccess( + host: targetHost, + port: 9987, + ); + if (accessState == MacOSLocalNetworkState.denied) { + if (!mounted) return; + setState(() { _phase = ConnectionPhase.idle; }); + _showLocalNetworkDeniedSnackBar(); + return; + } + } + setState(() { _phase = ConnectionPhase.connecting; _snapshot = null; @@ -1115,11 +1157,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { }); } catch (e) { if (!mounted) return; - final errorStr = e.toString(); - if (errorStr.contains('PermissionDenied') || - errorStr.contains('Operation not permitted')) { - _showPermissionDeniedDialog(errorStr); - } setState(() { _phase = ConnectionPhase.idle; }); @@ -1144,59 +1181,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver { } catch (_) {} } - void _showPermissionDeniedDialog(String rawError) { - final l10n = AppL10n.of(context); - final isNetwork = rawError.contains('9987') || rawError.contains('connect'); - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text( - isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied, - ), - content: Text( - isNetwork - ? l10n.networkPermissionBody - : l10n.microphonePermissionBody, - ), - actions: [ - if (Platform.isIOS && !isNetwork) - TextButton( - onPressed: () { - Navigator.pop(ctx); - unawaited(_openIosAppSettings()); - }, - child: Text(l10n.networkPermissionOpenSettings), - ), - if (Platform.isMacOS) - TextButton( - onPressed: () { - Navigator.pop(ctx); - try { - Process.run('open', [ - 'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork', - ]); - } catch (_) {} - }, - child: Text(l10n.networkPermissionOpenSettings), - ), - TextButton( - onPressed: () => Navigator.pop(ctx), - child: Text(MaterialLocalizations.of(context).okButtonLabel), - ), - ], - ), - ); - } - - Future _openIosAppSettings() async { - try { - await _iosPlatformChannel.invokeMethod('openAppSettings'); - } catch (_) { - // Best-effort affordance only; if iOS refuses the URL, the - // dialog still explained the missing microphone permission. - } - } - Future _setPtt(bool active, {bool reportError = true}) async { if (active) { try { diff --git a/apps/chanora_flutter/lib/services/macos_permissions_service.dart b/apps/chanora_flutter/lib/services/macos_permissions_service.dart index de4d6da..d7b8837 100644 --- a/apps/chanora_flutter/lib/services/macos_permissions_service.dart +++ b/apps/chanora_flutter/lib/services/macos_permissions_service.dart @@ -63,6 +63,8 @@ const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt'; @visibleForTesting const String methodCheckLocalNetwork = 'checkLocalNetwork'; @visibleForTesting +const String methodCheckLocalNetworkAccess = 'checkLocalNetworkAccess'; +@visibleForTesting const String methodRequestNotifications = 'requestNotifications'; @visibleForTesting const String methodCheckNotifications = 'checkNotifications'; @@ -440,6 +442,37 @@ class MacOSPermissionsService { } } + /// Probe whether Local Network access is currently denied for [host]:[port] + /// by creating a short-lived NWConnection and checking + /// `unsatisfiedReason == .localNetworkDenied`. + /// + /// This does NOT trigger a new system prompt — it is a read-only check. + /// Returns [MacOSLocalNetworkState.unsupported] on non-macOS platforms. + Future checkLocalNetworkAccess({ + required String host, + required int port, + }) async { + final ch = _channel; + if (ch == null) return MacOSLocalNetworkState.unsupported; + try { + final raw = await ch.invokeMethod( + methodCheckLocalNetworkAccess, + {'host': host, 'port': port}, + ); + final state = _parseLocalNetworkState(raw); + _localNetworkState.value = state; + return state; + } catch (e, st) { + developer.log( + 'checkLocalNetworkAccess failed', + name: 'MacOSPermissionsService', + error: e, + stackTrace: st, + ); + return _localNetworkState.value; + } + } + // -- Outbound: Notifications ---------------------------------------------- Future _checkNotifications() async { diff --git a/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift b/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift index 55144c3..7c5513e 100644 --- a/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift +++ b/apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift @@ -63,6 +63,18 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin { case "triggerLocalNetworkPrompt": triggerLocalNetworkPrompt(result: result) + case "checkLocalNetworkAccess": + guard let args = call.arguments as? [String: Any], + let host = args["host"] as? String, + let port = args["port"] as? Int else { + result(FlutterError( + code: "INVALID_ARGS", + message: "checkLocalNetworkAccess requires host (String) and port (Int)", + details: nil)) + return + } + checkLocalNetworkAccess(host: host, port: port, result: result) + // -- Notifications ------------------------------------------------------ case "checkNotifications": checkNotifications(result: result) @@ -199,10 +211,10 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin { 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 { + // Check for DNS policy-denied error (kDNSServiceErr_PolicyDenied = -65570). + // This is the canonical signal that the user denied the Local Network prompt. + if case .dns(let dnsError) = error, + dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) { result("Denied") self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [ "state": "Denied", @@ -214,11 +226,23 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin { } } 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 .waiting(let error): + // The browser is waiting for network. If the specific DNS error + // is kDNSServiceErr_PolicyDenied, the user explicitly denied + // the Local Network prompt — report immediately. + if case .dns(let dnsError) = error, + dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) { + if !resolved { + resolved = true + result("Denied") + self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [ + "state": "Denied", + ]) + } + browser.cancel() + } + // Otherwise the system dialog may be showing — wait for + // .ready, .failed, or the timeout. case .setup, .cancelled: break @unknown default: @@ -247,6 +271,77 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin { } } + /// Probe whether Local Network access is currently denied for a specific + /// host:port by creating a short-lived NWConnection and checking + /// `unsatisfiedReason == .localNetworkDenied`. This does NOT trigger a + /// new system prompt — it is a read-only check. + private func checkLocalNetworkAccess( + host: String, port: Int, result: @escaping FlutterResult + ) { + if #available(macOS 15.0, *) { + checkLocalNetworkAccessImpl(host: host, port: port, result: result) + } else { + result("Unsupported") + } + } + + @available(macOS 15.0, *) + private func checkLocalNetworkAccessImpl( + host: String, port: Int, result: @escaping FlutterResult + ) { + guard let endpointPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + result("NotDetermined") + return + } + let endpointHost = NWEndpoint.Host(host) + let connection = NWConnection( + host: endpointHost, port: endpointPort, using: .tcp) + let queue = DispatchQueue( + label: "app.chanora.macos_permissions.local_network_check") + + var didComplete = false + + func finish(_ state: String) { + guard !didComplete else { return } + didComplete = true + connection.cancel() + result(state) + } + + connection.stateUpdateHandler = { state in + switch state { + case .waiting, .failed: + if connection.currentPath?.unsatisfiedReason + == .localNetworkDenied { + finish("Denied") + } else { + finish("NotDetermined") + } + case .ready: + finish("Granted") + case .cancelled: + finish("NotDetermined") + case .setup, .preparing: + break + @unknown default: + break + } + } + + connection.start(queue: queue) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if !didComplete { + if connection.currentPath?.unsatisfiedReason + == .localNetworkDenied { + finish("Denied") + } else { + finish("NotDetermined") + } + } + } + } + // ========================================================================= // Notifications // ========================================================================= diff --git a/apps/chanora_flutter/test/services/macos_permissions_service_test.dart b/apps/chanora_flutter/test/services/macos_permissions_service_test.dart index fb5a324..9a58126 100644 --- a/apps/chanora_flutter/test/services/macos_permissions_service_test.dart +++ b/apps/chanora_flutter/test/services/macos_permissions_service_test.dart @@ -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',