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 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)
}

View file

@ -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<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()
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)
}
}
}

View file

@ -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
}
}

View file

@ -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<Void, Never>) 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()
}
}

View file

@ -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])

View file

@ -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<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 }
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)
}
}
}

View file

@ -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
}

View file

@ -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<Void, Error>) 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<Void, Error>) 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<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
}
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<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
}
@ -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<String> = [
".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)
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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) {}

View file

@ -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 }())
}
}