Keep firmware work inside machine directories and clean up mounts

This commit is contained in:
Lakr
2026-09-24 22:22:43 +09:00
parent db4c305753
commit ea6cfb151f
19 changed files with 234 additions and 96 deletions
+3 -4
View File
@@ -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
+1 -1
View File
@@ -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,
+10
View File
@@ -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 \
@@ -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
}
@@ -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)
}
}
@@ -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)
@@ -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")
}
}
@@ -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)",
@@ -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 {
@@ -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,
@@ -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")
}
}