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
+44 -60
View File
@@ -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<void> _openIosAppSettings() async {
try {
await _iosPlatformChannel.invokeMethod<bool>('openAppSettings');
} catch (_) {
// Best-effort affordance only; if iOS refuses the URL, the
// dialog still explained the missing microphone permission.
}
}
Future<void> _setPtt(bool active, {bool reportError = true}) async {
if (active) {
try {
@@ -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<MacOSLocalNetworkState> checkLocalNetworkAccess({
required String host,
required int port,
}) async {
final ch = _channel;
if (ch == null) return MacOSLocalNetworkState.unsupported;
try {
final raw = await ch.invokeMethod<String>(
methodCheckLocalNetworkAccess,
<String, dynamic>{'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<MacOSPermissionState> _checkNotifications() async {