From 37745690a8963030b6feaaa0fa234cb02d7e235b Mon Sep 17 00:00:00 2001 From: Lakr Date: Wed, 30 Sep 2026 18:54:58 +0900 Subject: [PATCH] Let Launchpad update a stopped machine's guest environment A machine's restore tree is removed after its first boot, so cfw install cannot run again and a machine made by an older bundle never gets newer hook dylibs. The helper gains updateGuestEnvironment, which runs the bundle's `vphone-cli cfw update-environment` as root through the same checks, output channel, lock and cancel as the install. Launchpad offers Update Guest Environment in a stopped, installed machine's menu, and vphone-launchpad-cli gains `cfw update-environment `. The helper protocol changed, so CURRENT_PROJECT_VERSION goes to 9 and Launchpad reinstalls the helper. The CLI verb itself lands with the installer's resource-only mode. Co-Authored-By: Claude Opus 5.5 --- Documents/Guides/launchpad-cli.md | 1 + .../Configuration/Version.xcconfig | 2 +- .../VPhoneLaunchpadControlCommands.swift | 21 ++++++++++ .../VPhoneLaunchpadHelperClient.swift | 31 ++++++++++++++ .../VPhoneLaunchpad/Localizable.xcstrings | 27 ++++++++++++ .../VPhoneLaunchpadMachineLibrary.swift | 35 ++++++++++++++++ .../VPhoneLaunchpadMachinesView.swift | 6 +++ ...VPhoneLaunchpadHelperFirmwareRequest.swift | 28 +++++++++---- .../VPhoneLaunchpadHelperService.swift | 42 ++++++++++++++++++- .../VPhoneLaunchpadControl.swift | 2 + .../VPhoneLaunchpadHelperProtocol.swift | 12 ++++++ 11 files changed, 197 insertions(+), 10 deletions(-) diff --git a/Documents/Guides/launchpad-cli.md b/Documents/Guides/launchpad-cli.md index e0243d9..abe3cdd 100644 --- a/Documents/Guides/launchpad-cli.md +++ b/Documents/Guides/launchpad-cli.md @@ -60,6 +60,7 @@ expires asks for an administrator password on the Mac, as the window does. | `vm wait ` / `vm log [--kind create\|dfu\|patch]` | Wait for vphoned; read a console log | | `vm create [...] [--from ]` | The New Machine pipeline; `--from` retries from a step | | `cfw install ` | Install CFW into a stopped machine through the helper | +| `cfw update-environment ` | Redeploy the active bundle's guest resources (vphoned, hook dylibs) into a stopped machine through the helper; nothing else changes | | `guest send ` | One raw `vphone.sock` request (tap, swipe, key, screenshot) | | `guest rpc [params]` | Any vphoned method, see `Research/vphoned_http_api.md` | | `exec ` | Run the active bundle's `vphone-cli`, streaming its output | diff --git a/VPhoneLaunchpad/Configuration/Version.xcconfig b/VPhoneLaunchpad/Configuration/Version.xcconfig index c4e5054..441b678 100644 --- a/VPhoneLaunchpad/Configuration/Version.xcconfig +++ b/VPhoneLaunchpad/Configuration/Version.xcconfig @@ -1,4 +1,4 @@ MARKETING_VERSION = 2.2.0 // Bump whenever the helper changes. The app blesses the helper again when the // installed helper's version or bytes differ from the copy it embeds. -CURRENT_PROJECT_VERSION = 8 +CURRENT_PROJECT_VERSION = 9 diff --git a/VPhoneLaunchpad/VPhoneLaunchpad/Control/VPhoneLaunchpadControlCommands.swift b/VPhoneLaunchpad/VPhoneLaunchpad/Control/VPhoneLaunchpadControlCommands.swift index 15a19d1..92d01d5 100644 --- a/VPhoneLaunchpad/VPhoneLaunchpad/Control/VPhoneLaunchpadControlCommands.swift +++ b/VPhoneLaunchpad/VPhoneLaunchpad/Control/VPhoneLaunchpadControlCommands.swift @@ -57,6 +57,7 @@ struct VPhoneLaunchpadControlCommands { case "vm.log": return try await log(request) case "vm.create": return try await create(request, emit: emit) case "cfw.install": return try await installCustomFirmware(request, emit: emit) + case "cfw.update-environment": return try await updateGuestEnvironment(request, emit: emit) case "guest.send": return try await sendToGuest(request) case "guest.rpc": return try await callGuest(request) case "exec": return try await exec(request, emit: emit) @@ -584,6 +585,26 @@ struct VPhoneLaunchpadControlCommands { return ["name": machine.name, "bundle": version, "status": status] } + private func updateGuestEnvironment(_ request: VPhoneLaunchpadControlRequest, emit: @escaping Emit) async throws -> Any { + let machine = try await machine(request) + guard let version = bundles.activeVersion else { + throw VPhoneLaunchpadError("No Core Bundle version is in use.") + } + guard library.state(of: machine) == .stopped else { + throw VPhoneLaunchpadError("Stop \(machine.name) before updating its guest environment.") + } + let status = try await model.helper.updateGuestEnvironment( + bundleVersion: version, + machineName: machine.name, + libraryRoot: machine.libraryRoot, + onLine: emit, + ) + guard status == 0 else { + throw VPhoneLaunchpadError("Unable to update the guest environment. Check the log for details.") + } + return ["name": machine.name, "bundle": version, "status": status] + } + // MARK: - Guest private func sendToGuest(_ request: VPhoneLaunchpadControlRequest) async throws -> Any { diff --git a/VPhoneLaunchpad/VPhoneLaunchpad/HostSetup/VPhoneLaunchpadHelperClient.swift b/VPhoneLaunchpad/VPhoneLaunchpad/HostSetup/VPhoneLaunchpadHelperClient.swift index 0d89b5d..1cdaa47 100644 --- a/VPhoneLaunchpad/VPhoneLaunchpad/HostSetup/VPhoneLaunchpadHelperClient.swift +++ b/VPhoneLaunchpad/VPhoneLaunchpad/HostSetup/VPhoneLaunchpadHelperClient.swift @@ -277,6 +277,37 @@ final class VPhoneLaunchpadHelperClient { } } + /// Runs `cfw update-environment` as root: redeploys the active bundle's + /// guest resources into a stopped machine. Output lines go to `onLine`. + func updateGuestEnvironment( + bundleVersion: String, + machineName: String, + libraryRoot: String, + onLine: @escaping @Sendable (String) -> Void, + ) async throws -> Int32 { + let authorization = try await authorizationSession.externalForm() + receiver.setHandler(onLine) + defer { receiver.setHandler(nil) } + return try await withTaskCancellationHandler { + try await request { proxy, done in + proxy.updateGuestEnvironment( + authorization: authorization, + bundleVersion: bundleVersion, + machineName: machineName, + libraryRoot: libraryRoot, + ) { status, message in + if let message { + done(.failure(VPhoneLaunchpadError(message))) + } else { + done(.success(status)) + } + } + } + } onCancel: { + Task { @MainActor in self.cancelCustomFirmware() } + } + } + /// Never prompts. The helper stops only an install this user started. func cancelCustomFirmware() { let proxy = currentConnection().remoteObjectProxy as? VPhoneLaunchpadHelperProtocol diff --git a/VPhoneLaunchpad/VPhoneLaunchpad/Localizable.xcstrings b/VPhoneLaunchpad/VPhoneLaunchpad/Localizable.xcstrings index 86e42fe..986b31a 100644 --- a/VPhoneLaunchpad/VPhoneLaunchpad/Localizable.xcstrings +++ b/VPhoneLaunchpad/VPhoneLaunchpad/Localizable.xcstrings @@ -6399,6 +6399,33 @@ } } }, + "Update Guest Environment" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Update Guest Environment" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ゲスト環境を更新" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "게스트 환경 업데이트" } }, + "vi" : { "stringUnit" : { "state" : "translated", "value" : "Cập nhật môi trường máy khách" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "更新客户机环境" } } + } + }, + "Updating guest environment…" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Updating guest environment…" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ゲスト環境を更新中…" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "게스트 환경 업데이트 중…" } }, + "vi" : { "stringUnit" : { "state" : "translated", "value" : "Đang cập nhật môi trường máy khách…" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在更新客户机环境…" } } + } + }, + "Unable to update the guest environment." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Unable to update the guest environment." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ゲスト環境を更新できません。" } }, + "ko" : { "stringUnit" : { "state" : "translated", "value" : "게스트 환경을 업데이트할 수 없습니다." } }, + "vi" : { "stringUnit" : { "state" : "translated", "value" : "Không thể cập nhật môi trường máy khách." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "无法更新客户机环境。" } } + } + }, "Install Custom Firmware" : { "localizations" : { "en" : { diff --git a/VPhoneLaunchpad/VPhoneLaunchpad/Machines/VPhoneLaunchpadMachineLibrary.swift b/VPhoneLaunchpad/VPhoneLaunchpad/Machines/VPhoneLaunchpadMachineLibrary.swift index a693ba2..a989857 100644 --- a/VPhoneLaunchpad/VPhoneLaunchpad/Machines/VPhoneLaunchpadMachineLibrary.swift +++ b/VPhoneLaunchpad/VPhoneLaunchpad/Machines/VPhoneLaunchpadMachineLibrary.swift @@ -364,6 +364,41 @@ final class VPhoneLaunchpadMachineLibrary { await refresh() } + /// Redeploys the active bundle's guest resources (vphoned and the hook + /// dylibs) into a stopped machine through the helper, and nothing else. + /// This is how a machine created by an older bundle gets newer hooks, + /// since its restore tree is gone after the first boot. + func updateGuestEnvironment(_ 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: "Updating guest environment…") + defer { activities[machine] = nil } + appendConsoleLog(machine, "$ vphone-cli cfw update-environment \(machine.name)") + let log = Self.consoleLog(machine) + do { + let status = try await helper.updateGuestEnvironment( + bundleVersion: version, + machineName: machine.name, + libraryRoot: machine.libraryRoot, + onLine: { line in Self.append(line, to: log) }, + ) + if status != 0 { + actionError = VPhoneLaunchpadError( + String(localized: "Unable to update the guest environment."), + 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 update the guest environment."), 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() diff --git a/VPhoneLaunchpad/VPhoneLaunchpad/Machines/VPhoneLaunchpadMachinesView.swift b/VPhoneLaunchpad/VPhoneLaunchpad/Machines/VPhoneLaunchpadMachinesView.swift index 1043605..acc351e 100644 --- a/VPhoneLaunchpad/VPhoneLaunchpad/Machines/VPhoneLaunchpadMachinesView.swift +++ b/VPhoneLaunchpad/VPhoneLaunchpad/Machines/VPhoneLaunchpadMachinesView.swift @@ -295,6 +295,12 @@ struct VPhoneLaunchpadMachinesView: View { // 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) + // The finished-install counterpart: redeploys the active bundle's + // guest resources without the restore tree. + Button("Update Guest Environment") { + Task { await library.updateGuestEnvironment(machine.path) } + } + .disabled(!isStopped || machine.restoreInfo == nil || machine.customFirmwareInstalled == false) Divider() Button("Show in Finder") { NSWorkspace.shared.activateFileViewerSelecting([machine.path.url]) diff --git a/VPhoneLaunchpad/VPhoneLaunchpadHelper/VPhoneLaunchpadHelperFirmwareRequest.swift b/VPhoneLaunchpad/VPhoneLaunchpadHelper/VPhoneLaunchpadHelperFirmwareRequest.swift index 94c7798..b4862b0 100644 --- a/VPhoneLaunchpad/VPhoneLaunchpadHelper/VPhoneLaunchpadHelperFirmwareRequest.swift +++ b/VPhoneLaunchpad/VPhoneLaunchpadHelper/VPhoneLaunchpadHelperFirmwareRequest.swift @@ -1,20 +1,28 @@ import Darwin import Foundation -/// A validated `vphone-cli cfw install` invocation. Built only from a store -/// bundle whose cdhash still matches its receipt, and only for a VM directory -/// the calling user owns. +/// A validated `vphone-cli cfw install` or `cfw update-environment` +/// invocation. Built only from a store bundle whose cdhash still matches its +/// receipt, and only for a VM directory the calling user owns. struct VPhoneLaunchpadHelperFirmwareRequest { + enum Operation { + /// The full install, which needs the prepared restore tree. + case install(keepArtifacts: Bool) + /// Redeploys the bundle's guest resources into a stopped machine and + /// nothing else. + case updateEnvironment + } + let executable: URL let arguments: [String] let environment: [String: String] let workingDirectory: URL init( + operation: Operation, bundleVersion: String, machineName: String, libraryRoot: String, - keepArtifacts: Bool, callerUID: uid_t, callerGID: gid_t, ) throws { @@ -45,9 +53,15 @@ struct VPhoneLaunchpadHelperFirmwareRequest { let userName = String(cString: account.pointee.pw_name) let home = String(cString: account.pointee.pw_dir) - var arguments = ["cfw", "install", machineName, "--library-root", libraryRoot] - if keepArtifacts { - arguments.append("--keep-artifacts") + var arguments: [String] + switch operation { + case let .install(keepArtifacts): + arguments = ["cfw", "install", machineName, "--library-root", libraryRoot] + if keepArtifacts { + arguments.append("--keep-artifacts") + } + case .updateEnvironment: + arguments = ["cfw", "update-environment", machineName, "--library-root", libraryRoot] } self.executable = executable diff --git a/VPhoneLaunchpad/VPhoneLaunchpadHelper/VPhoneLaunchpadHelperService.swift b/VPhoneLaunchpad/VPhoneLaunchpadHelper/VPhoneLaunchpadHelperService.swift index 3d2e7dc..89a3dfa 100644 --- a/VPhoneLaunchpad/VPhoneLaunchpadHelper/VPhoneLaunchpadHelperService.swift +++ b/VPhoneLaunchpad/VPhoneLaunchpadHelper/VPhoneLaunchpadHelperService.swift @@ -80,6 +80,44 @@ final class VPhoneLaunchpadHelperService: NSObject, VPhoneLaunchpadHelperProtoco libraryRoot: String, keepArtifacts: Bool, reply: @escaping @Sendable (Int32, String?) -> Void, + ) { + runFirmware( + .install(keepArtifacts: keepArtifacts), + authorization: authorization, + bundleVersion: bundleVersion, + machineName: machineName, + libraryRoot: libraryRoot, + reply: reply, + ) + } + + func updateGuestEnvironment( + authorization: Data, + bundleVersion: String, + machineName: String, + libraryRoot: String, + reply: @escaping @Sendable (Int32, String?) -> Void, + ) { + runFirmware( + .updateEnvironment, + authorization: authorization, + bundleVersion: bundleVersion, + machineName: machineName, + libraryRoot: libraryRoot, + reply: reply, + ) + } + + /// Both operations share one slot, so an install and an environment + /// update never write the same machine at once, and one cancel stops + /// either. + private func runFirmware( + _ operation: VPhoneLaunchpadHelperFirmwareRequest.Operation, + authorization: Data, + bundleVersion: String, + machineName: String, + libraryRoot: String, + reply: @escaping @Sendable (Int32, String?) -> Void, ) { let callerUID = callerUID let callerGID = callerGID @@ -88,10 +126,10 @@ final class VPhoneLaunchpadHelperService: NSObject, VPhoneLaunchpadHelperProtoco do { try VPhoneLaunchpadHelperAuthorization.require(authorization) request = try VPhoneLaunchpadHelperFirmwareRequest( + operation: operation, bundleVersion: bundleVersion, machineName: machineName, libraryRoot: libraryRoot, - keepArtifacts: keepArtifacts, callerUID: callerUID, callerGID: callerGID, ) @@ -113,7 +151,7 @@ final class VPhoneLaunchpadHelperService: NSObject, VPhoneLaunchpadHelperProtoco Self.firmwareLock.lock() guard Self.firmwareProcess == nil else { Self.firmwareLock.unlock() - reply(-1, "Another CFW install is in progress. Wait for it to finish, then try again.") + reply(-1, "Another CFW install or environment update is in progress. Wait for it to finish, then try again.") return } Self.firmwareProcess = process diff --git a/VPhoneLaunchpad/VPhoneLaunchpadShared/VPhoneLaunchpadControl.swift b/VPhoneLaunchpad/VPhoneLaunchpadShared/VPhoneLaunchpadControl.swift index 2d4a7fb..7404a85 100644 --- a/VPhoneLaunchpad/VPhoneLaunchpadShared/VPhoneLaunchpadControl.swift +++ b/VPhoneLaunchpad/VPhoneLaunchpadShared/VPhoneLaunchpadControl.swift @@ -171,6 +171,8 @@ nonisolated struct VPhoneLaunchpadControlCommand: Sendable { Self(name: "cfw.install", arguments: ["name"], options: ["root"], flags: ["keep-artifacts"], summary: "Install CFW into a stopped machine through the root helper."), + Self(name: "cfw.update-environment", arguments: ["name"], options: ["root"], flags: [], + summary: "Redeploy the active bundle's guest resources (vphoned and hook dylibs) into a stopped machine, and nothing else."), Self(name: "guest.send", arguments: ["name", "json"], options: ["root"], flags: [], summary: "Send one raw vphone.sock request, such as {\"t\":\"tap\",\"x\":645,\"y\":1398}."), diff --git a/VPhoneLaunchpad/VPhoneLaunchpadShared/VPhoneLaunchpadHelperProtocol.swift b/VPhoneLaunchpad/VPhoneLaunchpadShared/VPhoneLaunchpadHelperProtocol.swift index 2da97ec..c891a17 100644 --- a/VPhoneLaunchpad/VPhoneLaunchpadShared/VPhoneLaunchpadHelperProtocol.swift +++ b/VPhoneLaunchpad/VPhoneLaunchpadShared/VPhoneLaunchpadHelperProtocol.swift @@ -58,6 +58,18 @@ nonisolated protocol VPhoneLaunchpadHelperProtocol { reply: @escaping @Sendable (Int32, String?) -> Void, ) + /// Runs `vphone-cli cfw update-environment` from a store bundle as root: + /// redeploys the bundle's guest resources into a stopped machine and + /// nothing else. Same output channel and reply as `installCustomFirmware`, + /// and `cancelCustomFirmware` stops it. + func updateGuestEnvironment( + authorization: Data, + bundleVersion: String, + machineName: String, + libraryRoot: String, + reply: @escaping @Sendable (Int32, String?) -> Void, + ) + /// Sends SIGINT to a running CFW install started by the same user. It /// needs no authorization: it can only stop the caller's own install. func cancelCustomFirmware(reply: @escaping @Sendable () -> Void)