PadXcode-Daemon/Daemon/routes.swift
2026-04-12 22:14:13 -07:00

383 lines
16 KiB
Swift

import Vapor
import Foundation
func routes(_ app: Application) throws {
// MARK: - Health
app.get("health") { _ in ["status": "ok"] }
// MARK: - Files
app.get("files", "tree") { req async throws -> FileNode in
guard let path = req.query[String.self, at: "path"] else { throw Abort(.badRequest) }
// 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 async throws -> FileContentResponse in
guard let path = req.query[String.self, at: "path"] else { throw Abort(.badRequest) }
// 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 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)
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 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 {
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 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 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
}
// MARK: - Devices
app.get("devices", "list") { req async -> [ConnectedDevice] in
await DeviceManager.listConnectedDevices(logger: req.logger)
}
// MARK: - Build
app.post("build", "start") { req async throws -> BuildSessionResponse in
let buildReq = try req.content.decode(BuildRequest.self)
let sessionId = UUID().uuidString
let session = BuildSession(id: sessionId, logger: req.logger)
await BuildSessionStore.shared.create(session: session)
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 {
await runBuildPipeline(request: buildReq, session: session, logger: req.logger)
await BuildSessionStore.shared.remove(id: sessionId)
}
return BuildSessionResponse(sessionId: sessionId, webSocketURL: wsURL)
}
// FIX: ws.onClose returns EventLoopFuture<Void> use .get() not .sequence
app.webSocket("build", "stream", ":sessionId") { req, ws async in
guard let sid = req.parameters.get("sessionId"),
let session = await BuildSessionStore.shared.session(for: sid) else {
try? await ws.send(#"{"type":"stderr","text":"Unknown session.","exitCode":-1}"#)
try? await ws.close()
return
}
Task { @MainActor in req.application.daemonController?.clientConnected(sessionId: sid, type: .build) }
session.addClient(ws)
// Suspend until the client disconnects
try? await ws.onClose.get()
// 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
app.post("lsp", "start") { req async throws -> HTTPStatus in
struct LSPReq: Content { let projectPath: String }
let b = try req.content.decode(LSPReq.self)
try await LSPProxy.shared.start(projectPath: b.projectPath)
return .ok
}
// FIX: ws.onText is closure-based in Vapor 4, not AsyncSequence
app.webSocket("lsp", "stream") { req, ws async in
await LSPProxy.shared.addClient(ws)
ws.onText { _, text in
guard let data = text.data(using: .utf8) else { return }
Task { await LSPProxy.shared.forwardToLSP(data) }
}
// Suspend until client disconnects, then clean up
try? await ws.onClose.get()
await LSPProxy.shared.removeClient(ws)
}
// MARK: - System Health
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 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
// 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"],
body: .init(data: body))
}
}
// MARK: - Build Pipeline
private func runBuildPipeline(
request: BuildRequest,
session: BuildSession,
logger: Logger
) async {
// Pre-build hooks (XcodeGen etc.)
if !request.preBuildHooks.isEmpty {
guard await PreBuildRunner.run(
hooks: request.preBuildHooks,
session: session,
logger: logger
) else {
session.broadcast(BuildLogMessage(
type: .status, text: "❌ Aborted: pre-build hook failed.", exitCode: 1))
return
}
}
// Resolve project file
let projectURL = URL(fileURLWithPath: request.projectPath)
let workspaceURL = projectURL.appendingPathExtension("xcworkspace")
let xcodeprojURL = projectURL.appendingPathExtension("xcodeproj")
let projectArg: [String]
if FileManager.default.fileExists(atPath: workspaceURL.path) {
projectArg = ["-workspace", workspaceURL.path]
} else if FileManager.default.fileExists(atPath: xcodeprojURL.path) {
projectArg = ["-project", xcodeprojURL.path]
} else {
session.broadcast(BuildLogMessage(type: .stderr,
text: "❌ No .xcworkspace or .xcodeproj found at \(request.projectPath)", exitCode: nil))
session.broadcast(BuildLogMessage(type: .status, text: "❌ Build failed.", exitCode: 1))
return
}
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,
] + destinationArgs + [
"-configuration", request.configuration,
"-derivedDataPath", ddPath,
"CODE_SIGN_IDENTITY=Apple Development",
"DEVELOPMENT_TEAM=\(teamID)",
"-hideShellScriptEnvironment",
] + (UserDefaults.standard.object(forKey: "allowProvisioningUpdates") as? Bool ?? true
? ["-allowProvisioningUpdates"] : [])
+ request.extraBuildSettings + ["build"]
let exitCode = await session.runXcodebuild(arguments: args)
// Stop here for build-only action
guard exitCode == 0, request.action == .buildAndRun else { return }
// Locate .app bundle
guard let appPath = findBuiltApp(derivedDataPath: ddPath, scheme: request.scheme) else {
session.broadcast(BuildLogMessage(type: .stderr,
text: "❌ Could not locate .app bundle in DerivedData.", exitCode: nil))
return
}
session.broadcast(BuildLogMessage(type: .stdout, text: "📦 App: \(appPath)\n", exitCode: nil))
// Extract UDID
guard let udid = extractUDID(from: request.destination) else {
session.broadcast(BuildLogMessage(type: .stderr,
text: "❌ Could not parse device UDID from destination string.", exitCode: nil))
return
}
// Install Launch
let installCode = await DeviceManager.installApp(
appPath: appPath, deviceUDID: udid, session: session)
guard installCode == 0 else { return }
if let bundleID = request.bundleIdentifier {
await DeviceManager.launchApp(bundleID: bundleID, deviceUDID: udid, session: session)
}
}
// MARK: - Helpers
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 {
throw Abort(.notFound)
}
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"
]
let contents = try FileManager.default.contentsOfDirectory(
at: url, includingPropertiesForKeys: [.isDirectoryKey])
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, depth: depth + 1) }
return FileNode(name: url.lastPathComponent, path: path, isDirectory: true, children: children)
}
private func findBuiltApp(derivedDataPath: String, scheme: String) -> String? {
let products = URL(fileURLWithPath: derivedDataPath)
.appendingPathComponent("Build/Products")
guard let configs = try? FileManager.default.contentsOfDirectory(
at: products, includingPropertiesForKeys: nil) else { return nil }
for config in configs {
let app = config.appendingPathComponent("\(scheme).app")
if FileManager.default.fileExists(atPath: app.path) { return app.path }
}
return nil
}
private func extractUDID(from destination: String) -> String? {
for part in destination.split(separator: ",") {
let kv = part.split(separator: "=", maxSplits: 1)
if kv.count == 2,
kv[0].trimmingCharacters(in: .whitespaces) == "id" {
return String(kv[1].trimmingCharacters(in: .whitespaces))
}
}
return nil
}