2026-04-12 00:42:51 -07:00
|
|
|
|
import Foundation
|
|
|
|
|
|
import Vapor
|
|
|
|
|
|
import Observation
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
|
let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f.string(from: date)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Observable
|
2026-04-12 10:16:21 -07:00
|
|
|
|
@MainActor
|
2026-04-12 00:42:51 -07:00
|
|
|
|
final class DaemonController {
|
|
|
|
|
|
var serverState: ServerState = .stopped
|
|
|
|
|
|
var connectedClients: [ConnectedClient] = []
|
|
|
|
|
|
var logLines: [DaemonLogLine] = []
|
|
|
|
|
|
var buildCount: Int = 0
|
|
|
|
|
|
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
|
|
|
|
|
|
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()
|
|
|
|
|
|
vaporApp = app
|
|
|
|
|
|
serverState = .running(port: preferences.port, startedAt: Date())
|
|
|
|
|
|
appendLog("✅ Daemon running on 0.0.0.0:\(preferences.port)", level: .success)
|
|
|
|
|
|
appendLog("📡 Tailscale IP: \(preferences.tailscaleIP)", level: .info)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
serverState = .error(error.localizedDescription)
|
|
|
|
|
|
appendLog("❌ Failed to start: \(error.localizedDescription)", level: .error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func stop() async {
|
|
|
|
|
|
guard serverState.isRunning else { return }
|
|
|
|
|
|
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() } }
|
|
|
|
|
|
}
|