Support local Irisin bootstrap and multi-root uninstall

This commit is contained in:
Lakr
2026-09-25 14:48:42 +09:00
parent 158d704bc4
commit 153cfeb58b
11 changed files with 575 additions and 70 deletions
+1
View File
@@ -39,6 +39,7 @@ vphone-cli vm launch myphone
## カスタムファームウェアの Bootstrap
VM を起動したら、macOS のメニューバーで **Guest > Install Bootstrap…** を選び、環境のレイアウトを選択します。これによりゲスト内に Irisin がインストールされます。
Option キーを押しながらこのメニューを開くと、ローカルの Irisin `.deb` を選択できます。**Uninstall Bootstrap…** の Option メニューは rootless と RootHide の両環境を削除し、ゲストを再起動しません。通常の削除では再起動します。
初回の環境準備では、Irisin で `apt` と `bash` を選択します。**Install** ボタンを長押しし、**Bootstrap Install** を選んでください。このモードでは、今回のインストールに含まれるすべてのパッケージを先に展開してからインストール処理を再実行します。これにより、`debianutils` には `bash` が必要で、`bash` には設定済みの `debianutils` が必要という初期段階の依存関係を回避できます。初回の準備後は通常のインストールを使用できます。
+1
View File
@@ -39,6 +39,7 @@ vphone-cli vm launch myphone
## 커스텀 펌웨어 Bootstrap
VM을 실행한 뒤 macOS 메뉴 막대에서 **Guest > Install Bootstrap…**을 선택하고 환경 레이아웃을 고르세요. 그러면 게스트에 Irisin이 설치됩니다.
Option 키를 누른 채 이 메뉴를 열면 로컬 Irisin `.deb` 파일을 선택할 수 있습니다. **Uninstall Bootstrap…**의 Option 메뉴는 rootless와 RootHide 환경을 모두 삭제하지만 게스트를 재시동하지 않습니다. 일반 제거는 삭제 후 재시동합니다.
환경을 처음 준비할 때는 Irisin에서 `apt`와 `bash`를 선택하세요. **Install** 버튼을 길게 누른 다음 **Bootstrap Install**을 선택하세요. 이 모드는 이번 설치에 포함된 모든 패키지를 먼저 압축 해제한 뒤 설치 절차를 다시 실행합니다. 따라서 `debianutils`에는 `bash`가 필요하지만 `bash`에는 이미 설정된 `debianutils`가 필요한 초기 의존성 순환을 우회할 수 있습니다. 첫 준비가 끝나면 일반 설치 방식을 사용하면 됩니다.
+1
View File
@@ -39,6 +39,7 @@ vphone-cli vm launch myphone
## 定制固件 Bootstrap
启动虚拟机后,在 macOS 菜单栏选择 **Guest > Install Bootstrap…**,再选择所需的环境布局。此操作会在访客系统内安装 Irisin。
按住 Option 打开此菜单项可选取本地 Irisin `.deb`;按住 Option 打开 **Uninstall Bootstrap…** 会同时删除现存的 rootless 和 RootHide 环境,但不重启客体。普通卸载会在删除后重启。
首次准备环境时,在 Irisin 中选中 `apt` 和 `bash`,长按**安装**按钮,然后选择**引导安装(Bootstrap Install)**。这种模式会先解压本次安装涉及的所有软件包,再重新执行安装流程,以绕过初始阶段的依赖循环:`debianutils` 需要 `bash`,而 `bash` 又需要已经配置好的 `debianutils`。首次准备完成后,即可使用普通安装模式。
+19 -10
View File
@@ -31,6 +31,13 @@ before IcliKit copies it into a container, registers it, and owns rollback.
`POST /v1/bootstrap/install` (or RPC method `bootstrap.install`) accepts
`{"layout":"rootless"}` or `{"layout":"roothide"}` and installs the latest
published `Lakr233/Irisin` release as the selected bootstrap's initial app.
An optional `package_path` selects a guest-uploaded Irisin `.deb` instead.
The path must be `/var/root/Library/Caches/vphoned-irisin-<UUID>.deb`; vphoned
opens it without following symlinks, requires a regular file of at most 64 MiB,
and validates the Debian package name, architecture, app version, executables,
and launchd plist before installing. The Guest menu's Option alternate opens
a file picker, uploads the selected package, and chooses the layout. The default
menu item retains the verified latest-release download.
Rootless uses `/var/jb`. RootHide reuses the sole valid `.jbroot-<16 hex>` under
`/var/containers/Bundle/Application`, or creates
`.jbroot-000114514191980C` when none exists. The selected stem is zero padded
@@ -79,21 +86,23 @@ The status reports `phase` and, during download, `downloaded_bytes` and
`total_bytes` when the server provides a length. The sheet shows the download
progress, then the installation result without closing.
`GET /v1/bootstrap/inspect` (RPC `bootstrap.inspect`) reports the layout and
path recorded by a completed vphoned installation, or `installed: false`.
`GET /v1/bootstrap/inspect` (RPC `bootstrap.inspect`) reports `roots`, the
rootless `/var/jb` and all valid RootHide `.jbroot-<16 hex>` environments found
on the guest, including the completed vphoned root if it is now missing.
`POST /v1/bootstrap/uninstall` (RPC `bootstrap.uninstall`) requires
`{"jbroot":"<path from inspect>","force":true}`. It accepts only the
rootless `/var/jb` directory or a valid RootHide `.jbroot-<16 hex>` directory
recorded by vphoned. A rootless `/var/jb` symlink is accepted only when its
`{"roots":["<paths from inspect>"],"force":true}` and removes all the listed
environments in one operation. The paths must still match the current
inspection result. A single `jbroot` is accepted for older clients only when
it is the only environment. A rootless `/var/jb` symlink is accepted only when its
target is a physical directory under `/private/preboot`; the target and link
are both removed. The daemon rejects a changed path and symlinked child
directories. It unloads the bootstrap's launch daemons, unregisters apps in
its `Applications` directory, deletes the bootstrap root, marks the completion
record uninstalled, then schedules a full guest reboot. If cleanup fails, the
directories. It unloads each bootstrap's launch daemons, unregisters its apps,
deletes both rootless and RootHide roots, marks the completion record uninstalled,
then schedules a full guest reboot. `"reboot":false` skips the reboot; holding
Option on the Guest menu's uninstall item selects this mode. If cleanup fails, the
installed record remains so the operation can be retried. Irisin's mobile
Documents data outside the bootstrap is retained. Guest > Uninstall Bootstrap…
shows the recorded path
in a destructive confirmation alert before sending the request.
shows every path in a destructive confirmation alert before sending the request.
## HTTP and WebSocket contract
+6 -1
View File
@@ -218,6 +218,7 @@ enum GuestAPI {
return try GuestIrisinInstaller.install(
jailbreak: jailbreakInfo(),
layout: layout,
packagePath: params["package_path"] as? String,
)
case "bootstrap.status":
return GuestIrisinInstaller.status()
@@ -225,7 +226,11 @@ enum GuestAPI {
return try GuestIrisinInstaller.installedBootstrap()
case "bootstrap.uninstall":
try requireForce(params, "uninstall the bootstrap")
return try GuestIrisinInstaller.uninstall(expectedRoot: string(params, "jbroot"))
let roots = try (params["roots"] as? [String]) ?? [string(params, "jbroot")]
return try GuestIrisinInstaller.uninstall(
expectedRoots: roots,
reboot: params["reboot"] as? Bool ?? true,
)
case "bootstrap.firmware":
return try GuestIrisinInstaller.repairFirmwareRecord()
case "input.touch":
+107 -41
View File
@@ -16,7 +16,7 @@ enum GuestIrisinInstaller {
.deletingLastPathComponent()
.appendingPathComponent(".vphoned-boostrap-completed")
static func install(jailbreak: [String: Any], layout: String) throws -> [String: Any] {
static func install(jailbreak: [String: Any], layout: String, packagePath: String? = nil) throws -> [String: Any] {
installLock.lock()
defer { installLock.unlock() }
guard try completedBootstrap() == nil else {
@@ -24,7 +24,7 @@ enum GuestIrisinInstaller {
}
setProgress(["phase": "preparing", "layout": layout])
do {
let result = try performInstall(jailbreak: jailbreak, layout: layout)
let result = try performInstall(jailbreak: jailbreak, layout: layout, packagePath: packagePath)
setProgress(["phase": "completed", "layout": layout,
"version": result["version"] ?? "", "jbroot": result["jbroot"] ?? ""])
return result
@@ -41,24 +41,64 @@ enum GuestIrisinInstaller {
}
static func installedBootstrap() throws -> [String: Any] {
guard let installation = try completedBootstrap() else { return ["installed": false] }
return ["installed": true, "layout": installation.layout, "jbroot": installation.root]
let roots = try bootstrapRoots()
var result: [String: Any] = ["installed": !roots.isEmpty, "roots": roots.map(\.root)]
if let installation = try completedBootstrap() {
result["layout"] = installation.layout
result["jbroot"] = installation.root
}
return result
}
static func uninstall(expectedRoot: String) throws -> [String: Any] {
static func uninstall(expectedRoots: [String], reboot: Bool = true) throws -> [String: Any] {
installLock.lock()
defer { installLock.unlock() }
guard let installation = try completedBootstrap() else {
throw GuestAPIError.operationFailed("No completed vphoned bootstrap was found")
let roots = try bootstrapRoots()
guard !roots.isEmpty else {
throw GuestAPIError.operationFailed("No bootstrap environment was found")
}
let root = installation.root
guard expectedRoot == root else {
throw GuestAPIError.invalidRequest("Bootstrap path changed; inspect it again before uninstalling")
guard expectedRoots == roots.map(\.root) else {
throw GuestAPIError.invalidRequest("Bootstrap paths changed; inspect them again before uninstalling")
}
for installation in roots {
try removeBootstrap(root: installation.root, layout: installation.layout)
}
// A legacy marker may live beside vphoned on the read-only system
// volume. Shadow it with a writable tombstone after removal.
try writeMarker(["installed": false])
if reboot {
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
do {
_ = try requestReboot(userspace: false, force: true)
} catch {
NSLog("vphoned: bootstrap removed but reboot failed: %@", String(describing: error))
}
}
}
return ["roots": expectedRoots, "deleted": true, "reboot_scheduled": reboot]
}
private static func bootstrapRoots() throws -> [(layout: String, root: String)] {
var roots: [(layout: String, root: String)] = []
if let installation = try completedBootstrap() { roots.append(installation) }
if itemExists(URL(fileURLWithPath: "/var/jb")), !roots.contains(where: { $0.root == "/var/jb" }) {
roots.append(("rootless", "/var/jb"))
}
let parent = "/private/var/containers/Bundle/Application"
for name in try FileManager.default.contentsOfDirectory(atPath: parent).sorted() where roothideName(name) {
let root = parent + "/" + name
if !roots.contains(where: { $0.root == root }) {
roots.append(("roothide", root))
}
}
return roots.sorted { $0.root < $1.root }
}
private static func removeBootstrap(root: String, layout: String) throws {
let files = FileManager.default
let rootURL = URL(fileURLWithPath: root, isDirectory: true)
let removal = try removalRoot(root, layout: installation.layout)
let removal = try removalRoot(root, layout: layout)
if try directoryExistsWithoutSymlink(removal.physicalPath) {
for relative in ["Library/LaunchDaemons", "basebin/LaunchDaemons"] {
let directory = rootURL.appendingPathComponent(relative, isDirectory: true).path
@@ -83,17 +123,6 @@ enum GuestIrisinInstaller {
try files.removeItem(atPath: removal.physicalPath)
}
if removal.isSymlink { try files.removeItem(at: rootURL) }
// A legacy marker may live beside vphoned on the read-only system
// volume. Shadow it with a writable tombstone after removal.
try writeMarker(["installed": false])
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
do {
_ = try requestReboot(userspace: false, force: true)
} catch {
NSLog("vphoned: bootstrap removed but reboot failed: %@", String(describing: error))
}
}
return ["jbroot": root, "layout": installation.layout, "deleted": true, "reboot_scheduled": true]
}
private static func completedBootstrap() throws -> (layout: String, root: String)? {
@@ -185,7 +214,7 @@ enum GuestIrisinInstaller {
progressLock.unlock()
}
private static func performInstall(jailbreak: [String: Any], layout: String) throws -> [String: Any] {
private static func performInstall(jailbreak: [String: Any], layout: String, packagePath: String?) throws -> [String: Any] {
let detectedLayout = jailbreak["layout"] as? String
guard layout == "rootless" || layout == "roothide" else {
throw GuestAPIError.invalidRequest("layout must be rootless or roothide")
@@ -203,29 +232,41 @@ enum GuestIrisinInstaller {
}
let architecture = layout == "roothide" ? "iphoneos-arm64e" : "iphoneos-arm64"
let release = try releaseAsset(architecture: architecture)
setProgress(["phase": "downloading", "layout": layout, "tag": release.tag,
"downloaded_bytes": 0])
let work = FileManager.default.temporaryDirectory
.appendingPathComponent("vphoned-irisin-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: work) }
let package = work.appendingPathComponent(release.name)
let packageData = try fetch(release.url, reportDownload: true)
let digest = SHA256.hash(data: packageData).map { String(format: "%02x", $0) }.joined()
guard digest == release.digest else {
throw GuestAPIError.operationFailed("Irisin release asset SHA-256 mismatch")
let package = work.appendingPathComponent("Irisin.deb")
let tag: String
let expectedVersion: String?
if let packagePath {
try copyLocalPackage(packagePath, to: package)
tag = "local"
expectedVersion = nil
} else {
let release = try releaseAsset(architecture: architecture)
setProgress(["phase": "downloading", "layout": layout, "tag": release.tag,
"downloaded_bytes": 0])
let packageData = try fetch(release.url, reportDownload: true)
let digest = SHA256.hash(data: packageData).map { String(format: "%02x", $0) }.joined()
guard digest == release.digest else {
throw GuestAPIError.operationFailed("Irisin release asset SHA-256 mismatch")
}
try packageData.write(to: package, options: .atomic)
tag = release.tag
expectedVersion = release.version
}
try packageData.write(to: package, options: .atomic)
setProgress(["phase": "extracting", "layout": layout, "tag": release.tag])
setProgress(["phase": "extracting", "layout": layout, "tag": tag])
let metadata = try readDeb(package.path)
let control = metadata["control"] as? [String: String] ?? [:]
let version = control["Version"] ?? ""
guard control["Package"] == "wiki.qaq.irisin",
control["Version"] == release.version,
!version.isEmpty, version.count <= 128,
expectedVersion == nil || version == expectedVersion,
control["Architecture"] == architecture
else { throw GuestAPIError.operationFailed("Irisin package metadata does not match the release") }
else { throw GuestAPIError.operationFailed("Irisin package metadata does not match the selected layout") }
let extracted = work.appendingPathComponent("extracted", isDirectory: true)
_ = try extractDeb(package.path, to: extracted.path)
@@ -237,7 +278,7 @@ enum GuestIrisinInstaller {
let helper = payload.appendingPathComponent("usr/libexec/irisin-install")
let plist = payload.appendingPathComponent("Library/LaunchDaemons/\(serviceLabel).plist")
try validatePayload(app: app, daemon: daemon, helper: helper, plist: plist,
version: release.version, architecture: architecture, layout: layout)
version: version, architecture: architecture, layout: layout)
if layout == "roothide" {
try prepareRootHidePlist(plist, executable: root + "/usr/libexec/irisind")
@@ -256,7 +297,7 @@ enum GuestIrisinInstaller {
var replaced: [(target: URL, backup: URL?)] = []
do {
setProgress(["phase": "installing", "layout": layout, "tag": release.tag])
setProgress(["phase": "installing", "layout": layout, "tag": tag])
for (source, target) in components {
replaced.append(try replace(source, at: target))
}
@@ -277,16 +318,16 @@ enum GuestIrisinInstaller {
guard status["loaded"] as? Bool == true else {
throw GuestAPIError.operationFailed("Irisin daemon is not loaded")
}
setProgress(["phase": "firmware", "layout": layout, "tag": release.tag])
setProgress(["phase": "firmware", "layout": layout, "tag": tag])
let firmware = try ensureFirmwareRecord(root: root)
let marker = ["tag": release.tag, "layout": layout, "jbroot": root]
let marker = ["tag": tag, "layout": layout, "jbroot": root]
try writeMarker(marker)
for entry in replaced {
if let backup = entry.backup { try? FileManager.default.removeItem(at: backup) }
}
var result: [String: Any] = [
"tag": release.tag,
"version": release.version,
"tag": tag,
"version": version,
"architecture": architecture,
"layout": layout,
"jbroot": root,
@@ -494,6 +535,31 @@ enum GuestIrisinInstaller {
let digest: String
}
private static func copyLocalPackage(_ path: String, to destination: URL) throws {
let prefix = "/var/root/Library/Caches/vphoned-irisin-"
guard path.hasPrefix(prefix), path.hasSuffix(".deb"),
let id = UUID(uuidString: String(path.dropFirst(prefix.count).dropLast(4))),
path == prefix + id.uuidString + ".deb"
else { throw GuestAPIError.invalidRequest("Invalid staged Irisin package path") }
let descriptor = open(path, O_RDONLY | O_NOFOLLOW)
guard descriptor >= 0 else {
throw GuestAPIError.operationFailed("Could not open staged Irisin package")
}
defer { close(descriptor) }
var info = stat()
guard fstat(descriptor, &info) == 0,
info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
info.st_size > 0, info.st_size <= 64 * 1024 * 1024
else { throw GuestAPIError.invalidRequest("Irisin package must be a regular file under 64 MiB") }
let data = try FileHandle(fileDescriptor: descriptor, closeOnDealloc: false).readToEnd() ?? Data()
guard data.count == info.st_size else {
throw GuestAPIError.operationFailed("Could not read the complete Irisin package")
}
try data.write(to: destination, options: .atomic)
try? FileManager.default.removeItem(atPath: path)
}
private static func releaseAsset(architecture: String) throws -> Asset {
let data = try fetch(releaseURL)
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -28991,6 +28991,346 @@
}
}
},
"Bootstrap removed. The guest was not restarted." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bootstrap removed. The guest was not restarted."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ブートストラップを削除しました。ゲストは再起動していません。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "부트스트랩을 제거했습니다. 게스트는 재시동하지 않았습니다."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Đã gỡ bootstrap. Máy khách chưa khởi động lại."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "引导环境已卸载,客体未重启。"
}
}
}
},
"Choose an Irisin .deb file no larger than 64 MiB." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choose an Irisin .deb file no larger than 64 MiB."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "64 MiB 以下の Irisin .deb ファイルを選択してください。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "64 MiB 이하의 Irisin .deb 파일을 선택하세요."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Chọn tệp Irisin .deb không lớn hơn 64 MiB."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "请选择不超过 64 MiB 的 Irisin .deb 文件。"
}
}
}
},
"Choose an Irisin .deb package to install in the guest." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choose an Irisin .deb package to install in the guest."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ゲストにインストールする Irisin の .deb パッケージを選択してください。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "게스트에 설치할 Irisin .deb 패키지를 선택하세요."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Chọn gói Irisin .deb để cài vào máy khách."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选择要安装到客体的 Irisin .deb 软件包。"
}
}
}
},
"Choose the bootstrap layout for %@." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Choose the bootstrap layout for %@."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@ のブートストラップ構成を選択してください。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@의 부트스트랩 레이아웃을 선택하세요."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Chọn bố cục bootstrap cho %@."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "选择 %@ 的引导环境布局。"
}
}
}
},
"Install Bootstrap from File…" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Install Bootstrap from File…"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ファイルからブートストラップをインストール…"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "파일에서 부트스트랩 설치…"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cài đặt bootstrap từ tệp…"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "从文件安装引导环境…"
}
}
}
},
"Installing %@ in the guest." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Installing %@ in the guest."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ゲストに %@ をインストールしています。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "게스트에 %@ 설치 중."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Đang cài %@ vào máy khách."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在客体中安装 %@。"
}
}
}
},
"No bootstrap environment was found." : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No bootstrap environment was found."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ブートストラップ環境が見つかりません。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "부트스트랩 환경을 찾을 수 없습니다."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Không tìm thấy môi trường bootstrap."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "未找到引导环境。"
}
}
}
},
"Permanently delete these bootstrap environments and restart the guest?\n%@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Permanently delete these bootstrap environments and restart the guest?\n%@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "これらのブートストラップ環境を完全に削除してゲストを再起動しますか?\n%@"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 부트스트랩 환경을 영구 삭제하고 게스트를 재시동할까요?\n%@"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Xóa vĩnh viễn các môi trường bootstrap này và khởi động lại máy khách?\n%@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "要永久删除以下引导环境并重启客体吗?\n%@"
}
}
}
},
"Permanently delete these bootstrap environments without restarting the guest?\n%@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Permanently delete these bootstrap environments without restarting the guest?\n%@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ゲストを再起動せずに、これらのブートストラップ環境を完全に削除しますか?\n%@"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "게스트를 재시동하지 않고 이 부트스트랩 환경을 영구 삭제할까요?\n%@"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Xóa vĩnh viễn các môi trường bootstrap này mà không khởi động lại máy khách?\n%@"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "要永久删除以下引导环境且不重启客体吗?\n%@"
}
}
}
},
"Uninstall Bootstrap… (No Restart)" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Uninstall Bootstrap… (No Restart)"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "ブートストラップを削除…(再起動しない)"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "부트스트랩 제거… (재시동 안 함)"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Gỡ bootstrap… (không khởi động lại)"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "卸载引导环境…(不重启)"
}
}
}
},
"Yes (FairPlay)" : {
"localizations" : {
"ja" : {
@@ -312,10 +312,22 @@ final class VPhoneGuestControl {
return result["msg"] as? String ?? "Installed \(localURL.lastPathComponent)."
}
func installBootstrap(layout: String) async throws -> [String: Any] {
func installBootstrap(layout: String, localURL: URL? = nil) async throws -> [String: Any] {
guard guestCapabilities.contains("bootstrap_install") else {
throw ControlError.unsupportedCapability("bootstrap_install")
}
if let localURL {
let size = try FileManager.default.attributesOfItem(atPath: localURL.path)[.size] as? Int ?? 0
guard localURL.pathExtension.lowercased() == "deb", size > 0, size <= 64 * 1024 * 1024 else {
throw ControlError.guestError("Choose an Irisin .deb file no larger than 64 MiB.")
}
let data = try Data(contentsOf: localURL, options: .mappedIfSafe)
let path = "/var/root/Library/Caches/vphoned-irisin-\(UUID().uuidString).deb"
try await createDirectory(path: "/var/root/Library/Caches")
try await uploadFile(path: path, data: data)
defer { Task { try? await deleteFile(path: path) } }
return try await call("bootstrap.install", params: ["layout": layout, "package_path": path])
}
return try await call("bootstrap.install", params: ["layout": layout])
}
@@ -330,11 +342,11 @@ final class VPhoneGuestControl {
return try await call("bootstrap.inspect")
}
func uninstallBootstrap(at root: String) async throws -> [String: Any] {
func uninstallBootstrap(at roots: [String], reboot: Bool) async throws -> [String: Any] {
guard guestCapabilities.contains("bootstrap_uninstall") else {
throw ControlError.unsupportedCapability("bootstrap_uninstall")
}
return try await call("bootstrap.uninstall", params: ["jbroot": root, "force": true])
return try await call("bootstrap.uninstall", params: ["roots": roots, "reboot": reboot, "force": true])
}
func clipboardGet() async throws -> ClipboardContent {
@@ -1,4 +1,5 @@
import AppKit
import UniformTypeIdentifiers
// MARK: - Bootstrap Installation and Removal
@@ -6,17 +7,44 @@ import AppKit
/// vphoned's progress while it downloads, extracts and registers it.
extension VPhoneMenuController {
func updateBootstrapAvailability(available: Bool) {
installBootstrapItem?.isEnabled = available && !isInstallingBootstrap && !isUninstallingBootstrap
let enabled = available && !isInstallingBootstrap && !isUninstallingBootstrap
installBootstrapItem?.isEnabled = enabled
installBootstrapFromFileItem?.isEnabled = enabled
}
func updateBootstrapUninstallAvailability(available: Bool) {
uninstallBootstrapItem?.isEnabled = available && !isInstallingBootstrap && !isUninstallingBootstrap
let enabled = available && !isInstallingBootstrap && !isUninstallingBootstrap
uninstallBootstrapItem?.isEnabled = enabled
uninstallBootstrapNoRestartItem?.isEnabled = enabled
}
@objc func installBootstrap() {
chooseBootstrapLayout(localURL: nil)
}
@objc func installBootstrapFromFile() {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.allowedContentTypes = [UTType(filenameExtension: "deb") ?? .data]
panel.prompt = VPhoneLocalization.text("Install")
panel.message = VPhoneLocalization.text("Choose an Irisin .deb package to install in the guest.")
VPhoneAlert.present(panel) { [weak self] response in
guard response == .OK, let url = panel.url else { return }
self?.chooseBootstrapLayout(localURL: url)
}
}
private func chooseBootstrapLayout(localURL: URL?) {
let message = if let localURL {
VPhoneLocalization.format("Choose the bootstrap layout for %@.", localURL.lastPathComponent)
} else {
VPhoneLocalization.text("Choose the bootstrap layout. This installs the latest Irisin release once in the guest.")
}
VPhoneAlert.present(
title: "Install Bootstrap",
message: "Choose the bootstrap layout. This installs the latest Irisin release once in the guest.",
message: message,
style: .informational,
buttons: ["roothide", "rootless (deprecated)", "Cancel"],
) { [weak self] response in
@@ -26,17 +54,23 @@ extension VPhoneMenuController {
case .alertSecondButtonReturn: layout = "rootless"
default: return
}
self?.performBootstrapInstallation(layout: layout)
self?.performBootstrapInstallation(layout: layout, localURL: localURL)
}
}
private func performBootstrapInstallation(layout: String) {
private func performBootstrapInstallation(layout: String, localURL: URL?) {
isInstallingBootstrap = true
installBootstrapItem?.isEnabled = false
installBootstrapFromFileItem?.isEnabled = false
uninstallBootstrapItem?.isEnabled = false
uninstallBootstrapNoRestartItem?.isEnabled = false
let alert = NSAlert()
alert.messageText = VPhoneLocalization.text("Install Bootstrap")
alert.informativeText = VPhoneLocalization.text("Installing the latest Irisin release in the guest.")
alert.informativeText = if let localURL {
VPhoneLocalization.format("Installing %@ in the guest.", localURL.lastPathComponent)
} else {
VPhoneLocalization.text("Installing the latest Irisin release in the guest.")
}
let close = alert.addButton(withTitle: VPhoneLocalization.text("Close"))
close.isEnabled = false
let accessory = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 52))
@@ -73,7 +107,7 @@ extension VPhoneMenuController {
}
}
do {
let result = try await control.installBootstrap(layout: layout)
let result = try await control.installBootstrap(layout: layout, localURL: localURL)
poller.cancel()
let version = result["version"] as? String ?? ""
let root = result["jbroot"] as? String ?? ""
@@ -95,6 +129,14 @@ extension VPhoneMenuController {
}
@objc func uninstallBootstrap() {
performBootstrapUninstall(reboot: true)
}
@objc func uninstallBootstrapWithoutRestart() {
performBootstrapUninstall(reboot: false)
}
private func performBootstrapUninstall(reboot: Bool) {
guard !isInstallingBootstrap && !isUninstallingBootstrap else { return }
isUninstallingBootstrap = true
updateBootstrapAvailability(available: false)
@@ -103,22 +145,31 @@ extension VPhoneMenuController {
do {
let installation = try await control.installedBootstrap()
guard installation["installed"] as? Bool == true,
let root = installation["jbroot"] as? String else {
let roots = installation["roots"] as? [String], !roots.isEmpty else {
VPhoneAlert.present(
title: "Uninstall Bootstrap",
message: "No completed bootstrap installation was found.",
message: "No bootstrap environment was found.",
style: .informational,
)
finishBootstrapUninstall()
return
}
let message = if reboot {
VPhoneLocalization.format(
"Permanently delete these bootstrap environments and restart the guest?\n%@",
roots.joined(separator: "\n"),
)
} else {
VPhoneLocalization.format(
"Permanently delete these bootstrap environments without restarting the guest?\n%@",
roots.joined(separator: "\n"),
)
}
VPhoneAlert.present(
title: "Uninstall Bootstrap",
message: VPhoneLocalization.format(
"Permanently delete the bootstrap at %@ and restart the guest?", root,
),
message: message,
style: .warning,
buttons: ["Delete and Restart", "Cancel"],
buttons: [reboot ? "Delete and Restart" : "Delete", "Cancel"],
) { response in
guard response == .alertFirstButtonReturn else {
self.finishBootstrapUninstall()
@@ -126,10 +177,12 @@ extension VPhoneMenuController {
}
Task {
do {
_ = try await self.control.uninstallBootstrap(at: root)
_ = try await self.control.uninstallBootstrap(at: roots, reboot: reboot)
VPhoneAlert.present(
title: "Uninstall Bootstrap",
message: "Bootstrap removed. The guest is restarting.",
message: reboot
? "Bootstrap removed. The guest is restarting."
: "Bootstrap removed. The guest was not restarted.",
style: .informational,
)
} catch {
@@ -21,7 +21,9 @@ class VPhoneMenuController {
var connectPingItem: NSMenuItem?
var connectGuestHashItem: NSMenuItem?
var installBootstrapItem: NSMenuItem?
var installBootstrapFromFileItem: NSMenuItem?
var uninstallBootstrapItem: NSMenuItem?
var uninstallBootstrapNoRestartItem: NSMenuItem?
var isInstallingBootstrap = false
var isUninstallingBootstrap = false
var installPackageItem: NSMenuItem?
@@ -36,11 +36,26 @@ extension VPhoneMenuController {
installBootstrap.isEnabled = false
installBootstrapItem = installBootstrap
menu.addItem(installBootstrap)
let installFromFile = makeItem(
"Install Bootstrap from File…", action: #selector(installBootstrapFromFile), modifiers: [.option],
)
installFromFile.isAlternate = true
installFromFile.isEnabled = false
installBootstrapFromFileItem = installFromFile
menu.addItem(installFromFile)
let uninstallBootstrap = makeItem("Uninstall Bootstrap…", action: #selector(uninstallBootstrap))
uninstallBootstrap.isEnabled = false
uninstallBootstrapItem = uninstallBootstrap
menu.addItem(uninstallBootstrap)
let uninstallNoRestart = makeItem(
"Uninstall Bootstrap… (No Restart)", action: #selector(uninstallBootstrapWithoutRestart),
modifiers: [.option],
)
uninstallNoRestart.isAlternate = true
uninstallNoRestart.isEnabled = false
uninstallBootstrapNoRestartItem = uninstallNoRestart
menu.addItem(uninstallNoRestart)
menu.addItem(NSMenuItem.separator())