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 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 {
|
||||||
|
|
||||||
|
@NSApplicationDelegateAdaptor(PadXcodeDaemonDelegate.self) private var appDelegate
|
||||||
@StateObject private var preferences = AppPreferences()
|
@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() {
|
init() {
|
||||||
ensureConfigFile()
|
ensureConfigFile()
|
||||||
let prefs = AppPreferences()
|
let prefs = AppPreferences()
|
||||||
|
let ctrl = DaemonController(preferences: prefs)
|
||||||
_preferences = StateObject(wrappedValue: 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 {
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
// If stop() was called while we were awaiting startup, abort.
|
||||||
|
guard case .starting = serverState else {
|
||||||
|
try? await app.asyncShutdown()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
vaporApp = app
|
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)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import Vapor
|
||||||
struct DeviceManager {
|
struct DeviceManager {
|
||||||
|
|
||||||
static func listConnectedDevices(logger: Logger) async -> [ConnectedDevice] {
|
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
|
let tmp = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("padxcode-devices-\(UUID().uuidString).json")
|
.appendingPathComponent("padxcode-devices-\(UUID().uuidString).json")
|
||||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||||
|
|
@ -13,8 +16,13 @@ struct DeviceManager {
|
||||||
p.standardError = Pipe()
|
p.standardError = Pipe()
|
||||||
try? p.run(); p.waitUntilExit()
|
try? p.run(); p.waitUntilExit()
|
||||||
guard p.terminationStatus == 0,
|
guard p.terminationStatus == 0,
|
||||||
let data = try? Data(contentsOf: tmp) else { return [] }
|
let data = try? Data(contentsOf: tmp) else {
|
||||||
return parseDevicectlJSON(data, logger: logger)
|
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
|
||||||
|
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))
|
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
|
||||||
|
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))
|
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
|
||||||
|
// 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,
|
session.broadcast(BuildLogMessage(type: .status,
|
||||||
text: code == 0 ? "✅ App installed." : "❌ Install failed (exit \(code)).",
|
text: code == 0 ? "✅ App installed." : "❌ Install failed (exit \(code)).",
|
||||||
exitCode: Int(code)))
|
exitCode: Int(code)))
|
||||||
cont.resume(returning: 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()
|
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
|
||||||
|
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))
|
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
|
||||||
|
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))
|
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
|
||||||
|
g.notify(queue: .global()) {
|
||||||
session.broadcast(BuildLogMessage(type: .status,
|
session.broadcast(BuildLogMessage(type: .status,
|
||||||
text: code == 0 ? "✅ App launched." : "⚠️ Launch exit \(code).",
|
text: code == 0 ? "✅ App launched." : "⚠️ Launch exit \(code).",
|
||||||
exitCode: Int(code)))
|
exitCode: Int(code)))
|
||||||
cont.resume()
|
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 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])
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
}
|
||||||
catch {
|
do { try p.run() } 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,38 @@ 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) }
|
||||||
|
// 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)
|
let url = URL(fileURLWithPath: body.path)
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
|
@ -30,33 +48,81 @@ func routes(_ app: Application) throws {
|
||||||
.appendingPathComponent(".\(url.lastPathComponent).tmp")
|
.appendingPathComponent(".\(url.lastPathComponent).tmp")
|
||||||
try body.content.write(to: tmp, atomically: false, encoding: .utf8)
|
try body.content.write(to: tmp, atomically: false, encoding: .utf8)
|
||||||
_ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
|
_ = 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) }
|
||||||
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
do {
|
||||||
let dest = URL(fileURLWithPath: b.parentPath).appendingPathComponent(b.fileName)
|
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)
|
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) }
|
||||||
|
// 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 src = URL(fileURLWithPath: b.path)
|
||||||
let dest = src.deletingLastPathComponent().appendingPathComponent(b.newName)
|
let dest = src.deletingLastPathComponent().appendingPathComponent(b.newName)
|
||||||
try FileManager.default.moveItem(at: src, to: dest)
|
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 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)
|
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
|
||||||
|
// 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()
|
let p = Process()
|
||||||
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||||
p.arguments = ["-lc", "which xcodegen"]
|
p.arguments = ["-lc", "which xcodegen"]
|
||||||
p.standardOutput = Pipe()
|
p.standardOutput = Pipe(); p.standardError = Pipe()
|
||||||
p.standardError = Pipe()
|
|
||||||
try? p.run(); p.waitUntilExit()
|
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 url = URL(fileURLWithPath: NSHomeDirectory())
|
||||||
let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityKey])
|
let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityKey])
|
||||||
let free = Int64(values?.volumeAvailableCapacity ?? 0)
|
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,
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
|
|
|
||||||
|
|
@ -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 }())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue