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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user