// BuildService.swift // All shared models (FileNode, ConnectedDevice, ConsoleLine, etc.) // live in Shared/SharedModels.swift — do not redeclare them here. import Foundation import Combine import SwiftUI @MainActor final class BuildService: ObservableObject { @Published var fileTree: FileNode? @Published var devices: [ConnectedDevice] = [] @Published var consoleLines: [ConsoleLine] = [] @Published var isBuilding: Bool = false @Published var errorMessage: String? let historyStore = BuildHistoryStore() let healthMonitor: DaemonHealthMonitor let config: DaemonConfiguration private var webSocketTask: URLSessionWebSocketTask? private var buildStartTime: Date? private var currentBuildRecord: PartialBuildRecord? private struct PartialBuildRecord { let id = UUID() let projectName: String let scheme: String let action: String let configuration: String let destination: String var errorCount = 0 var warningCount = 0 var logBuffer = "" } init(config: DaemonConfiguration) { self.config = config self.healthMonitor = DaemonHealthMonitor(config: config) healthMonitor.onReconnected = { [weak self] in await self?.onDaemonReconnected() } healthMonitor.onDisconnected = { [weak self] in self?.handleDaemonDisconnected() } healthMonitor.startPolling() } // MARK: - File Tree func fetchFileTree(projectPath: String) async { guard let url = URL(string: "\(config.baseURL)/files/tree?path=\(projectPath.urlEncoded)") else { return } do { let (data, _) = try await URLSession.shared.data(from: url) fileTree = try JSONDecoder().decode(FileNode.self, from: data) } catch { errorMessage = "File tree: \(error.localizedDescription)" } } // MARK: - File Content func fetchFileContent(path: String) async -> String? { guard let url = URL(string: "\(config.baseURL)/files/content?path=\(path.urlEncoded)") else { return nil } do { let (data, _) = try await URLSession.shared.data(from: url) return try JSONDecoder().decode(FileContentResponse.self, from: data).content } catch { errorMessage = "Load file: \(error.localizedDescription)" return nil } } // MARK: - File Save func saveFile(path: String, content: String) async { do { try FileOperationValidator.validateSave(path: path, content: content) } catch { appendConsole("⚠️ \(error.localizedDescription)", type: .warning) return } do { try await withRetry(policy: .fileSave) { guard let url = URL(string: "\(self.config.baseURL)/files/save") else { throw URLError(.badURL) } let body: [String: String] = ["path": path, "content": content] guard let data = try? JSONEncoder().encode(body) else { throw URLError(.cannotDecodeContentData) } var req = URLRequest(url: url) req.httpMethod = "PUT" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = data req.timeoutInterval = 8 let (_, resp) = try await URLSession.shared.data(for: req) guard (resp as? HTTPURLResponse)?.statusCode == 204 else { throw URLError(.badServerResponse) } } } catch { appendConsole("❌ Save failed: \(error.localizedDescription)", type: .error) ToastStore.shared.show("Save failed", style: .error) } } // MARK: - Devices func fetchDevices() async { guard let url = URL(string: "\(config.baseURL)/devices/list") else { return } do { let (data, _) = try await URLSession.shared.data(from: url) devices = try JSONDecoder().decode([ConnectedDevice].self, from: data) } catch { errorMessage = "Devices: \(error.localizedDescription)" } } // MARK: - Build func startBuild( projectPath: String, scheme: String, selectedDevice: ConnectedDevice?, action: String = "build", preBuildHooks: [PreBuildHookRequest] = [], bundleIdentifier: String? = nil ) async { guard !isBuilding else { return } isBuilding = true buildStartTime = Date() consoleLines = [] let destination: String let destinationLabel: String if let device = selectedDevice { destination = "platform=iOS,id=\(device.udid)" destinationLabel = "\(device.name) (\(device.osVersion))" } else { destination = "" destinationLabel = "Build Only" } let resolvedAction = (selectedDevice == nil) ? "build" : action if selectedDevice == nil && action == "buildAndRun" { appendConsole("⚠️ No device selected — building without deploying.", type: .warning) } currentBuildRecord = PartialBuildRecord( projectName: URL(fileURLWithPath: projectPath).lastPathComponent, scheme: scheme, action: resolvedAction, configuration: config.buildConfiguration, destination: destinationLabel ) let buildReq = BuildRequest( projectPath: projectPath, scheme: scheme, destination: destination, action: resolvedAction, configuration: config.buildConfiguration, preBuildHooks: preBuildHooks, bundleIdentifier: bundleIdentifier, extraBuildSettings: [] ) guard let url = URL(string: "\(config.baseURL)/build/start"), let bodyData = try? JSONEncoder().encode(buildReq) else { isBuilding = false; return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = bodyData do { let (data, _) = try await URLSession.shared.data(for: request) let session = try JSONDecoder().decode(BuildSessionResponse.self, from: data) await connectBuildStream(webSocketURL: session.webSocketURL) } catch { appendConsole("❌ Failed to reach daemon: \(error.localizedDescription)", type: .error) isBuilding = false commitBuildRecord(outcome: .failure) } } // MARK: - WebSocket private func connectBuildStream(webSocketURL: String) async { guard let url = URL(string: webSocketURL) else { return } webSocketTask?.cancel() webSocketTask = URLSession.shared.webSocketTask(with: url) webSocketTask?.resume() appendConsole("🔌 Connected to build stream.", type: .info) await receiveLoop() } private func receiveLoop() async { guard let task = webSocketTask, task.state == .running else { isBuilding = false; return } do { let message = try await task.receive() switch message { case .string(let text): handleIncomingLog(text) case .data(let data): if let text = String(data: data, encoding: .utf8) { handleIncomingLog(text) } @unknown default: break } await receiveLoop() } catch { isBuilding = false commitBuildRecord(outcome: .failure) } } private func handleIncomingLog(_ text: String) { guard let data = text.data(using: .utf8), let msg = try? JSONDecoder().decode(BuildLogMessage.self, from: data) else { appendConsole(text, type: .standard); return } let newLines = ConsoleLine.fromBuildLogMessage(msg) consoleLines.append(contentsOf: newLines) if var rec = currentBuildRecord { rec.errorCount += newLines.filter { $0.type == .error }.count rec.warningCount += newLines.filter { $0.type == .warning }.count rec.logBuffer = String((rec.logBuffer + newLines.map(\.text).joined(separator: "\n")).suffix(2000)) currentBuildRecord = rec } if msg.type == .status { isBuilding = false commitBuildRecord(outcome: msg.exitCode == 0 ? .success : .failure) } } // MARK: - History private func commitBuildRecord(outcome: BuildRecord.Outcome) { guard let rec = currentBuildRecord else { return } let duration = buildStartTime.map { Date().timeIntervalSince($0) } ?? 0 historyStore.append(BuildRecord( id: rec.id, projectName: rec.projectName, scheme: rec.scheme, date: buildStartTime ?? Date(), duration: duration, action: rec.action, configuration: rec.configuration, destination: rec.destination, outcome: outcome, errorCount: rec.errorCount, warningCount: rec.warningCount, logSnippet: String(rec.logBuffer.suffix(500)) )) currentBuildRecord = nil buildStartTime = nil } // MARK: - Reconnection private func onDaemonReconnected() async { appendConsole("🔌 Reconnected to daemon.", type: .info) await fetchDevices() } private func handleDaemonDisconnected() { guard isBuilding else { return } appendConsole("⚠️ Lost connection during build.", type: .error) isBuilding = false commitBuildRecord(outcome: .failure) } // MARK: - Console func appendConsole(_ text: String, type: ConsoleLine.LineType) { let lines = text.split(separator: "\n", omittingEmptySubsequences: false) .map { ConsoleLine(text: String($0), type: type) } consoleLines.append(contentsOf: lines) } func clearConsole() { consoleLines = [] } }