mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-04-05 13:09:06 +08:00
Run SwiftFormat on firmware patcher Remove legacy Python firmware patchers Fix compare pipeline pyimg4 PATH handling Restore Python patchers and prefer fresh restore Update BinaryBuffer.swift Avoid double scanning in patcher apply Prefer Python TXM site before fallback Retarget TXM trustcache finder for 26.1 Remove legacy Python firmware patchers Fail fast on nested virtualization hosts Return nonzero on fatal boot startup Add amfidont helper for signed boot binary Stage AMFI boot args for next host reboot Add host preflight for boot entitlements Fail fast when boot entitlements are unavailable Switch firmware patch targets to Swift CLI Record real Swift firmware parity results Verify Swift firmware pipeline end-to-end parity Fix Swift firmware pipeline JB dry-run
396 lines
15 KiB
Swift
396 lines
15 KiB
Swift
// FirmwarePipeline.swift — Orchestrates full boot-chain firmware patching.
|
|
//
|
|
// Historical note: this file replaces the old Python firmware patcher implementation.
|
|
//
|
|
// Pipeline order: AVPBooter → iBSS → iBEC → LLB → TXM → Kernel → DeviceTree
|
|
//
|
|
// Variant selection (mirrors Makefile targets):
|
|
// .regular — base patchers only
|
|
// .dev — TXMDevPatcher instead of TXMPatcher
|
|
// .jb — TXMDevPatcher + IBootJBPatcher (iBSS) + KernelJBPatcher
|
|
|
|
import Darwin
|
|
import Foundation
|
|
|
|
/// Orchestrates firmware patching for all boot-chain components.
|
|
///
|
|
/// The pipeline discovers firmware files inside the VM directory (mirroring
|
|
/// `find_restore_dir` + `find_file` in the Python source), loads each file,
|
|
/// delegates to the appropriate ``Patcher``, and writes the patched data back.
|
|
///
|
|
/// The default loader mirrors the Python flow: it loads IM4P containers when
|
|
/// present, patches the extracted payload, and re-packages them on save.
|
|
public final class FirmwarePipeline {
|
|
// MARK: - Variant
|
|
|
|
public enum Variant: String, Sendable {
|
|
case regular
|
|
case dev
|
|
case jb
|
|
}
|
|
|
|
// MARK: - Firmware Loader (pluggable IM4P support)
|
|
|
|
/// Abstraction over IM4P vs raw firmware loading.
|
|
///
|
|
/// Provide a conforming type to override the default IM4P/raw handling.
|
|
public protocol FirmwareLoader {
|
|
/// Load firmware from `url`, returning the mutable payload data.
|
|
func load(from url: URL) throws -> Data
|
|
/// Save patched `data` back to `url`, repackaging as needed.
|
|
func save(_ data: Data, to url: URL) throws
|
|
}
|
|
|
|
/// Default loader: transparently handles IM4P containers and raw payloads.
|
|
public struct ContainerFirmwareLoader: FirmwareLoader {
|
|
public init() {}
|
|
public func load(from url: URL) throws -> Data {
|
|
try IM4PHandler.load(contentsOf: url).payload
|
|
}
|
|
|
|
public func save(_ data: Data, to url: URL) throws {
|
|
let original = try IM4PHandler.load(contentsOf: url).im4p
|
|
try IM4PHandler.save(patchedData: data, originalIM4P: original, to: url)
|
|
}
|
|
}
|
|
|
|
// MARK: - Component Descriptor
|
|
|
|
/// Describes a single firmware component in the pipeline.
|
|
struct ComponentDescriptor {
|
|
let name: String
|
|
/// If true, search paths are relative to the Restore directory.
|
|
/// If false, relative to the VM directory root.
|
|
let inRestoreDir: Bool
|
|
/// Glob patterns used to locate the file (tried in order).
|
|
let searchPatterns: [String]
|
|
/// Factories that create patchers to run in sequence for the loaded data.
|
|
let patcherFactories: [(Data, Bool) -> any Patcher]
|
|
}
|
|
|
|
// MARK: - Properties
|
|
|
|
let vmDirectory: URL
|
|
let variant: Variant
|
|
let verbose: Bool
|
|
let loader: any FirmwareLoader
|
|
|
|
// MARK: - Init
|
|
|
|
public init(
|
|
vmDirectory: URL,
|
|
variant: Variant = .regular,
|
|
verbose: Bool = true,
|
|
loader: (any FirmwareLoader)? = nil
|
|
) {
|
|
self.vmDirectory = vmDirectory
|
|
self.variant = variant
|
|
self.verbose = verbose
|
|
self.loader = loader ?? ContainerFirmwareLoader()
|
|
}
|
|
|
|
// MARK: - Pipeline Execution
|
|
|
|
/// Run the full patching pipeline.
|
|
///
|
|
/// Returns combined ``PatchRecord`` arrays from every component, in order.
|
|
/// Throws on the first component that fails to patch.
|
|
public func patchAll() throws -> [PatchRecord] {
|
|
let restoreDir = try findRestoreDirectory()
|
|
|
|
log("[*] VM directory: \(vmDirectory.path)")
|
|
log("[*] Restore directory: \(restoreDir.path)")
|
|
|
|
let components = buildComponentList()
|
|
log("[*] Patching \(components.count) boot-chain components ...")
|
|
|
|
var allRecords: [PatchRecord] = []
|
|
|
|
for component in components {
|
|
let baseDir = component.inRestoreDir ? restoreDir : vmDirectory
|
|
let fileURL = try findFile(in: baseDir, patterns: component.searchPatterns, label: component.name)
|
|
|
|
log("\n\(String(repeating: "=", count: 60))")
|
|
log(" \(component.name): \(fileURL.path)")
|
|
log(String(repeating: "=", count: 60))
|
|
|
|
// Load
|
|
let rawData = try loader.load(from: fileURL)
|
|
log(" format: \(rawData.count) bytes")
|
|
|
|
// Patch
|
|
var currentData = rawData
|
|
var componentRecords: [PatchRecord] = []
|
|
|
|
for makePatcher in component.patcherFactories {
|
|
let patcher = makePatcher(rawData, verbose)
|
|
let records = try patcher.findAll()
|
|
|
|
guard !records.isEmpty else {
|
|
throw PatcherError.patchSiteNotFound("\(component.name): no patches found")
|
|
}
|
|
|
|
let count = try patcher.apply()
|
|
log(" [+] \(count) \(component.name) patches applied")
|
|
|
|
componentRecords.append(contentsOf: records)
|
|
if let deviceTreePatcher = patcher as? DeviceTreePatcher {
|
|
currentData = deviceTreePatcher.patchedData
|
|
} else {
|
|
for record in records {
|
|
let range = record.fileOffset ..< record.fileOffset + record.patchedBytes.count
|
|
currentData.replaceSubrange(range, with: record.patchedBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
try loader.save(currentData, to: fileURL)
|
|
log(" [+] saved")
|
|
|
|
allRecords.append(contentsOf: componentRecords)
|
|
}
|
|
|
|
log("\n\(String(repeating: "=", count: 60))")
|
|
log(" All \(components.count) components patched successfully! (\(allRecords.count) total patches)")
|
|
log(String(repeating: "=", count: 60))
|
|
|
|
return allRecords
|
|
}
|
|
|
|
// MARK: - Component List Builder
|
|
|
|
/// Build the ordered component list based on the variant.
|
|
func buildComponentList() -> [ComponentDescriptor] {
|
|
var components: [ComponentDescriptor] = []
|
|
|
|
// 1. AVPBooter — always present, lives in VM root
|
|
components.append(ComponentDescriptor(
|
|
name: "AVPBooter",
|
|
inRestoreDir: false,
|
|
searchPatterns: ["AVPBooter*.bin"],
|
|
patcherFactories: [{ data, verbose in
|
|
AVPBooterPatcher(data: data, verbose: verbose)
|
|
}]
|
|
))
|
|
|
|
// 2. iBSS — JB variant runs the base iBSS patcher, then the nonce-skip extension.
|
|
components.append(ComponentDescriptor(
|
|
name: "iBSS",
|
|
inRestoreDir: true,
|
|
searchPatterns: ["Firmware/dfu/iBSS.vresearch101.RELEASE.im4p"],
|
|
patcherFactories: {
|
|
if variant == .jb {
|
|
return [
|
|
{ data, verbose in
|
|
IBootPatcher(data: data, mode: .ibss, verbose: verbose)
|
|
},
|
|
{ data, verbose in
|
|
IBootJBPatcher(data: data, mode: .ibss, verbose: verbose)
|
|
},
|
|
]
|
|
}
|
|
return [{ data, verbose in
|
|
IBootPatcher(data: data, mode: .ibss, verbose: verbose)
|
|
}]
|
|
}()
|
|
))
|
|
|
|
// 3. iBEC — same for all variants
|
|
components.append(ComponentDescriptor(
|
|
name: "iBEC",
|
|
inRestoreDir: true,
|
|
searchPatterns: ["Firmware/dfu/iBEC.vresearch101.RELEASE.im4p"],
|
|
patcherFactories: [{ data, verbose in
|
|
IBootPatcher(data: data, mode: .ibec, verbose: verbose)
|
|
}]
|
|
))
|
|
|
|
// 4. LLB — same for all variants
|
|
components.append(ComponentDescriptor(
|
|
name: "LLB",
|
|
inRestoreDir: true,
|
|
searchPatterns: ["Firmware/all_flash/LLB.vresearch101.RELEASE.im4p"],
|
|
patcherFactories: [{ data, verbose in
|
|
IBootPatcher(data: data, mode: .llb, verbose: verbose)
|
|
}]
|
|
))
|
|
|
|
// 5. TXM — dev/jb variants use TXMDevPatcher (adds entitlements, debugger, dev-mode)
|
|
components.append(ComponentDescriptor(
|
|
name: "TXM",
|
|
inRestoreDir: true,
|
|
searchPatterns: ["Firmware/txm.iphoneos.research.im4p"],
|
|
patcherFactories: [{ [variant] data, verbose in
|
|
if variant == .dev || variant == .jb {
|
|
return TXMDevPatcher(data: data, verbose: verbose)
|
|
}
|
|
return TXMPatcher(data: data, verbose: verbose)
|
|
}]
|
|
))
|
|
|
|
// 6. Kernel — JB variant runs base kernel patches first, then JB extensions.
|
|
components.append(ComponentDescriptor(
|
|
name: "kernelcache",
|
|
inRestoreDir: true,
|
|
searchPatterns: ["kernelcache.research.vphone600"],
|
|
patcherFactories: {
|
|
if variant == .jb {
|
|
return [
|
|
{ data, verbose in
|
|
KernelPatcher(data: data, verbose: verbose)
|
|
},
|
|
{ data, verbose in
|
|
KernelJBPatcher(data: data, verbose: verbose)
|
|
},
|
|
]
|
|
}
|
|
return [{ data, verbose in
|
|
KernelPatcher(data: data, verbose: verbose)
|
|
}]
|
|
}()
|
|
))
|
|
|
|
// 7. DeviceTree — same for all variants
|
|
components.append(ComponentDescriptor(
|
|
name: "DeviceTree",
|
|
inRestoreDir: true,
|
|
searchPatterns: ["Firmware/all_flash/DeviceTree.vphone600ap.im4p"],
|
|
patcherFactories: [{ data, verbose in
|
|
DeviceTreePatcher(data: data, verbose: verbose)
|
|
}]
|
|
))
|
|
|
|
return components
|
|
}
|
|
|
|
// MARK: - File Discovery
|
|
|
|
/// Find the `*Restore*` subdirectory inside the VM directory.
|
|
/// Mirrors Python `find_restore_dir`.
|
|
func findRestoreDirectory() throws -> URL {
|
|
let fm = FileManager.default
|
|
let contents = try fm.contentsOfDirectory(
|
|
at: vmDirectory,
|
|
includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey]
|
|
)
|
|
.filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true }
|
|
.filter { $0.lastPathComponent.contains("Restore") }
|
|
.sorted(by: compareRestoreDirectories)
|
|
|
|
guard let restoreDir = contents.first else {
|
|
throw PatcherError.fileNotFound("No *Restore* directory found in \(vmDirectory.path). Run prepare_firmware first.")
|
|
}
|
|
return restoreDir
|
|
}
|
|
|
|
private func compareRestoreDirectories(_ lhs: URL, _ rhs: URL) -> Bool {
|
|
let leftName = lhs.lastPathComponent
|
|
let rightName = rhs.lastPathComponent
|
|
|
|
if let left = parseRestoreDirectoryName(leftName),
|
|
let right = parseRestoreDirectoryName(rightName)
|
|
{
|
|
if left.version != right.version {
|
|
return left.version.lexicographicallyPrecedes(right.version, by: >)
|
|
}
|
|
if left.build != right.build {
|
|
return left.build.compare(right.build, options: .numeric) == .orderedDescending
|
|
}
|
|
}
|
|
|
|
let leftDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
|
let rightDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
|
if leftDate != rightDate {
|
|
return leftDate > rightDate
|
|
}
|
|
return leftName > rightName
|
|
}
|
|
|
|
private func parseRestoreDirectoryName(_ name: String) -> (version: [Int], build: String)? {
|
|
let pattern = #"_([0-9]+(?:\.[0-9]+)*)_([0-9A-Za-z]+)_Restore$"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
|
let range = NSRange(name.startIndex..., in: name)
|
|
guard let match = regex.firstMatch(in: name, range: range),
|
|
match.numberOfRanges == 3,
|
|
let versionRange = Range(match.range(at: 1), in: name),
|
|
let buildRange = Range(match.range(at: 2), in: name)
|
|
else { return nil }
|
|
|
|
let version = name[versionRange]
|
|
.split(separator: ".")
|
|
.compactMap { Int($0) }
|
|
let build = String(name[buildRange])
|
|
guard !version.isEmpty else { return nil }
|
|
return (version, build)
|
|
}
|
|
|
|
/// Find a firmware file by trying glob-style patterns under `baseDir`.
|
|
/// Mirrors Python `find_file`.
|
|
func findFile(in baseDir: URL, patterns: [String], label: String) throws -> URL {
|
|
let fm = FileManager.default
|
|
for pattern in patterns {
|
|
if pattern.contains("*") || pattern.contains("?") || pattern.contains("[") {
|
|
var matches: [URL] = []
|
|
if !pattern.contains("/") {
|
|
let urls = try fm.contentsOfDirectory(at: baseDir, includingPropertiesForKeys: [.isRegularFileKey])
|
|
for url in urls where fnmatch(pattern, url.lastPathComponent, 0) == 0 {
|
|
matches.append(url)
|
|
}
|
|
} else {
|
|
let enumerator = fm.enumerator(at: baseDir, includingPropertiesForKeys: [.isRegularFileKey])
|
|
while let url = enumerator?.nextObject() as? URL {
|
|
guard url.path.hasPrefix(baseDir.path + "/") else { continue }
|
|
let rel = String(url.path.dropFirst(baseDir.path.count + 1))
|
|
if fnmatch(pattern, rel, 0) == 0 {
|
|
matches.append(url)
|
|
}
|
|
}
|
|
}
|
|
if let first = matches.sorted(by: { $0.path < $1.path }).first {
|
|
return first
|
|
}
|
|
} else {
|
|
let candidate = baseDir.appendingPathComponent(pattern)
|
|
if fm.fileExists(atPath: candidate.path) {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
let searched = patterns.map { baseDir.appendingPathComponent($0).path }.joined(separator: "\n ")
|
|
throw PatcherError.fileNotFound("\(label) not found. Searched:\n \(searched)")
|
|
}
|
|
|
|
// MARK: - Data Extraction
|
|
|
|
/// Extract the patched data from a patcher's internal buffer.
|
|
///
|
|
/// All current patchers own a ``BinaryBuffer`` whose `.data` property
|
|
/// holds the mutated bytes after `apply()`. We use protocol-based
|
|
/// access where possible and fall back to manual patch application.
|
|
func extractPatchedData(from patcher: any Patcher, fallback: Data, records: [PatchRecord]) -> Data {
|
|
// Try known patcher types that expose their buffer.
|
|
if let avp = patcher as? AVPBooterPatcher { return avp.buffer.data }
|
|
if let iboot = patcher as? IBootPatcher { return iboot.buffer.data }
|
|
if let txm = patcher as? TXMPatcher { return txm.buffer.data }
|
|
if let kp = patcher as? KernelPatcher { return kp.buffer.data }
|
|
if let kjb = patcher as? KernelJBPatcher { return kjb.buffer.data }
|
|
if let dt = patcher as? DeviceTreePatcher { return dt.patchedData }
|
|
|
|
// Fallback: apply records manually to a copy of the original data.
|
|
var data = fallback
|
|
for record in records {
|
|
let range = record.fileOffset ..< record.fileOffset + record.patchedBytes.count
|
|
data.replaceSubrange(range, with: record.patchedBytes)
|
|
}
|
|
return data
|
|
}
|
|
|
|
// MARK: - Logging
|
|
|
|
func log(_ message: String) {
|
|
if verbose {
|
|
print(message)
|
|
}
|
|
}
|
|
}
|