Say when a CFW install did not finish, and give Core Bundle installs their own sheet

Issue #531's black screen came from a guest whose cache was never patched:
its first `cfw install` had not completed. Nothing said so; launchd just
panicked at pid 1. `restore` writes restore-info.json without a variant and
only a finished install records one, so that file already tells the two
apart. `vm launch` now refuses such a VM and names the command to run, and
`vm list` reports `customFirmwareInstalled`. An install that has to write
Disk.img in place (no APFS clone) clears the variant first, so one that
stops halfway is not reported as complete. Bundles from before
restore-info.json existed read as unknown and still launch.

Launchpad shows "Custom Firmware Not Installed" in the inspector, refuses
Start with an explanation instead of logging a black screen, and offers
Install Custom Firmware in the machine menu. It runs through the helper and
writes to the machine's console log.

Core Bundle install progress moves out of the inspector into a sheet of its
own. It opens when an install starts, reopens on launch if the last one did
not finish, and can be hidden while it runs; Core Bundle offers Show
Progress until it ends.

Also localizes the new strings and the firmware names for ja, ko, vi and
zh-Hans, renames the stale Extended preset string to Experimental, fixes a
RestoreInfo test still expecting the old "Only JB VMs" message, and carries
swiftformat's pass over the MISFix tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lakr
2026-09-30 16:17:23 +09:00
co-authored by Claude Opus 5.5
parent b323fc4ec7
commit f3cac9db17
15 changed files with 417 additions and 119 deletions
@@ -49,9 +49,17 @@ private enum MISFixture {
static let functionOffset = 0x2000
static let literalOffset = 0x3000
static var machHeaderVMA: UInt64 { base + UInt64(machHeaderOffset) }
static var functionVMA: UInt64 { base + UInt64(functionOffset) }
static var literalVMA: UInt64 { base + UInt64(literalOffset) }
static var machHeaderVMA: UInt64 {
base + UInt64(machHeaderOffset)
}
static var functionVMA: UInt64 {
base + UInt64(functionOffset)
}
static var literalVMA: UInt64 {
base + UInt64(literalOffset)
}
static let chunkName = "dyld_shared_cache_arm64e"
@@ -168,7 +176,9 @@ private enum MISFixture {
// accepts it.
var name = Data(installName.utf8)
name.append(0)
while name.count % 8 != 0 { name.append(0) }
while name.count % 8 != 0 {
name.append(0)
}
let commandSize = 24 + name.count
put32(0xFEED_FACF, at: machHeaderOffset)
put32(0x0100_000C, at: machHeaderOffset + 4) // CPU_TYPE_ARM64
@@ -485,7 +495,6 @@ struct DyldSharedCacheMISTrustAuthIdempotenceTests {
// MARK: - The shape detector on its own
@Suite
struct DyldSharedCacheMISTrustAuthShapeDetectorTests {
private func decode(_ words: [Data]) -> [ARM64Instruction] {
ARM64Disassembler().disassemble(words.reduce(Data(), +), at: MISFixture.functionVMA)
@@ -125,6 +125,9 @@ struct VPhoneCustomFirmwareInstaller {
throw ValidationError("The VM disk image changed during the install. Try again.")
}
image = try URL(fileURLWithPath: bundleDirectory.path).appendingPathComponent("Disk.img")
// From here a failure leaves a half-written guest, so it must not
// keep reading as installed. `cfw install` records it again on success.
clearRecordedInstall(invokingUser: invokingUser)
}
let attached = try tool(
@@ -244,6 +247,25 @@ struct VPhoneCustomFirmwareInstaller {
print("[+] CFW system install complete; vphoned is installed, no package bootstrap was staged")
}
/// Host bookkeeping in the caller's folder, written with the caller's
/// credentials as `cfw install` records the variant, never as root.
private func clearRecordedInstall(invokingUser: VPhoneInvokingUser?) {
let bundle = bundle
let clear: () throws -> Void = {
guard let vm = try? VPhoneBundle.load(at: bundle) else { return }
try VPhoneRestoreInfo.clearVariant(inBundle: vm)
}
do {
if let invokingUser {
try invokingUser.withUserCredentials(clear)
} else {
try clear()
}
} catch {
fputs("warning: could not mark the install as in progress: \(error)\n", stderr)
}
}
// MARK: - Host inputs
/// Resolve the bundle once, then hold it by descriptor. Every later
@@ -7,6 +7,8 @@ public struct VPhoneBundleReport: Codable, Equatable, Sendable {
public let diskSizeBytes: Int64
public let network: VPhoneVirtualMachineManifest.NetworkConfig
public let restoreInfo: VPhoneRestoreInfo?
/// `false` when the last `cfw install` did not finish; `nil` when unknown.
public let customFirmwareInstalled: Bool?
public let udid: String?
public init(bundle: VPhoneBundle) {
@@ -16,6 +18,7 @@ public struct VPhoneBundleReport: Codable, Equatable, Sendable {
diskSizeBytes = bundle.diskSizeBytes
network = bundle.manifest.networkConfig
restoreInfo = VPhoneRestoreInfo.load(fromBundle: bundle)
customFirmwareInstalled = VPhoneRestoreInfo.customFirmwareInstalled(inBundle: bundle)
udid = VPhoneRestoreOperations.resolveUDID(bundle: bundle)
}
}
@@ -98,6 +98,29 @@ public struct VPhoneRestoreInfo: Codable, Equatable, Sendable {
return merged
}
/// Whether `cfw install` finished on this bundle. `restore` writes
/// restore-info.json without a variant and only a completed install records
/// one, so a snapshot with no variant is a guest that cannot boot yet. `nil`
/// when there is no snapshot to tell: a bundle restored before it existed.
public static func customFirmwareInstalled(inBundle bundle: VPhoneBundle) -> Bool? {
guard let directory = readableDirectory(of: bundle),
let data = try? directory.readData(fileName),
let info = try? JSONDecoder().decode(VPhoneRestoreInfo.self, from: data)
else { return nil }
return info.variant != nil
}
/// Drop the recorded variant before an install that writes Disk.img in
/// place, so an install that stops halfway is not reported as complete.
public static func clearVariant(inBundle bundle: VPhoneBundle) throws {
guard let directory = readableDirectory(of: bundle),
let data = try? directory.readData(fileName),
let info = try? JSONDecoder().decode(VPhoneRestoreInfo.self, from: data),
info.variant != nil
else { return }
try VPhoneRestoreInfo(ios: info.ios, cloudOS: info.cloudOS).write(toBundle: bundle)
}
/// Remove the `iPhone*_Restore/` tree from the bundle; returns its name, or
/// nil if absent. Record versions (`derive`) first — it reads this directory.
/// Only a real folder is removed, without following any link inside it:
@@ -114,6 +114,13 @@ public struct VPhoneBootCommand: ParsableCommand {
"This VM was created as '\(existingVariant)', which this build does not support. Create a new VM and try again.",
)
}
// A restored guest without CFW panics at pid 1 and shows only a black
// screen, so say so before booting it.
if !dfu, VPhoneRestoreInfo.customFirmwareInstalled(inBundle: bundle) == false {
throw ValidationError(
"CFW installation on this VM did not complete, so it cannot boot. Stop the VM, run `sudo vphone-cli cfw install \(bundle.name)` again, then launch.",
)
}
if dfu, let packageURL = installPackageURL {
throw ValidationError(
@@ -161,11 +161,53 @@ struct RestoreInfoTests {
_ = try VPhoneBootCommand.parseAsRoot(["--config", b.configURL.path])
Issue.record("Boot accepted an explicitly unsupported VM variant")
} catch {
#expect(String(describing: error).contains("Only JB VMs are supported"))
#expect(String(describing: error).contains("which this build does not support"))
}
_ = try VPhoneBootCommand.parseAsRoot(["--config", b.configURL.path, "--dfu"])
}
@Test func `a restore without a recorded variant reads as an unfinished CFW install`() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
// No snapshot: a bundle from before restore-info.json, not known either way.
#expect(VPhoneRestoreInfo.customFirmwareInstalled(inBundle: b) == nil)
try VPhoneRestoreInfo(
ios: .init(version: "27.0", build: "24A435"),
cloudOS: .init(version: "26.4", build: "23E5207q"),
).write(toBundle: b)
#expect(VPhoneRestoreInfo.customFirmwareInstalled(inBundle: b) == false)
#expect(VPhoneBundleReport(bundle: b).customFirmwareInstalled == false)
try VPhoneRestoreInfo.recordVariant("jb", toBundle: b)
#expect(VPhoneRestoreInfo.customFirmwareInstalled(inBundle: b) == true)
try VPhoneRestoreInfo.clearVariant(inBundle: b)
#expect(VPhoneRestoreInfo.customFirmwareInstalled(inBundle: b) == false)
#expect(VPhoneRestoreInfo.load(fromBundle: b)?.ios.build == "24A435")
}
@Test func `normal boot refuses an unfinished CFW install but DFU still parses`() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
try b.manifest.write(to: b.configURL)
try VPhoneRestoreInfo(
ios: .init(version: "27.0", build: "24A435"),
cloudOS: .init(version: "26.4", build: "23E5207q"),
).write(toBundle: b)
do {
_ = try VPhoneBootCommand.parseAsRoot(["--config", b.configURL.path])
Issue.record("Boot accepted a VM whose CFW install did not finish")
} catch {
#expect(String(describing: error).contains("cfw install"))
}
_ = try VPhoneBootCommand.parseAsRoot(["--config", b.configURL.path, "--dfu"])
try VPhoneRestoreInfo.recordVariant("jb", toBundle: b)
_ = try VPhoneBootCommand.parseAsRoot(["--config", b.configURL.path])
}
@Test func `bundle report carries UDID`() throws {
let b = try makeBundle()
defer { try? FileManager.default.removeItem(at: b.url) }
@@ -11,6 +11,7 @@ final class VPhoneLaunchpadModel {
enum Panel: String, Identifiable {
case hostSetup
case coreBundle
case bundleInstall
var id: Self {
self
@@ -20,6 +21,7 @@ final class VPhoneLaunchpadModel {
switch self {
case .hostSetup: String(localized: "Host Setup")
case .coreBundle: String(localized: "Core Bundle")
case .bundleInstall: String(localized: "Core Bundle Install")
}
}
}
@@ -112,9 +114,13 @@ final class VPhoneLaunchpadModel {
await bundles.checkActive()
}
await listed
// An unfinished install is picked up in the inspector instead.
if panel == nil, bundles.progress == nil || bundles.progress?.isFinished == true {
panel = !host.requiredPassed ? .hostSetup : !bundles.isReady ? .coreBundle : nil
// An unfinished install reopens its own sheet instead.
if panel == nil {
if bundles.progress == nil || bundles.progress?.isFinished == true {
panel = !host.requiredPassed ? .hostSetup : !bundles.isReady ? .coreBundle : nil
} else {
panel = .bundleInstall
}
}
await bundles.fetchReleases()
await bundles.fetchArtifacts()
@@ -145,8 +151,8 @@ final class VPhoneLaunchpadModel {
// MARK: - Bundle install
/// An install runs in the inspector, not in the sheet it started from,
/// so the sheet closes and the inspector opens on its progress.
/// An install shows its progress in a sheet of its own, which replaces
/// the Core Bundle sheet it started from.
func installBundle(_ release: VPhoneLaunchpadRelease) async {
revealInstall()
await bundles.install(release)
@@ -171,15 +177,12 @@ final class VPhoneLaunchpadModel {
}
private func revealInstall() {
panel = nil
showsInspector = true
isInstallExpanded = true
present(.bundleInstall)
}
// MARK: - Inspector
var showsInspector = true
var isInstallExpanded = true
func removeBundle(_ version: String) async {
await bundles.remove(version)
@@ -24,6 +24,8 @@ struct VPhoneLaunchpadRootView: View {
VPhoneLaunchpadHostSetupView()
case .coreBundle:
VPhoneLaunchpadCoreBundleView()
case .bundleInstall:
VPhoneLaunchpadInstallView()
}
}
.environment(model)
@@ -27,8 +27,9 @@ struct VPhoneLaunchpadCoreBundleView: View {
Form {
if bundles.isInstalling {
Section {
Text("An install is in progress. Follow it in the inspector.")
.foregroundStyle(.secondary)
LabeledContent("An install is in progress.") {
Button("Show Progress") { model.present(.bundleInstall) }
}
}
}
if !bundles.installed.isEmpty {
@@ -1,48 +1,70 @@
import SwiftUI
/// The last Core Bundle install, as a section at the top of the inspector.
/// Its header collapses it to one summary row. It stays until dismissed,
/// across relaunches, and offers Retry and Skip when a step fails.
struct VPhoneLaunchpadInstallSection: View {
/// The last Core Bundle install, as a sheet of its own. It opens when an
/// install starts and again on launch if the last one did not finish. Hiding
/// it leaves the install running; Core Bundle offers it again until then.
struct VPhoneLaunchpadInstallView: View {
@Environment(VPhoneLaunchpadModel.self) private var model
@Environment(\.dismiss) private var dismiss
private var bundles: VPhoneLaunchpadCoreBundle {
model.bundles
}
var body: some View {
if let progress = bundles.progress {
Section {
summary(progress)
// Under the summary, so the download moves while collapsed too.
if progress.status(.download) == .running {
ProgressView(value: Double(progress.received), total: Double(max(progress.size, 1)))
.labelsHidden()
@Bindable var bundles = bundles
VPhoneLaunchpadSheet(Text("Core Bundle Install")) {
Form {
if let progress = bundles.progress {
Section {
summary(progress)
if progress.status(.download) == .running {
ProgressView(value: Double(progress.received), total: Double(max(progress.size, 1)))
.labelsHidden()
}
}
Section("Steps") {
steps(progress)
}
if let error = progress.error {
Section("Error") {
errorDetail(error)
}
}
} else {
Text("No install to show.")
.foregroundStyle(.secondary)
}
if model.isInstallExpanded {
details(progress)
}
.formStyle(.grouped)
} accessory: {
if let progress = bundles.progress, !bundles.isInstalling {
if progress.canSkip {
Button("Skip") { bundles.skipFailedChecks() }
.help("Use this version without the failed checks.")
}
} header: {
header
if progress.error != nil {
Button("Retry") {
Task { await model.retryInstall() }
}
.disabled(!model.canInstallBundles && progress.status(.install) != .passed)
}
}
} actions: {
if bundles.isInstalling {
Button("Hide") { dismiss() }
.help("The install keeps running. Core Bundle shows it again.")
.keyboardShortcut(.cancelAction)
} else {
Button("Done") {
bundles.dismissProgress()
dismiss()
}
.keyboardShortcut(.defaultAction)
}
}
}
private var header: some View {
Button {
withAnimation(.snappy(duration: 0.2)) { model.isInstallExpanded.toggle() }
} label: {
HStack(spacing: 4) {
Text("Core Bundle Install")
Spacer()
Image(systemName: "chevron.right")
.rotationEffect(.degrees(model.isInstallExpanded ? 90 : 0))
.foregroundStyle(.secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(model.isInstallExpanded ? "Hide the install steps" : "Show the install steps")
.frame(width: 480, height: 420)
.interactiveDismissDisabled(bundles.isInstalling)
}
// MARK: - Summary
@@ -72,8 +94,7 @@ struct VPhoneLaunchpadInstallSection: View {
// MARK: - Details
@ViewBuilder
private func details(_ progress: VPhoneLaunchpadCoreBundle.InstallProgress) -> some View {
private func steps(_ progress: VPhoneLaunchpadCoreBundle.InstallProgress) -> some View {
ForEach(progress.plan) { step in
HStack(spacing: 8) {
VPhoneLaunchpadStatusIcon(status: progress.status(step))
@@ -89,34 +110,19 @@ struct VPhoneLaunchpadInstallSection: View {
}
}
}
if let error = progress.error {
VStack(alignment: .leading, spacing: 4) {
Text(error.message)
if let detail = error.detail {
Text(detail)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
.lineLimit(6)
}
}
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
if !bundles.isInstalling {
HStack {
Spacer()
Button("Dismiss") { bundles.dismissProgress() }
if progress.canSkip {
Button("Skip") { bundles.skipFailedChecks() }
.help("Use this version without the failed checks.")
}
if progress.error != nil {
Button("Retry") {
Task { await model.retryInstall() }
}
.disabled(!model.canInstallBundles && progress.status(.install) != .passed)
}
}
private func errorDetail(_ error: VPhoneLaunchpadError) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(error.message)
if let detail = error.detail {
Text(detail)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
.lineLimit(8)
}
}
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -4909,39 +4909,140 @@
}
}
},
"Extended" : {
"Experimental" : {
"comment" : "The built-in patch preset",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Extended"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "拡張"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "확장"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mở rộng"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "扩展"
}
}
"en" : { "stringUnit" : { "state" : "translated", "value" : "Experimental" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "実験的" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "실험적" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Thử nghiệm" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "实验性" } }
}
},
"Experimental Custom Firmware" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Experimental Custom Firmware" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "実験的カスタムファームウェア" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "실험적 커스텀 펌웨어" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Firmware tùy chỉnh thử nghiệm" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "实验性自定义固件" } }
}
},
"Standard Custom Firmware" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Standard Custom Firmware" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "標準カスタムファームウェア" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "표준 커스텀 펌웨어" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Firmware tùy chỉnh tiêu chuẩn" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "标准自定义固件" } }
}
},
"Unknown Firmware" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Unknown Firmware" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "不明なファームウェア" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "알 수 없는 펌웨어" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Firmware không xác định" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "未知固件" } }
}
},
"Custom Firmware Not Installed" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Custom Firmware Not Installed" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "カスタムファームウェア未インストール" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "커스텀 펌웨어 미설치" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Chưa cài firmware tùy chỉnh" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "自定义固件未安装" } }
}
},
"Custom firmware installation on this machine did not complete, so it cannot boot. Choose Install Custom Firmware from the machine's menu, then start it again." : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Custom firmware installation on this machine did not complete, so it cannot boot. Choose Install Custom Firmware from the machine's menu, then start it again." } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "このマシンのカスタムファームウェアのインストールが完了していないため、起動できません。マシンのメニューから「カスタムファームウェアをインストール」を選択してから、もう一度起動してください。" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "이 머신의 커스텀 펌웨어 설치가 완료되지 않아 부팅할 수 없습니다. 머신 메뉴에서 '커스텀 펌웨어 설치'를 선택한 다음 다시 시작하십시오." } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Việc cài firmware tùy chỉnh trên máy này chưa hoàn tất nên máy không thể khởi động. Chọn Cài firmware tùy chỉnh trong menu của máy, rồi khởi động lại." } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "这台机器的自定义固件没有安装完成,无法启动。请在机器菜单中选择“安装自定义固件”,然后再启动。" } }
}
},
"Install Custom Firmware" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Install Custom Firmware" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "カスタムファームウェアをインストール" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "커스텀 펌웨어 설치" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Cài firmware tùy chỉnh" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "安装自定义固件" } }
}
},
"Installing custom firmware…" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Installing custom firmware…" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "カスタムファームウェアをインストール中…" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "커스텀 펌웨어 설치 중…" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Đang cài firmware tùy chỉnh…" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在安装自定义固件…" } }
}
},
"Choose Show Console Log for the full output." : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Choose Show Console Log for the full output." } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "完全な出力は「コンソールログを表示」で確認できます。" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "전체 출력은 '콘솔 로그 보기'에서 확인하십시오." } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Chọn Hiển thị nhật ký bảng điều khiển để xem toàn bộ đầu ra." } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "选择“显示控制台日志”查看完整输出。" } }
}
},
"An install is in progress." : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "An install is in progress." } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "インストール中です。" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "설치가 진행 중입니다." } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Đang cài đặt." } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在安装。" } }
}
},
"Hide" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Hide" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "隠す" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "숨기기" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Ẩn" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "隐藏" } }
}
},
"The install keeps running. Core Bundle shows it again." : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "The install keeps running. Core Bundle shows it again." } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "インストールは続行されます。コアバンドルから再度表示できます。" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "설치는 계속 진행됩니다. 코어 번들에서 다시 볼 수 있습니다." } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Việc cài đặt vẫn tiếp tục. Bundle lõi sẽ hiển thị lại." } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "安装会继续进行,可在核心 Bundle 中再次查看。" } }
}
},
"No install to show." : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "No install to show." } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "表示するインストールはありません。" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "표시할 설치가 없습니다." } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Không có bản cài đặt nào để hiển thị." } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "没有可显示的安装。" } }
}
},
"Steps" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Steps" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "手順" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "단계" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Các bước" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "步骤" } }
}
},
"Error" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Error" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "エラー" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "오류" } },
"vi" : { "stringUnit" : { "state" : "translated", "value" : "Lỗi" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "错误" } }
}
},
"Filter patches" : {
@@ -59,12 +59,35 @@ nonisolated struct VPhoneLaunchpadMachine: Decodable, Hashable, Identifiable, Se
let diskSizeBytes: Int64
let network: Network
let restoreInfo: RestoreInfo?
/// `false` when the last CFW install did not finish, `nil` when unknown.
let customFirmwareInstalled: Bool?
let udid: String?
/// The library `vm list` was run on. Not part of the JSON.
var libraryRoot = ""
private enum CodingKeys: String, CodingKey {
case name, cpuCount, memoryMB, diskSizeBytes, network, restoreInfo, udid
case name, cpuCount, memoryMB, diskSizeBytes, network, restoreInfo, customFirmwareInstalled, udid
}
/// The inspector's firmware line. A restore whose CFW install never
/// finished cannot boot, which matters more than which set it was meant for.
var firmwareName: String? {
guard let restoreInfo else { return nil }
if customFirmwareInstalled == false {
return String(localized: "Custom Firmware Not Installed")
}
return restoreInfo.firmwareName
}
/// Read from disk at launch time rather than from the last `vm list`, so a
/// machine that was just installed is not refused on stale data. Mirrors
/// `vm launch`: restore-info.json with no variant is an unfinished install.
static func customFirmwareIncomplete(at path: VPhoneLaunchpadMachinePath) -> Bool {
let file = path.url.appendingPathComponent("restore-info.json")
guard let data = try? Data(contentsOf: file),
let info = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
else { return false }
return info["variant"] == nil
}
var path: VPhoneLaunchpadMachinePath {
@@ -52,8 +52,6 @@ struct VPhoneLaunchpadMachineInspector: View {
var body: some View {
Form {
VPhoneLaunchpadInstallSection()
Section {
if let creation = library.creations[machine.path] {
creationSummary(creation)
@@ -67,8 +65,8 @@ struct VPhoneLaunchpadMachineInspector: View {
if let started = library.startedAt[machine.path] {
LabeledContent("Started", value: started.formatted(date: .omitted, time: .shortened))
}
if let restoreInfo = machine.restoreInfo {
LabeledContent("Firmware", value: restoreInfo.firmwareName)
if let firmwareName = machine.firmwareName {
LabeledContent("Firmware", value: firmwareName)
}
} header: {
Text(machine.name)
@@ -274,12 +274,35 @@ final class VPhoneLaunchpadMachineLibrary {
try? handle.write(contentsOf: Data("\n\(line)\n".utf8))
}
/// One line of helper output, from the XPC queue. Creates the log if the
/// machine has never been started from Launchpad.
private nonisolated static func append(_ line: String, to log: URL) {
if !FileManager.default.fileExists(atPath: log.path) {
try? FileManager.default.createDirectory(at: log.deletingLastPathComponent(), withIntermediateDirectories: true)
FileManager.default.createFile(atPath: log.path, contents: nil)
}
guard let handle = try? FileHandle(forWritingTo: log) else {
return
}
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: Data("\(line)\n".utf8))
}
// MARK: - Start and stop
func start(_ machine: Path, headless: Bool = false) {
guard let commandLine = bundles.commandLine() else {
return
}
// `vm launch` refuses these too, but only into the console log.
guard !VPhoneLaunchpadMachine.customFirmwareIncomplete(at: machine) else {
actionError = VPhoneLaunchpadError(
String(localized: "Unable to Start \(machine.name)"),
detail: String(localized: "Custom firmware installation on this machine did not complete, so it cannot boot. Choose Install Custom Firmware from the machine's menu, then start it again."),
)
return
}
var arguments = ["vm", "launch", machine.name] + machine.libraryArguments
if headless {
arguments.append("--headless")
@@ -307,6 +330,40 @@ final class VPhoneLaunchpadMachineLibrary {
}
}
/// Runs `cfw install` again through the helper, for a machine whose last
/// install did not finish. Output goes to the machine's console log.
func installCustomFirmware(_ machine: Path) async {
guard let version = bundles.activeVersion else {
actionError = VPhoneLaunchpadError(String(localized: "No Core Bundle version is in use. Choose a version in Core Bundle."))
return
}
activities[machine] = String(localized: "Installing custom firmware…")
defer { activities[machine] = nil }
appendConsoleLog(machine, "$ vphone-cli cfw install \(machine.name)")
let log = Self.consoleLog(machine)
do {
let status = try await helper.installCustomFirmware(
bundleVersion: version,
machineName: machine.name,
libraryRoot: machine.libraryRoot,
keepArtifacts: true,
onLine: { line in Self.append(line, to: log) },
)
if status != 0 {
actionError = VPhoneLaunchpadError(
String(localized: "Unable to install custom firmware. Check the log for details."),
detail: String(localized: "Choose Show Console Log for the full output."),
)
}
} catch {
if !(error is CancellationError) {
actionError = error as? VPhoneLaunchpadError
?? VPhoneLaunchpadError(String(localized: "Unable to install custom firmware. Check the log for details."), detail: error.localizedDescription)
}
}
await refresh()
}
func stop(_ machine: Path) async {
await perform(String(localized: "Stopping…"), on: machine, ["vm", "stop", machine.name] + machine.libraryArguments)
launched[machine]?.interrupt()
@@ -74,11 +74,6 @@ struct VPhoneLaunchpadMachinesView: View {
)
} else if library.selection.count > 1 {
ContentUnavailableView("\(library.selection.count) Machines Selected", systemImage: "iphone")
} else if model.bundles.progress != nil {
Form {
VPhoneLaunchpadInstallSection()
}
.formStyle(.grouped)
} else {
ContentUnavailableView("No Selection", systemImage: "iphone")
}
@@ -289,6 +284,12 @@ struct VPhoneLaunchpadMachinesView: View {
.disabled(!isStopped)
Button("Export…") { sheet = .export([machine.path]) }
.disabled(!isStopped)
Button("Install Custom Firmware") {
Task { await library.installCustomFirmware(machine.path) }
}
// Only for an unfinished install: that is when the restore tree it
// reads is still there. A finished one removes it.
.disabled(!isStopped || machine.customFirmwareInstalled != false)
Divider()
Button("Show in Finder") {
NSWorkspace.shared.activateFileViewerSelecting([machine.path.url])