// // LocalServer.swift // iris // // Created by Codex. // import Foundation import Network final class LocalServer: ObservableObject { @Published private(set) var isRunning = false @Published private(set) var port: Int @Published private(set) var sseClientCount = 0 @Published private(set) var lastWinnerTitle = "All Quiet" @Published private(set) var lastWinnerSubtitle = "No urgent updates" @Published private(set) var lastBroadcastAt: Date? = nil @Published private(set) var listenerState: String = "idle" @Published private(set) var listenerError: String? = nil @Published private(set) var lastConnectionAt: Date? = nil @Published private(set) var localAddresses: [String] = [] private let queue = DispatchQueue(label: "iris.localserver.queue") private var listener: NWListener? private var browser: NWBrowser? private var startDate = Date() private var currentEnvelope: WinnerEnvelope private var heartbeatTimer: DispatchSourceTimer? private var addressTimer: DispatchSourceTimer? private var requestBuffers: [ObjectIdentifier: Data] = [:] private var clients: [ObjectIdentifier: SSEClient] = [:] init(port: Int = 8765) { self.port = port let winner = Winner( id: "quiet-000", type: .allQuiet, title: "All Quiet", subtitle: "No urgent updates", priority: 0.05, ttlSec: 300 ) self.currentEnvelope = WinnerEnvelope( schema: 1, generatedAt: Int(Date().timeIntervalSince1970), winner: winner, debug: nil ) } var testURL: String { "http://172.20.10.1:\(port)/v1/stream" } func start() { guard listener == nil else { return } let parameters = NWParameters.tcp do { let portValue = NWEndpoint.Port(rawValue: UInt16(port)) ?? 8765 let listener = try NWListener(using: parameters, on: portValue) listener.newConnectionHandler = { [weak self] connection in self?.handleNewConnection(connection) } listener.stateUpdateHandler = { [weak self] state in DispatchQueue.main.async { self?.listenerState = "\(state)" if case .failed(let error) = state { self?.listenerError = "\(error)" } self?.isRunning = (state == .ready) } } self.listener = listener self.startDate = Date() listener.start(queue: queue) startHeartbeat() startLocalNetworkPrompt() startAddressUpdates() } catch { DispatchQueue.main.async { self.isRunning = false self.listenerState = "failed" self.listenerError = "\(error)" } } } func stop() { listener?.cancel() listener = nil stopLocalNetworkPrompt() stopHeartbeat() stopAddressUpdates() closeAllClients() DispatchQueue.main.async { self.isRunning = false } } func broadcastWinner(_ envelope: WinnerEnvelope) { let validated = (try? validateEnvelope(envelope)) ?? envelope currentEnvelope = validated DispatchQueue.main.async { self.lastWinnerTitle = validated.winner.title self.lastWinnerSubtitle = validated.winner.subtitle self.lastBroadcastAt = Date() } let data = sseEvent(name: "winner", payload: jsonLine(from: validated)) broadcast(data: data) } private func handleNewConnection(_ connection: NWConnection) { DispatchQueue.main.async { self.lastConnectionAt = Date() } connection.stateUpdateHandler = { [weak self] state in if case .failed = state { self?.removeClient(for: connection) } else if case .cancelled = state { self?.removeClient(for: connection) } } connection.start(queue: queue) receiveRequest(on: connection) } private func receiveRequest(on connection: NWConnection) { connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in guard let self = self else { return } if let data = data, !data.isEmpty { let key = ObjectIdentifier(connection) var buffer = self.requestBuffers[key] ?? Data() buffer.append(data) self.requestBuffers[key] = buffer if let requestLine = self.parseRequestLine(from: buffer) { self.requestBuffers[key] = nil self.handleRequest(requestLine: requestLine, connection: connection) return } } if isComplete || error != nil { self.requestBuffers[ObjectIdentifier(connection)] = nil self.removeClient(for: connection) connection.cancel() return } self.receiveRequest(on: connection) } } private func parseRequestLine(from data: Data) -> String? { guard let string = String(data: data, encoding: .utf8) else { return nil } guard let range = string.range(of: "\r\n") else { return nil } return String(string[..= 2 else { sendResponse(status: "400 Bad Request", body: "{}", contentType: "application/json", connection: connection) return } let method = parts[0] let path = String(parts[1]) guard method == "GET" else { sendResponse(status: "405 Method Not Allowed", body: "{}", contentType: "application/json", connection: connection) return } switch path { case "/v1/health": let uptime = Int(Date().timeIntervalSince(startDate)) let body = "{\"ok\":true,\"uptime_sec\":\(uptime)}" sendResponse(status: "200 OK", body: body, contentType: "application/json", connection: connection) case "/v1/winner": let body = jsonLine(from: currentEnvelope) sendResponse(status: "200 OK", body: body, contentType: "application/json", connection: connection) case "/v1/stream": startSSE(connection) default: sendResponse(status: "404 Not Found", body: "{}", contentType: "application/json", connection: connection) } } private func sendResponse(status: String, body: String, contentType: String, connection: NWConnection) { let response = """ HTTP/1.1 \(status)\r\n\ Content-Type: \(contentType)\r\n\ Content-Length: \(body.utf8.count)\r\n\ Connection: close\r\n\ \r\n\ \(body) """ connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in connection.cancel() }) } private func startSSE(_ connection: NWConnection) { let headers = """ HTTP/1.1 200 OK\r\n\ Content-Type: text/event-stream\r\n\ Cache-Control: no-cache\r\n\ Connection: keep-alive\r\n\ \r\n """ connection.send(content: headers.data(using: .utf8), completion: .contentProcessed { [weak self] error in if error != nil { connection.cancel() return } self?.addClient(connection) let initial = self?.sseEvent(name: "feed", payload: self?.initialFeedJSON() ?? "{}") ?? Data() connection.send(content: initial, completion: .contentProcessed { _ in }) let status = self?.sseEvent(name: "status", payload: self?.statusJSON() ?? "{}") ?? Data() connection.send(content: status, completion: .contentProcessed { _ in }) }) } private func addClient(_ connection: NWConnection) { let key = ObjectIdentifier(connection) clients[key] = SSEClient(connection: connection) DispatchQueue.main.async { self.sseClientCount = self.clients.count } } private func removeClient(for connection: NWConnection) { let key = ObjectIdentifier(connection) if clients.removeValue(forKey: key) != nil { DispatchQueue.main.async { self.sseClientCount = self.clients.count } } } private func closeAllClients() { for client in clients.values { client.connection.cancel() } clients.removeAll() DispatchQueue.main.async { self.sseClientCount = 0 } } private func startHeartbeat() { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + 15, repeating: 15) timer.setEventHandler { [weak self] in guard let self = self else { return } let data = self.sseEvent(name: "ping", payload: "{}") self.broadcast(data: data) } timer.resume() heartbeatTimer = timer } private func stopHeartbeat() { heartbeatTimer?.cancel() heartbeatTimer = nil } private func startAddressUpdates() { updateLocalAddresses() let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + 10, repeating: 10) timer.setEventHandler { [weak self] in self?.updateLocalAddresses() } timer.resume() addressTimer = timer } private func stopAddressUpdates() { addressTimer?.cancel() addressTimer = nil } private func updateLocalAddresses() { let addresses = Self.localInterfaceAddresses() DispatchQueue.main.async { self.localAddresses = addresses } } private func startLocalNetworkPrompt() { guard browser == nil else { return } let parameters = NWParameters.tcp let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: parameters) browser.stateUpdateHandler = { _ in } browser.browseResultsChangedHandler = { _, _ in } self.browser = browser browser.start(queue: queue) } private func stopLocalNetworkPrompt() { browser?.cancel() browser = nil } private func broadcast(data: Data) { for (key, client) in clients { client.connection.send(content: data, completion: .contentProcessed { [weak self] error in if error != nil { self?.clients.removeValue(forKey: key) DispatchQueue.main.async { self?.sseClientCount = self?.clients.count ?? 0 } } }) } } private func jsonLine(from envelope: WinnerEnvelope) -> String { let encoder = JSONEncoder() if let data = try? encoder.encode(envelope), let string = String(data: data, encoding: .utf8) { return string } return "{}" } private func initialFeedJSON() -> String { return "{\"schema\":1,\"generated_at\":1767716400,\"feed\":[{\"id\":\"demo:welcome\",\"type\":\"INFO\",\"title\":\"Glass Now online\",\"subtitle\":\"Connected to iPhone\",\"priority\":0.8,\"ttl_sec\":86400,\"bucket\":\"RIGHT_NOW\",\"actions\":[\"DISMISS\"]},{\"id\":\"demo:next\",\"type\":\"INFO\",\"title\":\"Next: Calendar\",\"subtitle\":\"Then Weather + POI\",\"priority\":0.4,\"ttl_sec\":86400,\"bucket\":\"FYI\",\"actions\":[\"DISMISS\"]}],\"meta\":{\"winner_id\":\"demo:welcome\",\"unread_count\":2}}" } private func statusJSON() -> String { let uptime = Int(Date().timeIntervalSince(startDate)) return "{\"server\":\"iphone\",\"version\":\"v1\",\"uptime_sec\":\(uptime)}" } private func sseEvent(name: String, payload: String?) -> Data { let dataLine = payload ?? "{}" let message = "event: \(name)\n" + "data: \(dataLine)\n\n" return Data(message.utf8) } } private struct SSEClient { let connection: NWConnection } private extension LocalServer { static func localInterfaceAddresses() -> [String] { var results: [String] = [] var addrList: UnsafeMutablePointer? guard getifaddrs(&addrList) == 0, let firstAddr = addrList else { return results } defer { freeifaddrs(addrList) } var ptr: UnsafeMutablePointer? = firstAddr while let addr = ptr?.pointee { let flags = Int32(addr.ifa_flags) let isUp = (flags & IFF_UP) != 0 let isLoopback = (flags & IFF_LOOPBACK) != 0 guard isUp, !isLoopback, let sa = addr.ifa_addr else { ptr = addr.ifa_next continue } let family = sa.pointee.sa_family if family == UInt8(AF_INET) { var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) let result = getnameinfo( sa, socklen_t(sa.pointee.sa_len), &hostname, socklen_t(hostname.count), nil, 0, NI_NUMERICHOST ) if result == 0, let ip = String(validatingUTF8: hostname) { let name = String(cString: addr.ifa_name) results.append("\(name): \(ip)") } } ptr = addr.ifa_next } return results.sorted() } }