mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-10-02 08:04:32 +08:00
Support local Irisin bootstrap and multi-root uninstall
This commit is contained in:
@@ -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` が必要という初期段階の依存関係を回避できます。初回の準備後は通常のインストールを使用できます。
|
||||
|
||||
|
||||
@@ -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`가 필요한 초기 의존성 순환을 우회할 수 있습니다. 첫 준비가 끝나면 일반 설치 방식을 사용하면 됩니다.
|
||||
|
||||
|
||||
@@ -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`。首次准备完成后,即可使用普通安装模式。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
+15
-3
@@ -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 {
|
||||
|
||||
+68
-15
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user