project setup

This commit is contained in:
Dallas Groot 2026-04-12 22:14:13 -07:00
parent 79e628ec96
commit 23cd2afe31
12 changed files with 454 additions and 129 deletions

View file

@ -1,27 +1,50 @@
import SwiftUI 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 @main
struct PadXcodeDaemonApp: App { struct PadXcodeDaemonApp: App {
@StateObject private var preferences = AppPreferences() @NSApplicationDelegateAdaptor(PadXcodeDaemonDelegate.self) private var appDelegate
@State private var controller: DaemonController @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() { init() {
ensureConfigFile() ensureConfigFile()
let prefs = AppPreferences() let prefs = AppPreferences()
_preferences = StateObject(wrappedValue: prefs) let ctrl = DaemonController(preferences: prefs)
_controller = State(wrappedValue: DaemonController(preferences: prefs)) _preferences = StateObject(wrappedValue: prefs)
_controller = StateObject(wrappedValue: ctrl)
// Store before applicationDidFinishLaunching fires.
PadXcodeDaemonApp.sharedController = ctrl
} }
var body: some Scene { var body: some Scene {
MenuBarExtra { MenuBarExtra {
MenuBarView(controller: controller, preferences: preferences) MenuBarView(controller: controller, preferences: preferences)
.task {
// Start daemon on first appearance if not already running
if !controller.serverState.isRunning {
await controller.start()
}
}
} label: { } label: {
MenuBarIcon(controller: controller) MenuBarIcon(controller: controller)
} }

View file

@ -14,11 +14,20 @@ final class BuildSession: @unchecked Sendable {
private let logger: Logger private let logger: Logger
private var clients: [WebSocket] = [] private var clients: [WebSocket] = []
private let lock = NSLock() private let lock = NSLock()
private var activeProcess: Process?
init(id: String, logger: Logger) { self.id = id; self.logger = logger } init(id: String, logger: Logger) { self.id = id; self.logger = logger }
func addClient(_ ws: WebSocket) { lock.lock(); defer { lock.unlock() }; clients.append(ws) } 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) { func broadcast(_ message: BuildLogMessage) {
guard let data = try? JSONEncoder().encode(message), guard let data = try? JSONEncoder().encode(message),
let text = String(data: data, encoding: .utf8) else { return } let text = String(data: data, encoding: .utf8) else { return }
@ -34,6 +43,21 @@ final class BuildSession: @unchecked Sendable {
process.arguments = arguments process.arguments = arguments
let outPipe = Pipe(); let errPipe = Pipe() let outPipe = Pipe(); let errPipe = Pipe()
process.standardOutput = outPipe; process.standardError = errPipe 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<Int32, Never>, returning value: Int32) {
lock.lock(); defer { lock.unlock() }
guard !done else { return }
done = true
continuation.resume(returning: value)
}
}
let guard_ = ResumeOnce()
let group = DispatchGroup() let group = DispatchGroup()
group.enter() group.enter()
outPipe.fileHandleForReading.readabilityHandler = { [weak self] h in outPipe.fileHandleForReading.readabilityHandler = { [weak self] h in
@ -50,15 +74,19 @@ final class BuildSession: @unchecked Sendable {
process.terminationHandler = { [weak self] proc in process.terminationHandler = { [weak self] proc in
let code = proc.terminationStatus let code = proc.terminationStatus
group.notify(queue: .global()) { group.notify(queue: .global()) {
self?.lock.lock(); self?.activeProcess = nil; self?.lock.unlock()
let msg = code == 0 ? "✅ Build succeeded." : "❌ Build failed (exit \(code))." let msg = code == 0 ? "✅ Build succeeded." : "❌ Build failed (exit \(code))."
self?.broadcast(BuildLogMessage(type: .status, text: msg, exitCode: Int(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)") } do {
catch { 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)) broadcast(BuildLogMessage(type: .stderr, text: "Failed to launch xcodebuild: \(error)", exitCode: nil))
continuation.resume(returning: -1) guard_.resume(continuation: continuation, returning: -1)
} }
} }
} }

View file

@ -1,6 +1,5 @@
import Foundation import Foundation
import Vapor import Vapor
import Observation
enum ServerState: Equatable { enum ServerState: Equatable {
case stopped case stopped
@ -41,18 +40,16 @@ struct DaemonLogLine: Identifiable {
} }
} }
} }
var formattedTime: String { var formattedTime: String { DaemonLogLine.timeFormatter.string(from: date) }
let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f.string(from: date) private static let timeFormatter: DateFormatter = { let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f }()
}
} }
@Observable
@MainActor @MainActor
final class DaemonController { final class DaemonController: ObservableObject {
var serverState: ServerState = .stopped @Published var serverState: ServerState = .stopped
var connectedClients: [ConnectedClient] = [] @Published var connectedClients: [ConnectedClient] = []
var logLines: [DaemonLogLine] = [] @Published var logLines: [DaemonLogLine] = []
var buildCount: Int = 0 @Published var buildCount: Int = 0
var preferences: AppPreferences var preferences: AppPreferences
private var vaporApp: Application? private var vaporApp: Application?
private let maxLogLines = 500 private let maxLogLines = 500
@ -66,6 +63,7 @@ final class DaemonController {
func start() async { func start() async {
guard !serverState.isRunning else { return } guard !serverState.isRunning else { return }
serverState = .starting serverState = .starting
buildCount = 0
appendLog("Starting PadXcode daemon on port \(preferences.port)", level: .info) appendLog("Starting PadXcode daemon on port \(preferences.port)", level: .info)
do { do {
var env = try Environment.detect() var env = try Environment.detect()
@ -78,10 +76,27 @@ final class DaemonController {
app.http.server.configuration.port = preferences.port app.http.server.configuration.port = preferences.port
try configure(app, controller: self) try configure(app, controller: self)
try await app.startup() try await app.startup()
vaporApp = app
serverState = .running(port: preferences.port, startedAt: Date()) // If stop() was called while we were awaiting startup, abort.
appendLog("✅ Daemon running on 0.0.0.0:\(preferences.port)", level: .success) guard case .starting = serverState else {
appendLog("📡 Tailscale IP: \(preferences.tailscaleIP)", level: .info) 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 { } catch {
serverState = .error(error.localizedDescription) serverState = .error(error.localizedDescription)
appendLog("❌ Failed to start: \(error.localizedDescription)", level: .error) appendLog("❌ Failed to start: \(error.localizedDescription)", level: .error)
@ -89,7 +104,12 @@ final class DaemonController {
} }
func stop() async { 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) appendLog("Stopping daemon…", level: .info)
try? await vaporApp?.asyncShutdown() try? await vaporApp?.asyncShutdown()
vaporApp = nil; serverState = .stopped; connectedClients = [] vaporApp = nil; serverState = .stopped; connectedClients = []
@ -114,4 +134,22 @@ final class DaemonController {
} }
func clearLog() { logLines = [] } func clearLog() { logLines = [] }
func applyPortChange() async { if serverState.isRunning { await restart() } } 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
}
} }

View file

@ -4,17 +4,25 @@ import Vapor
struct DeviceManager { struct DeviceManager {
static func listConnectedDevices(logger: Logger) async -> [ConnectedDevice] { static func listConnectedDevices(logger: Logger) async -> [ConnectedDevice] {
let tmp = FileManager.default.temporaryDirectory // Run synchronous Process on a background thread to avoid blocking the NIO event loop.
.appendingPathComponent("padxcode-devices-\(UUID().uuidString).json") await withCheckedContinuation { cont in
defer { try? FileManager.default.removeItem(at: tmp) } DispatchQueue.global(qos: .userInitiated).async {
let p = Process() let tmp = FileManager.default.temporaryDirectory
p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") .appendingPathComponent("padxcode-devices-\(UUID().uuidString).json")
p.arguments = ["devicectl", "list", "devices", "--json-output", tmp.path] defer { try? FileManager.default.removeItem(at: tmp) }
p.standardError = Pipe() let p = Process()
try? p.run(); p.waitUntilExit() p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
guard p.terminationStatus == 0, p.arguments = ["devicectl", "list", "devices", "--json-output", tmp.path]
let data = try? Data(contentsOf: tmp) else { return [] } p.standardError = Pipe()
return parseDevicectlJSON(data, logger: logger) 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 @discardableResult
@ -26,22 +34,44 @@ struct DeviceManager {
let out = Pipe(); let err = Pipe() let out = Pipe(); let err = Pipe()
p.standardOutput = out; p.standardError = err p.standardOutput = out; p.standardError = err
return await withCheckedContinuation { cont in 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 out.fileHandleForReading.readabilityHandler = { h in
let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return } let d = h.availableData
session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) 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 err.fileHandleForReading.readabilityHandler = { h in
let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return } let d = h.availableData
session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) 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 p.terminationHandler = { proc in
let code = proc.terminationStatus let code = proc.terminationStatus
session.broadcast(BuildLogMessage(type: .status, // Wait for both pipes to drain before resuming the continuation,
text: code == 0 ? "✅ App installed." : "❌ Install failed (exit \(code)).", // otherwise install/launch output is truncated.
exitCode: Int(code))) g.notify(queue: .global()) {
cont.resume(returning: code) 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() let out = Pipe(); let err = Pipe()
p.standardOutput = out; p.standardError = err p.standardOutput = out; p.standardError = err
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
let g = DispatchGroup()
g.enter()
out.fileHandleForReading.readabilityHandler = { h in out.fileHandleForReading.readabilityHandler = { h in
let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return } let d = h.availableData
session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) 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 err.fileHandleForReading.readabilityHandler = { h in
let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return } let d = h.availableData
session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) 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 p.terminationHandler = { proc in
let code = proc.terminationStatus let code = proc.terminationStatus
session.broadcast(BuildLogMessage(type: .status, g.notify(queue: .global()) {
text: code == 0 ? "✅ App launched." : "⚠️ Launch exit \(code).", session.broadcast(BuildLogMessage(type: .status,
exitCode: Int(code))) 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() cont.resume()
} }
try? p.run()
} }
} }

View file

@ -7,11 +7,25 @@ actor LSPProxy {
private var stdinPipe: Pipe? private var stdinPipe: Pipe?
private var clients: [WebSocket] = [] private var clients: [WebSocket] = []
func start(projectPath: String) throws { func start(projectPath: String) async throws {
guard process == nil else { return } // 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() let p = Process()
p.executableURL = URL(fileURLWithPath: p.executableURL = URL(fileURLWithPath: lspPath)
"/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp")
p.currentDirectoryURL = URL(fileURLWithPath: projectPath) p.currentDirectoryURL = URL(fileURLWithPath: projectPath)
let inPipe = Pipe(); let outPipe = Pipe(); let errPipe = Pipe() let inPipe = Pipe(); let outPipe = Pipe(); let errPipe = Pipe()
p.standardInput = inPipe; p.standardOutput = outPipe; p.standardError = errPipe p.standardInput = inPipe; p.standardOutput = outPipe; p.standardError = errPipe
@ -24,8 +38,8 @@ actor LSPProxy {
try p.run(); process = p try p.run(); process = p
} }
func stop() { process?.terminate(); process = nil; stdinPipe = nil } func stop() { process?.terminate(); process = nil; stdinPipe = nil; clients.removeAll() }
private func handleTermination() { process = nil; broadcastStatus("lsp_terminated") } private func handleTermination() { process = nil; broadcastStatus("lsp_terminated"); clients.removeAll() }
func addClient(_ ws: WebSocket) { clients.append(ws) } func addClient(_ ws: WebSocket) { clients.append(ws) }
func removeClient(_ ws: WebSocket) { clients.removeAll { $0 === 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) } } 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] { private func extractJSONBodies(from data: Data) -> [Data] {
var results: [Data] = []; var rem = data var results: [Data] = []; var rem = data
let sep = Data([0x0D,0x0A,0x0D,0x0A]) let sep = Data([0x0D,0x0A,0x0D,0x0A])

View file

@ -27,34 +27,56 @@ final class PreBuildRunner {
p.environment = env p.environment = env
let out = Pipe(); let err = Pipe() let out = Pipe(); let err = Pipe()
p.standardOutput = out; p.standardError = err 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<Bool, Never>, 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 } final class TimedOutBox { var value = false }
let timedOutBox = TimedOutBox() let timedOutBox = TimedOutBox()
let wd = DispatchWorkItem { timedOutBox.value = true; p.terminate() 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) DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(hook.timeoutSeconds), execute: wd)
let g = DispatchGroup() let g = DispatchGroup()
g.enter() g.enter()
out.fileHandleForReading.readabilityHandler = { h in out.fileHandleForReading.readabilityHandler = { h in
let d = h.availableData let d = h.availableData
if d.isEmpty { out.fileHandleForReading.readabilityHandler = nil; g.leave(); return } 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() g.enter()
err.fileHandleForReading.readabilityHandler = { h in err.fileHandleForReading.readabilityHandler = { h in
let d = h.availableData let d = h.availableData
if d.isEmpty { err.fileHandleForReading.readabilityHandler = nil; g.leave(); return } 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 p.terminationHandler = { proc in
wd.cancel() 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() } do { try p.run() } catch {
catch {
wd.cancel() wd.cancel()
session.broadcast(BuildLogMessage(type: .stderr, text: "Launch failed: \(error)", exitCode: nil)) session.broadcast(BuildLogMessage(type: .stderr,
cont.resume(returning: false) text: "Launch failed: \(error)", exitCode: nil))
guard_.resume(continuation: cont, returning: false)
} }
} }
} }

View file

@ -24,9 +24,16 @@ func ensureConfigFile() {
} }
func loadTeamID() -> String { 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" let file = "\(NSHomeDirectory())/.padxcode/config.json"
guard let data = try? Data(contentsOf: URL(fileURLWithPath: file)), guard let data = try? Data(contentsOf: URL(fileURLWithPath: file)),
let json = try? JSONDecoder().decode([String: String].self, from: data), 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 return id
} }

View file

@ -9,54 +9,120 @@ func routes(_ app: Application) throws {
// MARK: - Files // 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) } 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 path = req.query[String.self, at: "path"] else { throw Abort(.badRequest) }
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { throw Abort(.notFound) } // Run file I/O on a background thread so we don't block the NIO event loop.
return FileContentResponse(path: path, content: content) 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) let body = try req.content.decode(FileSaveRequest.self)
guard !body.path.contains("..") else { throw Abort(.forbidden) } guard !body.path.contains("..") else { throw Abort(.forbidden) }
let url = URL(fileURLWithPath: body.path) // Run file I/O on a background thread so we don't block the NIO event loop.
try FileManager.default.createDirectory( try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
at: url.deletingLastPathComponent(), withIntermediateDirectories: true) DispatchQueue.global(qos: .userInitiated).async {
let tmp = url.deletingLastPathComponent() do {
.appendingPathComponent(".\(url.lastPathComponent).tmp") let url = URL(fileURLWithPath: body.path)
try body.content.write(to: tmp, atomically: false, encoding: .utf8) try FileManager.default.createDirectory(
_ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) 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 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 } struct CR: Content { let parentPath: String; let fileName: String; let content: String }
let b = try req.content.decode(CR.self) let b = try req.content.decode(CR.self)
guard !b.parentPath.contains(".."), !b.fileName.contains("..") else { throw Abort(.forbidden) } guard !b.parentPath.contains(".."), !b.fileName.contains("..") else { throw Abort(.forbidden) }
let dest = URL(fileURLWithPath: b.parentPath).appendingPathComponent(b.fileName) try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
guard !FileManager.default.fileExists(atPath: dest.path) else { throw Abort(.conflict) } DispatchQueue.global(qos: .userInitiated).async {
try b.content.write(to: dest, atomically: true, encoding: .utf8) 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 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 } struct RR: Content { let path: String; let newName: String }
let b = try req.content.decode(RR.self) let b = try req.content.decode(RR.self)
guard !b.path.contains("..") else { throw Abort(.forbidden) } guard !b.path.contains("..") else { throw Abort(.forbidden) }
let src = URL(fileURLWithPath: b.path) // Validate newName: no path separators or traversal sequences.
let dest = src.deletingLastPathComponent().appendingPathComponent(b.newName) guard !b.newName.contains("/"), !b.newName.contains("\\"),
try FileManager.default.moveItem(at: src, to: dest) !b.newName.contains(".."), !b.newName.isEmpty else {
throw Abort(.forbidden, reason: "Invalid file name")
}
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) 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 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 let path = req.query[String.self, at: "path"] else { throw Abort(.badRequest) }
guard !path.contains("..") else { throw Abort(.forbidden) } guard !path.contains("..") else { throw Abort(.forbidden) }
try FileManager.default.removeItem(atPath: path) try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) 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 return .noContent
} }
@ -73,7 +139,8 @@ func routes(_ app: Application) throws {
let sessionId = UUID().uuidString let sessionId = UUID().uuidString
let session = BuildSession(id: sessionId, logger: req.logger) let session = BuildSession(id: sessionId, logger: req.logger)
await BuildSessionStore.shared.create(session: session) 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 host = req.headers.first(name: .host) ?? "localhost:8080"
let wsURL = "ws://\(host)/build/stream/\(sessionId)" let wsURL = "ws://\(host)/build/stream/\(sessionId)"
Task { Task {
@ -91,11 +158,24 @@ func routes(_ app: Application) throws {
try? await ws.close() try? await ws.close()
return return
} }
req.application.daemonController?.clientConnected(sessionId: sid, type: .build) Task { @MainActor in req.application.daemonController?.clientConnected(sessionId: sid, type: .build) }
session.addClient(ws) session.addClient(ws)
// Suspend until the client disconnects // Suspend until the client disconnects
try? await ws.onClose.get() 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 // MARK: - LSP
@ -121,28 +201,43 @@ func routes(_ app: Application) throws {
// MARK: - System Health // MARK: - System Health
app.get("system", "xcodegen-check") { _ -> HTTPStatus in app.get("system", "xcodegen-check") { _ async -> HTTPStatus in
let p = Process() // Run on a background thread waitUntilExit() blocks and must not run on NIO event loop.
p.executableURL = URL(fileURLWithPath: "/bin/zsh") await withCheckedContinuation { cont in
p.arguments = ["-lc", "which xcodegen"] DispatchQueue.global(qos: .utility).async {
p.standardOutput = Pipe() let p = Process()
p.standardError = Pipe() p.executableURL = URL(fileURLWithPath: "/bin/zsh")
try? p.run(); p.waitUntilExit() p.arguments = ["-lc", "which xcodegen"]
return p.terminationStatus == 0 ? .ok : .notFound 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 app.get("system", "disk-space") { req async -> Response in
let url = URL(fileURLWithPath: NSHomeDirectory()) let (free, body) = await withCheckedContinuation { cont in
let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityKey]) DispatchQueue.global(qos: .utility).async {
let free = Int64(values?.volumeAvailableCapacity ?? 0) let url = URL(fileURLWithPath: NSHomeDirectory())
let body = try JSONEncoder().encode(["freeBytes": free]) 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, return Response(status: .ok,
headers: ["Content-Type": "application/json"], headers: ["Content-Type": "application/json"],
body: .init(data: body)) body: .init(data: body))
} }
app.get("system", "validate") { req -> Response in 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) let body = try JSONEncoder().encode(checks)
return Response(status: .ok, return Response(status: .ok,
headers: ["Content-Type": "application/json"], headers: ["Content-Type": "application/json"],
@ -189,16 +284,22 @@ private func runBuildPipeline(
let ddPath = "/tmp/padxcode-derived-\(abs(request.projectPath.hashValue))" let ddPath = "/tmp/padxcode-derived-\(abs(request.projectPath.hashValue))"
let teamID = loadTeamID() 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 + [ let args = projectArg + [
"-scheme", request.scheme, "-scheme", request.scheme,
"-destination", request.destination, ] + destinationArgs + [
"-configuration", request.configuration, "-configuration", request.configuration,
"-derivedDataPath", ddPath, "-derivedDataPath", ddPath,
"CODE_SIGN_IDENTITY=Apple Development", "CODE_SIGN_IDENTITY=Apple Development",
"DEVELOPMENT_TEAM=\(teamID)", "DEVELOPMENT_TEAM=\(teamID)",
"-allowProvisioningUpdates",
"-hideShellScriptEnvironment", "-hideShellScriptEnvironment",
] + request.extraBuildSettings + ["build"] ] + (UserDefaults.standard.object(forKey: "allowProvisioningUpdates") as? Bool ?? true
? ["-allowProvisioningUpdates"] : [])
+ request.extraBuildSettings + ["build"]
let exitCode = await session.runXcodebuild(arguments: args) let exitCode = await session.runXcodebuild(arguments: args)
@ -232,7 +333,7 @@ private func runBuildPipeline(
// MARK: - Helpers // 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) let url = URL(fileURLWithPath: path)
var isDir: ObjCBool = false var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else {
@ -241,6 +342,10 @@ private func buildFileTree(at path: String) throws -> FileNode {
if !isDir.boolValue { if !isDir.boolValue {
return FileNode(name: url.lastPathComponent, path: path, isDirectory: false, children: nil) 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<String> = [ let ignored: Set<String> = [
".git", ".build", "DerivedData", ".DS_Store", ".git", ".build", "DerivedData", ".DS_Store",
"node_modules", ".swiftpm", "Pods", "xcuserdata" "node_modules", ".swiftpm", "Pods", "xcuserdata"
@ -250,7 +355,7 @@ private func buildFileTree(at path: String) throws -> FileNode {
let children: [FileNode] = try contents let children: [FileNode] = try contents
.filter { !ignored.contains($0.lastPathComponent) && !$0.lastPathComponent.hasPrefix(".") } .filter { !ignored.contains($0.lastPathComponent) && !$0.lastPathComponent.hasPrefix(".") }
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } .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) return FileNode(name: url.lastPathComponent, path: path, isDirectory: true, children: children)
} }

View file

@ -2,6 +2,7 @@ import Foundation
import ServiceManagement import ServiceManagement
import AppKit import AppKit
@MainActor
final class AppPreferences: ObservableObject { final class AppPreferences: ObservableObject {
@Published var port: Int { @Published var port: Int {
@ -34,8 +35,15 @@ final class AppPreferences: ObservableObject {
} }
} }
var tailscaleIP: String { // Cached to avoid blocking the main thread on every view render.
TailscaleDetector.currentIP() ?? "Not detected" // 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() { init() {
@ -46,12 +54,11 @@ final class AppPreferences: ObservableObject {
self.allowProvisioningUpdates = d.object(forKey: "allowProvisioningUpdates") as? Bool ?? true self.allowProvisioningUpdates = d.object(forKey: "allowProvisioningUpdates") as? Bool ?? true
self.buildConfiguration = d.string(forKey: "buildConfiguration") ?? "Debug" self.buildConfiguration = d.string(forKey: "buildConfiguration") ?? "Debug"
applyDockPolicy() applyDockPolicy()
Task { refreshTailscaleIP() }
} }
private func applyDockPolicy() { private func applyDockPolicy() {
DispatchQueue.main.async { NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
NSApp.setActivationPolicy(self.showInDock ? .regular : .accessory)
}
} }
} }

View file

@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct DaemonLogWindowView: View { struct DaemonLogWindowView: View {
@Bindable var controller: DaemonController @ObservedObject var controller: DaemonController
@State private var filterLevel: DaemonLogLine.Level? = nil @State private var filterLevel: DaemonLogLine.Level? = nil
@State private var searchText = "" @State private var searchText = ""
@State private var autoscroll = true @State private var autoscroll = true

View file

@ -2,7 +2,7 @@ import SwiftUI
import ServiceManagement import ServiceManagement
struct DaemonSettingsView: View { struct DaemonSettingsView: View {
@Bindable var controller: DaemonController @ObservedObject var controller: DaemonController
@ObservedObject var preferences: AppPreferences @ObservedObject var preferences: AppPreferences
@State private var pendingPort = "" @State private var pendingPort = ""
@State private var showRestartAlert = false @State private var showRestartAlert = false
@ -14,7 +14,7 @@ struct DaemonSettingsView: View {
generalTab.tabItem { Label("General", systemImage:"gearshape") } generalTab.tabItem { Label("General", systemImage:"gearshape") }
} }
.padding(20).frame(width:520) .padding(20).frame(width:520)
.onAppear { pendingPort = String(preferences.port) } .onAppear { pendingPort = String(preferences.port); preferences.refreshTailscaleIP() }
.alert("Restart Required", isPresented:$showRestartAlert) { .alert("Restart Required", isPresented:$showRestartAlert) {
Button("Restart Now") { Task { await controller.applyPortChange() } } Button("Restart Now") { Task { await controller.applyPortChange() } }
Button("Later", role:.cancel) {} Button("Later", role:.cancel) {}

View file

@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct MenuBarView: View { struct MenuBarView: View {
@Bindable var controller: DaemonController @ObservedObject var controller: DaemonController
@ObservedObject var preferences: AppPreferences @ObservedObject var preferences: AppPreferences
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@ -30,9 +30,23 @@ struct MenuBarView: View {
Text(controller.serverState.statusLabel).font(.subheadline).foregroundStyle(.secondary) Text(controller.serverState.statusLabel).font(.subheadline).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Button { Task { if controller.serverState.isRunning { await controller.stop() } else { await controller.start() } } } Button {
label: { Text(controller.serverState.isRunning ? "Stop" : "Start").frame(width:44) } Task {
.buttonStyle(.bordered).controlSize(.small).tint(controller.serverState.isRunning ? .red : .green) 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 }())
} }
} }