From 23cd2afe31d9011f2787bc582830cd2b97218d0e Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sun, 12 Apr 2026 22:14:13 -0700 Subject: [PATCH] project setup --- App/PadXcodeDaemonApp.swift | 43 +++++-- Daemon/BuildManager.swift | 36 +++++- Daemon/DaemonController.swift | 68 ++++++++--- Daemon/DeviceManager.swift | 103 +++++++++++----- Daemon/LSPProxy.swift | 46 +++++++- Daemon/PreBuildRunner.swift | 38 ++++-- Daemon/Setup.swift | 9 +- Daemon/routes.swift | 195 ++++++++++++++++++++++++------- Preferences/AppPreferences.swift | 17 ++- UI/DaemonLogWindowView.swift | 2 +- UI/DaemonSettingsView.swift | 4 +- UI/MenuBarView.swift | 22 +++- 12 files changed, 454 insertions(+), 129 deletions(-) diff --git a/App/PadXcodeDaemonApp.swift b/App/PadXcodeDaemonApp.swift index 72d24e0..8d21804 100644 --- a/App/PadXcodeDaemonApp.swift +++ b/App/PadXcodeDaemonApp.swift @@ -1,27 +1,50 @@ import SwiftUI +import AppKit + +// MARK: - App Delegate + +final class PadXcodeDaemonDelegate: NSObject, NSApplicationDelegate { + + func applicationDidFinishLaunching(_ notification: Notification) { + // Start daemon on app launch. The controller is created by the App + // struct before this fires and stored in the shared reference below. + guard let controller = PadXcodeDaemonApp.sharedController else { return } + Task { @MainActor in + await controller.start() + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return false + } +} + +// MARK: - App @main struct PadXcodeDaemonApp: App { - @StateObject private var preferences = AppPreferences() - @State private var controller: DaemonController + @NSApplicationDelegateAdaptor(PadXcodeDaemonDelegate.self) private var appDelegate + @StateObject private var preferences = AppPreferences() + @StateObject private var controller: DaemonController + + // Static reference so the delegate can access the controller before + // SwiftUI finishes wiring up @State bindings. + static var sharedController: DaemonController? init() { ensureConfigFile() let prefs = AppPreferences() - _preferences = StateObject(wrappedValue: prefs) - _controller = State(wrappedValue: DaemonController(preferences: prefs)) + let ctrl = DaemonController(preferences: prefs) + _preferences = StateObject(wrappedValue: prefs) + _controller = StateObject(wrappedValue: ctrl) + // Store before applicationDidFinishLaunching fires. + PadXcodeDaemonApp.sharedController = ctrl } var body: some Scene { MenuBarExtra { MenuBarView(controller: controller, preferences: preferences) - .task { - // Start daemon on first appearance if not already running - if !controller.serverState.isRunning { - await controller.start() - } - } } label: { MenuBarIcon(controller: controller) } diff --git a/Daemon/BuildManager.swift b/Daemon/BuildManager.swift index 4b7c642..e2d0137 100644 --- a/Daemon/BuildManager.swift +++ b/Daemon/BuildManager.swift @@ -14,11 +14,20 @@ final class BuildSession: @unchecked Sendable { 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 } @@ -34,6 +43,21 @@ final class BuildSession: @unchecked Sendable { 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 @@ -50,15 +74,19 @@ final class BuildSession: @unchecked Sendable { 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))) - continuation.resume(returning: code) + guard_.resume(continuation: continuation, returning: code) } } - do { try process.run(); logger.info("xcodebuild started. PID: \(process.processIdentifier)") } - catch { + 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)) - continuation.resume(returning: -1) + guard_.resume(continuation: continuation, returning: -1) } } } diff --git a/Daemon/DaemonController.swift b/Daemon/DaemonController.swift index a51d924..d69d058 100644 --- a/Daemon/DaemonController.swift +++ b/Daemon/DaemonController.swift @@ -1,6 +1,5 @@ import Foundation import Vapor -import Observation enum ServerState: Equatable { case stopped @@ -41,18 +40,16 @@ struct DaemonLogLine: Identifiable { } } } - var formattedTime: String { - let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f.string(from: date) - } + var formattedTime: String { DaemonLogLine.timeFormatter.string(from: date) } + private static let timeFormatter: DateFormatter = { let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f }() } -@Observable @MainActor -final class DaemonController { - var serverState: ServerState = .stopped - var connectedClients: [ConnectedClient] = [] - var logLines: [DaemonLogLine] = [] - var buildCount: Int = 0 +final class DaemonController: ObservableObject { + @Published var serverState: ServerState = .stopped + @Published var connectedClients: [ConnectedClient] = [] + @Published var logLines: [DaemonLogLine] = [] + @Published var buildCount: Int = 0 var preferences: AppPreferences private var vaporApp: Application? private let maxLogLines = 500 @@ -66,6 +63,7 @@ final class DaemonController { func start() async { guard !serverState.isRunning else { return } serverState = .starting + buildCount = 0 appendLog("Starting PadXcode daemon on port \(preferences.port)…", level: .info) do { var env = try Environment.detect() @@ -78,10 +76,27 @@ final class DaemonController { app.http.server.configuration.port = preferences.port try configure(app, controller: self) try await app.startup() - vaporApp = app - serverState = .running(port: preferences.port, startedAt: Date()) - appendLog("✅ Daemon running on 0.0.0.0:\(preferences.port)", level: .success) - appendLog("📡 Tailscale IP: \(preferences.tailscaleIP)", level: .info) + + // If stop() was called while we were awaiting startup, abort. + guard case .starting = serverState else { + try? await app.asyncShutdown() + return + } + + vaporApp = app + + // Verify the HTTP server is actually listening before declaring running. + // app.startup() returning does not guarantee the port is bound yet. + let port = preferences.port + let verified = await verifyListening(port: port) + if verified { + serverState = .running(port: port, startedAt: Date()) + appendLog("✅ Daemon running on 0.0.0.0:\(port)", level: .success) + appendLog("📡 Tailscale IP: \(preferences.tailscaleIP)", level: .info) + } else { + serverState = .error("Server started but port \(port) did not respond") + appendLog("❌ Server did not respond on port \(port) after startup", level: .error) + } } catch { serverState = .error(error.localizedDescription) appendLog("❌ Failed to start: \(error.localizedDescription)", level: .error) @@ -89,7 +104,12 @@ final class DaemonController { } func stop() async { - guard serverState.isRunning else { return } + // Also handle .starting: if stop() arrives while Vapor is starting up, + // mark as stopped so the app doesn't become orphaned. + switch serverState { + case .running, .starting: break + default: return + } appendLog("Stopping daemon…", level: .info) try? await vaporApp?.asyncShutdown() vaporApp = nil; serverState = .stopped; connectedClients = [] @@ -114,4 +134,22 @@ final class DaemonController { } func clearLog() { logLines = [] } func applyPortChange() async { if serverState.isRunning { await restart() } } + + // MARK: - Private: verify server is actually listening on the port + + private func verifyListening(port: Int) async -> Bool { + // Retry up to 10 times with 300ms spacing — gives Vapor 3s to bind the port. + for attempt in 1...10 { + guard let url = URL(string: "http://127.0.0.1:\(port)/health") else { return false } + var req = URLRequest(url: url) + req.timeoutInterval = 1 + if let (_, resp) = try? await URLSession.shared.data(for: req), + (resp as? HTTPURLResponse)?.statusCode == 200 { + appendLog("🔎 Health check passed on attempt \(attempt)", level: .info) + return true + } + try? await Task.sleep(for: .milliseconds(300)) + } + return false + } } diff --git a/Daemon/DeviceManager.swift b/Daemon/DeviceManager.swift index 8bdbc87..574b074 100644 --- a/Daemon/DeviceManager.swift +++ b/Daemon/DeviceManager.swift @@ -4,17 +4,25 @@ import Vapor struct DeviceManager { static func listConnectedDevices(logger: Logger) async -> [ConnectedDevice] { - let tmp = FileManager.default.temporaryDirectory - .appendingPathComponent("padxcode-devices-\(UUID().uuidString).json") - defer { try? FileManager.default.removeItem(at: tmp) } - let p = Process() - p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") - p.arguments = ["devicectl", "list", "devices", "--json-output", tmp.path] - p.standardError = Pipe() - try? p.run(); p.waitUntilExit() - guard p.terminationStatus == 0, - let data = try? Data(contentsOf: tmp) else { return [] } - return parseDevicectlJSON(data, logger: logger) + // Run synchronous Process on a background thread to avoid blocking the NIO event loop. + await withCheckedContinuation { cont in + DispatchQueue.global(qos: .userInitiated).async { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("padxcode-devices-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: tmp) } + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + p.arguments = ["devicectl", "list", "devices", "--json-output", tmp.path] + p.standardError = Pipe() + try? p.run(); p.waitUntilExit() + guard p.terminationStatus == 0, + let data = try? Data(contentsOf: tmp) else { + cont.resume(returning: []) + return + } + cont.resume(returning: parseDevicectlJSON(data, logger: logger)) + } + } } @discardableResult @@ -26,22 +34,44 @@ struct DeviceManager { let out = Pipe(); let err = Pipe() p.standardOutput = out; p.standardError = err return await withCheckedContinuation { cont in + let g = DispatchGroup() + // Clear handlers on empty data to prevent pipe handle leak after process exits. + g.enter() out.fileHandleForReading.readabilityHandler = { h in - let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return } - session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) + let d = h.availableData + if d.isEmpty { out.fileHandleForReading.readabilityHandler = nil; g.leave(); return } + if let t = String(data: d, encoding: .utf8) { + session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) + } } + g.enter() err.fileHandleForReading.readabilityHandler = { h in - let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return } - session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) + let d = h.availableData + if d.isEmpty { err.fileHandleForReading.readabilityHandler = nil; g.leave(); return } + if let t = String(data: d, encoding: .utf8) { + session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) + } } p.terminationHandler = { proc in let code = proc.terminationStatus - session.broadcast(BuildLogMessage(type: .status, - text: code == 0 ? "✅ App installed." : "❌ Install failed (exit \(code)).", - exitCode: Int(code))) - cont.resume(returning: code) + // Wait for both pipes to drain before resuming the continuation, + // otherwise install/launch output is truncated. + g.notify(queue: .global()) { + session.broadcast(BuildLogMessage(type: .status, + text: code == 0 ? "✅ App installed." : "❌ Install failed (exit \(code)).", + exitCode: Int(code))) + cont.resume(returning: code) + } + } + // If xcrun fails to launch, terminationHandler never fires → continuation hangs forever. + // Catch the error and resume immediately. + do { + try p.run() + } catch { + session.broadcast(BuildLogMessage(type: .stderr, + text: "❌ Failed to launch xcrun install: \(error)", exitCode: nil)) + cont.resume(returning: -1) } - try? p.run() } } @@ -53,22 +83,39 @@ struct DeviceManager { let out = Pipe(); let err = Pipe() p.standardOutput = out; p.standardError = err await withCheckedContinuation { (cont: CheckedContinuation) in + let g = DispatchGroup() + g.enter() out.fileHandleForReading.readabilityHandler = { h in - let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return } - session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) + let d = h.availableData + if d.isEmpty { out.fileHandleForReading.readabilityHandler = nil; g.leave(); return } + if let t = String(data: d, encoding: .utf8) { + session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) + } } + g.enter() err.fileHandleForReading.readabilityHandler = { h in - let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return } - session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) + let d = h.availableData + if d.isEmpty { err.fileHandleForReading.readabilityHandler = nil; g.leave(); return } + if let t = String(data: d, encoding: .utf8) { + session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) + } } p.terminationHandler = { proc in let code = proc.terminationStatus - session.broadcast(BuildLogMessage(type: .status, - text: code == 0 ? "✅ App launched." : "⚠️ Launch exit \(code).", - exitCode: Int(code))) + g.notify(queue: .global()) { + session.broadcast(BuildLogMessage(type: .status, + text: code == 0 ? "✅ App launched." : "⚠️ Launch exit \(code).", + exitCode: Int(code))) + cont.resume() + } + } + do { + try p.run() + } catch { + session.broadcast(BuildLogMessage(type: .stderr, + text: "❌ Failed to launch xcrun launch: \(error)", exitCode: nil)) cont.resume() } - try? p.run() } } diff --git a/Daemon/LSPProxy.swift b/Daemon/LSPProxy.swift index e82c463..ff9d214 100644 --- a/Daemon/LSPProxy.swift +++ b/Daemon/LSPProxy.swift @@ -7,11 +7,25 @@ actor LSPProxy { private var stdinPipe: Pipe? private var clients: [WebSocket] = [] - func start(projectPath: String) throws { - guard process == nil else { return } + func start(projectPath: String) async throws { + // If already running for a DIFFERENT project, restart for the new one. + // Keeping the old process would give wrong completions/diagnostics. + if process != nil { + stop() + } + // Resolve sourcekit-lsp via xcrun so we respect the active xcode-select toolchain. + // Run on a background thread because waitUntilExit() blocks. + let lspPath = await withCheckedContinuation { cont in + DispatchQueue.global(qos: .userInitiated).async { + cont.resume(returning: Self.resolveLSPPathSync()) + } + } + guard FileManager.default.fileExists(atPath: lspPath) else { + throw NSError(domain: "LSPProxy", code: 1, + userInfo: [NSLocalizedDescriptionKey: "sourcekit-lsp not found at \(lspPath)"]) + } let p = Process() - p.executableURL = URL(fileURLWithPath: - "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp") + p.executableURL = URL(fileURLWithPath: lspPath) p.currentDirectoryURL = URL(fileURLWithPath: projectPath) let inPipe = Pipe(); let outPipe = Pipe(); let errPipe = Pipe() p.standardInput = inPipe; p.standardOutput = outPipe; p.standardError = errPipe @@ -24,8 +38,8 @@ actor LSPProxy { try p.run(); process = p } - func stop() { process?.terminate(); process = nil; stdinPipe = nil } - private func handleTermination() { process = nil; broadcastStatus("lsp_terminated") } + func stop() { process?.terminate(); process = nil; stdinPipe = nil; clients.removeAll() } + private func handleTermination() { process = nil; broadcastStatus("lsp_terminated"); clients.removeAll() } func addClient(_ ws: WebSocket) { clients.append(ws) } func removeClient(_ ws: WebSocket) { clients.removeAll { $0 === ws } } @@ -51,6 +65,26 @@ actor LSPProxy { for ws in clients where !ws.isClosed { let w = ws; let msg = #"{"padxcode_status":"\#(s)"}"#; Task { try? await w.send(msg) } } } + private static func resolveLSPPathSync() -> String { + // Try xcrun first — respects xcode-select. + let xcrun = Process() + xcrun.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + xcrun.arguments = ["--find", "sourcekit-lsp"] + let pipe = Pipe() + xcrun.standardOutput = pipe; xcrun.standardError = Pipe() + if let _ = try? xcrun.run() { + xcrun.waitUntilExit() + if xcrun.terminationStatus == 0, + let path = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty { + return path + } + } + // Fallback to known default location. + return "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp" + } + private func extractJSONBodies(from data: Data) -> [Data] { var results: [Data] = []; var rem = data let sep = Data([0x0D,0x0A,0x0D,0x0A]) diff --git a/Daemon/PreBuildRunner.swift b/Daemon/PreBuildRunner.swift index a520a3a..4670ea7 100644 --- a/Daemon/PreBuildRunner.swift +++ b/Daemon/PreBuildRunner.swift @@ -27,34 +27,56 @@ final class PreBuildRunner { p.environment = env let out = Pipe(); let err = Pipe() p.standardOutput = out; p.standardError = err + + // Guard against double-resume: if p.run() throws, the catch block resumes first, + // then empty-data pipe handlers fire → g.leave()×2 → g.notify → second resume. + final class ResumeOnce { + private let lock = NSLock(); private var done = false + func resume(continuation: CheckedContinuation, returning value: Bool) { + lock.lock(); defer { lock.unlock() } + guard !done else { return }; done = true + continuation.resume(returning: value) + } + } + let guard_ = ResumeOnce() + final class TimedOutBox { var value = false } let timedOutBox = TimedOutBox() let wd = DispatchWorkItem { timedOutBox.value = true; p.terminate() - session.broadcast(BuildLogMessage(type: .stderr, text: "⏱ Hook '\(hook.name)' timed out.", exitCode: nil)) + session.broadcast(BuildLogMessage(type: .stderr, + text: "⏱ Hook '\(hook.name)' timed out.", exitCode: nil)) } DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(hook.timeoutSeconds), execute: wd) + let g = DispatchGroup() g.enter() out.fileHandleForReading.readabilityHandler = { h in let d = h.availableData if d.isEmpty { out.fileHandleForReading.readabilityHandler = nil; g.leave(); return } - if let t = String(data: d, encoding: .utf8) { session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) } + if let t = String(data: d, encoding: .utf8) { + session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) + } } g.enter() err.fileHandleForReading.readabilityHandler = { h in let d = h.availableData if d.isEmpty { err.fileHandleForReading.readabilityHandler = nil; g.leave(); return } - if let t = String(data: d, encoding: .utf8) { session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) } + if let t = String(data: d, encoding: .utf8) { + session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) + } } p.terminationHandler = { proc in wd.cancel() - g.notify(queue: .global()) { cont.resume(returning: proc.terminationStatus == 0 && !timedOutBox.value) } + g.notify(queue: .global()) { + guard_.resume(continuation: cont, + returning: proc.terminationStatus == 0 && !timedOutBox.value) + } } - do { try p.run() } - catch { + do { try p.run() } catch { wd.cancel() - session.broadcast(BuildLogMessage(type: .stderr, text: "Launch failed: \(error)", exitCode: nil)) - cont.resume(returning: false) + session.broadcast(BuildLogMessage(type: .stderr, + text: "Launch failed: \(error)", exitCode: nil)) + guard_.resume(continuation: cont, returning: false) } } } diff --git a/Daemon/Setup.swift b/Daemon/Setup.swift index 9d3a73c..263db82 100644 --- a/Daemon/Setup.swift +++ b/Daemon/Setup.swift @@ -24,9 +24,16 @@ func ensureConfigFile() { } func loadTeamID() -> String { + // Prefer UserDefaults (set by Settings UI) over config file. + // This ensures changes made in the Settings window take effect immediately. + let fromDefaults = UserDefaults.standard.string(forKey: "developmentTeam") ?? "" + if !fromDefaults.isEmpty && fromDefaults != "YOUR_10_CHAR_TEAM_ID" { return fromDefaults } + + // Fall back to ~/.padxcode/config.json for initial setup. let file = "\(NSHomeDirectory())/.padxcode/config.json" guard let data = try? Data(contentsOf: URL(fileURLWithPath: file)), let json = try? JSONDecoder().decode([String: String].self, from: data), - let id = json["developmentTeam"] else { return "" } + let id = json["developmentTeam"], + !id.isEmpty, id != "YOUR_10_CHAR_TEAM_ID" else { return fromDefaults } return id } diff --git a/Daemon/routes.swift b/Daemon/routes.swift index 1daf90b..3ab1508 100644 --- a/Daemon/routes.swift +++ b/Daemon/routes.swift @@ -9,54 +9,120 @@ func routes(_ app: Application) throws { // MARK: - Files - app.get("files", "tree") { req -> FileNode in + app.get("files", "tree") { req async throws -> FileNode in guard let path = req.query[String.self, at: "path"] else { throw Abort(.badRequest) } - return try buildFileTree(at: path) + // Run file I/O on a background thread so we don't block the NIO event loop. + return try await withCheckedThrowingContinuation { cont in + DispatchQueue.global(qos: .userInitiated).async { + do { cont.resume(returning: try buildFileTree(at: path)) } + catch { cont.resume(throwing: error) } + } + } } - app.get("files", "content") { req -> FileContentResponse in + app.get("files", "content") { req async throws -> FileContentResponse in guard let path = req.query[String.self, at: "path"] else { throw Abort(.badRequest) } - guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { throw Abort(.notFound) } - return FileContentResponse(path: path, content: content) + // Run file I/O on a background thread so we don't block the NIO event loop. + return try await withCheckedThrowingContinuation { cont in + DispatchQueue.global(qos: .userInitiated).async { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { + cont.resume(throwing: Abort(.notFound)) + return + } + cont.resume(returning: FileContentResponse(path: path, content: content)) + } + } } - app.put("files", "save") { req -> HTTPStatus in + app.put("files", "save") { req async throws -> HTTPStatus in let body = try req.content.decode(FileSaveRequest.self) guard !body.path.contains("..") else { throw Abort(.forbidden) } - let url = URL(fileURLWithPath: body.path) - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - let tmp = url.deletingLastPathComponent() - .appendingPathComponent(".\(url.lastPathComponent).tmp") - try body.content.write(to: tmp, atomically: false, encoding: .utf8) - _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + // Run file I/O on a background thread so we don't block the NIO event loop. + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + do { + let url = URL(fileURLWithPath: body.path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let tmp = url.deletingLastPathComponent() + .appendingPathComponent(".\(url.lastPathComponent).tmp") + try body.content.write(to: tmp, atomically: false, encoding: .utf8) + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + cont.resume() + } catch { + cont.resume(throwing: error) + } + } + } return .noContent } - app.post("files", "create") { req -> HTTPStatus in + app.post("files", "create") { req async throws -> HTTPStatus in struct CR: Content { let parentPath: String; let fileName: String; let content: String } let b = try req.content.decode(CR.self) guard !b.parentPath.contains(".."), !b.fileName.contains("..") else { throw Abort(.forbidden) } - let dest = URL(fileURLWithPath: b.parentPath).appendingPathComponent(b.fileName) - guard !FileManager.default.fileExists(atPath: dest.path) else { throw Abort(.conflict) } - try b.content.write(to: dest, atomically: true, encoding: .utf8) + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + do { + let dest = URL(fileURLWithPath: b.parentPath).appendingPathComponent(b.fileName) + guard !FileManager.default.fileExists(atPath: dest.path) else { + cont.resume(throwing: Abort(.conflict)); return + } + try b.content.write(to: dest, atomically: true, encoding: .utf8) + cont.resume() + } catch { + cont.resume(throwing: error) + } + } + } return .created } - app.post("files", "rename") { req -> HTTPStatus in + app.post("files", "rename") { req async throws -> HTTPStatus in struct RR: Content { let path: String; let newName: String } let b = try req.content.decode(RR.self) guard !b.path.contains("..") else { throw Abort(.forbidden) } - let src = URL(fileURLWithPath: b.path) - let dest = src.deletingLastPathComponent().appendingPathComponent(b.newName) - try FileManager.default.moveItem(at: src, to: dest) + // Validate newName: no path separators or traversal sequences. + guard !b.newName.contains("/"), !b.newName.contains("\\"), + !b.newName.contains(".."), !b.newName.isEmpty else { + throw Abort(.forbidden, reason: "Invalid file name") + } + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + do { + let src = URL(fileURLWithPath: b.path) + let dest = src.deletingLastPathComponent().appendingPathComponent(b.newName) + try FileManager.default.moveItem(at: src, to: dest) + cont.resume() + } catch { + cont.resume(throwing: error) + } + } + } return .ok } - app.delete("files", "delete") { req -> HTTPStatus in + app.delete("files", "delete") { req async throws -> HTTPStatus in guard let path = req.query[String.self, at: "path"] else { throw Abort(.badRequest) } guard !path.contains("..") else { throw Abort(.forbidden) } - try FileManager.default.removeItem(atPath: path) + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + DispatchQueue.global(qos: .userInitiated).async { + do { + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { + cont.resume(throwing: Abort(.notFound)); return + } + // Only delete files — refuse directory deletions to prevent mass data loss. + guard !isDir.boolValue else { + cont.resume(throwing: Abort(.forbidden, reason: "Directory deletion not allowed")); return + } + try FileManager.default.removeItem(atPath: path) + cont.resume() + } catch { + cont.resume(throwing: error) + } + } + } return .noContent } @@ -73,7 +139,8 @@ func routes(_ app: Application) throws { let sessionId = UUID().uuidString let session = BuildSession(id: sessionId, logger: req.logger) await BuildSessionStore.shared.create(session: session) - req.application.daemonController?.buildStarted(scheme: buildReq.scheme) + let scheme = buildReq.scheme + Task { @MainActor in req.application.daemonController?.buildStarted(scheme: scheme) } let host = req.headers.first(name: .host) ?? "localhost:8080" let wsURL = "ws://\(host)/build/stream/\(sessionId)" Task { @@ -91,11 +158,24 @@ func routes(_ app: Application) throws { try? await ws.close() return } - req.application.daemonController?.clientConnected(sessionId: sid, type: .build) + Task { @MainActor in req.application.daemonController?.clientConnected(sessionId: sid, type: .build) } session.addClient(ws) // Suspend until the client disconnects try? await ws.onClose.get() - req.application.daemonController?.clientDisconnected(sessionId: sid) + // Cancel xcodebuild if still running when client disconnects mid-build. + session.cancelBuild() + Task { @MainActor in req.application.daemonController?.clientDisconnected(sessionId: sid) } + } + + // MARK: - Build Cancel + + app.post("build", "cancel", ":sessionId") { req async -> HTTPStatus in + guard let sid = req.parameters.get("sessionId"), + let session = await BuildSessionStore.shared.session(for: sid) else { + throw Abort(.notFound) + } + session.cancelBuild() + return .accepted } // MARK: - LSP @@ -121,28 +201,43 @@ func routes(_ app: Application) throws { // MARK: - System Health - app.get("system", "xcodegen-check") { _ -> HTTPStatus in - let p = Process() - p.executableURL = URL(fileURLWithPath: "/bin/zsh") - p.arguments = ["-lc", "which xcodegen"] - p.standardOutput = Pipe() - p.standardError = Pipe() - try? p.run(); p.waitUntilExit() - return p.terminationStatus == 0 ? .ok : .notFound + app.get("system", "xcodegen-check") { _ async -> HTTPStatus in + // Run on a background thread — waitUntilExit() blocks and must not run on NIO event loop. + await withCheckedContinuation { cont in + DispatchQueue.global(qos: .utility).async { + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/zsh") + p.arguments = ["-lc", "which xcodegen"] + p.standardOutput = Pipe(); p.standardError = Pipe() + try? p.run(); p.waitUntilExit() + cont.resume(returning: p.terminationStatus == 0 ? HTTPStatus.ok : .notFound) + } + } } - app.get("system", "disk-space") { req -> Response in - let url = URL(fileURLWithPath: NSHomeDirectory()) - let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityKey]) - let free = Int64(values?.volumeAvailableCapacity ?? 0) - let body = try JSONEncoder().encode(["freeBytes": free]) + app.get("system", "disk-space") { req async -> Response in + let (free, body) = await withCheckedContinuation { cont in + DispatchQueue.global(qos: .utility).async { + let url = URL(fileURLWithPath: NSHomeDirectory()) + let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityKey]) + let free = Int64(values?.volumeAvailableCapacity ?? 0) + let body = (try? JSONEncoder().encode(["freeBytes": free])) ?? Data() + cont.resume(returning: (free, body)) + } + } return Response(status: .ok, headers: ["Content-Type": "application/json"], body: .init(data: body)) } app.get("system", "validate") { req -> Response in - let checks = DaemonStartupValidator.run() + // DaemonStartupValidator.run() executes multiple shell commands with + // waitUntilExit() — must run off the NIO event loop. + let checks: [StartupCheckResult] = await withCheckedContinuation { cont in + DispatchQueue.global(qos: .utility).async { + cont.resume(returning: DaemonStartupValidator.run()) + } + } let body = try JSONEncoder().encode(checks) return Response(status: .ok, headers: ["Content-Type": "application/json"], @@ -189,16 +284,22 @@ private func runBuildPipeline( let ddPath = "/tmp/padxcode-derived-\(abs(request.projectPath.hashValue))" let teamID = loadTeamID() + // Only include -destination if non-empty. An empty destination (build-only mode) + // would cause xcodebuild to error immediately with "destination is required". + let destinationArgs: [String] = request.destination.isEmpty + ? [] + : ["-destination", request.destination] let args = projectArg + [ "-scheme", request.scheme, - "-destination", request.destination, + ] + destinationArgs + [ "-configuration", request.configuration, "-derivedDataPath", ddPath, "CODE_SIGN_IDENTITY=Apple Development", "DEVELOPMENT_TEAM=\(teamID)", - "-allowProvisioningUpdates", "-hideShellScriptEnvironment", - ] + request.extraBuildSettings + ["build"] + ] + (UserDefaults.standard.object(forKey: "allowProvisioningUpdates") as? Bool ?? true + ? ["-allowProvisioningUpdates"] : []) + + request.extraBuildSettings + ["build"] let exitCode = await session.runXcodebuild(arguments: args) @@ -232,7 +333,7 @@ private func runBuildPipeline( // MARK: - Helpers -private func buildFileTree(at path: String) throws -> FileNode { +private func buildFileTree(at path: String, depth: Int = 0) throws -> FileNode { let url = URL(fileURLWithPath: path) var isDir: ObjCBool = false guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { @@ -241,6 +342,10 @@ private func buildFileTree(at path: String) throws -> FileNode { if !isDir.boolValue { return FileNode(name: url.lastPathComponent, path: path, isDirectory: false, children: nil) } + // Hard depth cap — prevents stack overflow on symlink loops or unusually deep trees. + guard depth < 12 else { + return FileNode(name: url.lastPathComponent, path: path, isDirectory: true, children: nil) + } let ignored: Set = [ ".git", ".build", "DerivedData", ".DS_Store", "node_modules", ".swiftpm", "Pods", "xcuserdata" @@ -250,7 +355,7 @@ private func buildFileTree(at path: String) throws -> FileNode { let children: [FileNode] = try contents .filter { !ignored.contains($0.lastPathComponent) && !$0.lastPathComponent.hasPrefix(".") } .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } - .map { try buildFileTree(at: $0.path) } + .map { try buildFileTree(at: $0.path, depth: depth + 1) } return FileNode(name: url.lastPathComponent, path: path, isDirectory: true, children: children) } diff --git a/Preferences/AppPreferences.swift b/Preferences/AppPreferences.swift index d2f8750..b9f3db4 100644 --- a/Preferences/AppPreferences.swift +++ b/Preferences/AppPreferences.swift @@ -2,6 +2,7 @@ import Foundation import ServiceManagement import AppKit +@MainActor final class AppPreferences: ObservableObject { @Published var port: Int { @@ -34,8 +35,15 @@ final class AppPreferences: ObservableObject { } } - var tailscaleIP: String { - TailscaleDetector.currentIP() ?? "Not detected" + // Cached to avoid blocking the main thread on every view render. + // Refreshed once at init and when explicitly requested. + @Published private(set) var tailscaleIP: String = "Not detected" + + func refreshTailscaleIP() { + DispatchQueue.global(qos: .utility).async { + let ip = TailscaleDetector.currentIP() ?? "Not detected" + DispatchQueue.main.async { self.tailscaleIP = ip } + } } init() { @@ -46,12 +54,11 @@ final class AppPreferences: ObservableObject { self.allowProvisioningUpdates = d.object(forKey: "allowProvisioningUpdates") as? Bool ?? true self.buildConfiguration = d.string(forKey: "buildConfiguration") ?? "Debug" applyDockPolicy() + Task { refreshTailscaleIP() } } private func applyDockPolicy() { - DispatchQueue.main.async { - NSApp.setActivationPolicy(self.showInDock ? .regular : .accessory) - } + NSApp.setActivationPolicy(showInDock ? .regular : .accessory) } } diff --git a/UI/DaemonLogWindowView.swift b/UI/DaemonLogWindowView.swift index 51d956a..fffec40 100644 --- a/UI/DaemonLogWindowView.swift +++ b/UI/DaemonLogWindowView.swift @@ -1,7 +1,7 @@ import SwiftUI struct DaemonLogWindowView: View { - @Bindable var controller: DaemonController + @ObservedObject var controller: DaemonController @State private var filterLevel: DaemonLogLine.Level? = nil @State private var searchText = "" @State private var autoscroll = true diff --git a/UI/DaemonSettingsView.swift b/UI/DaemonSettingsView.swift index 90e3ab6..4ce94db 100644 --- a/UI/DaemonSettingsView.swift +++ b/UI/DaemonSettingsView.swift @@ -2,7 +2,7 @@ import SwiftUI import ServiceManagement struct DaemonSettingsView: View { - @Bindable var controller: DaemonController + @ObservedObject var controller: DaemonController @ObservedObject var preferences: AppPreferences @State private var pendingPort = "" @State private var showRestartAlert = false @@ -14,7 +14,7 @@ struct DaemonSettingsView: View { generalTab.tabItem { Label("General", systemImage:"gearshape") } } .padding(20).frame(width:520) - .onAppear { pendingPort = String(preferences.port) } + .onAppear { pendingPort = String(preferences.port); preferences.refreshTailscaleIP() } .alert("Restart Required", isPresented:$showRestartAlert) { Button("Restart Now") { Task { await controller.applyPortChange() } } Button("Later", role:.cancel) {} diff --git a/UI/MenuBarView.swift b/UI/MenuBarView.swift index df4a709..f1baa86 100644 --- a/UI/MenuBarView.swift +++ b/UI/MenuBarView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MenuBarView: View { - @Bindable var controller: DaemonController + @ObservedObject var controller: DaemonController @ObservedObject var preferences: AppPreferences @Environment(\.openWindow) private var openWindow @@ -30,9 +30,23 @@ struct MenuBarView: View { Text(controller.serverState.statusLabel).font(.subheadline).foregroundStyle(.secondary) } Spacer() - Button { Task { if controller.serverState.isRunning { await controller.stop() } else { await controller.start() } } } - label: { Text(controller.serverState.isRunning ? "Stop" : "Start").frame(width:44) } - .buttonStyle(.bordered).controlSize(.small).tint(controller.serverState.isRunning ? .red : .green) + Button { + Task { + if controller.serverState.isRunning { await controller.stop() } + else if case .starting = controller.serverState { } // ignore during startup + else { await controller.start() } + } + } + label: { + if case .starting = controller.serverState { + ProgressView().scaleEffect(0.6).frame(width:44) + } else { + Text(controller.serverState.isRunning ? "Stop" : "Start").frame(width:44) + } + } + .buttonStyle(.bordered).controlSize(.small) + .tint(controller.serverState.isRunning ? .red : .green) + .disabled({ if case .starting = controller.serverState { return true }; return false }()) } }