mirror of
https://github.com/Lakr233/vphone-cli.git
synced 2026-09-02 02:34:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87f796c62a | ||
|
|
deabe6ffca | ||
|
|
2af884b56c | ||
|
|
977c17b56d | ||
|
|
53aa44dd57 | ||
|
|
58988bd518 | ||
|
|
72ccbde1de | ||
|
|
39b6d04108 | ||
|
|
339719f34d | ||
|
|
421cb7aa7a | ||
|
|
dee895aeb7 | ||
|
|
673146ba0a | ||
|
|
988e5dc389 | ||
|
|
9f7e21402a | ||
|
|
3bb6f0fda4 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[submodule "scripts/resources"]
|
||||
path = scripts/resources
|
||||
url = https://github.com/Lakr233/vphone-cli-storage.git
|
||||
url = https://github.com/zqxwce/vphone-cli-storage.git
|
||||
[submodule "vendor/libcapstone-spm"]
|
||||
path = vendor/libcapstone-spm
|
||||
url = https://github.com/Lakr233/libcapstone-spm.git
|
||||
|
||||
@@ -12,6 +12,7 @@ Boot a virtual iPhone via Apple's Virtualization.framework using PCC research VM
|
||||
|
||||
- Apple Silicon
|
||||
- macOS 15+ (Sequoia)
|
||||
- Xcode + iOS SDK (cross-compiles the guest daemon)
|
||||
- [SIP/AMFI relaxation to allow private PV=3 entitlements with unsigned-binary](#sipamfi-relaxation)
|
||||
|
||||
**Dependencies:**
|
||||
@@ -60,8 +61,8 @@ vphone-cli vm info myphone # show one VM
|
||||
vphone-cli vm new myphone # create an empty bundle (cpu/mem/disk options)
|
||||
vphone-cli vm config myphone --cpu 8 --memory 8192
|
||||
vphone-cli vm clone myphone myphone-2 # fast APFS clone, fresh device identity
|
||||
vphone-cli vm export myphone --out myphone.tar.xz # xz -9; skips restore dir + staging files
|
||||
vphone-cli vm import --in myphone.tar.xz --name restored
|
||||
vphone-cli vm export myphone --out myphone.tzst # zstd fast by default (--max = xz -9); --out may be a dir (auto-names <vm>.tzst/.txz); skips restore dir + staging files
|
||||
vphone-cli vm import myphone.tzst --name restored
|
||||
vphone-cli vm rename myphone iphone16
|
||||
vphone-cli vm delete iphone16
|
||||
```
|
||||
@@ -106,16 +107,19 @@ See [`research/0_binary_patch_comparison.md`](./research/0_binary_patch_comparis
|
||||
|
||||
## Locations
|
||||
|
||||
Everything vphone-cli creates lives under `~/.vphone/` — kept outside the repo and the `.app` so the signed bundle stays portable:
|
||||
Everything vphone-cli creates lives under `~/.vphone/` — kept outside the repo and the `.app` so the signed bundle stays portable. Redirect the whole tree with `$VPHONE_ROOT`:
|
||||
|
||||
| Path | Contents |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `~/.vphone/` | The per-user data root — override the entire location with `$VPHONE_ROOT`. |
|
||||
| `~/.vphone/VMs/` | VM bundles — one directory per VM. This is the library; override with `$VPHONE_LIBRARY_ROOT`. |
|
||||
| `~/.vphone/ipsws/`| Downloaded iPhone + cloudOS IPSWs, cached and reused across VMs. |
|
||||
| `~/.vphone/tools/`| Cached APFS seal-volume artifacts (`apfs_sealvolume_<version>`) fetched during `fw prepare`. |
|
||||
| `~/.vphone/debs/` | Cached `.deb` packages the `jb`/`exp` CFW install lays into the guest (Sileo, apt, …). |
|
||||
| `~/.vphone/venv/` | Auto-provisioned Python environment (see [Python runtime](#python-runtime); override with `$VPHONE_VENV_DIR`). |
|
||||
|
||||
Precedence: the per-item overrides (`$VPHONE_LIBRARY_ROOT`, `$VPHONE_VENV_DIR`) win over `$VPHONE_ROOT`, which wins over the `~/.vphone` default. The `ipsws/`, `tools/`, and `debs/` caches always sit directly under whichever root is active.
|
||||
|
||||
## SIP/AMFI Relaxation
|
||||
|
||||
**Option A — fully disable SIP, then disable AMFI via boot-arg (most permissive).**
|
||||
@@ -163,9 +167,13 @@ vphone-amfidont # .build/vphone-cli.app/Contents/Resources/vphone-amfido
|
||||
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_26.6.1_23G83` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.6.1 | `17,3_27.0_24A5408d` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5418b` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5424a` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5430a` | `26.4-23E5207q` |
|
||||
|
||||
## FAQ
|
||||
|
||||
|
||||
+11
-3
@@ -12,6 +12,7 @@ PCC リサーチ VM インフラストラクチャを使用し、Apple の Virtu
|
||||
|
||||
- Apple Silicon
|
||||
- macOS 15+ (Sequoia)
|
||||
- Xcode + iOS SDK(ゲストデーモンをクロスコンパイルするため)
|
||||
- [未署名バイナリでプライベートな PV=3 エンタイトルメントを許可するための SIP/AMFI の緩和](#sipamfi-の緩和)
|
||||
|
||||
**依存関係:**
|
||||
@@ -60,8 +61,8 @@ vphone-cli vm info myphone # 1 つの VM を表示
|
||||
vphone-cli vm new myphone # 空のバンドルを作成(cpu/mem/disk オプション)
|
||||
vphone-cli vm config myphone --cpu 8 --memory 8192
|
||||
vphone-cli vm clone myphone myphone-2 # 高速 APFS クローン、新しいデバイスアイデンティティ
|
||||
vphone-cli vm export myphone --out myphone.tar.xz # xz -9; 復元ディレクトリ + ステージングファイルをスキップ
|
||||
vphone-cli vm import --in myphone.tar.xz --name restored
|
||||
vphone-cli vm export myphone --out myphone.tzst # zstd fast by default (--max = xz -9); --out がディレクトリなら <vm>.tzst/.txz を自動命名; 復元ディレクトリ + ステージングファイルをスキップ
|
||||
vphone-cli vm import myphone.tzst --name restored
|
||||
vphone-cli vm rename myphone iphone16
|
||||
vphone-cli vm delete iphone16
|
||||
```
|
||||
@@ -106,16 +107,19 @@ vphone-cli vm launch myphone # 6. 初回起動
|
||||
|
||||
## 場所
|
||||
|
||||
vphone-cli が生成するものはすべて `~/.vphone/` 以下に置かれます — 署名済みバンドルがポータブルであり続けるよう、リポジトリと `.app` の外に保管されます:
|
||||
vphone-cli が生成するものはすべて `~/.vphone/` 以下に置かれます — 署名済みバンドルがポータブルであり続けるよう、リポジトリと `.app` の外に保管されます。`$VPHONE_ROOT` でツリー全体をリダイレクトできます:
|
||||
|
||||
| パス | 内容 |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `~/.vphone/` | ユーザー別データルート — `$VPHONE_ROOT` で場所全体を上書きします。 |
|
||||
| `~/.vphone/VMs/` | VM バンドル — VM ごとに 1 ディレクトリ。これがライブラリです。`$VPHONE_LIBRARY_ROOT` で上書きできます。 |
|
||||
| `~/.vphone/ipsws/`| ダウンロードされた iPhone + cloudOS の IPSW。キャッシュされ、複数の VM で再利用されます。 |
|
||||
| `~/.vphone/tools/`| `fw prepare` 中に取得された APFS seal-volume アーティファクト(`apfs_sealvolume_<version>`)のキャッシュ。 |
|
||||
| `~/.vphone/debs/` | `jb`/`exp` の CFW インストールがゲストに配置する `.deb` パッケージのキャッシュ(Sileo、apt など)。 |
|
||||
| `~/.vphone/venv/` | 自動的にプロビジョニングされる Python 環境([Python ランタイム](#python-ランタイム) を参照。`$VPHONE_VENV_DIR` で上書き可能)。 |
|
||||
|
||||
優先順位: 項目ごとの上書き(`$VPHONE_LIBRARY_ROOT`、`$VPHONE_VENV_DIR`)が `$VPHONE_ROOT` より優先され、`$VPHONE_ROOT` は `~/.vphone` のデフォルトより優先されます。`ipsws/`、`tools/`、`debs/` キャッシュは、常に現在有効なルートの直下に置かれます。
|
||||
|
||||
## SIP/AMFI の緩和
|
||||
|
||||
**オプション A — SIP を完全に無効化し、boot-arg で AMFI を無効化する(最も緩い)。**
|
||||
@@ -163,9 +167,13 @@ vphone-amfidont # ローカルビルドの場合は .build/vphone-cli.ap
|
||||
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_26.6.1_23G83` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.6.1 | `17,3_27.0_24A5408d` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5418b` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5424a` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5430a` | `26.4-23E5207q` |
|
||||
|
||||
## FAQ
|
||||
|
||||
|
||||
+11
-3
@@ -12,6 +12,7 @@ PCC 리서치 VM 인프라를 사용하여 Apple의 Virtualization.framework로
|
||||
|
||||
- Apple Silicon
|
||||
- macOS 15+ (Sequoia)
|
||||
- Xcode + iOS SDK (게스트 데몬 크로스 컴파일용)
|
||||
- [서명되지 않은 바이너리로 private PV=3 권한을 허용하기 위한 SIP/AMFI 완화](#sipamfi-완화)
|
||||
|
||||
**의존성:**
|
||||
@@ -60,8 +61,8 @@ vphone-cli vm info myphone # VM 하나 표시
|
||||
vphone-cli vm new myphone # 빈 번들 생성 (cpu/mem/disk 옵션)
|
||||
vphone-cli vm config myphone --cpu 8 --memory 8192
|
||||
vphone-cli vm clone myphone myphone-2 # 빠른 APFS 복제, 새로운 기기 식별자
|
||||
vphone-cli vm export myphone --out myphone.tar.xz # xz -9; restore 디렉토리 + 스테이징 파일 건너뜀
|
||||
vphone-cli vm import --in myphone.tar.xz --name restored
|
||||
vphone-cli vm export myphone --out myphone.tzst # zstd fast by default (--max = xz -9); --out 이 디렉토리면 <vm>.tzst/.txz 자동 명명; restore 디렉토리 + 스테이징 파일 건너뜀
|
||||
vphone-cli vm import myphone.tzst --name restored
|
||||
vphone-cli vm rename myphone iphone16
|
||||
vphone-cli vm delete iphone16
|
||||
```
|
||||
@@ -106,16 +107,19 @@ vphone-cli vm launch myphone # 6. 첫 부팅
|
||||
|
||||
## 위치
|
||||
|
||||
vphone-cli가 생성하는 모든 것은 `~/.vphone/` 아래에 있습니다 — 서명된 번들이 이식 가능하도록 저장소와 `.app` 외부에 보관됩니다:
|
||||
vphone-cli가 생성하는 모든 것은 `~/.vphone/` 아래에 있습니다 — 서명된 번들이 이식 가능하도록 저장소와 `.app` 외부에 보관됩니다. `$VPHONE_ROOT`로 전체 트리를 리디렉션할 수 있습니다:
|
||||
|
||||
| 경로 | 내용 |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `~/.vphone/` | 사용자별 데이터 루트 — `$VPHONE_ROOT`로 전체 위치를 재정의합니다. |
|
||||
| `~/.vphone/VMs/` | VM 번들 — VM마다 하나의 디렉터리. 라이브러리이며, `$VPHONE_LIBRARY_ROOT`로 재정의할 수 있습니다. |
|
||||
| `~/.vphone/ipsws/`| 다운로드된 iPhone + cloudOS IPSW, 캐시되어 여러 VM에서 재사용됩니다. |
|
||||
| `~/.vphone/tools/`| `fw prepare` 중에 가져온 APFS seal-volume 아티팩트(`apfs_sealvolume_<version>`) 캐시. |
|
||||
| `~/.vphone/debs/` | `jb`/`exp` CFW 설치가 게스트에 넣는 `.deb` 패키지 캐시 (Sileo, apt 등). |
|
||||
| `~/.vphone/venv/` | 자동으로 프로비저닝되는 Python 환경 ([Python 런타임](#python-런타임) 참조; `$VPHONE_VENV_DIR`로 재정의). |
|
||||
|
||||
우선순위: 항목별 재정의(`$VPHONE_LIBRARY_ROOT`, `$VPHONE_VENV_DIR`)가 `$VPHONE_ROOT`보다 우선하고, `$VPHONE_ROOT`는 `~/.vphone` 기본값보다 우선합니다. `ipsws/`, `tools/`, `debs/` 캐시는 항상 현재 활성 루트 바로 아래에 위치합니다.
|
||||
|
||||
## SIP/AMFI 완화
|
||||
|
||||
**방법 A — SIP를 완전히 비활성화한 후, boot-arg로 AMFI를 비활성화 (가장 관대).**
|
||||
@@ -163,9 +167,13 @@ vphone-amfidont # 로컬 빌드의 경우 .build/vphone-cli.app/Contents
|
||||
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_26.6.1_23G83` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.6.1 | `17,3_27.0_24A5408d` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5418b` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5424a` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5430a` | `26.4-23E5207q` |
|
||||
|
||||
## FAQ
|
||||
|
||||
|
||||
+11
-3
@@ -12,6 +12,7 @@
|
||||
|
||||
- Apple Silicon
|
||||
- macOS 15+(Sequoia)
|
||||
- Xcode + iOS SDK(用于交叉编译访客守护进程)
|
||||
- [放宽 SIP/AMFI,以允许未签名二进制使用私有 PV=3 授权](#放宽-sipamfi)
|
||||
|
||||
**依赖:**
|
||||
@@ -60,8 +61,8 @@ vphone-cli vm info myphone # 显示某台虚拟机
|
||||
vphone-cli vm new myphone # 创建一个空 bundle(cpu/内存/磁盘选项)
|
||||
vphone-cli vm config myphone --cpu 8 --memory 8192
|
||||
vphone-cli vm clone myphone myphone-2 # 快速 APFS 克隆,全新设备标识
|
||||
vphone-cli vm export myphone --out myphone.tar.xz # xz -9;跳过 restore 目录 + 暂存文件
|
||||
vphone-cli vm import --in myphone.tar.xz --name restored
|
||||
vphone-cli vm export myphone --out myphone.tzst # zstd fast by default(--max = xz -9);--out 为目录时自动命名 <vm>.tzst/.txz;跳过 restore 目录 + 暂存文件
|
||||
vphone-cli vm import myphone.tzst --name restored
|
||||
vphone-cli vm rename myphone iphone16
|
||||
vphone-cli vm delete iphone16
|
||||
```
|
||||
@@ -106,16 +107,19 @@ vphone-cli vm launch myphone # 6. 首次启动
|
||||
|
||||
## 位置
|
||||
|
||||
vphone-cli 创建的所有内容都位于 `~/.vphone/` 下——保存在仓库和 `.app` 之外,以便签名后的包保持可移植:
|
||||
vphone-cli 创建的所有内容都位于 `~/.vphone/` 下——保存在仓库和 `.app` 之外,以便签名后的包保持可移植。可用 `$VPHONE_ROOT` 重定向整个目录树:
|
||||
|
||||
| 路径 | 内容 |
|
||||
| ----------------- | -------------------------------------------------------------------------------- |
|
||||
| `~/.vphone/` | 每用户数据根目录——用 `$VPHONE_ROOT` 覆盖整个位置。 |
|
||||
| `~/.vphone/VMs/` | 虚拟机包——每个虚拟机一个目录。这是库;可用 `$VPHONE_LIBRARY_ROOT` 覆盖。 |
|
||||
| `~/.vphone/ipsws/`| 已下载的 iPhone + cloudOS IPSW,缓存后在多个虚拟机间复用。 |
|
||||
| `~/.vphone/tools/`| `fw prepare` 期间获取的 APFS seal-volume 制品(`apfs_sealvolume_<version>`)缓存。 |
|
||||
| `~/.vphone/debs/` | `jb`/`exp` CFW 安装写入客户机的 `.deb` 包缓存(Sileo、apt 等)。 |
|
||||
| `~/.vphone/venv/` | 自动配置的 Python 环境(见 [Python 运行时](#python-运行时);可用 `$VPHONE_VENV_DIR` 覆盖)。 |
|
||||
|
||||
优先级:单项覆盖(`$VPHONE_LIBRARY_ROOT`、`$VPHONE_VENV_DIR`)优先于 `$VPHONE_ROOT`,`$VPHONE_ROOT` 优先于 `~/.vphone` 默认值。`ipsws/`、`tools/` 和 `debs/` 缓存始终位于当前生效的根目录之下。
|
||||
|
||||
## 放宽 SIP/AMFI
|
||||
|
||||
**方案 A——完全禁用 SIP,然后通过 boot-arg 禁用 AMFI(最宽松)。**
|
||||
@@ -163,9 +167,13 @@ vphone-amfidont # 本地构建见 .build/vphone-cli.app/Contents/Resourc
|
||||
| Mac16,11 26.2 | `17,3_26.5_23F77` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_26.5.2_23F84` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.4.1 | `17,3_26.6_23G71` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_26.6.1_23G83` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5380h` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.4.1 | `17,3_27.0_24A5390f` | `26.4-23E5207q` |
|
||||
| Mac16,6 26.6.1 | `17,3_27.0_24A5408d` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5418b` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5424a` | `26.4-23E5207q` |
|
||||
| Mac16,11 27.0b2 | `17,3_27.0_24A5430a` | `26.4-23E5207q` |
|
||||
|
||||
## 常见问题
|
||||
|
||||
|
||||
+1
-1
Submodule scripts/resources updated: ee9a2845b3...2ef6b06a10
@@ -229,6 +229,15 @@ else
|
||||
log " Havoc source already present"
|
||||
fi
|
||||
|
||||
FRIDA_LIST="$(dirname "$HAVOC_LIST")/frida.list"
|
||||
if ! grep -rIl 'build.frida.re' /etc/apt /var/jb/etc/apt 2>/dev/null | grep -q .; then
|
||||
mkdir -p "$(dirname "$FRIDA_LIST")"
|
||||
printf '%s\n' 'deb https://build.frida.re/ ./' > "$FRIDA_LIST"
|
||||
log " Frida source added: $FRIDA_LIST"
|
||||
else
|
||||
log " Frida source already present"
|
||||
fi
|
||||
|
||||
apt-get -o Acquire::AllowInsecureRepositories=true \
|
||||
-o Acquire::AllowDowngradeToInsecureRepositories=true \
|
||||
update -qq 2>&1 || log " apt update exited with $?"
|
||||
|
||||
@@ -167,46 +167,110 @@ public enum VPhoneBundleOps {
|
||||
|
||||
// MARK: - Export / Import
|
||||
|
||||
/// Compression preset for `export`. Both import transparently — `importArchive`
|
||||
/// auto-detects the compressor when it extracts. `threads=0` → all cores.
|
||||
public enum ExportCompression: String, CaseIterable, Sendable {
|
||||
case fast, max
|
||||
|
||||
var tarArgs: [String] {
|
||||
switch self {
|
||||
case .fast: ["--zstd", "--options", "zstd:compression-level=3,zstd:threads=0"]
|
||||
case .max: ["-J", "--options", "xz:compression-level=9,xz:threads=0"]
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension for auto-named output when `export`'s destination is a directory.
|
||||
public var fileExtension: String {
|
||||
switch self {
|
||||
case .fast: "tzst"
|
||||
case .max: "txz"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regenerable staging artifacts that never need to travel in an export:
|
||||
/// `.vphoned.signed` is re-staged on the next launch, and the CFW install
|
||||
/// inputs/temp are consumed at install time (the result already lives in
|
||||
/// `Disk.img`). Always excluded.
|
||||
static let exportExcludePatterns = ["*.vphoned.signed", "*cfw_input*", "*cfw_jb_input*", "*.cfw_temp*"]
|
||||
|
||||
/// When `to` is an existing directory, the archive is written inside it as
|
||||
/// `<name>.<compression.fileExtension>`. Returns the resolved output URL.
|
||||
///
|
||||
/// Runs as a two-stage `tar` pipeline (uncompressed producer → compressing
|
||||
/// consumer via bsdtar's `@-`) so `progress` can be driven off the
|
||||
/// uncompressed byte stream: it is called with `(bytesDone, totalBytes)`,
|
||||
/// where `totalBytes` is the bundle's on-disk logical size (minus excludes).
|
||||
@discardableResult
|
||||
public static func export(
|
||||
bundleNamed name: String, to outFile: URL, includeIPSW: Bool, in library: VPhoneLibrary
|
||||
) throws {
|
||||
bundleNamed name: String, to outFile: URL, includeIPSW: Bool,
|
||||
compression: ExportCompression = .fast, in library: VPhoneLibrary,
|
||||
progress: ((Int64, Int64) -> Void)? = nil
|
||||
) throws -> URL {
|
||||
_ = try library.bundle(named: name) // validate it exists
|
||||
// xz at max level, multithreaded (threads=0 → all cores) — the densest
|
||||
// compressor libarchive offers, for a multi-GB Disk.img.
|
||||
var args = ["-cf", outFile.path, "-J", "--options", "xz:compression-level=9,xz:threads=0"]
|
||||
if !includeIPSW { args += ["--exclude", "*_Restore*"] }
|
||||
for pattern in exportExcludePatterns { args += ["--exclude", pattern] }
|
||||
args += ["-C", library.root.path, name]
|
||||
let r = try VPhoneProcessRunner.runCapturing(URL(fileURLWithPath: "/usr/bin/tar"), args)
|
||||
guard r.succeeded else { throw VPhoneBundleOpsError.tarFailed(r.stderr) }
|
||||
var isDir: ObjCBool = false
|
||||
let outFile = FileManager.default.fileExists(atPath: outFile.path, isDirectory: &isDir) && isDir.boolValue
|
||||
? outFile.appendingPathComponent("\(name).\(compression.fileExtension)")
|
||||
: outFile
|
||||
|
||||
// gnutar (not the bsdtar-default pax): pax extended headers make the
|
||||
// consumer's `@-` reader misbid the stream as mtree ("Line too long")
|
||||
// on large members; gnutar also carries files >8 GB (ustar cannot).
|
||||
var producer = ["--format", "gnutar", "-cf", "-"]
|
||||
if !includeIPSW { producer += ["--exclude", "*_Restore*"] }
|
||||
for pattern in exportExcludePatterns { producer += ["--exclude", pattern] }
|
||||
producer += ["-C", library.root.path, name]
|
||||
let consumer = ["-cf", outFile.path] + compression.tarArgs + ["@-"]
|
||||
|
||||
let total = progress != nil
|
||||
? archivedLogicalSize(bundleDir: library.url(forName: name), libraryRoot: library.root, includeIPSW: includeIPSW)
|
||||
: 0
|
||||
let err = try VPhoneProcessRunner.runCountingTarPipe(
|
||||
producerArgs: producer, sourceFile: nil, consumerArgs: consumer
|
||||
) { done in progress?(done, total) }
|
||||
if let err { throw VPhoneBundleOpsError.tarFailed(err) }
|
||||
return outFile
|
||||
}
|
||||
|
||||
/// On-disk logical size of the members `export` will archive, mirroring the
|
||||
/// tar `--exclude` patterns so the progress total matches the streamed bytes.
|
||||
private static func archivedLogicalSize(
|
||||
bundleDir: URL, libraryRoot: URL, includeIPSW: Bool
|
||||
) -> Int64 {
|
||||
let fm = FileManager.default
|
||||
guard let en = fm.enumerator(
|
||||
at: bundleDir,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey]) else { return 0 }
|
||||
let prefix = libraryRoot.path.count + 1 // members are "<name>/..."
|
||||
var total: Int64 = 0
|
||||
for case let url as URL in en {
|
||||
let rel = String(url.path.dropFirst(prefix))
|
||||
if !includeIPSW, rel.contains("_Restore") { en.skipDescendants(); continue }
|
||||
if exportExcludePatterns.contains(where: { fnmatch($0, rel, 0) == 0 }) { continue }
|
||||
guard let vals = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
|
||||
vals.isRegularFile == true else { continue }
|
||||
total += Int64(vals.fileSize ?? 0)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/// Extracts (auto-detecting gzip/zstd/xz) into a private staging dir, then
|
||||
/// promotes the single top-level bundle to the library. Extracting first
|
||||
/// means the archive is decompressed once; `progress` is called with
|
||||
/// `(bytesDone, totalBytes)` as the compressed file is fed into `tar -x`,
|
||||
/// where `totalBytes` is the archive's size on disk.
|
||||
public static func importArchive(
|
||||
from inFile: URL, name: String?, in library: VPhoneLibrary
|
||||
from inFile: URL, name: String?, in library: VPhoneLibrary,
|
||||
progress: ((Int64, Int64) -> Void)? = nil
|
||||
) throws -> VPhoneBundle {
|
||||
let fm = FileManager.default
|
||||
// Auto-detect the compression (-tf, not -tzf) so both legacy gzip and
|
||||
// current xz archives import.
|
||||
let listing = try VPhoneProcessRunner.runCapturing(
|
||||
URL(fileURLWithPath: "/usr/bin/tar"), ["-tf", inFile.path])
|
||||
guard listing.succeeded else { throw VPhoneBundleOpsError.tarFailed(listing.stderr) }
|
||||
let topDirs = Set(listing.stdout.split(whereSeparator: \.isNewline).compactMap {
|
||||
$0.split(separator: "/").first.map(String.init)
|
||||
})
|
||||
guard topDirs.count == 1, let archived = topDirs.first else {
|
||||
throw VPhoneBundleOpsError.badArchive(
|
||||
"expected a single top-level bundle directory, found \(topDirs.sorted())")
|
||||
// Fail fast when the destination name is already known (explicit rename).
|
||||
if let name {
|
||||
try requireValidName(name)
|
||||
if fm.fileExists(atPath: library.url(forName: name).path) {
|
||||
throw VPhoneLibraryError.alreadyExists(name: name)
|
||||
}
|
||||
}
|
||||
let finalName = name ?? archived
|
||||
try requireValidName(finalName)
|
||||
let dst = library.url(forName: finalName)
|
||||
if fm.fileExists(atPath: dst.path) { throw VPhoneLibraryError.alreadyExists(name: finalName) }
|
||||
|
||||
// Extract into a private staging dir so the archive's OWN top-level name
|
||||
// can never clobber/merge into an existing bundle of that name; only the
|
||||
@@ -216,9 +280,21 @@ public enum VPhoneBundleOps {
|
||||
try fm.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
defer { try? fm.removeItem(at: staging) }
|
||||
|
||||
let extract = try VPhoneProcessRunner.runCapturing(
|
||||
URL(fileURLWithPath: "/usr/bin/tar"), ["-xf", inFile.path, "-C", staging.path])
|
||||
guard extract.succeeded else { throw VPhoneBundleOpsError.tarFailed(extract.stderr) }
|
||||
let total = progress != nil ? fileByteSize(inFile) : 0
|
||||
let err = try VPhoneProcessRunner.runCountingTarPipe(
|
||||
producerArgs: nil, sourceFile: inFile, consumerArgs: ["-xf", "-", "-C", staging.path]
|
||||
) { done in progress?(done, total) }
|
||||
if let err { throw VPhoneBundleOpsError.tarFailed(err) }
|
||||
|
||||
let entries = try fm.contentsOfDirectory(atPath: staging.path)
|
||||
guard entries.count == 1, let archived = entries.first else {
|
||||
throw VPhoneBundleOpsError.badArchive(
|
||||
"expected a single top-level bundle directory, found \(entries.sorted())")
|
||||
}
|
||||
let finalName = name ?? archived
|
||||
try requireValidName(finalName)
|
||||
let dst = library.url(forName: finalName)
|
||||
if fm.fileExists(atPath: dst.path) { throw VPhoneLibraryError.alreadyExists(name: finalName) }
|
||||
let extracted = staging.appendingPathComponent(archived)
|
||||
guard fm.fileExists(atPath: extracted.appendingPathComponent("config.plist").path) else {
|
||||
throw VPhoneBundleOpsError.badArchive(
|
||||
@@ -227,4 +303,9 @@ public enum VPhoneBundleOps {
|
||||
try fm.moveItem(at: extracted, to: dst)
|
||||
return try VPhoneBundle.load(at: dst)
|
||||
}
|
||||
|
||||
private static func fileByteSize(_ url: URL) -> Int64 {
|
||||
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize
|
||||
return Int64(size ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,15 @@ public enum VPhoneFirmwareCatalog {
|
||||
.init(iosName: "iOS 26.5", iosURL: "https://updates.cdn-apple.com/2026SpringFCS/fullrestores/122-63074/5E6B4A05-BDBC-45FE-9606-22B8F4315989/iPhone17,3_26.5_23F77_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 26.5.2", iosURL: "https://updates.cdn-apple.com/2026SpringFCS/fullrestores/140-25549/1AFB1F72-E48E-476A-9C21-42B27C846C01/iPhone17,3_26.5.2_23F84_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 26.6", iosURL: "https://updates.cdn-apple.com/2026SummerFCS/fullrestores/140-58193/1F477C3E-934B-43C0-B428-753B9E005EC0/iPhone17,3_26.6_23G71_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 26.6.1", iosURL: "https://updates.cdn-apple.com/2026SummerFCS/fullrestores/140-93817/B5362BAA-F3EE-49C8-BA43-309F0DAD1362/iPhone17,3_26.6.1_23G83_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 27 beta 1", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/122-99394/32118457-A80B-4953-BF2A-11F74FD7D375/iPhone17,3_27.0_24A5355q_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 27 beta 2", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/140-21207/F0510574-F649-48C5-B535-0A477E342BFB/iPhone17,3_27.0_24A5370h_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 27 beta 3", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/140-35950/D135F5B5-C2BE-4630-8AE9-C78A6F0E8381/iPhone17,3_27.0_24A5380h_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 27 beta 4", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/140-57108/5E816D0E-89BB-4B95-8825-6A3EDF22E509/iPhone17,3_27.0_24A5390f_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 27 beta 5", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/fullrestores/140-86338/57B34BF9-3BF5-4B47-BCCA-81B282175957/iPhone17,3_27.0_24A5408d_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 27 beta 6", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/ad5b3026-b03e-4b21-8bcb-96d6ea527e09/iPhone17,3_27.0_24A5418b_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 27 beta 7", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/ad5a4f9d-f005-466b-bbcf-3b466040074b/iPhone17,3_27.0_24A5424a_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
.init(iosName: "iOS 27 beta 8", iosURL: "https://updates.cdn-apple.com/2026SpringSeed/2d03d580-843b-4b2a-b09d-976b31c10744/iPhone17,3_27.0_24A5430a_Restore.ipsw", cloudosName: "cloudOS 26.4", cloudosURL: cloud264),
|
||||
]
|
||||
|
||||
/// Distinct cloudOS images (first-seen order) for the "choose the cloudOS" prompt.
|
||||
|
||||
@@ -41,8 +41,8 @@ public struct VPhoneLibrary: Sendable {
|
||||
// `~/.vphone/VMs` — deliberately space-free: bundle paths flow into the
|
||||
// shell/make firmware pipeline, and "Application Support" (a space) breaks
|
||||
// any unquoted expansion there. Keep the default path shell-safe.
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".vphone/VMs", isDirectory: true)
|
||||
return VPhoneResources.userDataRoot()
|
||||
.appendingPathComponent("VMs", isDirectory: true)
|
||||
}
|
||||
|
||||
public func url(forName name: String) -> URL {
|
||||
|
||||
@@ -79,6 +79,97 @@ public enum VPhoneProcessRunner {
|
||||
stderr: String(decoding: errBox.take(), as: UTF8.self))
|
||||
}
|
||||
|
||||
/// Stream an archive through a `/usr/bin/tar` consumer that reads on stdin,
|
||||
/// invoking `onBytes` with the running byte total so a caller can drive a
|
||||
/// progress bar off the *uncompressed* (export) or *compressed* (import)
|
||||
/// stream. The byte source is exactly one of:
|
||||
/// - `producerArgs`: a `tar` producing an uncompressed archive to a pipe
|
||||
/// (export → count uncompressed input; the consumer compresses via `@-`).
|
||||
/// - `sourceFile`: the archive file read directly (import → count the file
|
||||
/// as it is fed into `tar -x`).
|
||||
/// Returns the stderr of whichever stage exited nonzero (consumer first), or
|
||||
/// `nil` on success. SIGPIPE is ignored for the duration so a consumer that
|
||||
/// dies early surfaces as its exit status rather than killing this process.
|
||||
public static func runCountingTarPipe(
|
||||
producerArgs: [String]?,
|
||||
sourceFile: URL?,
|
||||
consumerArgs: [String],
|
||||
onBytes: ((Int64) -> Void)? = nil
|
||||
) throws -> String? {
|
||||
let tar = URL(fileURLWithPath: "/usr/bin/tar")
|
||||
let prevPIPE = signal(SIGPIPE, SIG_IGN)
|
||||
defer { signal(SIGPIPE, prevPIPE) }
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
let consumer = Process()
|
||||
consumer.executableURL = tar
|
||||
consumer.arguments = consumerArgs
|
||||
let cIn = Pipe()
|
||||
consumer.standardInput = cIn
|
||||
consumer.standardOutput = FileHandle.nullDevice
|
||||
let cErr = Pipe()
|
||||
consumer.standardError = cErr
|
||||
let cErrBox = DataBox()
|
||||
group.enter()
|
||||
cErr.fileHandleForReading.readabilityHandler = { handle in
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { handle.readabilityHandler = nil; group.leave() }
|
||||
else { cErrBox.append(chunk) }
|
||||
}
|
||||
|
||||
var producer: Process?
|
||||
let source: FileHandle
|
||||
let pErrBox = DataBox()
|
||||
if let producerArgs {
|
||||
let p = Process()
|
||||
p.executableURL = tar
|
||||
p.arguments = producerArgs
|
||||
let pOut = Pipe()
|
||||
p.standardOutput = pOut
|
||||
let pErr = Pipe()
|
||||
p.standardError = pErr
|
||||
group.enter()
|
||||
pErr.fileHandleForReading.readabilityHandler = { handle in
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { handle.readabilityHandler = nil; group.leave() }
|
||||
else { pErrBox.append(chunk) }
|
||||
}
|
||||
producer = p
|
||||
source = pOut.fileHandleForReading
|
||||
} else if let sourceFile {
|
||||
source = try FileHandle(forReadingFrom: sourceFile)
|
||||
} else {
|
||||
preconditionFailure("runCountingTarPipe: producerArgs or sourceFile required")
|
||||
}
|
||||
|
||||
try consumer.run()
|
||||
try producer?.run()
|
||||
|
||||
let sink = cIn.fileHandleForWriting
|
||||
var total: Int64 = 0
|
||||
while true {
|
||||
guard let chunk = try? source.read(upToCount: 1 << 20), !chunk.isEmpty else { break }
|
||||
do { try sink.write(contentsOf: chunk) } catch { break } // consumer died; status below
|
||||
total += Int64(chunk.count)
|
||||
onBytes?(total)
|
||||
}
|
||||
try? sink.close()
|
||||
try? source.close()
|
||||
|
||||
producer?.waitUntilExit()
|
||||
consumer.waitUntilExit()
|
||||
group.wait()
|
||||
|
||||
if consumer.terminationStatus != 0 {
|
||||
return String(decoding: cErrBox.take(), as: UTF8.self)
|
||||
}
|
||||
if let producer, producer.terminationStatus != 0 {
|
||||
return String(decoding: pErrBox.take(), as: UTF8.self)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Run `executable args`, inheriting the parent's stdout/stderr so output
|
||||
/// streams live to the terminal (for long-running tools: downloads, restore,
|
||||
/// CFW install). Returns the child's exit status; throws only on spawn failure.
|
||||
|
||||
@@ -70,9 +70,17 @@ public struct VPhoneResources: Sendable {
|
||||
|
||||
// MARK: - Cache dirs
|
||||
|
||||
public var userCacheDir: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".vphone")
|
||||
/// The per-user data root: `$VPHONE_ROOT` when set, else `~/.vphone`. Both
|
||||
/// `VPhoneResources` (ipsws/tools/debs/venv) and `VPhoneLibrary` (VMs)
|
||||
/// derive from this so one variable redirects everything vphone-cli creates.
|
||||
public static func userDataRoot() -> URL {
|
||||
if let root = ProcessInfo.processInfo.environment["VPHONE_ROOT"], !root.isEmpty {
|
||||
return URL(fileURLWithPath: root, isDirectory: true)
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".vphone")
|
||||
}
|
||||
|
||||
public var userCacheDir: URL { Self.userDataRoot() }
|
||||
public var ipswCacheDir: URL { userCacheDir.appendingPathComponent("ipsws") }
|
||||
public var sealVolumeCacheDir: URL { userCacheDir.appendingPathComponent("tools") }
|
||||
public var debsCacheDir: URL { userCacheDir.appendingPathComponent("debs") }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - VPhoneProgressBar
|
||||
|
||||
/// A single-line, redrawing byte progress bar for long transfers. Renders to
|
||||
/// stderr only when it is a TTY, so piped/`--json`/GUI-subprocess invocations
|
||||
/// (stdout consumed elsewhere) stay clean and the bar simply no-ops.
|
||||
final class VPhoneProgressBar {
|
||||
private let label: String
|
||||
private let enabled: Bool
|
||||
private let start = Date()
|
||||
private let width = 28
|
||||
private var lastRender = Date.distantPast
|
||||
private var total: Int64 = 0
|
||||
|
||||
init(label: String) {
|
||||
self.label = label
|
||||
self.enabled = isatty(FileHandle.standardError.fileDescriptor) != 0
|
||||
}
|
||||
|
||||
func update(done: Int64, total: Int64) {
|
||||
guard enabled else { return }
|
||||
self.total = total
|
||||
let now = Date()
|
||||
if done < total, now.timeIntervalSince(lastRender) < 0.066 { return } // ~15 fps
|
||||
lastRender = now
|
||||
render(done: done, now: now)
|
||||
}
|
||||
|
||||
func finish() {
|
||||
guard enabled else { return }
|
||||
render(done: total, now: Date())
|
||||
FileHandle.standardError.write(Data("\n".utf8))
|
||||
}
|
||||
|
||||
private func render(done: Int64, now: Date) {
|
||||
let frac = total > 0 ? min(1.0, Double(done) / Double(total)) : 0
|
||||
let filled = Int(frac * Double(width))
|
||||
let bar = String(repeating: "█", count: filled) + String(repeating: "░", count: width - filled)
|
||||
let elapsed = now.timeIntervalSince(start)
|
||||
let rate = elapsed > 0 ? Double(done) / elapsed : 0
|
||||
|
||||
var line = "\r\(label) [\(bar)] \(Int(frac * 100))% \(Self.bytes(done))"
|
||||
if total > 0 { line += "/\(Self.bytes(total))" }
|
||||
if rate > 0 { line += " \(Self.bytes(Int64(rate)))/s" }
|
||||
if total > 0, rate > 0, done < total { line += " eta \(Self.clock(Double(total - done) / rate))" }
|
||||
line += "\u{1B}[K" // clear to end of line
|
||||
FileHandle.standardError.write(Data(line.utf8))
|
||||
}
|
||||
|
||||
static func bytes(_ n: Int64) -> String {
|
||||
let units = ["B", "KB", "MB", "GB", "TB"]
|
||||
var value = Double(n), i = 0
|
||||
while value >= 1024, i < units.count - 1 { value /= 1024; i += 1 }
|
||||
return i == 0 ? "\(n) B" : String(format: "%.1f %@", value, units[i])
|
||||
}
|
||||
|
||||
static func clock(_ seconds: Double) -> String {
|
||||
let s = Int(seconds.rounded())
|
||||
return s >= 3600
|
||||
? String(format: "%d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60)
|
||||
: String(format: "%02d:%02d", s / 60, s % 60)
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,19 @@ struct VPhoneVMExportCommand: ParsableCommand {
|
||||
@OptionGroup var lib: VPhoneLibraryOption
|
||||
@Argument(help: "VM name") var name: String?
|
||||
@Option(name: .shortAndLong, help: "output archive path") var out: String
|
||||
@Flag(help: "densest compression (xz -9) instead of the default fast (zstd -3)") var max = false
|
||||
@Flag(help: "include the *_Restore* IPSW directory") var includeIpsw = false
|
||||
|
||||
func run() throws {
|
||||
let name = try VPhoneVMSelection.resolveExisting(name, in: lib.library)
|
||||
try VPhoneBundleOps.export(
|
||||
bundleNamed: name, to: URL(fileURLWithPath: out), includeIPSW: includeIpsw, in: lib.library)
|
||||
print("exported \(name) → \(out)")
|
||||
let compression: VPhoneBundleOps.ExportCompression = max ? .max : .fast
|
||||
let bar = VPhoneProgressBar(label: "exporting \(name)")
|
||||
let outURL = try VPhoneBundleOps.export(
|
||||
bundleNamed: name, to: URL(fileURLWithPath: out), includeIPSW: includeIpsw,
|
||||
compression: compression, in: lib.library,
|
||||
progress: { done, total in bar.update(done: done, total: total) })
|
||||
bar.finish()
|
||||
print("exported \(name) → \(outURL.path)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +49,15 @@ struct VPhoneVMImportCommand: ParsableCommand {
|
||||
commandName: "import", abstract: "Import a VM bundle from a .tgz archive")
|
||||
|
||||
@OptionGroup var lib: VPhoneLibraryOption
|
||||
@Option(name: [.customShort("i"), .customLong("in")], help: "input archive path") var input: String
|
||||
@Argument(help: "input archive path") var input: String
|
||||
@Option(name: .shortAndLong, help: "name for the imported VM (default: the archive's own name)") var name: String?
|
||||
|
||||
func run() throws {
|
||||
let bar = VPhoneProgressBar(label: "importing")
|
||||
let bundle = try VPhoneBundleOps.importArchive(
|
||||
from: URL(fileURLWithPath: input), name: name, in: lib.library)
|
||||
from: URL(fileURLWithPath: input), name: name, in: lib.library,
|
||||
progress: { done, total in bar.update(done: done, total: total) })
|
||||
bar.finish()
|
||||
print("imported → \(bundle.name)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,4 +361,115 @@ struct BundleOpsTests {
|
||||
_ = try VPhoneBundleOps.importArchive(from: archive, name: nil, in: VPhoneLibrary(root: root))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compression presets
|
||||
|
||||
private static let zstdMagic: [UInt8] = [0x28, 0xB5, 0x2F, 0xFD]
|
||||
private static let xzMagic: [UInt8] = [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]
|
||||
|
||||
private func magic(_ url: URL, _ n: Int) throws -> [UInt8] {
|
||||
Array(try Data(contentsOf: url).prefix(n))
|
||||
}
|
||||
|
||||
private func exportAndImport(
|
||||
_ compression: VPhoneBundleOps.ExportCompression?
|
||||
) throws -> (archive: URL, imported: VPhoneBundle) {
|
||||
let root = try makeRoot()
|
||||
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||
let lib = VPhoneLibrary(root: root)
|
||||
_ = try VPhoneBundleOps.create(
|
||||
.init(name: "orig", cpuCount: 6, memoryMB: 2048, diskSizeGB: 1,
|
||||
romSource: rom, sepromSource: seprom), in: lib)
|
||||
let archive = root.appendingPathComponent("orig.archive")
|
||||
if let compression {
|
||||
try VPhoneBundleOps.export(
|
||||
bundleNamed: "orig", to: archive, includeIPSW: false, compression: compression, in: lib)
|
||||
} else {
|
||||
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib)
|
||||
}
|
||||
let dstRoot = try makeRoot()
|
||||
let imported = try VPhoneBundleOps.importArchive(
|
||||
from: archive, name: "copy", in: VPhoneLibrary(root: dstRoot))
|
||||
return (archive, imported)
|
||||
}
|
||||
|
||||
@Test func exportDefaultsToFastZstd() throws {
|
||||
let (archive, imported) = try exportAndImport(nil)
|
||||
#expect(try magic(archive, 4) == Self.zstdMagic)
|
||||
#expect(imported.manifest.cpuCount == 6)
|
||||
}
|
||||
|
||||
@Test func exportFastProducesZstdAndRoundTrips() throws {
|
||||
let (archive, imported) = try exportAndImport(.fast)
|
||||
#expect(try magic(archive, 4) == Self.zstdMagic)
|
||||
#expect(imported.manifest.cpuCount == 6)
|
||||
}
|
||||
|
||||
@Test func exportMaxProducesXzAndRoundTrips() throws {
|
||||
let (archive, imported) = try exportAndImport(.max)
|
||||
#expect(try magic(archive, 6) == Self.xzMagic)
|
||||
#expect(imported.manifest.cpuCount == 6)
|
||||
}
|
||||
|
||||
@Test func exportToDirectoryAutoNamesWithExtension() throws {
|
||||
let root = try makeRoot()
|
||||
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||
let lib = VPhoneLibrary(root: root)
|
||||
_ = try VPhoneBundleOps.create(
|
||||
.init(name: "orig", cpuCount: 6, memoryMB: 2048, diskSizeGB: 1,
|
||||
romSource: rom, sepromSource: seprom), in: lib)
|
||||
let outDir = try makeRoot()
|
||||
let zstdOut = try VPhoneBundleOps.export(
|
||||
bundleNamed: "orig", to: outDir, includeIPSW: false, in: lib)
|
||||
#expect(zstdOut == outDir.appendingPathComponent("orig.tzst"))
|
||||
#expect(FileManager.default.fileExists(atPath: zstdOut.path))
|
||||
let xzOut = try VPhoneBundleOps.export(
|
||||
bundleNamed: "orig", to: outDir, includeIPSW: false, compression: .max, in: lib)
|
||||
#expect(xzOut == outDir.appendingPathComponent("orig.txz"))
|
||||
#expect(FileManager.default.fileExists(atPath: xzOut.path))
|
||||
}
|
||||
|
||||
@Test func exportAndImportReportProgress() throws {
|
||||
final class Collector {
|
||||
private(set) var dones: [Int64] = []
|
||||
private(set) var total: Int64 = 0
|
||||
func add(_ done: Int64, _ total: Int64) { dones.append(done); self.total = total }
|
||||
}
|
||||
let root = try makeRoot()
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let rom = try fakeROM(); let seprom = try fakeROM()
|
||||
let lib = VPhoneLibrary(root: root)
|
||||
_ = try VPhoneBundleOps.create(
|
||||
.init(name: "orig", cpuCount: 6, memoryMB: 2048, diskSizeGB: 1,
|
||||
romSource: rom, sepromSource: seprom), in: lib)
|
||||
|
||||
let exp = Collector()
|
||||
let archive = root.appendingPathComponent("orig.tzst")
|
||||
try VPhoneBundleOps.export(bundleNamed: "orig", to: archive, includeIPSW: false, in: lib) {
|
||||
exp.add($0, $1)
|
||||
}
|
||||
#expect(!exp.dones.isEmpty)
|
||||
#expect(exp.total > 0) // bundle logical size
|
||||
#expect(exp.dones.last! > 0)
|
||||
#expect(exp.dones == exp.dones.sorted()) // monotonically non-decreasing
|
||||
|
||||
let imp = Collector()
|
||||
let dstRoot = try makeRoot()
|
||||
defer { try? FileManager.default.removeItem(at: dstRoot) }
|
||||
_ = try VPhoneBundleOps.importArchive(from: archive, name: "copy", in: VPhoneLibrary(root: dstRoot)) {
|
||||
imp.add($0, $1)
|
||||
}
|
||||
let archiveSize = try Data(contentsOf: archive).count
|
||||
#expect(!imp.dones.isEmpty)
|
||||
#expect(imp.total == Int64(archiveSize)) // total == compressed file size
|
||||
#expect(imp.dones.last! == Int64(archiveSize)) // whole archive fed
|
||||
#expect(imp.dones == imp.dones.sorted())
|
||||
}
|
||||
|
||||
@Test func compressionPresetTarArgs() {
|
||||
#expect(VPhoneBundleOps.ExportCompression.fast.tarArgs
|
||||
== ["--zstd", "--options", "zstd:compression-level=3,zstd:threads=0"])
|
||||
#expect(VPhoneBundleOps.ExportCompression.max.tarArgs
|
||||
== ["-J", "--options", "xz:compression-level=9,xz:threads=0"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ struct FirmwarePickerTests {
|
||||
|
||||
// MARK: - Catalog integrity
|
||||
|
||||
@Test func catalogHasNineteenPairings() {
|
||||
#expect(VPhoneFirmwareCatalog.pairings.count == 19)
|
||||
@Test func catalogHasTwentyThreePairings() {
|
||||
#expect(VPhoneFirmwareCatalog.pairings.count == 23)
|
||||
}
|
||||
|
||||
@Test func everyPairingIsPopulated() {
|
||||
@@ -106,7 +106,7 @@ struct FirmwarePickerTests {
|
||||
iphone: nil, cloudos: nil, isInteractive: true,
|
||||
read: reader(["1"]), write: { lines.append($0) })
|
||||
let menu = lines.filter { $0.hasPrefix(" [") }
|
||||
#expect(menu.count == 19)
|
||||
#expect(menu.count == 23)
|
||||
// Label text starts in one column regardless of 1- vs 2-digit index.
|
||||
let labelStarts = Set(menu.map { $0.range(of: "] ")!.upperBound.utf16Offset(in: $0) })
|
||||
#expect(labelStarts.count == 1)
|
||||
|
||||
@@ -50,10 +50,18 @@ struct LibraryTests {
|
||||
#expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root")
|
||||
}
|
||||
|
||||
@Test func defaultRootHonorsVPHONERoot() {
|
||||
unsetenv("VPHONE_LIBRARY_ROOT")
|
||||
setenv("VPHONE_ROOT", "/tmp/vphone-test-root", 1)
|
||||
defer { unsetenv("VPHONE_ROOT") }
|
||||
#expect(VPhoneLibrary.defaultRoot().path == "/tmp/vphone-test-root/VMs")
|
||||
}
|
||||
|
||||
@Test func defaultRootIsShellSafe() {
|
||||
// The default root feeds the shell/make firmware pipeline; a space in it
|
||||
// (e.g. "Application Support") breaks unquoted expansion. Must stay space-free.
|
||||
unsetenv("VPHONE_LIBRARY_ROOT")
|
||||
unsetenv("VPHONE_ROOT")
|
||||
#expect(!VPhoneLibrary.defaultRoot().path.contains(" "))
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ struct ResourcesTests {
|
||||
}
|
||||
|
||||
@Test func cacheDirsAreHomeRelativeAndToolsBinIsBaseRelative() {
|
||||
// The VPHONE_ROOT override would relocate the cache; only assert the default.
|
||||
if ProcessInfo.processInfo.environment["VPHONE_ROOT"] != nil { return }
|
||||
let r = VPhoneResources(base: URL(fileURLWithPath: "/Applications/vphone-cli.app/Contents/Resources"))
|
||||
#expect(r.userCacheDir.path.hasSuffix("/.vphone"))
|
||||
#expect(r.toolsBinDir.path == r.base.appendingPathComponent(".tools/bin").path)
|
||||
@@ -43,12 +45,36 @@ struct ResourcesTests {
|
||||
}
|
||||
|
||||
@Test func managedVenvDefaultsUnderDotVphone() {
|
||||
// The override env var would change this; only assert the default.
|
||||
// The override env vars would change this; only assert the default.
|
||||
if ProcessInfo.processInfo.environment["VPHONE_VENV_DIR"] != nil { return }
|
||||
if ProcessInfo.processInfo.environment["VPHONE_ROOT"] != nil { return }
|
||||
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
|
||||
#expect(r.managedVenvDir.path.hasSuffix("/.vphone/venv"))
|
||||
}
|
||||
|
||||
@Test func userCacheDirHonorsVPHONERoot() {
|
||||
unsetenv("VPHONE_VENV_DIR")
|
||||
setenv("VPHONE_ROOT", "/tmp/vphone-test-root", 1)
|
||||
defer { unsetenv("VPHONE_ROOT") }
|
||||
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
|
||||
#expect(r.userCacheDir.path == "/tmp/vphone-test-root")
|
||||
#expect(r.ipswCacheDir.path == "/tmp/vphone-test-root/ipsws")
|
||||
#expect(r.sealVolumeCacheDir.path == "/tmp/vphone-test-root/tools")
|
||||
#expect(r.debsCacheDir.path == "/tmp/vphone-test-root/debs")
|
||||
#expect(r.managedVenvDir.path == "/tmp/vphone-test-root/venv")
|
||||
}
|
||||
|
||||
@Test func managedVenvOverrideBeatsVPHONERoot() {
|
||||
setenv("VPHONE_ROOT", "/tmp/vphone-test-root", 1)
|
||||
setenv("VPHONE_VENV_DIR", "/tmp/custom-venv", 1)
|
||||
defer {
|
||||
unsetenv("VPHONE_ROOT")
|
||||
unsetenv("VPHONE_VENV_DIR")
|
||||
}
|
||||
let r = VPhoneResources(base: URL(fileURLWithPath: "/x"))
|
||||
#expect(r.managedVenvDir.path == "/tmp/custom-venv")
|
||||
}
|
||||
|
||||
@Test func pythonUsabilityProbeRejectsMissingAcceptsDevVenv() {
|
||||
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
let r = VPhoneResources(base: cwd)
|
||||
|
||||
Reference in New Issue
Block a user