vphone-cli: Add variant, udid, and device to vm info

`vm info` now reports the CFW variant the VM was last restored to, its
predicted UDID, and the product type the guest identifies as.

- udid: read from the bundle's udid-prediction.txt (VPhoneRestoreOps.resolveUDID)
- variant + device: recorded into restore-info.json at CFW-install time, in
  both the standalone `cfw install` and the `vm create` orchestrator. device is
  iPhone99,11 for every variant except exp, whose DeviceTree rewrite -> iPhone17,3.
- both new restore-info.json fields are optional, so pre-existing bundles decode
  unchanged and pick up variant/device on their next install.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
zqxwce
2026-08-03 11:19:21 +03:00
committed by zqxwce
co-authored by Claude Fable 5
parent c6fa19efcb
commit aeba01f13c
8 changed files with 110 additions and 3 deletions
@@ -6,6 +6,7 @@ public struct VPhoneBundleReport: Codable, Equatable, Sendable {
public let memoryMB: Int
public let diskSizeBytes: Int64
public let restoreInfo: VPhoneRestoreInfo?
public let udid: String?
public init(bundle: VPhoneBundle) {
self.name = bundle.name
@@ -13,5 +14,6 @@ public struct VPhoneBundleReport: Codable, Equatable, Sendable {
self.memoryMB = Int(bundle.manifest.memorySize / (1024 * 1024))
self.diskSizeBytes = bundle.diskSizeBytes
self.restoreInfo = VPhoneRestoreInfo.load(fromBundle: bundle)
self.udid = VPhoneRestoreOps.resolveUDID(bundle: bundle)
}
}
+27 -1
View File
@@ -16,14 +16,26 @@ public struct VPhoneRestoreInfo: Codable, Equatable, Sendable {
public let ios: OSVersion
public let cloudOS: OSVersion
public let variant: String?
public let device: String?
public init(ios: OSVersion, cloudOS: OSVersion) {
public init(ios: OSVersion, cloudOS: OSVersion, variant: String? = nil, device: String? = nil) {
self.ios = ios
self.cloudOS = cloudOS
self.variant = variant
self.device = device
}
static let fileName = "restore-info.json"
public static let baseDevice = "iPhone99,11"
public static let experimentalDevice = "iPhone17,3"
/// Only `exp` rewrites the DeviceTree identity; others keep the base type.
public static func device(forVariant variant: String) -> String {
variant == "exp" ? experimentalDevice : baseDevice
}
public static func url(forBundle bundle: VPhoneBundle) -> URL {
bundle.url.appendingPathComponent(fileName)
}
@@ -57,6 +69,20 @@ public struct VPhoneRestoreInfo: Codable, Equatable, Sendable {
try encoder.encode(self).write(to: Self.url(forBundle: bundle))
}
/// Set `variant` (and its device) on the bundle's restore-info.json, keeping
/// the recorded versions. nil if no versions exist yet to preserve.
@discardableResult
public static func recordVariant(_ variant: String, toBundle bundle: VPhoneBundle) throws
-> VPhoneRestoreInfo?
{
guard let base = load(fromBundle: bundle) else { return nil }
let merged = VPhoneRestoreInfo(
ios: base.ios, cloudOS: base.cloudOS,
variant: variant, device: device(forVariant: variant))
try merged.write(toBundle: bundle)
return merged
}
/// Remove the `iPhone*_Restore/` tree from the bundle; returns its name, or
/// nil if absent. Record versions (`derive`) first it reads this directory.
@discardableResult
+13
View File
@@ -23,6 +23,19 @@ public enum VPhoneRestoreOps {
return nil
}
// MARK: - UDID
/// UDID from the `UDID=` line of the bundle's udid-prediction.txt, or nil.
public static func resolveUDID(bundle: VPhoneBundle) -> String? {
let pred = bundle.url.appendingPathComponent("udid-prediction.txt")
guard let text = try? String(contentsOf: pred, encoding: .utf8) else { return nil }
for line in text.split(whereSeparator: \.isNewline) where line.hasPrefix("UDID=") {
let value = line.dropFirst("UDID=".count).trimmingCharacters(in: .whitespaces)
return value.isEmpty ? nil : value
}
return nil
}
// MARK: - AEA
/// True if the file begins with the AEA1 magic (`41 45 41 31`).
@@ -521,6 +521,10 @@ public struct VPhoneCreateOrchestrator {
}
guard code == 0 else { throw VPhoneCreateError.cfwInstallFailed(code) }
print("[+] CFW installed (\(options.variant)).")
if let bundle = try? VPhoneBundle.load(at: bundleURL),
let info = try? VPhoneRestoreInfo.recordVariant(options.variant, toBundle: bundle), info.variant != nil {
print("[+] Recorded variant \(options.variant), device \(info.device ?? "?")")
}
}
// MARK: - first boot
+7 -2
View File
@@ -151,8 +151,13 @@ struct VPhoneCFWInstallCommand: ParsableCommand {
code = try VPhoneProcessRunner.runStreaming(
URL(fileURLWithPath: "/bin/zsh"), args, env: env, echo: v.showsToolDetail)
}
if code == 0, !keepArtifacts, let removed = try? VPhoneRestoreInfo.removeBuiltFirmware(fromBundle: bundle) {
print("[cfw] removed built firmware \(removed)/ to save space (--keep-artifacts to keep)")
if code == 0 {
if let info = try? VPhoneRestoreInfo.recordVariant(variant, toBundle: bundle), info.variant != nil {
print("[cfw] recorded variant \(variant), device \(info.device ?? "?")")
}
if !keepArtifacts, let removed = try? VPhoneRestoreInfo.removeBuiltFirmware(fromBundle: bundle) {
print("[cfw] removed built firmware \(removed)/ to save space (--keep-artifacts to keep)")
}
}
throw ExitCode(code)
}
+3
View File
@@ -91,9 +91,12 @@ struct VPhoneVMInfoCommand: ParsableCommand {
print("mem: \(report.memoryMB) MB")
print("disk: \(report.diskSizeBytes) bytes")
print("net: \(describeNetwork(bundle.manifest.networkConfig))")
if let udid = report.udid { print("udid: \(udid)") }
if let info = report.restoreInfo {
print("iOS: \(info.ios.version) (\(info.ios.build))")
print("cloudOS: \(info.cloudOS.version) (\(info.cloudOS.build))")
if let variant = info.variant { print("variant: \(variant)") }
if let device = info.device { print("device: \(device)") }
}
}
}
@@ -85,6 +85,44 @@ struct RestoreInfoTests {
#expect(report.restoreInfo?.cloudOS.build == "23E5207q")
}
@Test func deviceForVariant() {
#expect(VPhoneRestoreInfo.device(forVariant: "exp") == "iPhone17,3")
for v in ["regular", "dev", "jb"] {
#expect(VPhoneRestoreInfo.device(forVariant: v) == "iPhone99,11")
}
}
@Test func recordVariantMergesIntoVersions() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try VPhoneRestoreInfo(
ios: .init(version: "18.6.2", build: "22G100"),
cloudOS: .init(version: "26.1", build: "23B85")).write(toBundle: b)
let merged = try VPhoneRestoreInfo.recordVariant("exp", toBundle: b)
#expect(merged?.variant == "exp")
#expect(merged?.device == "iPhone17,3")
let loaded = VPhoneRestoreInfo.load(fromBundle: b)
#expect(loaded?.ios.build == "22G100")
#expect(loaded?.variant == "exp")
#expect(loaded?.device == "iPhone17,3")
}
@Test func recordVariantNilWithoutVersions() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
#expect(try VPhoneRestoreInfo.recordVariant("jb", toBundle: b) == nil)
}
@Test func bundleReportCarriesUDID() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try "UDID=AAAABBBB-1122334455667788\n"
.write(to: b.url.appendingPathComponent("udid-prediction.txt"), atomically: true, encoding: .utf8)
#expect(VPhoneBundleReport(bundle: b).udid == "AAAABBBB-1122334455667788")
}
/// The snapshot lives at the bundle root, so `vm export` must not strip it:
/// it is matched by neither the `*_Restore*` exclude nor any regenerable-
/// artifact pattern. Guards against a future exclude edit dropping it.
@@ -33,6 +33,22 @@ struct RestoreOpsTests {
#expect(VPhoneRestoreOps.resolveECID(explicit: nil, bundle: b) == nil)
}
@Test func resolveUDIDFromPredictionFile() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let b = try bundle(in: root)
try "UDID=AAAABBBB-1122334455667788\nECID=1122334455667788\n"
.write(to: root.appendingPathComponent("udid-prediction.txt"), atomically: true, encoding: .utf8)
#expect(VPhoneRestoreOps.resolveUDID(bundle: b) == "AAAABBBB-1122334455667788")
}
@Test func resolveUDIDNilWhenMissing() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let b = try bundle(in: root)
#expect(VPhoneRestoreOps.resolveUDID(bundle: b) == nil)
}
@Test func isAEAEncryptedDetectsMagic() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)