PadXcode-Daemon/Daemon/DeviceManager.swift

100 lines
5 KiB
Swift
Raw Normal View History

2026-04-12 00:42:51 -07:00
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)
}
}
}