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, 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) } } } }