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) } } }