// // ServerController.swift // Zyquo Router // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Owns the embedded server's lifecycle for the app: Start/Stop, state for // the status pill, and the served endpoint URL. The server itself runs in a // detached task; stopping cancels it, which drains the accept loop and // cancels in-flight requests (and their upstream calls). // import Foundation import SwiftUI @MainActor final class ServerController: ObservableObject { enum State: Equatable { case stopped case starting case running(port: Int) case failed(String) } @Published private(set) var state: State = .stopped @AppStorage("serverPort") var port = 8787 @AppStorage("bindLAN") var bindLAN = false /// Shared with Routes at Start; the dashboard reads totals from it. let usageMeter = UsageMeter() /// Live traffic log shared with the Requests screen. let requestLog = RequestLogStore() /// When the current run started (uptime tile). @Published private(set) var startedAt: Date? private var serverTask: Task? var endpointURL: String { "http://localhost:\(port)/v1" } var isRunning: Bool { if case .running = state { return true } return false } func start() { guard serverTask == nil else { return } state = .starting let host = bindLAN ? "0.0.0.0" : "127.0.0.1" let port = port let localKeys = PersistenceService.shared.load([APIKeyRecord].self, from: "local-keys.json") ?? [] // LAN exposure is gated behind at least one local API key (D12). if bindLAN, !localKeys.contains(where: \.enabled) { state = .failed("LAN mode requires at least one enabled local API key — create one in Keys, or switch back to Localhost only.") return } let routerConfig = RouterConfigStore.snapshot() let routes = Routes( router: RequestRouter( aliases: routerConfig.aliases, disabledIDs: routerConfig.disabledIDs, fallbackChains: routerConfig.fallbackChains ), auth: AuthMiddleware(keys: localKeys), usageMeter: usageMeter, requestLog: requestLog ) let server = HTTPServer(host: host, port: port) { request in await routes.handle(request) } // The Task inherits MainActor isolation, so state updates after `run` // returns are direct; only the nonisolated onRunning callback hops. serverTask = Task { do { try await server.run { Task { @MainActor [weak self] in guard let self else { return } self.state = .running(port: port) self.startedAt = Date() } } self.serverTask = nil self.state = .stopped self.startedAt = nil } catch is CancellationError { self.serverTask = nil self.state = .stopped self.startedAt = nil } catch { self.serverTask = nil self.state = .failed(error.localizedDescription) self.startedAt = nil } } } func stop() { serverTask?.cancel() } func toggle() { isRunning || state == .starting ? stop() : start() } }