mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
feat: add VPhoneCore library — VM bundle model + host primitives
The library the consolidated CLI is built on: - bundle/library model (VPhoneBundle, VPhoneLibrary, VPhoneVirtualMachineManifest), bundle ops + reporting, restore helpers (VPhoneRestoreOps) - host process primitives: VPhoneProcessRunner, VPhoneManagedProcess (spawn + stdout pattern-match + SIGKILL-escalating terminate), VPhoneLaunchLayout, VPhoneBootPatterns - VPhoneResources: bundled-.app vs dev asset resolution, and Python resolution that provisions a per-user venv (~/.vphone/venv) on demand so the app is portable — never depends on the repo's .venv - VPhoneVerbosity (quiet/info/debug/trace) and VPhoneVMPicker Manifest moves out of the executable target into VPhoneCore. Full unit-test suite under tests/VPhoneCoreTests. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01Y4VDqWf5pVakcFLqB23CKe
This commit is contained in:
committed by
zqxwce
co-authored by
Claude Opus 4.8
parent
f92d82cd75
commit
828be6dbc6
@@ -22,15 +22,24 @@ let package = Package(
|
|||||||
.product(name: "Capstone", package: "libcapstone-spm"),
|
.product(name: "Capstone", package: "libcapstone-spm"),
|
||||||
.product(name: "Img4tool", package: "libimg4-spm"),
|
.product(name: "Img4tool", package: "libimg4-spm"),
|
||||||
.product(name: "MachOKit", package: "MachOKit"),
|
.product(name: "MachOKit", package: "MachOKit"),
|
||||||
|
"VPhoneCore",
|
||||||
],
|
],
|
||||||
path: "sources/FirmwarePatcher"
|
path: "sources/FirmwarePatcher"
|
||||||
),
|
),
|
||||||
|
.target(
|
||||||
|
name: "VPhoneCore",
|
||||||
|
path: "sources/VPhoneCore",
|
||||||
|
linkerSettings: [
|
||||||
|
.linkedFramework("Virtualization"),
|
||||||
|
]
|
||||||
|
),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "vphone-cli",
|
name: "vphone-cli",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "Dynamic", package: "Dynamic"),
|
.product(name: "Dynamic", package: "Dynamic"),
|
||||||
"FirmwarePatcher",
|
"FirmwarePatcher",
|
||||||
|
"VPhoneCore",
|
||||||
],
|
],
|
||||||
path: "sources/vphone-cli",
|
path: "sources/vphone-cli",
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
@@ -46,5 +55,10 @@ let package = Package(
|
|||||||
dependencies: ["FirmwarePatcher"],
|
dependencies: ["FirmwarePatcher"],
|
||||||
path: "tests/FirmwarePatcherTests"
|
path: "tests/FirmwarePatcherTests"
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "VPhoneCoreTests",
|
||||||
|
dependencies: ["VPhoneCore"],
|
||||||
|
path: "tests/VPhoneCoreTests"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - VPhoneBootPatterns
|
||||||
|
|
||||||
|
/// Boot-log regex patterns and device-identity normalization, ported VERBATIM
|
||||||
|
/// from `scripts/setup_machine.sh` so the native `vm create` orchestrator
|
||||||
|
/// (`VPhoneCreateOrchestrator`, executable target) matches the proven shell
|
||||||
|
/// choreography exactly. Lives in VPhoneCore (rather than alongside the
|
||||||
|
/// orchestrator) purely so `VPhoneCoreTests` can unit-test these pure,
|
||||||
|
/// device-independent pieces — the orchestrator itself needs `FirmwarePatcher`
|
||||||
|
/// and can't live in VPhoneCore without creating a package dependency cycle
|
||||||
|
/// (FirmwarePatcher already depends on VPhoneCore).
|
||||||
|
public enum VPhoneBootPatterns {
|
||||||
|
/// `BOOT_BASH_PROMPT_REGEX` (setup_machine.sh:39) — the iosbinpack bash
|
||||||
|
/// prompt, or a ramdisk/root shell prompt.
|
||||||
|
public static let promptRegex = #"bash-[0-9]+(\.[0-9]+)+#|:/[^ ]* root#"#
|
||||||
|
|
||||||
|
/// `BOOT_PANIC_REGEX` (setup_machine.sh:40).
|
||||||
|
public static let panicRegex = #"(^|[^p])(panic|kernel panic|panic\.apple\.com|stackshot succeeded)"#
|
||||||
|
|
||||||
|
/// `monitor_boot_log_until` (setup_machine.sh:363-388) checks panic and
|
||||||
|
/// prompt with DIFFERENT case sensitivity — `grep -Eiq "$BOOT_PANIC_REGEX"`
|
||||||
|
/// (case-insensitive) vs. `grep -Eq "$BOOT_BASH_PROMPT_REGEX"` (case-
|
||||||
|
/// sensitive). A single `NSRegularExpression` has one global case-folding
|
||||||
|
/// setting, so the panic half is wrapped in an ICU scoped inline modifier
|
||||||
|
/// (`(?i:...)`) to fold ONLY that half, leaving the prompt half exactly as
|
||||||
|
/// case-sensitive as the shell's plain `grep -E`.
|
||||||
|
public static let panicOrPromptRegex = "(?i:\(panicRegex))|\(promptRegex)"
|
||||||
|
|
||||||
|
/// `send_first_boot_commands` (setup_machine.sh:344-361) — verbatim order,
|
||||||
|
/// including the exact PATH string (setup_machine.sh:348).
|
||||||
|
public static let firstBootCommands: [String] = [
|
||||||
|
"export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/bin/X11:/usr/games:/iosbinpack64/usr/local/sbin:/iosbinpack64/usr/local/bin:/iosbinpack64/usr/sbin:/iosbinpack64/usr/bin:/iosbinpack64/sbin:/iosbinpack64/bin'",
|
||||||
|
"cp /iosbinpack64/etc/profile /var/profile",
|
||||||
|
"cp /iosbinpack64/etc/motd /var/motd",
|
||||||
|
"mkdir -p /var/dropbear",
|
||||||
|
"dropbearkey -t rsa -f /var/dropbear/dropbear_rsa_host_key",
|
||||||
|
"dropbearkey -t ecdsa -f /var/dropbear/dropbear_ecdsa_host_key",
|
||||||
|
"shutdown -h now",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Port of `normalize_ecid` (setup_machine.sh:80-86): strip one leading
|
||||||
|
/// `0x` and one leading `0X` (matching the shell's two sequential `#0x`/
|
||||||
|
/// `#0X` strips), require 1-16 ASCII hex digits, uppercase, and left-pad
|
||||||
|
/// with zeros to 16 characters. `nil` for anything that isn't valid hex —
|
||||||
|
/// the shell's `return 1` from the `[[ =~ ]]` guard.
|
||||||
|
public static func normalizeECID(_ raw: String) -> String? {
|
||||||
|
var value = raw
|
||||||
|
if value.hasPrefix("0x") { value.removeFirst(2) }
|
||||||
|
if value.hasPrefix("0X") { value.removeFirst(2) }
|
||||||
|
guard !value.isEmpty, value.count <= 16, value.allSatisfy(isASCIIHexDigit) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return String(repeating: "0", count: 16 - value.count) + value.uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASCII-only hex digit check (`[0-9A-Fa-f]`) — deliberately narrower than
|
||||||
|
/// `Character.isHexDigit`, which also accepts Unicode fullwidth digits
|
||||||
|
/// that the shell's `[[ =~ ^[0-9A-Fa-f]{1,16}$ ]]` would reject.
|
||||||
|
private static func isASCIIHexDigit(_ c: Character) -> Bool {
|
||||||
|
guard let ascii = c.asciiValue else { return false }
|
||||||
|
return (0x30...0x39).contains(ascii) || (0x41...0x46).contains(ascii) || (0x61...0x66).contains(ascii)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure parse of `sysctl -n kern.hv_vmm_present` output — `"1"` means the
|
||||||
|
/// host is itself an Apple VM (nested), where Virtualization.framework
|
||||||
|
/// PV=3 guest boot is unavailable. Mirrors the `boot_host_preflight.sh`
|
||||||
|
/// gate `make boot` applied before native `vm create` replaced it.
|
||||||
|
public static func parseHVVmmPresent(_ output: String) -> Bool {
|
||||||
|
output.trimmingCharacters(in: .whitespacesAndNewlines) == "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Bundle
|
||||||
|
|
||||||
|
public struct VPhoneBundle: Sendable {
|
||||||
|
public let url: URL
|
||||||
|
public let manifest: VPhoneVirtualMachineManifest
|
||||||
|
|
||||||
|
public init(url: URL, manifest: VPhoneVirtualMachineManifest) {
|
||||||
|
self.url = url
|
||||||
|
self.manifest = manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
public var name: String { url.lastPathComponent }
|
||||||
|
public var configURL: URL { url.appendingPathComponent("config.plist") }
|
||||||
|
|
||||||
|
public var diskSizeBytes: Int64 {
|
||||||
|
let disk = url.appendingPathComponent(manifest.diskImage)
|
||||||
|
let attrs = try? FileManager.default.attributesOfItem(atPath: disk.path)
|
||||||
|
return (attrs?[.size] as? NSNumber)?.int64Value ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func load(at url: URL) throws -> VPhoneBundle {
|
||||||
|
let manifest = try VPhoneVirtualMachineManifest.load(
|
||||||
|
from: url.appendingPathComponent("config.plist"))
|
||||||
|
return VPhoneBundle(url: url, manifest: manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum VPhoneBundleOpsError: Error, Equatable {
|
||||||
|
case tarFailed(String)
|
||||||
|
case badArchive(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VPhoneBundleOps {
|
||||||
|
public struct NewBundleSpec: Sendable {
|
||||||
|
public var name: String
|
||||||
|
public var cpuCount: UInt
|
||||||
|
public var memoryMB: UInt64
|
||||||
|
public var diskSizeGB: UInt64
|
||||||
|
public var romSource: URL
|
||||||
|
public var sepromSource: URL
|
||||||
|
|
||||||
|
public init(name: String, cpuCount: UInt, memoryMB: UInt64, diskSizeGB: UInt64,
|
||||||
|
romSource: URL, sepromSource: URL) {
|
||||||
|
self.name = name; self.cpuCount = cpuCount; self.memoryMB = memoryMB
|
||||||
|
self.diskSizeGB = diskSizeGB; self.romSource = romSource; self.sepromSource = sepromSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let frameworkResources = URL(fileURLWithPath:
|
||||||
|
"/System/Library/Frameworks/Virtualization.framework/Versions/A/Resources")
|
||||||
|
|
||||||
|
public static func defaultROMSource() -> URL {
|
||||||
|
frameworkResources.appendingPathComponent("AVPBooter.vresearch1.bin")
|
||||||
|
}
|
||||||
|
public static func defaultSEPROMSource() -> URL {
|
||||||
|
frameworkResources.appendingPathComponent("AVPSEPBooter.vresearch1.bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requireValidName(_ name: String) throws {
|
||||||
|
guard !name.isEmpty, !name.contains("/"), !name.hasPrefix(".") else {
|
||||||
|
throw VPhoneLibraryError.invalidName(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func create(_ spec: NewBundleSpec, in library: VPhoneLibrary) throws -> VPhoneBundle {
|
||||||
|
try requireValidName(spec.name)
|
||||||
|
let fm = FileManager.default
|
||||||
|
let dir = library.url(forName: spec.name)
|
||||||
|
if fm.fileExists(atPath: dir.path) {
|
||||||
|
throw VPhoneLibraryError.alreadyExists(name: spec.name)
|
||||||
|
}
|
||||||
|
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
// Roll back the partial bundle on any failure after the dir is created,
|
||||||
|
// so a retry with the same name isn't permanently blocked by the
|
||||||
|
// alreadyExists check.
|
||||||
|
do {
|
||||||
|
// Sparse disk image: create then truncate to size (no bytes written).
|
||||||
|
let disk = dir.appendingPathComponent("Disk.img")
|
||||||
|
fm.createFile(atPath: disk.path, contents: nil)
|
||||||
|
let handle = try FileHandle(forWritingTo: disk)
|
||||||
|
do {
|
||||||
|
try handle.truncate(atOffset: spec.diskSizeGB * 1024 * 1024 * 1024)
|
||||||
|
try handle.close()
|
||||||
|
} catch {
|
||||||
|
try? handle.close()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEP storage: 512 KB of zeros (real bytes, matches vm_create.sh).
|
||||||
|
try Data(count: 512 * 1024).write(to: dir.appendingPathComponent("SEPStorage"))
|
||||||
|
|
||||||
|
// ROMs.
|
||||||
|
try fm.copyItem(at: spec.romSource, to: dir.appendingPathComponent("AVPBooter.vresearch1.bin"))
|
||||||
|
try fm.copyItem(at: spec.sepromSource, to: dir.appendingPathComponent("AVPSEPBooter.vresearch1.bin"))
|
||||||
|
|
||||||
|
// Manifest.
|
||||||
|
let manifest = VPhoneVirtualMachineManifest(
|
||||||
|
cpuCount: spec.cpuCount,
|
||||||
|
memorySize: spec.memoryMB * 1024 * 1024,
|
||||||
|
romImages: .init(avpBooter: "AVPBooter.vresearch1.bin",
|
||||||
|
avpSEPBooter: "AVPSEPBooter.vresearch1.bin"))
|
||||||
|
try manifest.write(to: dir.appendingPathComponent("config.plist"))
|
||||||
|
|
||||||
|
return VPhoneBundle(url: dir, manifest: manifest)
|
||||||
|
} catch {
|
||||||
|
try? fm.removeItem(at: dir)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Config editing
|
||||||
|
|
||||||
|
public static func updateConfig(
|
||||||
|
bundleNamed name: String, in library: VPhoneLibrary,
|
||||||
|
cpuCount: UInt?, memoryMB: UInt64?
|
||||||
|
) throws -> VPhoneBundle {
|
||||||
|
let bundle = try library.bundle(named: name)
|
||||||
|
let updated = bundle.manifest.updating(
|
||||||
|
cpuCount: cpuCount,
|
||||||
|
memorySize: memoryMB.map { $0 * 1024 * 1024 },
|
||||||
|
screenConfig: nil)
|
||||||
|
try updated.write(to: bundle.configURL)
|
||||||
|
return VPhoneBundle(url: bundle.url, manifest: updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rename / delete
|
||||||
|
|
||||||
|
public static func rename(
|
||||||
|
bundleNamed name: String, to newName: String, in library: VPhoneLibrary
|
||||||
|
) throws -> VPhoneBundle {
|
||||||
|
try requireValidName(newName)
|
||||||
|
let src = try library.bundle(named: name).url
|
||||||
|
let dst = library.url(forName: newName)
|
||||||
|
if FileManager.default.fileExists(atPath: dst.path) {
|
||||||
|
throw VPhoneLibraryError.alreadyExists(name: newName)
|
||||||
|
}
|
||||||
|
try FileManager.default.moveItem(at: src, to: dst)
|
||||||
|
return try VPhoneBundle.load(at: dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func delete(bundleNamed name: String, in library: VPhoneLibrary) throws {
|
||||||
|
let url = try library.bundle(named: name).url
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Clone
|
||||||
|
|
||||||
|
/// Clone a bundle with a fast APFS copy-on-write clone (fallback: recursive
|
||||||
|
/// copy), then reset the boot-identity artifacts so the clone comes up as a
|
||||||
|
/// fresh device on next boot. NOTE: SEPStorage is copied as-is — cloning an
|
||||||
|
/// already-restored VM may need a re-restore for a fully clean identity.
|
||||||
|
public static func clone(
|
||||||
|
bundleNamed name: String, to newName: String, in library: VPhoneLibrary
|
||||||
|
) throws -> VPhoneBundle {
|
||||||
|
try requireValidName(newName)
|
||||||
|
let src = try library.bundle(named: name).url
|
||||||
|
let dst = library.url(forName: newName)
|
||||||
|
let fm = FileManager.default
|
||||||
|
if fm.fileExists(atPath: dst.path) { throw VPhoneLibraryError.alreadyExists(name: newName) }
|
||||||
|
|
||||||
|
// APFS CoW clone; fall back to a plain recursive copy off-APFS.
|
||||||
|
if clonefile(src.path, dst.path, 0) != 0 {
|
||||||
|
try? fm.removeItem(at: dst) // clear any partial clonefile output first
|
||||||
|
try fm.copyItem(at: src, to: dst)
|
||||||
|
}
|
||||||
|
try resetIdentity(inBundleAt: dst)
|
||||||
|
return try VPhoneBundle.load(at: dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resetIdentity(inBundleAt dir: URL) throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
for name in ["nvram.bin", "udid-prediction.txt"] {
|
||||||
|
let u = dir.appendingPathComponent(name)
|
||||||
|
if fm.fileExists(atPath: u.path) { try fm.removeItem(at: u) }
|
||||||
|
}
|
||||||
|
let entries = try fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)
|
||||||
|
for u in entries where u.pathExtension == "shsh" { try fm.removeItem(at: u) }
|
||||||
|
let configURL = dir.appendingPathComponent("config.plist")
|
||||||
|
let manifest = try VPhoneVirtualMachineManifest.load(from: configURL)
|
||||||
|
try manifest.updating(machineIdentifier: Data()).write(to: configURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Export / Import
|
||||||
|
|
||||||
|
/// Regenerable staging artifacts that never need to travel in an export:
|
||||||
|
/// `.vphoned.signed` is re-staged on the next launch, and the CFW install
|
||||||
|
/// inputs/temp are consumed at install time (the result already lives in
|
||||||
|
/// `Disk.img`). Always excluded.
|
||||||
|
static let exportExcludePatterns = ["*.vphoned.signed", "*cfw_input*", "*cfw_jb_input*", "*.cfw_temp*"]
|
||||||
|
|
||||||
|
public static func export(
|
||||||
|
bundleNamed name: String, to outFile: URL, includeIPSW: Bool, in library: VPhoneLibrary
|
||||||
|
) throws {
|
||||||
|
_ = try library.bundle(named: name) // validate it exists
|
||||||
|
// xz at max level, multithreaded (threads=0 → all cores) — the densest
|
||||||
|
// compressor libarchive offers, for a multi-GB Disk.img.
|
||||||
|
var args = ["-cf", outFile.path, "-J", "--options", "xz:compression-level=9,xz:threads=0"]
|
||||||
|
if !includeIPSW { args += ["--exclude", "*_Restore*"] }
|
||||||
|
for pattern in exportExcludePatterns { args += ["--exclude", pattern] }
|
||||||
|
args += ["-C", library.root.path, name]
|
||||||
|
let r = try VPhoneProcessRunner.runCapturing(URL(fileURLWithPath: "/usr/bin/tar"), args)
|
||||||
|
guard r.succeeded else { throw VPhoneBundleOpsError.tarFailed(r.stderr) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func importArchive(
|
||||||
|
from inFile: URL, name: String?, in library: VPhoneLibrary
|
||||||
|
) throws -> VPhoneBundle {
|
||||||
|
let fm = FileManager.default
|
||||||
|
// Auto-detect the compression (-tf, not -tzf) so both legacy gzip and
|
||||||
|
// current xz archives import.
|
||||||
|
let listing = try VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/usr/bin/tar"), ["-tf", inFile.path])
|
||||||
|
guard listing.succeeded else { throw VPhoneBundleOpsError.tarFailed(listing.stderr) }
|
||||||
|
let topDirs = Set(listing.stdout.split(whereSeparator: \.isNewline).compactMap {
|
||||||
|
$0.split(separator: "/").first.map(String.init)
|
||||||
|
})
|
||||||
|
guard topDirs.count == 1, let archived = topDirs.first else {
|
||||||
|
throw VPhoneBundleOpsError.badArchive(
|
||||||
|
"expected a single top-level bundle directory, found \(topDirs.sorted())")
|
||||||
|
}
|
||||||
|
let finalName = name ?? archived
|
||||||
|
try requireValidName(finalName)
|
||||||
|
let dst = library.url(forName: finalName)
|
||||||
|
if fm.fileExists(atPath: dst.path) { throw VPhoneLibraryError.alreadyExists(name: finalName) }
|
||||||
|
|
||||||
|
// Extract into a private staging dir so the archive's OWN top-level name
|
||||||
|
// can never clobber/merge into an existing bundle of that name; only the
|
||||||
|
// validated destination name is ever placed into the library.
|
||||||
|
try fm.createDirectory(at: library.root, withIntermediateDirectories: true)
|
||||||
|
let staging = library.root.appendingPathComponent(".import-\(UUID().uuidString)")
|
||||||
|
try fm.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||||
|
defer { try? fm.removeItem(at: staging) }
|
||||||
|
|
||||||
|
let extract = try VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/usr/bin/tar"), ["-xf", inFile.path, "-C", staging.path])
|
||||||
|
guard extract.succeeded else { throw VPhoneBundleOpsError.tarFailed(extract.stderr) }
|
||||||
|
let extracted = staging.appendingPathComponent(archived)
|
||||||
|
guard fm.fileExists(atPath: extracted.appendingPathComponent("config.plist").path) else {
|
||||||
|
throw VPhoneBundleOpsError.badArchive(
|
||||||
|
"archive did not contain a valid bundle (\(archived)/config.plist)")
|
||||||
|
}
|
||||||
|
try fm.moveItem(at: extracted, to: dst)
|
||||||
|
return try VPhoneBundle.load(at: dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct VPhoneBundleReport: Codable, Equatable, Sendable {
|
||||||
|
public let name: String
|
||||||
|
public let cpuCount: Int
|
||||||
|
public let memoryMB: Int
|
||||||
|
public let diskSizeBytes: Int64
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - VPhoneLaunchLayout
|
||||||
|
|
||||||
|
public struct VPhoneLaunchLayout: Sendable {
|
||||||
|
public let resources: VPhoneResources
|
||||||
|
|
||||||
|
public init(resources: VPhoneResources) { self.resources = resources }
|
||||||
|
public init(projectRoot: URL) { self.init(resources: VPhoneResources(base: projectRoot)) }
|
||||||
|
|
||||||
|
public var preflightScript: URL { resources.preflightScript }
|
||||||
|
public var fwPrepareScript: URL { resources.fwPrepareScript }
|
||||||
|
public var cfwInstallHostScript: URL { resources.cfwInstallHostScript }
|
||||||
|
public var pmd3Bridge: URL { resources.pmd3Bridge }
|
||||||
|
public var vphoned: URL { resources.vphoned }
|
||||||
|
|
||||||
|
public func python() throws -> URL { try resources.pythonExecutable() }
|
||||||
|
|
||||||
|
/// Copy the built vphoned into the bundle if present and different.
|
||||||
|
@discardableResult
|
||||||
|
public func stageVphoned(into bundle: VPhoneBundle) throws -> Bool {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: vphoned.path) else { return false }
|
||||||
|
let dst = bundle.url.appendingPathComponent(".vphoned.signed")
|
||||||
|
if fm.fileExists(atPath: dst.path),
|
||||||
|
let a = try? Data(contentsOf: vphoned),
|
||||||
|
let b = try? Data(contentsOf: dst), a == b {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if fm.fileExists(atPath: dst.path) { try fm.removeItem(at: dst) }
|
||||||
|
try fm.copyItem(at: vphoned, to: dst)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VPhoneLsof
|
||||||
|
|
||||||
|
public enum VPhoneLsof {
|
||||||
|
public static func parsePIDs(_ output: String) -> [Int32] {
|
||||||
|
var seen = Set<Int32>()
|
||||||
|
var pids: [Int32] = []
|
||||||
|
for line in output.split(whereSeparator: \.isNewline) {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let pid = Int32(trimmed), !seen.contains(pid) else { continue }
|
||||||
|
seen.insert(pid)
|
||||||
|
pids.append(pid)
|
||||||
|
}
|
||||||
|
return pids.sorted()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum VPhoneLibraryError: Error, Equatable {
|
||||||
|
case notFound(name: String)
|
||||||
|
case alreadyExists(name: String)
|
||||||
|
case invalidName(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VPhoneLibraryError: CustomStringConvertible, LocalizedError {
|
||||||
|
public var description: String {
|
||||||
|
switch self {
|
||||||
|
case let .notFound(name): "VM '\(name)' not found"
|
||||||
|
case let .alreadyExists(name): "VM '\(name)' already exists"
|
||||||
|
case let .invalidName(name): "Invalid VM name '\(name)' (must be non-empty, contain no '/', and not start with '.')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public var errorDescription: String? { description }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct VPhoneLibrarySkip: Sendable {
|
||||||
|
public let name: String
|
||||||
|
public let reason: String
|
||||||
|
|
||||||
|
public init(name: String, reason: String) {
|
||||||
|
self.name = name
|
||||||
|
self.reason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Library
|
||||||
|
|
||||||
|
public struct VPhoneLibrary: Sendable {
|
||||||
|
public let root: URL
|
||||||
|
|
||||||
|
public init(root: URL) { self.root = root }
|
||||||
|
|
||||||
|
public static func defaultRoot() -> URL {
|
||||||
|
if let override = ProcessInfo.processInfo.environment["VPHONE_LIBRARY_ROOT"] {
|
||||||
|
return URL(fileURLWithPath: override, isDirectory: true)
|
||||||
|
}
|
||||||
|
// `~/.vphone/VMs` — deliberately space-free: bundle paths flow into the
|
||||||
|
// shell/make firmware pipeline, and "Application Support" (a space) breaks
|
||||||
|
// any unquoted expansion there. Keep the default path shell-safe.
|
||||||
|
return FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".vphone/VMs", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func url(forName name: String) -> URL {
|
||||||
|
root.appendingPathComponent(name, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scan() throws -> (bundles: [VPhoneBundle], skipped: [VPhoneLibrarySkip]) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: root.path) else { return ([], []) }
|
||||||
|
let entries = try fm.contentsOfDirectory(
|
||||||
|
at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
||||||
|
var bundles: [VPhoneBundle] = []
|
||||||
|
var skipped: [VPhoneLibrarySkip] = []
|
||||||
|
for url in entries
|
||||||
|
where fm.fileExists(atPath: url.appendingPathComponent("config.plist").path) {
|
||||||
|
do {
|
||||||
|
bundles.append(try VPhoneBundle.load(at: url))
|
||||||
|
} catch {
|
||||||
|
skipped.append(VPhoneLibrarySkip(name: url.lastPathComponent, reason: "\(error)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (bundles.sorted { $0.name < $1.name }, skipped.sorted { $0.name < $1.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
public func bundles() throws -> [VPhoneBundle] {
|
||||||
|
try scan().bundles
|
||||||
|
}
|
||||||
|
|
||||||
|
public func bundle(named name: String) throws -> VPhoneBundle {
|
||||||
|
let url = url(forName: name)
|
||||||
|
guard FileManager.default.fileExists(
|
||||||
|
atPath: url.appendingPathComponent("config.plist").path) else {
|
||||||
|
throw VPhoneLibraryError.notFound(name: name)
|
||||||
|
}
|
||||||
|
return try VPhoneBundle.load(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - VPhoneMatchResult
|
||||||
|
|
||||||
|
public enum VPhoneMatchResult: Equatable, Sendable {
|
||||||
|
case matched
|
||||||
|
case exited(Int32)
|
||||||
|
case timedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VPhoneManagedProcess
|
||||||
|
|
||||||
|
/// A long-lived child process with stdin driving and stdout/stderr
|
||||||
|
/// regex-waiting — the primitive for DFU background boot, first-boot
|
||||||
|
/// command injection, and boot log analysis.
|
||||||
|
public final class VPhoneManagedProcess: @unchecked Sendable {
|
||||||
|
/// Thread-safe accumulator for the combined stdout+stderr bytes, plus an
|
||||||
|
/// EOF flag so `waitForOutput` can tell "no more output is coming" apart
|
||||||
|
/// from "the handler just hasn't run yet". `@unchecked Sendable` because
|
||||||
|
/// access is serialized by its lock, matching `VPhoneProcessRunner.DataBox`.
|
||||||
|
///
|
||||||
|
/// Raw `Data` is accumulated (not decoded per-chunk) because a multi-byte
|
||||||
|
/// UTF-8 character can straddle two `readabilityHandler` reads — decoding
|
||||||
|
/// each chunk independently would turn the split character into U+FFFD.
|
||||||
|
/// Decoding the whole buffer at match time is always correct.
|
||||||
|
private final class OutputBox: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var data = Data()
|
||||||
|
private var eof = false
|
||||||
|
|
||||||
|
func append(_ chunk: Data) {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
data.append(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotText() -> String {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return String(decoding: data, as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markEOF() {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
eof = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasReachedEOF() -> Bool {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return eof
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let process = Process()
|
||||||
|
private let stdinPipe = Pipe()
|
||||||
|
private let outPipe = Pipe()
|
||||||
|
private let box = OutputBox()
|
||||||
|
private let echo: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ executable: URL,
|
||||||
|
_ args: [String],
|
||||||
|
cwd: URL? = nil,
|
||||||
|
env: [String: String]? = nil,
|
||||||
|
echo: Bool = true
|
||||||
|
) {
|
||||||
|
self.echo = echo
|
||||||
|
process.executableURL = executable
|
||||||
|
process.arguments = args
|
||||||
|
if let cwd { process.currentDirectoryURL = cwd }
|
||||||
|
if let env { process.environment = env }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the child with a piped stdin and a combined piped stdout/stderr.
|
||||||
|
/// The read side is drained by a readability handler that appends decoded
|
||||||
|
/// chunks to `box` and (when `echo`) forwards them live to the terminal.
|
||||||
|
public func start() throws {
|
||||||
|
process.standardInput = stdinPipe
|
||||||
|
process.standardOutput = outPipe
|
||||||
|
process.standardError = outPipe
|
||||||
|
|
||||||
|
let box = self.box
|
||||||
|
let echo = self.echo
|
||||||
|
outPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||||
|
let chunk = handle.availableData
|
||||||
|
if chunk.isEmpty {
|
||||||
|
handle.readabilityHandler = nil
|
||||||
|
box.markEOF()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
box.append(chunk)
|
||||||
|
if echo {
|
||||||
|
FileHandle.standardOutput.write(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try process.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll (~50 ms) until a captured line matches `regex`, the process exits
|
||||||
|
/// first, or `timeout` elapses. Matches against the FULL accumulated
|
||||||
|
/// buffer each poll (not just newly-arrived text) so a line that lands
|
||||||
|
/// between polls is never missed. On exit, waits briefly for the
|
||||||
|
/// readability handler to deliver the final EOF chunk and re-checks the
|
||||||
|
/// buffer once more before reporting `.exited` — a real match that
|
||||||
|
/// arrives in the same instant as process exit still wins.
|
||||||
|
public func waitForOutput(matching regex: String, timeout: TimeInterval) -> VPhoneMatchResult {
|
||||||
|
let re = try? NSRegularExpression(pattern: regex)
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
|
||||||
|
while true {
|
||||||
|
if matches(re, box.snapshotText()) { return .matched }
|
||||||
|
|
||||||
|
if !process.isRunning {
|
||||||
|
while !box.hasReachedEOF() && Date() < deadline {
|
||||||
|
Thread.sleep(forTimeInterval: 0.02)
|
||||||
|
}
|
||||||
|
if matches(re, box.snapshotText()) { return .matched }
|
||||||
|
return .exited(process.terminationStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Date() >= deadline { return .timedOut }
|
||||||
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matches(_ re: NSRegularExpression?, _ text: String) -> Bool {
|
||||||
|
guard let re else { return false }
|
||||||
|
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||||
|
return re.firstMatch(in: text, options: [], range: range) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `line + "\n"` to the child's stdin.
|
||||||
|
public func send(_ line: String) {
|
||||||
|
guard let data = (line + "\n").data(using: .utf8) else { return }
|
||||||
|
try? stdinPipe.fileHandleForWriting.write(contentsOf: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func waitUntilExit() -> Int32 {
|
||||||
|
process.waitUntilExit()
|
||||||
|
return process.terminationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `interrupt()` (SIGINT), then escalate to an unmaskable `SIGKILL` if
|
||||||
|
/// the process is still running after a ~2 s grace period. `SIGKILL` (not
|
||||||
|
/// `Process.terminate()`'s `SIGTERM`) is required here: a child that
|
||||||
|
/// traps or ignores `SIGTERM` would otherwise survive and hang
|
||||||
|
/// `terminate()`/`waitUntilExit()` forever — exactly the kind of
|
||||||
|
/// DFU/boot child this class manages.
|
||||||
|
public func terminate() {
|
||||||
|
guard process.isRunning else { return }
|
||||||
|
process.interrupt()
|
||||||
|
|
||||||
|
let deadline = Date().addingTimeInterval(2)
|
||||||
|
while process.isRunning && Date() < deadline {
|
||||||
|
Thread.sleep(forTimeInterval: 0.05)
|
||||||
|
}
|
||||||
|
if process.isRunning {
|
||||||
|
kill(process.processIdentifier, SIGKILL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - VPhoneProcessResult
|
||||||
|
|
||||||
|
public struct VPhoneProcessResult: Sendable {
|
||||||
|
public let exitCode: Int32
|
||||||
|
public let stdout: String
|
||||||
|
public let stderr: String
|
||||||
|
|
||||||
|
public init(exitCode: Int32, stdout: String, stderr: String) {
|
||||||
|
self.exitCode = exitCode
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
public var succeeded: Bool { exitCode == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VPhoneProcessRunner
|
||||||
|
|
||||||
|
public enum VPhoneProcessRunner {
|
||||||
|
/// Thread-safe accumulator for a pipe's bytes. `@unchecked Sendable`
|
||||||
|
/// because access is serialized by its lock, satisfying the readability
|
||||||
|
/// handler's `@Sendable` requirement under Swift 6 strict concurrency.
|
||||||
|
private final class DataBox: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var data = Data()
|
||||||
|
func append(_ chunk: Data) { lock.lock(); data.append(chunk); lock.unlock() }
|
||||||
|
func take() -> Data { lock.lock(); defer { lock.unlock() }; return data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `executable args` to completion, capturing stdout/stderr.
|
||||||
|
/// Throws only if the process cannot be launched; a nonzero exit is
|
||||||
|
/// returned in the result, not thrown.
|
||||||
|
///
|
||||||
|
/// stdout and stderr are drained CONCURRENTLY via readability handlers —
|
||||||
|
/// a sequential "read stdout fully, then stderr" drain deadlocks when a
|
||||||
|
/// child fills one pipe's ~64 KB buffer while still writing the other.
|
||||||
|
public static func runCapturing(
|
||||||
|
_ executable: URL,
|
||||||
|
_ args: [String],
|
||||||
|
cwd: URL? = nil,
|
||||||
|
env: [String: String]? = nil
|
||||||
|
) throws -> VPhoneProcessResult {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = executable
|
||||||
|
process.arguments = args
|
||||||
|
if let cwd { process.currentDirectoryURL = cwd }
|
||||||
|
if let env { process.environment = env }
|
||||||
|
|
||||||
|
let outPipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
process.standardOutput = outPipe
|
||||||
|
process.standardError = errPipe
|
||||||
|
|
||||||
|
let outBox = DataBox()
|
||||||
|
let errBox = DataBox()
|
||||||
|
let group = DispatchGroup()
|
||||||
|
group.enter()
|
||||||
|
group.enter()
|
||||||
|
outPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||||
|
let chunk = handle.availableData
|
||||||
|
if chunk.isEmpty { handle.readabilityHandler = nil; group.leave() }
|
||||||
|
else { outBox.append(chunk) }
|
||||||
|
}
|
||||||
|
errPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||||
|
let chunk = handle.availableData
|
||||||
|
if chunk.isEmpty { handle.readabilityHandler = nil; group.leave() }
|
||||||
|
else { errBox.append(chunk) }
|
||||||
|
}
|
||||||
|
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
group.wait()
|
||||||
|
|
||||||
|
return VPhoneProcessResult(
|
||||||
|
exitCode: process.terminationStatus,
|
||||||
|
stdout: String(decoding: outBox.take(), as: UTF8.self),
|
||||||
|
stderr: String(decoding: errBox.take(), as: UTF8.self))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `executable args`, inheriting the parent's stdout/stderr so output
|
||||||
|
/// streams live to the terminal (for long-running tools: downloads, restore,
|
||||||
|
/// CFW install). Returns the child's exit status; throws only on spawn failure.
|
||||||
|
/// When `echo` is false, the child's stdout/stderr are redirected to the null
|
||||||
|
/// device so nothing reaches the terminal; the exit status is still returned.
|
||||||
|
public static func runStreaming(
|
||||||
|
_ executable: URL,
|
||||||
|
_ args: [String],
|
||||||
|
cwd: URL? = nil,
|
||||||
|
env: [String: String]? = nil,
|
||||||
|
echo: Bool = true
|
||||||
|
) throws -> Int32 {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = executable
|
||||||
|
process.arguments = args
|
||||||
|
if let cwd { process.currentDirectoryURL = cwd }
|
||||||
|
if let env { process.environment = env }
|
||||||
|
|
||||||
|
if !echo {
|
||||||
|
let devNull = FileHandle.nullDevice
|
||||||
|
process.standardOutput = devNull
|
||||||
|
process.standardError = devNull
|
||||||
|
}
|
||||||
|
// When echo is true: no pipe redirection → child inherits our stdio (live streaming).
|
||||||
|
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
return process.terminationStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - VPhoneResourcesError
|
||||||
|
|
||||||
|
public enum VPhoneResourcesError: Error, Equatable {
|
||||||
|
case pythonNotFound(String)
|
||||||
|
case venvBootstrapFailed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - VPhoneResources
|
||||||
|
|
||||||
|
public struct VPhoneResources: Sendable {
|
||||||
|
public let base: URL
|
||||||
|
|
||||||
|
public init(base: URL) { self.base = base }
|
||||||
|
|
||||||
|
// MARK: - Resolution
|
||||||
|
|
||||||
|
public static func resolve(executablePath: String = CommandLine.arguments[0]) -> VPhoneResources {
|
||||||
|
let exe = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath()
|
||||||
|
let macos = exe.deletingLastPathComponent() // …/Contents/MacOS
|
||||||
|
if macos.lastPathComponent == "MacOS",
|
||||||
|
macos.deletingLastPathComponent().lastPathComponent == "Contents" {
|
||||||
|
return VPhoneResources(base: macos.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Resources")) // …/Contents/Resources
|
||||||
|
}
|
||||||
|
var dir = macos
|
||||||
|
for _ in 0..<6 {
|
||||||
|
if FileManager.default.fileExists(atPath: dir.appendingPathComponent("scripts").path) {
|
||||||
|
return VPhoneResources(base: dir)
|
||||||
|
}
|
||||||
|
dir = dir.deletingLastPathComponent()
|
||||||
|
}
|
||||||
|
return VPhoneResources(base: URL(fileURLWithPath: FileManager.default.currentDirectoryPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Assets
|
||||||
|
|
||||||
|
public var scriptsDir: URL { base.appendingPathComponent("scripts") }
|
||||||
|
public var patchersDir: URL { scriptsDir.appendingPathComponent("patchers") }
|
||||||
|
public var resourceArchivesDir: URL { scriptsDir.appendingPathComponent("resources") }
|
||||||
|
public var fwPrepareScript: URL { scriptsDir.appendingPathComponent("fw_prepare.sh") }
|
||||||
|
public var cfwInstallHostScript: URL { scriptsDir.appendingPathComponent("cfw_install_host.sh") }
|
||||||
|
public var preflightScript: URL { scriptsDir.appendingPathComponent("boot_host_preflight.sh") }
|
||||||
|
public var pmd3Bridge: URL { scriptsDir.appendingPathComponent("pymobiledevice3_bridge.py") }
|
||||||
|
public var cfwPy: URL { patchersDir.appendingPathComponent("cfw.py") }
|
||||||
|
public var apfsSnapRename: URL { base.appendingPathComponent("tools/apfs_snap_rename.py") }
|
||||||
|
public var signcert: URL { scriptsDir.appendingPathComponent("vphoned/signcert.p12") }
|
||||||
|
|
||||||
|
public var vphoned: URL {
|
||||||
|
let bundled = base.appendingPathComponent("vphoned.signed")
|
||||||
|
if FileManager.default.fileExists(atPath: bundled.path) { return bundled }
|
||||||
|
// Dev fallback: build.sh stages the signed daemon under .build (a
|
||||||
|
// gitignored build-output dir) rather than cluttering the repo root.
|
||||||
|
return base.appendingPathComponent(".build/vphoned.signed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache dirs
|
||||||
|
|
||||||
|
public var userCacheDir: URL {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".vphone")
|
||||||
|
}
|
||||||
|
public var ipswCacheDir: URL { userCacheDir.appendingPathComponent("ipsws") }
|
||||||
|
public var sealVolumeCacheDir: URL { userCacheDir.appendingPathComponent("tools") }
|
||||||
|
public var toolsBinDir: URL { base.appendingPathComponent(".tools/bin") }
|
||||||
|
|
||||||
|
// MARK: - Python
|
||||||
|
|
||||||
|
/// Runtime pip deps, mirrored from requirements.txt (fallback when the
|
||||||
|
/// bundled requirements.txt is somehow absent).
|
||||||
|
static let fallbackRequirements =
|
||||||
|
["typer", "capstone", "keystone-engine", "pyimg4", "pymobiledevice3>=9.5.0", "ipsw-parser"]
|
||||||
|
|
||||||
|
/// Bundled/dev requirements list the managed venv is provisioned from.
|
||||||
|
public var requirementsFile: URL { base.appendingPathComponent("requirements.txt") }
|
||||||
|
|
||||||
|
/// Per-user managed venv, created on demand. Deliberately OUTSIDE both the
|
||||||
|
/// repo and the .app so the app is portable — a venv is never moved between
|
||||||
|
/// machines (its links would break); it is built fresh on each host.
|
||||||
|
public var managedVenvDir: URL {
|
||||||
|
if let dir = ProcessInfo.processInfo.environment["VPHONE_VENV_DIR"], !dir.isEmpty {
|
||||||
|
return URL(fileURLWithPath: dir)
|
||||||
|
}
|
||||||
|
return userCacheDir.appendingPathComponent("venv")
|
||||||
|
}
|
||||||
|
private var managedVenvPython: URL { managedVenvDir.appendingPathComponent("bin/python3") }
|
||||||
|
|
||||||
|
/// A python is usable only if it carries an `ipsw_parser` new enough for the
|
||||||
|
/// bridge — the exact gap behind `IPSW has no attribute 'create_from_path'`
|
||||||
|
/// when an old system-python build gets picked up.
|
||||||
|
func pythonIsUsable(_ python: URL) -> Bool {
|
||||||
|
guard FileManager.default.isExecutableFile(atPath: python.path) else { return false }
|
||||||
|
let probe = "from ipsw_parser.ipsw import IPSW; import sys; "
|
||||||
|
+ "sys.exit(0 if hasattr(IPSW, 'create_from_path') else 1)"
|
||||||
|
return (try? VPhoneProcessRunner.runCapturing(python, ["-c", probe]))?.succeeded == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a python with working deps: an explicit `VPHONE_PYTHON`, the dev
|
||||||
|
/// repo `.venv`, the managed per-user venv, else provision the managed venv
|
||||||
|
/// on this machine. Never silently falls back to a stale system python.
|
||||||
|
public func pythonExecutable() throws -> URL {
|
||||||
|
if let override = ProcessInfo.processInfo.environment["VPHONE_PYTHON"], !override.isEmpty {
|
||||||
|
let u = URL(fileURLWithPath: override)
|
||||||
|
if pythonIsUsable(u) { return u }
|
||||||
|
}
|
||||||
|
let devVenv = base.appendingPathComponent(".venv/bin/python3")
|
||||||
|
if pythonIsUsable(devVenv) { return devVenv }
|
||||||
|
if pythonIsUsable(managedVenvPython) { return managedVenvPython }
|
||||||
|
return try bootstrapManagedVenv()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provision `~/.vphone/venv`: try each candidate host python for real
|
||||||
|
/// (build the venv, install deps, verify) and use the first that fully
|
||||||
|
/// succeeds — a candidate that imports `venv` can still fail `-m venv`
|
||||||
|
/// (e.g. a broken `ensurepip`), so we fall through instead of trusting it.
|
||||||
|
/// One-time per machine.
|
||||||
|
private func bootstrapManagedVenv() throws -> URL {
|
||||||
|
func log(_ s: String) { FileHandle.standardError.write(Data((s + "\n").utf8)) }
|
||||||
|
let candidates = candidateHostPythons()
|
||||||
|
guard !candidates.isEmpty else {
|
||||||
|
throw VPhoneResourcesError.venvBootstrapFailed(
|
||||||
|
"no host python3 found — install one (e.g. `brew install [email protected]`) or set VPHONE_PYTHON")
|
||||||
|
}
|
||||||
|
log("[*] First run: provisioning the vphone Python environment at \(managedVenvDir.path) (one-time)…")
|
||||||
|
let py = managedVenvPython
|
||||||
|
let install: [String] = FileManager.default.fileExists(atPath: requirementsFile.path)
|
||||||
|
? ["-m", "pip", "install", "-r", requirementsFile.path]
|
||||||
|
: ["-m", "pip", "install"] + Self.fallbackRequirements
|
||||||
|
var lastError = "no candidate python could build a usable venv"
|
||||||
|
|
||||||
|
for host in candidates {
|
||||||
|
log(" → trying \(host.path) …")
|
||||||
|
try? FileManager.default.removeItem(at: managedVenvDir)
|
||||||
|
try FileManager.default.createDirectory(at: userCacheDir, withIntermediateDirectories: true)
|
||||||
|
guard (try? VPhoneProcessRunner.runStreaming(host, ["-m", "venv", managedVenvDir.path])) == 0 else {
|
||||||
|
lastError = "python -m venv failed with \(host.path)"; continue
|
||||||
|
}
|
||||||
|
_ = try? VPhoneProcessRunner.runStreaming(py, ["-m", "pip", "install", "--upgrade", "-q", "pip"])
|
||||||
|
guard (try? VPhoneProcessRunner.runStreaming(py, install)) == 0 else {
|
||||||
|
lastError = "pip install failed with \(host.path)"; continue
|
||||||
|
}
|
||||||
|
guard pythonIsUsable(py) else {
|
||||||
|
lastError = "venv from \(host.path) still lacks a usable ipsw_parser (too old?)"; continue
|
||||||
|
}
|
||||||
|
log("[+] Python environment ready: \(py.path)")
|
||||||
|
return py
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: managedVenvDir)
|
||||||
|
throw VPhoneResourcesError.venvBootstrapFailed(
|
||||||
|
lastError + " — install a modern python3 (e.g. `brew install [email protected]`) or set VPHONE_PYTHON")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered, existence-checked host python3 candidates to bootstrap from.
|
||||||
|
/// Canonical Homebrew locations first, then versioned names on PATH, then
|
||||||
|
/// generic `python3`, then system `/usr/bin/python3` (3.9) as a last resort
|
||||||
|
/// (it resolves an old, broken pymobiledevice3 stack).
|
||||||
|
private func candidateHostPythons() -> [URL] {
|
||||||
|
var paths: [String] = []
|
||||||
|
if let override = ProcessInfo.processInfo.environment["VPHONE_PYTHON"], !override.isEmpty {
|
||||||
|
paths.append(override)
|
||||||
|
}
|
||||||
|
paths += ["/opt/homebrew/bin/python3", "/usr/local/bin/python3"]
|
||||||
|
for name in ["python3.14", "python3.13", "python3.12", "python3.11", "python3.10"] {
|
||||||
|
if let p = which(name) { paths.append(p) }
|
||||||
|
}
|
||||||
|
if let p = which("python3") { paths.append(p) }
|
||||||
|
paths.append("/usr/bin/python3")
|
||||||
|
|
||||||
|
var seen = Set<String>()
|
||||||
|
return paths.filter { !$0.isEmpty && seen.insert($0).inserted }
|
||||||
|
.filter { FileManager.default.isExecutableFile(atPath: $0) }
|
||||||
|
.map { URL(fileURLWithPath: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func which(_ name: String) -> String? {
|
||||||
|
let r = try? VPhoneProcessRunner.runCapturing(URL(fileURLWithPath: "/usr/bin/env"), ["which", name])
|
||||||
|
guard let r, r.succeeded else { return nil }
|
||||||
|
let p = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return p.isEmpty ? nil : p
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum VPhoneRestoreError: Error, Equatable {
|
||||||
|
case ecidUnresolved
|
||||||
|
case noSHSH
|
||||||
|
case noRestoreDir
|
||||||
|
case aeaDecryptFailed(String)
|
||||||
|
case aeaStillEncrypted(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VPhoneRestoreOps {
|
||||||
|
// MARK: - ECID
|
||||||
|
|
||||||
|
/// ECID from `--ecid`, else the `ECID=` line of the bundle's udid-prediction.txt.
|
||||||
|
public static func resolveECID(explicit: String?, bundle: VPhoneBundle) -> String? {
|
||||||
|
if let explicit, !explicit.isEmpty { return explicit }
|
||||||
|
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("ECID=") {
|
||||||
|
let value = line.dropFirst("ECID=".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`).
|
||||||
|
public static func isAEAEncrypted(_ url: URL) throws -> Bool {
|
||||||
|
let handle = try FileHandle(forReadingFrom: url)
|
||||||
|
defer { try? handle.close() }
|
||||||
|
let head = handle.readData(ofLength: 4)
|
||||||
|
return head == Data([0x41, 0x45, 0x41, 0x31])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt every AEA1-encrypted `*.dmg.aea` in `dir` in place (via `ipsw fw aea`),
|
||||||
|
/// keeping the `.aea` filename with decrypted content (matches make restore_offline).
|
||||||
|
public static func decryptAEAImages(inRestoreDir dir: URL) throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let entries = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
|
||||||
|
for aea in entries where aea.lastPathComponent.hasSuffix(".dmg.aea") {
|
||||||
|
guard try isAEAEncrypted(aea) else { continue }
|
||||||
|
let code = try VPhoneProcessRunner.runStreaming(
|
||||||
|
URL(fileURLWithPath: "/usr/bin/env"), ["ipsw", "fw", "aea", "-o", dir.path, aea.path])
|
||||||
|
guard code == 0 else { throw VPhoneRestoreError.aeaDecryptFailed(aea.lastPathComponent) }
|
||||||
|
// ipsw wrote <dir>/<name minus .aea>; move it onto the .aea filename.
|
||||||
|
// Confirm the decrypted output exists BEFORE removing the original (mv -f semantics).
|
||||||
|
let decrypted = dir.appendingPathComponent(aea.deletingPathExtension().lastPathComponent)
|
||||||
|
guard fm.fileExists(atPath: decrypted.path) else {
|
||||||
|
throw VPhoneRestoreError.aeaDecryptFailed(aea.lastPathComponent)
|
||||||
|
}
|
||||||
|
if fm.fileExists(atPath: aea.path) { try fm.removeItem(at: aea) }
|
||||||
|
try fm.moveItem(at: decrypted, to: aea)
|
||||||
|
if try isAEAEncrypted(aea) { throw VPhoneRestoreError.aeaStillEncrypted(aea.lastPathComponent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum VPhoneVMPickerError: Error, CustomStringConvertible, Equatable {
|
||||||
|
case emptyLibrary(root: String)
|
||||||
|
case notInteractive
|
||||||
|
case aborted
|
||||||
|
case invalidSelection
|
||||||
|
|
||||||
|
public var description: String {
|
||||||
|
switch self {
|
||||||
|
case .emptyLibrary(let root):
|
||||||
|
"no VMs found in \(root) — create one with 'vphone-cli vm create <name>'"
|
||||||
|
case .notInteractive:
|
||||||
|
"no VM name given and stdin is not a terminal — pass the name explicitly"
|
||||||
|
case .aborted:
|
||||||
|
"selection aborted"
|
||||||
|
case .invalidSelection:
|
||||||
|
"no valid selection made"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VPhoneVMPicker {
|
||||||
|
/// Resolve an existing-VM name. Returns `provided` unchanged when given;
|
||||||
|
/// otherwise shows a numbered menu of `names` and reads a 1-based index or an
|
||||||
|
/// exact name. All output goes through `write` (caller routes to stderr); input
|
||||||
|
/// via `read` (nil = EOF). I/O is injected so the logic is unit-testable.
|
||||||
|
public static func resolve(
|
||||||
|
provided: String?,
|
||||||
|
names: [String],
|
||||||
|
libraryRoot: String,
|
||||||
|
isInteractive: Bool,
|
||||||
|
maxRetries: Int = 5,
|
||||||
|
read: () -> String?,
|
||||||
|
write: (String) -> Void
|
||||||
|
) throws -> String {
|
||||||
|
if let provided, !provided.isEmpty { return provided }
|
||||||
|
guard isInteractive else { throw VPhoneVMPickerError.notInteractive }
|
||||||
|
guard !names.isEmpty else { throw VPhoneVMPickerError.emptyLibrary(root: libraryRoot) }
|
||||||
|
|
||||||
|
write("Select a VM:")
|
||||||
|
for (i, name) in names.enumerated() { write(" [\(i + 1)] \(name)") }
|
||||||
|
|
||||||
|
for _ in 0..<maxRetries {
|
||||||
|
write("Enter number or name: ")
|
||||||
|
guard let line = read() else { throw VPhoneVMPickerError.aborted }
|
||||||
|
let choice = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if choice.isEmpty { continue }
|
||||||
|
if let idx = Int(choice), idx >= 1, idx <= names.count { return names[idx - 1] }
|
||||||
|
if let match = names.first(where: { $0 == choice }) { return match }
|
||||||
|
write(" '\(choice)' is not a valid selection.")
|
||||||
|
}
|
||||||
|
throw VPhoneVMPickerError.invalidSelection
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// CLI output verbosity. Higher levels are supersets of lower ones.
|
||||||
|
///
|
||||||
|
/// The guest VM serial console is deliberately NOT part of this ladder:
|
||||||
|
/// `vm launch` always streams it, `vm create` never does — neither depends
|
||||||
|
/// on the verbosity level.
|
||||||
|
public enum VPhoneVerbosity: Int, Comparable, Sendable {
|
||||||
|
case quiet = 0 // tool banners + [*]/[+] markers only
|
||||||
|
case info = 1 // + wrapped-subprocess stdout + pmd3 restore log (INFO, colorful)
|
||||||
|
case debug = 2 // + pmd3 DEBUG logs (deeper restore detail)
|
||||||
|
case trace = 3 // + vphone-cli internal trace (spawned argv/env, managed-process events)
|
||||||
|
|
||||||
|
public static func < (lhs: VPhoneVerbosity, rhs: VPhoneVerbosity) -> Bool {
|
||||||
|
lhs.rawValue < rhs.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a repeated `-v` count (0…N) to a level, clamped to `.trace`.
|
||||||
|
public init(count: Int) {
|
||||||
|
self = VPhoneVerbosity(rawValue: min(max(count, 0), 3)) ?? .trace
|
||||||
|
}
|
||||||
|
|
||||||
|
public var showsToolDetail: Bool { self >= .info }
|
||||||
|
public var tracesInternals: Bool { self >= .trace }
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
public enum VPhoneManifestError: Error {
|
||||||
|
case loadFailed(path: String, underlying: Error)
|
||||||
|
case parseFailed(path: String, underlying: Error)
|
||||||
|
case writeFailed(path: String, underlying: Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension VPhoneManifestError: CustomStringConvertible, LocalizedError {
|
||||||
|
public var description: String {
|
||||||
|
switch self {
|
||||||
|
case let .loadFailed(path, underlying): "Failed to load manifest from \(path): \(underlying)"
|
||||||
|
case let .parseFailed(path, underlying): "Failed to parse manifest at \(path): \(underlying)"
|
||||||
|
case let .writeFailed(path, underlying): "Failed to write manifest to \(path): \(underlying)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public var errorDescription: String? { description }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VPhoneVirtualMachineManifest represents the on-disk VM configuration manifest.
|
||||||
|
/// Structure is compatible with security-pcc's VMBundle.Config format.
|
||||||
|
public struct VPhoneVirtualMachineManifest: Codable, Sendable {
|
||||||
|
// MARK: - Platform
|
||||||
|
|
||||||
|
/// Platform type (fixed to vresearch101 for vphone)
|
||||||
|
public let platformType: PlatformType
|
||||||
|
|
||||||
|
/// Platform fusing mode (prod/dev) - determined by host OS capabilities
|
||||||
|
public let platformFusing: PlatformFusing?
|
||||||
|
|
||||||
|
/// Machine identifier (opaque ECID representation)
|
||||||
|
public let machineIdentifier: Data
|
||||||
|
|
||||||
|
// MARK: - Hardware
|
||||||
|
|
||||||
|
/// CPU core count
|
||||||
|
public let cpuCount: UInt
|
||||||
|
|
||||||
|
/// Memory size in bytes
|
||||||
|
public let memorySize: UInt64
|
||||||
|
|
||||||
|
// MARK: - Display
|
||||||
|
|
||||||
|
/// Screen configuration
|
||||||
|
public let screenConfig: ScreenConfig
|
||||||
|
|
||||||
|
// MARK: - Network
|
||||||
|
|
||||||
|
/// Network configuration (NAT mode for vphone)
|
||||||
|
public let networkConfig: NetworkConfig
|
||||||
|
|
||||||
|
// MARK: - Storage
|
||||||
|
|
||||||
|
/// Disk image filename
|
||||||
|
public let diskImage: String
|
||||||
|
|
||||||
|
/// NVRAM storage filename
|
||||||
|
public let nvramStorage: String
|
||||||
|
|
||||||
|
// MARK: - ROMs
|
||||||
|
|
||||||
|
/// ROM image paths
|
||||||
|
public let romImages: ROMImages?
|
||||||
|
|
||||||
|
// MARK: - SEP
|
||||||
|
|
||||||
|
/// SEP storage filename
|
||||||
|
public let sepStorage: String
|
||||||
|
|
||||||
|
// MARK: - Nested Types
|
||||||
|
|
||||||
|
public enum PlatformType: String, Codable, Sendable {
|
||||||
|
case vresearch101
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PlatformFusing: String, Codable, Sendable {
|
||||||
|
case prod
|
||||||
|
case dev
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ScreenConfig: Codable, Sendable {
|
||||||
|
public let width: Int
|
||||||
|
public let height: Int
|
||||||
|
public let pixelsPerInch: Int
|
||||||
|
public let scale: Double
|
||||||
|
|
||||||
|
public static let `default` = ScreenConfig(
|
||||||
|
width: 1290,
|
||||||
|
height: 2796,
|
||||||
|
pixelsPerInch: 460,
|
||||||
|
scale: 3.0
|
||||||
|
)
|
||||||
|
|
||||||
|
public init(width: Int, height: Int, pixelsPerInch: Int, scale: Double) {
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.pixelsPerInch = pixelsPerInch
|
||||||
|
self.scale = scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NetworkConfig: Codable, Sendable {
|
||||||
|
public let mode: NetworkMode
|
||||||
|
public let macAddress: String
|
||||||
|
|
||||||
|
public enum NetworkMode: String, Codable, Sendable {
|
||||||
|
case nat
|
||||||
|
case bridged
|
||||||
|
case hostOnly
|
||||||
|
case none
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = NetworkConfig(mode: .nat, macAddress: "")
|
||||||
|
|
||||||
|
public init(mode: NetworkMode, macAddress: String) {
|
||||||
|
self.mode = mode
|
||||||
|
self.macAddress = macAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ROMImages: Codable, Sendable {
|
||||||
|
public let avpBooter: String
|
||||||
|
public let avpSEPBooter: String
|
||||||
|
|
||||||
|
public init(avpBooter: String, avpSEPBooter: String) {
|
||||||
|
self.avpBooter = avpBooter
|
||||||
|
self.avpSEPBooter = avpSEPBooter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Init from VM creation parameters
|
||||||
|
|
||||||
|
public init(
|
||||||
|
platformType: PlatformType = .vresearch101,
|
||||||
|
platformFusing: PlatformFusing? = nil,
|
||||||
|
machineIdentifier: Data = Data(),
|
||||||
|
cpuCount: UInt,
|
||||||
|
memorySize: UInt64,
|
||||||
|
screenConfig: ScreenConfig = .default,
|
||||||
|
networkConfig: NetworkConfig = .default,
|
||||||
|
diskImage: String = "Disk.img",
|
||||||
|
nvramStorage: String = "nvram.bin",
|
||||||
|
romImages: ROMImages?,
|
||||||
|
sepStorage: String = "SEPStorage"
|
||||||
|
) {
|
||||||
|
self.platformType = platformType
|
||||||
|
self.platformFusing = platformFusing
|
||||||
|
self.machineIdentifier = machineIdentifier
|
||||||
|
self.cpuCount = cpuCount
|
||||||
|
self.memorySize = memorySize
|
||||||
|
self.screenConfig = screenConfig
|
||||||
|
self.networkConfig = networkConfig
|
||||||
|
self.diskImage = diskImage
|
||||||
|
self.nvramStorage = nvramStorage
|
||||||
|
self.romImages = romImages
|
||||||
|
self.sepStorage = sepStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load/Save
|
||||||
|
|
||||||
|
/// Load manifest from a plist file
|
||||||
|
public static func load(from url: URL) throws -> VPhoneVirtualMachineManifest {
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
throw VPhoneManifestError.loadFailed(path: url.path, underlying: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = PropertyListDecoder()
|
||||||
|
do {
|
||||||
|
return try decoder.decode(VPhoneVirtualMachineManifest.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw VPhoneManifestError.parseFailed(path: url.path, underlying: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save manifest to a plist file
|
||||||
|
public func write(to url: URL) throws {
|
||||||
|
let encoder = PropertyListEncoder()
|
||||||
|
encoder.outputFormat = .xml
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try encoder.encode(self)
|
||||||
|
try data.write(to: url)
|
||||||
|
} catch {
|
||||||
|
throw VPhoneManifestError.writeFailed(path: url.path, underlying: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience
|
||||||
|
|
||||||
|
/// Convert to JSON string for logging/debugging
|
||||||
|
public func asJSON() -> String {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .withoutEscapingSlashes
|
||||||
|
do {
|
||||||
|
return try String(decoding: encoder.encode(self), as: UTF8.self)
|
||||||
|
} catch {
|
||||||
|
return "{ }"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve relative path to absolute URL within VM directory
|
||||||
|
public func resolve(path: String, in vmDirectory: URL) -> URL {
|
||||||
|
vmDirectory.appendingPathComponent(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get VZMacMachineIdentifier from manifest data
|
||||||
|
public func vzMachineIdentifier() -> VZMacMachineIdentifier? {
|
||||||
|
VZMacMachineIdentifier(dataRepresentation: machineIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Editing
|
||||||
|
|
||||||
|
public func updating(
|
||||||
|
cpuCount: UInt? = nil,
|
||||||
|
memorySize: UInt64? = nil,
|
||||||
|
screenConfig: ScreenConfig? = nil,
|
||||||
|
machineIdentifier: Data? = nil
|
||||||
|
) -> VPhoneVirtualMachineManifest {
|
||||||
|
VPhoneVirtualMachineManifest(
|
||||||
|
platformType: platformType,
|
||||||
|
platformFusing: platformFusing,
|
||||||
|
machineIdentifier: machineIdentifier ?? self.machineIdentifier,
|
||||||
|
cpuCount: cpuCount ?? self.cpuCount,
|
||||||
|
memorySize: memorySize ?? self.memorySize,
|
||||||
|
screenConfig: screenConfig ?? self.screenConfig,
|
||||||
|
networkConfig: networkConfig,
|
||||||
|
diskImage: diskImage,
|
||||||
|
nvramStorage: nvramStorage,
|
||||||
|
romImages: romImages,
|
||||||
|
sepStorage: sepStorage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Virtualization
|
|
||||||
|
|
||||||
/// VPhoneVirtualMachineManifest represents the on-disk VM configuration manifest.
|
|
||||||
/// Structure is compatible with security-pcc's VMBundle.Config format.
|
|
||||||
struct VPhoneVirtualMachineManifest: Codable {
|
|
||||||
// MARK: - Platform
|
|
||||||
|
|
||||||
/// Platform type (fixed to vresearch101 for vphone)
|
|
||||||
let platformType: PlatformType
|
|
||||||
|
|
||||||
/// Platform fusing mode (prod/dev) - determined by host OS capabilities
|
|
||||||
let platformFusing: PlatformFusing?
|
|
||||||
|
|
||||||
/// Machine identifier (opaque ECID representation)
|
|
||||||
let machineIdentifier: Data
|
|
||||||
|
|
||||||
// MARK: - Hardware
|
|
||||||
|
|
||||||
/// CPU core count
|
|
||||||
let cpuCount: UInt
|
|
||||||
|
|
||||||
/// Memory size in bytes
|
|
||||||
let memorySize: UInt64
|
|
||||||
|
|
||||||
// MARK: - Display
|
|
||||||
|
|
||||||
/// Screen configuration
|
|
||||||
let screenConfig: ScreenConfig
|
|
||||||
|
|
||||||
// MARK: - Network
|
|
||||||
|
|
||||||
/// Network configuration (NAT mode for vphone)
|
|
||||||
let networkConfig: NetworkConfig
|
|
||||||
|
|
||||||
// MARK: - Storage
|
|
||||||
|
|
||||||
/// Disk image filename
|
|
||||||
let diskImage: String
|
|
||||||
|
|
||||||
/// NVRAM storage filename
|
|
||||||
let nvramStorage: String
|
|
||||||
|
|
||||||
// MARK: - ROMs
|
|
||||||
|
|
||||||
/// ROM image paths
|
|
||||||
let romImages: ROMImages?
|
|
||||||
|
|
||||||
// MARK: - SEP
|
|
||||||
|
|
||||||
/// SEP storage filename
|
|
||||||
let sepStorage: String
|
|
||||||
|
|
||||||
// MARK: - Nested Types
|
|
||||||
|
|
||||||
enum PlatformType: String, Codable {
|
|
||||||
case vresearch101
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PlatformFusing: String, Codable {
|
|
||||||
case prod
|
|
||||||
case dev
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ScreenConfig: Codable {
|
|
||||||
let width: Int
|
|
||||||
let height: Int
|
|
||||||
let pixelsPerInch: Int
|
|
||||||
let scale: Double
|
|
||||||
|
|
||||||
static let `default` = ScreenConfig(
|
|
||||||
width: 1290,
|
|
||||||
height: 2796,
|
|
||||||
pixelsPerInch: 460,
|
|
||||||
scale: 3.0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NetworkConfig: Codable {
|
|
||||||
let mode: NetworkMode
|
|
||||||
let macAddress: String
|
|
||||||
|
|
||||||
enum NetworkMode: String, Codable {
|
|
||||||
case nat
|
|
||||||
case bridged
|
|
||||||
case hostOnly
|
|
||||||
case none
|
|
||||||
}
|
|
||||||
|
|
||||||
static let `default` = NetworkConfig(mode: .nat, macAddress: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ROMImages: Codable {
|
|
||||||
let avpBooter: String
|
|
||||||
let avpSEPBooter: String
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Init from VM creation parameters
|
|
||||||
|
|
||||||
init(
|
|
||||||
platformType: PlatformType = .vresearch101,
|
|
||||||
platformFusing: PlatformFusing? = nil,
|
|
||||||
machineIdentifier: Data = Data(),
|
|
||||||
cpuCount: UInt,
|
|
||||||
memorySize: UInt64,
|
|
||||||
screenConfig: ScreenConfig = .default,
|
|
||||||
networkConfig: NetworkConfig = .default,
|
|
||||||
diskImage: String = "Disk.img",
|
|
||||||
nvramStorage: String = "nvram.bin",
|
|
||||||
romImages: ROMImages?,
|
|
||||||
sepStorage: String = "SEPStorage"
|
|
||||||
) {
|
|
||||||
self.platformType = platformType
|
|
||||||
self.platformFusing = platformFusing
|
|
||||||
self.machineIdentifier = machineIdentifier
|
|
||||||
self.cpuCount = cpuCount
|
|
||||||
self.memorySize = memorySize
|
|
||||||
self.screenConfig = screenConfig
|
|
||||||
self.networkConfig = networkConfig
|
|
||||||
self.diskImage = diskImage
|
|
||||||
self.nvramStorage = nvramStorage
|
|
||||||
self.romImages = romImages
|
|
||||||
self.sepStorage = sepStorage
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Load/Save
|
|
||||||
|
|
||||||
/// Load manifest from a plist file
|
|
||||||
static func load(from url: URL) throws -> VPhoneVirtualMachineManifest {
|
|
||||||
let data: Data
|
|
||||||
do {
|
|
||||||
data = try Data(contentsOf: url)
|
|
||||||
} catch {
|
|
||||||
throw VPhoneError.manifestLoadFailed(path: url.path, underlying: error)
|
|
||||||
}
|
|
||||||
|
|
||||||
let decoder = PropertyListDecoder()
|
|
||||||
do {
|
|
||||||
return try decoder.decode(VPhoneVirtualMachineManifest.self, from: data)
|
|
||||||
} catch {
|
|
||||||
throw VPhoneError.manifestParseFailed(path: url.path, underlying: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save manifest to a plist file
|
|
||||||
func write(to url: URL) throws {
|
|
||||||
let encoder = PropertyListEncoder()
|
|
||||||
encoder.outputFormat = .xml
|
|
||||||
|
|
||||||
do {
|
|
||||||
let data = try encoder.encode(self)
|
|
||||||
try data.write(to: url)
|
|
||||||
} catch {
|
|
||||||
throw VPhoneError.manifestWriteFailed(path: url.path, underlying: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience
|
|
||||||
|
|
||||||
/// Convert to JSON string for logging/debugging
|
|
||||||
func asJSON() -> String {
|
|
||||||
let encoder = JSONEncoder()
|
|
||||||
encoder.outputFormatting = .withoutEscapingSlashes
|
|
||||||
do {
|
|
||||||
return try String(decoding: encoder.encode(self), as: UTF8.self)
|
|
||||||
} catch {
|
|
||||||
return "{ }"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve relative path to absolute URL within VM directory
|
|
||||||
func resolve(path: String, in vmDirectory: URL) -> URL {
|
|
||||||
vmDirectory.appendingPathComponent(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get VZMacMachineIdentifier from manifest data
|
|
||||||
func vzMachineIdentifier() -> VZMacMachineIdentifier? {
|
|
||||||
VZMacMachineIdentifier(dataRepresentation: machineIdentifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct BundleOpsTests {
|
||||||
|
private func makeRoot() throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fakeROM() throws -> URL {
|
||||||
|
let f = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".bin")
|
||||||
|
try Data([0xAA, 0xBB, 0xCC]).write(to: f)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func createsBundleWithSparseDiskAndManifest() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
|
||||||
|
let spec = VPhoneBundleOps.NewBundleSpec(
|
||||||
|
name: "newvm", cpuCount: 8, memoryMB: 8192, diskSizeGB: 64,
|
||||||
|
romSource: rom, sepromSource: seprom)
|
||||||
|
let bundle = try VPhoneBundleOps.create(spec, in: VPhoneLibrary(root: root))
|
||||||
|
|
||||||
|
#expect(bundle.manifest.cpuCount == 8)
|
||||||
|
#expect(bundle.manifest.memorySize == 8192 * 1024 * 1024)
|
||||||
|
let disk = bundle.url.appendingPathComponent("Disk.img")
|
||||||
|
let size = (try FileManager.default.attributesOfItem(atPath: disk.path)[.size] as? NSNumber)?.int64Value
|
||||||
|
#expect(size == Int64(64 * 1024 * 1024 * 1024))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: bundle.url.appendingPathComponent("SEPStorage").path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: bundle.url.appendingPathComponent("AVPBooter.vresearch1.bin").path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsDuplicateName() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let spec = VPhoneBundleOps.NewBundleSpec(
|
||||||
|
name: "dup", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom)
|
||||||
|
_ = try VPhoneBundleOps.create(spec, in: VPhoneLibrary(root: root))
|
||||||
|
#expect(throws: VPhoneLibraryError.self) {
|
||||||
|
_ = try VPhoneBundleOps.create(spec, in: VPhoneLibrary(root: root))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsInvalidNames() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
for bad in ["", "a/b", ".hidden"] {
|
||||||
|
#expect(throws: VPhoneLibraryError.self) {
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: bad, cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rollsBackPartialBundleOnFailure() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
// A non-existent ROM source makes copyItem fail AFTER the dir is created.
|
||||||
|
let missingRom = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString + ".bin")
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "partial", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: missingRom, sepromSource: missingRom), in: lib)
|
||||||
|
}
|
||||||
|
// The half-built directory must be removed so the name is reusable.
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: lib.url(forName: "partial").path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func updateConfigPersistsFields() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "cfg", cpuCount: 8, memoryMB: 8192, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
|
||||||
|
let updated = try VPhoneBundleOps.updateConfig(
|
||||||
|
bundleNamed: "cfg", in: lib, cpuCount: 4, memoryMB: nil)
|
||||||
|
#expect(updated.manifest.cpuCount == 4)
|
||||||
|
#expect(updated.manifest.memorySize == 8192 * 1024 * 1024)
|
||||||
|
|
||||||
|
// Persisted: a fresh load sees the change.
|
||||||
|
#expect(try lib.bundle(named: "cfg").manifest.cpuCount == 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renameThenDelete() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "old", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
|
||||||
|
let renamed = try VPhoneBundleOps.rename(bundleNamed: "old", to: "shiny", in: lib)
|
||||||
|
#expect(renamed.name == "shiny")
|
||||||
|
#expect(throws: VPhoneLibraryError.self) { _ = try lib.bundle(named: "old") }
|
||||||
|
|
||||||
|
try VPhoneBundleOps.delete(bundleNamed: "shiny", in: lib)
|
||||||
|
#expect(try lib.bundles().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func renameRejectsExistingTarget() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
for n in ["a", "b"] {
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: n, cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
}
|
||||||
|
#expect(throws: VPhoneLibraryError.alreadyExists(name: "b")) {
|
||||||
|
_ = try VPhoneBundleOps.rename(bundleNamed: "a", to: "b", in: lib)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func cloneCopiesBundleAndResetsIdentity() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
let src = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "src", cpuCount: 8, memoryMB: 4096, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
// Simulate a booted/restored VM: identity artifacts present + non-empty machineIdentifier.
|
||||||
|
let fm = FileManager.default
|
||||||
|
try Data([1, 2, 3]).write(to: src.url.appendingPathComponent("nvram.bin"))
|
||||||
|
try Data([4]).write(to: src.url.appendingPathComponent("udid-prediction.txt"))
|
||||||
|
try Data([5]).write(to: src.url.appendingPathComponent("ABC123.shsh"))
|
||||||
|
let withID = src.manifest.updating(machineIdentifier: Data([9, 9]))
|
||||||
|
try withID.write(to: src.configURL)
|
||||||
|
|
||||||
|
let clone = try VPhoneBundleOps.clone(bundleNamed: "src", to: "dst", in: lib)
|
||||||
|
|
||||||
|
// Copy happened (disk + ROMs present in the clone).
|
||||||
|
#expect(fm.fileExists(atPath: clone.url.appendingPathComponent("Disk.img").path))
|
||||||
|
#expect(fm.fileExists(atPath: clone.url.appendingPathComponent("AVPBooter.vresearch1.bin").path))
|
||||||
|
// Identity artifacts cleared in the clone.
|
||||||
|
#expect(!fm.fileExists(atPath: clone.url.appendingPathComponent("nvram.bin").path))
|
||||||
|
#expect(!fm.fileExists(atPath: clone.url.appendingPathComponent("udid-prediction.txt").path))
|
||||||
|
#expect(!fm.fileExists(atPath: clone.url.appendingPathComponent("ABC123.shsh").path))
|
||||||
|
#expect(clone.manifest.machineIdentifier.isEmpty)
|
||||||
|
// Original untouched.
|
||||||
|
#expect(fm.fileExists(atPath: src.url.appendingPathComponent("nvram.bin").path))
|
||||||
|
#expect(try lib.bundle(named: "src").manifest.machineIdentifier == Data([9, 9]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func cloneRejectsExistingTarget() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
for n in ["a", "b"] {
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: n, cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
}
|
||||||
|
#expect(throws: VPhoneLibraryError.alreadyExists(name: "b")) {
|
||||||
|
_ = try VPhoneBundleOps.clone(bundleNamed: "a", to: "b", in: lib)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func exportThenImportRoundTrips() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "orig", cpuCount: 8, memoryMB: 4096, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
|
||||||
|
let archive = root.appendingPathComponent("orig.tgz")
|
||||||
|
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: archive.path))
|
||||||
|
|
||||||
|
// Import into a fresh library, renaming.
|
||||||
|
let root2 = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root2) }
|
||||||
|
let lib2 = VPhoneLibrary(root: root2)
|
||||||
|
let imported = try VPhoneBundleOps.importArchive(from: archive, name: "copy", in: lib2)
|
||||||
|
#expect(imported.name == "copy")
|
||||||
|
#expect(imported.manifest.cpuCount == 8)
|
||||||
|
#expect(imported.manifest.memorySize == 4096 * 1024 * 1024)
|
||||||
|
#expect(try lib2.bundle(named: "copy").manifest.cpuCount == 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func importRejectsExistingName() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "orig", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
let archive = root.appendingPathComponent("orig.tgz")
|
||||||
|
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
|
||||||
|
// Importing back under the same existing name must fail.
|
||||||
|
#expect(throws: VPhoneLibraryError.alreadyExists(name: "orig")) {
|
||||||
|
_ = try VPhoneBundleOps.importArchive(from: archive, name: nil, in: lib)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func importWithRenameDoesNotClobberArchivedNameCollision() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
// Existing "orig" (cpu 16) that must NOT be touched by the import.
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "orig", cpuCount: 16, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
// Archive of a DIFFERENT "orig" (cpu 4) from a separate library.
|
||||||
|
let root2 = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root2) }
|
||||||
|
let lib2 = VPhoneLibrary(root: root2)
|
||||||
|
_ = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "orig", cpuCount: 4, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib2)
|
||||||
|
let archive = root2.appendingPathComponent("orig.tgz")
|
||||||
|
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib2)
|
||||||
|
|
||||||
|
let imported = try VPhoneBundleOps.importArchive(from: archive, name: "renamed", in: lib)
|
||||||
|
#expect(imported.name == "renamed")
|
||||||
|
#expect(imported.manifest.cpuCount == 4)
|
||||||
|
#expect(try lib.bundle(named: "orig").manifest.cpuCount == 16) // untouched
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func exportExcludesRestoreDirByDefault() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
let b = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "orig", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
let restoreDir = b.url.appendingPathComponent("iPhone_Restore")
|
||||||
|
try FileManager.default.createDirectory(at: restoreDir, withIntermediateDirectories: true)
|
||||||
|
try Data([0]).write(to: restoreDir.appendingPathComponent("marker"))
|
||||||
|
|
||||||
|
let archive = root.appendingPathComponent("orig.tgz")
|
||||||
|
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
|
||||||
|
let listing = try VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/usr/bin/tar"), ["-tf", archive.path])
|
||||||
|
#expect(listing.succeeded)
|
||||||
|
#expect(!listing.stdout.contains("iPhone_Restore"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func exportExcludesRegenerableStagingFiles() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||||
|
defer { try? FileManager.default.removeItem(at: rom); try? FileManager.default.removeItem(at: seprom) }
|
||||||
|
let lib = VPhoneLibrary(root: root)
|
||||||
|
let b = try VPhoneBundleOps.create(
|
||||||
|
.init(name: "orig", cpuCount: 2, memoryMB: 2048, diskSizeGB: 1,
|
||||||
|
romSource: rom, sepromSource: seprom), in: lib)
|
||||||
|
let fm = FileManager.default
|
||||||
|
try Data([0]).write(to: b.url.appendingPathComponent(".vphoned.signed"))
|
||||||
|
try fm.createDirectory(at: b.url.appendingPathComponent("cfw_input/jb"), withIntermediateDirectories: true)
|
||||||
|
try Data([0]).write(to: b.url.appendingPathComponent("cfw_input/jb/f"))
|
||||||
|
try fm.createDirectory(at: b.url.appendingPathComponent("cfw_jb_input"), withIntermediateDirectories: true)
|
||||||
|
try Data([0]).write(to: b.url.appendingPathComponent("cfw_jb_input/f"))
|
||||||
|
try fm.createDirectory(at: b.url.appendingPathComponent(".cfw_temp"), withIntermediateDirectories: true)
|
||||||
|
try Data([0]).write(to: b.url.appendingPathComponent(".cfw_temp/f"))
|
||||||
|
|
||||||
|
let archive = root.appendingPathComponent("orig.tgz")
|
||||||
|
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
|
||||||
|
let listing = try VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/usr/bin/tar"), ["-tf", archive.path])
|
||||||
|
#expect(listing.succeeded)
|
||||||
|
#expect(!listing.stdout.contains(".vphoned.signed"))
|
||||||
|
#expect(!listing.stdout.contains("cfw_input"))
|
||||||
|
#expect(!listing.stdout.contains("cfw_jb_input"))
|
||||||
|
#expect(!listing.stdout.contains(".cfw_temp"))
|
||||||
|
// The real payload still travels.
|
||||||
|
#expect(listing.stdout.contains("Disk.img"))
|
||||||
|
#expect(listing.stdout.contains("config.plist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func importRejectsMultiTopLevelArchive() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
// An archive with TWO top-level dirs is not a single bundle → badArchive.
|
||||||
|
let src = root.appendingPathComponent("src")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: src.appendingPathComponent("a"), withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: src.appendingPathComponent("b"), withIntermediateDirectories: true)
|
||||||
|
try Data([0]).write(to: src.appendingPathComponent("a/x"))
|
||||||
|
try Data([0]).write(to: src.appendingPathComponent("b/y"))
|
||||||
|
let archive = root.appendingPathComponent("multi.tgz")
|
||||||
|
let made = try VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/usr/bin/tar"), ["-czf", archive.path, "-C", src.path, "a", "b"])
|
||||||
|
#expect(made.succeeded)
|
||||||
|
|
||||||
|
#expect(throws: VPhoneBundleOpsError.self) {
|
||||||
|
_ = try VPhoneBundleOps.importArchive(from: archive, name: nil, in: VPhoneLibrary(root: root))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct BundleReportTests {
|
||||||
|
@Test func mapsManifestFields() throws {
|
||||||
|
let manifest = VPhoneVirtualMachineManifest(
|
||||||
|
cpuCount: 6, memorySize: 4 * 1024 * 1024 * 1024,
|
||||||
|
romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
|
||||||
|
let bundle = VPhoneBundle(url: URL(fileURLWithPath: "/tmp/myvm"), manifest: manifest)
|
||||||
|
|
||||||
|
let report = VPhoneBundleReport(bundle: bundle)
|
||||||
|
#expect(report.name == "myvm")
|
||||||
|
#expect(report.cpuCount == 6)
|
||||||
|
#expect(report.memoryMB == 4096)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func encodesToJSON() throws {
|
||||||
|
let manifest = VPhoneVirtualMachineManifest(
|
||||||
|
cpuCount: 2, memorySize: 2 * 1024 * 1024 * 1024,
|
||||||
|
romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
|
||||||
|
let report = VPhoneBundleReport(
|
||||||
|
bundle: VPhoneBundle(url: URL(fileURLWithPath: "/tmp/x"), manifest: manifest))
|
||||||
|
let data = try JSONEncoder().encode(report)
|
||||||
|
let back = try JSONDecoder().decode(VPhoneBundleReport.self, from: data)
|
||||||
|
#expect(back == report)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
// Tests for `VPhoneBootPatterns` — the pure, device-independent pieces of the
|
||||||
|
// native `vm create` pipeline (`VPhoneCreateOrchestrator`, executable target).
|
||||||
|
// The orchestrator's live stages (DFU/restore/CFW/first-boot/boot-analysis)
|
||||||
|
// need a real device and are NOT exercised here; see the Task 2 report.
|
||||||
|
struct CreateOrchestratorTests {
|
||||||
|
// MARK: - promptRegex / panicRegex (verbatim ports of setup_machine.sh's
|
||||||
|
// BOOT_BASH_PROMPT_REGEX / BOOT_PANIC_REGEX)
|
||||||
|
|
||||||
|
private func matches(_ pattern: String, _ line: String) throws -> Bool {
|
||||||
|
let re = try NSRegularExpression(pattern: pattern)
|
||||||
|
return re.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func promptRegexMatchesIosbinpackBashPrompt() throws {
|
||||||
|
#expect(try matches(VPhoneBootPatterns.promptRegex, "bash-3.2#"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func promptRegexMatchesRamdiskRootPrompt() throws {
|
||||||
|
#expect(try matches(VPhoneBootPatterns.promptRegex, ":/ root#"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func promptRegexDoesNotMatchOrdinaryLogLine() throws {
|
||||||
|
#expect(try !matches(VPhoneBootPatterns.promptRegex, "vphoned: connected, awaiting handshake"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func panicRegexMatchesKernelPanicLine() throws {
|
||||||
|
#expect(try matches(VPhoneBootPatterns.panicRegex, "panic(cpu 0 caller 0xfffffff01234): test panic"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func panicRegexMatchesStackshotSucceeded() throws {
|
||||||
|
#expect(try matches(VPhoneBootPatterns.panicRegex, "stackshot succeeded"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func panicRegexDoesNotMatchOrdinaryLogLine() throws {
|
||||||
|
#expect(try !matches(VPhoneBootPatterns.panicRegex, "vphoned: connected, awaiting handshake"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - panicOrPromptRegex (mixed case-sensitivity, per
|
||||||
|
// monitor_boot_log_until's `grep -Ei` panic vs. `grep -E` prompt)
|
||||||
|
|
||||||
|
@Test func panicOrPromptRegexMatchesPanicCaseInsensitively() throws {
|
||||||
|
#expect(try matches(VPhoneBootPatterns.panicOrPromptRegex, "PANIC(cpu 0 caller 0x0): uppercase panic"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func panicOrPromptRegexKeepsPromptCaseSensitive() throws {
|
||||||
|
#expect(try matches(VPhoneBootPatterns.panicOrPromptRegex, "bash-3.2#"))
|
||||||
|
#expect(try !matches(VPhoneBootPatterns.panicOrPromptRegex, "BASH-3.2#"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - normalizeECID (port of setup_machine.sh's normalize_ecid)
|
||||||
|
|
||||||
|
@Test func normalizeECIDStripsPrefixAndPads() {
|
||||||
|
#expect(VPhoneBootPatterns.normalizeECID("0xabc") == "0000000000000ABC")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func normalizeECIDPassesThroughSixteenHexDigits() {
|
||||||
|
#expect(VPhoneBootPatterns.normalizeECID("0011223344556677") == "0011223344556677")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func normalizeECIDUppercasesLowerHex() {
|
||||||
|
#expect(VPhoneBootPatterns.normalizeECID("0xdeadbeef") == "00000000DEADBEEF")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func normalizeECIDRejectsNonHex() {
|
||||||
|
#expect(VPhoneBootPatterns.normalizeECID("zzzz") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func normalizeECIDRejectsOverlongInput() {
|
||||||
|
#expect(VPhoneBootPatterns.normalizeECID("00112233445566778") == nil) // 17 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func normalizeECIDRejectsEmptyInput() {
|
||||||
|
#expect(VPhoneBootPatterns.normalizeECID("") == nil)
|
||||||
|
#expect(VPhoneBootPatterns.normalizeECID("0x") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - parseHVVmmPresent (nested-VM host preflight)
|
||||||
|
|
||||||
|
@Test func parseHVVmmPresentTrueWhenNested() {
|
||||||
|
#expect(VPhoneBootPatterns.parseHVVmmPresent("1\n") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseHVVmmPresentFalseWhenNotNested() {
|
||||||
|
#expect(VPhoneBootPatterns.parseHVVmmPresent("0") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseHVVmmPresentFalseWhenEmpty() {
|
||||||
|
#expect(VPhoneBootPatterns.parseHVVmmPresent("") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - firstBootCommands (setup_machine.sh:344-361)
|
||||||
|
|
||||||
|
@Test func firstBootCommandsMatchSetupMachineVerbatim() {
|
||||||
|
#expect(VPhoneBootPatterns.firstBootCommands == [
|
||||||
|
"export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/bin/X11:/usr/games:/iosbinpack64/usr/local/sbin:/iosbinpack64/usr/local/bin:/iosbinpack64/usr/sbin:/iosbinpack64/usr/bin:/iosbinpack64/sbin:/iosbinpack64/bin'",
|
||||||
|
"cp /iosbinpack64/etc/profile /var/profile",
|
||||||
|
"cp /iosbinpack64/etc/motd /var/motd",
|
||||||
|
"mkdir -p /var/dropbear",
|
||||||
|
"dropbearkey -t rsa -f /var/dropbear/dropbear_rsa_host_key",
|
||||||
|
"dropbearkey -t ecdsa -f /var/dropbear/dropbear_ecdsa_host_key",
|
||||||
|
"shutdown -h now",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct LaunchLayoutTests {
|
||||||
|
@Test func resolvesArtifactPaths() {
|
||||||
|
let layout = VPhoneLaunchLayout(projectRoot: URL(fileURLWithPath: "/proj"))
|
||||||
|
#expect(layout.preflightScript.path == "/proj/scripts/boot_host_preflight.sh")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolvesToolPaths() {
|
||||||
|
let layout = VPhoneLaunchLayout(projectRoot: URL(fileURLWithPath: "/proj"))
|
||||||
|
#expect(layout.fwPrepareScript.path == "/proj/scripts/fw_prepare.sh")
|
||||||
|
#expect(layout.cfwInstallHostScript.path == "/proj/scripts/cfw_install_host.sh")
|
||||||
|
#expect(layout.pmd3Bridge.path == "/proj/scripts/pymobiledevice3_bridge.py")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func delegatesToResources() {
|
||||||
|
let resources = VPhoneResources(base: URL(fileURLWithPath: "/proj"))
|
||||||
|
let layout = VPhoneLaunchLayout(resources: resources)
|
||||||
|
#expect(layout.fwPrepareScript.path == resources.fwPrepareScript.path)
|
||||||
|
#expect(layout.vphoned.path == resources.vphoned.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parsesLsofPIDs() {
|
||||||
|
#expect(VPhoneLsof.parsePIDs("123\n456\n123\n\n \nnotapid\n789\n") == [123, 456, 789])
|
||||||
|
#expect(VPhoneLsof.parsePIDs("") == [])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stageVphonedCopiesWhenSourceExists() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root.appendingPathComponent(".build"), withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
try Data([1, 2, 3]).write(to: root.appendingPathComponent(".build/vphoned.signed"))
|
||||||
|
|
||||||
|
let bundleDir = root.appendingPathComponent("bundle")
|
||||||
|
try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true)
|
||||||
|
let manifest = VPhoneVirtualMachineManifest(
|
||||||
|
cpuCount: 2, memorySize: 1024 * 1024,
|
||||||
|
romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
|
||||||
|
let bundle = VPhoneBundle(url: bundleDir, manifest: manifest)
|
||||||
|
|
||||||
|
let layout = VPhoneLaunchLayout(projectRoot: root)
|
||||||
|
#expect(try layout.stageVphoned(into: bundle) == true)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: bundleDir.appendingPathComponent(".vphoned.signed").path))
|
||||||
|
// Second call is a no-op (already identical).
|
||||||
|
#expect(try layout.stageVphoned(into: bundle) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stageVphonedReturnsFalseWhenSourceAbsent() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
// No .build/vphoned.signed created → source absent.
|
||||||
|
let bundleDir = root.appendingPathComponent("bundle")
|
||||||
|
try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true)
|
||||||
|
let manifest = VPhoneVirtualMachineManifest(
|
||||||
|
cpuCount: 2, memorySize: 1024 * 1024, romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
|
||||||
|
let bundle = VPhoneBundle(url: bundleDir, manifest: manifest)
|
||||||
|
|
||||||
|
#expect(try VPhoneLaunchLayout(projectRoot: root).stageVphoned(into: bundle) == false)
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: bundleDir.appendingPathComponent(".vphoned.signed").path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func stageVphonedOverwritesStaleDestination() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root.appendingPathComponent(".build"), withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
try Data([9, 9, 9, 9]).write(to: root.appendingPathComponent(".build/vphoned.signed"))
|
||||||
|
|
||||||
|
let bundleDir = root.appendingPathComponent("bundle")
|
||||||
|
try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true)
|
||||||
|
// Pre-populate dst with DIFFERENT bytes.
|
||||||
|
try Data([1, 1]).write(to: bundleDir.appendingPathComponent(".vphoned.signed"))
|
||||||
|
let manifest = VPhoneVirtualMachineManifest(
|
||||||
|
cpuCount: 2, memorySize: 1024 * 1024, romImages: .init(avpBooter: "a", avpSEPBooter: "b"))
|
||||||
|
let bundle = VPhoneBundle(url: bundleDir, manifest: manifest)
|
||||||
|
|
||||||
|
#expect(try VPhoneLaunchLayout(projectRoot: root).stageVphoned(into: bundle) == true)
|
||||||
|
let staged = try Data(contentsOf: bundleDir.appendingPathComponent(".vphoned.signed"))
|
||||||
|
#expect(staged == Data([9, 9, 9, 9]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct LibraryTests {
|
||||||
|
private func makeRoot() throws -> URL {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeBundle(_ name: String, in root: URL) throws {
|
||||||
|
let dir = root.appendingPathComponent(name)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
let manifest = VPhoneVirtualMachineManifest(
|
||||||
|
cpuCount: 8, memorySize: 8 * 1024 * 1024 * 1024,
|
||||||
|
romImages: .init(avpBooter: "AVPBooter.vresearch1.bin",
|
||||||
|
avpSEPBooter: "AVPSEPBooter.vresearch1.bin"))
|
||||||
|
try manifest.write(to: dir.appendingPathComponent("config.plist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func scansOnlyDirsWithManifest() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
try writeBundle("alpha", in: root)
|
||||||
|
try writeBundle("beta", in: root)
|
||||||
|
// A stray dir without config.plist must be ignored.
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root.appendingPathComponent("junk"), withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let names = try VPhoneLibrary(root: root).bundles().map(\.name)
|
||||||
|
#expect(names == ["alpha", "beta"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func bundleNamedThrowsWhenMissing() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
#expect(throws: VPhoneLibraryError.self) {
|
||||||
|
_ = try VPhoneLibrary(root: root).bundle(named: "nope")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultRootHonorsEnvOverride() {
|
||||||
|
setenv("VPHONE_LIBRARY_ROOT", "/tmp/vphone-test-root", 1)
|
||||||
|
defer { unsetenv("VPHONE_LIBRARY_ROOT") }
|
||||||
|
#expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultRootIsShellSafe() {
|
||||||
|
// The default root feeds the shell/make firmware pipeline; a space in it
|
||||||
|
// (e.g. "Application Support") breaks unquoted expansion. Must stay space-free.
|
||||||
|
unsetenv("VPHONE_LIBRARY_ROOT")
|
||||||
|
#expect(!VPhoneLibrary.defaultRoot().path.contains(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func scanReportsCorruptBundlesInsteadOfDropping() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
try writeBundle("good", in: root)
|
||||||
|
// A directory WITH config.plist but corrupt contents must be reported, not silently dropped.
|
||||||
|
let bad = root.appendingPathComponent("bad")
|
||||||
|
try FileManager.default.createDirectory(at: bad, withIntermediateDirectories: true)
|
||||||
|
try Data("not a plist".utf8).write(to: bad.appendingPathComponent("config.plist"))
|
||||||
|
|
||||||
|
let result = try VPhoneLibrary(root: root).scan()
|
||||||
|
#expect(result.bundles.map(\.name) == ["good"])
|
||||||
|
#expect(result.skipped.map(\.name) == ["bad"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct ManagedProcessTests {
|
||||||
|
@Test func waitsForOutputMatch() throws {
|
||||||
|
// Emit a line after a short delay, then sleep; the matcher should catch it.
|
||||||
|
let p = VPhoneManagedProcess(
|
||||||
|
URL(fileURLWithPath: "/bin/sh"),
|
||||||
|
["-c", "sleep 0.2; echo READY-NOW; sleep 2"], echo: false)
|
||||||
|
try p.start()
|
||||||
|
let r = p.waitForOutput(matching: "READY-NOW", timeout: 5)
|
||||||
|
#expect(r == .matched)
|
||||||
|
p.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func reportsExitBeforeMatch() throws {
|
||||||
|
let p = VPhoneManagedProcess(URL(fileURLWithPath: "/bin/sh"),
|
||||||
|
["-c", "echo nope; exit 3"], echo: false)
|
||||||
|
try p.start()
|
||||||
|
let r = p.waitForOutput(matching: "WILL-NOT-APPEAR", timeout: 5)
|
||||||
|
#expect(r == .exited(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func timesOutWhenNoMatch() throws {
|
||||||
|
let p = VPhoneManagedProcess(URL(fileURLWithPath: "/bin/sh"),
|
||||||
|
["-c", "sleep 3"], echo: false)
|
||||||
|
try p.start()
|
||||||
|
let r = p.waitForOutput(matching: "NEVER", timeout: 0.5)
|
||||||
|
#expect(r == .timedOut)
|
||||||
|
p.terminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sendWritesToStdinAndDrivesTheChild() throws {
|
||||||
|
// Child echoes a marker only after it reads a line from stdin.
|
||||||
|
let p = VPhoneManagedProcess(URL(fileURLWithPath: "/bin/sh"),
|
||||||
|
["-c", "read line; echo GOT:$line"], echo: false)
|
||||||
|
try p.start()
|
||||||
|
p.send("hello")
|
||||||
|
let r = p.waitForOutput(matching: "GOT:hello", timeout: 5)
|
||||||
|
#expect(r == .matched)
|
||||||
|
#expect(p.waitUntilExit() == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func terminateForceKillsAResistantChild() throws {
|
||||||
|
// Child IGNORES SIGINT and SIGTERM, so terminate() must escalate to SIGKILL
|
||||||
|
// and waitUntilExit() must return (not hang).
|
||||||
|
let p = VPhoneManagedProcess(URL(fileURLWithPath: "/bin/sh"),
|
||||||
|
["-c", "trap '' INT TERM; while :; do sleep 1; done"], echo: false)
|
||||||
|
try p.start()
|
||||||
|
_ = p.waitForOutput(matching: "NEVER", timeout: 0.4) // let it install the traps
|
||||||
|
p.terminate()
|
||||||
|
#expect(p.waitUntilExit() != 0) // SIGKILL → nonzero/signal status, and it RETURNED
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct ManifestTests {
|
||||||
|
private func sampleManifest() -> VPhoneVirtualMachineManifest {
|
||||||
|
VPhoneVirtualMachineManifest(
|
||||||
|
cpuCount: 8,
|
||||||
|
memorySize: 8 * 1024 * 1024 * 1024,
|
||||||
|
romImages: .init(avpBooter: "AVPBooter.vresearch1.bin",
|
||||||
|
avpSEPBooter: "AVPSEPBooter.vresearch1.bin")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func roundTripsThroughPlist() throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
let url = dir.appendingPathComponent("config.plist")
|
||||||
|
try sampleManifest().write(to: url)
|
||||||
|
let loaded = try VPhoneVirtualMachineManifest.load(from: url)
|
||||||
|
|
||||||
|
#expect(loaded.cpuCount == 8)
|
||||||
|
#expect(loaded.memorySize == 8 * 1024 * 1024 * 1024)
|
||||||
|
#expect(loaded.romImages?.avpBooter == "AVPBooter.vresearch1.bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func updatingReplacesOnlyGivenFields() {
|
||||||
|
let updated = sampleManifest().updating(cpuCount: 4, memorySize: nil, screenConfig: nil)
|
||||||
|
#expect(updated.cpuCount == 4)
|
||||||
|
#expect(updated.memorySize == 8 * 1024 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct ProcessRunnerTests {
|
||||||
|
@Test func capturesStdoutAndZeroExit() throws {
|
||||||
|
let r = try VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/bin/echo"), ["hello", "world"])
|
||||||
|
#expect(r.exitCode == 0)
|
||||||
|
#expect(r.succeeded)
|
||||||
|
#expect(r.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "hello world")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func capturesNonzeroExit() throws {
|
||||||
|
// `/usr/bin/false` exits 1 with no output.
|
||||||
|
let r = try VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/usr/bin/false"), [])
|
||||||
|
#expect(r.exitCode == 1)
|
||||||
|
#expect(!r.succeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func passesCwd() throws {
|
||||||
|
let r = try VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/bin/pwd"), [], cwd: URL(fileURLWithPath: "/tmp"))
|
||||||
|
// /tmp is a symlink to /private/tmp on macOS; accept either.
|
||||||
|
let out = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
#expect(out == "/tmp" || out == "/private/tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func drainsBothPipesConcurrentlyWithoutDeadlock() throws {
|
||||||
|
// Child writes 100 KB to stderr BEFORE any stdout; a sequential
|
||||||
|
// "read stdout fully first" drain would deadlock at the ~64 KB stderr
|
||||||
|
// pipe buffer. Concurrent draining must complete without hanging.
|
||||||
|
let r = try VPhoneProcessRunner.runCapturing(
|
||||||
|
URL(fileURLWithPath: "/bin/sh"),
|
||||||
|
["-c", "yes E | head -c 100000 1>&2; yes X | head -c 100000"])
|
||||||
|
#expect(r.exitCode == 0)
|
||||||
|
#expect(r.stdout.utf8.count == 100000)
|
||||||
|
#expect(r.stderr.utf8.count == 100000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func runStreamingReturnsExitCode() throws {
|
||||||
|
#expect(try VPhoneProcessRunner.runStreaming(URL(fileURLWithPath: "/usr/bin/true"), []) == 0)
|
||||||
|
#expect(try VPhoneProcessRunner.runStreaming(URL(fileURLWithPath: "/usr/bin/false"), []) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func runStreamingEchoFalseStillReturnsExitCode() throws {
|
||||||
|
// Child writes to stdout but echo:false discards it; exit code still propagates.
|
||||||
|
#expect(try VPhoneProcessRunner.runStreaming(
|
||||||
|
URL(fileURLWithPath: "/bin/sh"), ["-c", "echo noise; exit 0"], echo: false) == 0)
|
||||||
|
#expect(try VPhoneProcessRunner.runStreaming(
|
||||||
|
URL(fileURLWithPath: "/bin/sh"), ["-c", "echo noise >&2; exit 7"], echo: false) == 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct ResourcesTests {
|
||||||
|
@Test func bundledLayoutResolvesToContentsResources() {
|
||||||
|
let exe = "/Applications/vphone-cli.app/Contents/MacOS/vphone-cli"
|
||||||
|
let r = VPhoneResources.resolve(executablePath: exe)
|
||||||
|
#expect(r.base.path == "/Applications/vphone-cli.app/Contents/Resources")
|
||||||
|
#expect(r.fwPrepareScript.path == "/Applications/vphone-cli.app/Contents/Resources/scripts/fw_prepare.sh")
|
||||||
|
#expect(r.cfwPy.path == "/Applications/vphone-cli.app/Contents/Resources/scripts/patchers/cfw.py")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func devLayoutWalksUpToProjectRoot() throws {
|
||||||
|
// Fake a dev tree: <root>/.build/release/vphone-cli with a <root>/scripts dir.
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root.appendingPathComponent(".build/release"), withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root.appendingPathComponent("scripts"), withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let exe = root.appendingPathComponent(".build/release/vphone-cli").path
|
||||||
|
let r = VPhoneResources.resolve(executablePath: exe)
|
||||||
|
#expect(r.base.path == root.resolvingSymlinksInPath().path)
|
||||||
|
#expect(r.resourceArchivesDir.path == root.resolvingSymlinksInPath()
|
||||||
|
.appendingPathComponent("scripts/resources").path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func cacheDirsAreHomeRelativeAndToolsBinIsBaseRelative() {
|
||||||
|
let r = VPhoneResources(base: URL(fileURLWithPath: "/Applications/vphone-cli.app/Contents/Resources"))
|
||||||
|
#expect(r.userCacheDir.path.hasSuffix("/.vphone"))
|
||||||
|
#expect(r.toolsBinDir.path == r.base.appendingPathComponent(".tools/bin").path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func managedVenvDefaultsUnderDotVphone() {
|
||||||
|
// The override env var would change this; only assert the default.
|
||||||
|
if ProcessInfo.processInfo.environment["VPHONE_VENV_DIR"] != nil { return }
|
||||||
|
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
|
||||||
|
#expect(r.managedVenvDir.path.hasSuffix("/.vphone/venv"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pythonUsabilityProbeRejectsMissingAcceptsDevVenv() {
|
||||||
|
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||||
|
let r = VPhoneResources(base: cwd)
|
||||||
|
// A non-existent interpreter is never usable.
|
||||||
|
#expect(r.pythonIsUsable(URL(fileURLWithPath: "/does/not/exist/python3")) == false)
|
||||||
|
// The dev .venv (when present) carries a modern ipsw_parser and must pass.
|
||||||
|
let devVenv = cwd.appendingPathComponent(".venv/bin/python3")
|
||||||
|
if FileManager.default.isExecutableFile(atPath: devVenv.path) {
|
||||||
|
#expect(r.pythonIsUsable(devVenv) == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct RestoreOpsTests {
|
||||||
|
private func bundle(in root: URL) throws -> VPhoneBundle {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveECIDPrefersExplicit() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let b = try bundle(in: root)
|
||||||
|
#expect(VPhoneRestoreOps.resolveECID(explicit: "0xABCD", bundle: b) == "0xABCD")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveECIDFromPredictionFile() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let b = try bundle(in: root)
|
||||||
|
try "UDID=AAAA-1122334455667788\nECID=1122334455667788\n"
|
||||||
|
.write(to: root.appendingPathComponent("udid-prediction.txt"), atomically: true, encoding: .utf8)
|
||||||
|
#expect(VPhoneRestoreOps.resolveECID(explicit: nil, bundle: b) == "1122334455667788")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolveECIDNilWhenMissing() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let b = try bundle(in: root)
|
||||||
|
#expect(VPhoneRestoreOps.resolveECID(explicit: nil, bundle: b) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func isAEAEncryptedDetectsMagic() throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
let enc = dir.appendingPathComponent("a.aea")
|
||||||
|
try (Data([0x41, 0x45, 0x41, 0x31]) + Data([0, 1, 2])).write(to: enc)
|
||||||
|
let plain = dir.appendingPathComponent("b.dmg")
|
||||||
|
try Data([0, 0, 0, 0, 9]).write(to: plain)
|
||||||
|
#expect(try VPhoneRestoreOps.isAEAEncrypted(enc) == true)
|
||||||
|
#expect(try VPhoneRestoreOps.isAEAEncrypted(plain) == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct VMPickerTests {
|
||||||
|
/// A scripted stdin: pops one line per `read()` call.
|
||||||
|
private func reader(_ lines: [String?]) -> () -> String? {
|
||||||
|
var i = 0
|
||||||
|
return { defer { i += 1 }; return i < lines.count ? lines[i] : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func returnsProvidedNameUnchanged() throws {
|
||||||
|
let out = try VPhoneVMPicker.resolve(
|
||||||
|
provided: "myvm", names: ["a", "b"], libraryRoot: "/r", isInteractive: true,
|
||||||
|
read: { nil }, write: { _ in })
|
||||||
|
#expect(out == "myvm") // no prompt when a name is supplied
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nonInteractiveWithoutNameThrows() {
|
||||||
|
#expect(throws: VPhoneVMPickerError.notInteractive) {
|
||||||
|
_ = try VPhoneVMPicker.resolve(
|
||||||
|
provided: nil, names: ["a"], libraryRoot: "/r", isInteractive: false,
|
||||||
|
read: { nil }, write: { _ in })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyLibraryThrows() {
|
||||||
|
#expect(throws: VPhoneVMPickerError.emptyLibrary(root: "/r")) {
|
||||||
|
_ = try VPhoneVMPicker.resolve(
|
||||||
|
provided: nil, names: [], libraryRoot: "/r", isInteractive: true,
|
||||||
|
read: { nil }, write: { _ in })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func selectsByIndex() throws {
|
||||||
|
let out = try VPhoneVMPicker.resolve(
|
||||||
|
provided: nil, names: ["alpha", "beta", "gamma"], libraryRoot: "/r",
|
||||||
|
isInteractive: true, read: reader(["2"]), write: { _ in })
|
||||||
|
#expect(out == "beta")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func selectsByExactName() throws {
|
||||||
|
let out = try VPhoneVMPicker.resolve(
|
||||||
|
provided: nil, names: ["alpha", "beta"], libraryRoot: "/r",
|
||||||
|
isInteractive: true, read: reader(["alpha"]), write: { _ in })
|
||||||
|
#expect(out == "alpha")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func retriesThenSucceeds() throws {
|
||||||
|
// blank, out-of-range, bad-name, then a good index.
|
||||||
|
let out = try VPhoneVMPicker.resolve(
|
||||||
|
provided: nil, names: ["alpha", "beta"], libraryRoot: "/r",
|
||||||
|
isInteractive: true, read: reader(["", "9", "nope", "1"]), write: { _ in })
|
||||||
|
#expect(out == "alpha")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func eofAborts() {
|
||||||
|
#expect(throws: VPhoneVMPickerError.aborted) {
|
||||||
|
_ = try VPhoneVMPicker.resolve(
|
||||||
|
provided: nil, names: ["alpha"], libraryRoot: "/r", isInteractive: true,
|
||||||
|
read: { nil }, write: { _ in })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tooManyInvalidThrows() {
|
||||||
|
#expect(throws: VPhoneVMPickerError.invalidSelection) {
|
||||||
|
_ = try VPhoneVMPicker.resolve(
|
||||||
|
provided: nil, names: ["alpha"], libraryRoot: "/r", isInteractive: true,
|
||||||
|
maxRetries: 2, read: reader(["x", "y", "z"]), write: { _ in })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@testable import VPhoneCore
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
struct VerbosityTests {
|
||||||
|
@Test func countClampsToRange() {
|
||||||
|
#expect(VPhoneVerbosity(count: 0) == .quiet)
|
||||||
|
#expect(VPhoneVerbosity(count: 1) == .info)
|
||||||
|
#expect(VPhoneVerbosity(count: 2) == .debug)
|
||||||
|
#expect(VPhoneVerbosity(count: 3) == .trace)
|
||||||
|
#expect(VPhoneVerbosity(count: 9) == .trace) // clamp up
|
||||||
|
#expect(VPhoneVerbosity(count: -4) == .quiet) // clamp down
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func gatesAreMonotonic() {
|
||||||
|
#expect(VPhoneVerbosity.quiet.showsToolDetail == false)
|
||||||
|
#expect(VPhoneVerbosity.info.showsToolDetail == true)
|
||||||
|
#expect(VPhoneVerbosity.debug.tracesInternals == false)
|
||||||
|
#expect(VPhoneVerbosity.trace.tracesInternals == true)
|
||||||
|
#expect(VPhoneVerbosity.quiet < .info)
|
||||||
|
#expect(VPhoneVerbosity.debug < .trace)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user