initial commit
This commit is contained in:
383
IrisCompanion/iris/Network/LocalServer.swift
Normal file
383
IrisCompanion/iris/Network/LocalServer.swift
Normal file
@@ -0,0 +1,383 @@
|
||||
//
|
||||
// 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[..<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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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<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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user