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