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;
|
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
||||||
|
|
||||||
const MethodChannel _iosPlatformChannel = MethodChannel('chanora/ios_platform');
|
|
||||||
|
|
||||||
const Color _appSurfaceColor = Color(0xFFFFFBFE);
|
const Color _appSurfaceColor = Color(0xFFFFFBFE);
|
||||||
|
|
||||||
/// Top padding for macOS to clear traffic-light buttons.
|
/// 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) {
|
bool _handleFocusedPttKey(KeyEvent event) {
|
||||||
final label = pttDisplayLabelForKey(event.logicalKey);
|
final label = pttDisplayLabelForKey(event.logicalKey);
|
||||||
final isBoundKey =
|
final isBoundKey =
|
||||||
@@ -1079,6 +1103,24 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await _macOSPermissions.triggerLocalNetworkPrompt();
|
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(() {
|
setState(() {
|
||||||
_phase = ConnectionPhase.connecting;
|
_phase = ConnectionPhase.connecting;
|
||||||
_snapshot = null;
|
_snapshot = null;
|
||||||
@@ -1115,11 +1157,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final errorStr = e.toString();
|
|
||||||
if (errorStr.contains('PermissionDenied') ||
|
|
||||||
errorStr.contains('Operation not permitted')) {
|
|
||||||
_showPermissionDeniedDialog(errorStr);
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_phase = ConnectionPhase.idle;
|
_phase = ConnectionPhase.idle;
|
||||||
});
|
});
|
||||||
@@ -1144,59 +1181,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
} catch (_) {}
|
} 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 {
|
Future<void> _setPtt(bool active, {bool reportError = true}) async {
|
||||||
if (active) {
|
if (active) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ const String methodTriggerLocalNetworkPrompt = 'triggerLocalNetworkPrompt';
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
const String methodCheckLocalNetwork = 'checkLocalNetwork';
|
const String methodCheckLocalNetwork = 'checkLocalNetwork';
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
|
const String methodCheckLocalNetworkAccess = 'checkLocalNetworkAccess';
|
||||||
|
@visibleForTesting
|
||||||
const String methodRequestNotifications = 'requestNotifications';
|
const String methodRequestNotifications = 'requestNotifications';
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
const String methodCheckNotifications = 'checkNotifications';
|
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 ----------------------------------------------
|
// -- Outbound: Notifications ----------------------------------------------
|
||||||
|
|
||||||
Future<MacOSPermissionState> _checkNotifications() async {
|
Future<MacOSPermissionState> _checkNotifications() async {
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|||||||
case "triggerLocalNetworkPrompt":
|
case "triggerLocalNetworkPrompt":
|
||||||
triggerLocalNetworkPrompt(result: result)
|
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 ------------------------------------------------------
|
// -- Notifications ------------------------------------------------------
|
||||||
case "checkNotifications":
|
case "checkNotifications":
|
||||||
checkNotifications(result: result)
|
checkNotifications(result: result)
|
||||||
@@ -199,10 +211,10 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|||||||
case .failed(let error):
|
case .failed(let error):
|
||||||
if !resolved {
|
if !resolved {
|
||||||
resolved = true
|
resolved = true
|
||||||
let code = error.errorCode
|
// Check for DNS policy-denied error (kDNSServiceErr_PolicyDenied = -65570).
|
||||||
// POSIX permission-denied or network-down signals that
|
// This is the canonical signal that the user denied the Local Network prompt.
|
||||||
// the user denied the Local Network prompt.
|
if case .dns(let dnsError) = error,
|
||||||
if code == ENETDOWN || code == EACCES || code == EPERM {
|
dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) {
|
||||||
result("Denied")
|
result("Denied")
|
||||||
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
self?.channel?.invokeMethod("localNetworkStateChanged", arguments: [
|
||||||
"state": "Denied",
|
"state": "Denied",
|
||||||
@@ -214,11 +226,23 @@ class MacOSPermissionsHandler: NSObject, FlutterPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
browser.cancel()
|
browser.cancel()
|
||||||
case .waiting:
|
case .waiting(let error):
|
||||||
// The browser is waiting for network — this is normal and
|
// The browser is waiting for network. If the specific DNS error
|
||||||
// may mean the permission dialog is showing. Don't resolve
|
// is kDNSServiceErr_PolicyDenied, the user explicitly denied
|
||||||
// yet; wait for .ready or .failed or the timeout.
|
// the Local Network prompt — report immediately.
|
||||||
break
|
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:
|
case .setup, .cancelled:
|
||||||
break
|
break
|
||||||
@unknown default:
|
@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
|
// Notifications
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -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(
|
test(
|
||||||
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
|
'SWE4-UV / SysRS-166: requestNotifications() emits outbound '
|
||||||
'requestNotifications MethodCall; returns the platform response',
|
'requestNotifications MethodCall; returns the platform response',
|
||||||
|
|||||||
Reference in New Issue
Block a user