setup: Record iOS + cloudOS versions on restore

Snapshot the restored iOS userland and cloudOS kernel versions to
restore-info.json at the bundle root so they are readable without booting
the VM, rewritten after every successful restore (vm create and vm restore).

Versions are read host-side from the bundle's iPhone*_Restore plists
(iPhone-BuildManifest.plist for iOS, the hybrid BuildManifest.plist for
cloudOS). vm list / vm info / --json surface them; the file lives at the
bundle root so vm export carries it even when the IPSW dir is excluded.

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
zqxwce
2026-07-30 13:27:18 +03:00
committed by GitHub
co-authored by Claude Fable 5
parent 25da0fa24a
commit 966bddb62a
6 changed files with 233 additions and 3 deletions
@@ -5,11 +5,13 @@ public struct VPhoneBundleReport: Codable, Equatable, Sendable {
public let cpuCount: Int
public let memoryMB: Int
public let diskSizeBytes: Int64
public let restoreInfo: VPhoneRestoreInfo?
public init(bundle: VPhoneBundle) {
self.name = bundle.name
self.cpuCount = Int(bundle.manifest.cpuCount)
self.memoryMB = Int(bundle.manifest.memorySize / (1024 * 1024))
self.diskSizeBytes = bundle.diskSizeBytes
self.restoreInfo = VPhoneRestoreInfo.load(fromBundle: bundle)
}
}
@@ -0,0 +1,78 @@
import Foundation
/// iOS-userland and cloudOS-kernel versions a bundle was restored with, recorded
/// host-side so they're readable without booting the VM. Persisted as
/// `restore-info.json` at the bundle root and rewritten on every restore.
public struct VPhoneRestoreInfo: Codable, Equatable, Sendable {
public struct OSVersion: Codable, Equatable, Sendable {
public let version: String
public let build: String
public init(version: String, build: String) {
self.version = version
self.build = build
}
}
public let ios: OSVersion
public let cloudOS: OSVersion
public init(ios: OSVersion, cloudOS: OSVersion) {
self.ios = ios
self.cloudOS = cloudOS
}
static let fileName = "restore-info.json"
public static func url(forBundle bundle: VPhoneBundle) -> URL {
bundle.url.appendingPathComponent(fileName)
}
/// The `restore-info.json` snapshot if present, else derived live from the
/// bundle's restore-directory plists so bundles restored before this file
/// existed still report their versions. `nil` when neither is available.
public static func load(fromBundle bundle: VPhoneBundle) -> VPhoneRestoreInfo? {
if let data = try? Data(contentsOf: url(forBundle: bundle)),
let info = try? JSONDecoder().decode(VPhoneRestoreInfo.self, from: data) {
return info
}
return derive(fromBundle: bundle)
}
/// Read both versions from the bundle's `iPhone*_Restore` plists:
/// `iPhone-BuildManifest.plist` (iOS userland) and the hybrid
/// `BuildManifest.plist` (cloudOS kernel). `nil` if the restore directory or
/// either version is missing.
public static func derive(fromBundle bundle: VPhoneBundle) -> VPhoneRestoreInfo? {
guard let restoreDir = findRestoreDirectory(inBundle: bundle),
let ios = readVersion(restoreDir.appendingPathComponent("iPhone-BuildManifest.plist")),
let cloudOS = readVersion(restoreDir.appendingPathComponent("BuildManifest.plist"))
else { return nil }
return VPhoneRestoreInfo(ios: ios, cloudOS: cloudOS)
}
public func write(toBundle bundle: VPhoneBundle) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(self).write(to: Self.url(forBundle: bundle))
}
// MARK: - Restore-directory reads
static func findRestoreDirectory(inBundle bundle: VPhoneBundle) -> URL? {
let entries = (try? FileManager.default.contentsOfDirectory(
at: bundle.url, includingPropertiesForKeys: nil)) ?? []
return entries
.filter { $0.lastPathComponent.hasPrefix("iPhone") && $0.lastPathComponent.hasSuffix("_Restore") }
.max { $0.lastPathComponent < $1.lastPathComponent }
}
private static func readVersion(_ plist: URL) -> OSVersion? {
guard let data = try? Data(contentsOf: plist),
let root = (try? PropertyListSerialization.propertyList(from: data, format: nil)) as? [String: Any],
let version = root["ProductVersion"] as? String,
let build = root["ProductBuildVersion"] as? String
else { return nil }
return OSVersion(version: version, build: build)
}
}
@@ -370,6 +370,8 @@ public struct VPhoneCreateOrchestrator {
python, restoreArgs, cwd: bundleURL, echo: v.showsToolDetail)
guard restoreCode == 0 else { throw VPhoneCreateError.restoreUpdateFailed(restoreCode) }
recordRestoreVersions(bundleURL: bundleURL)
// wait_for_post_restore_reboot: a plain case-insensitive 'panic' grep
// distinct from (narrower than) BOOT_PANIC_REGEX used elsewhere.
print("[*] Restore complete; waiting up to 30s for reboot/panic before stopping DFU...")
@@ -386,6 +388,25 @@ public struct VPhoneCreateOrchestrator {
// `defer` above terminates the DFU process on every exit path.
}
/// Snapshot the just-restored iOS + cloudOS versions to `restore-info.json`,
/// read host-side from the bundle's restore-dir plists. Best-effort: the
/// restore already succeeded, so a metadata miss is a warning, not a failure.
private func recordRestoreVersions(bundleURL: URL) {
guard let bundle = try? VPhoneBundle.load(at: bundleURL),
let info = VPhoneRestoreInfo.derive(fromBundle: bundle)
else {
print("[!] Could not record restore versions (metadata not found)")
return
}
do {
try info.write(toBundle: bundle)
print("[+] Recorded versions: iOS \(info.ios.version) (\(info.ios.build)), "
+ "cloudOS \(info.cloudOS.version) (\(info.cloudOS.build))")
} catch {
print("[!] Could not write restore-info.json: \(error)")
}
}
private func loadDeviceIdentity(bundleURL: URL) throws -> (udid: String, ecid: String) {
let predictionFile = bundleURL.appendingPathComponent("udid-prediction.txt")
let deadline = Date().addingTimeInterval(30)
+25 -2
View File
@@ -45,6 +45,8 @@ struct VPhoneRestoreCommand: ParsableCommand {
if getShsh {
throw ExitCode(try pmd3("restore-get-shsh", extra: []))
}
let code: Int32
if offline {
let fm = FileManager.default
let shshes = ((try? fm.contentsOfDirectory(at: bundle.url, includingPropertiesForKeys: nil)) ?? [])
@@ -57,9 +59,30 @@ struct VPhoneRestoreCommand: ParsableCommand {
guard let restoreDir else { throw VPhoneRestoreError.noRestoreDir }
print("[restore] decrypting AEA images in \(restoreDir.lastPathComponent)...")
try VPhoneRestoreOps.decryptAEAImages(inRestoreDir: restoreDir)
throw ExitCode(try pmd3("restore-update", extra: ["--tss", shsh.path]))
code = try pmd3("restore-update", extra: ["--tss", shsh.path])
} else {
code = try pmd3("restore-update", extra: [])
}
if code == 0 { recordRestoreVersions(bundle: bundle) }
throw ExitCode(code)
}
/// Snapshot the just-restored iOS + cloudOS versions to `restore-info.json`,
/// read host-side from the bundle's restore-dir plists. Best-effort: the
/// restore already succeeded, so a metadata miss is only a warning.
private func recordRestoreVersions(bundle: VPhoneBundle) {
guard let info = VPhoneRestoreInfo.derive(fromBundle: bundle) else {
FileHandle.standardError.write(Data("warning: could not record restore versions (metadata not found)\n".utf8))
return
}
do {
try info.write(toBundle: bundle)
print("[restore] recorded iOS \(info.ios.version) (\(info.ios.build)) / "
+ "cloudOS \(info.cloudOS.version) (\(info.cloudOS.build))")
} catch {
FileHandle.standardError.write(Data("warning: could not write restore-info.json: \(error)\n".utf8))
}
throw ExitCode(try pmd3("restore-update", extra: []))
}
}
+9 -1
View File
@@ -60,7 +60,11 @@ struct VPhoneVMListCommand: ParsableCommand {
print("(no VMs in \(library.root.path))")
} else {
for r in reports {
print("\(r.name) \(r.cpuCount) CPU \(r.memoryMB) MB \(r.diskSizeBytes / (1024*1024*1024)) GB disk")
var line = "\(r.name) \(r.cpuCount) CPU \(r.memoryMB) MB \(r.diskSizeBytes / (1024*1024*1024)) GB disk"
if let info = r.restoreInfo {
line += " iOS \(info.ios.version) / cloudOS \(info.cloudOS.version)"
}
print(line)
}
}
}
@@ -85,6 +89,10 @@ struct VPhoneVMInfoCommand: ParsableCommand {
print("cpu: \(report.cpuCount)")
print("mem: \(report.memoryMB) MB")
print("disk: \(report.diskSizeBytes) bytes")
if let info = report.restoreInfo {
print("iOS: \(info.ios.version) (\(info.ios.build))")
print("cloudOS: \(info.cloudOS.version) (\(info.cloudOS.build))")
}
}
}
}
@@ -0,0 +1,98 @@
@testable import VPhoneCore
import Foundation
import Testing
struct RestoreInfoTests {
private func makeBundle() throws -> VPhoneBundle {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let manifest = VPhoneVirtualMachineManifest(
cpuCount: 2, memorySize: 1024 * 1024, romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
return VPhoneBundle(url: root, manifest: manifest)
}
/// Write a restore dir with the two BuildManifest plists. Omit a key by
/// passing nil for its value to exercise the missing-key path.
private func makeRestoreDir(
in bundle: VPhoneBundle, iosVersion: String?, iosBuild: String?,
cloudVersion: String?, cloudBuild: String?
) throws {
let dir = bundle.url.appendingPathComponent("iPhone17,3_27.0_24A5390f_Restore")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
func write(_ name: String, _ version: String?, _ build: String?) throws {
var dict: [String: Any] = [:]
if let version { dict["ProductVersion"] = version }
if let build { dict["ProductBuildVersion"] = build }
let data = try PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0)
try data.write(to: dir.appendingPathComponent(name))
}
try write("iPhone-BuildManifest.plist", iosVersion, iosBuild)
try write("BuildManifest.plist", cloudVersion, cloudBuild)
}
@Test func derivesBothVersionsFromPlists() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try makeRestoreDir(in: b, iosVersion: "27.0", iosBuild: "24A5390f",
cloudVersion: "26.4", cloudBuild: "23E5207q")
let info = VPhoneRestoreInfo.derive(fromBundle: b)
#expect(info?.ios == .init(version: "27.0", build: "24A5390f"))
#expect(info?.cloudOS == .init(version: "26.4", build: "23E5207q"))
}
@Test func deriveNilWhenNoRestoreDir() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
#expect(VPhoneRestoreInfo.derive(fromBundle: b) == nil)
}
@Test func deriveNilWhenVersionKeyMissing() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try makeRestoreDir(in: b, iosVersion: "27.0", iosBuild: "24A5390f",
cloudVersion: nil, cloudBuild: "23E5207q")
#expect(VPhoneRestoreInfo.derive(fromBundle: b) == nil)
}
@Test func writeThenLoadRoundTrips() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
let info = VPhoneRestoreInfo(
ios: .init(version: "18.6.2", build: "22G100"),
cloudOS: .init(version: "26.1", build: "23B85"))
try info.write(toBundle: b)
#expect(VPhoneRestoreInfo.load(fromBundle: b) == info)
}
@Test func loadFallsBackToDeriveWhenNoJSON() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try makeRestoreDir(in: b, iosVersion: "27.0", iosBuild: "24A5390f",
cloudVersion: "26.4", cloudBuild: "23E5207q")
// No restore-info.json written load() must derive from the plists.
let info = VPhoneRestoreInfo.load(fromBundle: b)
#expect(info?.ios.version == "27.0")
#expect(info?.cloudOS.version == "26.4")
}
@Test func bundleReportCarriesRestoreInfo() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try makeRestoreDir(in: b, iosVersion: "27.0", iosBuild: "24A5390f",
cloudVersion: "26.4", cloudBuild: "23E5207q")
let report = VPhoneBundleReport(bundle: b)
#expect(report.restoreInfo?.ios.build == "24A5390f")
#expect(report.restoreInfo?.cloudOS.build == "23E5207q")
}
/// 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.
@Test func notExcludedFromExport() throws {
let name = VPhoneRestoreInfo.fileName
#expect(fnmatch("*_Restore*", name, 0) != 0)
for pattern in VPhoneBundleOps.exportExcludePatterns {
#expect(fnmatch(pattern, name, 0) != 0)
}
}
}