384 lines
14 KiB
Swift
384 lines
14 KiB
Swift
|
|
//
|
||
|
|
// 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()
|
||
|
|
}
|
||
|
|
}
|