155 lines
6.5 KiB
Swift
155 lines
6.5 KiB
Swift
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
|
||
}
|
||
}
|