project setup
This commit is contained in:
parent
79e628ec96
commit
23cd2afe31
12 changed files with 454 additions and 129 deletions
|
|
@ -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 {
|
||||
|
||||
@NSApplicationDelegateAdaptor(PadXcodeDaemonDelegate.self) private var appDelegate
|
||||
@StateObject private var preferences = AppPreferences()
|
||||
@State private var controller: DaemonController
|
||||
@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()
|
||||
let ctrl = DaemonController(preferences: prefs)
|
||||
_preferences = StateObject(wrappedValue: prefs)
|
||||
_controller = State(wrappedValue: DaemonController(preferences: 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
// If stop() was called while we were awaiting startup, abort.
|
||||
guard case .starting = serverState else {
|
||||
try? await app.asyncShutdown()
|
||||
return
|
||||
}
|
||||
|
||||
vaporApp = app
|
||||
serverState = .running(port: preferences.port, startedAt: Date())
|
||||
appendLog("✅ Daemon running on 0.0.0.0:\(preferences.port)", level: .success)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import Vapor
|
|||
struct DeviceManager {
|
||||
|
||||
static func listConnectedDevices(logger: Logger) async -> [ConnectedDevice] {
|
||||
// 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) }
|
||||
|
|
@ -13,8 +16,13 @@ struct DeviceManager {
|
|||
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)
|
||||
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 }
|
||||
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 }
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
try? p.run()
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 }
|
||||
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 }
|
||||
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
|
||||
g.notify(queue: .global()) {
|
||||
session.broadcast(BuildLogMessage(type: .status,
|
||||
text: code == 0 ? "✅ App launched." : "⚠️ Launch exit \(code).",
|
||||
exitCode: Int(code)))
|
||||
cont.resume()
|
||||
}
|
||||
try? p.run()
|
||||
}
|
||||
do {
|
||||
try p.run()
|
||||
} catch {
|
||||
session.broadcast(BuildLogMessage(type: .stderr,
|
||||
text: "❌ Failed to launch xcrun launch: \(error)", exitCode: nil))
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,20 +9,38 @@ 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) }
|
||||
// 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)
|
||||
|
|
@ -30,33 +48,81 @@ func routes(_ app: Application) throws {
|
|||
.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) }
|
||||
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 { throw Abort(.conflict) }
|
||||
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) }
|
||||
// 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 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
|
||||
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()
|
||||
p.standardOutput = Pipe(); p.standardError = Pipe()
|
||||
try? p.run(); p.waitUntilExit()
|
||||
return p.terminationStatus == 0 ? .ok : .notFound
|
||||
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 (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])
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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 }())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue