← back to Exo
local network check (#1103)
ea841aca378e2fb0386bae0ae956580295cd8a80 · 2026-01-08 17:24:46 +0500 · Sami Khan
## Motivation
After machine restart, macOS local network permission can appear enabled
in System Settings but not actually work. EXO fails to discover other
machines, and the only fix is manually toggling the permission off/on
and relaunching. Users had no way to know this was happening.
## Changes
- Added LocalNetworkChecker service that detects if local network access
is actually functional
- Added warning banner with instructions and "Open Settings" button when
blocked
- Added NSLocalNetworkUsageDescription and NSBonjourServices to
Info.plist (required by macOS)
<img width="386" height="712" alt="image"
src="https://github.com/user-attachments/assets/c6fc873d-2c6a-4c9b-89cb-f7bc7322e25b"
/>
## Why It Works
Uses NWConnection to UDP multicast address 224.0.0.251:5353 (mDNS),
which is subject to the app's actual TCC permission state. Other
approaches (NWBrowser, dns-sd subprocess) either require additional
entitlements or run with their own permissions, giving false results.
## Test Plan
### Manual Testing
Hardware: MacBook Pro
- Toggle local network OFF in System Settings → warning banner appears
- Toggle local network ON → warning disappears
- Verified detection correctly reflects actual permission state
### Automated Testing
N/A
Files touched
M app/EXO/EXO/ContentView.swiftM app/EXO/EXO/EXOApp.swiftM app/EXO/EXO/Info.plistA app/EXO/EXO/Services/LocalNetworkChecker.swift
Diff
commit ea841aca378e2fb0386bae0ae956580295cd8a80
Author: Sami Khan <98742866+samiamjidkhan@users.noreply.github.com>
Date: Thu Jan 8 17:24:46 2026 +0500
local network check (#1103)
## Motivation
After machine restart, macOS local network permission can appear enabled
in System Settings but not actually work. EXO fails to discover other
machines, and the only fix is manually toggling the permission off/on
and relaunching. Users had no way to know this was happening.
## Changes
- Added LocalNetworkChecker service that detects if local network access
is actually functional
- Added warning banner with instructions and "Open Settings" button when
blocked
- Added NSLocalNetworkUsageDescription and NSBonjourServices to
Info.plist (required by macOS)
<img width="386" height="712" alt="image"
src="https://github.com/user-attachments/assets/c6fc873d-2c6a-4c9b-89cb-f7bc7322e25b"
/>
## Why It Works
Uses NWConnection to UDP multicast address 224.0.0.251:5353 (mDNS),
which is subject to the app's actual TCC permission state. Other
approaches (NWBrowser, dns-sd subprocess) either require additional
entitlements or run with their own permissions, giving false results.
## Test Plan
### Manual Testing
Hardware: MacBook Pro
- Toggle local network OFF in System Settings → warning banner appears
- Toggle local network ON → warning disappears
- Verified detection correctly reflects actual permission state
### Automated Testing
N/A
---
app/EXO/EXO/ContentView.swift | 52 +++++++++
app/EXO/EXO/EXOApp.swift | 6 +
app/EXO/EXO/Info.plist | 8 ++
app/EXO/EXO/Services/LocalNetworkChecker.swift | 149 +++++++++++++++++++++++++
4 files changed, 215 insertions(+)
diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift
index 75a23c12..2125f0f9 100644
--- a/app/EXO/EXO/ContentView.swift
+++ b/app/EXO/EXO/ContentView.swift
@@ -12,6 +12,7 @@ struct ContentView: View {
@EnvironmentObject private var controller: ExoProcessController
@EnvironmentObject private var stateService: ClusterStateService
@EnvironmentObject private var networkStatusService: NetworkStatusService
+ @EnvironmentObject private var localNetworkChecker: LocalNetworkChecker
@EnvironmentObject private var updater: SparkleUpdater
@State private var focusedNode: NodeViewModel?
@State private var deletingInstanceIDs: Set<String> = []
@@ -26,6 +27,9 @@ struct ContentView: View {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
statusSection
+ if shouldShowLocalNetworkWarning {
+ localNetworkWarningBanner
+ }
if shouldShowClusterDetails {
Divider()
overviewSection
@@ -40,6 +44,7 @@ struct ContentView: View {
}
.animation(.easeInOut(duration: 0.3), value: shouldShowClusterDetails)
.animation(.easeInOut(duration: 0.3), value: shouldShowInstances)
+ .animation(.easeInOut(duration: 0.3), value: shouldShowLocalNetworkWarning)
.padding()
.frame(width: 340)
.onAppear {
@@ -49,6 +54,53 @@ struct ContentView: View {
}
}
+ private var shouldShowLocalNetworkWarning: Bool {
+ if case .notWorking = localNetworkChecker.status {
+ return controller.status != .stopped
+ }
+ return false
+ }
+
+ private var localNetworkWarningBanner: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 6) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ Text("Local Network Access Issue")
+ .font(.caption)
+ .fontWeight(.semibold)
+ }
+ Text("Device discovery won't work. To fix:\n1. Quit EXO\n2. Open System Settings → Privacy & Security → Local Network\n3. Toggle EXO off, then back on\n4. Relaunch EXO")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Button {
+ openLocalNetworkSettings()
+ } label: {
+ Text("Open Settings")
+ .font(.caption2)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color.orange.opacity(0.1))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.orange.opacity(0.3), lineWidth: 1)
+ )
+ }
+
+ private func openLocalNetworkSettings() {
+ // Open Privacy & Security settings - Local Network section
+ if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+
private var topologySection: some View {
Group {
if let topology = stateService.latestSnapshot?.topologyViewModel(localNodeId: stateService.localNodeId), !topology.nodes.isEmpty {
diff --git a/app/EXO/EXO/EXOApp.swift b/app/EXO/EXO/EXOApp.swift
index da6dd31b..af1be1b5 100644
--- a/app/EXO/EXO/EXOApp.swift
+++ b/app/EXO/EXO/EXOApp.swift
@@ -19,6 +19,7 @@ struct EXOApp: App {
@StateObject private var controller: ExoProcessController
@StateObject private var stateService: ClusterStateService
@StateObject private var networkStatusService: NetworkStatusService
+ @StateObject private var localNetworkChecker: LocalNetworkChecker
@StateObject private var updater: SparkleUpdater
private let terminationObserver: TerminationObserver
private let ciContext = CIContext(options: nil)
@@ -37,9 +38,13 @@ struct EXOApp: App {
_stateService = StateObject(wrappedValue: service)
let networkStatus = NetworkStatusService()
_networkStatusService = StateObject(wrappedValue: networkStatus)
+ let localNetwork = LocalNetworkChecker()
+ _localNetworkChecker = StateObject(wrappedValue: localNetwork)
_updater = StateObject(wrappedValue: updater)
enableLaunchAtLoginIfNeeded()
NetworkSetupHelper.ensureLaunchDaemonInstalled()
+ // Check local network access BEFORE launching exo
+ localNetwork.check()
controller.scheduleLaunch(after: 15)
service.startPolling()
networkStatus.startPolling()
@@ -51,6 +56,7 @@ struct EXOApp: App {
.environmentObject(controller)
.environmentObject(stateService)
.environmentObject(networkStatusService)
+ .environmentObject(localNetworkChecker)
.environmentObject(updater)
} label: {
menuBarIcon
diff --git a/app/EXO/EXO/Info.plist b/app/EXO/EXO/Info.plist
index 5a7e8cbf..df4073e5 100644
--- a/app/EXO/EXO/Info.plist
+++ b/app/EXO/EXO/Info.plist
@@ -8,5 +8,13 @@
<string>$(EXO_BUILD_TAG)</string>
<key>EXOBuildCommit</key>
<string>$(EXO_BUILD_COMMIT)</string>
+ <key>NSLocalNetworkUsageDescription</key>
+ <string>EXO needs local network access to discover and connect to other devices in your cluster for distributed AI inference.</string>
+ <key>NSBonjourServices</key>
+ <array>
+ <string>_p2p._tcp</string>
+ <string>_p2p._udp</string>
+ <string>_libp2p._udp</string>
+ </array>
</dict>
</plist>
diff --git a/app/EXO/EXO/Services/LocalNetworkChecker.swift b/app/EXO/EXO/Services/LocalNetworkChecker.swift
new file mode 100644
index 00000000..5b3a48d2
--- /dev/null
+++ b/app/EXO/EXO/Services/LocalNetworkChecker.swift
@@ -0,0 +1,149 @@
+import Foundation
+import Network
+import os.log
+
+/// Checks if the app's local network permission is actually functional.
+///
+/// macOS local network permission can appear enabled in System Preferences but not
+/// actually work after a restart. This service detects this by creating a UDP
+/// connection to the mDNS multicast address (224.0.0.251:5353).
+@MainActor
+final class LocalNetworkChecker: ObservableObject {
+ enum Status: Equatable {
+ case unknown
+ case checking
+ case working
+ case notWorking(reason: String)
+
+ var isHealthy: Bool {
+ if case .working = self { return true }
+ return false
+ }
+
+ var displayText: String {
+ switch self {
+ case .unknown:
+ return "Unknown"
+ case .checking:
+ return "Checking..."
+ case .working:
+ return "Working"
+ case .notWorking(let reason):
+ return reason
+ }
+ }
+ }
+
+ private static let logger = Logger(subsystem: "io.exo.EXO", category: "LocalNetworkChecker")
+
+ @Published private(set) var status: Status = .unknown
+ @Published private(set) var lastConnectionState: String = "none"
+
+ private var connection: NWConnection?
+ private var checkTask: Task<Void, Never>?
+
+ /// Checks if local network access is working.
+ func check() {
+ checkTask?.cancel()
+ status = .checking
+ lastConnectionState = "connecting"
+
+ checkTask = Task { [weak self] in
+ guard let self else { return }
+ let result = await self.performCheck()
+ self.status = result
+ Self.logger.info("Local network check complete: \(result.displayText)")
+ }
+ }
+
+ private func performCheck() async -> Status {
+ Self.logger.info("Checking local network access via UDP multicast")
+
+ connection?.cancel()
+ connection = nil
+
+ // mDNS multicast address - same as libp2p uses for peer discovery
+ let host = NWEndpoint.Host("224.0.0.251")
+ let port = NWEndpoint.Port(integerLiteral: 5353)
+
+ let params = NWParameters.udp
+ params.allowLocalEndpointReuse = true
+
+ let conn = NWConnection(host: host, port: port, using: params)
+ connection = conn
+
+ return await withCheckedContinuation { continuation in
+ var hasResumed = false
+ let lock = NSLock()
+
+ let resumeOnce: (Status) -> Void = { status in
+ lock.lock()
+ defer { lock.unlock() }
+ guard !hasResumed else { return }
+ hasResumed = true
+ continuation.resume(returning: status)
+ }
+
+ conn.stateUpdateHandler = { [weak self] state in
+ let stateStr: String
+ switch state {
+ case .setup: stateStr = "setup"
+ case .preparing: stateStr = "preparing"
+ case .ready: stateStr = "ready"
+ case .waiting(let e): stateStr = "waiting(\(e))"
+ case .failed(let e): stateStr = "failed(\(e))"
+ case .cancelled: stateStr = "cancelled"
+ @unknown default: stateStr = "unknown"
+ }
+
+ Task { @MainActor in
+ self?.lastConnectionState = stateStr
+ }
+
+ switch state {
+ case .ready:
+ resumeOnce(.working)
+ case .waiting(let error):
+ let errorStr = "\(error)"
+ if errorStr.contains("54") || errorStr.contains("ECONNRESET") {
+ resumeOnce(.notWorking(reason: "Connection blocked"))
+ }
+ case .failed(let error):
+ let errorStr = "\(error)"
+ if errorStr.contains("65") || errorStr.contains("EHOSTUNREACH") ||
+ errorStr.contains("permission") || errorStr.contains("denied") {
+ resumeOnce(.notWorking(reason: "Permission denied"))
+ } else {
+ resumeOnce(.notWorking(reason: "Failed: \(error.localizedDescription)"))
+ }
+ case .cancelled, .setup, .preparing:
+ break
+ @unknown default:
+ break
+ }
+ }
+
+ conn.start(queue: .main)
+
+ Task {
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
+ let state = conn.state
+ switch state {
+ case .ready:
+ resumeOnce(.working)
+ case .waiting, .preparing, .setup:
+ resumeOnce(.notWorking(reason: "Timeout (may be blocked)"))
+ default:
+ resumeOnce(.notWorking(reason: "Timeout"))
+ }
+ }
+ }
+ }
+
+ func stop() {
+ checkTask?.cancel()
+ checkTask = nil
+ connection?.cancel()
+ connection = nil
+ }
+}
← 077b1bc7 exo-bench (Benchmark model pp & tg speed) (#1099)
·
back to Exo
·
We shouldn't fail on an HTTPException in the tier-2 discover f76d543d →