Files
vphone-cli/VPhoneLaunchpad/VPhoneLaunchpadHelper/VPhoneLaunchpadHelperService.swift
T
LakrandClaude Opus 5.5 37745690a8 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>
2026-09-30 18:54:58 +09:00

235 lines
8.3 KiB
Swift

import Darwin
import Foundation
/// The object exported to one app connection.
final class VPhoneLaunchpadHelperService: NSObject, VPhoneLaunchpadHelperProtocol, @unchecked Sendable {
private weak var connection: NSXPCConnection?
private let callerUID: uid_t
private let callerGID: gid_t
private let work = DispatchQueue(label: "com.vphone.launchpad.helper.work")
/// One CFW install at a time across every connection: two installs
/// host-mounting disks at once is never what anyone wants. The owner is
/// the user that started it, the only one allowed to cancel it.
private static let firmwareLock = NSLock()
private nonisolated(unsafe) static var firmwareProcess: Process?
private nonisolated(unsafe) static var firmwareOwner: uid_t?
init(connection: NSXPCConnection) {
self.connection = connection
callerUID = connection.effectiveUserIdentifier
callerGID = connection.effectiveGroupIdentifier
}
// MARK: - Version
func helperVersion(reply: @escaping @Sendable (String) -> Void) {
reply(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0")
}
// MARK: - Bundles
func installBundle(
authorization: Data,
version: String,
archive: FileHandle,
sha256: String,
reply: @escaping @Sendable (String?) -> Void,
) {
work.async {
do {
try VPhoneLaunchpadHelperAuthorization.require(authorization)
try VPhoneLaunchpadHelperBundleInstaller.install(version: version, archive: archive, sha256: sha256)
reply(nil)
} catch {
reply(error.localizedDescription)
}
}
}
func removeBundle(authorization: Data, version: String, reply: @escaping @Sendable (String?) -> Void) {
work.async {
do {
try VPhoneLaunchpadHelperAuthorization.require(authorization)
try VPhoneLaunchpadHelperBundleInstaller.remove(version: version)
reply(nil)
} catch {
reply(error.localizedDescription)
}
}
}
func allowVirtualMachine(authorization: Data, bundleVersion: String, reply: @escaping @Sendable (String?) -> Void) {
work.async {
do {
try VPhoneLaunchpadHelperAuthorization.require(authorization)
try VPhoneLaunchpadHelperAMFI.allow(bundleVersion: bundleVersion)
reply(nil)
} catch {
reply(error.localizedDescription)
}
}
}
// MARK: - CFW install
func installCustomFirmware(
authorization: Data,
bundleVersion: String,
machineName: String,
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
DispatchQueue.global(qos: .userInitiated).async { [self] in
let request: VPhoneLaunchpadHelperFirmwareRequest
do {
try VPhoneLaunchpadHelperAuthorization.require(authorization)
request = try VPhoneLaunchpadHelperFirmwareRequest(
operation: operation,
bundleVersion: bundleVersion,
machineName: machineName,
libraryRoot: libraryRoot,
callerUID: callerUID,
callerGID: callerGID,
)
} catch {
reply(-1, error.localizedDescription)
return
}
let process = Process()
process.executableURL = request.executable
process.arguments = request.arguments
process.environment = request.environment
process.currentDirectoryURL = request.workingDirectory
let pipe = Pipe()
process.standardInput = FileHandle.nullDevice
process.standardOutput = pipe
process.standardError = pipe
Self.firmwareLock.lock()
guard Self.firmwareProcess == nil else {
Self.firmwareLock.unlock()
reply(-1, "Another CFW install or environment update is in progress. Wait for it to finish, then try again.")
return
}
Self.firmwareProcess = process
Self.firmwareOwner = callerUID
Self.firmwareLock.unlock()
defer {
Self.firmwareLock.lock()
Self.firmwareProcess = nil
Self.firmwareOwner = nil
Self.firmwareLock.unlock()
}
do {
try process.run()
} catch {
reply(-1, "Unable to start vphone-cli. \(error.localizedDescription)")
return
}
emit("$ vphone-cli \(request.arguments.joined(separator: " ")) (as root)")
VPhoneLaunchpadLineReader.readLines(from: pipe.fileHandleForReading) { emit($0) }
process.waitUntilExit()
reply(process.terminationStatus, nil)
}
}
func cancelCustomFirmware(reply: @escaping @Sendable () -> Void) {
let callerUID = callerUID
// Not on `work`: a bundle install queued there must not delay a cancel.
// No authorization check: the install's right may have expired by
// now, and a prompt here could leave the install running. Only the
// user who started the install can stop it.
DispatchQueue.global(qos: .userInitiated).async {
defer { reply() }
Self.firmwareLock.lock()
if Self.firmwareOwner == callerUID {
Self.firmwareProcess?.interrupt()
}
Self.firmwareLock.unlock()
}
}
// MARK: - Uninstall
func uninstallHelper(authorization: Data, reply: @escaping @Sendable (String?) -> Void) {
work.async { [self] in
do {
try VPhoneLaunchpadHelperAuthorization.require(authorization)
} catch {
reply(error.localizedDescription)
return
}
removeHelper(reply: reply)
}
}
private func removeHelper(reply: @escaping @Sendable (String?) -> Void) {
let label = VPhoneLaunchpadHelperIdentity.label
let fileManager = FileManager.default
try? fileManager.removeItem(atPath: "/Library/LaunchDaemons/\(label).plist")
try? fileManager.removeItem(atPath: "/Library/PrivilegedHelperTools/\(label)")
reply(nil)
// Booting out our own job ends this process; give the reply a moment
// to leave first.
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/launchctl")
process.arguments = ["bootout", "system/\(label)"]
try? process.run()
process.waitUntilExit()
exit(0)
}
}
// MARK: - Output
private func emit(_ line: String) {
let client = connection?.remoteObjectProxy as? VPhoneLaunchpadHelperClientProtocol
client?.helperDidEmit(line: line)
}
}