305 lines
11 KiB
Swift
305 lines
11 KiB
Swift
// 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
|
|
|
|
var 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 else {
|
|
isBuilding = false; return
|
|
}
|
|
// Use a while loop — not recursion — to avoid unbounded call stack growth
|
|
// on long builds with many log messages.
|
|
while task.state == .running {
|
|
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
|
|
}
|
|
// Break out if the build completed via status message
|
|
if !isBuilding { break }
|
|
} catch {
|
|
// Socket closed or error — only record failure if still building
|
|
// (avoids double-recording when build completes normally)
|
|
if isBuilding {
|
|
isBuilding = false
|
|
commitBuildRecord(outcome: .failure)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
// Build completed normally — close the WebSocket to free resources.
|
|
webSocketTask?.cancel(with: .normalClosure, reason: nil)
|
|
webSocketTask = nil
|
|
}
|
|
|
|
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 = [] }
|
|
|
|
/// Rewires this service to use the real environment DaemonConfiguration.
|
|
/// Must be called from ContentView.initialLoad() since @EnvironmentObject
|
|
/// is unavailable at ContentView.init() time, causing a duplicate object
|
|
/// to be created in init(). This replaces that placeholder with the real one.
|
|
func rewireConfig(_ config: DaemonConfiguration, gitStore: GitStore) {
|
|
self.config = config
|
|
healthMonitor.stopPolling()
|
|
healthMonitor.config = config
|
|
healthMonitor.startPolling()
|
|
}
|
|
}
|