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 = [] @@ -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 @@ $(EXO_BUILD_TAG) EXOBuildCommit $(EXO_BUILD_COMMIT) + NSLocalNetworkUsageDescription + EXO needs local network access to discover and connect to other devices in your cluster for distributed AI inference. + NSBonjourServices + + _p2p._tcp + _p2p._udp + _libp2p._udp + 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? + + /// 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 + } +}