Rewrite error messages and labels to say what failed and what to do next

Reword CLI, firmware patcher, restore, escalator, VM window and Launchpad
errors, status text and labels as complete sentences that name the failed
step and the recovery action, and localize the new strings in the VM and
Launchpad catalogs for ja, ko, vi and zh-Hans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lakr233
2026-09-25 13:56:36 +07:00
co-authored by Claude Opus 5.5
parent c6cfb0756c
commit c27ea64740
68 changed files with 3510 additions and 293 deletions
@@ -109,7 +109,7 @@ public final class CryptexFilesystemPatcher: Patcher {
rebuiltData = try serializePayload(updatedManifest)
guard attachedDevices.isEmpty else {
throw FirmwarePatcher.PatcherError.patchVerificationFailed("Filesystem image is still attached")
throw FirmwarePatcher.PatcherError.patchVerificationFailed("The filesystem image is still attached. Detach it, then try again.")
}
return 1
@@ -157,7 +157,7 @@ public final class CryptexFilesystemPatcher: Patcher {
}
guard attachedDevices.isEmpty else {
throw FirmwarePatcher.PatcherError.patchVerificationFailed("Filesystem image is still attached")
throw FirmwarePatcher.PatcherError.patchVerificationFailed("The filesystem image is still attached. Detach it, then try again.")
}
print("- Finalizing merged image…")
@@ -177,7 +177,7 @@ public final class CryptexFilesystemPatcher: Patcher {
func copyCryptex(targetMount: String, appOS: Bool = false, systemOS: Bool = false) throws {
guard appOS || systemOS, !(appOS && systemOS) else {
throw FirmwarePatcher.PatcherError.patchVerificationFailed("Can patch only one at a time")
throw FirmwarePatcher.PatcherError.patchVerificationFailed("Specify exactly one cryptex to copy: AppOS or SystemOS.")
}
let osPath = if appOS {
@@ -17,7 +17,7 @@ extension CryptexFilesystemPatcher {
let keys: [URLResourceKey] = [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey]
guard let enumerator = FileManager.default.enumerator(at: sourceRoot, includingPropertiesForKeys: keys) else {
throw FirmwareManifest.ManifestError.fileNotFound(
"Unable to read \(sourceRoot.path). Check that the image is still mounted, then try again.",
"\(sourceRoot.path). Check that the image is still mounted, then try again.",
)
}
for case let fileURL as URL in enumerator {
@@ -20,15 +20,11 @@ enum CryptexFileOperationError: Error, CustomStringConvertible {
var description: String {
switch self {
case let .chown(path, code):
"cannot change owner of \(path): \(String(cString: strerror(code)))"
"Unable to change the owner of \(path): \(String(cString: strerror(code)))"
case let .unlink(path, code):
"cannot remove \(path): \(String(cString: strerror(code)))"
"Unable to remove \(path): \(String(cString: strerror(code)))"
case let .symlinkOntoDirectory(path):
"""
refusing to replace the directory at \(path) with a symlink.
`ln -sf` would have created the link *inside* it, which leaves the \
guest with a path that resolves to nothing and only fails at boot.
"""
"Unable to create a symlink at \(path) because a directory already exists there."
}
}
}
@@ -118,7 +118,7 @@ extension CryptexFilesystemPatcher {
// Find the section header for /private/var
guard let sectionIndex = lines.firstIndex(of: "# ./private/var") else {
throw FirmwareManifest.ManifestError.fileNotFound("/private/var")
throw FirmwareManifest.ManifestError.fileNotFound("the /private/var entry in \(mtree.path)")
}
// Look at the lines after that header until the next section header
@@ -135,7 +135,7 @@ extension CryptexFilesystemPatcher {
guard let match = line.range(of: #"time=([0-9]+(?:\.[0-9]+)?)"#,
options: .regularExpression)
else {
throw FirmwareManifest.ManifestError.fileNotFound("modification time for /private/var in \(mtree.path)")
throw FirmwareManifest.ManifestError.fileNotFound("the /private/var modification time in \(mtree.path)")
}
let matchedText = String(line[match])
@@ -37,9 +37,9 @@ public enum DyldSharedCacheError: Error, CustomStringConvertible, Sendable {
public var description: String {
switch self {
case let .noChunksFound(directory):
"No dyld shared cache chunks found under \(directory)"
"No dyld shared cache chunks found in \(directory). Check the path, then try again."
case let .noMappingsParsed(directory):
"Chunks present under \(directory) but no mapping table was recognised"
"Found dyld shared cache chunks under \(directory), but none has a recognized mapping table."
case let .addressNotMapped(vma):
"VMA 0x\(String(vma, radix: 16, uppercase: true)) is not mapped by any chunk"
case let .addressSpanCrossesChunk(vma, length):
@@ -47,9 +47,9 @@ public enum DyldSharedCacheError: Error, CustomStringConvertible, Sendable {
case let .shortRead(vma, wanted, got):
"Short read at VMA 0x\(String(vma, radix: 16, uppercase: true)): got \(got) of \(wanted) bytes"
case let .noCodeDirectory(chunk):
"Chunk \(chunk) has no recognised CS_SuperBlob / CS_CodeDirectory"
"Chunk \(chunk) has no recognized code signature."
case let .unsupportedCodeDirectory(chunk, reason):
"Chunk \(chunk) code directory unsupported: \(reason)"
"Chunk \(chunk) has an unsupported code signature: \(reason)"
case let .notADyldCache(path):
"\(path) does not start with a dyld cache header"
case let .localSymbolsMissing(path):
@@ -32,11 +32,11 @@ public enum FirmwareManifest {
public var description: String {
switch self {
case let .fileNotFound(path):
"Manifest file not found: \(path)"
"File not found: \(path)"
case let .invalidPlist(path):
"Invalid plist: \(path)"
case let .identityNotFound(msg):
"Identity not found: \(msg)"
"Unable to find the \(msg)."
case let .missingKey(key):
"Missing required key: \(key)"
}
@@ -157,10 +157,10 @@ public enum FirmwareManifest {
}
}
guard let rel = release else {
throw ManifestError.identityNotFound("No release identity for DeviceClass=\(deviceClass)")
throw ManifestError.identityNotFound("release identity for \(deviceClass) in the cloudOS BuildManifest")
}
guard let res = research else {
throw ManifestError.identityNotFound("No research identity for DeviceClass=\(deviceClass)")
throw ManifestError.identityNotFound("research identity for \(deviceClass) in the cloudOS BuildManifest")
}
return (rel, res)
}
@@ -176,7 +176,7 @@ public enum FirmwareManifest {
return i
}
}
throw ManifestError.identityNotFound("No erase identity found in iPhone manifest")
throw ManifestError.identityNotFound("erase identity in the iPhone BuildManifest")
}
// MARK: - Build Identity Construction
@@ -68,7 +68,7 @@ public final class ManifestHashPatcher: Patcher {
options: [],
format: nil,
) as? PlistDict else {
throw FirmwareManifest.ManifestError.invalidPlist("")
throw FirmwareManifest.ManifestError.invalidPlist("BuildManifest.plist")
}
return buildManifest
}
@@ -76,7 +76,7 @@ public final class ManifestHashPatcher: Patcher {
func applyPatches(buildManifest: PlistDict) throws -> PlistDict {
var buildManifest = buildManifest
guard let restoreDir else {
throw FirmwareManifest.ManifestError.fileNotFound("Restore Directory")
throw FirmwareManifest.ManifestError.fileNotFound("the restore directory")
}
// We assume that FirmwareManifest has generated the manifest containing a single build identity.
@@ -140,11 +140,11 @@ struct Create: ParsableCommand {
func validate() throws {
if zstd, xz {
throw ValidationError("choose one of --zstd or --xz")
throw ValidationError("Use either --zstd or --xz, not both.")
}
guard VPhoneArchiveFormat(rawValue: format) != nil else {
throw ValidationError(
"unknown format '\(format)' (gnutar, pax, ustar)",
"Unknown format '\(format)'. Use gnutar, pax, or ustar.",
)
}
}
@@ -59,7 +59,7 @@ enum VPhoneCustomFirmwareDyldSharedCacheVerbs {
default: (10, cleaned[...])
}
guard !digits.isEmpty, let parsed = UInt32(digits, radix: radix) else {
throw ValidationError("not an integer: '\(value)'")
throw ValidationError("'\(value)' is not a valid number. Enter a decimal or hexadecimal value.")
}
return parsed
}
@@ -44,7 +44,7 @@ struct VPhoneCustomFirmwareInstaller {
}
func run() throws {
guard geteuid() == 0 else { throw ValidationError("CFW install requires administrator privileges") }
guard geteuid() == 0 else { throw ValidationError("CFW installation needs root. Run this command with sudo.") }
let invokingUser = VPhoneInvokingUser.current
defer {
if let invokingUser {
@@ -58,19 +58,19 @@ struct VPhoneCustomFirmwareInstaller {
}
let diskImage = bundle.appendingPathComponent("Disk.img")
guard fm.fileExists(atPath: diskImage.path) else {
throw ValidationError("Disk.img is missing: \(diskImage.path)")
throw ValidationError("The VM disk image is missing: \(diskImage.path). Create the VM again, then install CFW.")
}
let busy = try VPhoneProcessRunner.runCapturing(
URL(fileURLWithPath: "/usr/sbin/lsof"), [diskImage.path],
)
guard busy.stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ValidationError("VM disk is in use; stop the VM before installing CFW")
throw ValidationError("The VM disk is in use. Stop the VM, then install CFW again.")
}
let capacity =
try bundle.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
.volumeAvailableCapacityForImportantUsage ?? 0
guard capacity > 50 * 1024 * 1024 * 1024 else {
throw ValidationError("Less than 50 GiB available; CFW install stopped before mounting")
throw ValidationError("Less than 50 GiB of disk space is available. Free up space, then install CFW again.")
}
let attached = try tool(
@@ -87,7 +87,7 @@ struct VPhoneCustomFirmwareInstaller {
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")
throw ValidationError("Unable to attach the VM disk image. Try again.")
}
var diskAttached = true
var workToClean: URL?
@@ -115,7 +115,7 @@ struct VPhoneCustomFirmwareInstaller {
let container = plist["APFSContainerReference"] as? String,
container.hasPrefix("disk")
else {
throw ValidationError("Could not resolve the APFS container for \(baseDisk)")
throw ValidationError("Unable to read the VM disk image. Try again.")
}
let work = bundle.appendingPathComponent(".cfw-native-\(UUID().uuidString)")
let system = work.appendingPathComponent("system")
@@ -163,7 +163,7 @@ struct VPhoneCustomFirmwareInstaller {
&& fm.fileExists(atPath: $0.appendingPathComponent("iPhone-BuildManifest.plist").path)
})
else {
throw ValidationError("No prepared iPhone restore tree exists in \(bundle.path)")
throw ValidationError("No prepared iPhone restore tree was found in \(bundle.path). Run fw prepare, then install CFW again.")
}
try installCryptexes(restore: restore, system: system, work: work)
let version = try productVersion(system: system)
@@ -395,7 +395,7 @@ struct VPhoneCustomFirmwareInstaller {
|| (try? alias.resourceValues(forKeys: [.isSymbolicLinkKey]))?.isSymbolicLink == true
{
guard (try? fm.destinationOfSymbolicLink(atPath: alias.path)) == target else {
throw ValidationError("Root path /vh is already occupied")
throw ValidationError("Another file already uses /vh on the VM system volume. Remove it, then install CFW again.")
}
} else {
try fm.createSymbolicLink(atPath: alias.path, withDestinationPath: target)
@@ -431,7 +431,7 @@ struct VPhoneCustomFirmwareInstaller {
let preboot = volumes.first(where: { ($0["Roles"] as? [String])?.contains("Preboot") == true }),
let device = preboot["DeviceIdentifier"] as? String
else {
throw ValidationError("Could not locate the VM's Preboot APFS volume")
throw ValidationError("Unable to find the VM's Preboot volume. Restore the VM, then install CFW again.")
}
let mount = work.appendingPathComponent("preboot")
try fm.createDirectory(at: mount, withIntermediateDirectories: false)
@@ -442,7 +442,7 @@ struct VPhoneCustomFirmwareInstaller {
$0.appendingPathComponent("usr/standalone/firmware/devicetree.img4")
}.filter { fm.fileExists(atPath: $0.path) }
guard candidates.count == 1, let deviceTree = candidates.first else {
throw ValidationError("Expected one restored devicetree.img4 in Preboot, found \(candidates.count)")
throw ValidationError("Expected one device tree in the Preboot volume but found \(candidates.count). Restore the VM, then install CFW again.")
}
try patch("patch-post-restore-dt", [deviceTree.path])
if let build = spoofBuild {
@@ -573,7 +573,7 @@ struct VPhoneCustomFirmwareInstaller {
) as? [String: Any],
let version = value["ProductVersion"] as? String
else {
throw ValidationError("SystemVersion.plist has no ProductVersion")
throw ValidationError("Unable to read the iOS version from the VM system volume. Restore the VM, then install CFW again.")
}
return version
}
@@ -597,7 +597,7 @@ struct VPhoneCustomFirmwareInstaller {
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")
throw ValidationError("Unable to confirm that the CFW volumes are detached. Eject them in Disk Utility, then try again.")
}
let root = work.resolvingSymlinksInPath().path
guard
@@ -606,7 +606,7 @@ struct VPhoneCustomFirmwareInstaller {
return path == root || path.hasPrefix(root + "/")
})
else {
throw ValidationError("CFW volume is still mounted under \(work.path)")
throw ValidationError("A CFW volume is still mounted under \(work.path). Eject it, then try again.")
}
try fm.removeItem(at: work)
}
@@ -89,9 +89,7 @@ private func requireUntruncatedMachO(at url: URL) throws {
// claiming more commands than that much room is lying about one of the two.
guard 32 + sizeOfCommands <= fileSize, numberOfCommands * 8 <= sizeOfCommands else {
throw PatcherError.invalidFormat(
"\(url.lastPathComponent): truncated Mach-O — the header claims "
+ "\(numberOfCommands) load command(s) in \(sizeOfCommands) bytes, "
+ "and the file is \(fileSize) bytes",
"\(url.lastPathComponent) is a truncated Mach-O file: the header lists \(numberOfCommands) load commands in \(sizeOfCommands) bytes, but the file is only \(fileSize) bytes.",
)
}
}
@@ -17,11 +17,11 @@ enum VPhoneFirmwarePreparer {
var errorDescription: String? {
switch self {
case let .existingRestore(path):
"Firmware preparation would replace an existing restore tree: \(path.path)"
"A restore tree already exists at \(path.path). Remove it, then prepare the firmware again."
case let .missingComponent(path):
"Firmware component is missing: \(path.path)"
"A firmware component is missing: \(path.path). Check that the IPSW is complete, then prepare the firmware again."
case let .sourceNameMismatch(path, version, build):
"IPSW name and BuildManifest disagree (\(version)/\(build)): \(path.path)"
"The IPSW file name does not match its contents (\(version)/\(build)): \(path.path). Use the original file name or download the IPSW again."
}
}
}
@@ -13,7 +13,7 @@ enum VPhoneHostPreflight {
if probe.succeeded,
probe.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "1"
{
throw ValidationError("Nested virtualization is unavailable for this VM")
throw ValidationError("This Mac is running inside a VM, so vphone-vm cannot start a guest. Run it on a macOS host that is not itself a VM.")
}
return try VPhoneGuestLaunchPlanner()
}
@@ -13,9 +13,9 @@ enum VPhonePCCGPURecovery {
var errorDescription: String? {
switch self {
case .identityTimedOut:
"Temporary PCC VM did not report its ECID"
"The temporary PCC VM did not report its ECID. Try again."
case .recoveryTimedOut:
"Temporary PCC VM did not enter DFU/recovery mode"
"The temporary PCC VM did not enter DFU or recovery mode. Try again."
case let .toolFailed(tool, detail):
"\(tool) failed while reading the restored PCC System volume: \(detail)"
}
@@ -118,7 +118,7 @@ enum VPhonePCCGPURecovery {
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")
throw Error.toolFailed("hdiutil", "could not attach the PCC disk image. Try again.")
}
var mountToClean: URL?
var diskAttached = true
@@ -151,7 +151,7 @@ enum VPhonePCCGPURecovery {
) as? [String: Any],
let container = plist["APFSContainerReference"] as? String,
container.hasPrefix("disk")
else { throw Error.toolFailed("diskutil", "could not locate the restored APFS container") }
else { throw Error.toolFailed("diskutil", "could not find the restored system volume. Try again.") }
let mount = restoreDirectory.deletingLastPathComponent()
.appending(path: ".pcc-system-\(UUID().uuidString)")
@@ -75,8 +75,7 @@ struct VPhoneVirtualMachineWriteManifestCommand: ParsableCommand {
_ = try VPhoneVirtualMachineManifest.load(from: configURL)
} else if try !((FileManager.default.contentsOfDirectory(atPath: vmDirectory.path)).isEmpty) {
throw ValidationError(
"The VM directory contains data but has no config.plist. Recreate the VM in a new directory; "
+ "write-manifest cannot upgrade an existing VM.",
"This VM directory has data but no config.plist. write-manifest cannot upgrade an existing VM. Create the VM in a new directory.",
)
}
try manifest.write(to: configURL)
@@ -120,7 +119,7 @@ struct VPhoneVirtualMachineListCommand: ParsableCommand {
let data = try JSONEncoder().encode(reports)
print(String(decoding: data, as: UTF8.self))
} else if reports.isEmpty {
print("(no VMs in \(library.root.path))")
print("No VMs in \(library.root.path). Create one with vm create.")
} else {
for r in reports {
var line =
@@ -241,9 +240,9 @@ struct VPhoneVirtualMachineConfigCommand: ParsableCommand {
case "bridged": return .bridged
case "none", "off": return .off
case "hostonly", "host-only":
throw ValidationError("network mode 'hostOnly' is not supported; use nat, bridged, or none")
throw ValidationError("Host-only networking is not supported. Use nat, bridged, or none.")
default:
throw ValidationError("unknown network mode '\(s)'; expected nat, bridged, or none")
throw ValidationError("Unknown network mode '\(s)'. Use nat, bridged, or none.")
}
}
}
@@ -287,7 +286,7 @@ struct VPhoneVirtualMachineDeleteCommand: ParsableCommand {
if !force {
print("Delete '\(name)' and all its files? [y/N] ", terminator: "")
guard (readLine() ?? "").lowercased() == "y" else {
print("aborted")
print("Canceled. Nothing was deleted.")
return
}
}
@@ -6,8 +6,7 @@ struct VPhoneVirtualMachineCreateCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a VM end-to-end (prepare → patch → restore → CFW → first boot)",
discussion: "Runs the JB pipeline for a fresh VM. Needs an internet connection "
+ "(IPSW download), a non-nested macOS host, and sudo (CFW host-mount).",
discussion: "Runs the full jailbreak pipeline for a new VM. Requires an internet connection to download IPSWs, a macOS host that is not itself a VM, and sudo to install custom firmware.",
)
@OptionGroup var lib: VPhoneLibraryOption
@@ -31,8 +31,7 @@ private enum VPhoneVirtualMachineCreationError: Error, CustomStringConvertible {
case let .invalidECID(v):
"Invalid ECID in the device identity file: '\(v)'. Run vm create again to regenerate it."
case let .udidECIDMismatch(udid, ecid):
"The UDID and ECID in the device identity file do not match: \(udid) vs 0x\(ecid). "
+ "Run vm create again to regenerate it."
"The UDID and ECID in the device identity file do not match (\(udid), 0x\(ecid)). Run vm create again to regenerate it."
case .recoveryTimeout:
"Timed out waiting for the device to enter recovery mode."
// No exit code any more: the restore backend is in this process, so
@@ -42,7 +41,7 @@ private enum VPhoneVirtualMachineCreationError: Error, CustomStringConvertible {
case let .cfwInstallFailed(code):
"Custom firmware installation failed (exit code \(code))."
case .bootAnalysisPanic:
"Boot analysis failed: the guest panicked."
"Boot check failed: the guest panicked. Run vm create again."
case let .bootAnalysisExited(code):
"Boot check ended before vphoned connected (exit code \(code))."
case .bootAnalysisTimeout:
@@ -7,8 +7,7 @@ struct VPhoneVirtualMachineCloneCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "clone",
abstract: "Clone a VM bundle (fast APFS clone; resets device identity)",
discussion: "The clone boots as a fresh device (nvram/machineIdentifier/shsh cleared). "
+ "SEPStorage is copied as-is; a cloned, already-restored VM may need re-restoring.",
discussion: "The clone starts as a new device, with NVRAM, machine identifier, and SHSH blobs cleared. SEPStorage is copied unchanged, so a clone of a restored VM may need to be restored again.",
)
@OptionGroup var lib: VPhoneLibraryOption
@@ -494,11 +494,11 @@ const char *vphone_restore_error_string(int code)
case VPHONE_RESTORE_E_TICKET:
return "the ticket file could not be read as a TSS response";
case VPHONE_RESTORE_E_OUT_OF_MEMORY:
return "out of memory";
return "Not enough memory. Close other apps and try again.";
case VPHONE_RESTORE_E_BUSY:
return "another restore is already running in this process";
default:
return "idevicerestore stopped; see the log for what happened";
return "Check the log above for details.";
}
}
@@ -81,7 +81,7 @@ extension VPhoneRestoreBackendError: CustomStringConvertible {
case let .ecidInvalid(value):
"Invalid ECID: \(value)"
case let .ecidTooLarge(value):
"ECID does not fit in 64 bits: \(value)"
"ECID is too long: \(value). Enter up to 16 hexadecimal digits."
case let .noRestoreDirectory(dir):
"No iPhone*_Restore directory found in \(dir.path)"
case .multipleRestoreDirectories:
@@ -91,20 +91,19 @@ extension VPhoneRestoreBackendError: CustomStringConvertible {
case .recoveryDeviceUnreadable:
"The recovery endpoint stopped answering while it was being read"
case .restoreAlreadyRunning:
"Another restore is already running in this process"
"Another restore is already running. Wait for it to finish, then try again."
case let .restoreDirectoryUnusable(dir):
"\(dir.path) cannot be used as a restore directory; it must be an extracted "
+ "iPhone*_Restore directory, not a .ipsw archive"
"Unable to use \(dir.path) as a restore directory. Choose an extracted iPhone*_Restore directory, not an .ipsw archive."
case let .ticketUnreadable(path):
"\(path.path) could not be read as a TSS response plist"
case let .restoreFailed(code, reason):
"Restore failed (\(code)): \(reason)"
"Unable to read the SHSH ticket at \(path.path). Fetch a new ticket and try again."
case let .restoreFailed(_, reason):
"Restore failed. \(reason)"
case let .shshNotProduced(dir):
"The TSS record was fetched but no .shsh appeared under \(dir.path)"
"The SHSH ticket was fetched, but no file was saved in \(dir.path). Try again."
case let .shshMalformed(path):
"\(path.path) is not a TSS response dictionary"
"\(path.path) is not a valid SHSH ticket. Fetch a new ticket and try again."
case let .shshNotDecompressible(path):
"\(path.path) is gzipped and could not be decompressed"
"The SHSH ticket at \(path.path) is damaged. Fetch a new ticket and try again."
}
}
}
@@ -852,18 +852,18 @@ int main(int argc, char **argv) {
return 0;
}
if (strcmp(verb, "off") == 0) {
if (geteuid() != 0) { fprintf(stderr, "error: run as root\n"); return 1; }
if (geteuid() != 0) { fprintf(stderr, "error: This command needs root. Run it with sudo.\n"); return 1; }
return cmd_off(self_path);
}
if (strcmp(verb, "allow") == 0) {
if (geteuid() != 0) { fprintf(stderr, "error: run as root\n"); return 1; }
if (geteuid() != 0) { fprintf(stderr, "error: This command needs root. Run it with sudo.\n"); return 1; }
int hold = 0, first = 2;
if (argc > 3 && strcmp(argv[2], "--hold") == 0) {
hold = atoi(argv[3]);
first = 4;
}
if (first >= argc) {
fprintf(stderr, "error: no binary given\n");
fprintf(stderr, "error: Specify at least one binary to allow.\n");
return 2;
}
return cmd_allow(self_path, argc - first, argv + first, hold);
File diff suppressed because it is too large Load Diff
@@ -16,7 +16,8 @@ final class VPhoneGuestControl {
var description: String {
switch self {
case .notConnected: "not connected to vphoned"
case .notConnected:
VPhoneLocalization.text("The guest agent is not connected. Wait for it to connect, then try again.")
case let .unsupportedCapability(value): "guest does not support capability: \(value)"
case let .protocolError(value): "API protocol error: \(value)"
case let .guestError(value): value
@@ -134,7 +134,7 @@ class VPhoneFileBrowserModel {
quickLookController.open(data: data, filename: file.name)
} catch {
guard !Task.isCancelled else { return }
self.error = VPhoneLocalization.format("Quick Look download failed: %@", String(describing: error))
self.error = VPhoneLocalization.format("Unable to preview “%@”. Check the connection, then try again.", file.name)
}
quickLookTask = nil
}
@@ -161,7 +161,7 @@ class VPhoneFileBrowserModel {
selection.formIntersection(Set(files.map(\.id)))
} catch {
guard generation == refreshGeneration, path == currentPath else { return }
self.error = "\(error)"
self.error = VPhoneLocalization.text("Unable to load this folder. Check the connection, then try again.")
files = []
}
isLoading = false
@@ -195,7 +195,7 @@ class VPhoneFileBrowserModel {
try data.write(to: dest)
print("[files] downloaded \(remotePath) (\(data.count) bytes)")
} catch {
self.error = VPhoneLocalization.format("Download failed: %@", String(describing: error))
self.error = VPhoneLocalization.format("Unable to download “%@”. Try again.", name)
}
}
@@ -205,12 +205,12 @@ class VPhoneFileBrowserModel {
ancestors: Set<String>,
) async {
guard !file.isSymbolicLink || file.resolvedPath != nil else {
error = VPhoneLocalization.format("Download requires an updated guest agent to follow %@.", file.path)
error = VPhoneLocalization.format("Unable to download %@. Update the guest agent, then try again.", file.path)
return
}
let resolvedPath = file.resolvedPath ?? file.path
guard !ancestors.contains(resolvedPath) else {
error = VPhoneLocalization.format("Directory link creates a cycle at %@.", file.path)
error = VPhoneLocalization.format("Unable to download %@. The folder link points back to a parent folder.", file.path)
return
}
let ancestors = ancestors.union([resolvedPath])
@@ -218,7 +218,7 @@ class VPhoneFileBrowserModel {
do {
try FileManager.default.createDirectory(at: localDir, withIntermediateDirectories: true)
} catch {
self.error = VPhoneLocalization.format("Create directory failed: %@", String(describing: error))
self.error = VPhoneLocalization.format("Unable to create the folder “%@” on this Mac. Choose another location, then try again.", file.name)
return
}
@@ -226,7 +226,7 @@ class VPhoneFileBrowserModel {
do {
entries = try await control.listFiles(path: file.path)
} catch {
self.error = VPhoneLocalization.format("List directory failed: %@", String(describing: error))
self.error = VPhoneLocalization.format("Unable to read the folder “%@”. Check the connection, then try again.", file.name)
return
}
@@ -255,7 +255,7 @@ class VPhoneFileBrowserModel {
// Mapped: this is a drag-and-drop target, so the size is the
// user's choice and the transfer chunks it anyway.
guard let data = try? Data(contentsOf: url, options: .mappedIfSafe) else {
uploadError = VPhoneLocalization.format("Could not read \"%@\" from disk.", name)
uploadError = VPhoneLocalization.format("Unable to read “%@”. Check that the file still exists, then try again.", name)
break
}
let dest = (currentPath as NSString).appendingPathComponent(name)
@@ -267,7 +267,7 @@ class VPhoneFileBrowserModel {
transferCurrent = Int64(data.count)
print("[files] uploaded \(name) (\(data.count) bytes)")
} catch {
uploadError = VPhoneLocalization.format("Upload failed for \"%@\": %@", name, String(describing: error))
uploadError = VPhoneLocalization.format("Unable to upload “%@”. Check the connection, then try again.", name)
break
}
}
@@ -281,7 +281,7 @@ class VPhoneFileBrowserModel {
func createNewFolder(name: String) async {
guard !files.contains(where: { $0.name == name }) else {
error = VPhoneLocalization.format("An item named %@ already exists.", name)
error = VPhoneLocalization.format("An item named “%@” already exists. Choose a different name.", name)
return
}
let path = (currentPath as NSString).appendingPathComponent(name)
@@ -289,7 +289,7 @@ class VPhoneFileBrowserModel {
try await control.createDirectory(path: path)
await refresh()
} catch {
self.error = VPhoneLocalization.format("Create folder failed: %@", String(describing: error))
self.error = VPhoneLocalization.format("Unable to create the folder “%@”. Check the connection, then try again.", name)
}
}
@@ -299,7 +299,7 @@ class VPhoneFileBrowserModel {
do {
try await control.deleteFile(path: file.path)
} catch {
self.error = VPhoneLocalization.format("Delete failed: %@", String(describing: error))
self.error = VPhoneLocalization.format("Unable to delete “%@”. Check the connection, then try again.", file.name)
return
}
}
@@ -310,7 +310,7 @@ class VPhoneFileBrowserModel {
func renameFile(_ file: VPhoneRemoteFile, to newName: String) async {
guard newName != file.name else { return }
guard !files.contains(where: { $0.dir == file.dir && $0.name == newName }) else {
error = VPhoneLocalization.format("An item named %@ already exists.", newName)
error = VPhoneLocalization.format("An item named “%@” already exists. Choose a different name.", newName)
return
}
let newPath = (file.dir as NSString).appendingPathComponent(newName)
@@ -318,7 +318,7 @@ class VPhoneFileBrowserModel {
try await control.renameFile(from: file.path, to: newPath)
await refresh()
} catch {
self.error = VPhoneLocalization.format("Rename failed: %@", String(describing: error))
self.error = VPhoneLocalization.format("Unable to rename “%@”. Check the connection, then try again.", file.name)
}
}
}
@@ -47,7 +47,7 @@ struct VPhoneKeychainItem: Identifiable, Hashable {
}
if valueEncoding == "base64" {
return VPhoneLocalization.format(
"[%@ binary]",
"Binary data (%@)",
ByteCountFormatter.string(fromByteCount: Int64(valueSize), countStyle: .file),
)
}
@@ -75,13 +75,13 @@ struct VPhoneKeychainItem: Identifiable, Hashable {
var protectionDescription: String {
switch protection {
case "ak": VPhoneLocalization.text("WhenUnlocked")
case "ck": VPhoneLocalization.text("AfterFirstUnlock")
case "ak": VPhoneLocalization.text("When Unlocked")
case "ck": VPhoneLocalization.text("After First Unlock")
case "dk": VPhoneLocalization.text("Always")
case "aku": VPhoneLocalization.text("WhenUnlocked (ThisDevice)")
case "cku": VPhoneLocalization.text("AfterFirstUnlock (ThisDevice)")
case "dku": VPhoneLocalization.text("Always (ThisDevice)")
case "akpu": VPhoneLocalization.text("WhenPasscodeSet (ThisDevice)")
case "aku": VPhoneLocalization.text("When Unlocked (This Device Only)")
case "cku": VPhoneLocalization.text("After First Unlock (This Device Only)")
case "dku": VPhoneLocalization.text("Always (This Device Only)")
case "akpu": VPhoneLocalization.text("When Passcode Set (This Device Only)")
default: protection
}
}
@@ -130,7 +130,7 @@ struct VPhoneGuestPreferencesView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ContentUnavailableView(
"No Preference Read",
"No Preferences Loaded",
systemImage: "list.bullet.rectangle",
description: Text("Enter a domain, then choose Read. Leave Key empty to read every key in the domain."),
)
@@ -38,7 +38,7 @@ extension VPhoneMenuController {
let charging = makeItem("Charging", action: #selector(setBatteryConnectivity(_:)))
charging.tag = 1
charging.state = .on
let disconnected = makeItem("Disconnected", action: #selector(setBatteryConnectivity(_:)))
let disconnected = makeItem("Not Charging", action: #selector(setBatteryConnectivity(_:)))
disconnected.tag = 2
menu.addItem(charging)
@@ -202,11 +202,11 @@ extension VPhoneMenuController {
private func updateStatusLabel(charge: Double, connectivity: Int) {
let connLabel = VPhoneLocalization.text(connectivity == 1 ? "charging" : "not charging")
let lpmLabel =
let format =
ProcessInfo.processInfo.isLowPowerModeEnabled
? VPhoneLocalization.text(", low power") : ""
? "Status: %@%% (%@, Low Power Mode)" : "Status: %@%% (%@)"
batterySyncStatusItem?.title = VPhoneLocalization.format(
"Status: %@%% (%@)", String(Int(charge)), connLabel + lpmLabel,
format, String(Int(charge)), connLabel,
)
}
@@ -114,16 +114,28 @@ extension VPhoneMenuController {
indicator.isIndeterminate = false
indicator.doubleValue = 100
statusLabel.stringValue = VPhoneLocalization.text("Bootstrap installed")
var message = VPhoneLocalization.format("Installed Irisin %@ in %@.", version, root)
if let warning = result["service_start_warning"] as? String {
message += "\n\n" + VPhoneLocalization.format("Daemon start: %@", warning)
alert.informativeText = if result["service_start_warning"] as? String != nil {
VPhoneLocalization.format(
"Installed Irisin %1$@ in %2$@.\n\nSome services did not start, but the bootstrap is ready to use.",
version,
root,
)
} else {
VPhoneLocalization.format("Installed Irisin %@ in %@.", version, root)
}
alert.informativeText = message
} catch {
poller.cancel()
statusLabel.stringValue = VPhoneLocalization.text("Bootstrap installation failed")
alert.alertStyle = .warning
alert.informativeText = String(describing: error)
alert.informativeText = if localURL != nil {
VPhoneLocalization.text(
"Unable to install the bootstrap. Check the file and guest connection, then try again.",
)
} else {
VPhoneLocalization.text(
"Unable to install the bootstrap. Check that the guest agent is connected, then try again.",
)
}
}
}
}
@@ -187,8 +199,8 @@ extension VPhoneMenuController {
)
} catch {
VPhoneAlert.present(
title: "Bootstrap removal failed",
message: String(describing: error),
title: "Unable to Remove Bootstrap",
message: "Unable to remove the bootstrap. Check that the guest agent is connected, then try again.",
style: .warning,
)
}
@@ -197,8 +209,8 @@ extension VPhoneMenuController {
}
} catch {
VPhoneAlert.present(
title: "Bootstrap removal failed",
message: String(describing: error),
title: "Unable to Remove Bootstrap",
message: "Unable to remove the bootstrap. Check that the guest agent is connected, then try again.",
style: .warning,
)
finishBootstrapUninstall()
@@ -78,8 +78,7 @@ extension VPhoneMenuController {
} catch {
VPhoneAlert.present(
title: "Developer Mode",
message: "Unable to read Developer Mode status. Check that the guest agent is connected, "
+ "then try again.",
message: "Unable to read Developer Mode status. Check that the guest agent is connected, then try again.",
style: .warning,
)
}
@@ -109,8 +108,7 @@ extension VPhoneMenuController {
} catch {
VPhoneAlert.present(
title: "Guest Agent Hash",
message: "Unable to read the guest agent hash. Check that the guest agent is connected, "
+ "then try again.",
message: "Unable to read the guest agent hash. Check that the guest agent is connected, then try again.",
style: .warning,
)
}
@@ -49,7 +49,7 @@ extension VPhoneMenuController {
uninstallBootstrapItem = uninstallBootstrap
menu.addItem(uninstallBootstrap)
let uninstallNoRestart = makeItem(
"Uninstall Bootstrap… (No Restart)", action: #selector(uninstallBootstrapWithoutRestart),
"Uninstall Bootstrap Without Restarting…", action: #selector(uninstallBootstrapWithoutRestart),
modifiers: [.option],
)
uninstallNoRestart.isAlternate = true
@@ -100,9 +100,8 @@ final class VPhoneConsoleModel {
}
return true
} catch {
let detail = String(describing: error)
status = VPhoneGuestToolStatus(
message: String(localized: "Unable to capture the guest log: \(detail). Check the connection; retrying in 3 seconds.", bundle: VPhoneLocalization.bundle),
message: String(localized: "Unable to capture the guest log. Check the connection. Retrying in 3 seconds.", bundle: VPhoneLocalization.bundle),
isError: true,
)
return false
@@ -136,7 +136,7 @@ struct VPhoneControlsView: View {
await model.setLowPowerMode(enabled)
})
.disabled(!model.canWrite || model.lowPowerMode == nil)
.help("Turn Low Power Mode on or off through powerd")
.help("Turn Low Power Mode on or off")
}
}
@@ -116,7 +116,7 @@ extension VPhoneCrashReport {
var title: String {
switch self {
case .jetsamEvent: String(localized: "Jetsam Event", bundle: VPhoneLocalization.bundle)
case .excResource: String(localized: "Exc Resource", bundle: VPhoneLocalization.bundle)
case .excResource: String(localized: "Resource Limit", bundle: VPhoneLocalization.bundle)
case .excUserFault: String(localized: "User Fault", bundle: VPhoneLocalization.bundle)
case .cpuResource: String(localized: "CPU Resource", bundle: VPhoneLocalization.bundle)
case .diskWritesResource: String(localized: "Disk Writes", bundle: VPhoneLocalization.bundle)
@@ -277,7 +277,7 @@ final class VPhoneDeviceInfoModel {
if developer.bool("enabled") == true {
(developerValue, developerTone) = (Self.text("On"), .good)
} else if developer.bool("armed") == true {
(developerValue, developerTone) = (Self.text("Armed, turns on after restart"), .warning)
(developerValue, developerTone) = (Self.text("On After Restart"), .warning)
} else if developer.bool("enabled") == false {
(developerValue, developerTone) = (Self.text("Off"), nil)
} else {
@@ -355,14 +355,14 @@ final class VPhoneDeviceInfoModel {
/// UIDeviceOrientation raw values.
private static func deviceOrientation(_ value: Int?) -> String {
switch value {
case 1: "portrait"
case 2: "portrait-upside-down"
case 3: "landscape-left"
case 4: "landscape-right"
case 5: "face-up"
case 6: "face-down"
case 1: Self.text("Portrait")
case 2: Self.text("Upside Down")
case 3: Self.text("Landscape Left")
case 4: Self.text("Landscape Right")
case 5: Self.text("Face Up")
case 6: Self.text("Face Down")
case nil: "—"
default: "unknown"
default: Self.text("Unknown")
}
}
}
@@ -162,8 +162,8 @@ final class VPhoneProcessesModel {
await refresh(automatic: true)
let signal = request.signal.name
if let (target, reason) = failures.first, failures.count == request.targets.count, failures.count == 1 {
fail(String(localized: "Unable to send \(signal) to \(target.reference): \(reason). Check that the process still exists, then try again.", bundle: VPhoneLocalization.bundle))
if let (target, _) = failures.first, failures.count == request.targets.count, failures.count == 1 {
fail(String(localized: "Unable to send \(signal) to \(target.reference). Check that the process still exists, then try again.", bundle: VPhoneLocalization.bundle))
} else if !failures.isEmpty {
let names = failures.map(\.0.reference).joined(separator: ", ")
fail(String(localized: "Unable to send \(signal) to \(names). Check that the processes still exist, then try again.", bundle: VPhoneLocalization.bundle))
@@ -63,15 +63,15 @@ enum VPhoneServiceAction: Hashable {
}
}
func failureTitle(_ label: String) -> String {
func failureMessage(_ label: String, reason: String) -> String {
switch self {
case .start: String(localized: "Unable to start \(label).", bundle: VPhoneLocalization.bundle)
case .stop: String(localized: "Unable to stop \(label).", bundle: VPhoneLocalization.bundle)
case .restart: String(localized: "Unable to restart \(label).", bundle: VPhoneLocalization.bundle)
case .enable: String(localized: "Unable to enable \(label).", bundle: VPhoneLocalization.bundle)
case .disable: String(localized: "Unable to disable \(label).", bundle: VPhoneLocalization.bundle)
case let .signal(signal): String(localized: "Unable to send \(signal.title) to \(label).", bundle: VPhoneLocalization.bundle)
case .remove: String(localized: "Unable to remove \(label).", bundle: VPhoneLocalization.bundle)
case .start: String(localized: "Unable to start \(label). \(reason)", bundle: VPhoneLocalization.bundle)
case .stop: String(localized: "Unable to stop \(label). \(reason)", bundle: VPhoneLocalization.bundle)
case .restart: String(localized: "Unable to restart \(label). \(reason)", bundle: VPhoneLocalization.bundle)
case .enable: String(localized: "Unable to enable \(label). \(reason)", bundle: VPhoneLocalization.bundle)
case .disable: String(localized: "Unable to disable \(label). \(reason)", bundle: VPhoneLocalization.bundle)
case let .signal(signal): String(localized: "Unable to send \(signal.title) to \(label). \(reason)", bundle: VPhoneLocalization.bundle)
case .remove: String(localized: "Unable to remove \(label). \(reason)", bundle: VPhoneLocalization.bundle)
}
}
@@ -171,7 +171,7 @@ final class VPhoneServicesModel {
} catch {
guard selection == label else { return }
detail = nil
detailError = String(localized: "Unable to print \(label). \(Self.reason(error))", bundle: VPhoneLocalization.bundle)
detailError = String(localized: "Unable to load details for \(label). \(Self.reason(error))", bundle: VPhoneLocalization.bundle)
}
}
@@ -215,7 +215,7 @@ final class VPhoneServicesModel {
do {
message = try await run(action, on: label)
} catch {
fail(action.failureTitle(label) + " " + Self.reason(error))
fail(action.failureMessage(label, reason: Self.reason(error)))
await refreshRow(label)
return
}
@@ -395,7 +395,7 @@ final class VPhoneUIInspectorModel {
guard case let VPhoneGuestControl.ControlError.guestError(detail) = error, !detail.isEmpty else {
return base
}
return String(localized: "\(base) Guest error: \(detail)", bundle: VPhoneLocalization.bundle)
return String(localized: "\(base) The guest reported: \(detail)", bundle: VPhoneLocalization.bundle)
}
private func succeed(_ message: String) {
@@ -102,7 +102,11 @@ class VPhoneVirtualMachineView: VZVirtualMachineView {
Task { @MainActor in
guard let control, control.isConnected else {
VPhoneAlert.present(title: "Install App Package", message: "Guest is not connected.", style: .warning)
VPhoneAlert.present(
title: "Install App Package",
message: "The guest agent is not connected. Wait for it to connect, then try again.",
style: .warning,
)
return
}
@@ -118,7 +122,11 @@ class VPhoneVirtualMachineView: VZVirtualMachineView {
style: .informational,
)
} catch {
VPhoneAlert.present(title: "Install App Package", message: "\(error)", style: .warning)
VPhoneAlert.present(
title: "Install App Package",
message: "Unable to install the app package. Check the file and guest connection, then try again.",
style: .warning,
)
}
}
return true
@@ -14,20 +14,17 @@ public enum VPhoneArchiveError: Error, CustomStringConvertible {
public var description: String {
switch self {
case let .cannotOpen(path, reason):
"Could not open \(path): \(reason)"
"Unable to open \(path) (\(reason)). Check that the file exists and try again."
case let .readFailed(path, reason):
"Could not read \(path): \(reason)"
"Unable to read \(path) (\(reason)). The file may be damaged. Download it again."
case let .writeFailed(path, reason):
"Could not write \(path): \(reason)"
"Unable to write to \(path) (\(reason)). Check that the folder is writable and has free space, then try again."
case let .memberNotFound(member, archive):
"\(archive) has no member named \(member)"
"\(archive) does not contain '\(member)'."
case let .pathEscapesDestination(member, destination):
"""
Refusing to unpack '\(member)': it resolves outside \(destination).
The archive is malformed or hostile; nothing was written.
"""
"Unable to unpack '\(member)' because it points outside \(destination). The archive may be damaged or unsafe. Nothing was written."
case let .destinationNotWritable(path):
"Cannot write into \(path)"
"Unable to write to \(path). Check that the folder is writable and try again."
case .cancelled:
"Cancelled"
}
@@ -37,7 +34,7 @@ public enum VPhoneArchiveError: Error, CustomStringConvertible {
/// libarchive's own message for a handle, or a stand-in when it has none.
func archiveErrorString(_ handle: OpaquePointer?) -> String {
guard let handle, let message = archive_error_string(handle) else {
return "unknown libarchive error"
return "unknown error"
}
return String(cString: message)
}
@@ -21,12 +21,12 @@ public enum VPhoneIPSWCache {
public var errorDescription: String? {
switch self {
case let .unsupportedSource(source): "Unsupported IPSW source: \(source)"
case let .missingFile(file): "IPSW does not exist: \(file.path)"
case let .invalidManifest(file): "IPSW has no readable BuildManifest.plist: \(file.path)"
case let .unexpectedHTTP(url, status): "IPSW download returned HTTP \(status): \(url)"
case let .unsupportedSource(source): "Unsupported IPSW source: \(source). Use a local file path or an HTTP(S) URL."
case let .missingFile(file): "IPSW not found at \(file.path). Check the path and try again."
case let .invalidManifest(file): "\(file.path) is not a valid IPSW. Choose a different file."
case let .unexpectedHTTP(url, _): "Unable to download the IPSW from \(url). Try again later."
case let .incompleteDownload(url, expected, actual):
"IPSW download from \(url) is incomplete: \(actual) of \(expected) bytes"
"The IPSW download from \(url) is incomplete (\(actual) of \(expected) bytes). Try again."
}
}
}
@@ -33,15 +33,15 @@ public enum VPhoneAEA {
public var errorDescription: String? {
switch self {
case let .notAEA(url):
"\(url.lastPathComponent) does not start with AEA1"
"\(url.lastPathComponent) is not an encrypted firmware archive."
case let .truncatedPrologue(url):
"\(url.lastPathComponent) has a truncated AEA prologue"
case let .missingAuthField(name):
"the AEA prologue has no '\(name)' field"
"\(url.lastPathComponent) is incomplete or damaged. Download the firmware again."
case .missingAuthField:
"The firmware file is missing decryption key information. Download the firmware again."
case .malformedFCSResponse:
"the AEA prologue's fcs-response is not the expected JSON"
case let .keyFetchFailed(url, status):
"fetching the AEA key from \(url.host() ?? url.absoluteString) returned HTTP \(status)"
"Unable to read the decryption key information in this firmware file. Download the firmware again."
case let .keyFetchFailed(url, _):
"Unable to download the decryption key from \(url.host() ?? url.absoluteString). Try again later."
}
}
}
@@ -35,14 +35,14 @@ public enum VPhoneFirmwareIndex {
public var errorDescription: String? {
switch self {
case let .fetchFailed(url, status):
"HTTP \(status) from \(url.host() ?? url.absoluteString)"
case let .fetchFailed(url, _):
"Unable to download the firmware catalog from \(url.host() ?? url.absoluteString). Check your connection and try again."
case let .unexpectedShape(url):
"\(url.host() ?? url.absoluteString) answered in a shape this does not understand"
"Unable to read the firmware catalog from \(url.host() ?? url.absoluteString). Try again later."
case .decompressionFailed:
"the firmware catalogue did not decompress"
"Unable to read the firmware catalog. Try again later."
case let .noRelease(os, what):
"the firmware catalogue lists no released \(os) \(what)"
"No released \(os) \(what) found in the firmware catalog."
}
}
}
@@ -43,17 +43,17 @@ public struct VPhoneRemoteZip: Sendable {
public var errorDescription: String? {
switch self {
case let .notSeekable(url):
"\(url.host() ?? "the server") does not support range requests, so the archive cannot be read without downloading all of it"
"\(url.host() ?? "The server") does not support partial downloads, so the archive must be downloaded in full."
case let .noEndOfCentralDirectory(url):
"\(url.lastPathComponent) does not end in a zip central directory"
case let .malformed(what):
"malformed zip: \(what)"
"\(url.lastPathComponent) is not a valid ZIP archive. Download it again."
case .malformed:
"The ZIP archive is damaged. Download it again."
case let .notFound(name):
"no member matching '\(name)' in the archive"
case let .unsupportedCompression(method):
"zip compression method \(method) is not supported (only stored and deflate)"
case let .http(status, url):
"HTTP \(status) from \(url.host() ?? url.absoluteString)"
"The archive does not contain '\(name)'."
case .unsupportedCompression:
"The archive uses an unsupported compression method. Download the full IPSW instead."
case let .http(_, url):
"Unable to download from \(url.host() ?? url.absoluteString). Try again later."
}
}
}
@@ -20,13 +20,13 @@ extension VPhoneRestoreError: LocalizedError {
public var errorDescription: String? {
switch self {
case .ecidUnresolved:
"No ECID: pass --ecid, or restore a bundle whose udid-prediction.txt carries one"
"ECID not found. Pass --ecid, or restore a VM whose udid-prediction.txt includes one."
case .noSHSH:
"No cached .shsh in the bundle; run `restore --get-shsh` first, or drop the --offline flag"
"No saved SHSH blob found for this VM. Run `restore --get-shsh` first, or run without --offline."
case let .aeaDecryptFailed(name):
"Could not decrypt \(name)"
"Unable to decrypt \(name). Download the firmware again and retry the restore."
case let .aeaStillEncrypted(name):
"\(name) is still AEA-encrypted after decryption reported success"
"\(name) is still encrypted after decryption. Download the firmware again and retry the restore."
}
}
}
@@ -27,12 +27,7 @@ extension VPhoneNetworkingError: CustomStringConvertible, LocalizedError {
case .noBridgeInterfaces:
"Bridged mode needs a host network interface, but none are available. Use nat instead."
case .bridgeInterfaceMustBeNamed:
"""
Bridged mode needs an interface name here. vphone-cli carries no \
entitlements, so it cannot list the host's bridgeable interfaces \
and cannot pick one for you.
Name it explicitly, for example: --bridge-interface en0
"""
"Bridged mode requires an interface name because vphone-cli cannot list host interfaces. Pass one with --bridge-interface, for example --bridge-interface en0."
case .bridgeInterfaceWithoutBridgedMode:
"--bridge-interface is only valid with --network bridged."
}
@@ -13,8 +13,8 @@ public enum VPhoneGuestBinaries {
switch self {
case let .missing(name, searched):
"""
no prebuilt guest binary '\(name)'. Build the VPhone scheme in VPhone.xcworkspace.
Looked in: \(searched.map(\.path).joined(separator: ", "))
Guest binary '\(name)' not found. Build the VPhone scheme in VPhone.xcworkspace.
Searched: \(searched.map(\.path).joined(separator: ", "))
"""
}
}
@@ -15,16 +15,14 @@ public enum VPhoneGuestLaunchError: Error, CustomStringConvertible {
switch self {
case let .missingCompanion(name, url):
return """
\(name) is missing — expected it next to this binary at:
\(url.path)
The bundle is incomplete. Rebuild the VPhone scheme in Xcode.
\(name) not found at \(url.path). The app bundle is incomplete. Rebuild the VPhone scheme in Xcode.
"""
case let .blockedByAMFI(guest, cdHash):
let helper = guest.deletingLastPathComponent()
.appendingPathComponent("VPhoneEscalator")
return """
amfid refused to launch vphone-vm, so no guest could start.
AMFI blocked vphone-vm, so the VM could not start.
Binary: \(guest.path)
CDHash: \(cdHash ?? "unavailable")
@@ -39,15 +37,14 @@ public enum VPhoneGuestLaunchError: Error, CustomStringConvertible {
case let .missingEntitlements(guest):
return """
vphone-vm is missing the private PV=3 entitlements: \(guest.path)
vphone-vm at \(guest.path) is missing required virtualization entitlements.
Rebuild the VPhone scheme in Xcode to sign this binary, then allow the new signature
through the host's AMFI policy before launching a VM.
"""
case let .probeFailed(code, output):
return """
vphone-vm could not start (exit \(code)). This is not the signature \
of an amfid refusal, so AMFI is not what to look at.
vphone-vm could not start (exit code \(code)). AMFI did not block it.
\(output.isEmpty ? "It produced no output." : output)
"""
}
@@ -18,11 +18,9 @@ extension VPhoneManifestError: CustomStringConvertible, LocalizedError {
case let .parseFailed(path):
"The VM configuration at \(path) is not valid. Recreate the VM, or restore a backup of config.plist."
case let .unsupportedSchema(path, found):
"The VM configuration at \(path) has \(found.map { "schema version \($0)" } ?? "no valid schema version"). "
+ "vphone 2.x requires schema version 2. Recreate this VM with `vphone-cli vm create`."
"The VM configuration at \(path) has \(found.map { "schema version \($0)" } ?? "no valid schema version"). vphone 2.x requires schema version 2. Recreate this VM with `vphone-cli vm create`."
case let .unsupportedRuntimeVersion(version):
"This vphone build reports version \(version). VMs with schema version 2 require vphone 2.x. "
+ "Install a vphone 2.x build before launching this VM."
"This vphone build is version \(version). VMs with schema version 2 require vphone 2.x. Install vphone 2.x before launching this VM."
case let .writeFailed(path):
"Unable to save the VM configuration to \(path). Check that the file is writable and try again."
}
@@ -41,8 +41,8 @@ final class VPhoneLaunchpadAppDelegate: NSObject, NSApplicationDelegate {
return .terminateNow
}
let alert = NSAlert()
alert.messageText = String(localized: "Stop creating the machine?")
alert.informativeText = String(localized: "Quitting stops the New Machine pipeline. You can retry it later from the step it stopped at.")
alert.messageText = String(localized: "Stop Creating Machine?")
alert.informativeText = String(localized: "Quitting stops creating this machine. You can retry later from the step where it stopped.")
alert.addButton(withTitle: String(localized: "Quit"))
alert.addButton(withTitle: String(localized: "Cancel"))
return alert.runModal() == .alertFirstButtonReturn ? .terminateNow : .terminateCancel
@@ -127,7 +127,7 @@ struct VPhoneLaunchpadCommandLine {
try Task.checkCancellation()
guard result.succeeded else {
throw VPhoneLaunchpadError(
String(localized: "\(Self.display(arguments)) exited with status \(result.status)."),
String(localized: "\(Self.display(arguments)) failed. Check the output and try again."),
detail: result.tail,
)
}
@@ -188,7 +188,7 @@ final class VPhoneLaunchpadCoreBundle {
$0.preflight = result.succeeded ? .passed : .failed
$0.preflightDetail = result.succeeded
? String(localized: "Passed")
: (result.lines.last ?? String(localized: "Exit status \(result.status)"))
: (result.lines.last ?? String(localized: "Preflight failed"))
.replacingOccurrences(of: "Error: ", with: "")
}
} catch {
@@ -228,7 +228,7 @@ final class VPhoneLaunchpadCoreBundle {
set(.verify, .running)
guard digest == release.sha256.lowercased() else {
throw VPhoneLaunchpadError(
String(localized: "The download does not match the published SHA-256."),
String(localized: "The download could not be verified. Try again."),
detail: String(localized: "Expected \(release.sha256)\nReceived \(digest)"),
)
}
@@ -250,7 +250,7 @@ final class VPhoneLaunchpadCoreBundle {
set(.preflight, installed?.preflight ?? .failed)
if installed?.preflight != .passed {
throw VPhoneLaunchpadError(
String(localized: "Host preflight did not pass."),
String(localized: "Host preflight failed. Fix the issue below, then choose Run Preflight Again."),
detail: installed?.preflightDetail,
)
}
@@ -259,7 +259,7 @@ final class VPhoneLaunchpadCoreBundle {
set(step, .failed)
}
progress?.error = error as? VPhoneLaunchpadError
?? VPhoneLaunchpadError(String(localized: "The install failed."), detail: error.localizedDescription)
?? VPhoneLaunchpadError(String(localized: "Unable to install the bundle. Try again."), detail: error.localizedDescription)
}
}
@@ -282,7 +282,7 @@ final class VPhoneLaunchpadCoreBundle {
do {
try await helper.removeBundle(version: version)
} catch {
actionError = VPhoneLaunchpadError(String(localized: "VPhone.bundle \(version) could not be removed."), detail: "\(error)")
actionError = VPhoneLaunchpadError(String(localized: "Unable to Remove VPhone.bundle \(version)"), detail: error.localizedDescription)
}
loadInstalled()
}
@@ -45,7 +45,7 @@ struct VPhoneLaunchpadCoreBundleView: View {
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.help("List releases again and rerun host preflight")
.help("Reload releases and run host preflight again.")
.disabled(bundles.isInstalling)
}
}
@@ -88,7 +88,7 @@ struct VPhoneLaunchpadCoreBundleView: View {
Section {
if progress.status(.download) == .running {
ProgressView(value: Double(progress.received), total: Double(max(progress.release.size, 1))) {
Text("Downloading")
Text("Downloading…")
} currentValueLabel: {
Text("\(Self.size(progress.received)) of \(Self.size(progress.release.size))")
}
@@ -27,7 +27,7 @@ nonisolated struct VPhoneLaunchpadRelease: Identifiable, Hashable, Sendable {
request.setValue("vphone-launchpad", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw VPhoneLaunchpadError(String(localized: "GitHub answered the release list with an error."))
throw VPhoneLaunchpadError(String(localized: "Unable to load releases from GitHub. Try again later."))
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
@@ -80,7 +80,7 @@ nonisolated struct VPhoneLaunchpadRelease: Identifiable, Hashable, Sendable {
request.setValue("vphone-launchpad", forHTTPHeaderField: "User-Agent")
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw VPhoneLaunchpadError(String(localized: "The download of \(assetName) failed."))
throw VPhoneLaunchpadError(String(localized: "Unable to download \(assetName). Check your connection and try again."))
}
let directory = FileManager.default.temporaryDirectory
@@ -69,8 +69,8 @@ final class VPhoneLaunchpadHelperClient {
func install() async throws {
guard isConfigured else {
throw VPhoneLaunchpadError(
String(localized: "This build has no signing team."),
detail: String(localized: "Set VPHONE_LAUNCHPAD_TEAM, rebuild, and sign the app before installing the helper."),
String(localized: "Unable to Install Helper"),
detail: String(localized: "This build has no signing team. Rebuild and sign the app with your team, then try again."),
)
}
try await Task.detached { try Self.bless() }.value
@@ -92,7 +92,7 @@ final class VPhoneLaunchpadHelperClient {
var authorization: AuthorizationRef?
var status = AuthorizationCreate(nil, nil, [], &authorization)
guard status == errAuthorizationSuccess, let authorization else {
throw VPhoneLaunchpadError(String(localized: "Cannot create an authorization (OSStatus \(status))."))
throw VPhoneLaunchpadError(String(localized: "Unable to Get Administrator Permission"), detail: String(localized: "Try again."))
}
defer { AuthorizationFree(authorization, []) }
@@ -113,13 +113,13 @@ final class VPhoneLaunchpadHelperClient {
if status == errAuthorizationCanceled {
throw CancellationError()
}
throw VPhoneLaunchpadError(String(localized: "Administrator authorization failed (OSStatus \(status))."))
throw VPhoneLaunchpadError(String(localized: "Unable to Get Administrator Permission"), detail: String(localized: "Try again."))
}
var error: Unmanaged<CFError>?
guard SMJobBless(kSMDomainSystemLaunchd, label as CFString, authorization, &error) else {
let reason = error.map { "\($0.takeRetainedValue())" } ?? String(localized: "Unknown error")
throw VPhoneLaunchpadError(String(localized: "The helper could not be installed."), detail: reason)
error?.release()
throw VPhoneLaunchpadError(String(localized: "Unable to Install Helper"), detail: String(localized: "Try again."))
}
}
@@ -216,7 +216,7 @@ final class VPhoneLaunchpadHelperClient {
once.resume(.failure(error))
}
guard let helper = proxy as? VPhoneLaunchpadHelperProtocol else {
once.resume(.failure(VPhoneLaunchpadError(String(localized: "The helper connection has the wrong interface."))))
once.resume(.failure(VPhoneLaunchpadError(String(localized: "Unable to connect to the helper. Quit and reopen the app, then try again."))))
return
}
body(helper) { once.resume($0) }
@@ -246,7 +246,7 @@ final class VPhoneLaunchpadHelperClient {
once.resume(.failure(VPhoneLaunchpadError(String(localized: "The helper did not answer."))))
}
guard let helper = proxy as? VPhoneLaunchpadHelperProtocol else {
once.resume(.failure(VPhoneLaunchpadError(String(localized: "The helper connection has the wrong interface."))))
once.resume(.failure(VPhoneLaunchpadError(String(localized: "Unable to connect to the helper. Quit and reopen the app, then try again."))))
return
}
body(helper) { once.resume($0) }
@@ -132,14 +132,14 @@ final class VPhoneLaunchpadHostSetup {
)!
func installHelper() async {
update(.helper, (.running, String(localized: "Waiting for an administrator…")))
update(.helper, (.running, String(localized: "Waiting for administrator approval…")))
do {
try await helper.install()
} catch is CancellationError {
} catch let error as VPhoneLaunchpadError {
actionError = error
} catch {
actionError = VPhoneLaunchpadError(String(localized: "The helper could not be installed."), detail: "\(error)")
actionError = VPhoneLaunchpadError(String(localized: "Unable to Install Helper"), detail: String(localized: "Try again."))
}
update(.helper, helperStatus())
}
@@ -190,13 +190,13 @@ final class VPhoneLaunchpadHostSetup {
nonisolated private static func macOSVersion() -> (VPhoneLaunchpadStatus, String) {
let version = ProcessInfo.processInfo.operatingSystemVersion
let text = "\(version.majorVersion).\(version.minorVersion)"
return version.majorVersion >= 15 ? (.passed, text) : (.failed, String(localized: "\(text) is too old"))
return version.majorVersion >= 15 ? (.passed, text) : (.failed, String(localized: "macOS \(text) is not supported"))
}
nonisolated private static func physicalMac() -> (VPhoneLaunchpadStatus, String) {
let present = sysctlInt("kern.hv_vmm_present") ?? 0
return present == 0
? (.passed, "kern.hv_vmm_present = 0")
? (.passed, String(localized: "Not a virtual machine"))
: (.failed, String(localized: "Running in a virtual machine"))
}
@@ -265,7 +265,7 @@ final class VPhoneLaunchpadHostSetup {
func applyPreview(blocked: Bool) {
update(.appleSilicon, (.passed, "arm64"))
update(.macOS, (.passed, "27.0"))
update(.physicalMac, (.passed, "kern.hv_vmm_present = 0"))
update(.physicalMac, (.passed, String(localized: "Not a virtual machine")))
update(.libraryVolume, (.passed, "~/.vphone/machines"))
update(.developerTools, blocked ? (.pending, String(localized: "Not requested")) : (.passed, String(localized: "Allowed")))
update(.helper, blocked ? (.pending, String(localized: "Not installed")) : (.passed, String(localized: "Version \("1")")))
@@ -24,7 +24,7 @@ struct VPhoneLaunchpadHostSetupView: View {
if !host.requiredPassed {
VStack(alignment: .leading, spacing: 4) {
if host.checks.contains(where: { $0.kind == .developerTools && $0.status != .passed }) {
Text("After allowing vphone-launchpad under Privacy & Security → Developer Tools, quit and reopen it.")
Text("Allow vphone-launchpad in Privacy & Security → Developer Tools, then quit and reopen the app.")
}
Text("Core Bundle appears once every required check passes.")
}
File diff suppressed because it is too large Load Diff
@@ -48,13 +48,13 @@ final class VPhoneLaunchpadCreationPipeline {
var title: String {
switch self {
case .create: String(localized: "Create bundle")
case .create: String(localized: "Create machine")
case .prepare: String(localized: "Download firmware")
case .patch: String(localized: "Patch boot chain")
case .bootDFU: String(localized: "Boot into DFU")
case .waitDFU: String(localized: "Wait for DFU")
case .restore: String(localized: "Restore")
case .stopDFU: String(localized: "Stop VM")
case .stopDFU: String(localized: "Stop machine")
case .installCFW: String(localized: "Install CFW")
case .firstBoot: String(localized: "First boot")
}
@@ -188,7 +188,7 @@ final class VPhoneLaunchpadCreationPipeline {
private func perform(_ step: Step) async throws {
guard let commandLine = bundles.commandLine() else {
throw VPhoneLaunchpadError(String(localized: "No core bundle is active."))
throw VPhoneLaunchpadError(String(localized: "No Core Bundle version is in use. Choose a version in Core Bundle."))
}
let name = options.name
let library = ["--library-root", libraryRoot.path]
@@ -235,7 +235,7 @@ final class VPhoneLaunchpadCreationPipeline {
try requireDFURunning()
try await Task.sleep(for: .seconds(1))
}
throw VPhoneLaunchpadError(String(localized: "The DFU boot did not write udid-prediction.txt within 30 seconds."))
throw VPhoneLaunchpadError(String(localized: "The machine did not enter DFU mode within 30 seconds."))
case .waitDFU:
let ecid = try Self.ecid(in: machine)
@@ -253,7 +253,7 @@ final class VPhoneLaunchpadCreationPipeline {
}
try await Task.sleep(for: .seconds(2))
}
throw VPhoneLaunchpadError(String(localized: "Timed out waiting for the device to enter recovery mode."))
throw VPhoneLaunchpadError(String(localized: "The machine did not respond in DFU mode."))
case .restore:
try requireDFURunning()
@@ -275,7 +275,7 @@ final class VPhoneLaunchpadCreationPipeline {
case .installCFW:
guard let version = bundles.activeVersion else {
throw VPhoneLaunchpadError(String(localized: "No core bundle is active."))
throw VPhoneLaunchpadError(String(localized: "No Core Bundle version is in use. Choose a version in Core Bundle."))
}
let status = try await helper.installCustomFirmware(
bundleVersion: version,
@@ -286,7 +286,7 @@ final class VPhoneLaunchpadCreationPipeline {
onLine: output,
)
guard status == 0 else {
throw VPhoneLaunchpadError(String(localized: "cfw install exited with status \(status)."), detail: log.suffix(12).joined(separator: "\n"))
throw VPhoneLaunchpadError(String(localized: "Unable to install CFW. Check the log for details."), detail: log.suffix(12).joined(separator: "\n"))
}
case .firstBoot:
@@ -310,10 +310,10 @@ final class VPhoneLaunchpadCreationPipeline {
for _ in 0 ..< 300 {
try Task.checkCancellation()
if (library.consoles[name] ?? []).contains(where: Self.isPanic) {
throw VPhoneLaunchpadError(String(localized: "The guest panicked on first boot."), detail: String(localized: "See the machine's console."))
throw VPhoneLaunchpadError(String(localized: "The machine had a kernel panic during first boot."), detail: String(localized: "See the machine's console."))
}
guard child.isRunning else {
throw VPhoneLaunchpadError(String(localized: "The VM exited before vphoned answered."))
throw VPhoneLaunchpadError(String(localized: "The machine stopped before first boot finished."))
}
if await Task.detached(operation: { Self.ping(socketPath: socket) }).value {
append("vphoned answered")
@@ -321,12 +321,12 @@ final class VPhoneLaunchpadCreationPipeline {
}
try await Task.sleep(for: .seconds(1))
}
throw VPhoneLaunchpadError(String(localized: "vphoned did not answer within 300 seconds."))
throw VPhoneLaunchpadError(String(localized: "The machine did not finish starting within 5 minutes."))
}
private func requireDFURunning() throws {
guard dfu?.isRunning == true else {
throw VPhoneLaunchpadError(String(localized: "The DFU boot exited."), detail: dfuLines.suffix(12).joined(separator: "\n"))
throw VPhoneLaunchpadError(String(localized: "The machine stopped while in DFU mode."), detail: dfuLines.suffix(12).joined(separator: "\n"))
}
}
@@ -355,7 +355,7 @@ final class VPhoneLaunchpadCreationPipeline {
if let suffix = udid.split(separator: "-", maxSplits: 1).last, udid.contains("-") {
return String(suffix)
}
throw VPhoneLaunchpadError(String(localized: "udid-prediction.txt has no ECID."))
throw VPhoneLaunchpadError(String(localized: "Unable to read the device ECID. Try again."))
}
nonisolated static func canonicalPath(_ url: URL) -> String {
@@ -123,7 +123,7 @@ struct VPhoneLaunchpadMachineInspector: View {
private var commands: some View {
let entries = Array(model.history.entries.suffix(12).reversed())
if entries.isEmpty {
Text("Commands Launchpad runs appear here.")
Text("Commands that Launchpad runs appear here.")
.foregroundStyle(.secondary)
}
ForEach(entries) { entry in
@@ -199,12 +199,12 @@ final class VPhoneLaunchpadMachineLibrary {
await refresh()
}
} catch {
actionError = VPhoneLaunchpadError(String(localized: "\(name) could not be started."), detail: error.localizedDescription)
actionError = VPhoneLaunchpadError(String(localized: "Unable to Start \(name)"), detail: error.localizedDescription)
}
}
func stop(_ name: String) async {
await perform(String(localized: "Stopping"), on: name, ["vm", "stop", name] + libraryArguments)
await perform(String(localized: "Stopping…"), on: name, ["vm", "stop", name] + libraryArguments)
launched[name]?.interrupt()
}
@@ -224,23 +224,23 @@ final class VPhoneLaunchpadMachineLibrary {
if let bridgeInterface, !bridgeInterface.isEmpty {
arguments += ["--bridge-interface", bridgeInterface]
}
await perform(String(localized: "Saving settings"), on: name, arguments)
await perform(String(localized: "Saving settings…"), on: name, arguments)
}
func rename(_ name: String, to newName: String) async {
if await perform(String(localized: "Renaming"), on: name, ["vm", "rename", name, newName] + libraryArguments) {
if await perform(String(localized: "Renaming…"), on: name, ["vm", "rename", name, newName] + libraryArguments) {
selection = newName
}
}
func clone(_ name: String, as newName: String) async {
if await perform(String(localized: "Cloning"), on: name, ["vm", "clone", name, newName] + libraryArguments) {
if await perform(String(localized: "Cloning…"), on: name, ["vm", "clone", name, newName] + libraryArguments) {
selection = newName
}
}
func delete(_ name: String) async {
await perform(String(localized: "Deleting"), on: name, ["vm", "delete", name, "--force"] + libraryArguments)
await perform(String(localized: "Deleting…"), on: name, ["vm", "delete", name, "--force"] + libraryArguments)
}
func export(_ name: String, to destination: URL, densest: Bool, includeIPSW: Bool) async {
@@ -251,7 +251,7 @@ final class VPhoneLaunchpadMachineLibrary {
if includeIPSW {
arguments.append("--include-ipsw")
}
await perform(String(localized: "Exporting"), on: name, arguments)
await perform(String(localized: "Exporting…"), on: name, arguments)
}
func importArchive(_ archive: URL) async {
@@ -281,7 +281,7 @@ final class VPhoneLaunchpadMachineLibrary {
return true
} catch {
actionError = error as? VPhoneLaunchpadError
?? VPhoneLaunchpadError(String(localized: "The command failed."), detail: error.localizedDescription)
?? VPhoneLaunchpadError(String(localized: "Unable to Complete Action"), detail: error.localizedDescription)
await refresh()
return false
}
@@ -90,7 +90,7 @@ struct VPhoneLaunchpadNameSheet: View {
Section {
TextField("Name", text: $name)
} footer: {
Text(note ?? "Letters, digits, dots, dashes and underscores.")
Text(note ?? "Use letters, numbers, periods, hyphens, and underscores.")
.foregroundStyle(.secondary)
}
}
@@ -126,12 +126,12 @@ struct VPhoneLaunchpadExportView: View {
var body: some View {
Form {
Section {
Toggle("Densest compression", isOn: $densest)
Toggle("Maximum compression", isOn: $densest)
Toggle("Include the restore IPSW directory", isOn: $includeIPSW)
} footer: {
Text(densest
? "Writes a .txz archive with xz -9. Smaller, and much slower."
: "Writes a .tzst archive with zstd -3.")
? "Creates a smaller .txz archive. Export takes much longer."
: "Creates a .tzst archive.")
.foregroundStyle(.secondary)
}
}
@@ -237,7 +237,7 @@ struct VPhoneLaunchpadMachinesView: View {
title: "Clone \(name)",
action: "Clone",
initial: "\(name)-clone",
note: "The clone boots as a new device. SEP storage is copied as is, so a restored machine may need restoring again.",
note: "The clone starts as a new device. Its Secure Enclave data is copied unchanged, so you may need to restore the clone again.",
) { newName in
Task { await library.clone(name, as: newName) }
}
@@ -78,7 +78,7 @@ struct VPhoneLaunchpadNewMachineView: View {
Section {
Toggle("Frida Stalker kernel relaxations", isOn: $enableFrida)
Toggle("Zero the dyld cache maxSlide", isOn: $forceMaxSlide)
Toggle("Disable dyld shared cache randomization", isOn: $forceMaxSlide)
Toggle("Keep the prepared restore tree", isOn: $keepArtifacts)
} header: {
Text("Options")
@@ -127,14 +127,14 @@ struct VPhoneLaunchpadNewMachineView: View {
} else {
HStack {
ProgressView().controlSize(.small)
Text("Loading fw catalog…").foregroundStyle(.secondary)
Text("Loading firmware catalog…").foregroundStyle(.secondary)
}
}
} header: {
Text("Firmware")
} footer: {
if !usesCustomSources, let catalog {
Text("Recommended pairings for \(catalog.device) from vphone-cli fw catalog.")
Text("Recommended firmware pairings for \(catalog.device).")
.foregroundStyle(.secondary)
}
}
@@ -238,7 +238,7 @@ struct VPhoneLaunchpadCreationView: View {
}
}
} else if creation.isRunning {
Text("Closing this window keeps it running.").foregroundStyle(.secondary)
Text("Creation continues if you close this window.").foregroundStyle(.secondary)
}
}
Section("Log") {
@@ -10,7 +10,7 @@ enum VPhoneLaunchpadHelperBundleInstaller {
}
let expected = sha256.lowercased().replacingOccurrences(of: "sha256:", with: "")
guard expected.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else {
throw VPhoneLaunchpadHelperError("\"\(sha256)\" is not a SHA-256 digest.")
throw VPhoneLaunchpadHelperError("The published SHA-256 \"\(sha256)\" is not valid.")
}
let fileManager = FileManager.default
@@ -29,16 +29,16 @@ enum VPhoneLaunchpadHelperBundleInstaller {
let archiveURL = staging.appendingPathComponent("VPhone.zip")
let actual = try copyAndHash(from: archive, to: archiveURL)
guard actual == expected else {
throw VPhoneLaunchpadHelperError("SHA-256 mismatch: expected \(expected), got \(actual).")
throw VPhoneLaunchpadHelperError("The download does not match the published SHA-256. Download it again.")
}
let extracted = staging.appendingPathComponent("extracted", isDirectory: true)
try runTool("/usr/bin/ditto", ["-x", "-k", "--noqtn", archiveURL.path, extracted.path])
let bundle = extracted.appendingPathComponent("VPhone.bundle", isDirectory: true)
try requireDirectory(bundle, "The archive does not contain VPhone.bundle.")
try requireDirectory(bundle, "The download does not contain VPhone.bundle. Download it again.")
for name in VPhoneLaunchpadBundleStore.pinnedExecutables {
let executable = bundle.appendingPathComponent("Contents/MacOS/\(name)")
try requireRegularFile(executable, "VPhone.bundle has no \(name).")
try requireRegularFile(executable, "VPhone.bundle is missing \(name). Download it again.")
}
try VPhoneLaunchpadHelperCodeCheck.requireValidBundle(bundle)
@@ -104,7 +104,7 @@ enum VPhoneLaunchpadHelperBundleInstaller {
info.st_mode & 0o022 == 0
else {
throw VPhoneLaunchpadHelperError(
"\(directory.path) must be a directory owned by root and not writable by others.",
"\(directory.path) is not secure. It must be a folder owned by root that only root can modify.",
)
}
}
@@ -118,7 +118,7 @@ enum VPhoneLaunchpadHelperBundleInstaller {
contents: nil,
attributes: [.posixPermissions: 0o600],
) else {
throw VPhoneLaunchpadHelperError("Cannot create \(destination.path).")
throw VPhoneLaunchpadHelperError("Unable to save the download. Try again.")
}
let output = try FileHandle(forWritingTo: destination)
defer { try? output.close() }
@@ -143,10 +143,10 @@ enum VPhoneLaunchpadHelperBundleInstaller {
for path in paths {
var info = stat()
guard lstat(path, &info) == 0 else {
throw VPhoneLaunchpadHelperError("Cannot inspect \(path).")
throw VPhoneLaunchpadHelperError("Unable to install VPhone.bundle. Try again.")
}
guard lchown(path, 0, 0) == 0 else {
throw VPhoneLaunchpadHelperError("Cannot change the owner of \(path).")
throw VPhoneLaunchpadHelperError("Unable to install VPhone.bundle. Try again.")
}
if (info.st_mode & S_IFMT) != S_IFLNK {
chmod(path, info.st_mode & 0o7755)
@@ -179,7 +179,7 @@ enum VPhoneLaunchpadHelperBundleInstaller {
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw VPhoneLaunchpadHelperError(
"\(URL(fileURLWithPath: path).lastPathComponent) exited with status \(process.terminationStatus).",
"Unable to extract the downloaded archive. Download it again.",
)
}
}
@@ -29,8 +29,7 @@ enum VPhoneLaunchpadHelperCodeCheck {
var error: Unmanaged<CFError>?
let status = SecStaticCodeCheckValidityWithErrors(code, flags, nil, &error)
guard status == errSecSuccess else {
let reason = error.map { "\($0.takeRetainedValue())" } ?? "OSStatus \(status)"
throw VPhoneLaunchpadHelperError("VPhone.bundle failed signature validation: \(reason)")
throw VPhoneLaunchpadHelperError("VPhone.bundle has an invalid code signature. Download it again.")
}
}
@@ -43,7 +42,7 @@ enum VPhoneLaunchpadHelperCodeCheck {
let dictionary = information as? [String: Any],
let hash = dictionary[kSecCodeInfoUnique as String] as? Data
else {
throw VPhoneLaunchpadHelperError("\(url.lastPathComponent) has no readable code signature.")
throw VPhoneLaunchpadHelperError("Unable to read the code signature of \(url.lastPathComponent). Reinstall VPhone.bundle.")
}
return hash.map { String(format: "%02x", $0) }.joined()
}
@@ -51,12 +50,12 @@ enum VPhoneLaunchpadHelperCodeCheck {
/// Refuses to go on unless `url` still has the cdhash recorded at install.
static func requireCDHash(_ url: URL, _ expected: String?) throws {
guard let expected else {
throw VPhoneLaunchpadHelperError("The bundle receipt has no cdhash for \(url.lastPathComponent).")
throw VPhoneLaunchpadHelperError("Unable to verify \(url.lastPathComponent). Reinstall VPhone.bundle.")
}
let actual = try cdhash(of: url)
guard actual == expected else {
throw VPhoneLaunchpadHelperError(
"\(url.lastPathComponent) changed since it was installed (cdhash \(actual), expected \(expected)).",
"\(url.lastPathComponent) was modified after installation. Reinstall VPhone.bundle.",
)
}
}
@@ -65,7 +64,7 @@ enum VPhoneLaunchpadHelperCodeCheck {
var code: SecStaticCode?
let status = SecStaticCodeCreateWithPath(url as CFURL, [], &code)
guard status == errSecSuccess, let code else {
throw VPhoneLaunchpadHelperError("Cannot read the code signature of \(url.path) (OSStatus \(status)).")
throw VPhoneLaunchpadHelperError("Unable to read the code signature of \(url.lastPathComponent). Reinstall VPhone.bundle.")
}
return code
}
@@ -23,7 +23,7 @@ struct VPhoneLaunchpadHelperFirmwareRequest {
throw VPhoneLaunchpadHelperError("\"\(bundleVersion)\" is not a valid bundle version.")
}
guard let receipt = VPhoneLaunchpadBundleReceipt.load(version: bundleVersion) else {
throw VPhoneLaunchpadHelperError("VPhone.bundle \(bundleVersion) is not installed.")
throw VPhoneLaunchpadHelperError("VPhone.bundle \(bundleVersion) is not installed. Install it in Core Bundle, then try again.")
}
let executable = VPhoneLaunchpadBundleStore.executable(version: bundleVersion, named: "vphone-cli")
try VPhoneLaunchpadHelperCodeCheck.requireCDHash(executable, receipt.cdhashes["vphone-cli"])
@@ -34,12 +34,12 @@ struct VPhoneLaunchpadHelperFirmwareRequest {
// The path must already be canonical: no symlink anywhere in it, so a
// component cannot be swapped to point root somewhere else.
guard libraryRoot.hasPrefix("/"), let resolved = realpath(libraryRoot, nil) else {
throw VPhoneLaunchpadHelperError("The library root \(libraryRoot) does not exist.")
throw VPhoneLaunchpadHelperError("The library folder \(libraryRoot) does not exist.")
}
let canonical = String(cString: resolved)
free(resolved)
guard canonical == libraryRoot else {
throw VPhoneLaunchpadHelperError("The library root must be a canonical path without symlinks.")
throw VPhoneLaunchpadHelperError("The library path cannot include symbolic links.")
}
let machine = URL(fileURLWithPath: libraryRoot, isDirectory: true)
.appendingPathComponent(machineName, isDirectory: true)
@@ -47,7 +47,7 @@ struct VPhoneLaunchpadHelperFirmwareRequest {
try Self.requireDirectory(machine.path, ownedBy: callerUID)
guard let account = getpwuid(callerUID) else {
throw VPhoneLaunchpadHelperError("Unknown user \(callerUID).")
throw VPhoneLaunchpadHelperError("Unable to find the user account with ID \(callerUID).")
}
let userName = String(cString: account.pointee.pw_name)
let home = String(cString: account.pointee.pw_dir)
@@ -81,10 +81,10 @@ struct VPhoneLaunchpadHelperFirmwareRequest {
private static func requireDirectory(_ path: String, ownedBy uid: uid_t) throws {
var info = stat()
guard lstat(path, &info) == 0, (info.st_mode & S_IFMT) == S_IFDIR else {
throw VPhoneLaunchpadHelperError("\(path) is not a directory.")
throw VPhoneLaunchpadHelperError("\(path) is not a folder.")
}
guard info.st_uid == uid else {
throw VPhoneLaunchpadHelperError("\(path) is not owned by the requesting user.")
throw VPhoneLaunchpadHelperError("\(path) is not owned by your user account.")
}
}
}
@@ -96,7 +96,7 @@ final class VPhoneLaunchpadHelperService: NSObject, VPhoneLaunchpadHelperProtoco
Self.firmwareLock.lock()
guard Self.firmwareProcess == nil else {
Self.firmwareLock.unlock()
reply(-1, "Another CFW install is already running.")
reply(-1, "Another CFW install is in progress. Wait for it to finish, then try again.")
return
}
Self.firmwareProcess = process
@@ -110,7 +110,7 @@ final class VPhoneLaunchpadHelperService: NSObject, VPhoneLaunchpadHelperProtoco
do {
try process.run()
} catch {
reply(-1, "Cannot start vphone-cli: \(error.localizedDescription)")
reply(-1, "Unable to start vphone-cli. \(error.localizedDescription)")
return
}
emit("$ vphone-cli \(request.arguments.joined(separator: " ")) (as root)")