15 Commits
Author SHA1 Message Date
zqxwce 87f796c62a resources: Update resources reference 2026-09-01 01:33:43 +03:00
zqxwceandClaude Fable 5 deabe6ffca catalog: Add iOS 27 beta 8 + cloudOS 26.4 firmware pairing
Add iPhone17,3 iOS 27.0 beta 8 (24A5430a) paired with cloudOS 26.4 to the
firmware catalog so it appears in the `fw prepare` picker, `fw catalog`,
and the `--json` report. Document it in the Tested Environments table
across README.md and the ja/ko/zh translations. Bump the hardcoded
pairing/menu count in FirmwarePickerTests 22 -> 23.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-09-01 01:33:43 +03:00
zqxwceandClaude Fable 5 2af884b56c resources: Bump storage submodule to fork main (drop ramdisk_input.tar.zst)
Fork main was force-pushed to ae7a0ac, orphaning the previously pinned
ee9a284. Advance the pin so fresh clones resolve against a live ref.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-29 03:44:16 +03:00
zqxwceandClaude Fable 5 977c17b56d resources: Point storage submodule to zqxwce fork
Retarget scripts/resources submodule URL from Lakr233 to the
zqxwce/vphone-cli-storage fork.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-29 03:24:32 +03:00
zqxwceandClaude Fable 5 53aa44dd57 catalog: Add iOS 27 beta 7 + cloudOS 26.4 firmware pairing
Add iPhone17,3 iOS 27.0 beta 7 (24A5424a) paired with cloudOS 26.4 to the
firmware catalog so it appears in the `fw prepare` picker, `fw catalog`,
and the `--json` report. Document it in the Tested Environments table
across README.md and the ja/ko/zh translations. Bump the hardcoded
pairing/menu count in FirmwarePickerTests 21 -> 22.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ej64m761KR33KaNYh3BuTo
2026-08-25 11:07:35 +03:00
zqxwceandClaude Opus 4.8 58988bd518 catalog: Replace iOS 26.6.1 RC with release build 23G83
The bundled 26.6.1 IPSW pointed at the 23G82 release candidate, which
differs from the shipped release. Point it at the 23G83 final restore
and update the tested-environment tables accordingly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-19 15:09:39 +03:00
Bent 72ccbde1de vphone-cli: Add VPHONE_ROOT env variable to override user data root 2026-08-18 11:48:47 +03:00
zqxwceandClaude Fable 5 39b6d04108 catalog: Add iOS 27 beta 6 + cloudOS 26.4 firmware pairing
Add iPhone17,3 iOS 27.0 beta 6 (24A5418b) paired with cloudOS 26.4 to the
firmware catalog so it appears in the `fw prepare` picker, `fw catalog`,
and the `--json` report. Document it in the Tested Environments table
across README.md and the ja/ko/zh translations. Bump the hardcoded
pairing/menu count in FirmwarePickerTests 20 -> 21.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-18 11:46:45 +03:00
zqxwceandClaude Fable 5 339719f34d catalog: Add iOS 26.6.1 + cloudOS 26.4 firmware pairing
Add iPhone17,3 iOS 26.6.1 (23G82) paired with cloudOS 26.4 to the
firmware catalog so it appears in the `fw prepare` picker, `fw catalog`,
and the `--json` report. Document it in the Tested Environments table
across README.md and the ja/ko/zh translations. Bump the hardcoded
pairing/menu count in FirmwarePickerTests 19 -> 20.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-13 17:58:15 +03:00
zqxwceandClaude Fable 5 421cb7aa7a docs: Update vm export/import examples for the reworked flags
Reflect fast-by-default compression (--max = xz -9), directory
auto-naming, and the positional import argument in the README and its
ja/ko/zh translations.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 16:19:33 +03:00
zqxwceandClaude Fable 5 dee895aeb7 cli: Rework vm export/import — presets, auto-naming, positional, progress
Export/import UX overhaul, wiring in the counting-pipe and progress-bar
primitives:

