From 7df0dd657105620ac74ab88a8884f4c414fefa4d Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sun, 12 Apr 2026 00:42:51 -0700 Subject: [PATCH] initial commit --- App/Info.plist | 25 +++ App/PadXcodeDaemonApp.swift | 45 +++++ Daemon/BuildManager.swift | 65 +++++++ Daemon/DaemonController.swift | 109 +++++++++++ Daemon/DaemonStartupValidator.swift | 135 ++++++++++++++ Daemon/DeviceManager.swift | 99 ++++++++++ Daemon/LSPProxy.swift | 70 +++++++ Daemon/Models.swift | 31 ++++ Daemon/PreBuildRunner.swift | 60 ++++++ Daemon/Setup.swift | 32 ++++ Daemon/configure.swift | 18 ++ Daemon/routes.swift | 278 ++++++++++++++++++++++++++++ PadXcodeDaemon.entitlements | 32 ++++ Preferences/AppPreferences.swift | 83 +++++++++ README.md | 41 ++++ UI/DaemonLogWindowView.swift | 64 +++++++ UI/DaemonSettingsView.swift | 108 +++++++++++ UI/MenuBarView.swift | 156 ++++++++++++++++ generate.sh | 27 +++ project.yml | 64 +++++++ 20 files changed, 1542 insertions(+) create mode 100644 App/Info.plist create mode 100644 App/PadXcodeDaemonApp.swift create mode 100644 Daemon/BuildManager.swift create mode 100644 Daemon/DaemonController.swift create mode 100644 Daemon/DaemonStartupValidator.swift create mode 100644 Daemon/DeviceManager.swift create mode 100644 Daemon/LSPProxy.swift create mode 100644 Daemon/Models.swift create mode 100644 Daemon/PreBuildRunner.swift create mode 100644 Daemon/Setup.swift create mode 100644 Daemon/configure.swift create mode 100644 Daemon/routes.swift create mode 100644 PadXcodeDaemon.entitlements create mode 100644 Preferences/AppPreferences.swift create mode 100644 README.md create mode 100644 UI/DaemonLogWindowView.swift create mode 100644 UI/DaemonSettingsView.swift create mode 100644 UI/MenuBarView.swift create mode 100755 generate.sh create mode 100644 project.yml diff --git a/App/Info.plist b/App/Info.plist new file mode 100644 index 0000000..b565c04 --- /dev/null +++ b/App/Info.plist @@ -0,0 +1,25 @@ + + + + + CFBundleName + PadXcode Daemon + CFBundleIdentifier + ca.dallasgroot.PadXcodeDaemon + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + LSUIElement + + NSPrincipalClass + NSApplication + com.apple.security.network.server + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + diff --git a/App/PadXcodeDaemonApp.swift b/App/PadXcodeDaemonApp.swift new file mode 100644 index 0000000..72d24e0 --- /dev/null +++ b/App/PadXcodeDaemonApp.swift @@ -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() + } +} diff --git a/Daemon/BuildManager.swift b/Daemon/BuildManager.swift new file mode 100644 index 0000000..7e4290d --- /dev/null +++ b/Daemon/BuildManager.swift @@ -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) + } + } + } +} diff --git a/Daemon/DaemonController.swift b/Daemon/DaemonController.swift new file mode 100644 index 0000000..d9345f6 --- /dev/null +++ b/Daemon/DaemonController.swift @@ -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() } } +} diff --git a/Daemon/DaemonStartupValidator.swift b/Daemon/DaemonStartupValidator.swift new file mode 100644 index 0000000..7a0ecba --- /dev/null +++ b/Daemon/DaemonStartupValidator.swift @@ -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 + ) ?? "" + } +} diff --git a/Daemon/DeviceManager.swift b/Daemon/DeviceManager.swift new file mode 100644 index 0000000..8bdbc87 --- /dev/null +++ b/Daemon/DeviceManager.swift @@ -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) 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) + } + } +} diff --git a/Daemon/LSPProxy.swift b/Daemon/LSPProxy.swift new file mode 100644 index 0000000..1e6aa2e --- /dev/null +++ b/Daemon/LSPProxy.swift @@ -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.. PreBuildHook { + PreBuildHook(name: "XcodeGen", command: "./generate.sh", + workingDirectory: projectRoot, timeoutSeconds: 60, continueOnFailure: false) + } +} diff --git a/Daemon/PreBuildRunner.swift b/Daemon/PreBuildRunner.swift new file mode 100644 index 0000000..88730a4 --- /dev/null +++ b/Daemon/PreBuildRunner.swift @@ -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) + } + } + } +} diff --git a/Daemon/Setup.swift b/Daemon/Setup.swift new file mode 100644 index 0000000..9d3a73c --- /dev/null +++ b/Daemon/Setup.swift @@ -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 +} diff --git a/Daemon/configure.swift b/Daemon/configure.swift new file mode 100644 index 0000000..7b1b3a3 --- /dev/null +++ b/Daemon/configure.swift @@ -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 } + } +} diff --git a/Daemon/routes.swift b/Daemon/routes.swift new file mode 100644 index 0000000..83a0e12 --- /dev/null +++ b/Daemon/routes.swift @@ -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 — 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 = [ + ".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 +} diff --git a/PadXcodeDaemon.entitlements b/PadXcodeDaemon.entitlements new file mode 100644 index 0000000..c85b366 --- /dev/null +++ b/PadXcodeDaemon.entitlements @@ -0,0 +1,32 @@ + + + + + + com.apple.security.network.server + + + + com.apple.security.network.client + + + + com.apple.security.files.user-selected.read-write + + + + com.apple.security.temporary-exception.mach-lookup.global-name + + com.apple.dt.Xcode.PropertyListService + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/Preferences/AppPreferences.swift b/Preferences/AppPreferences.swift new file mode 100644 index 0000000..d2f8750 --- /dev/null +++ b/Preferences/AppPreferences.swift @@ -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 + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..91b21da --- /dev/null +++ b/README.md @@ -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 diff --git a/UI/DaemonLogWindowView.swift b/UI/DaemonLogWindowView.swift new file mode 100644 index 0000000..4c9abd1 --- /dev/null +++ b/UI/DaemonLogWindowView.swift @@ -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) + } +} diff --git a/UI/DaemonSettingsView.swift b/UI/DaemonSettingsView.swift new file mode 100644 index 0000000..90e3ab6 --- /dev/null +++ b/UI/DaemonSettingsView.swift @@ -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 } + } +} diff --git a/UI/MenuBarView.swift b/UI/MenuBarView.swift new file mode 100644 index 0000000..df4a709 --- /dev/null +++ b/UI/MenuBarView.swift @@ -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 + } + } +} diff --git a/generate.sh b/generate.sh new file mode 100755 index 0000000..58a865b --- /dev/null +++ b/generate.sh @@ -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 "" diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..08c0bdc --- /dev/null +++ b/project.yml @@ -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