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 } }