mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-05 17:14:28 +00:00
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
182 lines
9.1 KiB
Swift
182 lines
9.1 KiB
Swift
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
|
|
}
|
|
}
|