- Compression: replace `--compress {fast,balanced,max}` with a fast
  (zstd -3) default and a single `--max` (xz -9) flag; drop balanced.
- `vm export --out DIR`: when the destination is an existing directory,
  auto-name the archive `<vm>.tzst`/`.txz` and print the resolved path.
- `vm import ARCHIVE`: take the archive as a positional argument instead
  of `--in`.
- Progress bars: export runs a two-stage tar pipeline (uncompressed
  gnutar producer -> compressing consumer via bsdtar `@-`) so the bar is
  driven off the known uncompressed total; import extracts once (was
  decompressing twice) and counts the archive as it is fed into `tar -x`.
  gnutar avoids the pax-header `@-` mtree misbid on large members and,
  unlike ustar, carries files >8 GB.

Import validates the single top-level bundle after extracting into a
private staging dir, still failing fast on an explicit --name collision.

Tests: default is fast zstd; --max is xz; directory auto-naming yields
the right extensions; export/import invoke progress with monotonic,
correctly-totalled callbacks.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 16:19:33 +03:00
zqxwceandClaude Fable 5 673146ba0a cli: Add byte-counting tar pipeline + progress-bar primitives
Two building blocks for streaming progress on vm export/import, unused
until the export/import rework wires them in:

- VPhoneProcessRunner.runCountingTarPipe: drives a /usr/bin/tar consumer
  that reads its archive on stdin, invoking a callback with the running
  byte total. The source is either a producer tar (export) or a file read
  directly (import). SIGPIPE is ignored so a consumer that dies early
  surfaces as its exit status instead of killing this process.
- VPhoneProgressBar: a single-line redrawing byte bar that renders to
  stderr only when it is a TTY, so piped/--json/GUI-subprocess runs stay
  clean and it simply no-ops.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 16:19:33 +03:00
zqxwceandClaude Fable 5 988e5dc389 docs: Add Xcode + iOS SDK to host prerequisites
The build cross-compiles the guest daemon (vphoned) for iOS, which
requires the iOS SDK bundled with Xcode. Add it to the host
prerequisites in the English, Japanese, Korean, and Chinese READMEs.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 14:01:08 +03:00
zqxwceandClaude Fable 5 9f7e21402a cli: Add --compress preset to vm export (fast/balanced/max)
Export previously hardcoded xz -9 (the densest but slowest libarchive
compressor), forcing a multi-GB Disk.img through the most aggressive
setting on every export.

Add a `--compress {fast,balanced,max}` preset on `vphone vm export`,
default `balanced`:
  fast     zstd -3
  balanced zstd -19  (new default)
  max      xz -9     (previous behavior)

All presets go through the same system /usr/bin/tar (libarchive) already
used for xz, so no new dependency and no raised platform floor. Import is
unchanged: it already auto-detects the compressor via `tar -tf`/`-xf`, so
zstd and xz archives both import.

Tests assert each preset's magic bytes (zstd vs xz), that fast/max
round-trip, and the default is zstd. README examples updated.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 13:45:31 +03:00
zqxwceandClaude Fable 5 3bb6f0fda4 frida: Add build.frida.re as a Sileo/apt source at first boot
The JB first-boot setup adds the Frida repo (deb https://build.frida.re/ ./)
next to the existing Havoc source, so Sileo/apt can install and update Frida
packages. Idempotent — skipped if a build.frida.re source is already present —
and picked up by the same insecure-repo `apt-get update` that follows.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-12 13:29:42 +03:00
18 changed files with 499 additions and 56 deletions
+1 -1
View File
@@ -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
+11 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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` |
## 常见问题
+9
View File
@@ -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 $?"
+110 -29
View File
@@ -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.
+2 -2
View File
@@ -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.
+10 -2
View File
@@ -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)
}
}
+14 -5
View File
@@ -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)")
}
}
+111
View File
@@ -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)
+8
View File
@@ -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 -1
View File
@@ -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)