mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-10-02 08:04:32 +08:00
Keep firmware work inside machine directories and clean up mounts
This commit is contained in:
@@ -55,11 +55,10 @@ The online restore obtains its ticket in process. For an offline restore, see `v
|
||||
|
||||
| Default path | Contents |
|
||||
| --- | --- |
|
||||
| `~/.vphone/VMs/` | One bundle per VM, including its disk and `config.plist` |
|
||||
| `~/.vphone/ipsws/` | Cached source IPSWs |
|
||||
| `~/.vphone/tools/` | Cached firmware patching tools and artifacts |
|
||||
| `~/.vphone/machines/<name>/` | One VM, including its disk, `config.plist`, and patch work files |
|
||||
| `~/.vphone/machines/<name>/.ipsw-cache/` | Remote source IPSWs downloaded for that VM; local IPSWs are read in place |
|
||||
|
||||
`VPHONE_ROOT` relocates the complete tree. `VPHONE_LIBRARY_ROOT` takes precedence for the VM library alone. Source IPSWs remain cached; the prepared restore tree is removed after a successful `vm create` unless `--keep-artifacts` is set.
|
||||
`VPHONE_ROOT` relocates the VM library. `VPHONE_LIBRARY_ROOT` takes precedence for the library alone. Downloaded IPSWs remain cached inside their VM; the prepared restore tree is removed after a successful `vm create` unless `--keep-artifacts` is set. JB patching does not create `~/.vphone/tools`.
|
||||
|
||||
```sh
|
||||
vphone-cli vm list
|
||||
|
||||
@@ -90,7 +90,7 @@ Boot the VM first, leaving it running. On a host with AMFI relaxed at boot,
|
||||
that is just:
|
||||
|
||||
```
|
||||
.../vphone-vm --config ~/.vphone/VMs/<name>/config.plist --dfu
|
||||
.../vphone-vm --config ~/.vphone/machines/<name>/config.plist --dfu
|
||||
```
|
||||
|
||||
Where amfid still refuses it, allow that one binary first — in another terminal,
|
||||
|
||||
@@ -7,6 +7,16 @@ bundle="${1:-$root/.build/XcodeBundle/Build/Products/Debug/VPhone.bundle}"
|
||||
macos="$bundle/Contents/MacOS"
|
||||
resources="$bundle/Contents/Resources"
|
||||
|
||||
file_copy_spawns="$(/usr/bin/find "$root/VPhoneExecutable" "$root/VPhoneKit" \
|
||||
"$root/VPhoneDaemon" "$root/VPhoneGuestComponents" \
|
||||
-type d \( -name Build -o -name .build -o -name '*Tests' -o -name '*TestFixtures' \) -prune -o \
|
||||
-type f -name '*.swift' -exec /usr/bin/grep -nE '"/(usr/)?bin/(cp|mv|rm)"' {} + || true)"
|
||||
[[ -z "$file_copy_spawns" ]] || {
|
||||
print -u2 "Host file operations must use in-process file APIs, not spawned cp/mv/rm:"
|
||||
print -u2 -- "$file_copy_spawns"
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -d "$bundle" ]] || { print -u2 "Missing Xcode bundle: $bundle"; exit 1; }
|
||||
|
||||
for name in vphone-vm vphone-cli VPhoneEscalator vphoned vphoned.signed \
|
||||
|
||||
+12
-6
@@ -26,12 +26,12 @@ public final class CryptexFilesystemPatcher: Patcher {
|
||||
public let restoreDir: URL
|
||||
public let verbose: Bool
|
||||
public let noBinpack: Bool
|
||||
let vphoneCliDirectory = URL(filePath: "./")
|
||||
let resources = VPhoneResources.resolve()
|
||||
|
||||
var buildManiest: Data
|
||||
var rebuiltData: Data?
|
||||
var tmpDirectories: [URL] = []
|
||||
var attachedDevices: Set<String> = []
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
@@ -48,8 +48,15 @@ public final class CryptexFilesystemPatcher: Patcher {
|
||||
}
|
||||
|
||||
deinit {
|
||||
for tmp in tmpDirectories {
|
||||
try? FileManager.default.removeItem(at: tmp)
|
||||
for device in Array(attachedDevices) {
|
||||
try? detachImage(deviceNode: device)
|
||||
}
|
||||
if attachedDevices.isEmpty {
|
||||
for tmp in tmpDirectories {
|
||||
try? FileManager.default.removeItem(at: tmp)
|
||||
}
|
||||
} else {
|
||||
fputs("warning: filesystem patch image is still attached; left VM work files in \(restoreDir.path)\n", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,9 +184,8 @@ public final class CryptexFilesystemPatcher: Patcher {
|
||||
}
|
||||
|
||||
func createTmpDir() throws -> URL {
|
||||
let tmpDir = FileManager.default.temporaryDirectory
|
||||
.appending(path: "vphone-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
|
||||
let tmpDir = restoreDir.appending(path: ".filesystem-patch-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
|
||||
tmpDirectories.append(tmpDir)
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
+35
-3
@@ -167,8 +167,23 @@ extension CryptexFilesystemPatcher {
|
||||
throw ProcessError.failed(process.terminationStatus, output)
|
||||
}
|
||||
|
||||
let root = try parsePlist(data: data)
|
||||
let root: PlistDict
|
||||
do {
|
||||
root = try parsePlist(data: data)
|
||||
} catch {
|
||||
if let output = String(data: data, encoding: .utf8),
|
||||
let range = output.range(of: #"/dev/disk[0-9]+"#, options: .regularExpression)
|
||||
{
|
||||
_ = try? runProcess("/usr/bin/hdiutil", ["detach", "-force", String(output[range])])
|
||||
}
|
||||
throw error
|
||||
}
|
||||
guard let entries = root["system-entities"] as? [Any] else {
|
||||
if let output = String(data: data, encoding: .utf8),
|
||||
let range = output.range(of: #"/dev/disk[0-9]+"#, options: .regularExpression)
|
||||
{
|
||||
_ = try? runProcess("/usr/bin/hdiutil", ["detach", "-force", String(output[range])])
|
||||
}
|
||||
throw FirmwareManifest.ManifestError.missingKey("system-entities")
|
||||
}
|
||||
for entry in entries {
|
||||
@@ -180,16 +195,33 @@ extension CryptexFilesystemPatcher {
|
||||
}
|
||||
let device = entry["dev-entry"] as? String ?? ""
|
||||
let mountPoint = entry["mount-point"] as? String ?? ""
|
||||
guard !device.isEmpty, !mountPoint.isEmpty else { continue }
|
||||
|
||||
attachedDevices.insert(device)
|
||||
if forceRW {
|
||||
_ = try runProcess("/sbin/mount", ["-u", "-w", device, mountPoint])
|
||||
do {
|
||||
_ = try runProcess("/sbin/mount", ["-u", "-w", device, mountPoint])
|
||||
} catch {
|
||||
try? detachImage(deviceNode: device)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return (device, mountPoint)
|
||||
}
|
||||
if let device = (entries.compactMap { ($0 as? PlistDict)?["dev-entry"] as? String })
|
||||
.first(where: { $0.hasPrefix("/dev/disk") })
|
||||
{
|
||||
_ = try? runProcess("/usr/bin/hdiutil", ["detach", "-force", device])
|
||||
}
|
||||
throw FirmwareManifest.ManifestError.missingKey("dev-entry or mount-point")
|
||||
}
|
||||
|
||||
func detachImage(deviceNode: String) throws {
|
||||
_ = try runProcess("/usr/bin/hdiutil", ["detach", deviceNode])
|
||||
do {
|
||||
_ = try runProcess("/usr/bin/hdiutil", ["detach", deviceNode])
|
||||
} catch {
|
||||
_ = try runProcess("/usr/bin/hdiutil", ["detach", "-force", deviceNode])
|
||||
}
|
||||
attachedDevices.remove(deviceNode)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -54,11 +54,9 @@ extension CryptexFilesystemPatcher {
|
||||
|
||||
private func identifyApfsSealvolume() throws -> URL {
|
||||
let iosVersion = try getProductVersion()
|
||||
// VPHONE_SEAL_DIR (set by the Command's `fw prepare`/`fw patch`) must agree with
|
||||
// wherever fw_prepare.sh's download_apfs_sealvolume() wrote the file; unset
|
||||
// (manual development flow) falls back to the historical repo-relative `.tools/`.
|
||||
// The optional filesystem merge reads a seal tool staged inside the VM.
|
||||
let sealDir = ProcessInfo.processInfo.environment["VPHONE_SEAL_DIR"].map { URL(fileURLWithPath: $0) }
|
||||
?? vphoneCliDirectory.appending(path: ".tools")
|
||||
?? restoreDir.appendingPathComponent(".tools")
|
||||
let path = sealDir.appendingPathComponent("apfs_sealvolume_\(iosVersion)")
|
||||
guard FileManager.default.fileExists(atPath: path.path) else {
|
||||
throw FirmwareManifest.ManifestError.fileNotFound(path.path)
|
||||
|
||||
+63
-10
@@ -70,9 +70,27 @@ struct VPhoneCustomFirmwareInstaller {
|
||||
.split(whereSeparator: \.isWhitespace).first.map(String.init),
|
||||
baseDisk.hasPrefix("/dev/disk")
|
||||
else {
|
||||
if let range = attached.range(of: #"/dev/disk[0-9]+"#, options: .regularExpression) {
|
||||
_ = try? tool("/usr/bin/hdiutil", ["detach", "-force", String(attached[range])], quiet: true)
|
||||
}
|
||||
throw ValidationError("hdiutil attached no disk device")
|
||||
}
|
||||
defer { _ = try? tool("/usr/bin/hdiutil", ["detach", baseDisk], quiet: true) }
|
||||
var diskAttached = true
|
||||
var workToClean: URL?
|
||||
defer {
|
||||
if diskAttached,
|
||||
(try? tool("/usr/bin/hdiutil", ["detach", baseDisk], quiet: true)) == nil
|
||||
{
|
||||
_ = try? tool("/usr/bin/hdiutil", ["detach", "-force", baseDisk], quiet: true)
|
||||
}
|
||||
if let workToClean {
|
||||
do {
|
||||
try removeWorkDirectory(workToClean)
|
||||
} catch {
|
||||
fputs("warning: left CFW work directory at \(workToClean.path): \(error)\n", stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let info = try tool("/usr/sbin/diskutil", ["info", "-plist", "\(baseDisk)s1"], quiet: true)
|
||||
guard let plist = try PropertyListSerialization.propertyList(
|
||||
@@ -87,21 +105,36 @@ struct VPhoneCustomFirmwareInstaller {
|
||||
let work = bundle.appendingPathComponent(".cfw-native-\(UUID().uuidString)")
|
||||
let system = work.appendingPathComponent("system")
|
||||
let data = work.appendingPathComponent("data")
|
||||
try fm.createDirectory(at: system, withIntermediateDirectories: true)
|
||||
try fm.createDirectory(at: data, withIntermediateDirectories: true)
|
||||
try fm.createDirectory(at: work, withIntermediateDirectories: false)
|
||||
workToClean = work
|
||||
var systemMounted = false
|
||||
var dataMounted = false
|
||||
defer {
|
||||
_ = try? tool("/sbin/umount", [data.path], quiet: true)
|
||||
_ = try? tool("/sbin/umount", [system.path], quiet: true)
|
||||
try? fm.removeItem(at: work)
|
||||
if dataMounted,
|
||||
(try? tool("/sbin/umount", [data.path], quiet: true)) == nil
|
||||
{
|
||||
_ = try? tool("/sbin/umount", ["-f", data.path], quiet: true)
|
||||
}
|
||||
if systemMounted,
|
||||
(try? tool("/sbin/umount", [system.path], quiet: true)) == nil
|
||||
{
|
||||
_ = try? tool("/sbin/umount", ["-f", system.path], quiet: true)
|
||||
}
|
||||
}
|
||||
try fm.createDirectory(at: system, withIntermediateDirectories: false)
|
||||
try fm.createDirectory(at: data, withIntermediateDirectories: false)
|
||||
try tool("/sbin/mount_apfs", ["-o", "rw", "/dev/\(container)s1", system.path])
|
||||
systemMounted = true
|
||||
try tool("/sbin/mount_apfs", ["-o", "rw", "/dev/\(container)s3", data.path])
|
||||
dataMounted = true
|
||||
print("[*] JB system install: \(bundle.lastPathComponent)")
|
||||
try installMounted(system: system, data: data, work: work)
|
||||
_ = try tool("/sbin/umount", [data.path])
|
||||
dataMounted = false
|
||||
_ = try tool("/sbin/umount", [system.path])
|
||||
systemMounted = false
|
||||
_ = try tool("/usr/bin/hdiutil", ["detach", baseDisk], quiet: true)
|
||||
try fm.removeItem(at: work)
|
||||
diskAttached = false
|
||||
try VPhoneAPFSSnapshot.rename(imageAt: diskImage)
|
||||
print("[+] JB system install complete; vphoned is installed, no package bootstrap was staged")
|
||||
}
|
||||
@@ -209,6 +242,7 @@ struct VPhoneCustomFirmwareInstaller {
|
||||
buildManifest: restore.appendingPathComponent("iPhone-BuildManifest.plist"),
|
||||
)
|
||||
let encrypted = restore.appendingPathComponent(paths.systemOS)
|
||||
let appImage = restore.appendingPathComponent(paths.appOS)
|
||||
let plain = work.appendingPathComponent("SystemOS.dmg")
|
||||
let key = try vphoneRunBlocking { try await VPhoneAEA.symmetricKey(of: encrypted) }
|
||||
try tool(
|
||||
@@ -231,7 +265,7 @@ struct VPhoneCustomFirmwareInstaller {
|
||||
try tool(
|
||||
"/usr/bin/hdiutil",
|
||||
["attach", "-mountpoint", appMount.path,
|
||||
restore.appendingPathComponent(paths.appOS).path,
|
||||
appImage.path,
|
||||
"-nobrowse", "-owners", "off"],
|
||||
quiet: true,
|
||||
)
|
||||
@@ -245,7 +279,9 @@ struct VPhoneCustomFirmwareInstaller {
|
||||
try fm.removeItem(at: destination)
|
||||
}
|
||||
try fm.createDirectory(at: destination, withIntermediateDirectories: true)
|
||||
try tool("/bin/cp", ["-R", source.appendingPathComponent(".").path, destination.path])
|
||||
for entry in try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) {
|
||||
try fm.copyItem(at: entry, to: destination.appendingPathComponent(entry.lastPathComponent))
|
||||
}
|
||||
}
|
||||
}
|
||||
try symlink("../../../System/Cryptexes/OS/System/Library/Caches/com.apple.dyld",
|
||||
@@ -402,10 +438,27 @@ struct VPhoneCustomFirmwareInstaller {
|
||||
}
|
||||
|
||||
private func replace(_ source: URL, at destination: URL, mode: Int) throws {
|
||||
try tool("/bin/cp", ["-f", source.path, destination.path], quiet: true)
|
||||
if fm.fileExists(atPath: destination.path) {
|
||||
try fm.removeItem(at: destination)
|
||||
}
|
||||
try fm.copyItem(at: source, to: destination)
|
||||
try fm.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: destination.path)
|
||||
}
|
||||
|
||||
private func removeWorkDirectory(_ work: URL) throws {
|
||||
guard let mounts = fm.mountedVolumeURLs(includingResourceValuesForKeys: nil, options: []) else {
|
||||
throw ValidationError("Could not verify that CFW volumes are detached")
|
||||
}
|
||||
let root = work.resolvingSymlinksInPath().path
|
||||
guard !mounts.contains(where: {
|
||||
let path = $0.resolvingSymlinksInPath().path
|
||||
return path == root || path.hasPrefix(root + "/")
|
||||
}) else {
|
||||
throw ValidationError("CFW volume is still mounted under \(work.path)")
|
||||
}
|
||||
try fm.removeItem(at: work)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func patch(_ verb: String, _ arguments: [String]) throws -> String {
|
||||
try tool(executable.path, ["cfw", verb] + arguments)
|
||||
|
||||
@@ -264,7 +264,6 @@ struct VPhoneFirmwarePrepareCommand: ParsableCommand {
|
||||
let bundle = try lib.library.bundle(named: name)
|
||||
defer {
|
||||
try? VPhoneHostFilePermissions.makeAccessible(at: bundle.url)
|
||||
try? VPhoneHostFilePermissions.makeAccessible(at: resources.ipswCacheDir)
|
||||
}
|
||||
try VPhoneFirmwarePreparer.prepare(
|
||||
iPhoneSource: phone,
|
||||
@@ -274,7 +273,6 @@ struct VPhoneFirmwarePrepareCommand: ParsableCommand {
|
||||
resources: resources,
|
||||
)
|
||||
try VPhoneHostFilePermissions.makeAccessible(at: bundle.url)
|
||||
try VPhoneHostFilePermissions.makeAccessible(at: resources.ipswCacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,16 +296,8 @@ struct VPhoneFirmwarePatchCommand: ParsableCommand {
|
||||
let bundle = try lib.library.bundle(named: name)
|
||||
defer {
|
||||
try? VPhoneHostFilePermissions.makeAccessible(at: bundle.url)
|
||||
try? VPhoneHostFilePermissions.makeAccessible(at: VPhoneResources.resolve().sealVolumeCacheDir)
|
||||
}
|
||||
|
||||
// In-process pipeline (no subprocess) — CryptexFilesystemPatcher's
|
||||
// apfs_sealvolume read honors VPHONE_SEAL_DIR from *this* process's
|
||||
// environment, so set it here to agree with `fw prepare`'s write.
|
||||
let resources = VPhoneResources.resolve()
|
||||
try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true)
|
||||
setenv("VPHONE_SEAL_DIR", resources.sealVolumeCacheDir.path, 1)
|
||||
|
||||
let pipeline = FirmwarePipeline(
|
||||
vmDirectory: bundle.url,
|
||||
variant: .jb,
|
||||
@@ -318,7 +308,6 @@ struct VPhoneFirmwarePatchCommand: ParsableCommand {
|
||||
)
|
||||
let records = try pipeline.patchAll()
|
||||
try VPhoneHostFilePermissions.makeAccessible(at: bundle.url)
|
||||
try VPhoneHostFilePermissions.makeAccessible(at: resources.sealVolumeCacheDir)
|
||||
print("[fw patch] applied \(records.count) JB patches")
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -80,7 +80,7 @@ struct VPhoneFirmwareSealToolCommand: ParsableCommand {
|
||||
}
|
||||
try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
|
||||
|
||||
let ramdisk = try vphoneRunBlocking { try await Self.fetchRamdisk(version: version) }
|
||||
let ramdisk = try vphoneRunBlocking { try await Self.fetchRamdisk(version: version, in: output) }
|
||||
defer { try? FileManager.default.removeItem(at: ramdisk.deletingLastPathComponent()) }
|
||||
|
||||
try Self.copyOut(of: ramdisk, to: destination)
|
||||
@@ -105,7 +105,7 @@ struct VPhoneFirmwareSealToolCommand: ParsableCommand {
|
||||
|
||||
/// Resolve the macOS release, then take BuildManifest.plist and the restore
|
||||
/// ramdisk out of its IPSW without downloading the IPSW.
|
||||
private static func fetchRamdisk(version: String) async throws -> URL {
|
||||
private static func fetchRamdisk(version: String, in output: URL) async throws -> URL {
|
||||
let release = try await VPhoneFirmwareIndex.macOSRelease(version: version)
|
||||
print(" macOS \(release.version) (\(release.build))")
|
||||
|
||||
@@ -137,11 +137,15 @@ struct VPhoneFirmwareSealToolCommand: ParsableCommand {
|
||||
print(" ramdisk: \(path)")
|
||||
|
||||
let im4p = try await zip.read(zip.entry(endingWith: path))
|
||||
let work = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("vphone-sealtool-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
|
||||
let work = output.appendingPathComponent(".vphone-sealtool-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: work, withIntermediateDirectories: false)
|
||||
let dmg = work.appendingPathComponent("ramdisk.dmg")
|
||||
try IM4P(im4p).payload().write(to: dmg)
|
||||
do {
|
||||
try IM4P(im4p).payload().write(to: dmg)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: work)
|
||||
throw error
|
||||
}
|
||||
return dmg
|
||||
}
|
||||
|
||||
|
||||
@@ -44,13 +44,16 @@ enum VPhoneFirmwarePreparer {
|
||||
}
|
||||
}
|
||||
|
||||
// Remote IPSWs belong to this VM, not a writable shared directory.
|
||||
// Local IPSWs are read in place and are never copied into the cache.
|
||||
let cacheDirectory = bundle.url.appendingPathComponent(".ipsw-cache", isDirectory: true)
|
||||
print("[*] Resolving iPhone IPSW...")
|
||||
let phone = try vphoneRunBlocking {
|
||||
try await VPhoneIPSWCache.resolve(iPhoneSource, in: resources.ipswCacheDir)
|
||||
try await VPhoneIPSWCache.resolve(iPhoneSource, in: cacheDirectory)
|
||||
}
|
||||
print("[*] Resolving cloudOS IPSW...")
|
||||
let cloud = try vphoneRunBlocking {
|
||||
try await VPhoneIPSWCache.resolve(cloudOSSource, in: resources.ipswCacheDir)
|
||||
try await VPhoneIPSWCache.resolve(cloudOSSource, in: cacheDirectory)
|
||||
}
|
||||
try checkIPhoneName(iPhoneSource, archive: phone)
|
||||
print("[+] iPhone \(phone.version) (\(phone.build)); cloudOS \(cloud.version) (\(cloud.build))")
|
||||
@@ -60,9 +63,17 @@ enum VPhoneFirmwarePreparer {
|
||||
let staging = bundle.url.appendingPathComponent(".firmware-prepare-\(UUID().uuidString)")
|
||||
let phoneTree = staging.appendingPathComponent(name)
|
||||
let cloudTree = staging.appendingPathComponent("cloudOS")
|
||||
try fm.createDirectory(at: phoneTree, withIntermediateDirectories: true)
|
||||
try fm.createDirectory(at: cloudTree, withIntermediateDirectories: true)
|
||||
defer { try? fm.removeItem(at: staging) }
|
||||
try fm.createDirectory(at: staging, withIntermediateDirectories: false)
|
||||
defer {
|
||||
let entries = (try? fm.contentsOfDirectory(atPath: staging.path)) ?? []
|
||||
if entries.contains(where: { $0.hasPrefix(".pcc-system-") || $0.hasPrefix(".pcc-restoration-") }) {
|
||||
fputs("warning: PCC mount may still be active; left staging at \(staging.path)\n", stderr)
|
||||
} else {
|
||||
try? fm.removeItem(at: staging)
|
||||
}
|
||||
}
|
||||
try fm.createDirectory(at: phoneTree, withIntermediateDirectories: false)
|
||||
try fm.createDirectory(at: cloudTree, withIntermediateDirectories: false)
|
||||
|
||||
print("[*] Extracting iPhone IPSW...")
|
||||
try VPhoneArchiveExtractor.extract(phone.file, into: phoneTree, options: .intoHostDirectory)
|
||||
|
||||
@@ -29,7 +29,7 @@ enum VPhonePCCGPURecovery {
|
||||
) throws {
|
||||
let fm = FileManager.default
|
||||
let temporaryLibrary = restoreDirectory.deletingLastPathComponent()
|
||||
.appending(path: ".pcc-restoration")
|
||||
.appending(path: ".pcc-restoration-\(UUID().uuidString)")
|
||||
let library = VPhoneLibrary(root: temporaryLibrary)
|
||||
let name = "pcc-\(UUID().uuidString.lowercased())"
|
||||
let vm = try VPhoneBundleOperations.create(.init(
|
||||
@@ -40,7 +40,16 @@ enum VPhonePCCGPURecovery {
|
||||
romSource: VPhoneBundleOperations.defaultROMSource(),
|
||||
sepromSource: VPhoneBundleOperations.defaultSEPROMSource(),
|
||||
), in: library)
|
||||
defer { try? fm.removeItem(at: temporaryLibrary) }
|
||||
defer {
|
||||
let diskImage = vm.url.appendingPathComponent("Disk.img")
|
||||
if let info = try? run("/usr/bin/hdiutil", ["info"]),
|
||||
!info.contains(diskImage.path)
|
||||
{
|
||||
try? fm.removeItem(at: temporaryLibrary)
|
||||
} else {
|
||||
fputs("warning: PCC disk image may still be attached; left \(temporaryLibrary.path)\n", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// The restore backend accepts a linked directory. Reuse the already
|
||||
// extracted cloudOS tree instead of writing a second copy of its OS image.
|
||||
@@ -107,8 +116,34 @@ enum VPhonePCCGPURecovery {
|
||||
guard let baseDisk = attached.split(whereSeparator: \.isNewline).first?
|
||||
.split(whereSeparator: \.isWhitespace).first.map(String.init),
|
||||
baseDisk.hasPrefix("/dev/disk")
|
||||
else { throw Error.toolFailed("hdiutil", "attached no disk device") }
|
||||
defer { _ = try? run("/usr/bin/hdiutil", ["detach", baseDisk]) }
|
||||
else {
|
||||
if let range = attached.range(of: #"/dev/disk[0-9]+"#, options: .regularExpression) {
|
||||
_ = try? run("/usr/bin/hdiutil", ["detach", "-force", String(attached[range])])
|
||||
}
|
||||
throw Error.toolFailed("hdiutil", "attached no disk device")
|
||||
}
|
||||
var mountToClean: URL?
|
||||
defer {
|
||||
if (try? run("/usr/bin/hdiutil", ["detach", baseDisk])) == nil {
|
||||
_ = try? run("/usr/bin/hdiutil", ["detach", "-force", baseDisk])
|
||||
}
|
||||
if let mountToClean {
|
||||
do {
|
||||
guard let mounts = fm.mountedVolumeURLs(
|
||||
includingResourceValuesForKeys: nil, options: [],
|
||||
) else {
|
||||
throw Error.toolFailed("hdiutil", "could not verify detached volumes")
|
||||
}
|
||||
let root = mountToClean.resolvingSymlinksInPath().path
|
||||
guard !mounts.contains(where: { $0.resolvingSymlinksInPath().path == root }) else {
|
||||
throw Error.toolFailed("hdiutil", "PCC volume is still mounted")
|
||||
}
|
||||
try fm.removeItem(at: mountToClean)
|
||||
} catch {
|
||||
fputs("warning: left PCC mount directory at \(mountToClean.path): \(error)\n", stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let info = try run("/usr/sbin/diskutil", ["info", "-plist", "\(baseDisk)s1"])
|
||||
guard let plist = try PropertyListSerialization.propertyList(
|
||||
@@ -121,10 +156,16 @@ enum VPhonePCCGPURecovery {
|
||||
|
||||
let mount = restoreDirectory.deletingLastPathComponent()
|
||||
.appending(path: ".pcc-system-\(UUID().uuidString)")
|
||||
try fm.createDirectory(at: mount, withIntermediateDirectories: true)
|
||||
defer { try? fm.removeItem(at: mount) }
|
||||
try fm.createDirectory(at: mount, withIntermediateDirectories: false)
|
||||
mountToClean = mount
|
||||
var mounted = false
|
||||
defer {
|
||||
if mounted, (try? run("/sbin/umount", [mount.path])) == nil {
|
||||
_ = try? run("/sbin/umount", ["-f", mount.path])
|
||||
}
|
||||
}
|
||||
try run("/sbin/mount_apfs", ["-o", "rdonly", "/dev/\(container)s1", mount.path])
|
||||
defer { _ = try? run("/sbin/umount", [mount.path]) }
|
||||
mounted = true
|
||||
|
||||
let source = mount.appending(
|
||||
path: "System/Library/Extensions/\(VPhonePCCGPUDriver.name)",
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ struct VPhoneVirtualMachineWriteManifestCommand: ParsableCommand {
|
||||
// MARK: - Shared options
|
||||
|
||||
struct VPhoneLibraryOption: ParsableArguments {
|
||||
@Option(name: [.customShort("l"), .long], help: "VM library root (default: ~/.vphone/VMs or $VPHONE_LIBRARY_ROOT)")
|
||||
@Option(name: [.customShort("l"), .long], help: "VM library root (default: ~/.vphone/machines or $VPHONE_LIBRARY_ROOT)")
|
||||
var libraryRoot: String?
|
||||
|
||||
var library: VPhoneLibrary {
|
||||
|
||||
+1
-7
@@ -99,7 +99,7 @@ public struct VPhoneVirtualMachineCreator {
|
||||
}
|
||||
|
||||
let bundleURL = library.url(forName: options.name)
|
||||
let ownedOutputs = [bundleURL, resources.ipswCacheDir, resources.sealVolumeCacheDir]
|
||||
let ownedOutputs = [bundleURL]
|
||||
var ownershipRestored = false
|
||||
var permissionsRestored = false
|
||||
defer {
|
||||
@@ -225,12 +225,6 @@ public struct VPhoneVirtualMachineCreator {
|
||||
bundleURL: URL,
|
||||
verbosity v: VPhoneVerbosity,
|
||||
) throws {
|
||||
// In-process pipeline (no subprocess) — CryptexFilesystemPatcher's
|
||||
// apfs_sealvolume read honors VPHONE_SEAL_DIR from *this* process's
|
||||
// environment, so set it here to agree with `fw prepare`'s write.
|
||||
try FileManager.default.createDirectory(at: resources.sealVolumeCacheDir, withIntermediateDirectories: true)
|
||||
setenv("VPHONE_SEAL_DIR", resources.sealVolumeCacheDir.path, 1)
|
||||
|
||||
trace("in-process FirmwarePipeline.patchAll variant=jb", v)
|
||||
let pipeline = FirmwarePipeline(
|
||||
vmDirectory: bundleURL,
|
||||
|
||||
+2
-2
@@ -54,9 +54,9 @@ public enum VPhoneRestoreService {
|
||||
// when a file of that name is already there ("SHSH '%s' already
|
||||
// present."), and a stale blob from a previous firmware would then be
|
||||
// what got copied out.
|
||||
let cacheDirectory = FileManager.default.temporaryDirectory
|
||||
let cacheDirectory = vmDir
|
||||
.appendingPathComponent("vphone-shsh-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: false)
|
||||
defer { try? FileManager.default.removeItem(at: cacheDirectory) }
|
||||
|
||||
try VPhoneRestoreRunner.run(
|
||||
|
||||
@@ -60,12 +60,32 @@ public enum VPhoneIPSWCache {
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 3 * 60 * 60
|
||||
let (downloaded, response) = try await session.download(for: request)
|
||||
defer { try? fm.removeItem(at: downloaded) }
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw Error.unexpectedHTTP(url, (response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
let size = try Int64(fm.attributesOfItem(atPath: downloaded.path)[.size] as? UInt64 ?? 0)
|
||||
let pending = cacheDirectory.appendingPathComponent(".\(cache.lastPathComponent).\(UUID().uuidString).partial")
|
||||
defer { try? fm.removeItem(at: pending) }
|
||||
guard fm.createFile(atPath: pending.path, contents: nil) else {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
let output = try FileHandle(forWritingTo: pending)
|
||||
defer { try? output.close() }
|
||||
var buffer = Data()
|
||||
var size: Int64 = 0
|
||||
for try await byte in bytes {
|
||||
buffer.append(byte)
|
||||
if buffer.count >= 1024 * 1024 {
|
||||
try output.write(contentsOf: buffer)
|
||||
size += Int64(buffer.count)
|
||||
buffer.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
if !buffer.isEmpty {
|
||||
try output.write(contentsOf: buffer)
|
||||
size += Int64(buffer.count)
|
||||
}
|
||||
try output.close()
|
||||
if response.expectedContentLength > 0, size != response.expectedContentLength {
|
||||
throw Error.incompleteDownload(
|
||||
url,
|
||||
@@ -74,9 +94,6 @@ public enum VPhoneIPSWCache {
|
||||
)
|
||||
}
|
||||
|
||||
let pending = cacheDirectory.appendingPathComponent(".\(cache.lastPathComponent).\(UUID().uuidString).partial")
|
||||
defer { try? fm.removeItem(at: pending) }
|
||||
try fm.moveItem(at: downloaded, to: pending)
|
||||
let metadata = try inspect(pending)
|
||||
try fm.moveItem(at: pending, to: cache)
|
||||
try VPhoneHostFilePermissions.makeAccessible(at: cache)
|
||||
|
||||
@@ -114,9 +114,7 @@ public struct VPhoneResources: Sendable {
|
||||
|
||||
// MARK: - Cache dirs
|
||||
|
||||
/// The per-user data root: `$VPHONE_ROOT` when set, else `~/.vphone`. Both
|
||||
/// `VPhoneResources` (ipsws/tools) and `VPhoneLibrary` (VMs) derive
|
||||
/// from this so one variable redirects everything vphone-cli creates.
|
||||
/// The per-user VM library root: `$VPHONE_ROOT` when set, else `~/.vphone`.
|
||||
public static func userDataRoot() -> URL {
|
||||
if let root = ProcessInfo.processInfo.environment["VPHONE_ROOT"], !root.isEmpty {
|
||||
return URL(fileURLWithPath: root, isDirectory: true)
|
||||
@@ -125,14 +123,6 @@ public struct VPhoneResources: Sendable {
|
||||
return home.appendingPathComponent(".vphone")
|
||||
}
|
||||
|
||||
public var ipswCacheDir: URL {
|
||||
Self.userDataRoot().appendingPathComponent("ipsws")
|
||||
}
|
||||
|
||||
public var sealVolumeCacheDir: URL {
|
||||
Self.userDataRoot().appendingPathComponent("tools")
|
||||
}
|
||||
|
||||
// MARK: - No interpreter
|
||||
|
||||
// There is deliberately nothing here any more.
|
||||
|
||||
@@ -44,11 +44,11 @@ public struct VPhoneLibrary: Sendable {
|
||||
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
|
||||
// `~/.vphone/machines` — 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 VPhoneResources.userDataRoot()
|
||||
.appendingPathComponent("VMs", isDirectory: true)
|
||||
.appendingPathComponent("machines", isDirectory: true)
|
||||
}
|
||||
|
||||
public func url(forName name: String) -> URL {
|
||||
|
||||
@@ -37,8 +37,8 @@ struct ResourcesTests {
|
||||
#expect(r.base.path == root.resolvingSymlinksInPath().path)
|
||||
}
|
||||
|
||||
@Test func `cache dirs are home relative`() {
|
||||
// The VPHONE_ROOT override would relocate the cache; assert the default
|
||||
@Test func `VM root is home relative`() {
|
||||
// The VPHONE_ROOT override would relocate the library; assert the default
|
||||
// with the variable held clear, rather than bailing out when some other
|
||||
// suite happens to have set it — that skip was the old way of living
|
||||
// with the race `ProcessEnvironment` now closes.
|
||||
@@ -49,10 +49,7 @@ struct ResourcesTests {
|
||||
|
||||
@Test func `user data root honors VPHONE root`() {
|
||||
ProcessEnvironment.withOverrides(["VPHONE_ROOT": "/tmp/vphone-test-root"]) {
|
||||
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
|
||||
#expect(VPhoneResources.userDataRoot().path == "/tmp/vphone-test-root")
|
||||
#expect(r.ipswCacheDir.path == "/tmp/vphone-test-root/ipsws")
|
||||
#expect(r.sealVolumeCacheDir.path == "/tmp/vphone-test-root/tools")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +57,7 @@ struct ResourcesTests {
|
||||
/// scripts under `scriptsDir`, and nothing else — no `PATH` walk, no
|
||||
/// interpreter. That claim is what the deleted venv tests used to guard
|
||||
/// from the other side, so assert it directly: every URL this type hands
|
||||
/// out is rooted in `base` or in the user data root.
|
||||
/// out is rooted in `base`.
|
||||
@Test func `every resource is rooted in the base or the data root`() {
|
||||
ProcessEnvironment.withOverrides(["VPHONE_ROOT": "/tmp/vphone-test-root"]) {
|
||||
let base = URL(fileURLWithPath: "/x")
|
||||
@@ -72,9 +69,6 @@ struct ResourcesTests {
|
||||
for url in rooted {
|
||||
#expect(url.path.hasPrefix("/x/"), "\(url.path) escapes the resource base")
|
||||
}
|
||||
for url in [r.ipswCacheDir, r.sealVolumeCacheDir] {
|
||||
#expect(url.path.hasPrefix("/tmp/vphone-test-root/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ struct LibraryTests {
|
||||
"VPHONE_LIBRARY_ROOT": nil,
|
||||
"VPHONE_ROOT": "/tmp/vphone-test-root",
|
||||
]) {
|
||||
#expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root/VMs")
|
||||
#expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root/machines")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user