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: "Img4tool", package: "libimg4-spm"),
|
||||
.product(name: "MachOKit", package: "MachOKit"),
|
||||
"VPhoneCore",
|
||||
],
|
||||
path: "sources/FirmwarePatcher"
|
||||
),
|
||||
.target(
|
||||
name: "VPhoneCore",
|
||||
path: "sources/VPhoneCore",
|
||||
linkerSettings: [
|
||||
.linkedFramework("Virtualization"),
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "vphone-cli",
|
||||
dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "Dynamic", package: "Dynamic"),
|
||||
"FirmwarePatcher",
|
||||
"VPhoneCore",
|
||||
],
|
||||
path: "sources/vphone-cli",
|
||||
linkerSettings: [
|
||||
@@ -46,5 +55,10 @@ let package = Package(
|
||||
dependencies: ["FirmwarePatcher"],
|
||||
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