PadXcode-Daemon/Daemon/DaemonController.swift
2026-04-12 22:14:13 -07:00

155 lines
6.5 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import Vapor
enum ServerState: Equatable {
case stopped
case starting
case running(port: Int, startedAt: Date)
case error(String)
var isRunning: Bool { if case .running = self { return true }; return false }
var statusLabel: String {
switch self {
case .stopped: return "Stopped"
case .starting: return "Starting…"
case .running: return "Running"
case .error(let m): return "Error: \(m)"
}
}
var uptime: String? {
guard case .running(_, let start) = self else { return nil }
let e = Date().timeIntervalSince(start)
if e < 60 { return String(format: "%.0fs", e) }
if e < 3600 { return String(format: "%dm %ds", Int(e/60), Int(e)%60) }
return String(format: "%dh %dm", Int(e/3600), Int(e/60)%60)
}
}
struct ConnectedClient: Identifiable {
let id = UUID(); let sessionId: String; let connectedAt: Date; let type: ClientType
enum ClientType: String { case build = "Build Stream"; case lsp = "LSP Stream" }
}
struct DaemonLogLine: Identifiable {
let id = UUID(); let date = Date(); let message: String; let level: Level
enum Level { case info, success, warning, error
var prefix: String {
switch self {
case .info: return ""; case .success: return ""
case .warning: return "⚠️"; case .error: return ""
}
}
}
var formattedTime: String { DaemonLogLine.timeFormatter.string(from: date) }
private static let timeFormatter: DateFormatter = { let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f }()
}
@MainActor
final class DaemonController: ObservableObject {
@Published var serverState: ServerState = .stopped
@Published var connectedClients: [ConnectedClient] = []
@Published var logLines: [DaemonLogLine] = []
@Published var buildCount: Int = 0
var preferences: AppPreferences
private var vaporApp: Application?
private let maxLogLines = 500
init(preferences: AppPreferences) { self.preferences = preferences }
// LoggingSystem can only be bootstrapped once per process lifetime.
// Guard with a static flag so restarts don't crash NIO's internal storage box.
private static var loggingBootstrapped = false
func start() async {
guard !serverState.isRunning else { return }
serverState = .starting
buildCount = 0
appendLog("Starting PadXcode daemon on port \(preferences.port)", level: .info)
do {
var env = try Environment.detect()
if !DaemonController.loggingBootstrapped {
try LoggingSystem.bootstrap(from: &env)
DaemonController.loggingBootstrapped = true
}
let app = try await Application.make(env)
app.http.server.configuration.hostname = "0.0.0.0"
app.http.server.configuration.port = preferences.port
try configure(app, controller: self)
try await app.startup()
// If stop() was called while we were awaiting startup, abort.
guard case .starting = serverState else {
try? await app.asyncShutdown()
return
}
vaporApp = app
// Verify the HTTP server is actually listening before declaring running.
// app.startup() returning does not guarantee the port is bound yet.
let port = preferences.port
let verified = await verifyListening(port: port)
if verified {
serverState = .running(port: port, startedAt: Date())
appendLog("✅ Daemon running on 0.0.0.0:\(port)", level: .success)
appendLog("📡 Tailscale IP: \(preferences.tailscaleIP)", level: .info)
} else {
serverState = .error("Server started but port \(port) did not respond")
appendLog("❌ Server did not respond on port \(port) after startup", level: .error)
}
} catch {
serverState = .error(error.localizedDescription)
appendLog("❌ Failed to start: \(error.localizedDescription)", level: .error)
}
}
func stop() async {
// Also handle .starting: if stop() arrives while Vapor is starting up,
// mark as stopped so the app doesn't become orphaned.
switch serverState {
case .running, .starting: break
default: return
}
appendLog("Stopping daemon…", level: .info)
try? await vaporApp?.asyncShutdown()
vaporApp = nil; serverState = .stopped; connectedClients = []
appendLog("Daemon stopped.", level: .info)
}
func restart() async { await stop(); try? await Task.sleep(for: .milliseconds(500)); await start() }
func clientConnected(sessionId: String, type: ConnectedClient.ClientType) {
connectedClients.append(ConnectedClient(sessionId: sessionId, connectedAt: Date(), type: type))
appendLog("Client connected: \(type.rawValue) [\(sessionId.prefix(8))]", level: .info)
}
func clientDisconnected(sessionId: String) {
connectedClients.removeAll { $0.sessionId == sessionId }
appendLog("Client disconnected [\(sessionId.prefix(8))]", level: .info)
}
func buildStarted(scheme: String) { buildCount += 1; appendLog("Build #\(buildCount): \(scheme)", level: .info) }
func appendLog(_ message: String, level: DaemonLogLine.Level) {
logLines.append(DaemonLogLine(message: message, level: level))
if logLines.count > maxLogLines { logLines.removeFirst(logLines.count - maxLogLines) }
}
func clearLog() { logLines = [] }
func applyPortChange() async { if serverState.isRunning { await restart() } }
// MARK: - Private: verify server is actually listening on the port
private func verifyListening(port: Int) async -> Bool {
// Retry up to 10 times with 300ms spacing gives Vapor 3s to bind the port.
for attempt in 1...10 {
guard let url = URL(string: "http://127.0.0.1:\(port)/health") else { return false }
var req = URLRequest(url: url)
req.timeoutInterval = 1
if let (_, resp) = try? await URLSession.shared.data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 200 {
appendLog("🔎 Health check passed on attempt \(attempt)", level: .info)
return true
}
try? await Task.sleep(for: .milliseconds(300))
}
return false
}
}