mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-05 09:04:27 +00:00
The ramdisk-based install (build/send an SSH ramdisk, iproxy-forward, then
push CFW files over SSH and flip the boot snapshot with snaputil in-VM) has
been fully replaced by the host-mount path: cfw_install_host.sh mounts the
VM's Disk.img on the host, places every file locally, and flips the boot
snapshot offline via tools/apfs_snap_rename.py. This removes all remaining
ramdisk generation, delivery, and usage — no legacy fallback.
Deleted:
- scripts/ramdisk_build.py, scripts/ramdisk_send.sh
Renamed:
- scripts/cfw_host_mode.sh -> scripts/cfw_transport.sh (was a conditional
"host-mode override"; now the sole, unconditionally-sourced transport)
setup_machine.sh: drop the USE_RAMDISK_CFW=1 branch and every iproxy/ramdisk
helper (usbmux UDID resolution, port picking, start/stop iproxy, wait-for-
ramdisk-ssh), all RAMDISK_*/IPROXY_* vars, the cleanup() iproxy handling, and
the orphaned cfw_install_target var. Only the host-mount cfw_install_host call
remains.
cfw_install{,_dev,_jb,_exp}.sh: delete the SSH transport (SSH_* vars, SSH_OPTS,
sshpass prereq/_sshpass/_ssh_retry, ssh_cmd/scp_to/scp_from/remote_file_exists/
remote_mount, wait_for_device_ssh_ready) and the dead ramdisk-mechanism body
blocks (snaputil snapshot flip, dropbearkey host-key pre-generation, halt-over-
SSH, CFW_SKIP_HALT). The transport is now sourced unconditionally from
cfw_transport.sh. dropbear -R generates host keys at first boot; the offline
apfs_snap_rename.py does the boot-source flip. Dropped the vestigial
CFW_HOST_MODE gate.
Also: pymobiledevice3_bridge.py (ramdisk-send command already gone), Makefile
(ramdisk targets/help/IRECOVERY_ECID removed), README + ja/ko/zh (install flow
rewritten to host-mount), AGENTS.md/CLAUDE.md architecture tree, and stale
comments in vphone_jb_setup.sh, VPhoneCLI.swift, apfs_snap_rename.py,
cfw_patch_post_restore_dt.py.
Verified booting via make setup_machine.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XZVS9oqVNmHJzpb8mFtKJx
290 lines
10 KiB
Swift
290 lines
10 KiB
Swift
import ArgumentParser
|
|
import FirmwarePatcher
|
|
import Foundation
|
|
|
|
struct VPhoneCLI: ParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "vphone-cli",
|
|
abstract: "Boot a virtual iPhone or patch firmware with the Swift pipeline",
|
|
subcommands: [VPhoneBootCLI.self, PatchFirmwareCLI.self, PatchComponentCLI.self],
|
|
defaultSubcommand: VPhoneBootCLI.self
|
|
)
|
|
}
|
|
|
|
struct VPhoneBootCLI: ParsableCommand {
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "boot",
|
|
abstract: "Boot a virtual iPhone (PV=3)",
|
|
discussion: """
|
|
Creates a Virtualization.framework VM with platform version 3 (vphone)
|
|
and boots it from a manifest plist that describes all paths and hardware.
|
|
|
|
Requires:
|
|
- macOS 15+ (Sequoia or later)
|
|
- SIP/AMFI disabled
|
|
- Signed with vphone entitlements (done automatically by wrapper script)
|
|
|
|
Example:
|
|
vphone-cli --config ./config.plist
|
|
"""
|
|
)
|
|
|
|
@Option(
|
|
help: "Path to VM manifest plist (config.plist). Required.",
|
|
transform: URL.init(fileURLWithPath:)
|
|
)
|
|
var config: URL
|
|
|
|
@Flag(help: "Boot into DFU mode")
|
|
var dfu: Bool = false
|
|
|
|
@Option(help: "Kernel GDB debug stub port on host (omit for system-assigned port; valid: 6000...65535)")
|
|
var kernelDebugPort: Int?
|
|
|
|
@Option(help: "Path to signed vphoned binary for guest auto-update")
|
|
var vphonedBin: String = ".vphoned.signed"
|
|
|
|
@Option(help: "Firmware variant to execute.")
|
|
var variant: PatchFirmwareCLI.VariantOption = .regular
|
|
|
|
@Option(
|
|
help: "Automatically install the given IPA/TIPA after the guest control channel connects. Unavailable with --dfu.",
|
|
transform: URL.init(fileURLWithPath:)
|
|
)
|
|
var installIPA: URL?
|
|
|
|
@Flag(name: .customLong("no-vphoned"), help: "Exclude vphoned usage (patchless-only).")
|
|
var noVphoned: Bool = false
|
|
|
|
/// DFU mode runs headless (no GUI).
|
|
var noGraphics: Bool {
|
|
dfu
|
|
}
|
|
|
|
var installPackageURL: URL? {
|
|
installIPA?.standardizedFileURL
|
|
}
|
|
|
|
mutating func validate() throws {
|
|
if dfu, let packageURL = installPackageURL {
|
|
throw ValidationError(
|
|
"`--install-ipa` is unavailable with `--dfu` because DFU mode does not start the guest control channel: \(packageURL.path)"
|
|
)
|
|
}
|
|
|
|
guard let packageURL = installPackageURL else { return }
|
|
|
|
guard FileManager.default.fileExists(atPath: packageURL.path) else {
|
|
throw ValidationError("`--install-ipa` file does not exist: \(packageURL.path)")
|
|
}
|
|
|
|
guard VPhoneInstallPackage.isSupportedFile(packageURL) else {
|
|
throw ValidationError(
|
|
"`--install-ipa` only supports .ipa or .tipa packages: \(packageURL.lastPathComponent)"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Resolve final options by merging manifest values.
|
|
func resolveOptions() throws -> VPhoneVirtualMachine.Options {
|
|
let manifest = try VPhoneVirtualMachineManifest.load(from: config)
|
|
print("[vphone] Loaded VM manifest from \(config.path)")
|
|
|
|
let vmDir = config.deletingLastPathComponent()
|
|
|
|
return VPhoneVirtualMachine.Options(
|
|
configURL: config,
|
|
romURL: manifest.romImages != nil ? manifest.resolve(path: manifest.romImages!.avpBooter, in: vmDir) : nil,
|
|
nvramURL: manifest.resolve(path: manifest.nvramStorage, in: vmDir),
|
|
diskURL: manifest.resolve(path: manifest.diskImage, in: vmDir),
|
|
cpuCount: Int(manifest.cpuCount),
|
|
memorySize: manifest.memorySize,
|
|
sepStorageURL: manifest.resolve(path: manifest.sepStorage, in: vmDir),
|
|
sepRomURL: manifest.romImages != nil ? manifest.resolve(path: manifest.romImages!.avpSEPBooter, in: vmDir) : nil,
|
|
screenWidth: manifest.screenConfig.width,
|
|
screenHeight: manifest.screenConfig.height,
|
|
screenPPI: manifest.screenConfig.pixelsPerInch,
|
|
screenScale: manifest.screenConfig.scale,
|
|
kernelDebugPort: kernelDebugPort,
|
|
variant: variant.virtualMachineVariant,
|
|
noVphoned: self.noVphoned
|
|
)
|
|
}
|
|
|
|
mutating func run() throws {}
|
|
}
|
|
|
|
struct PatchFirmwareCLI: ParsableCommand {
|
|
enum VariantOption: String, CaseIterable, ExpressibleByArgument {
|
|
case less
|
|
case regular
|
|
case dev
|
|
case jb
|
|
case exp
|
|
|
|
var pipelineVariant: FirmwarePipeline.Variant {
|
|
switch self {
|
|
case .less: .less
|
|
case .regular: .regular
|
|
case .dev: .dev
|
|
case .jb: .jb
|
|
case .exp: .exp
|
|
}
|
|
}
|
|
|
|
var virtualMachineVariant: VPhoneVirtualMachine.Variant {
|
|
switch self {
|
|
case .less: .less
|
|
case .regular: .regular
|
|
case .dev: .dev
|
|
case .jb: .jb
|
|
case .exp: .exp
|
|
}
|
|
}
|
|
}
|
|
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "patch-firmware",
|
|
abstract: "Patch boot-chain firmware in a VM directory using the Swift pipeline"
|
|
)
|
|
|
|
@Option(
|
|
name: [.customLong("vm-directory"), .customShort("d")],
|
|
help: "Path to the VM directory that contains the *Restore* folder.",
|
|
transform: URL.init(fileURLWithPath:)
|
|
)
|
|
var vmDirectory: URL
|
|
|
|
@Option(help: "Firmware variant to patch.")
|
|
var variant: VariantOption = .regular
|
|
|
|
@Option(
|
|
name: .customLong("records-out"),
|
|
help: "Optional path to write emitted PatchRecord JSON."
|
|
)
|
|
var recordsOut: String?
|
|
|
|
@Flag(name: .customLong("quiet"), help: "Suppress per-component progress output.")
|
|
var quiet: Bool = false
|
|
|
|
@Flag(name: .customLong("no-binpack"), help: "Exclude the SSH, VNC, ... binaries from being installed (patchless-only).")
|
|
var noBinpack: Bool = false
|
|
|
|
@Flag(name: .customLong("no-vphoned"), help: "Exclude vphoned from being installed (patchless-only).")
|
|
var noVphoned: Bool = false
|
|
|
|
mutating func run() throws {
|
|
let pipeline = FirmwarePipeline(
|
|
vmDirectory: vmDirectory,
|
|
variant: variant.pipelineVariant,
|
|
verbose: !quiet,
|
|
noBinpack: noBinpack,
|
|
noVphoned: noVphoned
|
|
)
|
|
let records = try pipeline.patchAll()
|
|
|
|
if let recordsOut {
|
|
let url = URL(fileURLWithPath: recordsOut)
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
try encoder.encode(records).write(to: url)
|
|
print("[patch-firmware] wrote \(records.count) patch records to \(url.path)")
|
|
} else {
|
|
print("[patch-firmware] applied \(records.count) patches for \(variant.rawValue)")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PatchComponentCLI: ParsableCommand {
|
|
enum ComponentOption: String, CaseIterable, ExpressibleByArgument {
|
|
case txm
|
|
case kernelBase = "kernel-base"
|
|
// TESTING/DIAGNOSTICS ONLY — not part of any production flow.
|
|
// Production JB patching runs through `patch-firmware --variant jb`; this
|
|
// standalone option exists so `tests/test_jb_kernel_patches.sh` can run the
|
|
// JB kernel layer over a single kernelcache and dump records via --records-out.
|
|
// (txm / kernel-base, by contrast, are standalone single-component patchers.)
|
|
case kernelJB = "kernel-jb"
|
|
}
|
|
|
|
static let configuration = CommandConfiguration(
|
|
commandName: "patch-component",
|
|
abstract: "Patch a single firmware component payload and write the patched raw bytes"
|
|
)
|
|
|
|
@Option(help: "Component to patch.")
|
|
var component: ComponentOption
|
|
|
|
@Option(
|
|
name: .customLong("input"),
|
|
help: "Path to the source firmware file (IM4P or raw).",
|
|
transform: URL.init(fileURLWithPath:)
|
|
)
|
|
var input: URL
|
|
|
|
@Option(
|
|
name: .customLong("output"),
|
|
help: "Path to write the patched raw payload bytes.",
|
|
transform: URL.init(fileURLWithPath:)
|
|
)
|
|
var output: URL
|
|
|
|
@Flag(name: .customLong("quiet"), help: "Suppress per-patch progress output.")
|
|
var quiet: Bool = false
|
|
|
|
@Option(
|
|
name: .customLong("records-out"),
|
|
help: "Optional path to write emitted PatchRecord JSON (for fast-loop validation)."
|
|
)
|
|
var recordsOut: String?
|
|
|
|
mutating func run() throws {
|
|
let payload = try IM4PHandler.load(contentsOf: input).payload
|
|
let count: Int
|
|
let patchedData: Data
|
|
var records: [PatchRecord] = []
|
|
|
|
switch component {
|
|
case .txm:
|
|
let patcher = TXMPatcher(data: payload, verbose: !quiet)
|
|
count = try patcher.apply()
|
|
patchedData = patcher.patchedData
|
|
|
|
case .kernelBase:
|
|
let patcher = KernelPatcher(data: payload, verbose: !quiet)
|
|
count = try patcher.apply()
|
|
patchedData = patcher.buffer.data
|
|
records = patcher.patches
|
|
|
|
case .kernelJB:
|
|
// Mirrors the pipeline's jb kernel layer. In FirmwarePipeline each kernel
|
|
// patcher runs on the *original* payload independently, so running
|
|
// KernelJBPatcher standalone faithfully reproduces JB hook behavior
|
|
// without the base patcher or the rest of the boot chain.
|
|
let patcher = KernelJBPatcher(data: payload, verbose: !quiet)
|
|
count = try patcher.apply()
|
|
patchedData = patcher.buffer.data
|
|
records = patcher.patches
|
|
}
|
|
|
|
let outputDir = output.deletingLastPathComponent()
|
|
try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
|
|
try patchedData.write(to: output)
|
|
|
|
if let recordsOut {
|
|
let url = URL(fileURLWithPath: recordsOut)
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
try encoder.encode(records).write(to: url)
|
|
if !quiet {
|
|
print("[patch-component] wrote \(records.count) patch records to \(url.path)")
|
|
}
|
|
}
|
|
|
|
if !quiet {
|
|
print("[patch-component] applied \(count) patches for \(component.rawValue)")
|
|
print("[patch-component] wrote patched payload to \(output.path)")
|
|
}
|
|
}
|
|
}
|