Files
aris-old/IrisCompanion/iris/Network/LocalServer.swift

371 lines
13 KiB
Swift
Raw Permalink Normal View History

2026-01-08 19:16:32 +00:00
//
// 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()
2026-01-10 00:25:36 +00:00
private var currentEnvelope: FeedEnvelope
2026-01-08 19:16:32 +00:00
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
2026-01-10 00:25:36 +00:00
self.currentEnvelope = FeedEnvelope.allQuiet(now: Int(Date().timeIntervalSince1970), reason: "no_feed", source: "server")
2026-01-08 19:16:32 +00:00
}
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
}
}
2026-01-10 00:25:36 +00:00
func broadcastFeed(_ envelope: FeedEnvelope) {
currentEnvelope = envelope
let winner = envelope.winnerItem()
2026-01-08 19:16:32 +00:00
DispatchQueue.main.async {
2026-01-10 00:25:36 +00:00
self.lastWinnerTitle = winner?.title ?? "All Quiet"
self.lastWinnerSubtitle = winner?.subtitle ?? "No urgent updates"
2026-01-08 19:16:32 +00:00
self.lastBroadcastAt = Date()
}
2026-01-10 00:25:36 +00:00
let data = sseEvent(name: "feed", payload: jsonLine(from: envelope))
2026-01-08 19:16:32 +00:00
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[..<range.lowerBound])
}
private func handleRequest(requestLine: String, connection: NWConnection) {
let parts = requestLine.split(separator: " ")
guard parts.count >= 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
}
}
})
}
}
2026-01-10 00:25:36 +00:00
private func jsonLine(from envelope: FeedEnvelope) -> String {
2026-01-08 19:16:32 +00:00
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<ifaddrs>?
guard getifaddrs(&addrList) == 0, let firstAddr = addrList else {
return results
}
defer { freeifaddrs(addrList) }
var ptr: UnsafeMutablePointer<ifaddrs>? = 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()
}
}