initial commit
This commit is contained in:
commit
7df0dd6571
20 changed files with 1542 additions and 0 deletions
25
App/Info.plist
Normal file
25
App/Info.plist
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>PadXcode Daemon</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>ca.dallasgroot.PadXcodeDaemon</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
45
App/PadXcodeDaemonApp.swift
Normal file
45
App/PadXcodeDaemonApp.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PadXcodeDaemonApp: App {
|
||||||
|
|
||||||
|
@StateObject private var preferences = AppPreferences()
|
||||||
|
@State private var controller: DaemonController
|
||||||
|
|
||||||
|
init() {
|
||||||
|
ensureConfigFile()
|
||||||
|
let prefs = AppPreferences()
|
||||||
|
_preferences = StateObject(wrappedValue: prefs)
|
||||||
|
_controller = State(wrappedValue: DaemonController(preferences: prefs))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.menuBarExtraStyle(.window)
|
||||||
|
|
||||||
|
Window("PadXcode Daemon Settings", id: "settings") {
|
||||||
|
DaemonSettingsView(controller: controller, preferences: preferences)
|
||||||
|
}
|
||||||
|
.windowResizability(.contentSize)
|
||||||
|
.defaultSize(width: 520, height: 580)
|
||||||
|
.windowStyle(.titleBar)
|
||||||
|
.commandsRemoved()
|
||||||
|
|
||||||
|
Window("Daemon Log", id: "log") {
|
||||||
|
DaemonLogWindowView(controller: controller)
|
||||||
|
}
|
||||||
|
.defaultSize(width: 700, height: 500)
|
||||||
|
.windowStyle(.titleBar)
|
||||||
|
.commandsRemoved()
|
||||||
|
}
|
||||||
|
}
|
||||||
65
Daemon/BuildManager.swift
Normal file
65
Daemon/BuildManager.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import Vapor
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor BuildSessionStore {
|
||||||
|
static let shared = BuildSessionStore()
|
||||||
|
private var sessions: [String: BuildSession] = [:]
|
||||||
|
func create(session: BuildSession) { sessions[session.id] = session }
|
||||||
|
func session(for id: String) -> BuildSession? { sessions[id] }
|
||||||
|
func remove(id: String) { sessions.removeValue(forKey: id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
final class BuildSession: @unchecked Sendable {
|
||||||
|
let id: String
|
||||||
|
private let logger: Logger
|
||||||
|
private var clients: [WebSocket] = []
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
init(id: String, logger: Logger) { self.id = id; self.logger = logger }
|
||||||
|
|
||||||
|
func addClient(_ ws: WebSocket) { lock.lock(); defer { lock.unlock() }; clients.append(ws) }
|
||||||
|
|
||||||
|
func broadcast(_ message: BuildLogMessage) {
|
||||||
|
guard let data = try? JSONEncoder().encode(message),
|
||||||
|
let text = String(data: data, encoding: .utf8) else { return }
|
||||||
|
lock.lock(); let snap = clients; lock.unlock()
|
||||||
|
for ws in snap where !ws.isClosed { ws.send(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func runXcodebuild(arguments: [String]) async -> Int32 {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcodebuild")
|
||||||
|
process.arguments = arguments
|
||||||
|
let outPipe = Pipe(); let errPipe = Pipe()
|
||||||
|
process.standardOutput = outPipe; process.standardError = errPipe
|
||||||
|
let group = DispatchGroup()
|
||||||
|
group.enter()
|
||||||
|
outPipe.fileHandleForReading.readabilityHandler = { [weak self] h in
|
||||||
|
let d = h.availableData
|
||||||
|
if d.isEmpty { outPipe.fileHandleForReading.readabilityHandler = nil; group.leave(); return }
|
||||||
|
if let t = String(data: d, encoding: .utf8) { self?.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil)) }
|
||||||
|
}
|
||||||
|
group.enter()
|
||||||
|
errPipe.fileHandleForReading.readabilityHandler = { [weak self] h in
|
||||||
|
let d = h.availableData
|
||||||
|
if d.isEmpty { errPipe.fileHandleForReading.readabilityHandler = nil; group.leave(); return }
|
||||||
|
if let t = String(data: d, encoding: .utf8) { self?.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil)) }
|
||||||
|
}
|
||||||
|
process.terminationHandler = { [weak self] proc in
|
||||||
|
let code = proc.terminationStatus
|
||||||
|
group.notify(queue: .global()) {
|
||||||
|
let msg = code == 0 ? "✅ Build succeeded." : "❌ Build failed (exit \(code))."
|
||||||
|
self?.broadcast(BuildLogMessage(type: .status, text: msg, exitCode: Int(code)))
|
||||||
|
continuation.resume(returning: code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do { try process.run(); logger.info("xcodebuild started. PID: \(process.processIdentifier)") }
|
||||||
|
catch {
|
||||||
|
broadcast(BuildLogMessage(type: .stderr, text: "Failed to launch xcodebuild: \(error)", exitCode: nil))
|
||||||
|
continuation.resume(returning: -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
Daemon/DaemonController.swift
Normal file
109
Daemon/DaemonController.swift
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import Foundation
|
||||||
|
import Vapor
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
enum ServerState: Equatable {
|
||||||
|
case stopped
|
||||||
|
case starting
|
||||||
|
case running(port: Int, startedAt: Date)
|
||||||
|
case error(String)
|
||||||
|
|
||||||
|
var isRunning: Bool { if case .running = self { return true }; return false }
|
||||||
|
var statusLabel: String {
|
||||||
|
switch self {
|
||||||
|
case .stopped: return "Stopped"
|
||||||
|
case .starting: return "Starting…"
|
||||||
|
case .running: return "Running"
|
||||||
|
case .error(let m): return "Error: \(m)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var uptime: String? {
|
||||||
|
guard case .running(_, let start) = self else { return nil }
|
||||||
|
let e = Date().timeIntervalSince(start)
|
||||||
|
if e < 60 { return String(format: "%.0fs", e) }
|
||||||
|
if e < 3600 { return String(format: "%dm %ds", Int(e/60), Int(e)%60) }
|
||||||
|
return String(format: "%dh %dm", Int(e/3600), Int(e/60)%60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ConnectedClient: Identifiable {
|
||||||
|
let id = UUID(); let sessionId: String; let connectedAt: Date; let type: ClientType
|
||||||
|
enum ClientType: String { case build = "Build Stream"; case lsp = "LSP Stream" }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DaemonLogLine: Identifiable {
|
||||||
|
let id = UUID(); let date = Date(); let message: String; let level: Level
|
||||||
|
enum Level { case info, success, warning, error
|
||||||
|
var prefix: String {
|
||||||
|
switch self {
|
||||||
|
case .info: return "ℹ️"; case .success: return "✅"
|
||||||
|
case .warning: return "⚠️"; case .error: return "❌"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var formattedTime: String {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "HH:mm:ss"; return f.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
final class DaemonController {
|
||||||
|
var serverState: ServerState = .stopped
|
||||||
|
var connectedClients: [ConnectedClient] = []
|
||||||
|
var logLines: [DaemonLogLine] = []
|
||||||
|
var buildCount: Int = 0
|
||||||
|
var preferences: AppPreferences
|
||||||
|
private var vaporApp: Application?
|
||||||
|
private let maxLogLines = 500
|
||||||
|
|
||||||
|
init(preferences: AppPreferences) { self.preferences = preferences }
|
||||||
|
|
||||||
|
func start() async {
|
||||||
|
guard !serverState.isRunning else { return }
|
||||||
|
serverState = .starting
|
||||||
|
appendLog("Starting PadXcode daemon on port \(preferences.port)…", level: .info)
|
||||||
|
do {
|
||||||
|
var env = try Environment.detect()
|
||||||
|
try LoggingSystem.bootstrap(from: &env)
|
||||||
|
let app = Application(env)
|
||||||
|
app.http.server.configuration.hostname = "0.0.0.0"
|
||||||
|
app.http.server.configuration.port = preferences.port
|
||||||
|
try configure(app, controller: self)
|
||||||
|
try await app.startup()
|
||||||
|
vaporApp = app
|
||||||
|
serverState = .running(port: preferences.port, startedAt: Date())
|
||||||
|
appendLog("✅ Daemon running on 0.0.0.0:\(preferences.port)", level: .success)
|
||||||
|
appendLog("📡 Tailscale IP: \(preferences.tailscaleIP)", level: .info)
|
||||||
|
} catch {
|
||||||
|
serverState = .error(error.localizedDescription)
|
||||||
|
appendLog("❌ Failed to start: \(error.localizedDescription)", level: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async {
|
||||||
|
guard serverState.isRunning else { return }
|
||||||
|
appendLog("Stopping daemon…", level: .info)
|
||||||
|
await vaporApp?.asyncShutdown()
|
||||||
|
vaporApp = nil; serverState = .stopped; connectedClients = []
|
||||||
|
appendLog("Daemon stopped.", level: .info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func restart() async { await stop(); try? await Task.sleep(for: .milliseconds(500)); await start() }
|
||||||
|
|
||||||
|
func clientConnected(sessionId: String, type: ConnectedClient.ClientType) {
|
||||||
|
connectedClients.append(ConnectedClient(sessionId: sessionId, connectedAt: Date(), type: type))
|
||||||
|
appendLog("Client connected: \(type.rawValue) [\(sessionId.prefix(8))]", level: .info)
|
||||||
|
}
|
||||||
|
func clientDisconnected(sessionId: String) {
|
||||||
|
connectedClients.removeAll { $0.sessionId == sessionId }
|
||||||
|
appendLog("Client disconnected [\(sessionId.prefix(8))]", level: .info)
|
||||||
|
}
|
||||||
|
func buildStarted(scheme: String) { buildCount += 1; appendLog("Build #\(buildCount): \(scheme)", level: .info) }
|
||||||
|
|
||||||
|
func appendLog(_ message: String, level: DaemonLogLine.Level) {
|
||||||
|
logLines.append(DaemonLogLine(message: message, level: level))
|
||||||
|
if logLines.count > maxLogLines { logLines.removeFirst(logLines.count - maxLogLines) }
|
||||||
|
}
|
||||||
|
func clearLog() { logLines = [] }
|
||||||
|
func applyPortChange() async { if serverState.isRunning { await restart() } }
|
||||||
|
}
|
||||||
135
Daemon/DaemonStartupValidator.swift
Normal file
135
Daemon/DaemonStartupValidator.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Result type (Codable so /system/validate can return JSON)
|
||||||
|
|
||||||
|
struct StartupCheckResult: Codable {
|
||||||
|
let name: String
|
||||||
|
let passed: Bool
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validator
|
||||||
|
|
||||||
|
enum DaemonStartupValidator {
|
||||||
|
|
||||||
|
static func run() -> [StartupCheckResult] {
|
||||||
|
[
|
||||||
|
checkXcode(),
|
||||||
|
checkXcodebuild(),
|
||||||
|
checkDevicectl(),
|
||||||
|
checkSourcekitLSP(),
|
||||||
|
checkTeamID(),
|
||||||
|
checkSigningIdentity(),
|
||||||
|
checkXcodeGen(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Checks
|
||||||
|
|
||||||
|
private static func checkXcode() -> StartupCheckResult {
|
||||||
|
let exists = FileManager.default.fileExists(atPath: "/Applications/Xcode.app")
|
||||||
|
return StartupCheckResult(
|
||||||
|
name: "Xcode Installation",
|
||||||
|
passed: exists,
|
||||||
|
message: exists
|
||||||
|
? "Xcode found at /Applications/Xcode.app"
|
||||||
|
: "Xcode not found — install from developer.apple.com or the App Store"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func checkXcodebuild() -> StartupCheckResult {
|
||||||
|
let result = shell("xcodebuild -version 2>/dev/null | head -1")
|
||||||
|
let passed = result.contains("Xcode")
|
||||||
|
return StartupCheckResult(
|
||||||
|
name: "xcodebuild",
|
||||||
|
passed: passed,
|
||||||
|
message: passed
|
||||||
|
? result.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: "xcodebuild not functional — run: xcode-select --install"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func checkDevicectl() -> StartupCheckResult {
|
||||||
|
let result = shell("xcrun devicectl --version 2>/dev/null")
|
||||||
|
let passed = !result.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
return StartupCheckResult(
|
||||||
|
name: "devicectl",
|
||||||
|
passed: passed,
|
||||||
|
message: passed ? "devicectl available" : "devicectl not found — requires Xcode 15+"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func checkSourcekitLSP() -> StartupCheckResult {
|
||||||
|
let path = "/Applications/Xcode.app/Contents/Developer/Toolchains/" +
|
||||||
|
"XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp"
|
||||||
|
let exists = FileManager.default.fileExists(atPath: path)
|
||||||
|
return StartupCheckResult(
|
||||||
|
name: "sourcekit-lsp",
|
||||||
|
passed: exists,
|
||||||
|
message: exists ? "sourcekit-lsp found" : "sourcekit-lsp not found — completions unavailable"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func checkTeamID() -> StartupCheckResult {
|
||||||
|
let teamID = loadTeamID()
|
||||||
|
let valid = teamID.count == 10
|
||||||
|
&& teamID != "YOUR_10_CHAR_TEAM_ID"
|
||||||
|
&& teamID.allSatisfy({ $0.isLetter || $0.isNumber })
|
||||||
|
return StartupCheckResult(
|
||||||
|
name: "Team ID",
|
||||||
|
passed: valid,
|
||||||
|
message: valid
|
||||||
|
? "Team ID configured: \(teamID)"
|
||||||
|
: "Team ID not set — edit ~/.padxcode/config.json"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func checkSigningIdentity() -> StartupCheckResult {
|
||||||
|
let result = shell(
|
||||||
|
"security find-identity -v -p codesigning 2>/dev/null | grep 'Apple Development'"
|
||||||
|
)
|
||||||
|
let passed = !result.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
let certName = result
|
||||||
|
.components(separatedBy: "\"")
|
||||||
|
.dropFirst()
|
||||||
|
.first ?? "certificate present"
|
||||||
|
return StartupCheckResult(
|
||||||
|
name: "Apple Development Certificate",
|
||||||
|
passed: passed,
|
||||||
|
message: passed
|
||||||
|
? "Found: \(certName)"
|
||||||
|
: "No Apple Development certificate — Xcode → Settings → Accounts → Manage Certificates"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func checkXcodeGen() -> StartupCheckResult {
|
||||||
|
let result = shell("which xcodegen 2>/dev/null")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let passed = !result.isEmpty
|
||||||
|
return StartupCheckResult(
|
||||||
|
name: "XcodeGen",
|
||||||
|
passed: passed,
|
||||||
|
message: passed
|
||||||
|
? "XcodeGen at \(result)"
|
||||||
|
: "XcodeGen not found — brew install xcodegen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shell helper
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private static func shell(_ command: String) -> String {
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||||
|
p.arguments = ["-lc", command]
|
||||||
|
let pipe = Pipe()
|
||||||
|
p.standardOutput = pipe
|
||||||
|
p.standardError = Pipe()
|
||||||
|
try? p.run()
|
||||||
|
p.waitUntilExit()
|
||||||
|
return String(
|
||||||
|
data: pipe.fileHandleForReading.readDataToEndOfFile(),
|
||||||
|
encoding: .utf8
|
||||||
|
) ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Daemon/DeviceManager.swift
Normal file
99
Daemon/DeviceManager.swift
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import Foundation
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
struct DeviceManager {
|
||||||
|
|
||||||
|
static func listConnectedDevices(logger: Logger) async -> [ConnectedDevice] {
|
||||||
|
let tmp = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("padxcode-devices-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
|
||||||
|
p.arguments = ["devicectl", "list", "devices", "--json-output", tmp.path]
|
||||||
|
p.standardError = Pipe()
|
||||||
|
try? p.run(); p.waitUntilExit()
|
||||||
|
guard p.terminationStatus == 0,
|
||||||
|
let data = try? Data(contentsOf: tmp) else { return [] }
|
||||||
|
return parseDevicectlJSON(data, logger: logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func installApp(appPath: String, deviceUDID: String, session: BuildSession) async -> Int32 {
|
||||||
|
session.broadcast(BuildLogMessage(type: .stdout, text: "📲 Installing on \(deviceUDID)…\n", exitCode: nil))
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
|
||||||
|
p.arguments = ["devicectl", "device", "install", "app", "--device", deviceUDID, appPath]
|
||||||
|
let out = Pipe(); let err = Pipe()
|
||||||
|
p.standardOutput = out; p.standardError = err
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
out.fileHandleForReading.readabilityHandler = { h in
|
||||||
|
let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return }
|
||||||
|
session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil))
|
||||||
|
}
|
||||||
|
err.fileHandleForReading.readabilityHandler = { h in
|
||||||
|
let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return }
|
||||||
|
session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil))
|
||||||
|
}
|
||||||
|
p.terminationHandler = { proc in
|
||||||
|
let code = proc.terminationStatus
|
||||||
|
session.broadcast(BuildLogMessage(type: .status,
|
||||||
|
text: code == 0 ? "✅ App installed." : "❌ Install failed (exit \(code)).",
|
||||||
|
exitCode: Int(code)))
|
||||||
|
cont.resume(returning: code)
|
||||||
|
}
|
||||||
|
try? p.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func launchApp(bundleID: String, deviceUDID: String, session: BuildSession) async {
|
||||||
|
session.broadcast(BuildLogMessage(type: .stdout, text: "🚀 Launching \(bundleID)…\n", exitCode: nil))
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
|
||||||
|
p.arguments = ["devicectl", "device", "process", "launch", "--device", deviceUDID, bundleID]
|
||||||
|
let out = Pipe(); let err = Pipe()
|
||||||
|
p.standardOutput = out; p.standardError = err
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
||||||
|
out.fileHandleForReading.readabilityHandler = { h in
|
||||||
|
let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return }
|
||||||
|
session.broadcast(BuildLogMessage(type: .stdout, text: t, exitCode: nil))
|
||||||
|
}
|
||||||
|
err.fileHandleForReading.readabilityHandler = { h in
|
||||||
|
let d = h.availableData; guard !d.isEmpty, let t = String(data: d, encoding: .utf8) else { return }
|
||||||
|
session.broadcast(BuildLogMessage(type: .stderr, text: t, exitCode: nil))
|
||||||
|
}
|
||||||
|
p.terminationHandler = { proc in
|
||||||
|
let code = proc.terminationStatus
|
||||||
|
session.broadcast(BuildLogMessage(type: .status,
|
||||||
|
text: code == 0 ? "✅ App launched." : "⚠️ Launch exit \(code).",
|
||||||
|
exitCode: Int(code)))
|
||||||
|
cont.resume()
|
||||||
|
}
|
||||||
|
try? p.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DevicectlOutput: Decodable {
|
||||||
|
struct Result: Decodable {
|
||||||
|
struct Device: Decodable {
|
||||||
|
struct CP: Decodable { let pairingState: String?; let tunnelIPAddress: String? }
|
||||||
|
struct HP: Decodable { let udid: String; let productType: String? }
|
||||||
|
struct DP: Decodable { let name: String; let osVersionNumber: String? }
|
||||||
|
let connectionProperties: CP; let hardwareProperties: HP; let deviceProperties: DP
|
||||||
|
}
|
||||||
|
let devices: [Device]
|
||||||
|
}
|
||||||
|
let result: Result
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseDevicectlJSON(_ data: Data, logger: Logger) -> [ConnectedDevice] {
|
||||||
|
guard let out = try? JSONDecoder().decode(DevicectlOutput.self, from: data) else { return [] }
|
||||||
|
return out.result.devices.compactMap { d in
|
||||||
|
guard d.connectionProperties.pairingState == "paired" else { return nil }
|
||||||
|
return ConnectedDevice(udid: d.hardwareProperties.udid,
|
||||||
|
name: d.deviceProperties.name,
|
||||||
|
model: d.hardwareProperties.productType ?? "Unknown",
|
||||||
|
osVersion: d.deviceProperties.osVersionNumber ?? "Unknown",
|
||||||
|
isNetworkConnected: d.connectionProperties.tunnelIPAddress != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Daemon/LSPProxy.swift
Normal file
70
Daemon/LSPProxy.swift
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import Vapor
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor LSPProxy {
|
||||||
|
static let shared = LSPProxy()
|
||||||
|
private var process: Process?
|
||||||
|
private var stdinPipe: Pipe?
|
||||||
|
private var clients: [WebSocket] = []
|
||||||
|
|
||||||
|
func start(projectPath: String) throws {
|
||||||
|
guard process == nil else { return }
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath:
|
||||||
|
"/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp")
|
||||||
|
p.currentDirectoryURL = URL(fileURLWithPath: projectPath)
|
||||||
|
let inPipe = Pipe(); let outPipe = Pipe(); let errPipe = Pipe()
|
||||||
|
p.standardInput = inPipe; p.standardOutput = outPipe; p.standardError = errPipe
|
||||||
|
stdinPipe = inPipe
|
||||||
|
outPipe.fileHandleForReading.readabilityHandler = { [weak self] h in
|
||||||
|
let d = h.availableData; guard !d.isEmpty else { return }
|
||||||
|
Task { await self?.broadcastToClients(d) }
|
||||||
|
}
|
||||||
|
p.terminationHandler = { [weak self] _ in Task { await self?.handleTermination() } }
|
||||||
|
try p.run(); process = p
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() { process?.terminate(); process = nil; stdinPipe = nil }
|
||||||
|
private func handleTermination() { process = nil; broadcastStatus("lsp_terminated") }
|
||||||
|
|
||||||
|
func addClient(_ ws: WebSocket) { clients.append(ws) }
|
||||||
|
func removeClient(_ ws: WebSocket) { clients.removeAll { $0 === ws } }
|
||||||
|
|
||||||
|
func forwardToLSP(_ message: Data) {
|
||||||
|
guard let pipe = stdinPipe else { return }
|
||||||
|
let header = "Content-Length: \(message.count)\r\n\r\n"
|
||||||
|
if let hd = header.data(using: .utf8) {
|
||||||
|
pipe.fileHandleForWriting.write(hd)
|
||||||
|
pipe.fileHandleForWriting.write(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func broadcastToClients(_ data: Data) {
|
||||||
|
let messages = extractJSONBodies(from: data)
|
||||||
|
for msg in messages {
|
||||||
|
guard let text = String(data: msg, encoding: .utf8) else { continue }
|
||||||
|
for ws in clients where !ws.isClosed { ws.send(text) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func broadcastStatus(_ s: String) {
|
||||||
|
for ws in clients where !ws.isClosed { ws.send(#"{"padxcode_status":"\#(s)"}"#) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractJSONBodies(from data: Data) -> [Data] {
|
||||||
|
var results: [Data] = []; var rem = data
|
||||||
|
let sep = Data([0x0D,0x0A,0x0D,0x0A])
|
||||||
|
while !rem.isEmpty {
|
||||||
|
guard let sr = rem.range(of: sep) else { break }
|
||||||
|
let hd = rem[rem.startIndex..<sr.lowerBound]
|
||||||
|
guard let hs = String(data: hd, encoding: .utf8),
|
||||||
|
let ls = hs.components(separatedBy: "\r\n").first(where: { $0.hasPrefix("Content-Length:") })?
|
||||||
|
.components(separatedBy: ":").last?.trimmingCharacters(in: .whitespaces),
|
||||||
|
let len = Int(ls) else { break }
|
||||||
|
let bs = sr.upperBound
|
||||||
|
let be = rem.index(bs, offsetBy: len, limitedBy: rem.endIndex) ?? rem.endIndex
|
||||||
|
results.append(Data(rem[bs..<be])); rem = rem[be...]
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Daemon/Models.swift
Normal file
31
Daemon/Models.swift
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
struct FileNode: Content {
|
||||||
|
let name: String; let path: String; let isDirectory: Bool; var children: [FileNode]?
|
||||||
|
}
|
||||||
|
struct FileContentResponse: Content { let path: String; let content: String }
|
||||||
|
struct FileSaveRequest: Content { let path: String; let content: String }
|
||||||
|
struct BuildRequest: Content {
|
||||||
|
let projectPath: String; let scheme: String; let destination: String
|
||||||
|
let action: BuildAction; let configuration: String
|
||||||
|
let preBuildHooks: [PreBuildHook]; let bundleIdentifier: String?
|
||||||
|
let extraBuildSettings: [String]
|
||||||
|
}
|
||||||
|
enum BuildAction: String, Codable { case build; case buildAndRun }
|
||||||
|
struct BuildSessionResponse: Content { let sessionId: String; let webSocketURL: String }
|
||||||
|
struct ConnectedDevice: Content {
|
||||||
|
let udid: String; let name: String; let model: String
|
||||||
|
let osVersion: String; let isNetworkConnected: Bool
|
||||||
|
}
|
||||||
|
struct BuildLogMessage: Content {
|
||||||
|
enum MessageType: String, Codable { case stdout, stderr, status }
|
||||||
|
let type: MessageType; let text: String; let exitCode: Int?
|
||||||
|
}
|
||||||
|
struct PreBuildHook: Codable {
|
||||||
|
let name: String; let command: String; let workingDirectory: String
|
||||||
|
let timeoutSeconds: Int; let continueOnFailure: Bool
|
||||||
|
static func xcodeGen(projectRoot: String) -> PreBuildHook {
|
||||||
|
PreBuildHook(name: "XcodeGen", command: "./generate.sh",
|
||||||
|
workingDirectory: projectRoot, timeoutSeconds: 60, continueOnFailure: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
Daemon/PreBuildRunner.swift
Normal file
60
Daemon/PreBuildRunner.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import Foundation
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
final class PreBuildRunner {
|
||||||
|
static func run(hooks: [PreBuildHook], session: BuildSession, logger: Logger) async -> Bool {
|
||||||
|
for hook in hooks {
|
||||||
|
session.broadcast(BuildLogMessage(type: .stdout, text: "\n⚙️ Hook: \(hook.name)\n", exitCode: nil))
|
||||||
|
let ok = await runHook(hook, session: session)
|
||||||
|
if !ok {
|
||||||
|
session.broadcast(BuildLogMessage(type: .stderr, text: "❌ Hook '\(hook.name)' failed.", exitCode: nil))
|
||||||
|
if !hook.continueOnFailure { return false }
|
||||||
|
} else {
|
||||||
|
session.broadcast(BuildLogMessage(type: .stdout, text: "✅ \(hook.name) done.\n", exitCode: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runHook(_ hook: PreBuildHook, session: BuildSession) async -> Bool {
|
||||||
|
await withCheckedContinuation { cont in
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||||
|
p.arguments = ["-l", "-c", hook.command]
|
||||||
|
p.currentDirectoryURL = URL(fileURLWithPath: hook.workingDirectory)
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:" + (env["PATH"] ?? "")
|
||||||
|
p.environment = env
|
||||||
|
let out = Pipe(); let err = Pipe()
|
||||||
|
p.standardOutput = out; p.standardError = err
|
||||||
|
var timedOut = false
|
||||||
|
let wd = DispatchWorkItem { timedOut = true; p.terminate()
|
||||||
|
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)) }
|
||||||
|
}
|
||||||
|
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)) }
|
||||||
|
}
|
||||||
|
p.terminationHandler = { proc in
|
||||||
|
wd.cancel()
|
||||||
|
g.notify(queue: .global()) { cont.resume(returning: proc.terminationStatus == 0 && !timedOut) }
|
||||||
|
}
|
||||||
|
do { try p.run() }
|
||||||
|
catch {
|
||||||
|
wd.cancel()
|
||||||
|
session.broadcast(BuildLogMessage(type: .stderr, text: "Launch failed: \(error)", exitCode: nil))
|
||||||
|
cont.resume(returning: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Daemon/Setup.swift
Normal file
32
Daemon/Setup.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
func ensureConfigFile() {
|
||||||
|
let dir = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".padxcode")
|
||||||
|
let file = dir.appendingPathComponent("config.json")
|
||||||
|
guard !FileManager.default.fileExists(atPath: file.path) else { return }
|
||||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
let defaults: [String: String] = [
|
||||||
|
"developmentTeam": "YOUR_10_CHAR_TEAM_ID",
|
||||||
|
"codeSignIdentity": "Apple Development",
|
||||||
|
"allowProvisioningUpdates": "true"
|
||||||
|
]
|
||||||
|
if let data = try? JSONEncoder().encode(defaults) { try? data.write(to: file) }
|
||||||
|
print("""
|
||||||
|
╔══════════════════════════════════════════════════╗
|
||||||
|
║ PadXcode Daemon — First Run Setup ║
|
||||||
|
╠══════════════════════════════════════════════════╣
|
||||||
|
║ Config created: ~/.padxcode/config.json ║
|
||||||
|
║ Edit it and set your developmentTeam to your ║
|
||||||
|
║ 10-character Apple Developer Team ID. ║
|
||||||
|
║ Find it at: developer.apple.com/account ║
|
||||||
|
╚══════════════════════════════════════════════════╝
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTeamID() -> String {
|
||||||
|
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 "" }
|
||||||
|
return id
|
||||||
|
}
|
||||||
18
Daemon/configure.swift
Normal file
18
Daemon/configure.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
// `public` removed — DaemonController is an internal type, not exported from a module.
|
||||||
|
func configure(_ app: Application, controller: DaemonController) throws {
|
||||||
|
app.storage[DaemonControllerKey.self] = controller
|
||||||
|
try routes(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DaemonControllerKey: StorageKey {
|
||||||
|
typealias Value = DaemonController
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Application {
|
||||||
|
var daemonController: DaemonController? {
|
||||||
|
get { storage[DaemonControllerKey.self] }
|
||||||
|
set { storage[DaemonControllerKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
278
Daemon/routes.swift
Normal file
278
Daemon/routes.swift
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
import Vapor
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
func routes(_ app: Application) throws {
|
||||||
|
|
||||||
|
// MARK: - Health
|
||||||
|
|
||||||
|
app.get("health") { _ in ["status": "ok"] }
|
||||||
|
|
||||||
|
// MARK: - Files
|
||||||
|
|
||||||
|
app.get("files", "tree") { req -> FileNode in
|
||||||
|
guard let path = req.query[String.self, at: "path"] else { throw Abort(.badRequest) }
|
||||||
|
return try buildFileTree(at: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("files", "content") { req -> 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.put("files", "save") { req -> HTTPStatus in
|
||||||
|
let body = try req.content.decode(FileSaveRequest.self)
|
||||||
|
guard !body.path.contains("..") else { throw Abort(.forbidden) }
|
||||||
|
let url = URL(fileURLWithPath: body.path)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
let tmp = url.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent(".\(url.lastPathComponent).tmp")
|
||||||
|
try body.content.write(to: tmp, atomically: false, encoding: .utf8)
|
||||||
|
_ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
|
||||||
|
return .noContent
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("files", "create") { req -> HTTPStatus in
|
||||||
|
struct CR: Content { let parentPath: String; let fileName: String; let content: String }
|
||||||
|
let b = try req.content.decode(CR.self)
|
||||||
|
guard !b.parentPath.contains(".."), !b.fileName.contains("..") else { throw Abort(.forbidden) }
|
||||||
|
let dest = URL(fileURLWithPath: b.parentPath).appendingPathComponent(b.fileName)
|
||||||
|
guard !FileManager.default.fileExists(atPath: dest.path) else { throw Abort(.conflict) }
|
||||||
|
try b.content.write(to: dest, atomically: true, encoding: .utf8)
|
||||||
|
return .created
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("files", "rename") { req -> HTTPStatus in
|
||||||
|
struct RR: Content { let path: String; let newName: String }
|
||||||
|
let b = try req.content.decode(RR.self)
|
||||||
|
guard !b.path.contains("..") else { throw Abort(.forbidden) }
|
||||||
|
let src = URL(fileURLWithPath: b.path)
|
||||||
|
let dest = src.deletingLastPathComponent().appendingPathComponent(b.newName)
|
||||||
|
try FileManager.default.moveItem(at: src, to: dest)
|
||||||
|
return .ok
|
||||||
|
}
|
||||||
|
|
||||||
|
app.delete("files", "delete") { req -> HTTPStatus in
|
||||||
|
guard let path = req.query[String.self, at: "path"] else { throw Abort(.badRequest) }
|
||||||
|
guard !path.contains("..") else { throw Abort(.forbidden) }
|
||||||
|
try FileManager.default.removeItem(atPath: path)
|
||||||
|
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)
|
||||||
|
req.application.daemonController?.buildStarted(scheme: buildReq.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 {
|
||||||
|
ws.send(#"{"type":"stderr","text":"Unknown session.","exitCode":-1}"#)
|
||||||
|
try? await ws.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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") { _ -> HTTPStatus in
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||||
|
p.arguments = ["-lc", "which xcodegen"]
|
||||||
|
p.standardOutput = Pipe()
|
||||||
|
p.standardError = Pipe()
|
||||||
|
try? p.run(); p.waitUntilExit()
|
||||||
|
return p.terminationStatus == 0 ? .ok : .notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("system", "disk-space") { req -> Response in
|
||||||
|
let url = URL(fileURLWithPath: NSHomeDirectory())
|
||||||
|
let values = try? url.resourceValues(forKeys: [.volumeAvailableCapacityKey])
|
||||||
|
let free = Int64(values?.volumeAvailableCapacity ?? 0)
|
||||||
|
let body = try JSONEncoder().encode(["freeBytes": free])
|
||||||
|
return Response(status: .ok,
|
||||||
|
headers: ["Content-Type": "application/json"],
|
||||||
|
body: .init(data: body))
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("system", "validate") { req -> Response in
|
||||||
|
let checks = 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()
|
||||||
|
var args = projectArg + [
|
||||||
|
"-scheme", request.scheme,
|
||||||
|
"-destination", request.destination,
|
||||||
|
"-configuration", request.configuration,
|
||||||
|
"-derivedDataPath", ddPath,
|
||||||
|
"CODE_SIGN_IDENTITY=Apple Development",
|
||||||
|
"DEVELOPMENT_TEAM=\(teamID)",
|
||||||
|
"-allowProvisioningUpdates",
|
||||||
|
"-hideShellScriptEnvironment",
|
||||||
|
] + 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) 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)
|
||||||
|
}
|
||||||
|
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) }
|
||||||
|
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
|
||||||
|
}
|
||||||
32
PadXcodeDaemon.entitlements
Normal file
32
PadXcodeDaemon.entitlements
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Allows the daemon to accept incoming HTTP/WebSocket connections -->
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Allows outbound calls (devicectl, sourcekit-lsp, Tailscale) -->
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Allows reading and writing project files anywhere the user selects -->
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Allows running child processes (xcodebuild, xcrun, zsh) -->
|
||||||
|
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
|
||||||
|
<array>
|
||||||
|
<string>com.apple.dt.Xcode.PropertyListService</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Hardened runtime: allow unsigned executable memory (needed by some Vapor deps) -->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<!-- Hardened runtime: allow DYLD env variables (needed for some SPM builds) -->
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
83
Preferences/AppPreferences.swift
Normal file
83
Preferences/AppPreferences.swift
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import Foundation
|
||||||
|
import ServiceManagement
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
final class AppPreferences: ObservableObject {
|
||||||
|
|
||||||
|
@Published var port: Int {
|
||||||
|
didSet { UserDefaults.standard.set(port, forKey: "daemonPort") }
|
||||||
|
}
|
||||||
|
@Published var showInDock: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(showInDock, forKey: "showInDock")
|
||||||
|
applyDockPolicy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published var developmentTeam: String {
|
||||||
|
didSet { UserDefaults.standard.set(developmentTeam, forKey: "developmentTeam") }
|
||||||
|
}
|
||||||
|
@Published var allowProvisioningUpdates: Bool {
|
||||||
|
didSet { UserDefaults.standard.set(allowProvisioningUpdates, forKey: "allowProvisioningUpdates") }
|
||||||
|
}
|
||||||
|
@Published var buildConfiguration: String {
|
||||||
|
didSet { UserDefaults.standard.set(buildConfiguration, forKey: "buildConfiguration") }
|
||||||
|
}
|
||||||
|
|
||||||
|
var launchAtLogin: Bool {
|
||||||
|
get { SMAppService.mainApp.status == .enabled }
|
||||||
|
set {
|
||||||
|
do {
|
||||||
|
if newValue { try SMAppService.mainApp.register() }
|
||||||
|
else { try SMAppService.mainApp.unregister() }
|
||||||
|
objectWillChange.send()
|
||||||
|
} catch { print("SMAppService error: \(error)") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tailscaleIP: String {
|
||||||
|
TailscaleDetector.currentIP() ?? "Not detected"
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let d = UserDefaults.standard
|
||||||
|
self.port = d.integer(forKey: "daemonPort").nonZero ?? 8080
|
||||||
|
self.showInDock = d.bool(forKey: "showInDock")
|
||||||
|
self.developmentTeam = d.string(forKey: "developmentTeam") ?? ""
|
||||||
|
self.allowProvisioningUpdates = d.object(forKey: "allowProvisioningUpdates") as? Bool ?? true
|
||||||
|
self.buildConfiguration = d.string(forKey: "buildConfiguration") ?? "Debug"
|
||||||
|
applyDockPolicy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyDockPolicy() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NSApp.setActivationPolicy(self.showInDock ? .regular : .accessory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Int {
|
||||||
|
var nonZero: Int? { self == 0 ? nil : self }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TailscaleDetector {
|
||||||
|
static func currentIP() -> String? {
|
||||||
|
let paths = [
|
||||||
|
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||||
|
"/usr/local/bin/tailscale",
|
||||||
|
"/opt/homebrew/bin/tailscale"
|
||||||
|
]
|
||||||
|
guard let exe = paths.first(where: { FileManager.default.fileExists(atPath: $0) }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let p = Process()
|
||||||
|
p.executableURL = URL(fileURLWithPath: exe)
|
||||||
|
p.arguments = ["ip", "--4"]
|
||||||
|
let pipe = Pipe()
|
||||||
|
p.standardOutput = pipe
|
||||||
|
p.standardError = Pipe()
|
||||||
|
try? p.run(); p.waitUntilExit()
|
||||||
|
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.components(separatedBy: "\n").first
|
||||||
|
}
|
||||||
|
}
|
||||||
41
README.md
Normal file
41
README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# PadXcode — Mac Daemon
|
||||||
|
|
||||||
|
macOS menu bar app. Runs a Vapor server the iPad connects to over Tailscale.
|
||||||
|
Builds your projects and deploys to your physical iPad wirelessly via devicectl.
|
||||||
|
|
||||||
|
## Xcode Setup
|
||||||
|
```bash
|
||||||
|
./generate.sh
|
||||||
|
open PadXcodeDaemon.xcodeproj
|
||||||
|
# Signing & Capabilities → set your Development Team
|
||||||
|
# Destination: My Mac → ⌘R
|
||||||
|
# Daemon appears in menu bar
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config
|
||||||
|
`~/.padxcode/config.json` is auto-created on first run. Edit it:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"developmentTeam": "YOUR_10_CHAR_TEAM_ID",
|
||||||
|
"codeSignIdentity": "Apple Development",
|
||||||
|
"allowProvisioningUpdates": "true"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git Setup
|
||||||
|
|
||||||
|
Your Raspberry Pi is the central git remote.
|
||||||
|
Xcode on Mac handles all git operations for this project.
|
||||||
|
|
||||||
|
**Clone on Mac:**
|
||||||
|
```bash
|
||||||
|
git clone http://pi.tailscale-ip/PadXcode-Daemon.git ~/Dev/PadXcode-Daemon
|
||||||
|
open ~/Dev/PadXcode-Daemon/PadXcodeDaemon.xcodeproj
|
||||||
|
```
|
||||||
|
Xcode → Source Control → Pull/Commit/Push as normal.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
- iPad edits NavidromePlayer (or any project) via PadXcode
|
||||||
|
- iPad Git panel commits and pushes to Pi via Working Copy
|
||||||
|
- Mac Xcode pulls from Pi to get latest
|
||||||
|
- Mac builds and deploys via the daemon
|
||||||
64
UI/DaemonLogWindowView.swift
Normal file
64
UI/DaemonLogWindowView.swift
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DaemonLogWindowView: View {
|
||||||
|
@Bindable var controller: DaemonController
|
||||||
|
@State private var filterLevel: DaemonLogLine.Level? = nil
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var autoscroll = true
|
||||||
|
|
||||||
|
private var filtered: [DaemonLogLine] {
|
||||||
|
var lines = controller.logLines
|
||||||
|
if let lv = filterLevel { lines = lines.filter { $0.level == lv } }
|
||||||
|
if !searchText.isEmpty { lines = lines.filter { $0.message.localizedCaseInsensitiveContains(searchText) } }
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing:0) {
|
||||||
|
HStack(spacing:10) {
|
||||||
|
ForEach(["All",nil,"info",DaemonLogLine.Level.info,"success",DaemonLogLine.Level.success,
|
||||||
|
"warning",DaemonLogLine.Level.warning,"error",DaemonLogLine.Level.error] as [Any], id:\.self) { _ in EmptyView() }
|
||||||
|
HStack(spacing:4) {
|
||||||
|
levelBtn("All", nil)
|
||||||
|
levelBtn("Info", .info)
|
||||||
|
levelBtn("OK", .success)
|
||||||
|
levelBtn("Warn", .warning)
|
||||||
|
levelBtn("Err", .error)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
HStack(spacing:4) {
|
||||||
|
Image(systemName:"magnifyingglass").foregroundStyle(.secondary).font(.caption)
|
||||||
|
TextField("Search…", text:$searchText).textFieldStyle(.roundedBorder).frame(width:160)
|
||||||
|
}
|
||||||
|
Toggle(isOn:$autoscroll) { Image(systemName:"arrow.down.to.line") }.toggleStyle(.button).help("Auto-scroll")
|
||||||
|
Button { controller.clearLog() } label: { Image(systemName:"trash") }.help("Clear")
|
||||||
|
}
|
||||||
|
.padding(.horizontal,12).padding(.vertical,8).background(Color(NSColor.windowBackgroundColor))
|
||||||
|
Divider()
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment:.leading,spacing:0) {
|
||||||
|
ForEach(filtered) { line in
|
||||||
|
HStack(alignment:.top,spacing:8) {
|
||||||
|
Text(line.formattedTime).font(.system(.caption2,design:.monospaced)).foregroundStyle(.tertiary).frame(width:60,alignment:.leading)
|
||||||
|
Text(line.level.prefix).font(.caption).frame(width:16)
|
||||||
|
Text(line.message).font(.system(.caption,design:.monospaced)).textSelection(.enabled).frame(maxWidth:.infinity,alignment:.leading)
|
||||||
|
}
|
||||||
|
.padding(.vertical,1).padding(.horizontal,4)
|
||||||
|
.background(line.level == .error ? Color.red.opacity(0.06) : line.level == .warning ? Color.orange.opacity(0.06) : .clear)
|
||||||
|
.id(line.id)
|
||||||
|
}
|
||||||
|
}.padding(.horizontal,8).padding(.vertical,4)
|
||||||
|
}
|
||||||
|
.onChange(of:filtered.count) { _,_ in if autoscroll, let last = filtered.last { proxy.scrollTo(last.id,anchor:.bottom) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(NSColor.textBackgroundColor))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelBtn(_ label: String, _ level: DaemonLogLine.Level?) -> some View {
|
||||||
|
Button(label) { filterLevel = level }
|
||||||
|
.buttonStyle(.bordered).controlSize(.mini)
|
||||||
|
.tint(filterLevel == level ? .accentColor : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
108
UI/DaemonSettingsView.swift
Normal file
108
UI/DaemonSettingsView.swift
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import SwiftUI
|
||||||
|
import ServiceManagement
|
||||||
|
|
||||||
|
struct DaemonSettingsView: View {
|
||||||
|
@Bindable var controller: DaemonController
|
||||||
|
@ObservedObject var preferences: AppPreferences
|
||||||
|
@State private var pendingPort = ""
|
||||||
|
@State private var showRestartAlert = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
serverTab.tabItem { Label("Server", systemImage:"server.rack") }
|
||||||
|
buildTab.tabItem { Label("Build", systemImage:"hammer") }
|
||||||
|
generalTab.tabItem { Label("General", systemImage:"gearshape") }
|
||||||
|
}
|
||||||
|
.padding(20).frame(width:520)
|
||||||
|
.onAppear { pendingPort = String(preferences.port) }
|
||||||
|
.alert("Restart Required", isPresented:$showRestartAlert) {
|
||||||
|
Button("Restart Now") { Task { await controller.applyPortChange() } }
|
||||||
|
Button("Later", role:.cancel) {}
|
||||||
|
} message: { Text("Port change takes effect after restart.") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var serverTab: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
TextField("Port", text:$pendingPort).textFieldStyle(.roundedBorder).frame(width:80).onSubmit { applyPort() }
|
||||||
|
Text("Default: 8080").foregroundStyle(.secondary).font(.caption)
|
||||||
|
Spacer()
|
||||||
|
Button("Apply") { applyPort() }.disabled(pendingPort == String(preferences.port))
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Tailscale IP").foregroundStyle(.secondary); Spacer()
|
||||||
|
Text(preferences.tailscaleIP).font(.system(.body,design:.monospaced))
|
||||||
|
Button { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(preferences.tailscaleIP,forType:.string) }
|
||||||
|
label: { Image(systemName:"doc.on.clipboard") }.buttonStyle(.plain).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("Status").foregroundStyle(.secondary); Spacer()
|
||||||
|
HStack(spacing:6) {
|
||||||
|
Circle().fill(statusColor).frame(width:8,height:8)
|
||||||
|
Text(controller.serverState.statusLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing:8) {
|
||||||
|
Button(controller.serverState.isRunning ? "Stop" : "Start") {
|
||||||
|
Task { if controller.serverState.isRunning { await controller.stop() } else { await controller.start() } }
|
||||||
|
}.tint(controller.serverState.isRunning ? .red : .green)
|
||||||
|
Button("Restart") { Task { await controller.restart() } }.disabled(!controller.serverState.isRunning)
|
||||||
|
}.buttonStyle(.bordered)
|
||||||
|
} header: { Text("Network & Status") }
|
||||||
|
}.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buildTab: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
TextField("XXXXXXXXXX", text:$preferences.developmentTeam)
|
||||||
|
.textFieldStyle(.roundedBorder).font(.system(.body,design:.monospaced)).frame(width:140)
|
||||||
|
Button("Find It") { NSWorkspace.shared.open(URL(string:"https://developer.apple.com/account#MembershipDetailsCard")!) }
|
||||||
|
.buttonStyle(.link).font(.caption)
|
||||||
|
}
|
||||||
|
if preferences.developmentTeam.isEmpty {
|
||||||
|
Label("Required for code signing.", systemImage:"exclamationmark.triangle.fill").foregroundStyle(.orange).font(.caption)
|
||||||
|
} else if preferences.developmentTeam.count == 10 {
|
||||||
|
Label("Team ID looks valid.", systemImage:"checkmark.circle.fill").foregroundStyle(.green).font(.caption)
|
||||||
|
}
|
||||||
|
} header: { Text("Development Team ID") } footer: { Text("Found at developer.apple.com/account → Membership → Team ID") }
|
||||||
|
Section {
|
||||||
|
Toggle("Allow Provisioning Updates", isOn:$preferences.allowProvisioningUpdates)
|
||||||
|
Picker("Configuration", selection:$preferences.buildConfiguration) {
|
||||||
|
Text("Debug").tag("Debug"); Text("Release").tag("Release")
|
||||||
|
}
|
||||||
|
} header: { Text("Build Options") }
|
||||||
|
}.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var generalTab: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Toggle("Launch at Login", isOn:Binding(get:{ preferences.launchAtLogin }, set:{ preferences.launchAtLogin = $0 }))
|
||||||
|
Toggle("Show in Dock", isOn:$preferences.showInDock)
|
||||||
|
} header: { Text("Startup") } footer: { Text("Launch at Login is managed by macOS System Settings.") }
|
||||||
|
Section {
|
||||||
|
Button("Open System Login Items") { SMAppService.openSystemSettingsLoginItems() }.buttonStyle(.link)
|
||||||
|
} header: { Text("System Settings") }
|
||||||
|
Section {
|
||||||
|
LabeledContent("Version", value:"1.0.0")
|
||||||
|
LabeledContent("Vapor", value:"4.x")
|
||||||
|
LabeledContent("Platform", value:"macOS Sequoia")
|
||||||
|
} header: { Text("About") }
|
||||||
|
}.formStyle(.grouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusColor: Color {
|
||||||
|
switch controller.serverState {
|
||||||
|
case .running: return .green; case .starting: return .yellow; case .error: return .red; default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPort() {
|
||||||
|
guard let p = Int(pendingPort), p > 1024, p < 65535, p != preferences.port else { return }
|
||||||
|
preferences.port = p
|
||||||
|
if controller.serverState.isRunning { showRestartAlert = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
156
UI/MenuBarView.swift
Normal file
156
UI/MenuBarView.swift
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MenuBarView: View {
|
||||||
|
@Bindable var controller: DaemonController
|
||||||
|
@ObservedObject var preferences: AppPreferences
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header.padding(.horizontal,14).padding(.top,14).padding(.bottom,10)
|
||||||
|
Divider()
|
||||||
|
statusGrid.padding(.horizontal,14).padding(.vertical,10)
|
||||||
|
Divider()
|
||||||
|
if !controller.connectedClients.isEmpty {
|
||||||
|
connectionsSection.padding(.horizontal,14).padding(.vertical,8)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
actionsSection.padding(.horizontal,14).padding(.vertical,8)
|
||||||
|
Divider()
|
||||||
|
footer.padding(.horizontal,14).padding(.vertical,8)
|
||||||
|
}
|
||||||
|
.frame(width: 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(spacing:10) {
|
||||||
|
Circle().fill(stateColor).frame(width:10,height:10).shadow(color:stateColor.opacity(0.6),radius:4)
|
||||||
|
VStack(alignment:.leading,spacing:2) {
|
||||||
|
Text("PadXcode Daemon").font(.headline)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusGrid: some View {
|
||||||
|
Grid(alignment:.leading,horizontalSpacing:12,verticalSpacing:6) {
|
||||||
|
GridRow {
|
||||||
|
StatusCell(label:"Port", value:"\(preferences.port)", icon:"antenna.radiowaves.left.and.right")
|
||||||
|
StatusCell(label:"Uptime", value:controller.serverState.uptime ?? "—", icon:"clock")
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
StatusCell(label:"Tailscale", value:preferences.tailscaleIP, icon:"network")
|
||||||
|
StatusCell(label:"Builds", value:"\(controller.buildCount)", icon:"hammer")
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
StatusCell(label:"Clients", value:"\(controller.connectedClients.count)", icon:"ipad.and.iphone")
|
||||||
|
StatusCell(label:"Config", value:preferences.developmentTeam.isEmpty ? "⚠️ No Team ID" : "✓ Signed", icon:"checkmark.seal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var connectionsSection: some View {
|
||||||
|
VStack(alignment:.leading,spacing:4) {
|
||||||
|
Text("Active Connections").font(.caption.bold()).foregroundStyle(.secondary)
|
||||||
|
ForEach(controller.connectedClients) { c in
|
||||||
|
HStack(spacing:6) {
|
||||||
|
Circle().fill(.green).frame(width:6,height:6)
|
||||||
|
Text(c.type.rawValue).font(.caption)
|
||||||
|
Spacer()
|
||||||
|
Text(c.sessionId.prefix(8)).font(.system(.caption2,design:.monospaced)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionsSection: some View {
|
||||||
|
VStack(spacing:2) {
|
||||||
|
MenuAction(label:"Restart Server", icon:"arrow.clockwise") { Task { await controller.restart() } }
|
||||||
|
MenuAction(label:"View Log", icon:"doc.text.magnifyingglass") { openWindow(id:"log"); NSApp.activate(ignoringOtherApps:true) }
|
||||||
|
MenuAction(label:"Copy Server URL", icon:"doc.on.clipboard") {
|
||||||
|
let url = "http://\(preferences.tailscaleIP):\(preferences.port)"
|
||||||
|
NSPasteboard.general.clearContents(); NSPasteboard.general.setString(url, forType:.string)
|
||||||
|
}
|
||||||
|
MenuAction(label:"Open Config Folder", icon:"folder") {
|
||||||
|
NSWorkspace.shared.open(URL(fileURLWithPath:NSHomeDirectory()).appendingPathComponent(".padxcode"))
|
||||||
|
}
|
||||||
|
MenuAction(label:"Settings…", icon:"gearshape", shortcut:",") { openSettingsWindow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
HStack {
|
||||||
|
Text("v1.0.0").font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
Spacer()
|
||||||
|
Button("Quit PadXcode Daemon") { Task { await controller.stop(); NSApp.terminate(nil) } }
|
||||||
|
.buttonStyle(.plain).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stateColor: Color {
|
||||||
|
switch controller.serverState {
|
||||||
|
case .running: return .green; case .starting: return .yellow
|
||||||
|
case .error: return .red; case .stopped: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSettingsWindow() {
|
||||||
|
openWindow(id:"settings")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline:.now()+0.05) {
|
||||||
|
NSApp.setActivationPolicy(.regular); NSApp.activate(ignoringOtherApps:true)
|
||||||
|
NSApp.windows.first(where:{ $0.title == "PadXcode Daemon Settings" })?.makeKeyAndOrderFront(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusCell: View {
|
||||||
|
let label: String; let value: String; let icon: String
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing:5) {
|
||||||
|
Image(systemName:icon).font(.caption).foregroundStyle(.secondary).frame(width:14)
|
||||||
|
VStack(alignment:.leading,spacing:0) {
|
||||||
|
Text(label).font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
Text(value).font(.system(.caption,design:.monospaced)).lineLimit(1)
|
||||||
|
}
|
||||||
|
}.frame(maxWidth:.infinity,alignment:.leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MenuAction: View {
|
||||||
|
let label: String; let icon: String; var shortcut: String? = nil; let action: () -> Void
|
||||||
|
var body: some View {
|
||||||
|
Button(action:action) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName:icon).frame(width:16).foregroundStyle(.secondary)
|
||||||
|
Text(label)
|
||||||
|
Spacer()
|
||||||
|
if let sc = shortcut { Text("⌘\(sc)").font(.caption).foregroundStyle(.tertiary) }
|
||||||
|
}.contentShape(Rectangle()).padding(.horizontal,4).padding(.vertical,5)
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MenuBarIcon: View {
|
||||||
|
let controller: DaemonController?
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing:3) {
|
||||||
|
Image(systemName: iconName).symbolRenderingMode(.palette).foregroundStyle(iconColor, .primary)
|
||||||
|
if let u = controller?.serverState.uptime { Text(u).font(.system(size:10,design:.monospaced)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var iconName: String {
|
||||||
|
switch controller?.serverState {
|
||||||
|
case .running: return "dot.radiowaves.left.and.right"; case .starting: return "arrow.clockwise.circle"
|
||||||
|
case .error: return "exclamationmark.circle"; default: return "antenna.radiowaves.left.and.right.slash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var iconColor: Color {
|
||||||
|
switch controller?.serverState {
|
||||||
|
case .running: return .green; case .starting: return .yellow; case .error: return .red; default: return .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
generate.sh
Executable file
27
generate.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
||||||
|
#!/bin/zsh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BOLD='\033[1m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'; RED='\033[0;31m'; NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "\n${BOLD}PadXcode Daemon — Generating Xcode project${NC}\n"
|
||||||
|
|
||||||
|
if ! command -v xcodegen &>/dev/null; then
|
||||||
|
echo -e "${RED}✗${NC} XcodeGen not found. brew install xcodegen"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="${0:A:h}"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
[ -d "PadXcodeDaemon.xcodeproj" ] && rm -rf PadXcodeDaemon.xcodeproj
|
||||||
|
|
||||||
|
echo -e "${BLUE}▸${NC} Generating PadXcodeDaemon.xcodeproj..."
|
||||||
|
xcodegen generate --spec project.yml
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}✓${NC} ${BOLD}Done.${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " open PadXcodeDaemon.xcodeproj"
|
||||||
|
echo " Signing & Capabilities → set your Development Team"
|
||||||
|
echo " Destination: My Mac"
|
||||||
|
echo " ⌘R to build and run"
|
||||||
|
echo ""
|
||||||
64
project.yml
Normal file
64
project.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
name: PadXcodeDaemon
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: ca.dallasgroot
|
||||||
|
deploymentTarget:
|
||||||
|
macOS: "14.0"
|
||||||
|
xcodeVersion: "15.0"
|
||||||
|
createIntermediateGroups: true
|
||||||
|
generateEmptyDirectories: true
|
||||||
|
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: ca.dallasgroot.PadXcodeDaemon
|
||||||
|
PRODUCT_NAME: PadXcodeDaemon
|
||||||
|
MARKETING_VERSION: "1.0.0"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
DEVELOPMENT_TEAM: ""
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL: "-Onone"
|
||||||
|
DEBUG_INFORMATION_FORMAT: dwarf
|
||||||
|
ENABLE_HARDENED_RUNTIME: YES
|
||||||
|
|
||||||
|
packages:
|
||||||
|
Vapor:
|
||||||
|
url: https://github.com/vapor/vapor.git
|
||||||
|
from: "4.99.0"
|
||||||
|
|
||||||
|
targets:
|
||||||
|
PadXcodeDaemon:
|
||||||
|
type: application
|
||||||
|
platform: macOS
|
||||||
|
deploymentTarget: "14.0"
|
||||||
|
|
||||||
|
sources:
|
||||||
|
- path: App
|
||||||
|
createIntermediateGroups: true
|
||||||
|
- path: Daemon
|
||||||
|
createIntermediateGroups: true
|
||||||
|
- path: UI
|
||||||
|
createIntermediateGroups: true
|
||||||
|
- path: Preferences
|
||||||
|
createIntermediateGroups: true
|
||||||
|
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
INFOPLIST_FILE: App/Info.plist
|
||||||
|
CODE_SIGN_ENTITLEMENTS: PadXcodeDaemon.entitlements
|
||||||
|
LD_RUNPATH_SEARCH_PATHS:
|
||||||
|
- "$(inherited)"
|
||||||
|
- "@executable_path/../Frameworks"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
- package: Vapor
|
||||||
|
product: Vapor
|
||||||
|
|
||||||
|
preBuildScripts:
|
||||||
|
- name: "SwiftLint (optional)"
|
||||||
|
script: |
|
||||||
|
if which swiftlint > /dev/null; then
|
||||||
|
swiftlint
|
||||||
|
fi
|
||||||
|
basedOnDependencyAnalysis: false
|
||||||
|
showEnvVars: false
|
||||||
Loading…
Reference in a new issue