99 lines
5 KiB
Swift
99 lines
5 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|