PadXcode-Daemon/Daemon/BuildManager.swift

94 lines
4.3 KiB
Swift
Raw Permalink Normal View History

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()
2026-04-12 22:14:13 -07:00
private var activeProcess: Process?
2026-04-12 00:42:51 -07:00
init(id: String, logger: Logger) { self.id = id; self.logger = logger }
func addClient(_ ws: WebSocket) { lock.lock(); defer { lock.unlock() }; clients.append(ws) }
2026-04-12 22:14:13 -07:00
/// Terminate the active xcodebuild process. Called when iPad disconnects mid-build.
func cancelBuild() {
lock.lock(); let p = activeProcess; lock.unlock()
p?.terminate()
broadcast(BuildLogMessage(type: .status,
text: "⚠️ Build cancelled by client disconnect.", exitCode: -2))
}
2026-04-12 00:42:51 -07:00
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
2026-04-12 22:14:13 -07:00
// Guard against double-resume: if process.run() throws, the pipe handlers
// still fire with empty data group.notify would call resume a second time.
final class ResumeOnce {
private let lock = NSLock()
private var done = false
func resume(continuation: CheckedContinuation<Int32, Never>, returning value: Int32) {
lock.lock(); defer { lock.unlock() }
guard !done else { return }
done = true
continuation.resume(returning: value)
}
}
let guard_ = ResumeOnce()
2026-04-12 00:42:51 -07:00
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()) {
2026-04-12 22:14:13 -07:00
self?.lock.lock(); self?.activeProcess = nil; self?.lock.unlock()
2026-04-12 00:42:51 -07:00
let msg = code == 0 ? "✅ Build succeeded." : "❌ Build failed (exit \(code))."
self?.broadcast(BuildLogMessage(type: .status, text: msg, exitCode: Int(code)))
2026-04-12 22:14:13 -07:00
guard_.resume(continuation: continuation, returning: code)
2026-04-12 00:42:51 -07:00
}
}
2026-04-12 22:14:13 -07:00
do {
try process.run()
lock.lock(); activeProcess = process; lock.unlock()
logger.info("xcodebuild started. PID: \(process.processIdentifier)")
} catch {
2026-04-12 00:42:51 -07:00
broadcast(BuildLogMessage(type: .stderr, text: "Failed to launch xcodebuild: \(error)", exitCode: nil))
2026-04-12 22:14:13 -07:00
guard_.resume(continuation: continuation, returning: -1)
2026-04-12 00:42:51 -07:00
}
}
}
}