mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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: []))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user