PadXcode-Daemon/Daemon/DaemonController.swift

156 lines
6.5 KiB
Swift
Raw Permalink Normal View History

2026-04-12 00:42:51 -07:00
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 ""
}
}
}
2026-04-12 22:14:13 -07:00
var formattedTime: String { DaemonLogLine.timeFormatter.string(from: date) }
private static let timeFormatter: DateFormatter = { let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f }()
2026-04-12 00:42:51 -07:00
}
2026-04-12 10:16:21 -07:00
@MainActor
2026-04-12 22:14:13 -07:00
final class DaemonController: ObservableObject {
@Published var serverState: ServerState = .stopped
@Published var connectedClients: [ConnectedClient] = []
@Published var logLines: [DaemonLogLine] = []
@Published var buildCount: Int = 0
2026-04-12 00:42:51 -07:00
var preferences: AppPreferences
private var vaporApp: Application?
private let maxLogLines = 500
init(preferences: AppPreferences) { self.preferences = preferences }
2026-04-12 10:16:21 -07:00
// 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
2026-04-12 00:42:51 -07:00
func start() async {
guard !serverState.isRunning else { return }
serverState = .starting
2026-04-12 22:14:13 -07:00
buildCount = 0
2026-04-12 00:42:51 -07:00
appendLog("Starting PadXcode daemon on port \(preferences.port)", level: .info)
do {
var env = try Environment.detect()
2026-04-12 10:16:21 -07:00
if !DaemonController.loggingBootstrapped {
try LoggingSystem.bootstrap(from: &env)
DaemonController.loggingBootstrapped = true
}
let app = try await Application.make(env)
2026-04-12 00:42:51 -07:00
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()
2026-04-12 22:14:13 -07:00
// 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)
}
2026-04-12 00:42:51 -07:00
} catch {
serverState = .error(error.localizedDescription)
appendLog("❌ Failed to start: \(error.localizedDescription)", level: .error)
}
}
func stop() async {
2026-04-12 22:14:13 -07:00
// 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
}
2026-04-12 00:42:51 -07:00
appendLog("Stopping daemon…", level: .info)
2026-04-12 10:16:21 -07:00
try? await vaporApp?.asyncShutdown()
2026-04-12 00:42:51 -07:00
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() } }
2026-04-12 22:14:13 -07:00
// 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
}
2026-04-12 00:42:51 -07:00
}