93 lines
4.3 KiB
Swift
93 lines
4.3 KiB
Swift
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()
|
|
private var activeProcess: Process?
|
|
|
|
init(id: String, logger: Logger) { self.id = id; self.logger = logger }
|
|
|
|
func addClient(_ ws: WebSocket) { lock.lock(); defer { lock.unlock() }; clients.append(ws) }
|
|
|
|
/// 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))
|
|
}
|
|
|
|
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()
|
|
for ws in snap where !ws.isClosed { let w = ws; Task { try? await w.send(text) } }
|
|
}
|
|
|
|
@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
|
|
|
|
// 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()
|
|
|
|
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()) {
|
|
self?.lock.lock(); self?.activeProcess = nil; self?.lock.unlock()
|
|
let msg = code == 0 ? "✅ Build succeeded." : "❌ Build failed (exit \(code))."
|
|
self?.broadcast(BuildLogMessage(type: .status, text: msg, exitCode: Int(code)))
|
|
guard_.resume(continuation: continuation, returning: code)
|
|
}
|
|
}
|
|
do {
|
|
try process.run()
|
|
lock.lock(); activeProcess = process; lock.unlock()
|
|
logger.info("xcodebuild started. PID: \(process.processIdentifier)")
|
|
} catch {
|
|
broadcast(BuildLogMessage(type: .stderr, text: "Failed to launch xcodebuild: \(error)", exitCode: nil))
|
|
guard_.resume(continuation: continuation, returning: -1)
|
|
}
|
|
}
|
|
}
|
|
}
|