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 <name>`.

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 <noreply@anthropic.com>
This commit is contained in:
Lakr
2026-09-30 18:54:58 +09:00
co-authored by Claude Opus 5.5
parent ce7b42d21f
commit 37745690a8
11 changed files with 197 additions and 10 deletions
+1
View File
@@ -60,6 +60,7 @@ expires asks for an administrator password on the Mac, as the window does.
| `vm wait <name>` / `vm log <name> [--kind create\|dfu\|patch]` | Wait for vphoned; read a console log |
| `vm create <name> [...] [--from <step>]` | The New Machine pipeline; `--from` retries from a step |
| `cfw install <name>` | Install CFW into a stopped machine through the helper |
| `cfw update-environment <name>` | Redeploy the active bundle's guest resources (vphoned, hook dylibs) into a stopped machine through the helper; nothing else changes |
| `guest send <name> <json>` | One raw `vphone.sock` request (tap, swipe, key, screenshot) |
| `guest rpc <name> <method> [params]` | Any vphoned method, see `Research/vphoned_http_api.md` |
| `exec <vphone-cli arguments>` | Run the active bundle's `vphone-cli`, streaming its output |
@@ -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
@@ -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 {
@@ -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
@@ -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" : {
@@ -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()
@@ -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])
@@ -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
@@ -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
@@ -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}."),
@@ -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)