PadXcode-iPad/Network/BuildService.swift

306 lines
11 KiB
Swift
Raw Normal View History

2026-04-12 00:46:30 -07:00
// 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
2026-04-12 22:07:35 -07:00
var config: DaemonConfiguration
2026-04-12 00:46:30 -07:00
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 {
2026-04-12 22:07:35 -07:00
guard let task = webSocketTask else {
2026-04-12 00:46:30 -07:00
isBuilding = false; return
}
2026-04-12 22:07:35 -07:00
// 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
2026-04-12 00:46:30 -07:00
}
}
2026-04-12 22:07:35 -07:00
// Build completed normally close the WebSocket to free resources.
webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil
2026-04-12 00:46:30 -07:00
}
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 = [] }
2026-04-12 22:07:35 -07:00
/// 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()
}
2026-04-12 00:46:30 -07:00
}