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) 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) 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) 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) 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 — 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 = [ ".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 }