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
@@ -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
// =========================================================================