2026-04-12 00:42:51 -07:00
|
|
|
import Vapor
|
|
|
|
|
import Foundation
|
|
|
|
|
|
|
|
|
|
actor BuildSessionStore {
|
|
|
|
|
static let shared = BuildSessionStore()
|
|
|
|
|
private var sessions: [String: BuildSession] = [:]
|
|
|
|
|
func create(session: BuildSession) { sessions[session.id] = session }
|
|
|
|
|
func session(for id: String) -> BuildSession? { sessions[id] }
|
|
|
|
|
func remove(id: String) { sessions.removeValue(forKey: id) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final class BuildSession: @unchecked Sendable {
|
|
|
|
|
let id: String
|
|
|
|
|
private let logger: Logger
|
|
|
|
|
private var clients: [WebSocket] = []
|
|
|
|
|
private let lock = NSLock()
|
|
|
|
|
|
|
|
|
|
init(id: String, logger: Logger) { self.id = id; self.logger = logger }
|
|
|
|
|
|
|
|
|
|
func addClient(_ ws: WebSocket) { lock.lock(); defer { lock.unlock() }; clients.append(ws) }
|
|
|
|
|
|
|
|
|
|
func broadcast(_ message: BuildLogMessage) {
|
|
|
|
|
guard let data = try? JSONEncoder().encode(message),
|
|
|
|
|
let text = String(data: data, encoding: .utf8) else { return }
|
|
|
|
|
lock.lock(); let snap = clients; lock.unlock()
|
2026-04-12 10:16:21 -07:00
|
|
|
for ws in snap where !ws.isClosed { let w = ws; Task { try? await w.send(text) } }
|
2026-04-12 00:42:51 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
|
func runXcodebuild(arguments: [String]) async -> Int32 {
|
|
|
|
|
await withCheckedContinuation { continuation in
|
|
|
|
|
let process = Process()
|
|
|
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcodebuild")
|
|
|
|
|
process.arguments = arguments
|
|
|
|
|
let outPipe = Pipe(); let errPipe = Pipe()
|
|
|
|
|
process.standardOutput = outPipe; process.standardError = errPipe
|
|
|
|
|
let group = DispatchGroup()
|
|
|
|
|
group.enter()
|
|
|
|
|
outPipe.fileHandleForReading.readabilityHandler = { [weak self] h in
|
|
|
|
|
let d = h.availableData
|
|
|
|
|
if d.isEmpty { outPipe.fileHandleForReading.readabilityHandler = nil; group.leave(); return }
|
|
|
|
|
if let t = String(data: d, encoding: .utf8) { self?.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) }
|
|
|
|
|
}
|
|
|
|
|
group.enter()
|
|
|
|
|
errPipe.fileHandleForReading.readabilityHandler = { [weak self] h in
|
|
|
|
|
let d = h.availableData
|
|
|
|
|
if d.isEmpty { errPipe.fileHandleForReading.readabilityHandler = nil; group.leave(); return }
|
|
|
|
|
if let t = String(data: d, encoding: .utf8) { self?.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) }
|
|
|
|
|
}
|
|
|
|
|
process.terminationHandler = { [weak self] proc in
|
|
|
|
|
let code = proc.terminationStatus
|
|
|
|
|
group.notify(queue: .global()) {
|
|
|
|
|
let msg = code == 0 ? "✅ Build succeeded." : "❌ Build failed (exit \(code))."
|
|
|
|
|
self?.broadcast(BuildLogMessage(type: .status, text: msg, exitCode: Int(code)))
|
|
|
|
|
continuation.resume(returning: code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
do { try process.run(); logger.info("xcodebuild started. PID: \(process.processIdentifier)") }
|
|
|
|
|
catch {
|
|
|
|
|
broadcast(BuildLogMessage(type: .stderr, text: "Failed to launch xcodebuild: \(error)", exitCode: nil))
|
|
|
|
|
continuation.resume(returning: -1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|