diff --git a/README.en.md b/README.en.md
index 806f689..e70b58f 100644
--- a/README.en.md
+++ b/README.en.md
@@ -62,18 +62,19 @@ modavd [--root
] [--json] [--quiet] [--verbose] [args]
| --- | --- |
| `list` | All instances and their status |
| `info ` / `status ` | Details of one instance / its status |
-| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--resolution 1080\|720] [--no-bridge] [--timeout 1200]`, `create --resume N` | Create an instance, then start it once in the background, headless, to finish initialization, and shut it down (see below). The arm64 execution bridge is included by default; `--no-bridge` records that this instance does not want the bridge, so it is not installed on later starts either. `--resume N` redoes the initialization of an instance whose creation failed |
+| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--resolution 1080\|720] [--no-bridge] [--timeout 1200]`, `create --resume N` | Create an instance, then start it once in the background, headless, to finish initialization, and shut it down (see below). The arm64 execution bridge is included by default; `--no-bridge` records that this instance does not want the bridge, so it is not installed on later starts either. New instances never get the Google Play Store; add it afterwards with `playstore install`. `--resume N` redoes the initialization of an instance whose creation failed |
| `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb N] [--diagnostics on\|off]` | Change settings. Diagnostics apply immediately; other settings take effect at the next start. `--data-gb` can only be increased, and game data is untouched: the emulator (from 0.36) grows the disk image at start and grows the file system online after boot. A disk created before 0.36 at 16 GB or smaller can only be grown to just below the next backup group (a 16 GB disk to about 30 GB) |
| `proxy [on\|off\|test] [--server URL\|auto] [--add PKG] [--remove PKG] [--apps PKG]` | Per-app proxy: the listed apps go through a proxy, the others direct (see below) |
| `delete --yes` | Delete a stopped instance, together with its game data |
| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | Start (headless by default, with the window for `--window`), wait until boot finishes and the post-boot setup is done, and before returning confirm that files can be written to `/sdcard` (normally it is ready long before this; it waits up to 60 more seconds and only warns at the deadline). If the instance is shutting down (for example, right after a click on the emulator window's ×), it first waits for the shutdown to finish (up to 30 seconds) and then starts it. If the instance has no execution bridge yet, install it afterward; `--no-bridge` skips that this time. No switch while it runs: if the instance is already running and `--window` / `--headless` asks for the other way, it fails (exit code 5) without taking it over or restarting it; to switch, `stop` and then `start`. With neither, it is taken over as it runs |
| `stop [--force] [--cancel-create]`, `stop --all [--force]` | Shut down: an app in the foreground is first sent home and given 2 s to save; then sync and ask Android for a clean shutdown (the foreground app is paused and stopped first), and if that fails, sync + kill; `--force` is sync + force-kill. Creating instances are refused unless `--cancel-create` is given. `--all` also stops creating instances and marks their results with `interrupted_create: true` |
| `bridge install\|uninstall\|verify` | Install, uninstall or check the arm64 execution bridge; it is installed automatically at start, so you normally do not run it by hand (see below) |
+| `playstore install\|uninstall\|status` | Add the Google Play Store to an instance, remove it, or check it (see below) |
| `disk compact` | Compact a stopped instance's data disk, or skip rewriting when estimated savings are too small. Verify before atomically replacing the original, and report host usage before and after, including growth. Large disks have budgets of many hours and effectively do not time out in normal use; cancel to stop an operation. Requires NTFS/ReFS, no snapshots and room for the temporary image. See [budgets and result fields](internal/emu/COMPACT.md) |
| `check [--memory M]` | Computer check before starting |
| `report-config [--no-clipboard]` | Copy the computer configuration and the game info from the running instances (read-only) |
| `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | Collect a problem report and zip it (read-only). Ctrl+C before ZIP completion returns `canceled`, deletes the unfinished ZIP and removes staging; `--keep-stage` preserves staging and includes its path in the error |
-| `components [status]`, `components source [auto\|googledownloads_cn\|tencent\|google]`, `components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads]` | View, choose, or fetch the Google components (adb and the system image; see "Fetching the Google components") |
+| `components [status]`, `components source [auto\|googledownloads_cn\|tencent\|google]`, `components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads] [--play-store]`, `components import-playstore ...` | View, choose, or fetch the Google components (adb and the system image; see "Fetching the Google components"); `--play-store` and `import-playstore` add the optional Google Play Store (see below) |
| `window show\|hide`, `install `, `shell `, `adb -- ` | Window, install an APK, guest command, adb passthrough |
| `update [status\|check\|prepare\|install\|recover] [--automas-version V]` | Update the whole ModAVD package in the root folder, using the update source from "Settings" (`设置`) (see "Updating ModAVD") |
| `servers`, `version`, `mcp`, `help [command]` | |
@@ -93,6 +94,19 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args]
- While the bridge is being installed (including the shutdowns and cold boots in it), `busy` is `bridging`; during this time `delete` and another `bridge` return instance busy (exit code 5). Callers should treat any `busy` value they do not recognize as busy.
- An instance that was not started through modavd or the manager (another program launches the emulator directly) does not get the bridge automatically; to install it, run `bridge install` once.
+**Google Play Store** (`components fetch --play-store`, `components import-playstore`, `playstore`, ModAVD#125):
+
+- The instances' Google APIs image has Google Play services but no store. The store is five files of Google's official `system-images;android-34;google_apis_playstore;x86_64` r14, the same release as the instances' image: the store, the Google-signed Play services, GSF and Partner Setup, and the store's fs-verity certificate. The root folder keeps only these five files (`sdk\playstore\`), and each instance gets them when asked; new instances never do.
+- The root gets the five files one of two ways, both optional (`components status` and `check` never count them as required):
+ - Download: `components fetch --play-store` (in the manager, 「补全 Google Play 商店服务」 in the component dialog) downloads that image (about 1.5 GB) after the license agreement, reads the five files out of its `system.img` read-only, each checked against its built-in size and SHA-256, and deletes the unpacked `system.img` and the downloaded zip (the zip stays with `--keep-downloads`). Skipped when the files are there already.
+ - Your own files: `components import-playstore ...` (in the manager, 「使用我自己的文件」). Give Google's zip `x86_64-34_r14.zip` (any name; checked against the size, 1,510,752,654 bytes, and SHA-1 of Google's repository, then read like a download) or the five files themselves (any names; each checked against its size and SHA-256, the values in the [documentation](https://modavd.github.io/en/guide/playstore); a folder gives the files in it with those names). A file that is neither, a hash that does not match or a missing file is refused (exit code 9, `components_rejected`; `error.details.reason` with `--json` is `unknown_file`, `mismatch` or `incomplete`) and nothing changes. Your files are only read. Nothing is downloaded and no license is asked.
+ - `components status` lists whether the five files are there and where they came from (`play_store.source`: `download` or `import`).
+- The store alone is not enough: the image's own Play services and GSF are signed with the test certificate, so their signature does not match the store's and Google's components refuse to work. They are replaced by the copies of the same version from the Play image.
+- It goes into the writable system overlay that the execution bridge brings, so the instance needs the bridge: without it `install` fails with `not_ready` (`details.reason` `no_bridge`), and without the root's five files with `not_ready` (`playstore_files_missing`), both before the instance is touched. An instance with the store refuses `bridge uninstall` until `playstore uninstall`. The image's own placeholder with the same package name (LicenseChecker) is hidden, and brought back on uninstall.
+- `install` puts the files in and boots cold once. `uninstall` disables the store, removes the updates Google Play installed and the files that were put in, boots cold once, then brings back and enables the placeholder. An instance that was off is shut down again afterwards; a running one comes back the way it ran. `status` is read-only and needs the instance running.
+- The device is not Play Protect certified. If signing in says so, register the instance's GSF ID as the [documentation](https://modavd.github.io/en/guide/playstore) describes. Games that check Play Integrity will most likely fail (the image is a userdebug build with test keys).
+- An instance that never had the store is not touched, and `mas-avd.json` gets no extra key. Once it is installed, `instances..playStore` is `true`, and `false` after uninstall; `play_store` in `info` / `list` is that value.
+
**Per-app proxy** (`proxy`):
- The TCP, UDP and DNS of the listed apps (by package name) go through the configured proxy; every other app goes direct as before. No VPN is involved: a root daemon in the guest (`modavd-proxyd`, carried in by `modavd.exe`) takes their traffic by the apps' uids with policy routing and a transparent proxy, so neither the apps nor the system see a VPN.
@@ -256,7 +270,7 @@ The ModAVD release package does not include adb (platform-tools 37.0.1) or the A
- Components that are already complete are skipped; by default the zip is deleted after a successful install, and `--keep-downloads` keeps it.
- It refuses first if the path has non-ASCII characters, if the folder is not a ModAVD root folder, if `mas-avd.json` sets `sdkRoot`, or if there is not enough space (the download plus the unpacked size, plus 500 MB spare). `--test-free-space-mb` is for tests only and pretends that only that many MB are free.
- Timeouts are shared with `update`. `components fetch` accepts `--connect-timeout` (connecting, the TLS handshake and receiving the response headers; 1-600 seconds, default 20), `--stall-timeout` (not a single byte received; 1-600 seconds, default 30), `--manifest-timeout` (fetching the manifest as a whole; 1-1800 seconds, default 40). Proxy: if `HTTPS_PROXY` / `HTTP_PROXY` is set, it follows the environment variables; otherwise it uses a proxy set manually in the Windows "Internet Options". `NO_PROXY` applies to both; setting it alone does not disable the system proxy. Automatic configuration scripts (PAC) are not supported; in that case set `HTTPS_PROXY`.
-- It writes only `downloads\`, `logs\fetch-components.log` (and the `.lock` beside it; only one fetch runs at a time per root folder), the two component directories under `sdk\`, and `sdk\.staging\`. It does not touch the adb server, does not write the registry, and does not need administrator rights. The log records only the start and end of each download; progress is shown but not logged.
+- It writes only `downloads\`, `logs\fetch-components.log` (and the `.lock` beside it; only one fetch runs at a time per root folder), the two component directories under `sdk\` (with `--play-store` and `import-playstore` also `sdk\playstore`), and `sdk\.staging\`. It does not touch the adb server, does not write the registry, and does not need administrator rights. The log records only the start and end of each download; progress is shown but not logged.
- Only Windows has a component manifest; macOS and Linux return `unsupported` (exit code 4).
- Exit codes: not accepting the license agreement is 2 (`license_declined`); a path with Chinese characters or not enough space is 4 (`precheck_failed`), and a folder that is not a ModAVD root folder or has `sdkRoot` set is also 4 (`not_ready`); a fetch already in progress is 5 (`busy`); failing to download the license or the components from any mirror is 9 (`components_unavailable`), and a downloaded zip whose SHA-1 is wrong on every mirror, or that fails verification after unpacking, is also 9 (`components_rejected`); being unable to read the free space is 1.
- `components status` (the default) and the components entry of `check` give the same result, and the rule for judging is the same as in AUTO-MAS (the key files being present counts as installed). `fetch` is stricter about what counts as "complete" (following the script: the key files must be non-empty, and for adb the pinned hash and version number are also verified), so it can happen that `status` shows a component as installed while `fetch` still reinstalls it, in which case the old directory is renamed and kept.
diff --git a/README.md b/README.md
index 7b630fc..6c3b467 100644
--- a/README.md
+++ b/README.md
@@ -62,20 +62,21 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
| --- | --- |
| `list` | 全部实例和状态 |
| `info ` / `status ` | 一台实例的详细信息 / 状态 |
-| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--resolution 1080\|720] [--no-bridge] [--timeout 1200]`、`create --resume N` | 新建,并在后台无头开机一次把初始化做完再关机(见下)。默认带 arm64 执行桥;`--no-bridge` 记下这台不要桥,以后开机也不装。`--resume N` 重做新建失败的那台的初始化 |
+| `create [--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30\|60] [--resolution 1080\|720] [--no-bridge] [--timeout 1200]`、`create --resume N` | 新建,并在后台无头开机一次把初始化做完再关机(见下)。默认带 arm64 执行桥;`--no-bridge` 记下这台不要桥,以后开机也不装。新建的实例不带 Google Play 商店,要的话建好后用 `playstore install`。`--resume N` 重做新建失败的那台的初始化 |
| `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb N] [--diagnostics on\|off]` | 修改;诊断模式立即生效,其余设置下次开机生效。`--data-gb` 只能改大,游戏数据不动:模拟器(0.36 起)开机时扩磁盘镜像,开机后在线扩文件系统。0.36 之前按 16 GB 及以下建的盘只能扩到下一个备份组之前(16 GB 约到 30 GB) |
| `proxy [on\|off\|test] [--server URL\|auto] [--add 包名] [--remove 包名] [--apps 包名]` | 按应用走代理:名单里的应用走代理,其余直连(见下) |
| `delete --yes` | 删除已关机的实例,连同游戏数据 |
| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 |
| `stop [--force] [--cancel-create]`、`stop --all [--force]` | 关机:前台开着应用时先按 HOME,等 2 秒让它保存;再 sync,让安卓正常关机(前台应用先收到暂停、停止),不成再 sync + kill;`--force` 是 sync + 强杀。新建中默认拒绝,`--cancel-create` 显式取消;`--all` 也关闭新建中的实例,结果标记 `interrupted_create: true` |
| `bridge install\|uninstall\|verify` | 装、卸、检查 arm64 执行桥;开机时会自动装,一般不用手动跑(见下) |
+| `playstore install\|uninstall\|status` | 给实例补全、移除、查看 Google Play 商店(见下) |
| `disk trim` | 磁盘回收:在开着的实例里对数据盘触发一次 FITRIM;成功只表示触发请求已返回,不等待或确认客体回收完成;模拟器太旧、不支持回收时返回不支持(退出码 4) |
| `disk info` | 看数据盘的大小:逻辑大小、覆盖层在宿主上实际占的空间;关机且取得启动锁后才运行只读的 qcow2 元数据检查,给出已分配和估计可回收大小。整理中或锁不可用时,仅返回可读取的文件头、宿主占用和原因;不可读的数值省略,不修复镜像 |
| `disk compact` | 整理已关机实例的数据盘;预计收益不足时跳过重写;转换后先校验再原子替换,原盘在替换成功前不动;报告前后占用和增减;大盘预算可达数十小时,正常整理基本不会超时,停止请取消;仅支持 NTFS/ReFS,拒绝快照或重写空间不足,详见[预算与结果契约](internal/emu/COMPACT.md) |
| `check [--memory M]` | 开机前的电脑检查 |
| `report-config [--no-clipboard]` | 复制电脑配置和开着实例里的游戏信息(只读) |
| `collect-info [--out-dir D] [--stage-dir D] [--keep-stage] [--no-explorer]` | 收集问题信息并打包(只读);ZIP 完成前的 Ctrl+C 返回 `canceled`,删除未完成 ZIP 并清理暂存目录;`--keep-stage` 则保留暂存目录并在错误中给出路径 |
-| `components [status]`、`components source [auto\|googledownloads_cn\|tencent\|google]`、`components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads]` | 查看、选择或补全 Google 组件(adb 和系统镜像;见「补全 Google 组件」) |
+| `components [status]`、`components source [auto\|googledownloads_cn\|tencent\|google]`、`components fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads] [--play-store]`、`components import-playstore <文件或文件夹>…` | 查看、选择或补全 Google 组件(adb 和系统镜像;见「补全 Google 组件」);`--play-store`、`import-playstore` 补全可选的 Google Play 商店(见下) |
| `window show\|hide`、`install `、`shell <命令>`、`adb -- <参数>` | 窗口、装 APK、客体命令、adb 透传 |
| `update [status\|check\|prepare\|install\|recover] [--automas-version V]` | 更新根目录里的整个 ModAVD 包,按「设置」里的更新源(见「更新 ModAVD」) |
| `servers`、`version`、`mcp`、`help [命令]` | |
@@ -99,6 +100,19 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数]
- 装桥期间(含其中的关机、冷启动)`busy` 为 `bridging`;这时 `delete`、再次 `bridge` 返回实例忙(退出码 5)。调用方不认识的 `busy` 取值一律当忙处理。
- 不经 modavd 或管理器开机的实例(别的程序直接拉起模拟器)不会自动装,要装就跑一次 `bridge install`。
+**Google Play 商店**(`components fetch --play-store`、`components import-playstore`、`playstore`,ModAVD#125):
+
+- 实例用的 Google APIs 镜像有 Play 服务、没有商店。商店取自 Google 官方的 `system-images;android-34;google_apis_playstore;x86_64` r14(和实例的镜像同一期)里的五个文件:商店、Google 签名的 Play 服务、GSF、合作伙伴设置和商店的 fs-verity 证书。根目录只留这五个文件(`sdk\playstore\`),每台实例要的时候再装进去;新建的实例不带。
+- 根目录的五个文件有两种来法,都是可选的,`components status`、`check` 不把它们算进必需组件:
+ - 下载:`components fetch --play-store`(管理器「补全组件」里勾选「补全 Google Play 商店服务」)同意许可协议后下载这个镜像(约 1.5 GB),从里面的 `system.img` 只读取出五个文件,每个都核对写死的大小和 SHA-256,然后删掉解出的 `system.img` 和下载的压缩包(`--keep-downloads` 时压缩包留着)。已经有了就跳过。
+ - 用自己的文件:`components import-playstore <文件或文件夹>…`(管理器里「使用我自己的文件」)。给 Google 官方的压缩包 `x86_64-34_r14.zip`(名字不限,按 Google 仓库清单核对大小 1,510,752,654 字节和 SHA-1,再像下载的一样取出五个文件),或者那五个文件本身(名字不限,逐个核对大小和 SHA-256,值和[文档站](https://modavd.github.io/guide/playstore)的表一样;给文件夹就取里面同名的几个)。认不出的文件、对不上的哈希、缺了文件,一律拒收(退出码 9,`components_rejected`,`--json` 的 `error.details.reason` 是 `unknown_file`、`mismatch` 或 `incomplete`),什么也不改;只读你的文件,不动它们。不下载,也不问许可协议。
+ - `components status` 列出五个文件在不在、从哪来(`play_store.source`:`download` 或 `import`)。
+- 只放商店不够:镜像自带的 Play 服务和 GSF 是测试证书签的,和商店签名对不上,Google 的组件会拒绝工作,所以一起换成 Play 版镜像里同版本的。
+- 装在执行桥带来的可写系统覆盖层里,所以实例要有执行桥:没有的 `install` 报 `not_ready`(`details.reason` 为 `no_bridge`),根目录还没有五个文件的报 `not_ready`(`playstore_files_missing`),这两种都在动实例之前就拒绝。装了商店的实例不能 `bridge uninstall`,要先 `playstore uninstall`。镜像自带的同名占位应用(LicenseChecker)会被藏起来,卸载时恢复。
+- `install`:放入文件后冷启动一次。`uninstall`:先停用商店,去掉 Google Play 装的更新、删掉放进去的文件,冷启动一次后恢复并启用占位应用。实例原来关着的,做完再关掉;开着的,照原来的方式开回来。`status` 是只读的,要实例开着。
+- 设备没有通过 Play 保护机制认证。登录时提示未认证的,按[文档](https://modavd.github.io/guide/playstore)登记这台实例的 GSF ID。查 Play Integrity 的游戏多半过不了(镜像是 userdebug、测试密钥)。
+- 没装过商店的实例什么都不动,`mas-avd.json` 里也不多键。装上后记 `instances..playStore: true`,卸掉后是 `false`;`info` / `list` 的 `play_store` 就是这一项。
+
**按应用走代理**(`proxy`):
- 名单里的应用(按包名)的 TCP、UDP 和 DNS 都走设好的代理,别的应用照旧直连。不用 VPN:客体里一个 root 守护进程(`modavd-proxyd`,随 `modavd.exe` 带进去)按应用的 uid 用策略路由和透明代理接走它们的流量,应用和系统都看不到 VPN。
@@ -258,7 +272,7 @@ ModAVD 的发布包里没有 adb(平台工具 37.0.1)和 Android 14 Google A
- 已经齐全的组件跳过;默认装好就删 zip,`--keep-downloads` 保留。
- 路径里有非 ASCII 字符、不是 ModAVD 根目录、`mas-avd.json` 设了 `sdkRoot`、空间不够(下载加解压再留 500 MB),先拒绝。`--test-free-space-mb` 只给测试用,假装只剩这么多 MB。
- 超时与 `update` 共用,`components fetch` 可用 `--connect-timeout`(连接、TLS 握手、收到响应头,1–600 秒,默认 20)、`--stall-timeout`(一个字节都没收到,1–600 秒,默认 30)、`--manifest-timeout`(取清单整体,1–1800 秒,默认 40)。代理:设了 `HTTPS_PROXY` / `HTTP_PROXY` 就照环境变量,否则用 Windows「Internet 选项」里手动设的代理;`NO_PROXY` 对两者都生效,只设它不会禁用系统代理;自动配置脚本(PAC)不支持,这时请设 `HTTPS_PROXY`。
-- 只写 `downloads\`、`logs\fetch-components.log`(和同目录的 `.lock`,一个根目录同时只跑一个)、`sdk\` 下的两个组件目录和 `sdk\.staging\`。不碰 adb server,不写注册表,不要管理员。日志里每次下载只记开始和结束,进度只显示不记。
+- 只写 `downloads\`、`logs\fetch-components.log`(和同目录的 `.lock`,一个根目录同时只跑一个)、`sdk\` 下的两个组件目录(`--play-store` 和 `import-playstore` 再加 `sdk\playstore`)和 `sdk\.staging\`。不碰 adb server,不写注册表,不要管理员。日志里每次下载只记开始和结束,进度只显示不记。
- 只有 Windows 有组件清单;macOS、Linux 返回 `unsupported`(退出码 4)。
- 退出码:没同意许可协议是 2(`license_declined`);路径有中文、空间不够是 4(`precheck_failed`),不是 ModAVD 根目录或设了 `sdkRoot` 也是 4(`not_ready`);正在补全是 5(`busy`);许可协议或组件从哪个镜像都没下成是 9(`components_unavailable`),下载的 zip 在每个镜像上 SHA-1 都不对、或者解压后没通过核对也是 9(`components_rejected`);读不出剩余空间是 1。
- `components status`(默认)和 `check` 的组件一项是同一份结果,判断规则和 AUTO-MAS 一致(关键文件在就算装了);`fetch` 判断「齐全」更严(照脚本:关键文件不为空,adb 还要核对钉住的哈希和版本号),所以 `status` 显示已装、`fetch` 仍会重装的情况是有的,旧目录会被改名留着。
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index f6e73b9..2b6d6b6 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -121,6 +121,7 @@ type manager interface {
AdbPassthrough(ctx context.Context, i int, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error)
InstallAPK(ctx context.Context, i int, apkPath string) (*emu.InstallResult, error)
Bridge(ctx context.Context, i int, action string) (*emu.BridgeResult, error)
+ PlayStore(ctx context.Context, i int, action string) (*emu.PlayStoreResult, error)
DiskTrim(ctx context.Context, i int) (*emu.DiskTrimResult, error)
DiskInfo(ctx context.Context, i int) (*emu.DiskInfoResult, error)
DiskCompact(ctx context.Context, i int) (*emu.DiskCompactResult, error)
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index 4823ced..3ec7fbe 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -85,8 +85,10 @@ var (
optAcceptLicense = flagSpec{"accept-license", kindBool, "",
"不询问,直接视为同意《Android 软件开发套件许可协议》(自动化用;协议全文照样取回并打印,取不到照样退出);--json 时必须给"}
optKeepDownloads = flagSpec{"keep-downloads", kindBool, "", "装好后保留 downloads 里的 zip(默认装好即删,省 1.5 GB)"}
- optMirror = flagSpec{"mirror", kindString, "ID", "只用一个内置镜像:googledownloads_cn / tencent / google"}
- optMirrorURL = flagSpec{"mirror-url", kindStrings, "URL",
+ optFetchStore = flagSpec{"play-store", kindBool, "",
+ "也补全可选的 Google Play 商店:下 Google Play 版系统镜像(约 1.5 GB),只取出商店等 5 个文件(约 230 MB)"}
+ optMirror = flagSpec{"mirror", kindString, "ID", "只用一个内置镜像:googledownloads_cn / tencent / google"}
+ optMirrorURL = flagSpec{"mirror-url", kindStrings, "URL",
"自建或测试用:用这个目录地址代替内置的三个镜像,可写多次或用逗号隔开,按顺序试;给了就不看 --mirror"}
optConnectTO = flagSpec{"connect-timeout", kindInt, "秒",
"等服务器回应(连接、TLS 握手、返回响应头)的上限,1–600,默认 20,超时换下一个镜像"}
@@ -102,8 +104,8 @@ var (
)
// fetchFlags are the options only components fetch takes.
-var fetchFlags = []flagSpec{optAcceptLicense, optKeepDownloads, optMirror, optMirrorURL, optConnectTO, optStallTO,
- optManifestTO, optTestFreeSpace}
+var fetchFlags = []flagSpec{optAcceptLicense, optKeepDownloads, optFetchStore, optMirror, optMirrorURL, optConnectTO,
+ optStallTO, optManifestTO, optTestFreeSpace}
var commands []*command
@@ -135,7 +137,7 @@ func init() {
details: "不给的选项用默认值:内存 6 GB、6 核、数据盘 64 GB、60 帧、1080p(1920×1080 / DPI 280)。内存和核数只能取列出的档位,不会自动凑整。" +
"建好文件后在后台无头开机一次,做完开机设置、宿主输入法、首次初始化和 arm64 执行桥(只对内置名单里的游戏生效," +
"其余应用照旧;--no-bridge 不装,以后也不装),再先 sync 后正常关机,一般要几分钟,进度逐步打印(「新建中 35%」,按做完的步骤算);" +
- "之后第一次开机和平常一样快。" +
+ "之后第一次开机和平常一样快。新建的实例不带 Google Play 商店,要的话建好后用 playstore install 补全。" +
"这期间实例的状态是 creating(status、list 里 creation_progress 是百分比),不能开机、修改或删除;失败(或被打断)是 create_failed," +
"用 create --resume N 重做初始化,或者 delete N --yes 删掉。",
flags: []flagSpec{optIndex, optTitle, optMemory, optCPU, optDataGB, optFPS, optResolution, optNoBridge, optCreateTimeout, optResume},
@@ -212,18 +214,26 @@ func init() {
run: runCollectInfo, human: humanCollectInfo,
},
{
- name: "components", args: "[status] | source [auto|googledownloads_cn|tencent|google] | fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads]",
- summary: "查看或补全 Google 组件(adb 和系统镜像)",
+ name: "components", args: "[status] | source [auto|googledownloads_cn|tencent|google] | fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads] [--play-store] | import-playstore <文件或文件夹>…",
+ summary: "查看或补全 Google 组件(adb 和系统镜像,可选的 Google Play 商店)",
details: "status(默认)查看组件,和 check 里的组件一样。fetch 补全缺少的组件:" +
"先从镜像取《Android 软件开发套件许可协议》全文并显示,输入 y 同意才继续,否则什么也不下;" +
"再依次试 googledownloads.cn、腾讯云镜像、dl.google.com,下载缺少的 adb 和系统镜像(约 1.5 GB,中断了再运行会接着下)," +
"校验后装进 sdk\\。已经齐全的跳过;不完整的旧目录改名为 <目录>.incomplete-<时间> 留着,不删。" +
+ "--play-store 再补全可选的 Google Play 商店(playstore install 要用;已经有了就跳过):下 Google Play 版系统镜像," +
+ "从里面的 system.img 只取出商店、Play 服务、GSF、合作伙伴设置和商店的证书这五个文件,核对 SHA-256 后放进 sdk\\playstore," +
+ "解出的镜像和下载的压缩包随即删掉,只留这五个文件。" +
+ "import-playstore 用自己的文件补全它,不下载、不问许可协议:给 Google 官方的 Google Play 版镜像压缩包(x86_64-34_r14.zip,名字不限," +
+ "按 Google 仓库清单核对大小和 SHA-1),或者那五个文件本身(名字不限,逐个核对大小和 SHA-256;给文件夹就取里面同名的几个);" +
+ "认不出或对不上的一律拒收,什么也不改;只读你的文件,不动它们。" +
+ "status 列出这五个文件在不在、从哪来(download / import),不把它算进必需组件。" +
"不带参数时默认查看组件;source 查看或修改记住的下载源(auto、googledownloads_cn、tencent、google)。" +
"--json 时必须给 --accept-license(仅 fetch)。只有 Windows 有组件清单,darwin、linux 返回 unsupported。" +
"退出码:没同意许可协议是 2(license_declined),路径有中文、空间不够、不是魔改 AVD 的文件夹是 4," +
- "正在补全是 5,下不成是 9(components_unavailable),下载或解压后没通过校验也是 9(components_rejected)。",
+ "正在补全是 5,下不成是 9(components_unavailable),下载或解压后没通过校验、导入的文件被拒收也是 9(components_rejected," +
+ "--json 的 error.details.reason 是 unknown_file、mismatch 或 incomplete)。",
flags: fetchFlags,
- posNames: []string{"status、source 或 fetch", "下载源"}, maxPos: 2,
+ posNames: []string{"status、source、fetch 或 import-playstore", "下载源或要导入的文件"}, maxPos: -1,
final: true,
run: runComponents, human: formatComponents,
},
@@ -245,6 +255,18 @@ func init() {
final: true,
run: runBridge, human: humanOf(formatBridge), exit: exitBridge,
},
+ {
+ name: "playstore", args: " install|uninstall|status", summary: "装、卸、查看 Google Play 商店",
+ details: "商店的五个文件取自 Google 官方的 Google Play 版系统镜像(和实例的镜像同一期),要先在根目录补全:" +
+ "components fetch --play-store(下载)或 components import-playstore(用自己的文件);没有就报 not_ready(details.reason 为 playstore_files_missing)。" +
+ "装在执行桥带来的可写系统镜像覆盖层里,所以实例要有执行桥。install 放入商店和 Google 签名的 Play 服务、GSF、合作伙伴设置," +
+ "藏起镜像自带的同名占位应用,再冷启动一次生效;uninstall 去掉 Google Play 装的更新、删掉这些文件,冷启动后恢复占位应用。" +
+ "实例原来关着的,做完再关掉;开着的,冷启动后照原来的方式开回来(带窗口或无头)。status:只读,实例要开着。" +
+ "没有执行桥的报 not_ready(details.reason 为 no_bridge)。装了商店的实例不能卸执行桥,要先卸商店。新建的实例都不带商店。",
+ posNames: []string{"实例编号", "install、uninstall 或 status"}, minPos: 2, maxPos: 2,
+ final: true,
+ run: runPlayStore, human: humanOf(formatPlayStore), exit: exitPlayStore,
+ },
{
name: "disk", args: " trim|info|compact", summary: "磁盘回收:触发回收或整理把客体已删文件占的空间还给宿主;看数据盘大小",
details: "trim:在客体的数据盘(/data)上触发一次回收(FITRIM,经 vold 空闲维护)," +
@@ -441,6 +463,8 @@ type HelpResult struct {
type ComponentsStatusResult struct {
Root string `json:"root"`
Components []emu.ComponentStatus `json:"components"`
+ // PlayStore is the optional Google Play Store's five files (not one of Components: never counted as missing).
+ PlayStore components.PlayStoreStatus `json:"play_store"`
}
// ComponentsSourceResult is `components source`: the saved per-user choice.
@@ -892,8 +916,11 @@ func runComponents(c *cmdCtx) (any, error) {
if len(c.inv.pos) > 0 {
action = strings.ToLower(c.inv.pos[0])
}
- if action != "fetch" && action != "status" && action != "source" {
- return nil, errs.Newf(errs.Usage, "只能是 status、source 或 fetch,收到「%s」", c.inv.pos[0]).WithAdvice(findCommand("components").usageAdvice())
+ if action != "fetch" && action != "status" && action != "source" && action != "import-playstore" {
+ return nil, errs.Newf(errs.Usage, "只能是 status、source、fetch 或 import-playstore,收到「%s」", c.inv.pos[0]).WithAdvice(findCommand("components").usageAdvice())
+ }
+ if action == "import-playstore" {
+ return runImportPlayStore(c)
}
if action == "source" {
for _, f := range fetchFlags {
@@ -941,7 +968,8 @@ func runComponents(c *cmdCtx) (any, error) {
if err != nil {
return nil, err
}
- return &ComponentsStatusResult{Root: r.Dir, Components: emu.ComponentStatuses(r.Components())}, nil
+ return &ComponentsStatusResult{Root: r.Dir, Components: emu.ComponentStatuses(r.Components()),
+ PlayStore: components.PlayStoreState(r)}, nil
}
var (
r *root.Root
@@ -1002,7 +1030,8 @@ func fetchOptions(c *cmdCtx) (components.Options, error) {
o := components.Options{
AcceptLicense: inv.boolOpt("accept-license"), JSON: inv.json, Input: input,
KeepDownloads: inv.boolOpt("keep-downloads"), Mirror: inv.strOpt("mirror"), MirrorURLs: inv.strsOpt("mirror-url"),
- Log: c.log,
+ PlayStore: inv.boolOpt("play-store"),
+ Log: c.log,
}
// --mirror / --mirror-url win. Otherwise the saved per-user source; with none saved, someone at a console is
// asked once (and the answer saved), while --accept-license (automation), --json and a stdin that is not a
@@ -1146,6 +1175,38 @@ func runBridge(c *cmdCtx) (any, error) {
return m.Bridge(c.ctx, i, action)
}
+func runPlayStore(c *cmdCtx) (any, error) {
+ i, err := parseIndex(c.inv.pos[0])
+ if err != nil {
+ return nil, err
+ }
+ action := strings.ToLower(c.inv.pos[1])
+ switch action {
+ case "install", "uninstall", "status":
+ default:
+ return nil, errs.Newf(errs.Usage, "第二个参数只能是 install、uninstall 或 status,收到「%s」", c.inv.pos[1]).
+ WithAdvice(findCommand("playstore").usageAdvice())
+ }
+ m, err := c.mgr()
+ if err != nil {
+ return nil, err
+ }
+ return m.PlayStore(c.ctx, i, action)
+}
+
+// exitPlayStore: 1 when the store did not end up as asked (installed after install, absent after uninstall). status
+// and --json: 0, the result says it.
+func exitPlayStore(data any, json bool) int {
+ r, ok := data.(*emu.PlayStoreResult)
+ if json || !ok {
+ return 0
+ }
+ if r.Action == "install" && !r.Installed || r.Action == "uninstall" && r.State != "absent" {
+ return 1
+ }
+ return 0
+}
+
// runDisk dispatches `disk ` (trim, info, compact); add a case per new action.
func runDisk(c *cmdCtx) (any, error) {
i, err := parseIndex(c.inv.pos[0])
diff --git a/internal/cli/format.go b/internal/cli/format.go
index 6d1a6dd..871e1f6 100644
--- a/internal/cli/format.go
+++ b/internal/cli/format.go
@@ -572,6 +572,9 @@ func formatComponents(w io.Writer, data any) {
for _, c := range r.Components {
fmt.Fprintln(w, componentLine(c))
}
+ fmt.Fprintln(w, playStoreStateLine(r.PlayStore))
+ case *components.PlayStoreImportResult:
+ formatImportPlayStore(w, r)
case *components.Result:
fetched := false
for _, c := range r.Components {
@@ -642,6 +645,26 @@ func formatBridge(w io.Writer, r *emu.BridgeResult) {
}
}
+func formatPlayStore(w io.Writer, r *emu.PlayStoreResult) {
+ state := map[string]string{"installed": "已装", "absent": "没装", "partial": "只装了一部分"}[r.State]
+ if state == "" {
+ state = "不知道(实例关着)"
+ }
+ fmt.Fprintf(w, "实例 %d Google Play 商店 %s:%s\n", r.Index, r.Action, state)
+ for _, l := range r.Files {
+ fmt.Fprintln(w, " "+l)
+ }
+ if r.Restarted {
+ fmt.Fprintln(w, "为了让改动生效,关机后又开了一次")
+ }
+ if r.Stopped {
+ fmt.Fprintln(w, "实例原来是关着的,做完已经关掉")
+ }
+ if r.Action == "install" && r.Installed {
+ fmt.Fprintln(w, "在实例里打开 Play 商店登录 Google 账号;提示设备未经 Play 保护机制认证时,按文档登记这台实例的 GSF ID")
+ }
+}
+
func formatDisk(w io.Writer, r *emu.DiskTrimResult) {
fmt.Fprintf(w, "实例 %d 已触发磁盘回收(trim)\n", r.Index)
}
diff --git a/internal/cli/playstore.go b/internal/cli/playstore.go
new file mode 100644
index 0000000..18883a2
--- /dev/null
+++ b/internal/cli/playstore.go
@@ -0,0 +1,96 @@
+package cli
+
+import (
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "modavd/internal/components"
+ "modavd/internal/errs"
+)
+
+// importPlayStore is components.ImportPlayStore (a variable for the tests).
+var importPlayStore = components.ImportPlayStore
+
+// runImportPlayStore is `components import-playstore ...`: the Google Play Store's files from what the
+// user has (components.ImportPlayStore), no license and no download.
+func runImportPlayStore(c *cmdCtx) (any, error) {
+ var given []string
+ for _, f := range fetchFlags {
+ if c.inv.isSet(f.name) {
+ given = append(given, "--"+f.name)
+ }
+ }
+ if len(given) > 0 {
+ return nil, errs.Newf(errs.Usage, "%s 只用于 components fetch", strings.Join(given, "、")).
+ WithAdvice(findCommand("components").usageAdvice())
+ }
+ paths := c.inv.pos[1:]
+ if len(paths) == 0 {
+ return nil, errs.New(errs.Usage, "components import-playstore 要给文件或文件夹:Google Play 版镜像的压缩包 x86_64-34_r14.zip,或者商店的五个文件").
+ WithAdvice(findCommand("components").usageAdvice())
+ }
+ files, err := components.PlayStoreImportFiles(paths)
+ if err != nil {
+ return nil, err
+ }
+ r, err := c.deps.resolveRoot(c.inv.root)
+ if err != nil {
+ return nil, err
+ }
+ var last time.Time
+ o := components.Options{Log: c.log, Progress: func(p components.Progress) {
+ if !p.Final && !last.IsZero() && time.Since(last) < 2*time.Second {
+ return
+ }
+ last = time.Now()
+ c.log.Infof("%s", p.Message)
+ }}
+ return importPlayStore(c.ctx, r, files, o)
+}
+
+// playStoreStateLine is the Google Play Store's line in components status, in componentLine's style.
+func playStoreStateLine(s components.PlayStoreStatus) string {
+ if s.Ready {
+ line := "✓ Google Play 商店"
+ if s.Version != "" {
+ line += " " + s.Version
+ }
+ from := map[string]string{components.PlayStoreFromDownload: ",下载的", components.PlayStoreFromImport: ",导入的"}[s.Source]
+ return line + "(可选" + from + ")"
+ }
+ var missing []string
+ for _, f := range s.Files {
+ if !f.Present {
+ missing = append(missing, f.Name)
+ }
+ }
+ state := " 未安装"
+ switch {
+ case len(missing) == 0:
+ state = " 不全,source.properties 不对"
+ case len(missing) < len(s.Files):
+ state = " 不全,缺 " + strings.Join(missing, "、")
+ }
+ return "✗ Google Play 商店" + state + "(可选;用 components fetch --play-store 或 components import-playstore <文件> 补全)"
+}
+
+func formatImportPlayStore(w io.Writer, r *components.PlayStoreImportResult) {
+ if r.Skipped {
+ fmt.Fprintln(w, "Google Play 商店的五个文件已经齐全、校验无误,没有改动(给的文件也核对过了)")
+ return
+ }
+ from := "商店的文件"
+ if r.From == "zip" {
+ from = "Google Play 版镜像的压缩包"
+ }
+ fmt.Fprintf(w, "已用%s补全 Google Play 商店:%s\n", from, r.Status.Dir)
+ if len(r.Kept) > 0 {
+ fmt.Fprintf(w, " 原来就有、校验无误留下的:%s\n", strings.Join(r.Kept, "、"))
+ }
+ if r.SetAside != "" {
+ fmt.Fprintf(w, " 原来不完整的目录改名为 %s,确认没用后可以自己删掉\n", r.SetAside)
+ }
+ fmt.Fprintln(w, "给实例装上:modavd playstore <实例编号> install")
+}
diff --git a/internal/cli/playstore_test.go b/internal/cli/playstore_test.go
new file mode 100644
index 0000000..71d9e15
--- /dev/null
+++ b/internal/cli/playstore_test.go
@@ -0,0 +1,185 @@
+//go:build windows
+
+package cli
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "modavd/internal/components"
+ "modavd/internal/emu"
+ "modavd/internal/errs"
+ "modavd/internal/root"
+ "modavd/internal/testutil"
+)
+
+// playStoreErr, when set, is what the fake manager's PlayStore returns.
+var playStoreErr error
+
+func (f *fakeManager) PlayStore(ctx context.Context, i int, action string) (*emu.PlayStoreResult, error) {
+ f.record("playstore " + itoa(i) + " " + action)
+ if playStoreErr != nil {
+ return nil, playStoreErr
+ }
+ state := map[string]string{"install": "installed", "uninstall": "absent", "status": "installed"}[action]
+ return &emu.PlayStoreResult{Index: i, Action: action, State: state, Installed: state == "installed",
+ Files: []string{}, Warnings: []string{}}, nil
+}
+
+// TestCreateHasNoPlayStore: new instances never get the store; create takes no --play-store.
+func TestCreateHasNoPlayStore(t *testing.T) {
+ h := newHarness()
+ var got []emu.CreateRequest
+ h.fake.create = func(req emu.CreateRequest) (*emu.InstanceInfo, error) {
+ got = append(got, req)
+ in := sampleInstance(1)
+ return &in, nil
+ }
+ if r := h.run(t, nil, "create", "--json"); r.code != 0 || len(got) != 1 {
+ t.Fatalf("create: exit %d, %+v", r.code, got)
+ }
+ if r := h.run(t, nil, "create", "--play-store"); r.code != 2 || len(got) != 1 {
+ t.Fatalf("create --play-store: exit %d, %d creates", r.code, len(got))
+ }
+}
+
+// TestComponentsFetchPlayStoreFlag: components fetch passes --play-store; status does not take it.
+func TestComponentsFetchPlayStoreFlag(t *testing.T) {
+ r := componentsRoot(t)
+ h := newHarness()
+ h.useRoot(r)
+ var got []components.Options
+ h.fetch = func(ctx context.Context, rr *root.Root, o components.Options) (*components.Result, error) {
+ got = append(got, o)
+ return &components.Result{Root: rr.Dir, Components: []components.ComponentResult{}, Downloads: []string{}}, nil
+ }
+ h.run(t, nil, "--json", "components", "fetch", "--accept-license")
+ h.run(t, nil, "--json", "components", "fetch", "--accept-license", "--play-store")
+ if len(got) != 2 || got[0].PlayStore || !got[1].PlayStore {
+ t.Fatalf("options %+v", got)
+ }
+ if res := h.run(t, nil, "components", "status", "--play-store"); res.code != 2 {
+ t.Fatalf("status --play-store: exit %d", res.code)
+ }
+ if res := h.run(t, nil, "components", "fetch", "x"); res.code != 2 {
+ t.Fatalf("fetch with a file: exit %d", res.code)
+ }
+}
+
+// TestComponentsStatusPlayStore: status reports the store's five files apart from the components (never missing).
+func TestComponentsStatusPlayStore(t *testing.T) {
+ r := componentsRoot(t)
+ h := newHarness()
+ h.useRoot(r)
+ res := h.run(t, nil, "--json", "components", "status")
+ data := envelope(t, res.stdout)["data"].(map[string]any)
+ ps, ok := data["play_store"].(map[string]any)
+ if res.code != 0 || !ok || ps["ready"] != false || len(ps["files"].([]any)) != 5 || ps["source"] != "" {
+ t.Fatalf("exit %d, play_store %v", res.code, data["play_store"])
+ }
+ for _, c := range data["components"].([]any) {
+ if c.(map[string]any)["id"] == "play-store" {
+ t.Error("the store is one of the components")
+ }
+ }
+ res = h.run(t, nil, "components")
+ if !strings.Contains(res.stdout, "✗ Google Play 商店 未安装(可选") {
+ t.Errorf("human: %s", res.stdout)
+ }
+}
+
+// TestComponentsImportPlayStore: import-playstore hands the files (a folder: the store's files in it) to the import,
+// with the root; it takes no fetch option and needs a file; a path that is not there is not found, a refusal keeps
+// its code, reason and exit code.
+func TestComponentsImportPlayStore(t *testing.T) {
+ r := componentsRoot(t)
+ h := newHarness()
+ h.useRoot(r)
+ old := importPlayStore
+ t.Cleanup(func() { importPlayStore = old })
+ var got [][]components.ImportFile
+ var fail error
+ importPlayStore = func(ctx context.Context, rr *root.Root, files []components.ImportFile, o components.Options) (*components.PlayStoreImportResult, error) {
+ if rr != r || o.Log == nil {
+ t.Errorf("root %v, log %v", rr, o.Log)
+ }
+ got = append(got, files)
+ if fail != nil {
+ return nil, fail
+ }
+ return &components.PlayStoreImportResult{Root: rr.Dir, From: "files", Used: []string{"a"}, Kept: []string{},
+ Status: components.PlayStoreStatus{Ready: true, Source: components.PlayStoreFromImport, Files: []components.PlayStoreFile{}}}, nil
+ }
+ user := testutil.TempDir(t)
+ zip := filepath.Join(user, "x86_64-34_r14.zip")
+ dir := filepath.Join(user, "five")
+ for p, body := range map[string]string{zip: "zip", filepath.Join(dir, "Phonesky.apk"): "s", filepath.Join(dir, "other.txt"): "x"} {
+ os.MkdirAll(filepath.Dir(p), 0o755)
+ os.WriteFile(p, []byte(body), 0o644)
+ }
+
+ res := h.run(t, nil, "--json", "components", "import-playstore", zip, dir)
+ if res.code != 0 || len(got) != 1 {
+ t.Fatalf("exit %d %s %s", res.code, res.stdout, res.stderr)
+ }
+ if len(got[0]) != 2 || !slices.Equal([]string{got[0][0].Path, got[0][1].Path}, []string{zip, filepath.Join(dir, "Phonesky.apk")}) {
+ t.Errorf("files %+v", got[0])
+ }
+ if data := envelope(t, res.stdout)["data"].(map[string]any); data["from"] != "files" {
+ t.Errorf("data %v", data)
+ }
+ if res = h.run(t, nil, "components", "import-playstore", zip); res.code != 0 || !strings.Contains(res.stdout, "已用商店的文件补全 Google Play 商店") {
+ t.Errorf("human: %d %s", res.code, res.stdout)
+ }
+
+ for _, args := range [][]string{{"components", "import-playstore"}, {"components", "import-playstore", zip, "--play-store"},
+ {"components", "import-playstore", zip, "--accept-license"}} {
+ if res := h.run(t, nil, args...); res.code != 2 {
+ t.Errorf("%v: exit %d", args, res.code)
+ }
+ }
+ if res := h.run(t, nil, "components", "import-playstore", filepath.Join(user, "gone.zip")); res.code != 3 {
+ t.Errorf("gone: exit %d", res.code)
+ }
+ n := len(got)
+ fail = errs.WithDetail(errs.New(errs.ComponentsRejected, "SHA-256 对不上"), errs.Reason, components.ImportReasonMismatch)
+ res = h.run(t, nil, "--json", "components", "import-playstore", zip)
+ e := envelope(t, res.stdout)["error"].(map[string]any)
+ if res.code != 9 || e["code"] != "components_rejected" || e["details"].(map[string]any)["reason"] != "mismatch" || len(got) != n+1 {
+ t.Errorf("refused: exit %d, %v", res.code, e)
+ }
+}
+
+// TestPlayStoreCommand: playstore install|uninstall|status reaches the manager; another action is a usage error;
+// without --json the exit code says whether the store ended up as asked; a refusal keeps its reason.
+func TestPlayStoreCommand(t *testing.T) {
+ h := newHarness()
+ for _, a := range []string{"install", "uninstall", "status"} {
+ if r := h.run(t, nil, "playstore", "3", a); r.code != 0 {
+ t.Errorf("%s: exit %d %s", a, r.code, r.stderr)
+ }
+ }
+ if r := h.run(t, nil, "playstore", "3", "remove"); r.code != 2 {
+ t.Errorf("remove: exit %d", r.code)
+ }
+ if want := []string{"playstore 3 install", "playstore 3 uninstall", "playstore 3 status"}; !slices.Equal(h.fake.calls, want) {
+ t.Errorf("calls %v", h.fake.calls)
+ }
+ if exitPlayStore(&emu.PlayStoreResult{Action: "install", State: "partial"}, false) != 1 ||
+ exitPlayStore(&emu.PlayStoreResult{Action: "uninstall", State: "installed"}, false) != 1 ||
+ exitPlayStore(&emu.PlayStoreResult{Action: "install", State: "partial"}, true) != 0 {
+ t.Error("exit codes")
+ }
+
+ playStoreErr = errs.WithDetail(errs.New(errs.NotReady, "还没有五个文件"), errs.Reason, emu.PlayStoreReasonFiles)
+ t.Cleanup(func() { playStoreErr = nil })
+ r := h.run(t, nil, "--json", "playstore", "3", "install")
+ e := envelope(t, r.stdout)["error"].(map[string]any)
+ if r.code != 4 || e["code"] != "not_ready" || e["details"].(map[string]any)["reason"] != "playstore_files_missing" {
+ t.Errorf("refused: exit %d, %v", r.code, e)
+ }
+}
diff --git a/internal/components/download.go b/internal/components/download.go
index 4f1aefb..45c4f73 100644
--- a/internal/components/download.go
+++ b/internal/components/download.go
@@ -167,6 +167,9 @@ func attr(e xml.StartElement, name string) string {
// ---- component zips --------------------------------------------------------------------------------------------
func (f *fetcher) zipPath(c root.SDKComponent) string {
+ if c.ZipName != "" {
+ return filepath.Join(f.r.Dir, "downloads", c.ZipName)
+ }
return filepath.Join(f.r.Dir, "downloads", path.Base(c.URL))
}
diff --git a/internal/components/fetch.go b/internal/components/fetch.go
index fbe4272..067a6a2 100644
--- a/internal/components/fetch.go
+++ b/internal/components/fetch.go
@@ -32,6 +32,7 @@ import (
"modavd/internal/errs"
"modavd/internal/host"
"modavd/internal/logx"
+ "modavd/internal/playstore"
"modavd/internal/root"
)
@@ -52,6 +53,10 @@ var (
fetchGOOS = runtime.GOOS
componentCatalogue = root.SDKComponents
diskFree = host.DiskFree
+ // playStoreCatalogue is the optional Google Play Store (Options.PlayStore); playStoreExtract takes its files out of
+ // the Play image's system.img.
+ playStoreCatalogue = root.PlayStoreComponent
+ playStoreExtract = playstore.Extract
)
// License returns the Android SDK license text and the mirror it came from, read from the mirrors of source the
@@ -102,6 +107,9 @@ type Options struct {
Input io.Reader
// KeepDownloads keeps the zips in downloads\ after installing (deleted by default: 1.5 GB).
KeepDownloads bool
+ // PlayStore also fetches the optional Google Play Store (root.PlayStoreComponent, ModAVD#125): Google's Play image,
+ // out of whose system.img only the store's files are kept.
+ PlayStore bool
// Mirror keeps only that built-in mirror (googledownloads_cn / tencent / google, any case). MirrorURLs replace the
// built-in list, tried in order (each entry may hold several, comma-separated), and win over Mirror.
Mirror string
@@ -354,16 +362,20 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) {
lock.Unlock()
f.note(fmt.Sprintf("退出码 %d", errs.ExitCode(err)))
}()
- f.say("魔改 AVD:补全 Google 组件(adb、Android 14 系统镜像)")
+ what := "adb、Android 14 系统镜像"
+ if f.o.PlayStore {
+ what += "、Google Play 商店"
+ }
+ f.say("魔改 AVD:补全 Google 组件(" + what + ")")
f.say("文件夹:" + r.Dir)
free := "-1"
if f.o.TestFreeSpaceMB != nil {
free = fmt.Sprint(*f.o.TestFreeSpaceMB)
}
f.note(fmt.Sprintf("modavd %s,参数:AcceptLicense=%t Mirror=%s MirrorUrl=%s KeepDownloads=%t ConnectTimeoutSec=%s "+
- "StallTimeoutSec=%s ManifestTimeoutSec=%s TestFreeSpaceMB=%s", buildinfo.Version, f.o.AcceptLicense, f.o.Mirror,
- strings.Join(f.o.MirrorURLs, ","), f.o.KeepDownloads, seconds(f.o.ConnectTimeout), seconds(f.o.StallTimeout),
- seconds(f.o.ManifestTimeout), free))
+ "StallTimeoutSec=%s ManifestTimeoutSec=%s TestFreeSpaceMB=%s PlayStore=%t", buildinfo.Version, f.o.AcceptLicense,
+ f.o.Mirror, strings.Join(f.o.MirrorURLs, ","), f.o.KeepDownloads, seconds(f.o.ConnectTimeout),
+ seconds(f.o.StallTimeout), seconds(f.o.ManifestTimeout), free, f.o.PlayStore))
if !isASCII(r.Dir) {
return nil, errs.New(errs.PrecheckFailed, "这个文件夹的路径里有中文或其他非英文字符,模拟器在这种路径下起不来").
@@ -384,6 +396,9 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) {
f.clearStaging()
specs := componentCatalogue()
+ if f.o.PlayStore {
+ specs = append(specs, playStoreCatalogue())
+ }
res = &Result{Root: r.Dir, Components: []ComponentResult{}, Downloads: []string{}}
var todo []root.SDKComponent
for _, c := range specs {
@@ -444,7 +459,11 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) {
return nil, err
}
f.say(" 解压安装…")
- aside, err := f.install(ctx, c, zipFile)
+ install := f.install
+ if c.ID == playStoreCatalogue().ID {
+ install = f.installPlayStore
+ }
+ aside, err := install(ctx, c, zipFile)
if aside != "" {
f.asides = append(f.asides, setAsideRel(f.sdk, aside))
}
diff --git a/internal/components/install.go b/internal/components/install.go
index 39467cf..9630ffc 100644
--- a/internal/components/install.go
+++ b/internal/components/install.go
@@ -12,6 +12,7 @@ import (
"time"
"modavd/internal/errs"
+ "modavd/internal/playstore"
"modavd/internal/root"
)
@@ -73,8 +74,104 @@ func (f *fetcher) install(ctx context.Context, c root.SDKComponent, zipPath stri
if err := f.checkStaged(c, staging); err != nil {
return "", err
}
- extracted := filepath.Join(staging, filepath.FromSlash(c.ArchiveRoot))
+ return f.place(c, filepath.Join(staging, filepath.FromSlash(c.ArchiveRoot)))
+}
+
+// installPlayStore is install for the Google Play Store (root.PlayStoreComponent): the five files are taken out of
+// the Play image's zip into sdk\.staging\ (playStoreFromZip) and go into place like a component's directory; the
+// unpacked 3 GB image is gone by then, and fetch deletes the zip afterwards (unless KeepDownloads), so only the five
+// files stay.
+func (f *fetcher) installPlayStore(ctx context.Context, c root.SDKComponent, zipPath string) (setAside string, err error) {
+ staging, out, err := f.playStoreStaging(c)
+ if err != nil {
+ return "", err
+ }
+ defer f.removeStaging(staging)
+ if err := f.playStoreFromZip(ctx, c, zipPath, staging, out); err != nil {
+ return "", err
+ }
+ return f.finishPlayStore(c, out, PlayStoreFromDownload)
+}
+
+// playStoreStaging is a fresh sdk\.staging\ and the directory in it the five files are gathered in.
+func (f *fetcher) playStoreStaging(c root.SDKComponent) (staging, out string, err error) {
+ staging = filepath.Join(f.stagingRoot(), c.ID)
+ f.removeStaging(staging)
+ out = filepath.Join(staging, root.PlayStoreDir)
+ if err := os.MkdirAll(out, 0o755); err != nil {
+ return "", "", errs.Wrap(errs.Internal, err, "建不了 "+out+":"+err.Error())
+ }
+ return staging, out, nil
+}
+
+// playStoreFromZip takes the five files out of the Play image's zip into out: only the system image is unpacked, to
+// staging\system.img, read without mounting anything (playStoreExtract checks each file's size and SHA-256) and
+// deleted again, whether that worked or not.
+func (f *fetcher) playStoreFromZip(ctx context.Context, c root.SDKComponent, zipPath, staging, out string) error {
+ f.where = "从 " + filepath.Base(zipPath) + " 解出 " + playstore.ImageMember
+ img := filepath.Join(staging, "system.img")
+ err := f.extractMember(ctx, c, zipPath, playstore.ImageMember, img)
+ if err == nil {
+ f.where = "从 system.img 取出 Google Play 商店的文件"
+ f.say(" 从系统镜像里取出商店、Play 服务、GSF、合作伙伴设置…")
+ if xerr := playStoreExtract(img, playStoreFiles, out); xerr != nil {
+ err = rejected("从 Google Play 版系统镜像里取商店的文件失败:%v", xerr)
+ }
+ }
+ if rerr := os.Remove(img); err == nil && rerr != nil {
+ return errs.Wrap(errs.Internal, rerr, "删不掉临时解出的 "+img+":"+rerr.Error())
+ }
+ return err
+}
+
+// finishPlayStore writes out's source.properties (with where the files came from), checks out as a whole (Complete:
+// every pin and the revision) and moves it into sdk\playstore.
+func (f *fetcher) finishPlayStore(c root.SDKComponent, out, source string) (setAside string, err error) {
+ props := c.Properties + playStoreSourceKey + "=" + source + "\n"
+ if err := os.WriteFile(filepath.Join(out, "source.properties"), []byte(props), 0o644); err != nil {
+ return "", errs.Wrap(errs.Internal, err, "写不了 source.properties:"+err.Error())
+ }
+ if !c.Complete(out) {
+ return "", rejected("%s 的文件校验失败", c.FetchName)
+ }
+ return f.place(c, out)
+}
+
+// extractMember unpacks the one entry name of zipPath to dest (refused like extract's entries: encrypted, another
+// method, a size that does not match).
+func (f *fetcher) extractMember(ctx context.Context, c root.SDKComponent, zipPath, name, dest string) error {
+ zr, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return rejected("压缩包打不开:%v", err)
+ }
+ defer zr.Close()
+ for _, e := range zr.File {
+ if strings.ReplaceAll(e.Name, `\`, "/") != name {
+ continue
+ }
+ if e.Flags&1 != 0 {
+ return rejected("压缩包里的 %s 是加密的", e.Name)
+ }
+ if e.Method != zip.Store && e.Method != zip.Deflate {
+ return rejected("压缩包里 %s 用了不支持的压缩方式 %d", e.Name, e.Method)
+ }
+ m := newMeter(f.o.Progress, "extracting", c.FetchName, max(int64(e.UncompressedSize64), 1), 0)
+ n, err := f.extractFile(ctx, e, dest, make([]byte, 1<<20), 0, m)
+ if err != nil {
+ return err
+ }
+ if n != int64(e.UncompressedSize64) {
+ return rejected("解压出的 %s 大小不对", e.Name)
+ }
+ m.finish(n)
+ return nil
+ }
+ return rejected("压缩包里没有 %s", name)
+}
+// place moves an unpacked, checked component directory (extracted) into sdk\ (the end of the script's
+// Install-Component), see install.
+func (f *fetcher) place(c root.SDKComponent, extracted string) (setAside string, err error) {
f.where = `把解压好的 ` + c.FetchName + ` 挪到 sdk\` + c.Target
target := c.Dir(f.sdk)
if _, err := os.Lstat(target); err == nil {
diff --git a/internal/components/playstore.go b/internal/components/playstore.go
new file mode 100644
index 0000000..b052513
--- /dev/null
+++ b/internal/components/playstore.go
@@ -0,0 +1,449 @@
+package components
+
+// The Google Play Store's five files (root.PlayStoreComponent, ModAVD/ModAVD#125). They get into sdk\playstore two
+// ways, and only they stay there: fetched along with the other components when asked for (Options.PlayStore: Google's
+// Play image is downloaded, the files are taken out of its system.img, the image and the zip are deleted), or imported
+// from what the user has (ImportPlayStore): that zip, or the five files themselves. Each file is checked against its
+// pinned size and SHA-256 before it goes in; source.properties says where they came from (ModAVD.Source). Instances
+// get the store with `modavd playstore install` (package emu); new instances never do.
+
+import (
+ "context"
+ "crypto/sha1"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "hash"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "modavd/internal/errs"
+ "modavd/internal/logx"
+ "modavd/internal/playstore"
+ "modavd/internal/root"
+)
+
+// Where the files in sdk\playstore came from (source.properties ModAVD.Source, PlayStoreStatus.Source).
+const (
+ PlayStoreFromDownload = "download" // components fetch --play-store
+ PlayStoreFromImport = "import" // components import-playstore
+ playStoreSourceKey = "ModAVD.Source"
+)
+
+// Values of errs.Reason on an import's errs.ComponentsRejected.
+const (
+ ImportReasonUnknown = "unknown_file" // neither the Play image's zip nor one of the five files (by size)
+ ImportReasonMismatch = "mismatch" // the size fits, the SHA-1 (zip) or SHA-256 (file) does not
+ ImportReasonIncomplete = "incomplete" // some of the five are neither given nor already in sdk\playstore
+)
+
+// playStoreFiles is the table of the five files (a variable for the tests, with root.PlayStoreComponent's pins
+// swapped in through playStoreCatalogue to match).
+var playStoreFiles = playstore.Files
+
+// PlayStoreFile is one of the five files in a PlayStoreStatus.
+type PlayStoreFile struct {
+ Name string `json:"name"` // its name in sdk\playstore
+ Guest string `json:"guest"` // where an install puts it in the instance
+ Size int64 `json:"size"` // its pinned size
+ Present bool `json:"present"` // a file of that size is there
+}
+
+// PlayStoreStatus is the root's Google Play Store files: what components status, the manager's component list and its
+// instance buttons show. Cheap: sizes and source.properties only; the SHA-256 of each file is checked where the files
+// are put in (fetch, import) and used (playstore install).
+type PlayStoreStatus struct {
+ Dir string `json:"dir"` // \playstore
+ Ready bool `json:"ready"` // all five are there and source.properties has the revision
+ Source string `json:"source"` // download, import, or "" (none, or put there some other way)
+ Version string `json:"version"` // Pkg.Revision, "" when none
+ Files []PlayStoreFile `json:"files"`
+}
+
+// PlayStoreState reads r's Google Play Store files (in the SDK the instances run on).
+func PlayStoreState(r *root.Root) PlayStoreStatus {
+ c := playStoreCatalogue()
+ dir := c.Dir(r.RuntimeSDK())
+ st := PlayStoreStatus{Dir: dir, Files: []PlayStoreFile{}}
+ all := true
+ for _, f := range playStoreFiles {
+ fi, err := os.Stat(filepath.Join(dir, f.Name))
+ present := err == nil && fi.Mode().IsRegular() && fi.Size() == f.Size
+ all = all && present
+ st.Files = append(st.Files, PlayStoreFile{Name: f.Name, Guest: f.Guest(), Size: f.Size, Present: present})
+ }
+ st.Version = root.Revision(dir)
+ if data, err := root.ReadFileShared(filepath.Join(dir, "source.properties")); err == nil {
+ _, values := root.ReadKeyValues(data, false)
+ st.Source = values[playStoreSourceKey]
+ }
+ st.Ready = all && strings.EqualFold(st.Version, c.Version)
+ return st
+}
+
+// ImportFile is one file handed to ImportPlayStore: Path is read (never changed), Name is what messages call it (the
+// user's name for it; Path's last part when "").
+type ImportFile struct {
+ Path string
+ Name string
+}
+
+func (f ImportFile) name() string {
+ if f.Name != "" {
+ return f.Name
+ }
+ return filepath.Base(f.Path)
+}
+
+// PlayStoreImportResult is the result of ImportPlayStore (`components import-playstore`, the manager's 「使用我自己的
+// 文件」). Every field is always present.
+type PlayStoreImportResult struct {
+ Root string `json:"root"`
+ From string `json:"from"` // zip (Google's Play image) or files (the five files themselves)
+ Used []string `json:"used"` // the given files that were checked and taken, by their names
+ Kept []string `json:"kept"` // files of sdk\playstore that were right and stay (not given this time)
+ Skipped bool `json:"skipped"` // sdk\playstore had all five already: the given files were checked, nothing changed
+ SetAside string `json:"set_aside"` // where an incomplete old sdk\playstore went (sdk\...incomplete-), "" when none
+ Status PlayStoreStatus `json:"status"` // after the import
+}
+
+// PlayStoreImportFiles turns the command line's paths into ImportPlayStore's files: a file as it is; a directory as
+// the files in it named like the five (Phonesky.apk ...), others in it are left alone. errs.NotFound for a path that is
+// not there, errs.InvalidArgument for a directory with none of them.
+func PlayStoreImportFiles(paths []string) ([]ImportFile, error) {
+ var out []ImportFile
+ for _, p := range paths {
+ fi, err := os.Stat(p)
+ if err != nil {
+ return nil, errs.Newf(errs.NotFound, "找不到 %s", p)
+ }
+ if !fi.IsDir() {
+ out = append(out, ImportFile{Path: p})
+ continue
+ }
+ n := len(out)
+ for _, f := range playStoreFiles {
+ q := filepath.Join(p, f.Name)
+ if st, err := os.Stat(q); err == nil && st.Mode().IsRegular() {
+ out = append(out, ImportFile{Path: q})
+ }
+ }
+ if len(out) == n {
+ return nil, errs.Newf(errs.InvalidArgument, "文件夹 %s 里没有 Google Play 商店的那五个文件(%s)", p, strings.Join(playStoreNames(), "、")).
+ WithAdvice("Google Play 版镜像的压缩包请直接给出它的路径")
+ }
+ }
+ return out, nil
+}
+
+func playStoreNames() []string {
+ names := make([]string, len(playStoreFiles))
+ for k, f := range playStoreFiles {
+ names[k] = f.Name
+ }
+ return names
+}
+
+// importItem is a given file once its size said what it is: the Play image's zip, or file slot of playStoreFiles.
+type importItem struct {
+ ImportFile
+ size int64
+ zip bool
+ slot int
+}
+
+func importRejected(reason, format string, args ...any) *errs.Error {
+ return errs.WithDetail(rejected(format, args...), errs.Reason, reason)
+}
+
+// ImportPlayStore puts the Google Play Store's five files into r's sdk\playstore from files the user has: Google's
+// Play image zip (x86_64-34_r14.zip under any name, checked against the size and SHA-1 of Google's repository, then
+// read like a download), or the five files themselves under any names (each checked against its pinned size and
+// SHA-256 while it is copied). A file whose size is neither is refused before anything is read; one whose hash does
+// not match after; nothing changes then. Files not given are taken from sdk\playstore when they are there and match,
+// and the import is refused when some are still missing. When sdk\playstore is complete already, the given files
+// are still checked and nothing changes (Skipped). The user's files are only read. It holds the fetch's lock and
+// writes logs\fetch-components.log like a fetch; no license is asked (nothing is downloaded).
+//
+// Error codes: Unsupported (not Windows), Usage (no file), NotFound, NotReady (not a ModAVD root, or sdkRoot set),
+// Busy (a fetch or an import runs), PrecheckFailed (space), ComponentsRejected (errs.Reason: ImportReason*),
+// Canceled, Internal.
+func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Options) (res *PlayStoreImportResult, err error) {
+ if fetchGOOS != "windows" {
+ return nil, errs.New(errs.Unsupported, "这个系统还没有组件清单").WithAdvice("目前只有 Windows 能补全组件")
+ }
+ if r == nil {
+ return nil, errs.New(errs.NotFound, "找不到魔改 AVD 根目录")
+ }
+ if len(files) == 0 {
+ return nil, errs.New(errs.Usage, "没有给要导入的文件").
+ WithAdvice("给 Google Play 版镜像的压缩包 x86_64-34_r14.zip,或者商店的五个文件:" + strings.Join(playStoreNames(), "、"))
+ }
+ if o.Log == nil {
+ o.Log = logx.Nop()
+ }
+ f := &fetcher{r: r, o: o, sdk: r.SDK()}
+ if _, err := os.Stat(filepath.Join(f.sdk, "emulator", "emulator.exe")); err != nil {
+ return nil, errs.Newf(errs.NotReady, `这里没有 sdk\emulator\emulator.exe,不像是魔改 AVD 的文件夹:%s`, r.Dir)
+ }
+ if r.LocalSDKRoot() != "" {
+ return nil, errs.New(errs.NotReady, "这个根目录在 mas-avd.json 里设了 sdkRoot(开发用的本地 SDK),不导入组件").
+ WithAdvice(`实例用的是那份 SDK;要给这个根目录的 sdk\ 导入,先去掉 sdkRoot`)
+ }
+ f.openLog()
+ defer f.closeLog()
+ lock, err := f.lock()
+ if err != nil {
+ return nil, err
+ }
+ defer lock.Unlock()
+ defer func() {
+ if err != nil {
+ if f.where != "" {
+ f.note("失败:" + f.where)
+ }
+ f.note("原因:" + errs.As(err).Error())
+ f.clearStaging()
+ }
+ f.note(fmt.Sprintf("退出码 %d", errs.ExitCode(err)))
+ }()
+ f.say("魔改 AVD:用自己的文件补全 Google Play 商店")
+ f.say("文件夹:" + r.Dir)
+ f.clearStaging()
+
+ c := playStoreCatalogue()
+ items, err := classifyImport(files, c)
+ if err != nil {
+ return nil, err
+ }
+ res = &PlayStoreImportResult{Root: r.Dir, From: "files", Used: []string{}, Kept: []string{}}
+ need := int64(500 << 20)
+ var zipItem *importItem
+ for k := range items {
+ it := &items[k]
+ f.note(fmt.Sprintf(" 给的文件:%s(%d 字节)", it.name(), it.size))
+ if it.zip {
+ if zipItem == nil {
+ zipItem = it
+ }
+ } else {
+ need += it.size
+ }
+ }
+ if zipItem != nil {
+ res.From = "zip"
+ need = 500<<20 + c.ExtractedBytes
+ }
+ if err := f.importSpace(need); err != nil {
+ return nil, err
+ }
+
+ staging, out, err := f.playStoreStaging(c)
+ if err != nil {
+ return nil, err
+ }
+ defer f.removeStaging(staging)
+ // every given file first: a wrong one refuses the import before the 3 GB of a zip are unpacked
+ have := make([]bool, len(playStoreFiles))
+ for k := range items {
+ it := &items[k]
+ if ctx.Err() != nil {
+ return nil, canceled(ctx)
+ }
+ if it.zip {
+ f.where = "校验 " + it.name()
+ f.say("校验 " + it.name() + "(SHA-1)…")
+ sum, err := f.readHashed(ctx, it.Path, sha1.New(), nil, it.name())
+ if err != nil {
+ return nil, err
+ }
+ if sum != c.SHA1 {
+ return nil, importRejected(ImportReasonMismatch, "%s 的 SHA-1 是 %s,应为 %s:不是 Google 仓库里那份 Google Play 版镜像(%s)",
+ it.name(), sum, c.SHA1, filepath.Base(playstore.ImageURL))
+ }
+ } else {
+ want := playStoreFiles[it.slot]
+ f.where = "校验 " + it.name()
+ f.say(fmt.Sprintf("校验 %s(%s)…", it.name(), want.Name))
+ var sum string
+ if have[it.slot] {
+ // the same file given twice: checked, the first copy stays
+ sum, err = f.readHashed(ctx, it.Path, sha256.New(), nil, it.name())
+ } else {
+ sum, err = f.copyHashed(ctx, it.Path, filepath.Join(out, want.Name), it.name())
+ }
+ if err != nil {
+ return nil, err
+ }
+ if sum != want.SHA256 {
+ return nil, importRejected(ImportReasonMismatch, "%s 和 Google Play 版镜像 r14 里的 %s 大小一样,SHA-256 却对不上(是 %s,应为 %s)",
+ it.name(), want.Name, sum, want.SHA256)
+ }
+ have[it.slot] = true
+ }
+ res.Used = append(res.Used, it.name())
+ }
+
+ target := c.Dir(f.sdk)
+ if c.Complete(target) {
+ f.say(`sdk\` + c.Target + " 里五个文件都已齐全、校验无误,没有改动")
+ res.Skipped, res.Status = true, PlayStoreState(r)
+ return res, nil
+ }
+ if zipItem != nil {
+ emptyDir(out)
+ if err := f.playStoreFromZip(ctx, c, zipItem.Path, staging, out); err != nil {
+ return nil, err
+ }
+ } else {
+ var missing []string
+ for k, pf := range playStoreFiles {
+ if have[k] {
+ continue
+ }
+ // one already in place and right stays (copied: the old directory is only set aside when the new one is whole)
+ f.where = `检查 sdk\` + c.Target + `\` + pf.Name
+ if fi, err := os.Stat(filepath.Join(target, pf.Name)); err == nil && fi.Mode().IsRegular() && fi.Size() == pf.Size {
+ sum, err := f.copyHashed(ctx, filepath.Join(target, pf.Name), filepath.Join(out, pf.Name), pf.Name)
+ if errs.CodeOf(err) == errs.Canceled {
+ return nil, err
+ }
+ if err == nil && sum == pf.SHA256 {
+ res.Kept = append(res.Kept, pf.Name)
+ continue
+ }
+ os.Remove(filepath.Join(out, pf.Name))
+ }
+ missing = append(missing, pf.Name)
+ }
+ if len(missing) > 0 {
+ return nil, importRejected(ImportReasonIncomplete, "还缺 %s,什么也没有改", strings.Join(missing, "、")).
+ WithAdvice("把五个文件一起给出,或者给 Google Play 版镜像的压缩包 " + filepath.Base(playstore.ImageURL))
+ }
+ }
+ aside, err := f.finishPlayStore(c, out, PlayStoreFromImport)
+ res.SetAside = setAsideRel(f.sdk, aside)
+ if err != nil {
+ return nil, err
+ }
+ f.say(`已装好:sdk\` + c.Target)
+ res.Status = PlayStoreState(r)
+ return res, nil
+}
+
+// emptyDir empties dir (out of a zip import: files given beside the zip were copied there and are not used).
+func emptyDir(dir string) {
+ entries, _ := os.ReadDir(dir)
+ for _, e := range entries {
+ os.RemoveAll(filepath.Join(dir, e.Name()))
+ }
+}
+
+// classifyImport says what each given file is by its size alone (the sizes of the zip and the five files differ):
+// anything else, or what is not a regular file, refuses the import before any file is read.
+func classifyImport(files []ImportFile, c root.SDKComponent) ([]importItem, error) {
+ var items []importItem
+ for _, in := range files {
+ fi, err := os.Stat(in.Path)
+ if err != nil {
+ return nil, errs.Newf(errs.NotFound, "找不到 %s", in.name())
+ }
+ if !fi.Mode().IsRegular() {
+ return nil, importRejected(ImportReasonUnknown, "%s 不是普通文件", in.name())
+ }
+ it := importItem{ImportFile: in, size: fi.Size(), slot: -1}
+ if c.Size > 0 && fi.Size() == c.Size {
+ it.zip = true
+ } else {
+ for k, f := range playStoreFiles {
+ if f.Size == fi.Size() {
+ it.slot = k
+ break
+ }
+ }
+ }
+ if !it.zip && it.slot < 0 {
+ return nil, importRejected(ImportReasonUnknown,
+ "%s(%d 字节)不是 Google Play 版镜像的压缩包 %s,也不是商店的五个文件(%s)之一",
+ in.name(), fi.Size(), filepath.Base(playstore.ImageURL), strings.Join(playStoreNames(), "、"))
+ }
+ items = append(items, it)
+ }
+ return items, nil
+}
+
+// importSpace: need bytes must fit on the root's drive (TestFreeSpaceMB as in a fetch).
+func (f *fetcher) importSpace(need int64) error {
+ drive := filepath.VolumeName(f.r.Dir) + `\`
+ f.where = "查 " + drive + " 的剩余空间"
+ freeBytes, err := diskFree(f.r.Dir)
+ if err != nil {
+ return errs.Wrap(errs.Internal, err, fmt.Sprintf("查不了 %s 的剩余空间:%s", drive, errs.As(err).Message))
+ }
+ free := int64(min(freeBytes, 1<<62))
+ if mb := f.o.TestFreeSpaceMB; mb != nil {
+ free = clampMB(*mb)
+ }
+ if free < need {
+ return errs.Newf(errs.PrecheckFailed, "磁盘空间不够:还需要约 %s,%s 只剩 %s", formatSize(float64(need)), drive, formatSize(float64(free)))
+ }
+ return nil
+}
+
+// readHashed is the hex hash h of the file at p, read with progress (verifying) and a stop at cancel.
+func (f *fetcher) readHashed(ctx context.Context, p string, h hash.Hash, w io.Writer, name string) (string, error) {
+ in, err := os.Open(p)
+ if err != nil {
+ return "", errs.Wrap(errs.Internal, err, "读不了 "+name+":"+err.Error())
+ }
+ defer in.Close()
+ st, err := in.Stat()
+ if err != nil {
+ return "", errs.Wrap(errs.Internal, err, "读不了 "+name+":"+err.Error())
+ }
+ m := newMeter(f.o.Progress, "verifying", name, max(st.Size(), 1), 0)
+ dst := io.Writer(h)
+ if w != nil {
+ dst = io.MultiWriter(h, w)
+ }
+ buf := make([]byte, 1<<20)
+ var done int64
+ for {
+ if ctx.Err() != nil {
+ return "", canceled(ctx)
+ }
+ n, rerr := in.Read(buf)
+ if n > 0 {
+ if _, err := dst.Write(buf[:n]); err != nil {
+ return "", errs.Wrap(errs.Internal, err, "写不了 "+name+" 的副本:"+err.Error())
+ }
+ done += int64(n)
+ m.add(done)
+ }
+ if errors.Is(rerr, io.EOF) {
+ break
+ }
+ if rerr != nil {
+ return "", errs.Wrap(errs.Internal, rerr, "读不了 "+name+":"+rerr.Error())
+ }
+ }
+ m.finish(done)
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// copyHashed copies src to dst (new) and returns the SHA-256 of what was written: the copy is what gets checked, so a
+// file that changes meanwhile cannot slip in.
+func (f *fetcher) copyHashed(ctx context.Context, src, dst, name string) (string, error) {
+ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if err != nil {
+ return "", errs.Wrap(errs.Internal, err, "写不了 "+dst+":"+err.Error())
+ }
+ sum, err := f.readHashed(ctx, src, sha256.New(), out, name)
+ if cerr := out.Close(); err == nil && cerr != nil {
+ err = errs.Wrap(errs.Internal, cerr, "写不了 "+dst+":"+cerr.Error())
+ }
+ return sum, err
+}
diff --git a/internal/components/playstore_windows_test.go b/internal/components/playstore_windows_test.go
new file mode 100644
index 0000000..ff54bdc
--- /dev/null
+++ b/internal/components/playstore_windows_test.go
@@ -0,0 +1,462 @@
+package components
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "modavd/internal/errs"
+ "modavd/internal/host"
+ "modavd/internal/playstore"
+ "modavd/internal/root"
+ "modavd/internal/testutil"
+)
+
+const (
+ storeImage = "the Play image's system.img"
+ storeAPK = "Phonesky.apk from the test image"
+ storeCert = "cert"
+ storeProps = "Pkg.Revision=14\n"
+)
+
+// storeFiles is the test table of the store's files: two, of different sizes, as the real five are.
+func storeFiles() []playstore.File {
+ return []playstore.File{
+ {Name: "Phonesky.apk", Partition: "product", Path: "priv-app/Phonesky/Phonesky.apk", Size: int64(len(storeAPK)), SHA256: sum256(storeAPK)},
+ {Name: "cert.der", Partition: "product", Path: "etc/cert.der", Size: int64(len(storeCert)), SHA256: sum256(storeCert)},
+ }
+}
+
+// storeSpec is the Google Play Store component as in the real table, with the test files: the zip at the Play
+// image's URL (whose last part is the Google APIs image's), its own name in downloads\, the files pinned.
+func storeSpec(data []byte) root.SDKComponent {
+ c := root.SDKComponent{
+ ID: "play-store", Name: root.PlayStoreName, FetchName: "Google Play 商店(测试)", Version: "14",
+ Target: root.PlayStoreDir, URL: "sys-img/google_apis_playstore/x86_64-34_r14.zip",
+ ZipName: "google_apis_playstore-x86_64-34_r14.zip", Size: int64(len(data)), SHA1: sum1(data), ArchiveRoot: "x86_64",
+ KeyHashes: map[string]string{}, ExtractedBytes: 1 << 20, Properties: storeProps,
+ }
+ for _, f := range storeFiles() {
+ c.StatusFiles = append(c.StatusFiles, f.Name)
+ c.KeyFiles = append(c.KeyFiles, f.Name)
+ c.KeyHashes[f.Name] = f.SHA256
+ }
+ return c
+}
+
+func storeZip(t *testing.T) []byte {
+ t.Helper()
+ // system.img not first: only that entry is unpacked
+ return makeZip(t, zentry{"x86_64/vendor.img", "vendor"}, zentry{"x86_64/system.img", storeImage},
+ zentry{"x86_64/kernel-ranchu", "kernel"})
+}
+
+// storeHooks puts the test store component and its files in the catalogue, and an extractor that checks it is given
+// the unpacked system.img (and nothing else of the zip beside it) and writes the files, or fails with fail.
+func storeHooks(t *testing.T, spec root.SDKComponent, fail error) *[]string {
+ t.Helper()
+ oldCat, oldExtract, oldFiles := playStoreCatalogue, playStoreExtract, playStoreFiles
+ t.Cleanup(func() { playStoreCatalogue, playStoreExtract, playStoreFiles = oldCat, oldExtract, oldFiles })
+ playStoreCatalogue = func() root.SDKComponent { return spec }
+ playStoreFiles = storeFiles()
+ var calls []string
+ playStoreExtract = func(img string, files []playstore.File, dir string) error {
+ b, err := os.ReadFile(img)
+ if err != nil || string(b) != storeImage {
+ t.Errorf("extract got %s: %q %v", img, b, err)
+ }
+ if siblings, _ := os.ReadDir(filepath.Dir(img)); len(siblings) != 2 { // system.img and the output directory
+ t.Errorf("staging holds %d entries", len(siblings))
+ }
+ calls = append(calls, filepath.Base(img))
+ if !slices.Equal(files, storeFiles()) {
+ t.Errorf("extract asked for %+v", files)
+ }
+ if fail != nil {
+ return fail
+ }
+ putFile(t, filepath.Join(dir, "Phonesky.apk"), storeAPK)
+ putFile(t, filepath.Join(dir, "cert.der"), storeCert)
+ return nil
+ }
+ return &calls
+}
+
+// entries are the names in dir, sorted; nil when it is not there.
+func entries(dir string) []string {
+ es, err := os.ReadDir(dir)
+ if err != nil {
+ return nil
+ }
+ var out []string
+ for _, e := range es {
+ out = append(out, e.Name())
+ }
+ return out
+}
+
+func storeDir(r *root.Root) string { return filepath.Join(r.SDK(), root.PlayStoreDir) }
+
+// TestFetchPlayStore: with PlayStore the store comes after the required components: its zip under its own name,
+// only the system image unpacked and handed to the extractor; afterwards sdk\playstore holds only the files and
+// source.properties (which says download), and the image, the zip and sdk\.staging are gone. The next run skips it.
+// Without PlayStore it is neither asked for nor touched.
+func TestFetchPlayStore(t *testing.T) {
+ pt, img, store := ptZip(t, adbBody), imgZip(t), storeZip(t)
+ setup(t, ptSpec(pt), imgSpec(img))
+ calls := storeHooks(t, storeSpec(store), nil)
+ m := newMirror(t, map[string][]byte{"pt.zip": pt, "x86_64-test.zip": img, "x86_64-34_r14.zip": store})
+ r := tempRoot(t)
+
+ // not asked for: nothing about the store
+ out := fetch(t, nil, r, opts(m))
+ if out.err != nil {
+ t.Fatal(out.err)
+ }
+ for _, q := range m.zipReqs() {
+ if strings.Contains(q.path, "playstore") {
+ t.Errorf("asked for %s", q.path)
+ }
+ }
+ if len(*calls) != 0 || exists(storeDir(r)) || len(out.res.Components) != 2 {
+ t.Fatalf("without PlayStore: calls %v, components %+v", *calls, out.res.Components)
+ }
+ if st := PlayStoreState(r); st.Ready || st.Source != "" || len(st.Files) != 2 || st.Files[0].Present {
+ t.Errorf("state before: %+v", st)
+ }
+
+ o := opts(m)
+ o.PlayStore = true
+ out = fetch(t, nil, r, o)
+ if out.err != nil {
+ t.Fatal(out.err)
+ }
+ dir := storeDir(r)
+ if got := readText(t, filepath.Join(dir, "Phonesky.apk")); got != storeAPK {
+ t.Errorf("Phonesky.apk = %q", got)
+ }
+ if got := readText(t, filepath.Join(dir, "source.properties")); got != storeProps+"ModAVD.Source=download\n" {
+ t.Errorf("source.properties = %q", got)
+ }
+ if got := entries(dir); !slices.Equal(got, []string{"Phonesky.apk", "cert.der", "source.properties"}) {
+ t.Errorf("sdk\\playstore holds %v, want the files and source.properties", got)
+ }
+ if len(*calls) != 1 || exists(filepath.Join(r.SDK(), ".staging")) {
+ t.Errorf("calls %v, staging left %v", *calls, exists(filepath.Join(r.SDK(), ".staging")))
+ }
+ if got := entries(downloadsDir(r)); !slices.Equal(got, []string{licenseFile}) {
+ t.Errorf("downloads %v", got)
+ }
+ last := out.res.Components[len(out.res.Components)-1]
+ if len(out.res.Components) != 3 || last.ID != "play-store" || !last.Downloaded || !last.Installed || last.Version != "14" ||
+ !out.res.Components[0].Skipped {
+ t.Errorf("components %+v", out.res.Components)
+ }
+ if !out.said("补全 Google 组件(adb、Android 14 系统镜像、Google Play 商店)") ||
+ !strings.Contains(out.notices.String(), "接下来要下载的 Google Play 商店(测试) 由 Google 提供") {
+ t.Errorf("messages %q / %q", out.infos, out.notices.String())
+ }
+ if st := PlayStoreState(r); !st.Ready || st.Source != PlayStoreFromDownload || st.Version != "14" || !st.Files[1].Present ||
+ st.Files[0].Guest != "/product/priv-app/Phonesky/Phonesky.apk" {
+ t.Errorf("state after: %+v", st)
+ }
+
+ // again: complete, skipped
+ n := len(m.all())
+ if out = fetch(t, nil, r, o); out.err != nil || len(m.all()) != n || !out.res.Components[2].Skipped {
+ t.Fatalf("rerun: %v %+v", out.err, out.res)
+ }
+}
+
+// TestFetchPlayStoreRefuses: an extractor that fails, a zip without the system image and a file that does not match
+// its pin are rejected; nothing goes into sdk\playstore and the staging is cleared. KeepDownloads keeps the zip
+// under its own name.
+func TestFetchPlayStoreRefuses(t *testing.T) {
+ store := storeZip(t)
+ setup(t)
+ for _, c := range []struct {
+ name string
+ zip []byte
+ fail error
+ spec func(root.SDKComponent) root.SDKComponent
+ wants string
+ }{
+ {"extract fails", store, errors.New("SHA-256 不对"), nil, "SHA-256 不对"},
+ {"no system.img", makeZip(t, zentry{"x86_64/vendor.img", "vendor"}), nil, nil, "没有 x86_64/system.img"},
+ {"pin", store, nil, func(s root.SDKComponent) root.SDKComponent {
+ s.KeyHashes = map[string]string{"Phonesky.apk": sum256("another"), "cert.der": sum256(storeCert)}
+ return s
+ }, "校验失败"},
+ } {
+ spec := storeSpec(c.zip)
+ if c.spec != nil {
+ spec = c.spec(spec)
+ }
+ storeHooks(t, spec, c.fail)
+ m := newMirror(t, map[string][]byte{"x86_64-34_r14.zip": c.zip})
+ r := tempRoot(t)
+ o := opts(m)
+ o.PlayStore = true
+ out := fetch(t, nil, r, o)
+ wantCode(t, out.err, "components_rejected")
+ if out.err == nil || !strings.Contains(out.err.Error(), c.wants) {
+ t.Errorf("%s: %v", c.name, out.err)
+ }
+ if exists(storeDir(r)) || exists(filepath.Join(r.SDK(), ".staging")) {
+ t.Errorf("%s: sdk\\playstore or the staging left", c.name)
+ }
+ }
+
+ storeHooks(t, storeSpec(store), nil)
+ m := newMirror(t, map[string][]byte{"x86_64-34_r14.zip": store})
+ r := tempRoot(t)
+ o := opts(m)
+ o.PlayStore, o.KeepDownloads = true, true
+ out := fetch(t, nil, r, o)
+ if out.err != nil || !exists(filepath.Join(downloadsDir(r), "google_apis_playstore-x86_64-34_r14.zip")) ||
+ exists(filepath.Join(downloadsDir(r), "x86_64-34_r14.zip")) {
+ t.Fatalf("keep downloads: %v %v", out.err, out.res.Downloads)
+ }
+}
+
+// TestFetchPlayStoreBadZip: a Play image zip of the right size whose SHA-1 is wrong (on every mirror) is refused and
+// not kept: no zip, no .part in downloads\, nothing unpacked or extracted, no sdk\playstore.
+func TestFetchPlayStoreBadZip(t *testing.T) {
+ store := storeZip(t)
+ setup(t)
+ calls := storeHooks(t, storeSpec(store), nil)
+ bad := append([]byte(nil), store...)
+ bad[len(bad)/2] ^= 0xFF
+ m := newMirror(t, map[string][]byte{"x86_64-34_r14.zip": bad})
+ r := tempRoot(t)
+ o := opts(m)
+ o.PlayStore = true
+ out := fetch(t, nil, r, o)
+ wantCode(t, out.err, "components_rejected")
+ for _, e := range entries(downloadsDir(r)) {
+ if e != licenseFile {
+ t.Errorf("downloads\\ keeps %s", e)
+ }
+ }
+ if len(*calls) != 0 || exists(storeDir(r)) || exists(filepath.Join(r.SDK(), ".staging")) {
+ t.Errorf("calls %v, sdk\\playstore %v", *calls, exists(storeDir(r)))
+ }
+}
+
+// ---- import ------------------------------------------------------------------------------------------------------
+
+// userFiles writes files (name -> body) into a fresh folder of the user's and returns their ImportFiles.
+func userFiles(t *testing.T, files ...[2]string) []ImportFile {
+ t.Helper()
+ dir := testutil.TempDir(t)
+ var out []ImportFile
+ for _, f := range files {
+ p := filepath.Join(dir, f[0])
+ putFile(t, p, f[1])
+ out = append(out, ImportFile{Path: p})
+ }
+ return out
+}
+
+func doImport(t *testing.T, r *root.Root, files []ImportFile) (*PlayStoreImportResult, error) {
+ t.Helper()
+ return ImportPlayStore(context.Background(), r, files, Options{})
+}
+
+func reasonOf(err error) string { return errs.DetailsOf(err)[errs.Reason] }
+
+// TestImportPlayStoreFiles: the files themselves, under other names, go in under theirs, checked; source.properties
+// says import; the user's files stay as they were. Importing again finds them complete and changes nothing.
+func TestImportPlayStoreFiles(t *testing.T) {
+ setup(t)
+ calls := storeHooks(t, storeSpec(storeZip(t)), nil)
+ r := tempRoot(t)
+ given := userFiles(t, [2]string{"store (1).apk", storeAPK}, [2]string{"c.bin", storeCert})
+ res, err := doImport(t, r, given)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.From != "files" || res.Skipped || !slices.Equal(res.Used, []string{"store (1).apk", "c.bin"}) || len(res.Kept) != 0 ||
+ !res.Status.Ready || res.Status.Source != PlayStoreFromImport {
+ t.Errorf("result %+v", res)
+ }
+ dir := storeDir(r)
+ if got := entries(dir); !slices.Equal(got, []string{"Phonesky.apk", "cert.der", "source.properties"}) {
+ t.Errorf("sdk\\playstore holds %v", got)
+ }
+ if readText(t, filepath.Join(dir, "cert.der")) != storeCert || !strings.Contains(readText(t, filepath.Join(dir, "source.properties")), "ModAVD.Source=import") {
+ t.Error("files in sdk\\playstore")
+ }
+ for k, body := range []string{storeAPK, storeCert} {
+ if readText(t, given[k].Path) != body {
+ t.Errorf("%s changed", given[k].Path)
+ }
+ }
+ if len(*calls) != 0 || exists(filepath.Join(r.SDK(), ".staging")) || exists(downloadsDir(r)) {
+ t.Errorf("calls %v, staging or downloads\\ left", *calls)
+ }
+ if !strings.Contains(logText(t, r), "用自己的文件补全 Google Play 商店") {
+ t.Error("no log")
+ }
+
+ res, err = doImport(t, r, given[:1])
+ if err != nil || !res.Skipped || !res.Status.Ready {
+ t.Fatalf("again: %+v %v", res, err)
+ }
+}
+
+// TestImportPlayStoreZip: Google's zip (any name) is checked against the size and SHA-1 and read like a download:
+// only its system image unpacked into the staging and handed to the extractor, the files go in, nothing is left in
+// the staging or downloads\, and the user's zip stays.
+func TestImportPlayStoreZip(t *testing.T) {
+ setup(t)
+ store := storeZip(t)
+ calls := storeHooks(t, storeSpec(store), nil)
+ r := tempRoot(t)
+ given := userFiles(t, [2]string{"play.zip", string(store)})
+ res, err := doImport(t, r, given)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.From != "zip" || !res.Status.Ready || res.Status.Source != PlayStoreFromImport || len(*calls) != 1 {
+ t.Errorf("result %+v, calls %v", res, *calls)
+ }
+ if got := entries(storeDir(r)); !slices.Equal(got, []string{"Phonesky.apk", "cert.der", "source.properties"}) {
+ t.Errorf("sdk\\playstore holds %v", got)
+ }
+ if exists(filepath.Join(r.SDK(), ".staging")) || exists(downloadsDir(r)) || readText(t, given[0].Path) != string(store) {
+ t.Error("staging or downloads\\ left, or the zip changed")
+ }
+}
+
+// TestImportPlayStoreRefuses: a file that is neither the zip nor one of the files (by size), one whose SHA-256 or
+// SHA-1 does not match, and a set that misses one are refused with their reasons; nothing is extracted and
+// sdk\playstore stays as it was (absent, or complete with the right files).
+func TestImportPlayStoreRefuses(t *testing.T) {
+ setup(t)
+ store := storeZip(t)
+ calls := storeHooks(t, storeSpec(store), nil)
+ badZip := append([]byte(nil), store...)
+ badZip[10] ^= 0xFF
+ forged := strings.Repeat("x", len(storeAPK)) // the store's size, not its content
+ r := tempRoot(t)
+ for _, c := range []struct {
+ name string
+ files [][2]string
+ reason string
+ wants string
+ }{
+ {"unknown", [][2]string{{"Phonesky.apk", storeAPK + "!"}, {"cert.der", storeCert}}, ImportReasonUnknown, "Phonesky.apk"},
+ {"forged file", [][2]string{{"Phonesky.apk", forged}, {"cert.der", storeCert}}, ImportReasonMismatch, "SHA-256"},
+ {"forged file after a good one", [][2]string{{"cert.der", storeCert}, {"a.apk", forged}}, ImportReasonMismatch, "a.apk"},
+ {"forged zip", [][2]string{{"x86_64-34_r14.zip", string(badZip)}}, ImportReasonMismatch, "SHA-1"},
+ {"good zip, forged file", [][2]string{{"x86_64-34_r14.zip", string(store)}, {"p.apk", forged}}, ImportReasonMismatch, "p.apk"},
+ {"one missing", [][2]string{{"Phonesky.apk", storeAPK}}, ImportReasonIncomplete, "cert.der"},
+ } {
+ _, err := doImport(t, r, userFiles(t, c.files...))
+ if errs.CodeOf(err) != errs.ComponentsRejected || reasonOf(err) != c.reason || !strings.Contains(err.Error(), c.wants) {
+ t.Errorf("%s: %v %v", c.name, err, errs.DetailsOf(err))
+ }
+ if exists(storeDir(r)) || exists(filepath.Join(r.SDK(), ".staging")) || len(*calls) != 0 {
+ t.Fatalf("%s: sdk\\playstore %v, calls %v", c.name, entries(storeDir(r)), *calls)
+ }
+ }
+
+ // complete already: a forged file is still refused, and the right files stay
+ if _, err := doImport(t, r, userFiles(t, [2]string{"a.apk", storeAPK}, [2]string{"c", storeCert})); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := doImport(t, r, userFiles(t, [2]string{"a.apk", forged})); reasonOf(err) != ImportReasonMismatch {
+ t.Errorf("forged over complete: %v", err)
+ }
+ if readText(t, filepath.Join(storeDir(r), "Phonesky.apk")) != storeAPK {
+ t.Error("the store's file was replaced")
+ }
+
+ if _, err := doImport(t, r, nil); errs.CodeOf(err) != errs.Usage {
+ t.Errorf("no file: %v", err)
+ }
+ if _, err := doImport(t, r, []ImportFile{{Path: filepath.Join(testutil.TempDir(t), "gone.apk")}}); errs.CodeOf(err) != errs.NotFound {
+ t.Errorf("a file that is not there: %v", err)
+ }
+ if _, err := doImport(t, r, []ImportFile{{Path: testutil.TempDir(t)}}); reasonOf(err) != ImportReasonUnknown {
+ t.Errorf("a directory: %v", err)
+ }
+}
+
+// TestImportPlayStoreKeeps: files not given are taken from sdk\playstore when they are right; the old directory,
+// incomplete, is set aside.
+func TestImportPlayStoreKeeps(t *testing.T) {
+ setup(t)
+ storeHooks(t, storeSpec(storeZip(t)), nil)
+ r := tempRoot(t)
+ putFile(t, filepath.Join(storeDir(r), "cert.der"), storeCert)
+ putFile(t, filepath.Join(storeDir(r), "Phonesky.apk"), "half")
+ res, err := doImport(t, r, userFiles(t, [2]string{"p.apk", storeAPK}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(res.Kept, []string{"cert.der"}) || !strings.Contains(res.SetAside, ".incomplete-") || !res.Status.Ready {
+ t.Errorf("result %+v", res)
+ }
+ if !exists(filepath.Join(r.Dir, res.SetAside)) {
+ t.Errorf("set aside %s is not there", res.SetAside)
+ }
+}
+
+// TestImportPlayStoreRoot: only on a ModAVD root, one import or fetch at a time.
+func TestImportPlayStoreRoot(t *testing.T) {
+ setup(t)
+ storeHooks(t, storeSpec(storeZip(t)), nil)
+ given := userFiles(t, [2]string{"p.apk", storeAPK}, [2]string{"c", storeCert})
+ notRoot, err := root.New(testutil.TempDir(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := doImport(t, notRoot, given); errs.CodeOf(err) != errs.NotReady {
+ t.Errorf("not a root: %v", err)
+ }
+ r := tempRoot(t)
+ if err := os.MkdirAll(r.LogsDir(), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ l, ok, err := host.TryLock(filepath.Join(r.LogsDir(), "fetch-components.lock"))
+ if err != nil || !ok {
+ t.Fatalf("lock: %v %v", ok, err)
+ }
+ _, err = doImport(t, r, given)
+ l.Unlock()
+ if errs.CodeOf(err) != errs.Busy || exists(storeDir(r)) {
+ t.Errorf("while a fetch runs: %v", err)
+ }
+ fetchGOOS = "linux"
+ if _, err := doImport(t, r, given); errs.CodeOf(err) != errs.Unsupported {
+ t.Errorf("linux: %v", err)
+ }
+}
+
+// TestPlayStoreImportFiles: a directory gives the files in it named like the store's, a file itself; a directory
+// without any of them and a path that is not there are refused.
+func TestPlayStoreImportFiles(t *testing.T) {
+ storeHooks(t, storeSpec(nil), nil)
+ dir := testutil.TempDir(t)
+ putFile(t, filepath.Join(dir, "cert.der"), storeCert)
+ putFile(t, filepath.Join(dir, "notes.txt"), "x")
+ zip := filepath.Join(testutil.TempDir(t), "x.zip")
+ putFile(t, zip, "zip")
+ got, err := PlayStoreImportFiles([]string{dir, zip})
+ if err != nil || len(got) != 2 || got[0].Path != filepath.Join(dir, "cert.der") || got[1].Path != zip {
+ t.Fatalf("%+v %v", got, err)
+ }
+ empty := testutil.TempDir(t)
+ if _, err := PlayStoreImportFiles([]string{empty}); errs.CodeOf(err) != errs.InvalidArgument {
+ t.Errorf("empty folder: %v", err)
+ }
+ if _, err := PlayStoreImportFiles([]string{filepath.Join(empty, "gone")}); errs.CodeOf(err) != errs.NotFound {
+ t.Errorf("gone: %v", err)
+ }
+}
diff --git a/internal/components/progress.go b/internal/components/progress.go
index 9ad16d6..4efa62d 100644
--- a/internal/components/progress.go
+++ b/internal/components/progress.go
@@ -9,7 +9,7 @@ import (
// Progress is one report of a download or an unpack: at most one every progressEvery, plus a final one each.
type Progress struct {
- Stage string // downloading / extracting
+ Stage string // downloading / extracting / verifying (ImportPlayStore reading the given files)
Name string // the component (fetch-components.ps1 display name)
Done int64 // bytes so far (a resumed download counts what was there)
Total int64 // bytes in all
@@ -67,12 +67,20 @@ func (m *meter) add(done int64) {
p.Message = fmt.Sprintf(" %s %5s%% %s / %s %s/s 剩余 %s", m.name, fixed(percent(done, m.total), 1),
formatSize(float64(done)), formatSize(float64(m.total)), formatSize(m.speed), formatDuration(eta))
} else {
- p.Message = fmt.Sprintf(" 解压 %s %5s%% %s / %s", m.name, fixed(percent(done, m.total), 1),
+ p.Message = fmt.Sprintf(" %s %s %5s%% %s / %s", m.verb(), m.name, fixed(percent(done, m.total), 1),
formatSize(float64(done)), formatSize(float64(m.total)))
}
m.report(p)
}
+// verb names what a stage other than downloading does: 校验 (verifying, an import reading the user's files) or 解压.
+func (m *meter) verb() string {
+ if m.stage == "verifying" {
+ return "校验"
+ }
+ return "解压"
+}
+
// finish is the final report.
func (m *meter) finish(done int64) {
if m.report == nil {
@@ -86,7 +94,7 @@ func (m *meter) finish(done int64) {
p.Message = fmt.Sprintf(" %s %5s%% %s / %s 平均 %s/s", m.name, fixed(percent(done, m.total), 1),
formatSize(float64(done)), formatSize(float64(m.total)), formatSize(p.Speed))
} else {
- p.Message = fmt.Sprintf(" 解压 %s 100.0%% %s", m.name, formatSize(float64(done)))
+ p.Message = fmt.Sprintf(" %s %s 100.0%% %s", m.verb(), m.name, formatSize(float64(done)))
}
m.report(p)
}
diff --git a/internal/emu/bridge.go b/internal/emu/bridge.go
index 8306d13..169aa09 100644
--- a/internal/emu/bridge.go
+++ b/internal/emu/bridge.go
@@ -677,6 +677,11 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu
return res, nil
case "uninstall":
+ if m.playStoreWanted(i) {
+ // the store lives in the overlay the bridge's marker makes the emulator boot with (playstore.go)
+ return nil, errs.Newf(errs.NotReady, "实例 %d 装着 Google Play 商店,它离不开执行桥带来的可写系统镜像", i).
+ WithAdvice(fmt.Sprintf("要卸执行桥,先运行 modavd playstore %d uninstall", i))
+ }
// recorded first, so none of the starts below (nor any later one) puts it back
if err := m.setBridgeWanted(i, false); err != nil {
return nil, err
diff --git a/internal/emu/playstore.go b/internal/emu/playstore.go
new file mode 100644
index 0000000..9779f9c
--- /dev/null
+++ b/internal/emu/playstore.go
@@ -0,0 +1,396 @@
+package emu
+
+// The Google Play Store (ModAVD/ModAVD#125, package playstore). Off unless asked for: `modavd playstore install`,
+// the manager's 「补全 Google Play 商店」 on an instance; new instances never get it. Its five files
+// (root.PlayStoreComponent, sdk\playstore) come from the root, put there by `components fetch --play-store` or
+// `components import-playstore` (package components); they are pushed into the guest, where the guest script
+// (guest/scripts/playstore.sh) checks them against the pinned SHA-256 and copies them into the instance's writable
+// system overlay, hiding the image's own placeholder com.android.vending. That overlay exists only on instances with
+// the execution bridge (its boot marker makes the emulator boot writable, bridge.go), so the store needs the bridge.
+// The files take effect at the next boot: an install boots the instance cold once.
+//
+// Uninstalling takes two runs of the script with a cold boot between: the first disables the store (so it cannot
+// update itself again), removes the updates Google Play installed for the store, Play services, GSF and Partner Setup
+// and deletes the files from the overlay's upper layer; the second, after the boot, enables com.android.vending again,
+// by then the image's placeholder.
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "modavd/internal/adb"
+ "modavd/internal/avd"
+ "modavd/internal/errs"
+ "modavd/internal/guest"
+ "modavd/internal/jsonord"
+ "modavd/internal/playstore"
+ "modavd/internal/root"
+)
+
+const (
+ // playStoreMetaKey is instances..playStore in mas-avd.json: true once playstore install put the store in, false
+ // after playstore uninstall. Absent on every instance that never had it.
+ playStoreMetaKey = "playStore"
+ // playStoreGuestDir is where the files and the script are pushed to; removed after each run.
+ playStoreGuestDir = "/data/local/tmp/modavd-playstore"
+ // playStoreScriptTimeout bounds one run of the guest script (the install copies about 230 MB).
+ playStoreScriptTimeout = 5 * time.Minute
+ // playStorePushTimeout bounds the push of one file (Play services is 144 MB).
+ playStorePushTimeout = 5 * time.Minute
+)
+
+// The results of the guest script's runs (the word after RESULT).
+const (
+ playStoreInstalled = "installed"
+ playStorePartial = "partial"
+ playStoreAbsent = "absent"
+ playStoreReboot = "reboot"
+ playStoreGone = "gone"
+ playStoreNotWritable = "not-writable"
+)
+
+// Values of errs.Reason on the store's refusals, for callers that offer the way out (the manager: 补全组件 for the
+// files, the bridge for the rest).
+const (
+ PlayStoreReasonFiles = "playstore_files_missing" // errs.NotReady: the root has not got the five files (or they are damaged)
+ PlayStoreReasonNoBridge = "no_bridge" // errs.NotReady: the instance does not boot with a writable system
+ PlayStoreReasonUnsupported = "unsupported_guest" // errs.Unsupported: an arm64 guest
+)
+
+// PlayStoreResult is the result of `modavd playstore`.
+type PlayStoreResult struct {
+ Index int `json:"index"`
+ Action string `json:"action"`
+ // State is what the guest has after the action: installed (all files in place, the placeholder hidden), absent
+ // (none of them, the placeholder back) or partial; "" when the instance was off and is off again before a check.
+ State string `json:"state"`
+ Installed bool `json:"installed"` // State is installed
+ Files []string `json:"files"` // the guest script's lines about each file and the placeholder
+ Restarted bool `json:"restarted"` // it was shut down and booted cold
+ Stopped bool `json:"stopped"` // it was off before and is off again
+ Warnings []string `json:"warnings"`
+}
+
+// playStoreRun is one run of the guest script.
+type playStoreRun struct {
+ Result string // the word after RESULT, "" when the script did not get there
+ Lines []string // its other output lines
+}
+
+// Hooks for the tests (PlayStore without an emulator).
+var (
+ playStoreFindQemu = (*Manager).findQemu
+ playStoreStart = (*Manager).Start
+ playStoreStop = func(m *Manager, ctx context.Context, i int) ([]string, error) {
+ sr, err := m.Stop(ctx, i, StopOptions{})
+ if err != nil {
+ return nil, err
+ }
+ return sr.Warnings, m.waitQemuGone(ctx, i)
+ }
+ playStoreScript = (*Manager).runPlayStoreScript
+ // playStoreFilesReady: the component in dir is complete (every file's pinned SHA-256).
+ playStoreFilesReady = func(dir string) bool { return root.PlayStoreComponent().Complete(dir) }
+)
+
+// playStoreDir is the store's component directory in the SDK the instances run on.
+func (m *Manager) playStoreDir() string {
+ return filepath.Join(m.Root.RuntimeSDK(), root.PlayStoreDir)
+}
+
+// playStoreReady: the component is there and every file matches its pinned SHA-256; otherwise errs.NotReady
+// (PlayStoreReasonFiles) with the ways to get them.
+func (m *Manager) playStoreReady() error {
+ if playStoreFilesReady(m.playStoreDir()) {
+ return nil
+ }
+ e := errs.Newf(errs.NotReady, "这个根目录还没有 Google Play 商店的五个文件,或者文件不全、已损坏(%s)", m.playStoreDir()).
+ WithAdvice("先在管理器的「补全组件」里勾选「补全 Google Play 商店服务」(或者用自己的文件导入);" +
+ "命令行是 modavd components fetch --play-store(下载约 1.5 GB,只留五个文件)或 modavd components import-playstore <文件>")
+ return errs.WithDetail(e, errs.Reason, PlayStoreReasonFiles)
+}
+
+// playStoreWanted: instances..playStore is true.
+func (m *Manager) playStoreWanted(i int) bool {
+ meta := m.Root.InstanceMeta(i)
+ if meta == nil {
+ return false
+ }
+ v := meta.Get(playStoreMetaKey)
+ if v == nil {
+ return false
+ }
+ b, ok := v.BoolVal()
+ return ok && b
+}
+
+func (m *Manager) setPlayStoreWanted(i int, on bool) error {
+ return m.Root.EditInstanceMeta(i, func(meta *jsonord.Obj) error {
+ meta.Set(playStoreMetaKey, jsonord.NewBool(on))
+ return nil
+ })
+}
+
+// playStoreBridgeError is nil when instance i boots with a writable system (it has the bridge and its marker).
+func (m *Manager) playStoreBridgeError(i int, cfg *avd.Ini) error {
+ if !bridgeGuestSupported(cfg) {
+ return errs.WithDetail(errs.New(errs.Unsupported, "arm64 客体没有 Google Play 商店这一项"),
+ errs.Reason, PlayStoreReasonUnsupported)
+ }
+ if !m.bridgeWanted(i, cfg) || !regularFile(bridgeMarker(m.Root, i)) {
+ e := errs.Newf(errs.NotReady, "实例 %d 没有 arm64 执行桥,不按可写系统镜像开机,放不进 Google Play 商店", i).
+ WithAdvice(fmt.Sprintf("先运行 modavd bridge %d install 装上执行桥,再补全商店", i))
+ return errs.WithDetail(e, errs.Reason, PlayStoreReasonNoBridge)
+ }
+ return nil
+}
+
+// PlayStoreCheck is what PlayStore checks before it touches anything, without a lock or a boot: the host, the
+// action, the instance (there, not being created) and, for install, a writable system (the bridge) and the root's
+// five files (their pinned SHA-256). The manager calls it before it starts the job, so the button hears at once.
+func (m *Manager) PlayStoreCheck(i int, action string) error {
+ _, err := m.playStoreCheck(i, action)
+ return err
+}
+
+func (m *Manager) playStoreCheck(i int, action string) (*avd.Instance, error) {
+ if err := requireWindows(); err != nil {
+ return nil, err
+ }
+ switch action {
+ case "install", "uninstall", "status":
+ default:
+ return nil, errs.Newf(errs.Usage, "只能是 install、uninstall 或 status,收到「%s」", action)
+ }
+ in, err := m.instance(i)
+ if err != nil {
+ return nil, err
+ }
+ if err := m.notCreated(i, "不能装卸 Google Play 商店"); err != nil {
+ return nil, err
+ }
+ if action == "install" {
+ // the bridge first: without it the files would not help
+ if err := m.playStoreBridgeError(i, in.Config); err != nil {
+ return nil, err
+ }
+ if err := m.playStoreReady(); err != nil {
+ return nil, err
+ }
+ }
+ return in, nil
+}
+
+// runPlayStoreScript runs the guest script's action on instance i (running) as root: the script and the file list
+// are pushed first, and for install the component's files too; the push directory is removed afterwards.
+func (m *Manager) runPlayStoreScript(ctx context.Context, i int, action string) (playStoreRun, error) {
+ serial := m.Root.Ports().Serial(i)
+ sh := func(cmd string, timeout time.Duration) adb.Result { return m.shell(ctx, i, cmd, timeout) }
+ if res := sh("su 0 rm -rf "+playStoreGuestDir+" && mkdir -p "+playStoreGuestDir, 0); res.Code != 0 {
+ return playStoreRun{}, errs.Newf(errs.LaunchFailed, "实例 %d 里建不了 %s:%s", i, playStoreGuestDir, tail(res.Out, 300))
+ }
+ defer sh("su 0 rm -rf "+playStoreGuestDir, 0)
+
+ tmp, err := os.MkdirTemp("", "modavd-playstore-")
+ if err != nil {
+ return playStoreRun{}, errs.Wrap(errs.Internal, err, "建不了临时目录")
+ }
+ defer os.RemoveAll(tmp)
+ list := filepath.Join(tmp, "files.txt")
+ if err := os.WriteFile(list, []byte(playstore.Manifest()), 0o644); err != nil {
+ return playStoreRun{}, errs.Wrap(errs.Internal, err, "写不了文件清单")
+ }
+ script, err := guest.LocalFile(filepath.Join(os.TempDir(), "modavd-guest"), guest.PlayStore)
+ if err != nil {
+ return playStoreRun{}, errs.Wrap(errs.Internal, err, "写不了客体脚本 "+guest.PlayStore)
+ }
+ pushes := []string{script, list}
+ if action == "install" {
+ for _, f := range playstore.Files {
+ pushes = append(pushes, filepath.Join(m.playStoreDir(), f.Name))
+ }
+ }
+ for _, local := range pushes {
+ res := m.ADB.Run(ctx, adb.RunOptions{Serial: serial, Timeout: playStorePushTimeout, Reregister: true},
+ "push", local, playStoreGuestDir+"/"+filepath.Base(local))
+ if res.Code != 0 {
+ return playStoreRun{}, errs.Newf(errs.LaunchFailed, "实例 %d 推送 %s 失败:%s", i, filepath.Base(local), tail(res.Out, 300))
+ }
+ }
+ res := sh(fmt.Sprintf("su 0 sh %s/%s %s %s", playStoreGuestDir, guest.PlayStore, action, playStoreGuestDir), playStoreScriptTimeout)
+ run := parsePlayStoreRun(res.Out)
+ if run.Result == "" {
+ return run, errs.Newf(errs.LaunchFailed, "实例 %d 的 Google Play 商店脚本(%s)没有跑完:%s", i, action, tail(res.Out, 300))
+ }
+ return run, nil
+}
+
+// parsePlayStoreRun splits the script's output into its lines and the RESULT word (the last one wins).
+func parsePlayStoreRun(out string) playStoreRun {
+ run := playStoreRun{Lines: []string{}}
+ for _, line := range outputLines(out) {
+ line = strings.TrimSpace(line)
+ switch {
+ case line == "":
+ case strings.HasPrefix(line, "RESULT "):
+ run.Result = strings.TrimSpace(strings.TrimPrefix(line, "RESULT "))
+ default:
+ run.Lines = append(run.Lines, line)
+ }
+ }
+ return run
+}
+
+// fileLines are the lines of a status about the files and the placeholder.
+func fileLines(lines []string) []string {
+ out := []string{}
+ for _, l := range lines {
+ for _, p := range []string{"play ", "image ", "absent ", "stub ", "package "} {
+ if strings.HasPrefix(l, p) {
+ out = append(out, l)
+ break
+ }
+ }
+ }
+ return out
+}
+
+func playStoreFailed(i int, action string, run playStoreRun) error {
+ what := map[string]string{"install": "装", "uninstall": "卸", "status": "查"}[action]
+ msg := fmt.Sprintf("实例 %d 没%s好 Google Play 商店(%s)", i, what, run.Result)
+ if len(run.Lines) > 0 {
+ msg += ":" + run.Lines[len(run.Lines)-1]
+ }
+ if run.Result == playStoreNotWritable {
+ e := errs.New(errs.NotReady, msg).WithAdvice(fmt.Sprintf("实例的系统镜像不可写:先运行 modavd bridge %d install,再试一次", i))
+ return errs.WithDetail(e, errs.Reason, PlayStoreReasonNoBridge)
+ }
+ return errs.New(errs.LaunchFailed, msg)
+}
+
+// PlayStore installs, uninstalls or checks (status) the Google Play Store on instance i. install and uninstall boot an
+// instance that is off and stop it again; their cold boots bring a running instance back the way it ran (window or
+// headless). status needs it running. Everything PlayStoreCheck refuses is refused before the instance is touched.
+func (m *Manager) PlayStore(ctx context.Context, i int, action string) (*PlayStoreResult, error) {
+ if _, err := m.playStoreCheck(i, action); err != nil {
+ return nil, err
+ }
+ // the bridge lock: the store lives in the bridge's overlay, and holding it makes status say busy (bridging), keeps
+ // delete, compact and bridge actions out meanwhile; the starts below run the bridge step under it (bridgeLocked)
+ lock, ok, err := m.tryOperationLock(i, "bridge")
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在装卸执行桥或 Google Play 商店,稍后再试", i),
+ map[string]string{errs.Reason: "locked", errs.Lock: "bridge"})
+ }
+ defer lock.Unlock()
+
+ res := &PlayStoreResult{Index: i, Action: action, Files: []string{}, Warnings: []string{}}
+ p, wasRunning, err := playStoreFindQemu(m, i)
+ if err != nil {
+ return nil, err
+ }
+ windowed := false
+ if wasRunning {
+ windowed, _ = qemuWindowed(p)
+ }
+ if action == "status" {
+ if !wasRunning {
+ return nil, errs.Newf(errs.Unsupported, "实例 %d 没开机", i).WithAdvice(fmt.Sprintf("先 modavd start %d", i))
+ }
+ return res, m.playStoreStatus(ctx, i, res)
+ }
+
+ boot := func() error {
+ sr, err := playStoreStart(m, ctx, i, StartOptions{Window: windowed, bridgeLocked: true})
+ if err != nil {
+ return err
+ }
+ res.Warnings = append(res.Warnings, sr.Warnings...)
+ return nil
+ }
+ restart := func() error {
+ w, err := playStoreStop(m, ctx, i)
+ res.Warnings = append(res.Warnings, w...)
+ if err != nil {
+ return err
+ }
+ res.Restarted = true
+ return boot()
+ }
+ if !wasRunning {
+ if err := boot(); err != nil {
+ return res, err
+ }
+ }
+
+ switch action {
+ case "install":
+ run, err := playStoreScript(m, ctx, i, "install")
+ if err != nil {
+ return res, err
+ }
+ if run.Result != playStoreInstalled {
+ return res, playStoreFailed(i, "install", run)
+ }
+ // recorded once it is in (bridge uninstall refuses from then on)
+ if err := m.setPlayStoreWanted(i, true); err != nil {
+ return res, err
+ }
+ if err := restart(); err != nil {
+ return res, err
+ }
+ case "uninstall":
+ run, err := playStoreScript(m, ctx, i, "uninstall")
+ if err != nil {
+ return res, err
+ }
+ if run.Result == playStoreReboot {
+ if err := restart(); err != nil {
+ return res, err
+ }
+ if run, err = playStoreScript(m, ctx, i, "uninstall"); err != nil {
+ return res, err
+ }
+ }
+ if run.Result != playStoreGone {
+ return res, playStoreFailed(i, "uninstall", run)
+ }
+ if err := m.setPlayStoreWanted(i, false); err != nil {
+ return res, err
+ }
+ }
+ if err := m.playStoreStatus(ctx, i, res); err != nil {
+ return res, err
+ }
+ if !wasRunning {
+ w, err := playStoreStop(m, ctx, i)
+ res.Warnings = append(res.Warnings, w...)
+ if err != nil {
+ return res, err
+ }
+ res.Stopped = true
+ }
+ return res, nil
+}
+
+// playStoreStatus fills res from the guest script's status.
+func (m *Manager) playStoreStatus(ctx context.Context, i int, res *PlayStoreResult) error {
+ run, err := playStoreScript(m, ctx, i, "status")
+ if err != nil {
+ return err
+ }
+ switch run.Result {
+ case playStoreInstalled, playStorePartial, playStoreAbsent:
+ default:
+ return playStoreFailed(i, "status", run)
+ }
+ res.State, res.Installed, res.Files = run.Result, run.Result == playStoreInstalled, fileLines(run.Lines)
+ return nil
+}
diff --git a/internal/emu/playstore_test.go b/internal/emu/playstore_test.go
new file mode 100644
index 0000000..b504fb4
--- /dev/null
+++ b/internal/emu/playstore_test.go
@@ -0,0 +1,319 @@
+//go:build windows
+
+package emu
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "modavd/internal/avd"
+ "modavd/internal/errs"
+ "modavd/internal/host"
+ "modavd/internal/root"
+)
+
+// playStoreHooks replaces what the store's code does to a guest for one test: the guest script (scripted results per
+// action, in order), the qemu scan, starts and stops, and with ready set the component check (without it the real
+// check reads the test root's sdk\playstore).
+type playStoreHooks struct {
+ ready *bool
+ running bool
+ results map[string][]string // action -> RESULT words, in order (the last one repeats)
+ calls []string // "script ", "start", "stop", "ready"
+ starts []StartOptions
+}
+
+func (h *playStoreHooks) install(t *testing.T) {
+ t.Helper()
+ ready, script, find, start, stop := playStoreFilesReady, playStoreScript, playStoreFindQemu, playStoreStart, playStoreStop
+ t.Cleanup(func() {
+ playStoreFilesReady, playStoreScript, playStoreFindQemu, playStoreStart, playStoreStop = ready, script, find, start, stop
+ })
+ real := playStoreFilesReady
+ playStoreFilesReady = func(dir string) bool {
+ h.calls = append(h.calls, "ready")
+ if h.ready == nil {
+ return real(dir)
+ }
+ return *h.ready
+ }
+ used := map[string]int{}
+ playStoreScript = func(_ *Manager, _ context.Context, _ int, action string) (playStoreRun, error) {
+ h.calls = append(h.calls, "script "+action)
+ rs := h.results[action]
+ if len(rs) == 0 {
+ return playStoreRun{}, errs.New(errs.LaunchFailed, "no result scripted for "+action)
+ }
+ k := min(used[action], len(rs)-1)
+ used[action]++
+ return playStoreRun{Result: rs[k], Lines: []string{"play /product/priv-app/Phonesky/Phonesky.apk", "stub hidden", "other"}}, nil
+ }
+ playStoreFindQemu = func(*Manager, int) (host.Process, bool, error) {
+ if h.running {
+ return host.Process{PID: 4242, Name: "qemu-system-x86_64-headless.exe"}, true, nil
+ }
+ return host.Process{}, false, nil
+ }
+ playStoreStart = func(_ *Manager, _ context.Context, _ int, opt StartOptions) (*StartResult, error) {
+ h.calls = append(h.calls, "start")
+ h.starts = append(h.starts, opt)
+ return &StartResult{}, nil
+ }
+ playStoreStop = func(*Manager, context.Context, int) ([]string, error) {
+ h.calls = append(h.calls, "stop")
+ return nil, nil
+ }
+}
+
+func yes() *bool { b := true; return &b }
+
+// guestCalls are the calls that touch the instance (boots, stops, guest script runs).
+func (h *playStoreHooks) guestCalls() []string {
+ var out []string
+ for _, c := range h.calls {
+ if c != "ready" {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+// playStoreInfo is instance i's InstanceInfo.PlayStore, read the way list reads it.
+func playStoreInfo(t *testing.T, m *Manager, i int) bool {
+ t.Helper()
+ in, err := m.instance(i)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m.instanceInfo(context.Background(), in, nil, nil).PlayStore
+}
+
+// TestCreateLeavesPlayStoreAlone: create never puts the store in: no component check, no guest script, no step in
+// the plan, no key in mas-avd.json.
+func TestCreateLeavesPlayStoreAlone(t *testing.T) {
+ m, _ := opsTestManager(t)
+ var h createHooks
+ h.install(t)
+ var ps playStoreHooks
+ ps.ready = yes()
+ ps.install(t)
+ var steps []string
+ i := 6
+ _, err := m.Create(context.Background(), CreateRequest{Index: &i, Progress: func(p Progress) { steps = append(steps, p.Step) }})
+ opsSkipIfNotImplemented(t, err)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ps.calls) != 0 {
+ t.Errorf("store calls %v, want none", ps.calls)
+ }
+ if slices.ContainsFunc(steps, func(s string) bool { return strings.Contains(s, "play") }) {
+ t.Errorf("progress steps %v", steps)
+ }
+ if meta := m.Root.InstanceMeta(6); meta == nil || meta.Get(playStoreMetaKey) != nil {
+ t.Errorf("mas-avd.json has %s", playStoreMetaKey)
+ }
+ if m.playStoreWanted(6) || playStoreInfo(t, m, 6) {
+ t.Error("the store is recorded without asking")
+ }
+}
+
+// TestPlayStoreInstallUninstall: install on an instance that is off boots it, runs the script, boots cold and checks,
+// then stops it; uninstall runs the script, boots cold when it asks for a reboot, runs it again, and records that
+// the store is gone. A running instance comes back as it ran and is left running. InstanceInfo.PlayStore follows.
+func TestPlayStoreInstallUninstall(t *testing.T) {
+ m, i := opsTestInstance(t)
+ writeBridgeMarker(m.Root, i)
+ ctx := context.Background()
+ var ps playStoreHooks
+ ps.ready = yes()
+ ps.results = map[string][]string{"install": {playStoreInstalled}, "status": {playStoreInstalled},
+ "uninstall": {playStoreReboot, playStoreGone}}
+ ps.install(t)
+
+ if err := m.PlayStoreCheck(i, "install"); err != nil {
+ t.Fatalf("check before install: %v", err)
+ }
+ ps.calls = nil
+ res, err := m.PlayStore(ctx, i, "install")
+ if err != nil || !res.Installed || res.State != playStoreInstalled || !res.Restarted || !res.Stopped {
+ t.Fatalf("install: %+v %v", res, err)
+ }
+ if want := []string{"ready", "start", "script install", "stop", "start", "script status", "stop"}; !slices.Equal(ps.calls, want) {
+ t.Errorf("install calls %v, want %v", ps.calls, want)
+ }
+ if !m.playStoreWanted(i) || !playStoreInfo(t, m, i) || len(res.Files) != 2 {
+ t.Errorf("wanted %v, info %v, files %q", m.playStoreWanted(i), playStoreInfo(t, m, i), res.Files)
+ }
+ if _, err := m.Bridge(ctx, i, "uninstall"); errs.CodeOf(err) != errs.NotReady || !strings.Contains(err.Error(), "Google Play") {
+ t.Errorf("bridge uninstall with the store: %v", err)
+ }
+
+ ps.calls, ps.running = nil, true
+ ps.results["status"] = []string{playStoreAbsent}
+ res, err = m.PlayStore(ctx, i, "uninstall")
+ if err != nil || res.Installed || res.State != playStoreAbsent || !res.Restarted || res.Stopped {
+ t.Fatalf("uninstall: %+v %v", res, err)
+ }
+ if want := []string{"script uninstall", "stop", "start", "script uninstall", "script status"}; !slices.Equal(ps.calls, want) {
+ t.Errorf("uninstall calls %v, want %v", ps.calls, want)
+ }
+ if m.playStoreWanted(i) || playStoreInfo(t, m, i) {
+ t.Error("still recorded after uninstall")
+ }
+ for k, opt := range ps.starts {
+ if opt.NoBridge || opt.Headless || !opt.bridgeLocked {
+ t.Errorf("start %d: %+v", k, opt)
+ }
+ }
+}
+
+// TestPlayStoreTakesBridgeLock: the store's actions hold the bridge lock (status says bridging meanwhile, delete and
+// bridge wait), so one held elsewhere makes them busy.
+func TestPlayStoreTakesBridgeLock(t *testing.T) {
+ m, i := opsTestInstance(t)
+ writeBridgeMarker(m.Root, i)
+ var ps playStoreHooks
+ ps.ready = yes()
+ ps.install(t)
+ l, ok, err := host.TryLock(m.lockPath(i, "bridge"))
+ if err != nil || !ok {
+ t.Fatalf("TryLock: %v %v", ok, err)
+ }
+ defer l.Unlock()
+ for _, action := range []string{"install", "uninstall", "status"} {
+ if _, err := m.PlayStore(context.Background(), i, action); errs.CodeOf(err) != errs.Busy {
+ t.Errorf("%s while the bridge lock is held: %v", action, err)
+ }
+ }
+ if calls := ps.guestCalls(); len(calls) != 0 {
+ t.Errorf("calls %v", calls)
+ }
+}
+
+// TestPlayStoreRefused: install needs the bridge and the root's five files (the real check: their pinned SHA-256),
+// status a running instance, the action a known word. Those refusals come before the instance is touched (no boot,
+// no script, nothing recorded), carry the reason the manager offers a way out by, and PlayStoreCheck gives the same.
+// A script that cannot write is not ready, one that stops early a failure; nothing is recorded then either.
+func TestPlayStoreRefused(t *testing.T) {
+ m, i := opsTestInstance(t)
+ ctx := context.Background()
+ var ps playStoreHooks
+ ps.install(t)
+ os.Remove(bridgeMarker(m.Root, i)) // a files-only create placed it
+ untouched := func(what string) {
+ t.Helper()
+ if calls := ps.guestCalls(); len(calls) != 0 {
+ t.Errorf("%s: calls %v", what, calls)
+ }
+ if meta := m.Root.InstanceMeta(i); meta == nil || meta.Get(playStoreMetaKey) != nil {
+ t.Errorf("%s: mas-avd.json has %s", what, playStoreMetaKey)
+ }
+ }
+ reason := func(err error) string { return errs.DetailsOf(err)[errs.Reason] }
+ both := []func() error{
+ func() error { _, err := m.PlayStore(ctx, i, "install"); return err },
+ func() error { return m.PlayStoreCheck(i, "install") },
+ }
+
+ for _, call := range both {
+ if err := call(); errs.CodeOf(err) != errs.NotReady || reason(err) != PlayStoreReasonNoBridge || !strings.Contains(err.Error(), "执行桥") {
+ t.Errorf("without the bridge marker: %v %v", err, errs.DetailsOf(err))
+ }
+ }
+ untouched("without the bridge")
+
+ writeBridgeMarker(m.Root, i)
+ for _, call := range both {
+ err := call()
+ if errs.CodeOf(err) != errs.NotReady || reason(err) != PlayStoreReasonFiles ||
+ !strings.Contains(errs.As(err).Advice, "components import-playstore") {
+ t.Errorf("without the files: %v %v", err, errs.DetailsOf(err))
+ }
+ }
+ untouched("without the files")
+ // files of the right names that are not the store's: still refused
+ dir := filepath.Join(m.Root.RuntimeSDK(), root.PlayStoreDir)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range root.PlayStoreComponent().KeyFiles {
+ os.WriteFile(filepath.Join(dir, name), []byte("not the store"), 0o644)
+ }
+ os.WriteFile(filepath.Join(dir, "source.properties"), []byte("Pkg.Revision=14\n"), 0o644)
+ if _, err := m.PlayStore(ctx, i, "install"); reason(err) != PlayStoreReasonFiles {
+ t.Errorf("with damaged files: %v", err)
+ }
+ untouched("with damaged files")
+
+ if _, err := m.PlayStore(ctx, i, "status"); errs.CodeOf(err) != errs.Unsupported {
+ t.Errorf("status of an instance that is off: %v", err)
+ }
+ if _, err := m.PlayStore(ctx, i, "remove"); errs.CodeOf(err) != errs.Usage {
+ t.Errorf("unknown action: %v", err)
+ }
+ if err := m.PlayStoreCheck(i, "uninstall"); err != nil {
+ t.Errorf("uninstall needs neither the files nor the bridge check: %v", err)
+ }
+ if err := m.PlayStoreCheck(7, "install"); errs.CodeOf(err) != errs.NotFound {
+ t.Errorf("an instance that is not there: %v", err)
+ }
+ untouched("status, unknown action and checks")
+
+ ps.ready = yes()
+ ps.results = map[string][]string{"install": {playStoreNotWritable}}
+ if _, err := m.PlayStore(ctx, i, "install"); errs.CodeOf(err) != errs.NotReady || reason(err) != PlayStoreReasonNoBridge ||
+ !strings.Contains(errs.As(err).Advice, "bridge") {
+ t.Errorf("not writable: %v", err)
+ }
+ ps.results = map[string][]string{"install": {"bad-file"}}
+ if _, err := m.PlayStore(ctx, i, "install"); errs.CodeOf(err) != errs.LaunchFailed {
+ t.Errorf("bad file: %v", err)
+ }
+ ps.results = map[string][]string{"uninstall": {playStoreReboot}}
+ if _, err := m.PlayStore(ctx, i, "uninstall"); err == nil {
+ t.Error("an uninstall that never finishes passed")
+ }
+ if m.playStoreWanted(i) {
+ t.Error("recorded after the failures")
+ }
+}
+
+// TestPlayStoreArm64Guest: an arm64 guest has no store, refused as unsupported.
+func TestPlayStoreArm64Guest(t *testing.T) {
+ m, i := opsTestInstance(t)
+ var ps playStoreHooks
+ ps.ready = yes()
+ ps.install(t)
+ cfg := avd.ConfigPath(m.Root, i)
+ b, err := os.ReadFile(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(cfg, append(b, "\r\nabi.type=arm64-v8a\r\nhw.cpu.arch=arm64\r\n"...), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ err = m.PlayStoreCheck(i, "install")
+ if errs.CodeOf(err) != errs.Unsupported || errs.DetailsOf(err)[errs.Reason] != PlayStoreReasonUnsupported {
+ t.Errorf("arm64: %v %v", err, errs.DetailsOf(err))
+ }
+}
+
+// TestParsePlayStoreRun: the RESULT line is the result (the last wins), the rest are lines; fileLines keeps the file
+// and placeholder lines.
+func TestParsePlayStoreRun(t *testing.T) {
+ run := parsePlayStoreRun("copied /product/a\r\nRESULT failed\n\nplay /product/a\nstub hidden\npackage /x\nRESULT installed\n")
+ if run.Result != playStoreInstalled || len(run.Lines) != 4 {
+ t.Fatalf("%+v", run)
+ }
+ if got := fileLines(run.Lines); !slices.Equal(got, []string{"play /product/a", "stub hidden", "package /x"}) {
+ t.Errorf("fileLines %q", got)
+ }
+ if parsePlayStoreRun("error: closed").Result != "" {
+ t.Error("a result without RESULT")
+ }
+}
diff --git a/internal/emu/status.go b/internal/emu/status.go
index 12a32ca..ffb0578 100644
--- a/internal/emu/status.go
+++ b/internal/emu/status.go
@@ -206,6 +206,7 @@ func (m *Manager) instanceInfo(ctx context.Context, in *avd.Instance, p *host.Pr
info.Renderer = metaText(meta.Get("renderer"))
info.SoftwareRenderer = metaTruthy(meta.Get("softwareRenderer"))
info.AppProxy = appProxySummary(meta)
+ info.PlayStore, _ = meta.Get(playStoreMetaKey).BoolVal()
}
info.Bridge = m.bridgeState(ctx, i, in.Config, info.Booted, info.Initialized)
return info
diff --git a/internal/emu/types.go b/internal/emu/types.go
index c1adb64..62c5507 100644
--- a/internal/emu/types.go
+++ b/internal/emu/types.go
@@ -61,6 +61,9 @@ type InstanceInfo struct {
// instance that is off or still booting and has the bridge's boot marker after its first boot reads unknown, since
// the marker stays while the bridge is installed and also after a failed install.
Bridge string `json:"bridge"`
+ // PlayStore: the Google Play Store was put in (mas-avd.json instances..playStore, set by `playstore
+ // install`, cleared by uninstall; playstore.go). Read from that record only; `playstore status` asks the guest.
+ PlayStore bool `json:"play_store"`
// Creation is "creating" while create sets the instance up (its headless first boot), "failed" when that stopped
// (CreationError says why, CreationLog is that boot's log); "" for a ready instance. See crud.go.
Creation string `json:"creation"`
diff --git a/internal/guest/guest.go b/internal/guest/guest.go
index 22cbf65..582bf7e 100644
--- a/internal/guest/guest.go
+++ b/internal/guest/guest.go
@@ -59,6 +59,8 @@ const (
IdlePoll = "mas-idlepoll.sh"
WifiFastpath = "mas-wifi-fastpath.sh"
VulkanFix = "vulkanfix.sh"
+ // PlayStore installs, checks and removes the Google Play Store (modavd only, not in AUTO-MAS).
+ PlayStore = "playstore.sh"
)
// RemoteDir is where the scripts go in the guest.
diff --git a/internal/guest/playstore_sh_test.go b/internal/guest/playstore_sh_test.go
new file mode 100644
index 0000000..4da3025
--- /dev/null
+++ b/internal/guest/playstore_sh_test.go
@@ -0,0 +1,223 @@
+package guest
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+
+ "modavd/internal/testutil"
+)
+
+// psGuest is a fake guest for playstore.sh under a POSIX sh (Git's on Windows): MODAVD_PS_ROOT is a temporary
+// directory standing for /, and pm, am, remount, chcon and chown are stubs on PATH (pm logs its arguments and answers
+// `pm path` by whether the placeholder is there). The overlay's upper layer is a separate tree, as in the guest.
+type psGuest struct {
+ t *testing.T
+ sh, root string
+ dir string // the push directory
+ env []string
+}
+
+func newPSGuest(t *testing.T) *psGuest {
+ t.Helper()
+ sh, err := exec.LookPath("sh")
+ if err != nil {
+ t.Skip("no sh")
+ }
+ for _, tool := range []string{"sha256sum", "cut", "grep"} {
+ if _, err := exec.LookPath(tool); err != nil {
+ t.Skip("no " + tool)
+ }
+ }
+ base := testutil.TempDir(t)
+ if strings.ContainsAny(base, " \t") {
+ t.Skip("the temporary directory has a space in its path (the guest's never do)")
+ }
+ g := &psGuest{t: t, sh: sh, root: filepath.ToSlash(filepath.Join(base, "root")), dir: filepath.ToSlash(filepath.Join(base, "push"))}
+ bin := filepath.Join(base, "bin")
+ for _, d := range []string{g.dir, bin, g.root + "/product/app/LicenseChecker", g.root + "/system_ext",
+ g.root + "/mnt/scratch/overlay/product/upper", g.root + "/mnt/scratch/overlay/system_ext/upper"} {
+ if err := os.MkdirAll(filepath.FromSlash(d), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ g.write(g.root+"/product/app/LicenseChecker/LicenseChecker.apk", "placeholder")
+ stub := func(name, body string) {
+ if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ stub("pm", `echo "$*" >> "$MODAVD_PS_ROOT/pm.log"
+case "$1" in
+path) if [ -e "$MODAVD_PS_ROOT/product/app/LicenseChecker" ]; then echo package:/product/app/LicenseChecker/LicenseChecker.apk
+ else echo package:/product/priv-app/Phonesky/Phonesky.apk; fi ;;
+uninstall-system-updates) echo Success ;;
+esac`)
+ for _, name := range []string{"am", "remount", "chcon", "chown"} {
+ stub(name, "exit 0")
+ }
+ // the stubs go first on PATH inside the shell (run): Git for Windows' bin\sh.exe puts its usr\bin, which has a real
+ // chcon and chown, before the PATH it is given
+ g.env = append(os.Environ(), "MODAVD_PS_ROOT="+g.root, "MODAVD_PS_STUBS="+filepath.ToSlash(bin))
+ return g
+}
+
+// stubbedSh runs `sh ` with the stubs first on PATH: set by the shell itself, in its own spelling of the
+// directory (pwd: /c/... under Git's sh), as a PATH entry cannot hold a drive letter's colon.
+const stubbedSh = `S=$(cd "$MODAVD_PS_STUBS" && pwd) || exit 97; PATH="$S:$PATH"; export PATH; exec sh "$@"`
+
+func (g *psGuest) write(p, data string) {
+ g.t.Helper()
+ if err := os.MkdirAll(filepath.Dir(filepath.FromSlash(p)), 0o755); err != nil {
+ g.t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.FromSlash(p), []byte(data), 0o644); err != nil {
+ g.t.Fatal(err)
+ }
+}
+
+func (g *psGuest) read(p string) (string, bool) {
+ b, err := os.ReadFile(filepath.FromSlash(p))
+ return string(b), err == nil
+}
+
+// files puts the store's (test) files and their list into the push directory; tamper changes one after listing it.
+func (g *psGuest) files(tamper bool) {
+ list := ""
+ for _, f := range [][3]string{{"Phonesky.apk", "/product/priv-app/Phonesky/Phonesky.apk", "store"},
+ {"PrebuiltGmsCore.apk", "/product/priv-app/PrebuiltGmsCore/PrebuiltGmsCore.apk", "play services"},
+ {"GoogleServicesFramework.apk", "/system_ext/priv-app/GoogleServicesFramework/GoogleServicesFramework.apk", "gsf"}} {
+ s := sha256.Sum256([]byte(f[2]))
+ list += f[0] + " " + f[1] + " " + hex.EncodeToString(s[:]) + "\n"
+ g.write(g.dir+"/"+f[0], f[2])
+ }
+ if tamper {
+ g.write(g.dir+"/PrebuiltGmsCore.apk", "something else")
+ }
+ g.write(g.dir+"/files.txt", list)
+}
+
+// TestPlayStoreScriptEmbedded: the script is embedded without CR (Android's sh chokes on CRLF).
+func TestPlayStoreScriptEmbedded(t *testing.T) {
+ b, err := Script(PlayStore)
+ if err != nil || len(b) == 0 || strings.ContainsRune(string(b), '\r') {
+ t.Fatalf("%d bytes, %v", len(b), err)
+ }
+}
+
+// run is the script's action: its RESULT word and its whole output.
+func (g *psGuest) run(action string) (string, string) {
+ g.t.Helper()
+ b, err := Script(PlayStore)
+ if err != nil {
+ g.t.Fatal(err)
+ }
+ script := filepath.Join(filepath.FromSlash(g.dir), "playstore.sh")
+ if err := os.WriteFile(script, b, 0o644); err != nil {
+ g.t.Fatal(err)
+ }
+ cmd := exec.Command(g.sh, "-c", stubbedSh, "sh", filepath.ToSlash(script), action, g.dir)
+ cmd.Env = g.env
+ out, _ := cmd.CombinedOutput()
+ result := ""
+ for _, line := range strings.Split(strings.ReplaceAll(string(out), "\r", ""), "\n") {
+ if w, ok := strings.CutPrefix(line, "RESULT "); ok {
+ result = w
+ }
+ }
+ return result, string(out)
+}
+
+// TestPlayStoreScript goes through the guest script: a tampered file installs nothing; the files go in and the
+// placeholder is hidden; status reads installed; uninstall removes the overlay's copies and the placeholder's
+// whiteout, asks for a reboot, and the run after it finds the placeholder back and enables it.
+func TestPlayStoreScript(t *testing.T) {
+ g := newPSGuest(t)
+
+ g.files(true)
+ if r, out := g.run("install"); r != "bad-file" {
+ t.Fatalf("tampered: %s\n%s", r, out)
+ }
+ if _, ok := g.read(g.root + "/product/priv-app/Phonesky/Phonesky.apk"); ok {
+ t.Fatal("a file was copied although another one did not match")
+ }
+ if _, ok := g.read(g.root + "/product/app/LicenseChecker/LicenseChecker.apk"); !ok {
+ t.Fatal("the placeholder went although nothing was installed")
+ }
+
+ g.files(false)
+ if r, out := g.run("install"); r != "installed" {
+ t.Fatalf("install: %s\n%s", r, out)
+ }
+ if got, _ := g.read(g.root + "/system_ext/priv-app/GoogleServicesFramework/GoogleServicesFramework.apk"); got != "gsf" {
+ t.Errorf("GSF in place: %q", got)
+ }
+ if _, ok := g.read(g.root + "/product/app/LicenseChecker/LicenseChecker.apk"); ok {
+ t.Error("the placeholder is still there")
+ }
+ if log, _ := g.read(g.root + "/pm.log"); !strings.Contains(log, "enable com.android.vending") {
+ t.Errorf("pm calls: %q", log)
+ }
+ if r, out := g.run("status"); r != "installed" || !strings.Contains(out, "play /product/priv-app/Phonesky/Phonesky.apk") ||
+ !strings.Contains(out, "stub hidden") {
+ t.Errorf("status: %s\n%s", r, out)
+ }
+
+ // in the guest the copies are in the overlay's upper layer and the hidden placeholder is a whiteout there
+ up := g.root + "/mnt/scratch/overlay"
+ // an uninstall before any boot since the install: the package manager still has the placeholder, yet the files
+ // just removed are only gone after a boot, so it asks for one
+ g.write(up+"/product/upper/priv-app/Phonesky/Phonesky.apk", "store")
+ g.write(g.root+"/product/app/LicenseChecker/LicenseChecker.apk", "placeholder")
+ if r, out := g.run("uninstall"); r != "reboot" {
+ t.Fatalf("uninstall before a boot: %s\n%s", r, out)
+ }
+ os.RemoveAll(filepath.FromSlash(g.root + "/product/app/LicenseChecker"))
+ g.write(up+"/product/upper/priv-app/Phonesky/Phonesky.apk", "store")
+ g.write(up+"/system_ext/upper/priv-app/GoogleServicesFramework/GoogleServicesFramework.apk", "gsf")
+ g.write(up+"/product/upper/app/LicenseChecker", "")
+ if r, out := g.run("uninstall"); r != "reboot" || !strings.Contains(out, "removed updates of com.android.vending") {
+ t.Fatalf("uninstall: %s\n%s", r, out)
+ }
+ for _, p := range []string{"/product/upper/priv-app/Phonesky/Phonesky.apk", "/product/upper/app/LicenseChecker",
+ "/system_ext/upper/priv-app/GoogleServicesFramework/GoogleServicesFramework.apk"} {
+ if _, ok := g.read(up + p); ok {
+ t.Errorf("%s left in the upper layer", p)
+ }
+ }
+ if log, _ := g.read(g.root + "/pm.log"); !strings.Contains(log, "disable com.android.vending") {
+ t.Errorf("pm calls: %q", log)
+ }
+ // after the cold boot: the placeholder is back (the overlay without the whiteout)
+ g.write(g.root+"/product/app/LicenseChecker/LicenseChecker.apk", "placeholder")
+ os.Remove(filepath.FromSlash(g.root + "/pm.log"))
+ if r, out := g.run("uninstall"); r != "gone" {
+ t.Fatalf("second uninstall: %s\n%s", r, out)
+ }
+ if log, _ := g.read(g.root + "/pm.log"); !strings.HasSuffix(strings.TrimSpace(log), "enable com.android.vending") {
+ t.Errorf("pm calls of the second run: %q", log)
+ }
+}
+
+// TestPlayStoreScriptNotWritable: without a writable product partition nothing is checked or copied.
+func TestPlayStoreScriptNotWritable(t *testing.T) {
+ if runtime.GOOS != "windows" && os.Geteuid() == 0 {
+ t.Skip("root writes anywhere")
+ }
+ g := newPSGuest(t)
+ g.files(false)
+ // a file where the partition's directory should be: touch inside it fails
+ os.RemoveAll(filepath.FromSlash(g.root + "/product"))
+ g.write(g.root+"/product", "not a directory")
+ if r, out := g.run("install"); r != "not-writable" {
+ t.Fatalf("install: %s\n%s", r, out)
+ }
+ if _, ok := g.read(g.root + "/system_ext/priv-app/GoogleServicesFramework/GoogleServicesFramework.apk"); ok {
+ t.Error("copied into a partition that is not writable")
+ }
+}
diff --git a/internal/guest/scripts/playstore.sh b/internal/guest/scripts/playstore.sh
new file mode 100644
index 0000000..e31e92a
--- /dev/null
+++ b/internal/guest/scripts/playstore.sh
@@ -0,0 +1,119 @@
+#!/system/bin/sh
+# ModAVD: the Google Play Store of an instance (ModAVD/ModAVD#125), run as root by modavd (internal/emu/playstore.go):
+# sh playstore.sh install|status|uninstall
+# holds files.txt (" " per line) and, for install, the files it names. The files
+# go into the instance's writable system overlay (adb remount's, which only instances with the execution bridge boot
+# with); the image's own com.android.vending, a license-check placeholder, is hidden there. Each run ends with one
+# line "RESULT " that modavd reads:
+# install: installed | not-writable | bad-file | failed
+# status: installed | partial | absent
+# uninstall: reboot (cold boot, then run uninstall again) | gone | not-writable | failed
+# Exit code 0 unless the result is a failure. MODAVD_PS_ROOT prefixes every absolute path (tests only; unset in the
+# guest).
+R=${MODAVD_PS_ROOT:-}
+D=$2
+LIST=$D/files.txt
+STUB=/product/app/LicenseChecker
+U=$R/mnt/scratch/overlay
+PKG=com.android.vending
+
+result() { echo "RESULT $1"; exit "${2:-0}"; }
+
+[ -f "$LIST" ] || { echo "no $LIST"; result failed 1; }
+
+sum() { sha256sum "$1" 2>/dev/null | cut -d' ' -f1; }
+
+writable() {
+ remount >/dev/null 2>&1
+ for p in product system_ext; do
+ if ! touch "$R/$p/.modavd-w" 2>/dev/null; then
+ echo "/$p is not writable"
+ result not-writable 3
+ fi
+ rm -f "$R/$p/.modavd-w"
+ done
+}
+
+# state prints a line per file (play: the store's copy is there; image: another file; absent) and the placeholder's,
+# and sets STATE.
+state() {
+ while read -r name target want; do
+ [ -n "$name" ] || continue
+ got=$(sum "$R$target")
+ if [ "$got" = "$want" ]; then echo "play $target"
+ elif [ -n "$got" ]; then echo "image $target"
+ else echo "absent $target"; fi
+ done < "$LIST" > "$D/state.txt"
+ cat "$D/state.txt"
+ n=$(grep -c . "$D/state.txt"); ok=$(grep -c '^play ' "$D/state.txt")
+ if [ -e "$R$STUB" ]; then echo "stub present"; stub=1; else echo "stub hidden"; stub=0; fi
+ p=$(pm path $PKG 2>/dev/null | head -1); echo "package ${p#package:}"
+ if [ "$ok" = "$n" ] && [ "$stub" = 0 ]; then STATE=installed
+ elif [ "$ok" = 0 ] && [ "$stub" = 1 ]; then STATE=absent
+ else STATE=partial; fi
+}
+
+case "$1" in
+install)
+ writable
+ while read -r name target want; do
+ [ -n "$name" ] || continue
+ got=$(sum "$D/$name")
+ if [ "$got" != "$want" ]; then
+ echo "$name: sha256 ${got:-missing}, want $want"
+ result bad-file 4
+ fi
+ done < "$LIST"
+ while read -r name target want; do
+ [ -n "$name" ] || continue
+ t=$R$target; dir=${t%/*}
+ if [ ! -d "$dir" ]; then
+ mkdir -p "$dir" && chmod 755 "$dir" && chcon u:object_r:system_file:s0 "$dir" || result failed 1
+ fi
+ cp "$D/$name" "$t.new" && chown root:root "$t.new" && chmod 644 "$t.new" &&
+ chcon u:object_r:system_file:s0 "$t.new" && mv "$t.new" "$t" || result failed 1
+ echo "copied $target"
+ done < "$LIST"
+ rm -rf "$R$STUB" && echo "hid $STUB"
+ pm enable $PKG >/dev/null 2>&1
+ sync
+ state
+ [ "$STATE" = installed ] || result failed 1
+ result installed
+ ;;
+status)
+ state
+ result "$STATE"
+ ;;
+uninstall)
+ # disabled first, so the store cannot update itself again before the shutdown; the run after the cold boot
+ # enables com.android.vending again (the placeholder by then)
+ am force-stop $PKG >/dev/null 2>&1
+ pm disable $PKG >/dev/null 2>&1
+ for p in $PKG com.google.android.gms com.google.android.gsf com.google.android.partnersetup; do
+ case "$(pm uninstall-system-updates $p 2>&1)" in *Success*) echo "removed updates of $p" ;; esac
+ done
+ writable
+ [ -d "$U" ] || { echo "no $U"; result failed 1; }
+ left=0
+ while read -r name target want; do
+ [ -n "$name" ] || continue
+ rest=${target#/}; part=${rest%%/*}; rest=${rest#*/}
+ if [ -e "$U/$part/upper/$rest" ]; then rm -f "$U/$part/upper/$rest" && echo "removed $target"; left=1; fi
+ done < "$LIST"
+ rmdir "$U/product/upper/priv-app/Phonesky" 2>/dev/null
+ if [ -e "$U/product/upper/app/LicenseChecker" ]; then
+ rm -f "$U/product/upper/app/LicenseChecker" && echo "restored $STUB"; left=1
+ fi
+ sync
+ [ $left = 1 ] && result reboot
+ case "$(pm path $PKG 2>/dev/null | head -1)" in
+ "package:$STUB/"*) pm enable $PKG >/dev/null 2>&1; result gone ;;
+ esac
+ result reboot
+ ;;
+*)
+ echo "usage: sh $0 install|status|uninstall "
+ result failed 2
+ ;;
+esac
diff --git a/internal/playstore/files.go b/internal/playstore/files.go
new file mode 100644
index 0000000..5568a53
--- /dev/null
+++ b/internal/playstore/files.go
@@ -0,0 +1,79 @@
+// Package playstore is the Google Play Store a ModAVD instance can have (ModAVD/ModAVD#125). The instances run
+// Google's "Google APIs" image, which has Google Play services but no store; the store comes from Google's "Google
+// Play" image of the same release (system-images;android-34;google_apis_playstore;x86_64 r14), downloaded on the
+// user's computer like the other Google components and never shipped by ModAVD.
+//
+// The store alone does not work there: the Google APIs image signs Play services and the Google Services Framework
+// with the Android test certificate, the store is signed by Google, and Google's components check each other's
+// signature. So the files taken are the store and the Google-signed copies of Play services, GSF and Partner Setup
+// (same versions as the image's own; Partner Setup writes GSF settings and crashes at every boot otherwise), plus the
+// store's fs-verity certificate. They go into the instance's writable system overlay, which only instances with the
+// execution bridge have; the image's own com.android.vending, a license-check placeholder, is hidden there.
+//
+// Files is the table of those files; Extract takes them out of the Play image's system.img (GPT, then the dynamic
+// partitions of its super partition, then ext4) without mounting anything.
+package playstore
+
+import "path"
+
+// The Play image the files come from, as Google's repository lists it (sys-img/google_apis_playstore/sys-img2-3.xml).
+const (
+ ImageURL = "sys-img/google_apis_playstore/x86_64-34_r14.zip" // relative to a mirror's base URL
+ ImageSize = 1510752654
+ ImageSHA1 = "01d32617fd1937e540faf8731d70cf50e35af854"
+ // ImageMember is the system image inside the zip, ImageMemberSize its size.
+ ImageMember = "x86_64/system.img"
+ ImageMemberSize = 3101687808
+ // Revision is Pkg.Revision of the image; the instances' Google APIs image is r14 too.
+ Revision = "14"
+)
+
+// File is one file of the store.
+type File struct {
+ Name string // its name on the host (sdk\playstore\) and where it is pushed to in the guest
+ Partition string // the dynamic partition it is on: product or system_ext
+ Path string // its path inside that partition
+ Size int64
+ SHA256 string // lower hex
+}
+
+// Guest is where the file goes in the instance: //.
+func (f File) Guest() string { return "/" + f.Partition + "/" + f.Path }
+
+// Files are the store's files, all from the r14 Play image (sizes and SHA-256 as found there).
+var Files = []File{
+ {"Phonesky.apk", "product", "priv-app/Phonesky/Phonesky.apk", 74664153,
+ "2616771999b1cde77fd000ae17a43784dded1f76b21ec12881137c84d5313dd2"},
+ {"PrebuiltGmsCore.apk", "product", "priv-app/PrebuiltGmsCore/PrebuiltGmsCore.apk", 144060779,
+ "63aaf087cdfad32fd9eca45f36966afe2f44db7e125e099f3e344ca99cee13c2"},
+ {"GoogleServicesFramework.apk", "system_ext", "priv-app/GoogleServicesFramework/GoogleServicesFramework.apk", 5744939,
+ "58999d24e4dbdde61f90b59a89c86edabc69e0908ddaaa2742091493d7e3b975"},
+ {"PartnerSetupPrebuilt.apk", "product", "priv-app/PartnerSetupPrebuilt/PartnerSetupPrebuilt.apk", 726118,
+ "20190a60924f36baae9ef9b7687691c445dd9a764b42c4751474293baf5ea15c"},
+ {"play_store_fsi_cert.der", "product", "etc/security/fsverity/play_store_fsi_cert.der", 1422,
+ "37aabd485044e3af6d84755268faf8392e226ca0d188be6a80335525ce0ec6ff"},
+}
+
+// Stub is the Google APIs image's own com.android.vending, a license-check placeholder the store replaces.
+const Stub = "/product/app/LicenseChecker"
+
+// Package is the store's package name.
+const Package = "com.android.vending"
+
+// ExtractedBytes is what the files take on disk.
+func ExtractedBytes() int64 {
+ var n int64
+ for _, f := range Files {
+ n += f.Size
+ }
+ return n
+}
+
+// Manifest is the file list the guest script reads: " " per line, LF.
+func Manifest() string {
+ s := ""
+ for _, f := range Files {
+ s += f.Name + " " + path.Clean(f.Guest()) + " " + f.SHA256 + "\n"
+ }
+ return s
+}
diff --git a/internal/playstore/image.go b/internal/playstore/image.go
new file mode 100644
index 0000000..4d1d7b0
--- /dev/null
+++ b/internal/playstore/image.go
@@ -0,0 +1,565 @@
+package playstore
+
+// Reading files out of an Android emulator system.img, read-only: a GPT disk whose "super" partition holds the
+// dynamic (logical) partitions (liblp metadata), each an ext4 file system. Only what these images use is supported:
+// linear extents on the super partition itself, ext4 with extents (no inline data, no meta_bg). Anything else is an
+// error, never a guess; the callers check every file against its pinned size and SHA-256 anyway.
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode/utf16"
+)
+
+// Extract writes files (taken from the system image img) into dir as dir\, each checked against its size and
+// SHA-256 before it is renamed into place from a .part file. Files already there are rewritten. dir must exist.
+func Extract(img string, files []File, dir string) error {
+ f, err := os.Open(img)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ st, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ parts, err := dynamicPartitions(f, st.Size())
+ if err != nil {
+ return err
+ }
+ fss := map[string]*ext4{}
+ for _, file := range files {
+ fs := fss[file.Partition]
+ if fs == nil {
+ p, ok := parts[file.Partition]
+ if !ok {
+ return fmt.Errorf("镜像里没有 %s 分区", file.Partition)
+ }
+ if fs, err = openExt4(p); err != nil {
+ return fmt.Errorf("%s 分区:%w", file.Partition, err)
+ }
+ fss[file.Partition] = fs
+ }
+ if err := extractOne(fs, file, dir); err != nil {
+ return fmt.Errorf("%s:%w", file.Guest(), err)
+ }
+ }
+ return nil
+}
+
+func extractOne(fs *ext4, file File, dir string) error {
+ ino, err := fs.lookup(file.Path)
+ if err != nil {
+ return err
+ }
+ in, err := fs.inode(ino)
+ if err != nil {
+ return err
+ }
+ if in.mode&0xF000 != 0x8000 {
+ return errors.New("不是普通文件")
+ }
+ if in.size != file.Size {
+ return fmt.Errorf("大小是 %d,应为 %d", in.size, file.Size)
+ }
+ dst := filepath.Join(dir, file.Name)
+ tmp := dst + ".part"
+ out, err := os.Create(tmp)
+ if err != nil {
+ return err
+ }
+ h := sha256.New()
+ _, err = fs.copyData(in, io.MultiWriter(out, h))
+ if cerr := out.Close(); err == nil {
+ err = cerr
+ }
+ if err == nil {
+ if got := hex.EncodeToString(h.Sum(nil)); got != file.SHA256 {
+ err = fmt.Errorf("SHA-256 是 %s,应为 %s", got, file.SHA256)
+ }
+ }
+ if err != nil {
+ os.Remove(tmp)
+ return err
+ }
+ return os.Rename(tmp, dst)
+}
+
+// ---- GPT and liblp ------------------------------------------------------------------------------------------------
+
+const sector = 512
+
+// gptPartition is the byte range of the GPT partition called name.
+func gptPartition(r io.ReaderAt, size int64, name string) (off, length int64, err error) {
+ h := make([]byte, 92)
+ if _, err := r.ReadAt(h, sector); err != nil {
+ return 0, 0, fmt.Errorf("读不了 GPT 头:%w", err)
+ }
+ if string(h[:8]) != "EFI PART" {
+ return 0, 0, errors.New("不是 GPT 磁盘镜像")
+ }
+ entries := int64(binary.LittleEndian.Uint64(h[72:]))
+ count := binary.LittleEndian.Uint32(h[80:])
+ esize := binary.LittleEndian.Uint32(h[84:])
+ if esize < 128 || esize > 4096 || count > 1024 {
+ return 0, 0, errors.New("GPT 分区表不对")
+ }
+ table := make([]byte, int(count)*int(esize))
+ if _, err := r.ReadAt(table, entries*sector); err != nil {
+ return 0, 0, fmt.Errorf("读不了 GPT 分区表:%w", err)
+ }
+ for k := 0; k < int(count); k++ {
+ e := table[k*int(esize):]
+ if bytes.Equal(e[:16], make([]byte, 16)) {
+ continue
+ }
+ u := make([]uint16, 36)
+ for j := range u {
+ u[j] = binary.LittleEndian.Uint16(e[56+2*j:])
+ }
+ if strings.TrimRight(string(utf16.Decode(u)), "\x00") != name {
+ continue
+ }
+ first, last := int64(binary.LittleEndian.Uint64(e[32:])), int64(binary.LittleEndian.Uint64(e[40:]))
+ off, length = first*sector, (last-first+1)*sector
+ if first <= 0 || last < first || off+length > size {
+ return 0, 0, fmt.Errorf("GPT 分区 %s 的范围不对", name)
+ }
+ return off, length, nil
+ }
+ return 0, 0, fmt.Errorf("GPT 里没有 %s 分区", name)
+}
+
+const (
+ lpReserved = 4096 // LP_PARTITION_RESERVED_BYTES
+ lpGeometrySize = 4096 // LP_METADATA_GEOMETRY_SIZE, twice (primary and backup)
+ lpGeometryMagic = 0x616c4467
+ lpHeaderMagic = 0x414c5030
+ lpTargetLinear = 0
+)
+
+// extent is part of a logical partition: length bytes at off in the image, starting at byte at of the partition.
+type extent struct{ at, off, length int64 }
+
+// partition is a logical partition, readable as one range of bytes.
+type partition struct {
+ r io.ReaderAt
+ extents []extent
+ size int64
+}
+
+func (p *partition) ReadAt(b []byte, off int64) (int, error) {
+ n := 0
+ for n < len(b) {
+ pos := off + int64(n)
+ if pos >= p.size {
+ return n, io.EOF
+ }
+ var e *extent
+ for k := range p.extents {
+ if x := &p.extents[k]; pos >= x.at && pos < x.at+x.length {
+ e = x
+ break
+ }
+ }
+ if e == nil {
+ return n, errors.New("分区里有没映射的范围")
+ }
+ chunk := min(int64(len(b)-n), e.at+e.length-pos)
+ m, err := p.r.ReadAt(b[n:n+int(chunk)], e.off+pos-e.at)
+ n += m
+ if err != nil {
+ return n, err
+ }
+ }
+ return n, nil
+}
+
+// dynamicPartitions are the logical partitions of the image's super partition, by name, from slot 0 of the primary
+// metadata (both checksums verified).
+func dynamicPartitions(r io.ReaderAt, size int64) (map[string]*partition, error) {
+ base, superLen, err := gptPartition(r, size, "super")
+ if err != nil {
+ return nil, err
+ }
+ g := make([]byte, 52)
+ if _, err := r.ReadAt(g, base+lpReserved); err != nil {
+ return nil, fmt.Errorf("读不了动态分区的几何信息:%w", err)
+ }
+ if binary.LittleEndian.Uint32(g) != lpGeometryMagic {
+ return nil, errors.New("super 分区里没有动态分区信息")
+ }
+ gsize := binary.LittleEndian.Uint32(g[4:])
+ if gsize < 52 || gsize > lpGeometrySize {
+ return nil, errors.New("动态分区的几何信息不对")
+ }
+ gb := make([]byte, gsize)
+ if _, err := r.ReadAt(gb, base+lpReserved); err != nil {
+ return nil, err
+ }
+ if !checksumOK(gb, 8) {
+ return nil, errors.New("动态分区的几何信息校验和不对")
+ }
+ metaMax := int64(binary.LittleEndian.Uint32(g[40:]))
+ hdrAt := base + lpReserved + 2*lpGeometrySize
+ h := make([]byte, 128)
+ if _, err := r.ReadAt(h, hdrAt); err != nil {
+ return nil, fmt.Errorf("读不了动态分区表:%w", err)
+ }
+ if binary.LittleEndian.Uint32(h) != lpHeaderMagic {
+ return nil, errors.New("动态分区表的头不对")
+ }
+ hsize := int64(binary.LittleEndian.Uint32(h[8:]))
+ tsize := int64(binary.LittleEndian.Uint32(h[44:]))
+ if hsize < 128 || hsize > 4096 || tsize <= 0 || hsize+tsize > metaMax || metaMax > superLen {
+ return nil, errors.New("动态分区表的大小不对")
+ }
+ hb := make([]byte, hsize)
+ if _, err := r.ReadAt(hb, hdrAt); err != nil {
+ return nil, err
+ }
+ if !checksumOK(hb, 12) {
+ return nil, errors.New("动态分区表头的校验和不对")
+ }
+ t := make([]byte, tsize)
+ if _, err := r.ReadAt(t, hdrAt+hsize); err != nil {
+ return nil, err
+ }
+ if sum := sha256.Sum256(t); !bytes.Equal(sum[:], hb[48:80]) {
+ return nil, errors.New("动态分区表的校验和不对")
+ }
+ desc := func(at int) (off, n, esize int) {
+ return int(binary.LittleEndian.Uint32(hb[at:])), int(binary.LittleEndian.Uint32(hb[at+4:])),
+ int(binary.LittleEndian.Uint32(hb[at+8:]))
+ }
+ po, pn, pe := desc(80)
+ eo, en, ee := desc(92)
+ if pe < 52 || ee < 24 || po+pn*pe > len(t) || eo+en*ee > len(t) {
+ return nil, errors.New("动态分区表的表项不对")
+ }
+ out := map[string]*partition{}
+ for k := 0; k < pn; k++ {
+ e := t[po+k*pe:]
+ name := string(bytes.TrimRight(e[:36], "\x00"))
+ first, num := int(binary.LittleEndian.Uint32(e[40:])), int(binary.LittleEndian.Uint32(e[44:]))
+ if first+num > en {
+ return nil, fmt.Errorf("动态分区 %s 的范围表不对", name)
+ }
+ p := &partition{r: r}
+ for j := first; j < first+num; j++ {
+ x := t[eo+j*ee:]
+ sectors := int64(binary.LittleEndian.Uint64(x))
+ typ := binary.LittleEndian.Uint32(x[8:])
+ data := int64(binary.LittleEndian.Uint64(x[12:]))
+ src := binary.LittleEndian.Uint32(x[20:])
+ if typ != lpTargetLinear || src != 0 {
+ return nil, fmt.Errorf("动态分区 %s 有不支持的范围(类型 %d,设备 %d)", name, typ, src)
+ }
+ if data < 0 || sectors <= 0 || (data+sectors)*sector > superLen {
+ return nil, fmt.Errorf("动态分区 %s 的范围超出 super 分区", name)
+ }
+ p.extents = append(p.extents, extent{at: p.size, off: base + data*sector, length: sectors * sector})
+ p.size += sectors * sector
+ }
+ out[name] = p
+ }
+ return out, nil
+}
+
+// checksumOK: the SHA-256 of b, with its 32 checksum bytes at at zeroed, is those bytes.
+func checksumOK(b []byte, at int) bool {
+ c := append([]byte(nil), b...)
+ want := append([]byte(nil), c[at:at+32]...)
+ clear(c[at : at+32])
+ sum := sha256.Sum256(c)
+ return bytes.Equal(sum[:], want)
+}
+
+// ---- ext4 ---------------------------------------------------------------------------------------------------------
+
+const (
+ ext4Magic = 0xEF53
+ incompatMetaBG = 0x10
+ incompat64Bit = 0x80
+ flagExtents = 0x80000
+ flagInlineData = 0x10000000
+ extentMagic = 0xF30A
+ maxExtentDepth = 5
+ maxDirBytes = 16 << 20
+ uninitExtentFlag = 32768
+)
+
+type ext4 struct {
+ r io.ReaderAt
+ size int64
+ blockSize int64
+ inodeSize int64
+ perGroup uint32
+ groups uint32
+ descSize int64
+ descTable int64 // byte offset of the group descriptor table
+ is64 bool
+ inodeCount uint32
+}
+
+type inode struct {
+ mode uint16
+ size int64
+ flags uint32
+ block []byte // i_block, 60 bytes
+}
+
+func openExt4(p *partition) (*ext4, error) {
+ sb := make([]byte, 1024)
+ if _, err := p.ReadAt(sb, 1024); err != nil {
+ return nil, fmt.Errorf("读不了超级块:%w", err)
+ }
+ if binary.LittleEndian.Uint16(sb[0x38:]) != ext4Magic {
+ return nil, errors.New("不是 ext4 文件系统")
+ }
+ logBS := binary.LittleEndian.Uint32(sb[0x18:])
+ if logBS > 6 {
+ return nil, errors.New("块大小不对")
+ }
+ fs := &ext4{r: p, size: p.size, blockSize: 1024 << logBS}
+ fs.inodeCount = binary.LittleEndian.Uint32(sb[0x00:])
+ blocks := uint64(binary.LittleEndian.Uint32(sb[0x04:]))
+ perGroupBlocks := binary.LittleEndian.Uint32(sb[0x20:])
+ fs.perGroup = binary.LittleEndian.Uint32(sb[0x28:])
+ fs.inodeSize = 128
+ if binary.LittleEndian.Uint32(sb[0x4C:]) >= 1 {
+ fs.inodeSize = int64(binary.LittleEndian.Uint16(sb[0x58:]))
+ }
+ incompat := binary.LittleEndian.Uint32(sb[0x60:])
+ if incompat&incompatMetaBG != 0 {
+ return nil, errors.New("不支持 meta_bg")
+ }
+ fs.is64 = incompat&incompat64Bit != 0
+ fs.descSize = 32
+ if fs.is64 {
+ blocks |= uint64(binary.LittleEndian.Uint32(sb[0x150:])) << 32
+ fs.descSize = int64(binary.LittleEndian.Uint16(sb[0xFE:]))
+ }
+ firstData := int64(binary.LittleEndian.Uint32(sb[0x14:]))
+ if perGroupBlocks == 0 || fs.perGroup == 0 || fs.inodeSize < 128 || fs.inodeSize > fs.blockSize ||
+ fs.descSize < 32 || int64(blocks)*fs.blockSize > p.size+fs.blockSize {
+ return nil, errors.New("超级块的参数不对")
+ }
+ fs.groups = uint32((blocks - uint64(firstData) + uint64(perGroupBlocks) - 1) / uint64(perGroupBlocks))
+ fs.descTable = (firstData + 1) * fs.blockSize
+ return fs, nil
+}
+
+func (fs *ext4) read(off, n int64) ([]byte, error) {
+ if off < 0 || n < 0 || off+n > fs.size {
+ return nil, errors.New("读的范围超出文件系统")
+ }
+ b := make([]byte, n)
+ if _, err := fs.r.ReadAt(b, off); err != nil {
+ return nil, err
+ }
+ return b, nil
+}
+
+func (fs *ext4) inode(ino uint32) (*inode, error) {
+ if ino == 0 || ino > fs.inodeCount {
+ return nil, fmt.Errorf("inode %d 不存在", ino)
+ }
+ g, idx := (ino-1)/fs.perGroup, (ino-1)%fs.perGroup
+ if g >= fs.groups {
+ return nil, fmt.Errorf("inode %d 不在任何块组里", ino)
+ }
+ d, err := fs.read(fs.descTable+int64(g)*fs.descSize, fs.descSize)
+ if err != nil {
+ return nil, err
+ }
+ table := int64(binary.LittleEndian.Uint32(d[0x08:]))
+ if fs.is64 && fs.descSize >= 0x2C {
+ table |= int64(binary.LittleEndian.Uint32(d[0x28:])) << 32
+ }
+ b, err := fs.read(table*fs.blockSize+int64(idx)*fs.inodeSize, 128)
+ if err != nil {
+ return nil, err
+ }
+ in := &inode{
+ mode: binary.LittleEndian.Uint16(b[0x00:]),
+ size: int64(binary.LittleEndian.Uint32(b[0x04:])) | int64(binary.LittleEndian.Uint32(b[0x6C:]))<<32,
+ flags: binary.LittleEndian.Uint32(b[0x20:]),
+ block: b[0x28 : 0x28+60],
+ }
+ if in.size < 0 {
+ return nil, fmt.Errorf("inode %d 的大小不对", ino)
+ }
+ return in, nil
+}
+
+// leaf is one mapped run of a file: count blocks from logical block logical at physical block physical; zero for an
+// uninitialized extent (reads as zeros).
+type leaf struct {
+ logical, physical, count int64
+ zero bool
+}
+
+// leaves are the extents of an inode, in the order of the tree.
+func (fs *ext4) leaves(in *inode) ([]leaf, error) {
+ if in.flags&flagInlineData != 0 {
+ return nil, errors.New("不支持内联数据")
+ }
+ if in.flags&flagExtents == 0 {
+ return nil, errors.New("不支持不用 extents 的文件")
+ }
+ var out []leaf
+ var walk func(node []byte, depth int) error
+ walk = func(node []byte, depth int) error {
+ if len(node) < 12 || binary.LittleEndian.Uint16(node) != extentMagic {
+ return errors.New("extent 头不对")
+ }
+ entries := int(binary.LittleEndian.Uint16(node[2:]))
+ d := int(binary.LittleEndian.Uint16(node[6:]))
+ if d != depth || 12+entries*12 > len(node) {
+ return errors.New("extent 树不对")
+ }
+ for k := 0; k < entries; k++ {
+ e := node[12+k*12:]
+ if d == 0 {
+ n := int64(binary.LittleEndian.Uint16(e[4:]))
+ zero := n > uninitExtentFlag
+ if zero {
+ n -= uninitExtentFlag
+ }
+ phys := int64(binary.LittleEndian.Uint16(e[6:]))<<32 | int64(binary.LittleEndian.Uint32(e[8:]))
+ out = append(out, leaf{int64(binary.LittleEndian.Uint32(e)), phys, n, zero})
+ continue
+ }
+ child := int64(binary.LittleEndian.Uint16(e[8:]))<<32 | int64(binary.LittleEndian.Uint32(e[4:]))
+ b, err := fs.read(child*fs.blockSize, fs.blockSize)
+ if err != nil {
+ return err
+ }
+ if err := walk(b, d-1); err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ if len(in.block) < 12 {
+ return nil, errors.New("extent 头不对")
+ }
+ depth := int(binary.LittleEndian.Uint16(in.block[6:]))
+ if depth > maxExtentDepth {
+ return nil, errors.New("extent 树太深")
+ }
+ return out, walk(in.block, depth)
+}
+
+// copyData writes the inode's in.size bytes to w (holes and uninitialized extents as zeros).
+func (fs *ext4) copyData(in *inode, w io.Writer) (int64, error) {
+ runs, err := fs.leaves(in)
+ if err != nil {
+ return 0, err
+ }
+ var done int64
+ zeros := make([]byte, fs.blockSize)
+ writeZeros := func(n int64) error {
+ for n > 0 {
+ c := min(n, int64(len(zeros)))
+ if _, err := w.Write(zeros[:c]); err != nil {
+ return err
+ }
+ n -= c
+ done += c
+ }
+ return nil
+ }
+ buf := make([]byte, 1<<20)
+ for _, run := range runs {
+ start := run.logical * fs.blockSize
+ if start < done {
+ return done, errors.New("extent 重叠")
+ }
+ if start >= in.size {
+ continue
+ }
+ if err := writeZeros(start - done); err != nil {
+ return done, err
+ }
+ n := min(run.count*fs.blockSize, in.size-start)
+ if run.zero {
+ if err := writeZeros(n); err != nil {
+ return done, err
+ }
+ continue
+ }
+ off := run.physical * fs.blockSize
+ if off < 0 || off+n > fs.size {
+ return done, errors.New("extent 超出文件系统")
+ }
+ for n > 0 {
+ c := min(n, int64(len(buf)))
+ if _, err := fs.r.ReadAt(buf[:c], off); err != nil {
+ return done, err
+ }
+ if _, err := w.Write(buf[:c]); err != nil {
+ return done, err
+ }
+ off, n, done = off+c, n-c, done+c
+ }
+ }
+ return done, writeZeros(in.size - done)
+}
+
+// lookup is the inode of path (relative to the root, "/"-separated); symlinks are not followed.
+func (fs *ext4) lookup(p string) (uint32, error) {
+ ino := uint32(2)
+ for _, name := range strings.Split(strings.Trim(p, "/"), "/") {
+ if name == "" || name == "." || name == ".." {
+ return 0, fmt.Errorf("路径不对:%s", p)
+ }
+ in, err := fs.inode(ino)
+ if err != nil {
+ return 0, err
+ }
+ if in.mode&0xF000 != 0x4000 {
+ return 0, fmt.Errorf("%s 不在目录里", name)
+ }
+ if in.size > maxDirBytes {
+ return 0, errors.New("目录太大")
+ }
+ var b bytes.Buffer
+ if _, err := fs.copyData(in, &b); err != nil {
+ return 0, err
+ }
+ next, err := findEntry(b.Bytes(), name)
+ if err != nil {
+ return 0, err
+ }
+ ino = next
+ }
+ return ino, nil
+}
+
+// findEntry is the inode of name in a directory's data (linear entries; htree nodes and checksum tails have inode 0
+// and are skipped).
+func findEntry(d []byte, name string) (uint32, error) {
+ for pos := 0; pos+8 <= len(d); {
+ ino := binary.LittleEndian.Uint32(d[pos:])
+ rec := int(binary.LittleEndian.Uint16(d[pos+4:]))
+ nl := int(d[pos+6])
+ if rec < 8 || pos+rec > len(d) || 8+nl > rec {
+ return 0, errors.New("目录项不对")
+ }
+ if ino != 0 && string(d[pos+8:pos+8+nl]) == name {
+ return ino, nil
+ }
+ pos += rec
+ }
+ return 0, fmt.Errorf("没有 %s", name)
+}
diff --git a/internal/playstore/image_test.go b/internal/playstore/image_test.go
new file mode 100644
index 0000000..de05158
--- /dev/null
+++ b/internal/playstore/image_test.go
@@ -0,0 +1,438 @@
+package playstore
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "unicode/utf16"
+
+ "modavd/internal/testutil"
+)
+
+// ---- a tiny ext4 ------------------------------------------------------------------------------------------------
+//
+// One block group, 1 KiB blocks: block 1 the superblock, block 2 the group descriptors, blocks 3–4 the inode table
+// (16 inodes of 128 bytes), data from block 5. Directories and files use extents; a file can ask for a depth-1 tree,
+// a hole before its data or an uninitialized extent.
+
+const tbs = 1024
+
+type tfile struct {
+ data []byte
+ hole int // blocks of hole before the data
+ uninit int // trailing blocks in an uninitialized extent (read as zeros, part of the size)
+ deep bool // a depth-1 extent tree
+}
+
+type tfs struct {
+ img []byte
+ next int64 // next free block
+ inodes uint32
+}
+
+func newTFS(blocks int) *tfs {
+ return &tfs{img: make([]byte, blocks*tbs), next: 5, inodes: 2}
+}
+
+func (f *tfs) alloc(n int64) int64 { b := f.next; f.next += n; return b }
+
+func (f *tfs) putInode(ino uint32, mode uint16, size int64, block []byte) {
+ off := 3*tbs + int64(ino-1)*128
+ binary.LittleEndian.PutUint16(f.img[off:], mode)
+ binary.LittleEndian.PutUint32(f.img[off+4:], uint32(size))
+ binary.LittleEndian.PutUint32(f.img[off+0x6C:], uint32(size>>32))
+ binary.LittleEndian.PutUint32(f.img[off+0x20:], flagExtents)
+ copy(f.img[off+0x28:off+0x28+60], block)
+}
+
+func extentHeader(entries, depth int) []byte {
+ h := make([]byte, 12)
+ binary.LittleEndian.PutUint16(h, extentMagic)
+ binary.LittleEndian.PutUint16(h[2:], uint16(entries))
+ binary.LittleEndian.PutUint16(h[4:], 4)
+ binary.LittleEndian.PutUint16(h[6:], uint16(depth))
+ return h
+}
+
+func extentLeaf(logical uint32, n uint16, phys int64) []byte {
+ e := make([]byte, 12)
+ binary.LittleEndian.PutUint32(e, logical)
+ binary.LittleEndian.PutUint16(e[4:], n)
+ binary.LittleEndian.PutUint16(e[6:], uint16(phys>>32))
+ binary.LittleEndian.PutUint32(e[8:], uint32(phys))
+ return e
+}
+
+// file writes a regular file and returns its inode.
+func (f *tfs) file(t tfile) uint32 {
+ f.inodes++
+ ino := f.inodes
+ nb := (int64(len(t.data)) + tbs - 1) / tbs
+ phys := f.alloc(nb)
+ copy(f.img[phys*tbs:], t.data)
+ leaves := [][]byte{extentLeaf(uint32(t.hole), uint16(nb), phys)}
+ size := int64(t.hole)*tbs + int64(len(t.data))
+ if t.uninit > 0 {
+ size = (int64(t.hole)+nb)*tbs + int64(t.uninit)*tbs
+ junk := f.alloc(int64(t.uninit)) // allocated but never written: what is there must not be read
+ for k := junk * tbs; k < (junk+int64(t.uninit))*tbs; k++ {
+ f.img[k] = 0xAA
+ }
+ leaves = append(leaves, extentLeaf(uint32(int64(t.hole)+nb), uint16(t.uninit)+uninitExtentFlag, junk))
+ }
+ var block []byte
+ if t.deep {
+ leafBlock := f.alloc(1)
+ node := extentHeader(len(leaves), 0)
+ for _, l := range leaves {
+ node = append(node, l...)
+ }
+ copy(f.img[leafBlock*tbs:], node)
+ idx := make([]byte, 12)
+ binary.LittleEndian.PutUint32(idx[4:], uint32(leafBlock))
+ block = append(extentHeader(1, 1), idx...)
+ } else {
+ block = extentHeader(len(leaves), 0)
+ for _, l := range leaves {
+ block = append(block, l...)
+ }
+ }
+ f.putInode(ino, 0x8000|0o644, size, block)
+ return ino
+}
+
+// dir writes a directory of the given entries (name -> inode, with a type byte) at inode ino (0: a new one).
+func (f *tfs) dir(ino uint32, entries map[string]uint32, isDir map[string]bool) uint32 {
+ if ino == 0 {
+ f.inodes++
+ ino = f.inodes
+ }
+ var d []byte
+ add := func(i uint32, name string, typ byte, last bool) {
+ rec := (8 + len(name) + 3) &^ 3
+ if last {
+ rec = tbs - len(d)
+ }
+ e := make([]byte, rec)
+ binary.LittleEndian.PutUint32(e, i)
+ binary.LittleEndian.PutUint16(e[4:], uint16(rec))
+ e[6], e[7] = byte(len(name)), typ
+ copy(e[8:], name)
+ d = append(d, e...)
+ }
+ add(ino, ".", 2, false)
+ add(2, "..", 2, false)
+ add(0, "", 0, false) // an empty slot, like a deleted entry or an htree node's fake one
+ k := 0
+ for name, i := range entries {
+ k++
+ typ := byte(1)
+ if isDir[name] {
+ typ = 2
+ }
+ add(i, name, typ, k == len(entries))
+ }
+ b := f.alloc(1)
+ copy(f.img[b*tbs:], d)
+ f.putInode(ino, 0x4000|0o755, tbs, append(extentHeader(1, 0), extentLeaf(0, 1, b)...))
+ return ino
+}
+
+func (f *tfs) finish() []byte {
+ sb := f.img[1024:]
+ binary.LittleEndian.PutUint32(sb[0x00:], 16)
+ binary.LittleEndian.PutUint32(sb[0x04:], uint32(len(f.img)/tbs))
+ binary.LittleEndian.PutUint32(sb[0x14:], 1)
+ binary.LittleEndian.PutUint32(sb[0x18:], 0)
+ binary.LittleEndian.PutUint32(sb[0x20:], 8192)
+ binary.LittleEndian.PutUint32(sb[0x28:], 16)
+ binary.LittleEndian.PutUint16(sb[0x38:], ext4Magic)
+ binary.LittleEndian.PutUint32(sb[0x4C:], 1)
+ binary.LittleEndian.PutUint16(sb[0x58:], 128)
+ binary.LittleEndian.PutUint32(sb[0x60:], 0x2|0x40)
+ binary.LittleEndian.PutUint32(f.img[2*tbs+0x08:], 3) // inode table at block 3
+ return f.img
+}
+
+// tree builds an ext4 holding files at the given "/"-separated paths.
+func tree(files map[string]tfile) []byte {
+ f := newTFS(1024)
+ type node struct {
+ kids map[string]*node
+ ino uint32
+ }
+ rootNode := &node{kids: map[string]*node{}}
+ for p, tf := range files {
+ n := rootNode
+ parts := strings.Split(p, "/")
+ for _, part := range parts[:len(parts)-1] {
+ if n.kids[part] == nil {
+ n.kids[part] = &node{kids: map[string]*node{}}
+ }
+ n = n.kids[part]
+ }
+ n.kids[parts[len(parts)-1]] = &node{ino: f.file(tf)}
+ }
+ var write func(n *node, ino uint32) uint32
+ write = func(n *node, ino uint32) uint32 {
+ entries, dirs := map[string]uint32{}, map[string]bool{}
+ for name, k := range n.kids {
+ if k.kids != nil {
+ entries[name], dirs[name] = write(k, 0), true
+ } else {
+ entries[name] = k.ino
+ }
+ }
+ return f.dir(ino, entries, dirs)
+ }
+ write(rootNode, 2)
+ return f.finish()
+}
+
+// ---- liblp and GPT around it --------------------------------------------------------------------------------------
+
+// systemImage is a GPT image with a vbmeta and a super partition whose dynamic partitions are parts (in order, each
+// as one linear extent, or two for split).
+func systemImage(t *testing.T, parts []namedPart) []byte {
+ t.Helper()
+ const superLBA, dataAt = 64, 65536 // super at sector 64; its first logical partition data at 64 KiB
+ var data []byte
+ type placed struct {
+ name string
+ ext [][2]int64 // sectors, start sector in super
+ }
+ var ps []placed
+ for _, p := range parts {
+ at := int64(dataAt + len(data))
+ pad := (len(p.fs) + sector - 1) / sector * sector
+ buf := make([]byte, pad)
+ copy(buf, p.fs)
+ if p.split {
+ // the first 4 KiB (superblock, group descriptors) in one extent, the rest (inodes, data) in the other;
+ // stored second part first, mapped in the right order
+ half := int64(8 * sector)
+ data = append(append(data, buf[half:]...), buf[:half]...)
+ ps = append(ps, placed{p.name, [][2]int64{{half / sector, (at + int64(pad) - half) / sector}, {(int64(pad) - half) / sector, at / sector}}})
+ } else {
+ data = append(data, buf...)
+ ps = append(ps, placed{p.name, [][2]int64{{int64(pad / sector), at / sector}}})
+ }
+ }
+ super := make([]byte, dataAt+len(data))
+ copy(super[dataAt:], data)
+ // geometry
+ g := super[lpReserved:]
+ binary.LittleEndian.PutUint32(g, lpGeometryMagic)
+ binary.LittleEndian.PutUint32(g[4:], 52)
+ binary.LittleEndian.PutUint32(g[40:], 8192) // metadata_max_size
+ binary.LittleEndian.PutUint32(g[44:], 1)
+ binary.LittleEndian.PutUint32(g[48:], 4096)
+ sum := sha256.Sum256(g[:52])
+ copy(g[8:40], sum[:])
+ // tables: partitions, then extents
+ var pt, et []byte
+ for _, p := range ps {
+ e := make([]byte, 52)
+ copy(e, p.name)
+ binary.LittleEndian.PutUint32(e[40:], uint32(len(et)/24))
+ binary.LittleEndian.PutUint32(e[44:], uint32(len(p.ext)))
+ pt = append(pt, e...)
+ for _, x := range p.ext {
+ b := make([]byte, 24)
+ binary.LittleEndian.PutUint64(b, uint64(x[0]))
+ binary.LittleEndian.PutUint64(b[12:], uint64(x[1]))
+ et = append(et, b...)
+ }
+ }
+ tables := append(append([]byte{}, pt...), et...)
+ h := make([]byte, 128)
+ binary.LittleEndian.PutUint32(h, lpHeaderMagic)
+ binary.LittleEndian.PutUint16(h[4:], 10)
+ binary.LittleEndian.PutUint32(h[8:], 128)
+ binary.LittleEndian.PutUint32(h[44:], uint32(len(tables)))
+ ts := sha256.Sum256(tables)
+ copy(h[48:80], ts[:])
+ desc := func(at, off, n, size int) {
+ binary.LittleEndian.PutUint32(h[at:], uint32(off))
+ binary.LittleEndian.PutUint32(h[at+4:], uint32(n))
+ binary.LittleEndian.PutUint32(h[at+8:], uint32(size))
+ }
+ desc(80, 0, len(ps), 52)
+ desc(92, len(pt), len(et)/24, 24)
+ desc(104, len(tables), 0, 48)
+ desc(116, len(tables), 0, 64)
+ hs := sha256.Sum256(h)
+ copy(h[12:44], hs[:])
+ copy(super[lpReserved+2*lpGeometrySize:], append(h, tables...))
+
+ img := make([]byte, superLBA*sector+len(super)+34*sector)
+ copy(img[superLBA*sector:], super)
+ // GPT header and entries (CRCs are not checked by the reader)
+ copy(img[sector:], "EFI PART")
+ binary.LittleEndian.PutUint64(img[sector+72:], 2)
+ binary.LittleEndian.PutUint32(img[sector+80:], 4)
+ binary.LittleEndian.PutUint32(img[sector+84:], 128)
+ entry := func(k int, name string, first, last int64) {
+ e := img[2*sector+k*128:]
+ e[0] = 1 // any non-zero type
+ binary.LittleEndian.PutUint64(e[32:], uint64(first))
+ binary.LittleEndian.PutUint64(e[40:], uint64(last))
+ for j, u := range utf16.Encode([]rune(name)) {
+ binary.LittleEndian.PutUint16(e[56+2*j:], u)
+ }
+ }
+ entry(0, "vbmeta", 8, 15)
+ entry(1, "super", superLBA, superLBA+int64(len(super)/sector)-1)
+ return img
+}
+
+type namedPart struct {
+ name string
+ fs []byte
+ split bool
+}
+
+func fileOf(name, part, p string, data []byte) File {
+ s := sha256.Sum256(data)
+ return File{Name: name, Partition: part, Path: p, Size: int64(len(data)), SHA256: hex.EncodeToString(s[:])}
+}
+
+func writeImage(t *testing.T, img []byte) string {
+ t.Helper()
+ p := filepath.Join(testutil.TempDir(t), "system.img")
+ if err := os.WriteFile(p, img, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func pattern(n int, seed byte) []byte {
+ b := make([]byte, n)
+ for k := range b {
+ b[k] = seed + byte(k*7) + byte(k>>9)
+ }
+ return b
+}
+
+// TestExtract takes files out of a synthetic system.img: two dynamic partitions (one split into two extents stored
+// out of order), nested directories, a depth-1 extent tree, a hole and an uninitialized extent.
+func TestExtract(t *testing.T) {
+ store := pattern(5000, 1)
+ gms := pattern(3*tbs+17, 2)
+ gsf := pattern(700, 3)
+ product := tree(map[string]tfile{
+ "priv-app/Phonesky/Phonesky.apk": {data: store, deep: true},
+ "priv-app/PrebuiltGmsCore/PrebuiltGmsCore.apk": {data: gms, hole: 2, uninit: 1},
+ "etc/other": {data: []byte("x")},
+ })
+ sysext := tree(map[string]tfile{"priv-app/GoogleServicesFramework/GoogleServicesFramework.apk": {data: gsf}})
+ img := writeImage(t, systemImage(t, []namedPart{{"system", tree(map[string]tfile{"a": {data: []byte("a")}}), false},
+ {"product", product, true}, {"system_ext", sysext, false}}))
+
+ gmsFull := append(append(make([]byte, 2*tbs), gms...), make([]byte, tbs-17+tbs)...)
+ files := []File{
+ fileOf("Phonesky.apk", "product", "priv-app/Phonesky/Phonesky.apk", store),
+ fileOf("PrebuiltGmsCore.apk", "product", "priv-app/PrebuiltGmsCore/PrebuiltGmsCore.apk", gmsFull),
+ fileOf("GoogleServicesFramework.apk", "system_ext", "priv-app/GoogleServicesFramework/GoogleServicesFramework.apk", gsf),
+ }
+ dir := testutil.TempDir(t)
+ if err := Extract(img, files, dir); err != nil {
+ t.Fatal(err)
+ }
+ for k, want := range [][]byte{store, gmsFull, gsf} {
+ got, err := os.ReadFile(filepath.Join(dir, files[k].Name))
+ if err != nil || !bytes.Equal(got, want) {
+ t.Errorf("%s: %d bytes, want %d (%v)", files[k].Name, len(got), len(want), err)
+ }
+ }
+ if left, _ := filepath.Glob(filepath.Join(dir, "*.part")); len(left) != 0 {
+ t.Errorf("left behind: %v", left)
+ }
+}
+
+// TestExtractRefuses: a pinned hash or size that does not match, a missing file or partition, and damaged liblp
+// metadata are errors, and no file is left for a rejected one.
+func TestExtractRefuses(t *testing.T) {
+ data := pattern(2100, 9)
+ good := fileOf("Phonesky.apk", "product", "priv-app/Phonesky/Phonesky.apk", data)
+ build := func() []byte {
+ return systemImage(t, []namedPart{{"product", tree(map[string]tfile{"priv-app/Phonesky/Phonesky.apk": {data: data}}), false}})
+ }
+ cases := []struct {
+ name string
+ file File
+ damage func(img []byte)
+ want string
+ }{
+ {"hash", File{good.Name, good.Partition, good.Path, good.Size, strings.Repeat("0", 64)}, nil, "SHA-256"},
+ {"size", File{good.Name, good.Partition, good.Path, good.Size + 1, good.SHA256}, nil, "大小"},
+ {"path", File{good.Name, good.Partition, "priv-app/Other/Other.apk", good.Size, good.SHA256}, nil, "没有 Other"},
+ {"partition", File{good.Name, "system_ext", good.Path, good.Size, good.SHA256}, nil, "没有 system_ext 分区"},
+ {"tables", good, func(img []byte) { img[64*sector+lpReserved+2*lpGeometrySize+128] ^= 1 }, "校验和"},
+ {"header", good, func(img []byte) { img[64*sector+lpReserved+2*lpGeometrySize+80] ^= 1 }, "校验和"},
+ {"geometry", good, func(img []byte) { img[64*sector+lpReserved+44] ^= 1 }, "校验和"},
+ {"gpt", good, func(img []byte) { img[sector] = 'X' }, "GPT"},
+ }
+ for _, c := range cases {
+ img := build()
+ if c.damage != nil {
+ c.damage(img)
+ }
+ dir := testutil.TempDir(t)
+ err := Extract(writeImage(t, img), []File{c.file}, dir)
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("%s: err = %v, want one about %q", c.name, err, c.want)
+ }
+ if entries, _ := os.ReadDir(dir); len(entries) != 0 {
+ t.Errorf("%s: left %d files", c.name, len(entries))
+ }
+ }
+}
+
+// TestFilesTable: the table has the five files, each with a 64-digit hash and a guest path on product or system_ext,
+// and the manifest the guest script reads lists them in order.
+func TestFilesTable(t *testing.T) {
+ if len(Files) != 5 {
+ t.Fatalf("%d files", len(Files))
+ }
+ lines := strings.Split(strings.TrimSuffix(Manifest(), "\n"), "\n")
+ var total int64
+ for k, f := range Files {
+ if len(f.SHA256) != 64 || strings.Trim(f.SHA256, "0123456789abcdef") != "" || f.Size <= 0 {
+ t.Errorf("%s: %+v", f.Name, f)
+ }
+ if f.Partition != "product" && f.Partition != "system_ext" || !strings.HasPrefix(f.Guest(), "/"+f.Partition+"/") {
+ t.Errorf("%s: guest path %s", f.Name, f.Guest())
+ }
+ if lines[k] != f.Name+" "+f.Guest()+" "+f.SHA256 {
+ t.Errorf("manifest line %d: %q", k, lines[k])
+ }
+ total += f.Size
+ }
+ if ExtractedBytes() != total || Files[0].Name != "Phonesky.apk" || Stub != "/product/app/LicenseChecker" {
+ t.Errorf("total %d, first %s, stub %s", ExtractedBytes(), Files[0].Name, Stub)
+ }
+}
+
+// TestExtractRealImage takes the store's files out of Google's own r14 system.img when MODAVD_TEST_PLAY_IMAGE names
+// it (a developer check; the file is 3 GB and Google's).
+func TestExtractRealImage(t *testing.T) {
+ img := os.Getenv("MODAVD_TEST_PLAY_IMAGE")
+ if img == "" {
+ t.Skip("MODAVD_TEST_PLAY_IMAGE not set")
+ }
+ dir := testutil.TempDir(t)
+ if err := Extract(img, Files, dir); err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range Files {
+ if st, err := os.Stat(filepath.Join(dir, f.Name)); err != nil || st.Size() != f.Size {
+ t.Errorf("%s: %v", f.Name, err)
+ }
+ }
+}
diff --git a/internal/root/components.go b/internal/root/components.go
index af40e09..fa82c49 100644
--- a/internal/root/components.go
+++ b/internal/root/components.go
@@ -10,6 +10,8 @@ import (
"runtime"
"strings"
"unicode"
+
+ "modavd/internal/playstore"
)
// ---- Google components -----------------------------------------------------------------------------------------
@@ -47,6 +49,7 @@ type SDKComponent struct {
// What components fetch needs; URL is "" on hosts that have no catalogue yet (darwin, linux), where Complete means
// nothing.
URL string // relative to a mirror's base URL
+ ZipName string // its name in downloads\, "" for the URL's last part
Size int64 // of the zip
SHA1 string // of the zip, lower hex
ArchiveRoot string // the zip's top directory, which becomes Target
@@ -90,6 +93,40 @@ func WindowsComponents() []SDKComponent {
}
}
+// PlayStoreDir is where the optional Google Play Store component goes, under .
+const PlayStoreDir = "playstore"
+
+// PlayStoreName is the Google Play Store component's name.
+const PlayStoreName = "Google Play 商店"
+
+// PlayStoreComponent is the optional Google Play Store (package playstore): the store and the Google-signed Play
+// services, GSF and Partner Setup taken out of Google's Play image of the same release, kept in \playstore. It is
+// not in SDKComponents, so check, components status and AUTO-MAS never count it as missing; only `components fetch
+// --play-store` fetches it, and only instances created or set up with the store use it. Complete pins every file's
+// SHA-256. The zip unpacks into one 3 GB system.img that fetch reads the files out of and deletes (ExtractedBytes
+// counts both); on hosts without a catalogue the URL is "".
+func PlayStoreComponent() SDKComponent {
+ files := make([]string, len(playstore.Files))
+ hashes := map[string]string{}
+ for k, f := range playstore.Files {
+ files[k] = f.Name
+ hashes[f.Name] = f.SHA256
+ }
+ c := SDKComponent{ID: "play-store", Name: PlayStoreName,
+ FetchName: "Google Play 商店(取自 Android 14 Google Play x86_64 镜像 r14)", Version: playstore.Revision,
+ Target: PlayStoreDir, StatusFiles: files, KeyFiles: files, KeyHashes: hashes}
+ if runtime.GOOS == "windows" {
+ c.URL, c.Size, c.SHA1, c.ArchiveRoot = playstore.ImageURL, playstore.ImageSize, playstore.ImageSHA1, "x86_64"
+ // the Google APIs image's zip has the same last part (x86_64-34_r14.zip): another name, or a .part of one
+ // would be resumed as the other
+ c.ZipName = "google_apis_playstore-x86_64-34_r14.zip"
+ c.ExtractedBytes = playstore.ImageMemberSize + playstore.ExtractedBytes()
+ c.Properties = "Pkg.Desc=Google Play Store, Google Play services, GSF and Partner Setup from " +
+ "system-images;android-34;google_apis_playstore;x86_64.\nPkg.Revision=" + playstore.Revision + "\n"
+ }
+ return c
+}
+
// SDKComponents is this host's table: the Windows one, or on darwin and linux the same two components with the host's
// adb name and no catalogue.
func SDKComponents() []SDKComponent {
diff --git a/internal/ui/API.md b/internal/ui/API.md
index bd1ee88..23e5369 100644
--- a/internal/ui/API.md
+++ b/internal/ui/API.md
@@ -8,7 +8,7 @@
- 启动器打开 `/?t=<令牌>`:服务回 `Set-Cookie: modavd_ui_<端口>=<令牌>; HttpOnly; SameSite=Strict; Path=/` 和 302 跳到 `/`。令牌每次启动随机生成(32 字节)。cookie 名带端口:cookie 只按主机分、不按端口分,所有管理器窗口又共用一个 Edge 配置,同名的话后开的管理器会把先开的那个顶掉。
- 所有 `/api/*` 都要带这个 cookie,没有或不对回 401。
- `Host` 不是 `127.0.0.1:<端口>` 或 `localhost:<端口>` 的请求一律 403(防 DNS 重绑定)。
-- 非 GET 请求:`Origin` 必须是本服务自己的地址(浏览器会带),否则 403;请求体是 JSON(`Content-Type: application/json`),上传 APK 例外。
+- 非 GET 请求:`Origin` 必须是本服务自己的地址(浏览器会带),否则 403;请求体是 JSON(`Content-Type: application/json`),上传 APK 和导入 Google Play 商店的文件例外。
- 静态文件 `/`、`/app.css`、`/js/*`、`/assets/*` 不要 cookie(页面要先加载才能带 cookie),但同样校验 Host。
## 错误
@@ -35,7 +35,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…","
| GET | `/api/info` | | `{"version","protocol","root":RootInfo}` |
| GET | `/api/instances` | | `ListResult` |
| GET | `/api/instances/{i}` | | `InstanceInfo` |
-| POST | `/api/instances` | `{title?, index?, memory_mb?, cpu?, data_partition_gb?, fps?, resolution?}` | 202 `Job`(`kind` 是 `create`:后台开机做完初始化再关机,要几分钟;没给 `index` 时服务端先选好编号,`Job.index` 就是新实例的;结果是 `InstanceInfo`) |
+| POST | `/api/instances` | `{title?, index?, memory_mb?, cpu?, data_partition_gb?, fps?, resolution?}` | 202 `Job`(`kind` 是 `create`:后台开机做完初始化再关机,要几分钟;没给 `index` 时服务端先选好编号,`Job.index` 就是新实例的;结果是 `InstanceInfo`)。新建的实例不带 Google Play 商店(没有 `play_store` 字段,给了回 400),要的话建好后用下面的 `playstore-install` |
| PATCH | `/api/instances/{i}` | `{title?, memory_mb?, cpu?, fps?, resolution?, data_partition_gb?, diagnostics?: bool}` | `UpdateResult`;诊断立即生效,普通配置下次开机生效 |
| GET | `/api/instances/{i}/diagnostics/preview` | | `{"index","files","bytes"}`:关闭诊断时预计清理的份数、字节,不改配置、不删文件 |
| DELETE | `/api/instances/{i}?confirm=1` | 不带 `confirm=1` 回 400 `usage` | `DeleteResult` |
@@ -46,6 +46,8 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…","
| POST | `/api/instances/{i}/disk-trim` | `{}` | 202 `Job`(`kind` 是 `disk-trim`):请求开着的安卓回收空间;`done` 和 100% 仅表示触发请求已返回,不等待或确认客体 FITRIM 完成,任务耗时也是请求耗时;实例没开机完成、或模拟器版本不支持时任务失败,code 是 `unsupported`;结果是 `DiskTrimResult` |
| POST | `/api/instances/{i}/disk-compact` | `{}` | 202 `Job`(`kind` 是 `disk-compact`):整理已关机实例的数据盘文件;预估收益不足时跳过重写;实例没关机时任务失败,code 是 `busy`;结果是 `DiskCompactResult` |
| POST | `/api/instances/{i}/resume` | `{}` | 202 `Job`(`kind` 是 `create`):重做新建失败的那台的初始化 |
+| POST | `/api/instances/{i}/playstore-install` | `{}` | 202 `Job`(`kind` 是 `playstore-install`):把根目录里的五个文件装进这台实例(同 `modavd playstore install`:放进执行桥的可写覆盖层、藏起占位应用、冷启动一次;关着的做完再关上,开着的照原来的方式开回来,要几分钟);结果是 `PlayStoreResult`。开始前先检查,不行立即回错误、不开任务:根目录还没有五个文件(或文件不全、损坏)是 422 `not_ready`,`details.reason` 为 `playstore_files_missing`(页面引导去「补全组件」勾选或导入);实例没有执行桥是 422 `not_ready`,`details.reason` 为 `no_bridge`;arm64 客体是 422 `unsupported`(`unsupported_guest`);新建中 409 `busy`;不存在 404。任务里客体脚本发现系统不可写也是 `not_ready` / `no_bridge` |
+| POST | `/api/instances/{i}/playstore-uninstall` | `{}` | 202 `Job`(`kind` 是 `playstore-uninstall`):从实例里移除商店(去掉 Google Play 装的更新、删掉五个文件、冷启动后恢复占位应用;同 `playstore uninstall`);结果是 `PlayStoreResult`。不要五个文件,也不检查执行桥 |
| POST | `/api/instances/{i}/input` | `{type:"tap", x, y}`(坐标是这台实例显示的像素(1280×720 或 1920×1080,竖屏时宽高对调))或 `{type:"key", key:"back"\|"home"\|"recents"}` | `{}` |
| GET | `/api/instances/{i}/screenshot` | | `image/png`(`adb exec-out screencap -p`),`Cache-Control: no-store`;没开机回 409 |
| GET | `/api/instances/{i}/log?kind=emulator\|logcat&lines=300` | | `text/plain; charset=utf-8`,最新那份日志的末尾;没有回 404 |
@@ -55,11 +57,12 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…","
| GET | `/api/proxy/suggest` | | `{"server","notes"}`:这台电脑的代理设置(同 `modavd proxy --server auto`),换成实例里看到的地址,设置没说是 SOCKS5 时探测协议;`notes` 说取自哪里。没设代理回 502 `proxy_unavailable` |
| POST | `/api/proxy/probe` | `{server}` | `{"server","host_addr","kind","udp","udp_error","notes"}`:在这台电脑上探测这个地址:是 SOCKS5 还是 HTTP 代理,SOCKS5 再经它发一个 DNS 查询试 UDP。`server` 换成实例里看到的地址(`127.0.0.1`、`localhost` 记成 `10.0.2.2`),协议换成探测到的,`notes` 说改了什么。连不上回 502 `proxy_unavailable` |
| POST | `/api/open` | `{what: "root"\|"logs"\|"instance"\|"collect-info", index?, job?}` | `{}`,在资源管理器里打开;`collect-info` 打开那个做完了的收集问题信息任务(`job` 是任务 id)生成的问题包所在的文件夹,不接受页面给的路径 |
-| GET | `/api/components` | | 组件状态 |
+| GET | `/api/components` | | `{"root","components":[…],"play_store":PlayStoreStatus}`:组件状态;`components` 最后一项是可选的 Google Play 商店的概要(`id` 为 `play-store`,`optional: true`,五个文件都在就是 `installed`),`play_store` 是它的细节,见下 |
| GET | `/api/components/license` | `?source=auto\|googledownloads_cn\|tencent\|google\|custom&mirror_urls=…`(可重复;不带时用保存的下载源) | `{"license":"全文","license_source":"镜像来源","license_sha256":"全文的 SHA-256"}`:从这个下载源取的许可协议,和补全时取的是同一份;不下载组件 |
| GET | `/api/components/source` | | 当前组件下载源:`source` 为 `auto`、内置镜像 ID 或 `custom`,以及 `mirror_urls`、`configured` |
| PUT | `/api/components/source` | `{"source":"auto"\|"googledownloads_cn"\|"tencent"\|"google"\|"custom", "mirror_urls":[...]}` | 保存当前用户的组件下载源;自定义地址只接受 HTTP(S) 目录 |
-| POST | `/api/components/fetch` | `{"accept_license":true, "license_sha256":"…", "source"?:..., "mirror_urls"?:[...]}`(`accept_license` 必须明确为 `true`;`license_sha256` 是看过的那份许可协议的,取自上面的接口,同一个下载源) | 202 `Job`(`kind` 是 `components`,根任务,`index` 为 `-1`)。不带 `source` / `mirror_urls` 时用保存的下载源。后台取到的许可协议和 `license_sha256` 对不上时,任务以 `components_rejected` 失败,什么也不下 |
+| POST | `/api/components/fetch` | `{"accept_license":true, "license_sha256":"…", "source"?:..., "mirror_urls"?:[...]}`(`accept_license` 必须明确为 `true`;`license_sha256` 是看过的那份许可协议的,取自上面的接口,同一个下载源) | 202 `Job`(`kind` 是 `components`,根任务,`index` 为 `-1`)。不带 `source` / `mirror_urls` 时用保存的下载源。后台取到的许可协议和 `license_sha256` 对不上时,任务以 `components_rejected` 失败,什么也不下。`"play_store": true` 再补全可选的 Google Play 商店:根目录还没有五个文件时下 Google Play 版系统镜像(约 1.5 GB),取出五个文件后压缩包和解出的 `system.img` 都删掉,只留这五个;已经有了(下载的或导入的)就跳过 |
+| POST | `/api/components/import-playstore` | `multipart/form-data`,文件字段 `file`,每个文件一个(1–8 个):Google 官方的 Google Play 版镜像压缩包 `x86_64-34_r14.zip`,或者那五个文件本身(文件名都不限) | 202 `Job`(`kind` 是 `playstore-import`,根任务,`index` 为 `-1`):「使用我自己的文件」。不下载、不问许可协议。服务端把上传存到临时文件夹,任务结束删掉;任务按大小认出每个文件,压缩包核对 Google 仓库清单的大小和 SHA-1,五个文件各自核对写死的大小和 SHA-256;结果是 `PlayStoreImportResult`。上传本身不对时立即回 400:不是 multipart 是 `usage`,没有 `file` 字段是 `usage`,空文件、超过 8 个、单个比压缩包还大是 `invalid_argument`。文件被拒收时任务以 `components_rejected` 失败,什么也不改,`details.reason`:`unknown_file`(大小既不是压缩包也不是五个文件之一)、`mismatch`(大小对上了、哈希对不上)、`incomplete`(只给了一部分,根目录里又没有其余的);另一个补全或导入在跑是 409 `busy`;空间不够 422 `precheck_failed` |
| POST | `/api/diag/collect` | `{out_dir?, stage_dir?, keep_stage?}` | 202 `Job`(`kind` 是 `collect-info`,根任务,`index` 为 `-1`) |
| GET | `/api/diag/report-config` | | `{"text":"…"}` |
| GET | `/api/check?refresh=1` | | `CheckResult`(要几秒:硬件加速检查和 Vulkan 探测) |
@@ -87,16 +90,16 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…","
"result":null,"error":null,"warnings":[]}
```
-- `kind`:`start` / `stop` / `install` / `create` / `components` / `collect-info` / `disk-info` / `disk-trim` / `disk-compact`(0.3.0 起没有重启,也没有显示 / 隐藏窗口)。
+- `kind`:`start` / `stop` / `install` / `create` / `components` / `collect-info` / `disk-info` / `disk-trim` / `disk-compact` / `playstore-install` / `playstore-uninstall` / `playstore-import`(0.3.0 起没有重启,也没有显示 / 隐藏窗口)。
- `state`:`running` / `done` / `failed` / `canceled`。
-- `progress.step`:开机用 emu 的 `launch` / `boot` / `tune` / `ime` / `first_boot` / `vulkan_fix` / `done`;关机(含等被取消的开机停下)是 `stop`,安装是 `install`,这两种做完是 `done`;磁盘任务进行中是 `disk_info` / `disk_trim` / `disk_compact`、做完是 `done`(进行中没有百分比,`total` 为 0,页面显示不定长进度条;做完 `done` = `total` = 1000);装执行桥的每一轮是 `bridge`;新建是 `create`、其中几次开机的上述各步(开机做完是 `setup`)、`create_stop`、`done`,几次开机合成一条不回退的进度。`total` 一律 1000。
+- `progress.step`:开机用 emu 的 `launch` / `boot` / `tune` / `ime` / `first_boot` / `vulkan_fix` / `done`;关机(含等被取消的开机停下)是 `stop`,安装是 `install`,这两种做完是 `done`;磁盘任务进行中是 `disk_info` / `disk_trim` / `disk_compact`、做完是 `done`(进行中没有百分比,`total` 为 0,页面显示不定长进度条;做完 `done` = `total` = 1000);装执行桥的每一轮是 `bridge`;商店的装、卸进行中是 `playstore-install` / `playstore-uninstall`(没有百分比)、做完是 `done`;导入时核对文件是 `verifying`、解压镜像是 `extracting`(`done` / `total` 是字节);新建是 `create`、其中几次开机的上述各步(开机做完是 `setup`)、`create_stop`、`done`,几次开机合成一条不回退的进度。`total` 一律 1000。
- 安装任务的 `result.apk` 是用户上传的文件名,不是服务端的临时路径(临时文件任务结束就删了)。
-- `progress` 在没有进度时为 `null`;`result` 是 `StartResult` / `StopResult` / `InstallResult` / `DiskInfoResult` / `DiskTrimResult` / `DiskCompactResult` / `InstanceInfo`(新建),没完成时为 `null`;`error` 失败时是错误对象,否则 `null`。
+- `progress` 在没有进度时为 `null`;`result` 是 `StartResult` / `StopResult` / `InstallResult` / `DiskInfoResult` / `DiskTrimResult` / `DiskCompactResult` / `InstanceInfo`(新建)/ `PlayStoreResult` / `PlayStoreImportResult`,没完成时为 `null`;`error` 失败时是错误对象,否则 `null`。
`DiskCompactResult` 保留非负的 `freed_bytes`。`before_on_disk_bytes` 和 `after_on_disk_bytes` 是整理前后宿主实际占用,新增的 `on_disk_delta_bytes` 等于后者减前者,正值表示占用增大。`skipped=true` 表示检查通过、预计收益不足,未重写原盘;此时前后占用相等、变化和释放量均为零。结果还包含 `reclaim_threshold_bytes` 和可省略的 `estimated_reclaimable_bytes`,详见 [整理预算与结果契约](../emu/COMPACT.md)。
- 同一实例同时只能有一个任务在跑;第二个回 409 `busy`。**例外**:开机任务进行中可以发关机(和 emu 一致:开机中照样能关)。
- 取消开机:只停止等待,模拟器继续开机,不会被强制结束。
-- `components` 和 `collect-info` 是根目录任务,`index` 为 `-1`;取消会传给诊断后端上下文。
+- `components`、`playstore-import` 和 `collect-info` 是根目录任务,`index` 为 `-1`,同时只跑一个;取消会传给诊断后端上下文。`components`、`playstore-import` 在跑时,开机、新建、商店的装卸回 409 `busy`,反过来也一样(它们会改 `sdk\`)。
### 事件流 `/api/events`
@@ -133,6 +136,22 @@ data: {} 每 15 秒
- 卡片的标记:页面连着时,服务每 30 秒问一次每个开着这个设置、已开机的实例(一条 root 命令),实例的状态或设置一变就马上问;正在开机、关机或做开机后设置的不问。GET、PUT 的结果也会更新标记。
- 没开机的实例没有 `ProxyMark`:页面按 `InstanceInfo.app_proxy`(只读配置的概要)显示「下次开机生效」。
+### Google Play 商店 `PlayStoreStatus`、`PlayStoreImportResult`、`PlayStoreResult`
+
+商店是可选的:根目录里留五个文件(`sdk\playstore\`,补全组件时勾选下载,或者导入自己的文件),每台实例要的时候再装进去;新建实例不带。
+
+- `PlayStoreStatus`(`GET /api/components` 的 `play_store`,和 `modavd components status --json` 的 `data.play_store` 相同):`{"dir","ready","source","version","files":[{"name","guest","size","present"}]}`。
+ - `ready`:五个文件都在、`source.properties` 的版本对(只看大小,SHA-256 在放进来和装进实例时核对)。实例的「补全 Google Play 商店」按钮按它决定是直接装,还是先引导去补全组件。
+ - `source`:`download`(补全组件时下载的)、`import`(用自己的文件导入的),没有或来历不明是 `""`。
+ - `files` 固定五项,顺序同文档站:`Phonesky.apk`、`PrebuiltGmsCore.apk`、`GoogleServicesFramework.apk`、`PartnerSetupPrebuilt.apk`、`play_store_fsi_cert.der`;`guest` 是装进实例后的路径,`present` 是这个文件在、大小对。
+- `PlayStoreImportResult`(`playstore-import` 任务的结果,同 `modavd components import-playstore --json`):`{"root","from","used","kept","skipped","set_aside","status":PlayStoreStatus}`。
+ - `from`:`zip`(给了压缩包)或 `files`(给了文件本身);`used`:核对通过、用上了的文件(按上传时的文件名);`kept`:这次没给、根目录里原有且核对无误留下的。
+ - `skipped`:根目录里五个文件本来就齐全,给的文件也核对过了,什么都没改。
+ - `set_aside`:原来不完整的 `sdk\playstore` 改名后的位置(`sdk\playstore.incomplete-<时间>`),没有是 `""`。
+- `PlayStoreResult`(`playstore-install` / `playstore-uninstall` 任务的结果,同 `modavd playstore … --json`):`{"index","action","state","installed","files","restarted","stopped","warnings"}`;`state` 是做完后客体里的情况:`installed`、`absent` 或 `partial`。
+- `InstanceInfo.play_store`(bool):这台实例装着商店(按 `mas-avd.json` 的记录,装成功后为 `true`,移除后 `false`;不问客体)。按钮据此显示「补全 Google Play 商店」或「移除 Google Play 商店」;`bridge` 为 `off` 的实例装不了(会回 `no_bridge`)。
+- 页面选文件和装 APK 一样:` `(可多选),每个文件追加一个 `file` 字段,用 `FormData` 发 `multipart/form-data`。
+
### 更新 `UpdateView`
`update.Status` 的全部字段(和 `modavd update --json` 的 `data` 相同:`root`、`updatable`、`not_updatable_reason`、`current_version`、`current_build`、`source`、`cdk_set`、`checked`、`check_error`、`latest_version`、`latest_source`、`update_available`、`required_automas`、`release_url`、`phase`、`prepared_version`、`last_result`、`recovered`、`updated`、`message`、`notices`),加上 `checking`、`checked_at`(RFC 3339,本次启动后没查过是 `""`)、`error`(上次检查的错误对象,没有是 `null`)、`install_by_cli`(现在恒为 `true`:管理器里不能安装,由 AUTO-MAS 调 `modavd update install`)。
diff --git a/internal/ui/backend.go b/internal/ui/backend.go
index 55e4aa9..035a21c 100644
--- a/internal/ui/backend.go
+++ b/internal/ui/backend.go
@@ -30,6 +30,10 @@ type backend interface {
DiskTrim(ctx context.Context, i int) (*emu.DiskTrimResult, error)
DiskInfo(ctx context.Context, i int) (*emu.DiskInfoResult, error)
DiskCompact(ctx context.Context, i int) (*emu.DiskCompactResult, error)
+ // PlayStore is `modavd playstore `; PlayStoreCheck what it refuses before touching the instance (no
+ // bridge, the root has not got the files ...), quick enough for a request.
+ PlayStore(ctx context.Context, i int, action string) (*emu.PlayStoreResult, error)
+ PlayStoreCheck(i int, action string) error
// AdbRun is Manager.ADB.Run (the screenshot: exec-out screencap -p with Raw).
AdbRun(ctx context.Context, opt adb.RunOptions, args ...string) adb.Result
// Serial is instance i's adb serial in the root's port plan.
diff --git a/internal/ui/diag_backend.go b/internal/ui/diag_backend.go
index 0ac906e..74d021f 100644
--- a/internal/ui/diag_backend.go
+++ b/internal/ui/diag_backend.go
@@ -20,6 +20,7 @@ var (
diagComponents = func(r *root.Root) []emu.ComponentStatus { return emu.ComponentStatuses(r.Components()) }
diagLicense = components.License
diagFetch = components.Fetch
+ diagImport = components.ImportPlayStore
diagCollect = report.Collect
diagConfig = report.BuildConfig
)
@@ -35,7 +36,9 @@ func (b *realDiagBackend) Components(ctx context.Context) (*DiagComponentsResult
if err := ctx.Err(); err != nil {
return nil, errs.Wrap(errs.Canceled, err, "已取消")
}
- return &DiagComponentsResult{Root: b.r.Dir, Components: toDiagComponents(diagComponents(b.r))}, nil
+ store := components.PlayStoreState(b.r)
+ return &DiagComponentsResult{Root: b.r.Dir, Components: append(toDiagComponents(diagComponents(b.r)), playStoreComponent(store)),
+ PlayStore: store}, nil
}
func (b *realDiagBackend) License(ctx context.Context, source components.SourceSettings) (*DiagLicenseResult, error) {
@@ -46,7 +49,7 @@ func (b *realDiagBackend) License(ctx context.Context, source components.SourceS
return &DiagLicenseResult{Text: text, Source: from, SHA256: components.LicenseSHA256(text)}, nil
}
-func (b *realDiagBackend) Fetch(ctx context.Context, accept bool, source components.SourceSettings, licenseSHA256 string, progress func(DiagProgress)) (*DiagFetchResult, error) {
+func (b *realDiagBackend) Fetch(ctx context.Context, accept, playStore bool, source components.SourceSettings, licenseSHA256 string, progress func(DiagProgress)) (*DiagFetchResult, error) {
if !accept {
return nil, errs.New(errs.LicenseDeclined, "没有同意许可协议,已退出,什么也没有下载")
}
@@ -56,6 +59,7 @@ func (b *realDiagBackend) Fetch(ctx context.Context, accept bool, source compone
Mirror: source.Mirror,
MirrorURLs: append([]string(nil), source.MirrorURLs...),
LicenseSHA256: licenseSHA256,
+ PlayStore: playStore,
SourceOrigin: components.SourceFromManager,
Log: b.log,
// the license text and the note under it: the user has just read them in the dialog, so they go to the
@@ -74,6 +78,14 @@ func (b *realDiagBackend) Fetch(ctx context.Context, accept bool, source compone
Components: toDiagFetchComponents(res.Components), Downloads: res.Downloads}, nil
}
+func (b *realDiagBackend) ImportPlayStore(ctx context.Context, files []components.ImportFile, progress func(DiagProgress)) (*components.PlayStoreImportResult, error) {
+ return diagImport(ctx, b.r, files, components.Options{Log: b.log, Progress: func(p components.Progress) {
+ if progress != nil {
+ progress(diagProgressFromComponent(p))
+ }
+ }})
+}
+
func (b *realDiagBackend) Collect(ctx context.Context, opt DiagCollectOptions, progress func(DiagProgress)) (*DiagCollectResult, error) {
res, err := diagCollect(ctx, b.r, report.CollectOptions{
OutDir: opt.OutDir, StageDir: opt.StageDir, KeepStage: opt.KeepStage, NoExplorer: true,
@@ -99,6 +111,16 @@ func (b *realDiagBackend) ReportConfig(ctx context.Context) (string, error) {
return res.Text, nil
}
+// playStoreComponent is the optional Google Play Store as the component list shows it: installed when its five files
+// are there (PlayStoreStatus.Ready; the pinned hashes are checked where they are put in and used).
+func playStoreComponent(s components.PlayStoreStatus) DiagComponent {
+ d := DiagComponent{ID: "play-store", Name: root.PlayStoreName, Optional: true, Installed: s.Ready}
+ if s.Ready {
+ d.Version = s.Version
+ }
+ return d
+}
+
func toDiagComponents(in []emu.ComponentStatus) []DiagComponent {
out := make([]DiagComponent, 0, len(in))
for _, c := range in {
diff --git a/internal/ui/diag_backend_test.go b/internal/ui/diag_backend_test.go
index 2872286..7bf9d95 100644
--- a/internal/ui/diag_backend_test.go
+++ b/internal/ui/diag_backend_test.go
@@ -20,7 +20,7 @@ func TestRealDiagBackendRejectsDeclinedLicense(t *testing.T) {
return &components.Result{}, nil
}
b := newRealDiagBackend(&root.Root{Dir: `C:\root`}, logx.Nop())
- if _, err := b.Fetch(context.Background(), false, components.SourceSettings{}, fakeHash, nil); err == nil || calls != 0 {
+ if _, err := b.Fetch(context.Background(), false, false, components.SourceSettings{}, fakeHash, nil); err == nil || calls != 0 {
t.Fatalf("declined license: err=%v calls=%d", err, calls)
}
}
@@ -46,7 +46,7 @@ func TestRealDiagBackendPassesSourceAndLicenseHash(t *testing.T) {
{Mirror: "google", Set: true},
{MirrorURLs: []string{"https://a.example/x/", "https://b.example/y/"}, Set: true},
} {
- if _, err := b.Fetch(context.Background(), true, s, fakeHash, nil); err != nil {
+ if _, err := b.Fetch(context.Background(), true, false, s, fakeHash, nil); err != nil {
t.Fatal(err)
}
}
diff --git a/internal/ui/diag_jobs_test.go b/internal/ui/diag_jobs_test.go
index d4feccd..2a6ee0f 100644
--- a/internal/ui/diag_jobs_test.go
+++ b/internal/ui/diag_jobs_test.go
@@ -39,7 +39,7 @@ func (f *fakeDiag) Components(context.Context) (*DiagComponentsResult, error) {
func (f *fakeDiag) License(context.Context, components.SourceSettings) (*DiagLicenseResult, error) {
return &DiagLicenseResult{Text: fakeTerms, Source: "mirror", SHA256: fakeTermsSHA}, nil
}
-func (f *fakeDiag) Fetch(ctx context.Context, accept bool, _ components.SourceSettings, _ string, progress func(DiagProgress)) (*DiagFetchResult, error) {
+func (f *fakeDiag) Fetch(ctx context.Context, accept, _ bool, _ components.SourceSettings, _ string, progress func(DiagProgress)) (*DiagFetchResult, error) {
if !accept {
return nil, errs.New(errs.Usage, "必须同意")
}
diff --git a/internal/ui/diag_source_test.go b/internal/ui/diag_source_test.go
index 13042ab..6cf55df 100644
--- a/internal/ui/diag_source_test.go
+++ b/internal/ui/diag_source_test.go
@@ -57,7 +57,7 @@ func (d *sourceRecordingDiag) License(_ context.Context, s components.SourceSett
return &DiagLicenseResult{Text: fakeTerms, Source: "mirror", SHA256: fakeTermsSHA}, nil
}
-func (d *sourceRecordingDiag) Fetch(_ context.Context, _ bool, s components.SourceSettings, hash string, _ func(DiagProgress)) (*DiagFetchResult, error) {
+func (d *sourceRecordingDiag) Fetch(_ context.Context, _, _ bool, s components.SourceSettings, hash string, _ func(DiagProgress)) (*DiagFetchResult, error) {
d.mu.Lock()
d.fetched = append(d.fetched, s)
d.hashes = append(d.hashes, hash)
diff --git a/internal/ui/diagnostics.go b/internal/ui/diagnostics.go
index cf1d7c1..ea22ee4 100644
--- a/internal/ui/diagnostics.go
+++ b/internal/ui/diagnostics.go
@@ -19,6 +19,9 @@ type DiagComponent struct {
type DiagComponentsResult struct {
Root string `json:"root"`
Components []DiagComponent `json:"components"`
+ // PlayStore is the optional Google Play Store's five files in detail (Components ends with its summary item,
+ // id play-store).
+ PlayStore components.PlayStoreStatus `json:"play_store"`
}
type DiagLicenseResult struct {
@@ -85,7 +88,10 @@ type DiagCollectResult struct {
type diagBackend interface {
Components(context.Context) (*DiagComponentsResult, error)
License(context.Context, components.SourceSettings) (*DiagLicenseResult, error)
- Fetch(context.Context, bool, components.SourceSettings, string, func(DiagProgress)) (*DiagFetchResult, error)
+ // Fetch: accept, then playStore (also the optional Google Play Store, components.Options.PlayStore)
+ Fetch(context.Context, bool, bool, components.SourceSettings, string, func(DiagProgress)) (*DiagFetchResult, error)
Collect(context.Context, DiagCollectOptions, func(DiagProgress)) (*DiagCollectResult, error)
ReportConfig(context.Context) (string, error)
+ // ImportPlayStore is components.ImportPlayStore on the root (the files the page uploaded).
+ ImportPlayStore(context.Context, []components.ImportFile, func(DiagProgress)) (*components.PlayStoreImportResult, error)
}
diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go
index aa8e3fa..446c639 100644
--- a/internal/ui/handlers.go
+++ b/internal/ui/handlers.go
@@ -413,6 +413,7 @@ func (s *server) handleComponentsFetch(w http.ResponseWriter, r *http.Request) {
Mirror string `json:"mirror"`
MirrorURLs []string `json:"mirror_urls"`
LicenseSHA256 string `json:"license_sha256"`
+ PlayStore bool `json:"play_store"` // also the optional Google Play Store
}
if !decode(w, r, &body) {
return
@@ -438,7 +439,7 @@ func (s *server) handleComponentsFetch(w http.ResponseWriter, r *http.Request) {
}
}
v, err := s.startRootJob(kindComponents, func(ctx context.Context, j *job, _ backend) (any, error) {
- return s.jobDiag(j).Fetch(ctx, true, source, body.LicenseSHA256, func(p DiagProgress) { s.setDiagProgress(j, p) })
+ return s.jobDiag(j).Fetch(ctx, true, body.PlayStore, source, body.LicenseSHA256, func(p DiagProgress) { s.setDiagProgress(j, p) })
})
s.respondJob(w)(v, err)
}
diff --git a/internal/ui/http.go b/internal/ui/http.go
index 586011b..02ae047 100644
--- a/internal/ui/http.go
+++ b/internal/ui/http.go
@@ -37,6 +37,8 @@ func (s *server) routes() {
m.HandleFunc("POST /api/instances/{i}/disk-trim", s.handleDiskTrim)
m.HandleFunc("POST /api/instances/{i}/disk-compact", s.handleDiskCompact)
m.HandleFunc("POST /api/instances/{i}/resume", s.handleResumeCreate)
+ m.HandleFunc("POST /api/instances/{i}/playstore-install", s.handlePlayStore("install"))
+ m.HandleFunc("POST /api/instances/{i}/playstore-uninstall", s.handlePlayStore("uninstall"))
m.HandleFunc("POST /api/instances/{i}/input", s.handleInput)
m.HandleFunc("GET /api/instances/{i}/screenshot", s.handleScreenshot)
m.HandleFunc("GET /api/instances/{i}/log", s.handleLog)
@@ -51,6 +53,7 @@ func (s *server) routes() {
m.HandleFunc("GET /api/components/source", s.handleComponentSource)
m.HandleFunc("PUT /api/components/source", s.handleComponentSource)
m.HandleFunc("POST /api/components/fetch", s.handleComponentsFetch)
+ m.HandleFunc("POST /api/components/import-playstore", s.handleImportPlayStore)
m.HandleFunc("POST /api/diag/collect", s.handleDiagCollect)
m.HandleFunc("GET /api/diag/report-config", s.handleReportConfig)
m.HandleFunc("GET /api/check", s.handleCheck)
diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go
index 3413ca8..4e8d47d 100644
--- a/internal/ui/jobs.go
+++ b/internal/ui/jobs.go
@@ -28,6 +28,11 @@ const (
kindDiskTrim = "disk-trim"
kindDiskCompact = "disk-compact"
+ // the Google Play Store (playstore.go): an instance's install and uninstall, the root's import of its files
+ kindPlayStoreInstall = "playstore-install"
+ kindPlayStoreUninstall = "playstore-uninstall"
+ kindPlayStoreImport = "playstore-import"
+
stateRunning = "running"
stateDone = "done"
stateFailed = "failed"
@@ -58,10 +63,28 @@ func kindLabel(kind string) string {
return "回收磁盘空间"
case kindDiskCompact:
return "整理磁盘"
+ case kindPlayStoreInstall:
+ return "补全 Google Play 商店"
+ case kindPlayStoreUninstall:
+ return "移除 Google Play 商店"
+ case kindPlayStoreImport:
+ return "导入 Google Play 商店的文件"
}
return kind
}
+// sdkJob: a root job that writes the root's sdk\ (components fetch, the store's import). bootJob: an instance job that
+// boots the instance, so reads sdk\. The two never run at the same time.
+func sdkJob(kind string) bool { return kind == kindComponents || kind == kindPlayStoreImport }
+
+func bootJob(kind string) bool {
+ switch kind {
+ case kindStart, kindCreate, "restart", kindPlayStoreInstall, kindPlayStoreUninstall:
+ return true
+ }
+ return false
+}
+
type jobProgress struct {
Step string `json:"step"`
Name string `json:"name,omitempty"`
@@ -200,8 +223,8 @@ func (s *server) startJob(kind string, i int, fn jobFunc) (jobView, error) {
if o.view.State != stateRunning {
continue
}
- if (kind == kindComponents && o.view.Index != rootJobIndex && (o.view.Kind == kindStart || o.view.Kind == kindCreate || o.view.Kind == "restart")) ||
- (i != rootJobIndex && o.view.Kind == kindComponents && (kind == kindStart || kind == kindCreate || kind == "restart")) {
+ if (sdkJob(kind) && o.view.Index != rootJobIndex && bootJob(o.view.Kind)) ||
+ (i != rootJobIndex && sdkJob(o.view.Kind) && bootJob(kind)) {
s.jobs.mu.Unlock()
return jobView{}, errs.Newf(errs.Busy, "任务 %s 正在运行(任务 %s),等它做完再试", kindLabel(o.view.Kind), o.view.ID)
}
@@ -286,7 +309,7 @@ func (s *server) finishJob(ctx context.Context, j *job, res any, err error) {
v.Error = newErrorBody(err)
view := j.snapshot()
s.jobs.mu.Unlock()
- if view.Kind == kindComponents {
+ if sdkJob(view.Kind) {
s.check.mu.Lock()
s.check.res, s.check.at = nil, time.Time{}
s.check.generation++
diff --git a/internal/ui/playstore.go b/internal/ui/playstore.go
new file mode 100644
index 0000000..8681d82
--- /dev/null
+++ b/internal/ui/playstore.go
@@ -0,0 +1,180 @@
+package ui
+
+// The Google Play Store (ModAVD/ModAVD#125) in the manager: an instance's 「补全 / 移除 Google Play 商店」 (emu
+// PlayStore) and 补全组件's 「使用我自己的文件」 (components.ImportPlayStore). The store's state shows in GET
+// /api/components (play_store) and on each instance (InstanceInfo.play_store).
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "mime"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "modavd/internal/components"
+ "modavd/internal/errs"
+ "modavd/internal/playstore"
+)
+
+// maxImportFiles: the five files and the zip, with room for one given twice.
+const maxImportFiles = 8
+
+var (
+ // maxImportPart is the largest file worth receiving: the Play image's zip (the five files are far smaller).
+ maxImportPart int64 = playstore.ImageSize
+ // maxImportBody bounds a whole import upload: the zip and the five files, with room for the multipart framing.
+ maxImportBody = maxImportPart + 2*playstore.ExtractedBytes() + 1<<20
+)
+
+// handlePlayStore is POST /api/instances/{i}/playstore-install and playstore-uninstall: a job running emu's
+// PlayStore. What it refuses before touching the instance (no bridge, the root has not got the files, not there,
+// being created) is answered at once, with errs.Reason in the details, so the page can offer the way out.
+func (s *server) handlePlayStore(action string) http.HandlerFunc {
+ kind, doing, done := kindPlayStoreInstall, "正在补全 Google Play 商店(会冷启动一次)", "已补全 Google Play 商店"
+ if action == "uninstall" {
+ kind, doing, done = kindPlayStoreUninstall, "正在移除 Google Play 商店(会冷启动一次)", "已移除 Google Play 商店"
+ }
+ return func(w http.ResponseWriter, r *http.Request) {
+ i, ok := pathIndex(w, r)
+ if !ok || !decode(w, r, &struct{}{}) {
+ return
+ }
+ if err := s.checkFree(kind, i); err != nil {
+ writeError(w, err)
+ return
+ }
+ if err := s.mgr.PlayStoreCheck(i, action); err != nil {
+ writeError(w, err)
+ return
+ }
+ s.respondJob(w)(s.startJob(kind, i, func(ctx context.Context, j *job, b backend) (any, error) {
+ s.setProgress(j, jobProgress{Step: kind, Message: fmt.Sprintf("实例 %d %s", i, doing), Total: progressTotal})
+ res, err := b.PlayStore(ctx, i, action)
+ if err != nil {
+ return nil, err
+ }
+ s.setProgress(j, jobProgress{Step: "done", Message: fmt.Sprintf("实例 %d %s", i, done), Done: progressTotal, Total: progressTotal})
+ return res, nil
+ }))
+ }
+}
+
+// handleImportPlayStore is POST /api/components/import-playstore: multipart/form-data with the field "file" once per
+// file (1 to maxImportFiles: Google's Play image zip, or the store's files). Like an APK upload the parts are streamed
+// to a folder under %TEMP%\modavd-ui\ (never held in memory) and deleted when the job ends; a part larger than the zip
+// is refused while it arrives. The job (a root job, kind playstore-import) checks and imports them; its result is
+// components.PlayStoreImportResult, a refusal components_rejected with errs.Reason.
+func (s *server) handleImportPlayStore(w http.ResponseWriter, r *http.Request) {
+ // refuse before the upload: 1.5 GB only to hear "busy" is the worst outcome
+ if err := s.checkFree(kindPlayStoreImport, rootJobIndex); err != nil {
+ writeError(w, err)
+ return
+ }
+ if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt != "multipart/form-data" {
+ writeError(w, errs.New(errs.Usage, "导入要用 multipart/form-data 上传,文件字段名 file(每个文件一个)"))
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, maxImportBody)
+ dir, files, err := receiveImport(r)
+ if err != nil {
+ writeError(w, err)
+ return
+ }
+ v, err := s.startRootJob(kindPlayStoreImport, func(ctx context.Context, j *job, _ backend) (any, error) {
+ defer os.RemoveAll(dir)
+ return s.jobDiag(j).ImportPlayStore(ctx, files, func(p DiagProgress) { s.setDiagProgress(j, p) })
+ })
+ if err != nil {
+ os.RemoveAll(dir)
+ }
+ s.respondJob(w)(v, err)
+}
+
+// receiveImport streams every "file" part into a new folder under uploadRoot: dir is that folder, files what the
+// import gets (each one's Name is the file name the page sent).
+func receiveImport(r *http.Request) (dir string, files []components.ImportFile, err error) {
+ mr, err := r.MultipartReader()
+ if err != nil {
+ return "", nil, errs.Wrap(errs.Usage, err, "上传的内容不是有效的 multipart/form-data")
+ }
+ defer func() {
+ if err != nil && dir != "" {
+ os.RemoveAll(dir)
+ dir, files = "", nil
+ }
+ }()
+ for {
+ part, perr := mr.NextPart()
+ if errors.Is(perr, io.EOF) {
+ break
+ }
+ if perr != nil {
+ return dir, nil, importUploadError(perr)
+ }
+ if part.FormName() != "file" {
+ part.Close()
+ continue
+ }
+ if len(files) == maxImportFiles {
+ part.Close()
+ return dir, nil, errs.Newf(errs.InvalidArgument, "一次最多导入 %d 个文件", maxImportFiles)
+ }
+ name := safeFileName(part.FileName())
+ if part.FileName() == "" {
+ name = fmt.Sprintf("文件 %d", len(files)+1)
+ }
+ if dir == "" {
+ if err := os.MkdirAll(uploadRoot(), 0o755); err != nil {
+ return "", nil, errs.Wrap(errs.Internal, err, "建不了临时文件夹 "+uploadRoot()+": "+err.Error())
+ }
+ if dir, err = os.MkdirTemp(uploadRoot(), "upload-"); err != nil {
+ return "", nil, errs.Wrap(errs.Internal, err, "建不了临时文件夹: "+err.Error())
+ }
+ }
+ // numbered: two files of the same name may come (both are checked)
+ path := filepath.Join(dir, fmt.Sprintf("%d-%s", len(files)+1, name))
+ n, err := saveImportPart(path, io.LimitReader(part, maxImportPart+1))
+ if err != nil {
+ return dir, nil, err
+ }
+ if n > maxImportPart {
+ return dir, nil, errs.Newf(errs.InvalidArgument, "%s 比 Google Play 版镜像的压缩包还大,不是要导入的文件", name)
+ }
+ if n == 0 {
+ return dir, nil, errs.Newf(errs.InvalidArgument, "上传的 %s 是空文件", name)
+ }
+ files = append(files, components.ImportFile{Path: path, Name: name})
+ }
+ if len(files) == 0 {
+ return dir, nil, errs.New(errs.Usage, "上传里没有文件字段 file")
+ }
+ return dir, files, nil
+}
+
+// saveImportPart is saveTo with the import's message for a body that is too large.
+func saveImportPart(path string, src io.Reader) (int64, error) {
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if err != nil {
+ return 0, errs.Wrap(errs.Internal, err, "写不了临时文件 "+path+": "+err.Error())
+ }
+ n, err := io.Copy(f, src)
+ cerr := f.Close()
+ if err != nil {
+ return n, importUploadError(err)
+ }
+ if cerr != nil {
+ return n, errs.Wrap(errs.Internal, cerr, "写临时文件失败: "+cerr.Error())
+ }
+ return n, nil
+}
+
+func importUploadError(err error) error {
+ var tooBig *http.MaxBytesError
+ if errors.As(err, &tooBig) {
+ return errs.New(errs.InvalidArgument, "上传的文件加起来太大:只要 Google Play 版镜像的压缩包,或者商店的五个文件")
+ }
+ return errs.Wrap(errs.Usage, err, "上传没有传完: "+err.Error())
+}
diff --git a/internal/ui/playstore_test.go b/internal/ui/playstore_test.go
new file mode 100644
index 0000000..141833e
--- /dev/null
+++ b/internal/ui/playstore_test.go
@@ -0,0 +1,342 @@
+//go:build windows
+
+package ui
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "path/filepath"
+ "slices"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+
+ "modavd/internal/components"
+ "modavd/internal/emu"
+ "modavd/internal/errs"
+ "modavd/internal/logx"
+ "modavd/internal/root"
+ "modavd/internal/testutil"
+)
+
+// psFake is the store's side of fakeBackend and fakeDiag (their structs are shared with other tests): what was
+// called, what the check refuses with, and the files each import got (read while the job ran).
+var psFake struct {
+ mu sync.Mutex
+ calls []string // "check install 3", "run install 3"
+ checkErr error
+ imported [][]importedFile
+ importErr error
+ gate chan struct{} // ImportPlayStore waits here when set
+}
+
+type importedFile struct{ name, path, content string }
+
+func resetPSFake(t *testing.T) {
+ t.Helper()
+ reset := func() {
+ psFake.mu.Lock()
+ psFake.calls, psFake.checkErr, psFake.imported, psFake.importErr, psFake.gate = nil, nil, nil, nil, nil
+ psFake.mu.Unlock()
+ }
+ reset()
+ t.Cleanup(reset)
+}
+
+func psCalls() []string {
+ psFake.mu.Lock()
+ defer psFake.mu.Unlock()
+ return slices.Clone(psFake.calls)
+}
+
+func (b fakeBackend) PlayStoreCheck(i int, action string) error {
+ psFake.mu.Lock()
+ defer psFake.mu.Unlock()
+ psFake.calls = append(psFake.calls, "check "+action+" "+strconv.Itoa(i))
+ return psFake.checkErr
+}
+
+func (b fakeBackend) PlayStore(ctx context.Context, i int, action string) (*emu.PlayStoreResult, error) {
+ psFake.mu.Lock()
+ psFake.calls = append(psFake.calls, "run "+action+" "+strconv.Itoa(i))
+ psFake.mu.Unlock()
+ state := map[string]string{"install": "installed", "uninstall": "absent"}[action]
+ return &emu.PlayStoreResult{Index: i, Action: action, State: state, Installed: state == "installed", Restarted: true,
+ Files: []string{}, Warnings: []string{}}, nil
+}
+
+func (f *fakeDiag) ImportPlayStore(ctx context.Context, files []components.ImportFile, progress func(DiagProgress)) (*components.PlayStoreImportResult, error) {
+ var got []importedFile
+ for _, in := range files {
+ b, err := os.ReadFile(in.Path)
+ if err != nil {
+ return nil, err
+ }
+ got = append(got, importedFile{in.Name, in.Path, string(b)})
+ }
+ psFake.mu.Lock()
+ psFake.imported = append(psFake.imported, got)
+ gate, fail := psFake.gate, psFake.importErr
+ psFake.mu.Unlock()
+ progress(DiagProgress{Stage: "verifying", Done: 1, Total: 2})
+ if gate != nil {
+ select {
+ case <-gate:
+ case <-ctx.Done():
+ return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消")
+ }
+ }
+ if fail != nil {
+ return nil, fail
+ }
+ return &components.PlayStoreImportResult{Root: fakeRoot.Root, From: "files", Used: []string{}, Kept: []string{},
+ Status: components.PlayStoreStatus{Ready: true, Source: components.PlayStoreFromImport, Files: []components.PlayStoreFile{}}}, nil
+}
+
+// storeDiag is a diagnostic backend that records what each fetch asked about the Google Play Store.
+type storeDiag struct {
+ fakeDiag
+ mu sync.Mutex
+ asked []bool
+}
+
+func (d *storeDiag) Fetch(ctx context.Context, accept, playStore bool, s components.SourceSettings, sha string, progress func(DiagProgress)) (*DiagFetchResult, error) {
+ d.mu.Lock()
+ d.asked = append(d.asked, playStore)
+ d.mu.Unlock()
+ return &DiagFetchResult{Root: fakeRoot.Root}, nil
+}
+
+// TestPlayStoreFetchAndCreate: play_store in a fetch body reaches the backend, without it the store is not asked
+// for; a create body with play_store is refused (new instances never get the store).
+func TestPlayStoreFetchAndCreate(t *testing.T) {
+ d := &storeDiag{}
+ h := newHarness(t, func(o *Options) { o.newDiagBackend = func(*logx.Logger) diagBackend { return d } })
+ for _, extra := range []string{"", `,"play_store":true`} {
+ var j jobView
+ expect(t, h.do("POST", "/api/components/fetch", fetchBody(extra)), http.StatusAccepted, &j)
+ h.waitJob(j.ID, stateIs(stateDone))
+ }
+ d.mu.Lock()
+ if len(d.asked) != 2 || d.asked[0] || !d.asked[1] {
+ t.Errorf("fetches asked %v", d.asked)
+ }
+ d.mu.Unlock()
+ expectError(t, h.do("POST", "/api/instances", map[string]any{"index": 3, "play_store": true}), http.StatusBadRequest, errs.Usage)
+ h.f.mu.Lock()
+ defer h.f.mu.Unlock()
+ if len(h.f.created) != 0 {
+ t.Errorf("created %+v", h.f.created)
+ }
+}
+
+// TestPlayStoreInstanceJobs: playstore-install / playstore-uninstall check first, then run as the instance's job
+// (kind, index, result); what the check refuses is answered at once with its reason and nothing runs.
+func TestPlayStoreInstanceJobs(t *testing.T) {
+ resetPSFake(t)
+ h := newHarness(t, nil)
+ for _, action := range []string{"install", "uninstall"} {
+ var j jobView
+ expect(t, h.do("POST", "/api/instances/3/playstore-"+action, nil), http.StatusAccepted, &j)
+ if j.Kind != "playstore-"+action || j.Index != 3 {
+ t.Fatalf("job %+v", j)
+ }
+ done := h.waitJob(j.ID, stateIs(stateDone))
+ res, _ := done.Result.(map[string]any)
+ if res["action"] != action || res["restarted"] != true {
+ t.Errorf("result %v", done.Result)
+ }
+ }
+ if want := []string{"check install 3", "run install 3", "check uninstall 3", "run uninstall 3"}; !slices.Equal(psCalls(), want) {
+ t.Errorf("calls %v, want %v", psCalls(), want)
+ }
+
+ for _, reason := range []string{emu.PlayStoreReasonFiles, emu.PlayStoreReasonNoBridge} {
+ resetPSFake(t)
+ psFake.mu.Lock()
+ psFake.checkErr = errs.WithDetail(errs.New(errs.NotReady, "不行").WithAdvice("先补全"), errs.Reason, reason)
+ psFake.mu.Unlock()
+ e := expectError(t, h.do("POST", "/api/instances/3/playstore-install", nil), http.StatusUnprocessableEntity, errs.NotReady)
+ if e.Details[errs.Reason] != reason || e.Advice != "先补全" {
+ t.Errorf("%s: %+v", reason, e)
+ }
+ if want := []string{"check install 3"}; !slices.Equal(psCalls(), want) {
+ t.Errorf("%s: calls %v", reason, psCalls())
+ }
+ }
+ expectError(t, h.do("POST", "/api/instances/x/playstore-install", nil), http.StatusBadRequest, errs.InvalidArgument)
+ expectError(t, h.do("POST", "/api/instances/3/playstore-install", `{"force":true}`), http.StatusBadRequest, errs.Usage)
+}
+
+// importBody is a multipart body with one "file" part per entry (name, content).
+func importBody(t *testing.T, files ...[2]string) ([]byte, string) {
+ t.Helper()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ mw.WriteField("note", "ignored")
+ for _, f := range files {
+ fw, err := mw.CreateFormFile("file", f[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ io.WriteString(fw, f[1])
+ }
+ mw.Close()
+ return buf.Bytes(), mw.FormDataContentType()
+}
+
+// TestImportPlayStoreUpload: the uploaded files reach the import under their names (two of the same name both),
+// as a root job; the upload folder is gone after it. Not multipart, no file, an empty one, too many and one larger
+// than the zip are refused before any job. A refusal of the import fails the job with its reason.
+func TestImportPlayStoreUpload(t *testing.T) {
+ resetPSFake(t)
+ tmp := testutil.TempDir(t)
+ t.Setenv("TMP", tmp)
+ t.Setenv("TEMP", tmp)
+ h := diagHarness(t, &fakeDiag{}, nil)
+
+ body, ct := importBody(t, [2]string{"Phonesky.apk", "store"}, [2]string{`..\..\cert.der`, "cert"}, [2]string{"Phonesky.apk", "again"})
+ var j jobView
+ expect(t, h.do("POST", "/api/components/import-playstore", body, withHeader("Content-Type", ct)), http.StatusAccepted, &j)
+ if j.Kind != kindPlayStoreImport || j.Index != rootJobIndex {
+ t.Fatalf("job %+v", j)
+ }
+ done := h.waitJob(j.ID, stateIs(stateDone))
+ if res, _ := done.Result.(map[string]any); res["from"] != "files" {
+ t.Errorf("result %v", done.Result)
+ }
+ psFake.mu.Lock()
+ got := psFake.imported
+ psFake.mu.Unlock()
+ if len(got) != 1 || len(got[0]) != 3 {
+ t.Fatalf("imported %+v", got)
+ }
+ for k, want := range []importedFile{{name: "Phonesky.apk", content: "store"}, {name: "cert.der", content: "cert"}, {name: "Phonesky.apk", content: "again"}} {
+ if g := got[0][k]; g.name != want.name || g.content != want.content || !strings.HasPrefix(g.path, uploadRoot()+`\`) {
+ t.Errorf("file %d: %+v", k, g)
+ }
+ }
+ if _, err := os.Stat(filepath.Dir(got[0][0].path)); !os.IsNotExist(err) {
+ t.Errorf("upload folder left: %v", err)
+ }
+
+ expectError(t, h.do("POST", "/api/components/import-playstore", `{"file":"C:\\x.zip"}`), http.StatusBadRequest, errs.Usage)
+ for _, c := range []struct {
+ files [][2]string
+ code string
+ }{
+ {nil, errs.Usage},
+ {[][2]string{{"a.apk", ""}}, errs.InvalidArgument},
+ {[][2]string{{"1", "a"}, {"2", "a"}, {"3", "a"}, {"4", "a"}, {"5", "a"}, {"6", "a"}, {"7", "a"}, {"8", "a"}, {"9", "a"}}, errs.InvalidArgument},
+ } {
+ body, ct := importBody(t, c.files...)
+ expectError(t, h.do("POST", "/api/components/import-playstore", body, withHeader("Content-Type", ct)), http.StatusBadRequest, c.code)
+ }
+ old := maxImportPart
+ maxImportPart = 4
+ body, ct = importBody(t, [2]string{"big.zip", "12345"})
+ expectError(t, h.do("POST", "/api/components/import-playstore", body, withHeader("Content-Type", ct)), http.StatusBadRequest, errs.InvalidArgument)
+ maxImportPart = old
+ psFake.mu.Lock()
+ n := len(psFake.imported)
+ psFake.mu.Unlock()
+ if n != 1 {
+ t.Errorf("imports after the refusals: %d", n)
+ }
+ if left, _ := os.ReadDir(uploadRoot()); len(left) != 0 {
+ t.Errorf("upload folders left: %v", left)
+ }
+
+ psFake.mu.Lock()
+ psFake.importErr = errs.WithDetail(errs.New(errs.ComponentsRejected, "SHA-256 对不上"), errs.Reason, components.ImportReasonMismatch)
+ psFake.mu.Unlock()
+ body, ct = importBody(t, [2]string{"Phonesky.apk", "forged"})
+ expect(t, h.do("POST", "/api/components/import-playstore", body, withHeader("Content-Type", ct)), http.StatusAccepted, &j)
+ failed := h.waitJob(j.ID, stateIs(stateFailed))
+ if failed.Error == nil || failed.Error.Code != errs.ComponentsRejected || failed.Error.Details[errs.Reason] != components.ImportReasonMismatch {
+ t.Errorf("refused import: %+v", failed.Error)
+ }
+}
+
+// TestPlayStoreImportExcludesBoots: while an import runs, the store's install (a boot), a start, another import and a
+// components fetch are busy; once it is done the install runs.
+func TestPlayStoreImportExcludesBoots(t *testing.T) {
+ resetPSFake(t)
+ tmp := testutil.TempDir(t)
+ t.Setenv("TMP", tmp)
+ t.Setenv("TEMP", tmp)
+ gate := make(chan struct{})
+ psFake.mu.Lock()
+ psFake.gate = gate
+ psFake.mu.Unlock()
+ h := diagHarness(t, &fakeDiag{}, nil)
+ body, ct := importBody(t, [2]string{"Phonesky.apk", "store"})
+ var j jobView
+ expect(t, h.do("POST", "/api/components/import-playstore", body, withHeader("Content-Type", ct)), http.StatusAccepted, &j)
+ h.waitJob(j.ID, func(v jobView) bool { return v.Progress != nil })
+ expectError(t, h.do("POST", "/api/instances/3/playstore-install", nil), http.StatusConflict, errs.Busy)
+ expectError(t, h.do("POST", "/api/instances/3/start", nil), http.StatusConflict, errs.Busy)
+ expectError(t, h.do("POST", "/api/components/import-playstore", body, withHeader("Content-Type", ct)), http.StatusConflict, errs.Busy)
+ expectError(t, h.do("POST", "/api/components/fetch", fetchBody("")), http.StatusConflict, errs.Busy)
+ close(gate)
+ h.waitJob(j.ID, stateIs(stateDone))
+ var k jobView
+ expect(t, h.do("POST", "/api/instances/3/playstore-install", nil), http.StatusAccepted, &k)
+ h.waitJob(k.ID, stateIs(stateDone))
+}
+
+// TestRealDiagBackendPlayStore: the real adapter hands components.Fetch the play_store choice and the import its
+// files with the job's log; the component list ends with the store (optional) and play_store has its five files.
+func TestRealDiagBackendPlayStore(t *testing.T) {
+ oldFetch, oldImport := diagFetch, diagImport
+ t.Cleanup(func() { diagFetch, diagImport = oldFetch, oldImport })
+ var got []components.Options
+ diagFetch = func(_ context.Context, r *root.Root, o components.Options) (*components.Result, error) {
+ got = append(got, o)
+ return &components.Result{Root: r.Dir}, nil
+ }
+ var imported []components.ImportFile
+ diagImport = func(_ context.Context, r *root.Root, files []components.ImportFile, o components.Options) (*components.PlayStoreImportResult, error) {
+ imported = append(imported, files...)
+ if o.Log == nil || o.Progress == nil {
+ t.Error("import without log or progress")
+ }
+ o.Progress(components.Progress{Stage: "verifying", Done: 1, Total: 2})
+ return &components.PlayStoreImportResult{Root: r.Dir}, nil
+ }
+ r, err := root.New(testutil.TempDir(t))
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := newRealDiagBackend(r, logx.Nop())
+ for _, store := range []bool{false, true} {
+ if _, err := b.Fetch(context.Background(), true, store, components.SourceSettings{}, fakeHash, nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if len(got) != 2 || got[0].PlayStore || !got[1].PlayStore {
+ t.Fatalf("options %+v", got)
+ }
+ var progress []DiagProgress
+ files := []components.ImportFile{{Path: `C:\u\1-Phonesky.apk`, Name: "Phonesky.apk"}}
+ if _, err := b.ImportPlayStore(context.Background(), files, func(p DiagProgress) { progress = append(progress, p) }); err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Equal(imported, files) || len(progress) != 1 || progress[0].Stage != "verifying" {
+ t.Errorf("imported %+v, progress %+v", imported, progress)
+ }
+
+ res, err := b.Components(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ last := res.Components[len(res.Components)-1]
+ if last.ID != "play-store" || !last.Optional || last.Installed || res.PlayStore.Ready || len(res.PlayStore.Files) != 5 {
+ t.Errorf("components %+v, play_store %+v", res.Components, res.PlayStore)
+ }
+}
diff --git a/internal/ui/web/app.css b/internal/ui/web/app.css
index c5d9661..a70c439 100644
--- a/internal/ui/web/app.css
+++ b/internal/ui/web/app.css
@@ -970,6 +970,29 @@ dialog > .toasts .toast.out { animation-name: toast-out-left; }
.comps summary .muted { font-weight: 400; }
.comp-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 12px; align-items: center; padding: 8px 12px; border-top: 1px solid var(--line); font-size: 13px; }
.comp-row .v { color: var(--text-3); font-variant-numeric: tabular-nums; }
+/* Google Play 商店服务: its own block in 电脑检查, and the option under the license in 补全组件 */
+.store-box {
+ display: flex; flex-direction: column; gap: 8px; margin-top: 8px; padding: 12px;
+ border: 1px solid var(--line); border-radius: var(--r-card); outline: none;
+ transition: box-shadow .3s var(--ease), border-color .3s var(--ease);
+}
+.store-box.flash { border-color: var(--focus); box-shadow: 0 0 0 3px var(--focus-ring); }
+.store-head { display: flex; align-items: center; gap: 8px; }
+.store-head strong { font-size: 14px; font-weight: 600; }
+.store-note { color: var(--text-2); font-size: 13px; }
+.store-box .diag-running, .store-opt .diag-running { margin: 0; overflow-wrap: anywhere; }
+.store-box .form-error, .store-opt .form-error { margin: 0; }
+.store-opt { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
+.store-choose .f-hint, .store-own { margin-left: 28px; }
+.store-choose .checkbox input:disabled { opacity: .5; cursor: default; }
+.store-own { margin-top: 6px; }
+.store-own .btn { margin-left: -10px; }
+.store-state { margin: 0; color: var(--tf); font-size: 13px; overflow-wrap: anywhere; }
+/* 补全 / 移除 Google Play 商店 on an instance */
+.dlg-store { --w: 480px; }
+.store-q { color: var(--text-2); margin-bottom: 12px; }
+.store-q strong { color: var(--text); font-weight: 400; }
+.dlg-store .note-box { margin-bottom: 0; }
.sk-row { height: 40px; margin: 10px 0; }
/* activity */
diff --git a/internal/ui/web/dev/mock.js b/internal/ui/web/dev/mock.js
index e79c90a..10404a3 100644
--- a/internal/ui/web/dev/mock.js
+++ b/internal/ui/web/dev/mock.js
@@ -1,15 +1,18 @@
// Mock backend for design work: ?mock=normal|empty|notready|busy|many replaces fetch('/api/…') and EventSource with
// an in-memory fake that follows internal/ui/API.md. Extra params: drop=1 (the event stream drops after 1.5 s and
// stays down), offline=1 (it never connects), shotfail=1 (every screenshot answers 500), diag=components-error|components-fail
-// (component status request or fetch fails). window.__mockShots counts screenshot requests per instance. Never used in
-// production: the server does not serve /dev/.
+// (component status request or fetch fails), store=download|import (the root has the Google Play Store's five files,
+// fetched or imported), storefail=1 (an instance's 补全 Google Play 商店 fails in the guest). window.__mockShots counts
+// screenshot requests per instance. Never used in production: the server does not serve /dev/.
// Screens are drawn on a canvas: abstract shapes only, no real game content.
const ROOT = 'X:\\ModAVD';
const INDEXES = [0, 1, 2, 3, 4, 6, 7, 8, 9];
const MEM = [3072, 4096, 5120, 6144];
const db = { insts: new Map(), jobs: [], streams: new Set(), seq: 40, check: 'ok', offline: false, drop: false, ready: true, diagMode: '', componentsReady: false,
- componentSource: { source: 'auto', mirror: '', mirror_urls: [], configured: false } };
+ componentSource: { source: 'auto', mirror: '', mirror_urls: [], configured: false },
+ // the root's Google Play Store files (sdk\playstore): source is download | import once they are there
+ store: { ready: false, source: '' } };
const now = () => Date.now();
const iso = (t = now()) => {
const d = new Date(t);
@@ -50,6 +53,8 @@ function mk(i, title, o = {}) {
renderer: initialized ? (o.sw ? SWIFT : GPU) : '', software_renderer: !!o.sw,
path: `${ROOT}\\avd\\mas_${i}.avd`,
app_proxy: null,
+ // the execution bridge (off | pending | installed | unknown) and the Google Play Store's record
+ bridge: o.bridge || (initialized ? 'installed' : 'pending'), play_store: !!o.store,
};
Object.defineProperty(inst, '_', { value: { scene: o.scene ?? i, mode: 'game', win: false, fail: o.fail || null, logs: initialized || st !== 'offline', tap: null }, enumerable: false });
db.insts.set(i, inst);
@@ -130,7 +135,7 @@ function setup(sc) {
mk(6, '碧蓝航线', { status: 'offline', mem: 4096, cpu: 4, fps: 30 });
mk(7, '日常备用', { status: 'online', upMin: 7, scene: 1, sw: true, mem: 3072, cpu: 2, fps: 30 });
mk(8, '', { status: 'offline', initialized: false, disk: 32 });
- mk(9, '公主连结', { status: 'offline', mem: 5120, cpu: 4 });
+ mk(9, '公主连结', { status: 'offline', mem: 5120, cpu: 4, bridge: 'off' });
longStart(3, 9);
return;
}
@@ -169,7 +174,7 @@ function setup(sc) {
}
// normal
mk(0, '明日方舟 · 主号', { status: 'online', upMin: 133, scene: 0, proxy: ['com.example.globalgame', 'com.google.android.gms'] });
- mk(1, '星穹铁道', { status: 'offline', mem: 6144, proxy: ['com.miHoYo.hkrpg'] });
+ mk(1, '星穹铁道', { status: 'offline', mem: 6144, proxy: ['com.miHoYo.hkrpg'], store: true });
mk(8, '重返未来 1999', { status: 'starting', upMin: 0.3, scene: 2 });
oldJob('stop', 1, 'done', 5400, 7, { result: { index: 1, name: 'mas_1', method: 'graceful', synced: true, seconds: 6.9, warnings: [] } });
oldJob('start', 0, 'done', 133 * 60, 38, { result: { launched: true, boot_seconds: 19.2, total_seconds: 37.6, first_boot: false, warnings: [], zygote_restarted: false } });
@@ -240,6 +245,105 @@ function checkResult() {
return { ...rootInfo(), ready, items, components: comps };
}
+// ---- the Google Play Store: the root's five files (GET /api/components play_store) -------------------------------------
+
+const STORE_FILES = [['Phonesky.apk', '/product/priv-app/Phonesky/Phonesky.apk', 74664153],
+ ['PrebuiltGmsCore.apk', '/product/priv-app/PrebuiltGmsCore/PrebuiltGmsCore.apk', 144060779],
+ ['GoogleServicesFramework.apk', '/system_ext/priv-app/GoogleServicesFramework/GoogleServicesFramework.apk', 5744939],
+ ['PartnerSetupPrebuilt.apk', '/product/priv-app/PartnerSetupPrebuilt/PartnerSetupPrebuilt.apk', 726118],
+ ['play_store_fsi_cert.der', '/product/etc/security/fsverity/play_store_fsi_cert.der', 1422]];
+const STORE_ZIP = 1510752654;
+
+function storeStatus() {
+ const s = db.store;
+ return { dir: ROOT + '\\sdk\\playstore', ready: s.ready, source: s.ready ? s.source : '', version: s.ready ? '14' : '',
+ files: STORE_FILES.map(([name, guest, size]) => ({ name, guest, size, present: s.ready })) };
+}
+
+// GET /api/components: the components, then the store's summary item
+function componentList() {
+ return [...checkResult().components,
+ { id: 'play-store', name: 'Google Play 商店', version: db.store.ready ? '14' : '', build: '', installed: db.store.ready, optional: true, local_sdk: false }];
+}
+
+const sdkJob = () => db.jobs.find((j) => (j.kind === 'components' || j.kind === 'playstore-import') && j.state === 'running');
+
+// 使用我自己的文件: the mock knows a file by its name (the real import by its size, then its hash): *.zip is the Play
+// image, the five names are the files, a name with "bad" fails its hash, anything else is not one of them.
+function runImport(j, files) {
+ const named = files.map((f) => ({ f, zip: /\.zip$/i.test(f.name), slot: STORE_FILES.findIndex(([n]) => n === f.name) }));
+ const unknown = named.find((x) => !x.zip && x.slot < 0);
+ const zip = named.find((x) => x.zip);
+ const steps = [];
+ for (const x of named) if (x.zip || x.slot >= 0) steps.push(['verifying', `校验 ${x.f.name}${x.zip ? '(SHA-1)' : `(${STORE_FILES[x.slot][0]})`}`, x.zip ? STORE_ZIP : STORE_FILES[x.slot][2]]);
+ if (zip) steps.push(['extracting', `解压 ${zip.f.name}`, 3101687808]);
+ log('info', '魔改 AVD:用自己的文件补全 Google Play 商店', j.id);
+ let k = 0;
+ const timer = setInterval(() => {
+ if (j.state !== 'running') { clearInterval(timer); return; }
+ if (unknown) {
+ clearInterval(timer);
+ return failJob(j, 'components_rejected', `${unknown.f.name}(${unknown.f.size} 字节)不是 Google Play 版镜像的压缩包 x86_64-34_r14.zip,也不是商店的五个文件(${STORE_FILES.map(([n]) => n).join('、')})之一`, '', { reason: 'unknown_file' });
+ }
+ if (k < steps.length) {
+ const [step, name, total] = steps[k++];
+ j.progress = { step, name, message: ` ${name} 100.0% ${(total / 1048576).toFixed(1)} MB / ${(total / 1048576).toFixed(1)} MB`, done: total, total };
+ emit('job', j);
+ log('info', name + '…', j.id);
+ const bad = named.find((x) => /bad/i.test(x.f.name));
+ if (bad && name.includes(bad.f.name)) {
+ clearInterval(timer);
+ failJob(j, 'components_rejected', bad.zip
+ ? `${bad.f.name} 的 SHA-1 是 0123…(mock),应为 01d32617fd1937e540faf8731d70cf50e35af854:不是 Google 仓库里那份 Google Play 版镜像(x86_64-34_r14.zip)`
+ : `${bad.f.name} 和 Google Play 版镜像 r14 里的 ${STORE_FILES[bad.slot][0]} 大小一样,SHA-256 却对不上(mock)`, '', { reason: 'mismatch' });
+ }
+ return;
+ }
+ clearInterval(timer);
+ const got = new Set(named.filter((x) => !x.zip).map((x) => x.slot));
+ if (!zip && !db.store.ready && got.size < STORE_FILES.length) {
+ const missing = STORE_FILES.filter((_, s) => !got.has(s)).map(([n]) => n);
+ return failJob(j, 'components_rejected', `还缺 ${missing.join('、')},什么也没有改`, '把五个文件一起给出,或者给 Google Play 版镜像的压缩包 x86_64-34_r14.zip', { reason: 'incomplete' });
+ }
+ const skipped = db.store.ready;
+ if (!skipped) db.store = { ready: true, source: 'import' };
+ finishJob(j, { root: ROOT, from: zip ? 'zip' : 'files', used: files.map((f) => f.name), kept: [], skipped, set_aside: '', status: storeStatus() });
+ }, 700);
+}
+
+// 补全 / 移除 Google Play 商店 on an instance: boots it cold once (an instance that was off is booted for it and stopped
+// again); the card follows the instance through the stop and the boot.
+function runPlayStore(inst, j, action) {
+ const i = inst.index;
+ const was = inst.running;
+ const set = (o) => { Object.assign(inst, o); emit('instances', list()); };
+ const stopIt = () => { set({ busy: 'stopping' }); };
+ const off = () => { set({ status: 'offline', running: false, booted: false, busy: '', pid: 0, started_at: '' }); inst.screencap.shm_ready = false; };
+ const boot = () => set({ status: 'starting', running: true, busy: 'starting', pid: 10000 + Math.floor(Math.random() * 9000), started_at: iso() });
+ const up = () => { set({ status: 'online', booted: true, busy: '' }); inst.screencap.shm_ready = true; };
+ const doing = action === 'install' ? '正在补全 Google Play 商店(会冷启动一次)' : '正在移除 Google Play 商店(会冷启动一次)';
+ j.progress = { step: j.kind, message: `实例 ${i} ${doing}`, done: 0, total: 1000 };
+ emit('job', j);
+ log('info', `实例 ${i} ${doing}`, j.id);
+ const steps = [];
+ if (!was) steps.push([600, boot], [2500, up]);
+ steps.push([1500, () => log('info', action === 'install' ? `实例 ${i} 推送商店的五个文件,放进系统覆盖层` : `实例 ${i} 卸掉 Google Play 装的更新,删掉商店的文件`, j.id)]);
+ if (action === 'install' && db.storefail) {
+ steps.push([800, () => failJob(j, 'launch_failed', `实例 ${i} 的 Google Play 商店脚本(install)没有跑完:cp: /product/priv-app/Phonesky/Phonesky.apk.new: No space left on device(mock)`, '')]);
+ run(j, steps);
+ return;
+ }
+ steps.push([800, stopIt], [1600, off], [700, boot], [2600, up]);
+ if (!was) steps.push([800, stopIt], [1400, off]);
+ steps.push([600, () => {
+ inst.play_store = action === 'install';
+ const state = action === 'install' ? 'installed' : 'absent';
+ finishJob(j, { index: i, action, state, installed: state === 'installed', files: STORE_FILES.map(([, g]) => `${state === 'installed' ? 'play' : 'absent'} ${g}`),
+ restarted: true, stopped: !was, warnings: [] });
+ }]);
+ run(j, steps);
+}
+
// ---- jobs ------------------------------------------------------------------------------------------------------------
function newJob(kind, i) {
@@ -269,11 +373,11 @@ function finishJob(j, result, warnings = []) {
emit('instances', list());
}
-function failJob(j, code, message, advice) {
+function failJob(j, code, message, advice, details) {
if (j.state !== 'running') return;
j.state = 'failed';
j.finished_at = iso();
- j.error = { code, message, advice };
+ j.error = { code, message, advice, ...(details ? { details } : {}) };
emit('job', j);
log('error', message, j.id);
emit('instances', list());
@@ -323,12 +427,15 @@ function mockLicense(source, urls) {
return { license, license_source: from, license_sha256: x.toString(16).padStart(8, '0').repeat(8) };
}
-function runDiag(j, kind = j.kind) {
+function runDiag(j, kind = j.kind, store = false) {
const stages = kind === 'components'
? [['downloading', '平台工具(adb)', 2.7, 8.0, 1.1], ['downloading', '平台工具(adb)', 6.4, 8.0, 1.3], ['extracting', '平台工具(adb)', 8.0, 8.0, 0],
['downloading', '系统镜像(Android 14 Google APIs x86_64)', 310, 1480, 24], ['downloading', '系统镜像(Android 14 Google APIs x86_64)', 960, 1480, 26],
['extracting', '系统镜像(Android 14 Google APIs x86_64)', 1480, 1480, 0]]
: [['collect', '正在收集目录与版本', 1, 12, 0.4], ['collect', '正在收集日志', 4, 12, 0.8], ['collect', '正在收集现场状态', 8, 12, 1.1], ['zip', '正在压缩问题包', 11, 12, 1.4], ['done', '问题包已生成', 12, 12, 0]];
+ // play_store: Google's Play image too, of which only the five files stay
+ if (store) stages.push(['downloading', 'Google Play 商店(取自 Android 14 Google Play x86_64 镜像 r14)', 420, 1441, 25], ['downloading', 'Google Play 商店(取自 Android 14 Google Play x86_64 镜像 r14)', 1180, 1441, 27],
+ ['extracting', 'Google Play 商店(取自 Android 14 Google Play x86_64 镜像 r14)', 2958, 2958, 0]);
if (kind === 'components') ['魔改 AVD:补全 Google 组件(adb、Android 14 系统镜像)', '已在管理器里同意许可协议。'].forEach((m) => log('info', m, j.id));
let n = 0;
const timer = setInterval(() => {
@@ -344,7 +451,8 @@ function runDiag(j, kind = j.kind) {
clearInterval(timer);
if (kind === 'components') {
db.componentsReady = true;
- finishJob(j, { root: ROOT, license: 'Android SDK License', license_source: 'Google 中国下载(mock)', components: checkResult().components, downloads: [] });
+ if (store) db.store = { ready: true, source: 'download' };
+ finishJob(j, { root: ROOT, license: 'Android SDK License', license_source: 'Google 中国下载(mock)', components: componentList(), downloads: [] });
} else finishJob(j, { zip_path: `${ROOT}\\logs\\魔改AVD问题信息-mock.zip`, zip_size: '1.2 MB', problems: [], mas_found: true, entries: ['summary.txt'],
privacy_note: '隐私提示(mock):问题包只保存在本机,不会自动上传。\r\n- 可以公开:summary.txt 和日志。\r\n- 不要公开:crash 文件夹。' });
}
@@ -685,8 +793,8 @@ async function screenshot(inst) {
// ---- HTTP ------------------------------------------------------------------------------------------------------------
const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
-const fail = (status, code, message, advice = '') => json({ error: { code, message, advice } }, status);
-const STATUS_OF = { usage: 400, invalid_argument: 400, not_found: 404, busy: 409, already_exists: 409, not_ready: 422, precheck_failed: 422, canceled: 499, launch_failed: 502, proxy_unavailable: 502, timeout: 504 };
+const fail = (status, code, message, advice = '', details) => json({ error: { code, message, advice, ...(details ? { details } : {}) } }, status);
+const STATUS_OF = { usage: 400, invalid_argument: 400, not_found: 404, busy: 409, already_exists: 409, not_ready: 422, precheck_failed: 422, unsupported: 422, canceled: 499, launch_failed: 502, proxy_unavailable: 502, timeout: 504 };
const PKG = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
// what speaks at a typed proxy address: 7890 / 7897 a mixed port, 7891 an HTTP proxy, anything else nothing
@@ -728,6 +836,7 @@ async function handle(url, init) {
if (p === '/api/info') return json({ version: '0.1.0', protocol: 1, root: rootInfo() });
if (p === '/api/instances' && method === 'GET') return json(list());
if (p === '/api/instances' && method === 'POST') {
+ if ('play_store' in body) return errResp('usage', '新建不带 Google Play 商店(没有 play_store 字段)', '建好后在实例的「更多操作」里补全');
const bad = validate(body, true);
if (bad) return errResp(...bad);
let i = body.index;
@@ -751,7 +860,21 @@ async function handle(url, init) {
}
if (p === '/api/components' && method === 'GET') {
if (db.diagMode === 'components-error') return errResp('internal', '组件状态暂不可用', '稍后重试');
- return json({ root: ROOT, components: checkResult().components });
+ return json({ root: ROOT, components: componentList(), play_store: storeStatus() });
+ }
+ if (p === '/api/components/import-playstore' && method === 'POST') {
+ if (!(init.body instanceof FormData)) return errResp('usage', '导入要用 multipart/form-data 上传,文件字段名 file(每个文件一个)');
+ const running = sdkJob();
+ if (running) return errResp('busy', `任务 ${running.kind === 'components' ? '补全组件' : '导入 Google Play 商店的文件'} 正在运行(任务 ${running.id}),等它做完再试`);
+ const files = init.body.getAll('file');
+ if (!files.length) return errResp('usage', '上传里没有文件字段 file');
+ if (files.length > 8) return errResp('invalid_argument', '一次最多导入 8 个文件');
+ const empty = files.find((f) => !f.size);
+ if (empty) return errResp('invalid_argument', `上传的 ${empty.name} 是空文件`);
+ await sleep(Math.min(3000, files.reduce((n, f) => n + f.size, 0) / 1e6)); // the upload itself
+ const j = newJob('playstore-import', -1);
+ runImport(j, files);
+ return json(j, 202);
}
if (p === '/api/components/license' && method === 'GET') {
await sleep(400);
@@ -773,8 +896,8 @@ async function handle(url, init) {
if (body.accept_license !== true) return errResp('usage', '补全组件必须明确同意许可协议');
const lic = mockLicense(body.source || db.componentSource.source, body.mirror_urls || db.componentSource.mirror_urls);
if (lic.error || body.license_sha256 !== lic.license_sha256) return errResp('usage', '补全组件要带上看过的许可协议的 license_sha256');
- if (db.jobs.some((j) => j.kind === 'components' && j.state === 'running')) return errResp('busy', '组件补全正在进行');
- const j = newJob('components', -1); runDiag(j, 'components'); return json(j, 202);
+ if (sdkJob()) return errResp('busy', sdkJob().kind === 'components' ? '组件补全正在进行' : '正在导入 Google Play 商店的文件,等它做完再试');
+ const j = newJob('components', -1); runDiag(j, 'components', body.play_store === true && !db.store.ready); return json(j, 202);
}
if (p === '/api/diag/collect' && method === 'POST') {
if (db.jobs.some((j) => j.kind === 'collect-info' && j.state === 'running')) return errResp('busy', '收集问题信息正在进行');
@@ -800,7 +923,7 @@ async function handle(url, init) {
const j = db.jobs.find((x) => x.id === decodeURIComponent(m[1]));
if (!j) return errResp('not_found', `没有任务 ${m[1]}`);
if (m[2] && method === 'POST') {
- if (j.state === 'running' && (j.kind === 'start' || j.kind === 'components' || j.kind === 'collect-info')) {
+ if (j.state === 'running' && (j.kind === 'start' || j.kind === 'components' || j.kind === 'collect-info' || j.kind === 'playstore-import')) {
j.state = 'canceled';
j.finished_at = iso();
j.error = { code: 'canceled', message: j.kind === 'start' ? '已停止等待,模拟器继续开机' : '已取消后台任务', advice: '' };
@@ -876,6 +999,22 @@ async function handle(url, init) {
setTimeout(() => finishJob(j, { index: i, apk: f.name, package: 'com.example.' + f.name.replace(/\.apk$/i, '').replace(/\W+/g, '').toLowerCase(), output: 'Success' }), 3000);
return json(j, 202);
}
+ if (act === 'playstore-install' || act === 'playstore-uninstall') {
+ const install = act === 'playstore-install';
+ const reason = (code, message, advice, r) => fail(STATUS_OF[code], code, message, advice, { reason: r });
+ if (busyJob || inst.status === 'creating') return errResp('busy', `魔改 AVD 实例 ${i} 正在做别的事`, '等它做完再试');
+ if (sdkJob()) return errResp('busy', `任务 ${sdkJob().kind === 'components' ? '补全组件' : '导入 Google Play 商店的文件'} 正在运行(任务 ${sdkJob().id}),等它做完再试`);
+ if (install && !db.store.ready) {
+ return reason('not_ready', `这个根目录还没有 Google Play 商店的五个文件,或者文件不全、已损坏(${ROOT}\\sdk\\playstore)`,
+ '先在管理器的「补全组件」里勾选「补全 Google Play 商店服务」(或者用自己的文件导入)', 'playstore_files_missing');
+ }
+ if (install && inst.bridge === 'off') {
+ return reason('not_ready', `实例 ${i} 没有 arm64 执行桥,不按可写系统镜像开机,放不进 Google Play 商店`, `先运行 modavd bridge ${i} install 装上执行桥,再补全商店`, 'no_bridge');
+ }
+ const j = newJob(act, i);
+ runPlayStore(inst, j, install ? 'install' : 'uninstall');
+ return json(j, 202);
+ }
if (act === 'disk-info' || act === 'disk-trim' || act === 'disk-compact') {
if (busyJob) return errResp('busy', `实例 ${i} 正在${busyJob.kind === 'stop' ? '关机' : '做别的事'}(任务 ${busyJob.id}),等它做完再试`);
const GB = 2 ** 30;
@@ -1037,6 +1176,8 @@ export function install(scenario, params) {
db.shotfail = params.get('shotfail') === '1';
db.proxyfail = params.get('proxyfail') === '1';
db.diagMode = params.get('diag') || (scenario === 'components-fail' ? 'components-fail' : scenario === 'diag-fail' ? 'diag-fail' : '');
+ if (['download', 'import'].includes(params.get('store'))) db.store = { ready: true, source: params.get('store') };
+ db.storefail = params.get('storefail') === '1';
window.__mockShots = {};
setup(scenario);
const real = window.fetch.bind(window);
diff --git a/internal/ui/web/js/actions.js b/internal/ui/web/js/actions.js
index 3ffb6bb..fa445e8 100644
--- a/internal/ui/web/js/actions.js
+++ b/internal/ui/web/js/actions.js
@@ -3,11 +3,29 @@
import { api } from './api.js';
import { S, update, instance } from './store.js';
import { toast, toastError, copyText } from './ui.js';
-import { quoted, dur, diskInfoText, diskTrimText, diskCompactText } from './format.js';
+import { quoted, dur, diskInfoText, diskTrimText, diskCompactText, playStoreText } from './format.js';
import { openActivity, runCheck } from './panels.js';
const VERB = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', 'disk-info': '查看磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘',
- components: '补全组件', 'collect-info': '收集问题信息' };
+ components: '补全组件', 'collect-info': '收集问题信息', 'playstore-install': '补全 Google Play 商店', 'playstore-uninstall': '移除 Google Play 商店',
+ 'playstore-import': '导入 Google Play 商店的文件' };
+// what the store needs once it is in an instance (the documentation site's 「自己装 Google Play 商店」)
+const GSF = '第一次用商店前,按文档站「自己装 Google Play 商店」在 Google 登记这台设备(GSF ID)。';
+// an import the server refused (components_rejected, details.reason) when its message has no advice of its own
+const IMPORT_ADVICE = {
+ unknown_file: '要的是 Google 官方的 Google Play 版镜像压缩包 x86_64-34_r14.zip,或者从它里面取出的五个文件。',
+ mismatch: '这个文件和 Google 发布的那份不一样(可能没下载完整或被改过),重新下载后再试。',
+ incomplete: '五个文件要一起选上,或者只选镜像压缩包。',
+};
+
+export function importAdvice(e) {
+ return (e && (e.advice || (e.code === 'components_rejected' && IMPORT_ADVICE[e.details?.reason]))) || '';
+}
+
+// The root's components changed (a fetch or an import finished): 组件 and the Google Play Store state read again.
+function reloadComponents() {
+ api.get('/api/components').then((v) => { S.components = v; S.componentsError = null; update(); }).catch(() => {});
+}
const isBoot = (j) => j.kind === 'start';
const activity = { label: '查看活动', run: () => openActivity() };
// wraps an API action: errors become a toast with the message and advice
@@ -21,6 +39,7 @@ export function onJob(job) {
S.jobs.set(job.id, job);
// components were just installed: 电脑检查 and the banner read the root again (the server dropped its cached check)
if (job.kind === 'components' && job.state === 'done' && prev && prev.state === 'running') runCheck(true);
+ if ((job.kind === 'components' || job.kind === 'playstore-import') && job.state !== 'running' && prev && prev.state === 'running') reloadComponents();
if (job.state !== 'running' && S.watch.delete(job.id)) notify(job);
if (S.jobs.size > 80) {
const old = [...S.jobs.values()].filter((j) => j.state !== 'running').sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
@@ -36,7 +55,7 @@ function notify(job) {
const who = job.index < 0 ? '' : quoted(instance(job.index), job.index);
const r = job.result || {};
const e = job.error || {};
- if (job.state === 'failed') toast({ tone: 'err', title: `${who}${VERB[job.kind] || ''}没有成功`, text: e.message, advice: e.advice, action: activity });
+ if (job.state === 'failed') toast({ tone: 'err', title: `${who}${VERB[job.kind] || ''}没有成功`, text: e.message, advice: job.kind === 'playstore-import' ? importAdvice(e) : e.advice, action: activity });
else if (job.state === 'canceled') { if (isBoot(job)) toast({ tone: 'info', title: '已停止等待', text: `${who}的模拟器会继续开机。` }); }
else if (isBoot(job)) {
const w = Math.max((r.warnings || []).length, (job.warnings || []).length);
@@ -51,9 +70,19 @@ function notify(job) {
else if (job.kind === 'disk-info') toast({ tone: 'info', title: `${who}的磁盘占用`, text: diskInfoText(r), timeout: 9000 });
else if (job.kind === 'disk-trim') toast({ title: `${who}已触发磁盘回收`, text: diskTrimText(), timeout: 6000 });
else if (job.kind === 'disk-compact') toast({ title: r.skipped ? `${who}已跳过磁盘重写` : `${who}磁盘整理好了`, text: diskCompactText(r), timeout: 6000 });
+ else if (job.kind === 'playstore-install') {
+ toast({ tone: r.installed ? 'ok' : 'warn', title: r.installed ? `${who}已补全 Google Play 商店` : `${who}的 Google Play 商店没装全`,
+ text: playStoreText(job.kind, r) + (r.installed ? GSF : ''), action: r.installed ? null : activity, timeout: 12000 });
+ } else if (job.kind === 'playstore-uninstall') {
+ toast({ tone: r.state === 'absent' ? 'ok' : 'warn', title: r.state === 'absent' ? `${who}已移除 Google Play 商店` : `${who}的 Google Play 商店没移除干净`,
+ text: playStoreText(job.kind, r), action: r.state === 'absent' ? null : activity, timeout: 6000 });
+ } else if (job.kind === 'playstore-import') {
+ toast({ title: 'Google Play 商店的文件已就绪', text: `${playStoreText(job.kind, r)}要用的实例在「更多操作」里补全商店。`, timeout: 9000 });
+ }
}
-async function run(i, kind, body) {
+// onErr: what to do when the server refuses at once (default: a toast with its message and advice)
+async function run(i, kind, body, onErr = toastError) {
S.pending[i] = kind;
update();
try {
@@ -66,7 +95,7 @@ async function run(i, kind, body) {
}
return job;
} catch (e) {
- toastError(e);
+ onErr(e);
} finally {
delete S.pending[i];
update();
@@ -93,6 +122,9 @@ export async function diskCompact(i) {
if (await run(i, 'disk-compact', {})) toast({ tone: 'info', title: '正在整理磁盘', text: `${quoted(instance(i), i)}正在检查并整理,期间不能开机`, timeout: 5000 });
}
+// 补全 / 移除 Google Play 商店 (action install | uninstall): a job that boots the instance cold once (playstore.js asks first)
+export const playStore = (i, action, onErr) => run(i, 'playstore-' + action, {}, onErr);
+
export const cancelJob = safe(async (id) => onJob(await api.post(`/api/jobs/${encodeURIComponent(id)}/cancel`)));
export const openFolder = safe((what, index) => api.post('/api/open', index == null ? { what } : { what, index }));
export const input = safe((i, body) => api.post(`/api/instances/${i}/input`, body));
diff --git a/internal/ui/web/js/api.js b/internal/ui/web/js/api.js
index 7fc4b08..3bc5a57 100644
--- a/internal/ui/web/js/api.js
+++ b/internal/ui/web/js/api.js
@@ -1,11 +1,13 @@
// The HTTP contract (internal/ui/API.md): JSON in and out, errors as {error:{code,message,advice}}.
export class ApiError extends Error {
- constructor(status, code, message, advice) {
+ // details: the machine-readable extras of some errors (details.reason says which way out to offer)
+ constructor(status, code, message, advice, details) {
super(message);
this.status = status;
this.code = code;
this.advice = advice || '';
+ this.details = details || {};
}
}
@@ -39,7 +41,7 @@ async function toError(res) {
let e = null;
try { e = (await res.json()).error; } catch { /* not JSON */ }
return new ApiError(res.status, (e && e.code) || 'http_' + res.status,
- (e && e.message) || `请求没有成功(HTTP ${res.status})`, e && e.advice);
+ (e && e.message) || `请求没有成功(HTTP ${res.status})`, e && e.advice, e && e.details);
}
export const api = {
diff --git a/internal/ui/web/js/cards.js b/internal/ui/web/js/cards.js
index 0b03b64..ee46d6e 100644
--- a/internal/ui/web/js/cards.js
+++ b/internal/ui/web/js/cards.js
@@ -10,6 +10,7 @@ import { openSettings, openDelete } from './forms.js';
import { openProxy, markOf } from './proxy.js';
import { openViewer, openLogs } from './viewer.js';
import { openActivity } from './panels.js';
+import { openPlayStore } from './playstore.js';
const cards = new Map();
let grid;
@@ -86,7 +87,10 @@ export function viewState(inst) {
const PILL = { online: ['运行中', 'ok'], starting: ['开机中', 'warn'], stopping: ['关机中', 'warn'], offline: ['已关机', 'idle'], error: ['开机失败', 'err'],
creating: ['新建中', 'warn'], createfail: ['新建失败', 'err'] };
-const FAIL = { start: '开机失败', stop: '关机失败', install: '安装失败', create: '新建失败', 'disk-info': '查看磁盘占用失败', 'disk-trim': '回收磁盘空间失败', 'disk-compact': '整理磁盘失败' };
+const FAIL = { start: '开机失败', stop: '关机失败', install: '安装失败', create: '新建失败', 'disk-info': '查看磁盘占用失败', 'disk-trim': '回收磁盘空间失败', 'disk-compact': '整理磁盘失败',
+ 'playstore-install': '补全 Google Play 商店失败', 'playstore-uninstall': '移除 Google Play 商店失败' };
+// 补全 / 移除 Google Play 商店: stops and boots the instance cold; the card is busy with it the whole time
+const STORE_JOB = { 'playstore-install': ['补全商店中', '正在补全 Google Play 商店…'], 'playstore-uninstall': ['移除商店中', '正在移除 Google Play 商店…'] };
function skeletonFor(v) {
const sk = (cls) => h('div', { class: 'sk ' + cls });
@@ -214,18 +218,20 @@ function updateCard(c, inst) {
c.next = 0;
c.hold = null;
}
- const busy = v.key === 'starting' || v.key === 'stopping' || v.key === 'creating' || v.kind === 'install' || v.kind === 'disk-trim' || v.kind === 'disk-compact';
+ const store = STORE_JOB[v.kind];
+ const busy = v.key === 'starting' || v.key === 'stopping' || v.key === 'creating' || v.kind === 'install' || v.kind === 'disk-trim' || v.kind === 'disk-compact' || !!store;
c.el.classList.toggle('is-busy', busy);
c.screen.classList.toggle('has-shot', live && !!c.url);
c.screen.classList.toggle('busy', busy);
setText(c.phText, v.key === 'error' ? '上次开机没有成功' : v.key === 'createfail' ? '新建时的初始化没做完' : live ? '正在取画面…' : '未开机');
if (busy) {
const p = v.job && v.job.progress;
- const msg = (p && stripInst(p.message)) || (v.key === 'stopping' ? '正在关机…' : v.key === 'creating' ? '正在新建:后台开机做初始化…' : v.kind === 'install' ? '正在安装 APK…'
+ const msg = (p && stripInst(p.message)) || (store ? store[1] : v.key === 'stopping' ? '正在关机…' : v.key === 'creating' ? '正在新建:后台开机做初始化…' : v.kind === 'install' ? '正在安装 APK…'
: v.kind === 'disk-trim' ? '正在触发磁盘回收…' : v.kind === 'disk-compact' ? '正在整理磁盘…' : '正在开机…');
setText(c.progMsg, msg);
setText(c.lmsg, msg);
- const n = cp ?? pct(p);
+ // the store's jobs report no percentage while they run (done 0 of 1000): an indeterminate bar, not a stuck 0%
+ const n = store ? null : cp ?? pct(p);
for (const [bar, fill] of [[c.progBar, c.progFill], [c.lbar, c.lfill]]) {
bar.classList.toggle('indet', n == null);
fill.style.width = n == null ? '' : n.toFixed(1) + '%';
@@ -235,7 +241,7 @@ function updateCard(c, inst) {
c.view.title = live ? '实时画面,可以点按' : '开机后才能看画面';
const blocked = rootBlocked();
- const mode = v.key === 'starting' || v.key === 'creating' ? ['busy', pt] : v.key === 'createfail' ? ['resume', '重试初始化']
+ const mode = store ? ['busy', store[0]] : v.key === 'starting' || v.key === 'creating' ? ['busy', pt] : v.key === 'createfail' ? ['resume', '重试初始化']
: v.key === 'stopping' ? ['busy', '关机中'] : inst.running ? ['stop', '关机']
: blocked ? ['blocked', '开机'] : ['start', '开机'];
const sig = mode.join();
@@ -300,6 +306,12 @@ function menuItems(c) {
: { label: '强制关机', icon: 'bolt', disabled: !running || v.key === 'stopping', run: () => A.stop(i, true) },
{ sep: true },
{ label: '安装 APK…', icon: 'download', disabled: inst.status !== 'online' || v.kind === 'install', run: () => pickApk(i) },
+ // the Google Play Store (InstanceInfo.play_store): on or off, it boots the instance cold once (playstore.js asks
+ // first). It lives in the execution bridge's writable system overlay, so an instance without the bridge cannot.
+ inst.play_store
+ ? { label: '移除 Google Play 商店…', icon: 'x', disabled: fresh || busy || !!v.kind, run: () => openPlayStore(i, 'uninstall') }
+ : { label: '补全 Google Play 商店…', icon: 'plus', disabled: fresh || busy || !!v.kind || inst.bridge === 'off',
+ hint: inst.bridge === 'off' ? '要执行桥' : '', run: () => openPlayStore(i, 'install') },
// 磁盘占用 reads only (on or off); 回收 needs the guest booted; 整理 rewrites the data disk file, so the instance must be off.
// Any job running on the instance blocks all three (the server would answer busy).
{ label: '磁盘占用', icon: 'info', disabled: !!v.kind, run: () => A.diskInfo(i) },
diff --git a/internal/ui/web/js/diagnostics.js b/internal/ui/web/js/diagnostics.js
index cb3d9c4..f9abb51 100644
--- a/internal/ui/web/js/diagnostics.js
+++ b/internal/ui/web/js/diagnostics.js
@@ -1,11 +1,12 @@
// 补全组件: the license dialog (from 电脑检查's 组件 item, the banner and its retry), and 设置 → 组件与问题反馈:
// component status, 收集问题信息, 复制配置信息. Everything runs as API jobs; closing a dialog does not stop them.
-import { h, icon, btn, setText, showError } from './dom.js';
+import { h, icon, btn, setText, showError, attr } from './dom.js';
import { S, update, subscribe, newestFirst } from './store.js';
import { api } from './api.js';
import { openDialog, toast, toastError, copyText } from './ui.js';
import { openCheck } from './panels.js';
+import { fmtBytes } from './format.js';
import * as A from './actions.js';
// before a package exists; afterwards the panel shows the note that came with it (what is inside, what may be shared)
@@ -62,13 +63,126 @@ function licenseURL(source, urls) {
let licenseCtl = null;
+// ---- Google Play 商店服务 (API.md "Google Play 商店"): the root's five files of the optional store, which an instance's
+// 「补全 Google Play 商店」 puts in. 补全组件 downloads them when its box is ticked (Google's Play image, only the five
+// files are kept); 「使用我自己的文件…」 imports that image's zip or the five files the user already has.
+const STORE_FROM = { download: '从 Google 官方下载', import: '用自己的文件导入' };
+const MAX_IMPORT = 8; // the server's limit: the five files and the zip, with room for one given twice
+let upload = null; // {n, bytes} while an import's files are being handed to the server
+
+export const storeStatus = () => (S.components && S.components.play_store) || null;
+
+function storeReadyText(st) {
+ const bits = [STORE_FROM[st.source], st.version && `r${st.version}`].filter(Boolean);
+ return `Google Play 商店服务已就绪${bits.length ? `(${bits.join(',')})` : ''}`;
+}
+
+const refusal = (message, advice) => Object.assign(new Error(message), { advice });
+
+// importPlayStore hands the picked files to the server (each one a "file" part, like an APK upload) and starts the
+// import job; its toast comes when it ends. The size and hash checks are the server's.
+export async function importPlayStore(files) {
+ files = [...files];
+ if (!files.length) return null;
+ if (activeJob('components') || activeJob('playstore-import') || upload) throw refusal('组件正在补全或导入', '等它做完再导入');
+ if (files.length > MAX_IMPORT) throw refusal(`一次最多选 ${MAX_IMPORT} 个文件`, '只要 Google Play 版镜像的压缩包 x86_64-34_r14.zip,或者商店的五个文件');
+ const empty = files.find((f) => !f.size);
+ if (empty) throw refusal(`${empty.name} 是空文件`, '换成 Google Play 版镜像的压缩包,或者商店的五个文件');
+ const fd = new FormData();
+ for (const f of files) fd.append('file', f, f.name);
+ upload = { n: files.length, bytes: files.reduce((n, f) => n + f.size, 0) };
+ update();
+ try {
+ return await startJob(api.post('/api/components/import-playstore', fd));
+ } finally {
+ upload = null;
+ update();
+ }
+}
+
+// storeBox: the store's part of 补全组件 (mode 'fetch': a box to tick, checked to start with when asked) and of
+// 电脑检查 (mode 'check': its own row with 下载…). Both offer 「使用我自己的文件…」 and show an import that runs or
+// failed. fillStoreBox updates it from S; b.wanted() says whether the fetch should take the store along.
+export function storeBox(mode, checked) {
+ const b = { mode, sig: null };
+ // the picker lives inside the dialog: the rest of the page is inert while a dialog is open
+ b.input = h('input', { type: 'file', multiple: true, accept: '.zip,.apk,.der', hidden: true, tabindex: '-1', 'aria-hidden': 'true' });
+ b.input.addEventListener('change', () => {
+ const files = [...b.input.files];
+ b.input.value = '';
+ b.local = null;
+ importPlayStore(files).catch((e) => { b.local = e; b.sig = null; fillStoreBox(b); });
+ fillStoreBox(b);
+ });
+ b.own = btn('btn btn-ghost btn-sm', [icon('folder'), '使用我自己的文件…'], () => { if (b.own.getAttribute('aria-disabled') !== 'true') b.input.click(); });
+ b.state = h('p', { class: 'store-state tone-ok', hidden: true });
+ b.text = h('span');
+ b.cancel = btn('btn btn-ghost btn-sm', '取消', () => b.id && A.cancelJob(b.id));
+ b.running = h('div', { class: 'diag-running', role: 'status', hidden: true }, h('span', { class: 'spin' }), b.text, b.cancel);
+ b.err = h('div', { class: 'form-error', role: 'alert', hidden: true });
+ if (mode === 'fetch') {
+ b.box = h('input', { type: 'checkbox', id: 'lic-store', checked: !!checked, 'aria-describedby': 'lic-store-h' });
+ b.choose = h('div', { class: 'store-choose' },
+ h('label', { class: 'checkbox' }, b.box, h('span', { text: '同时补全 Google Play 商店服务(约 1.5 GB,可选)' })),
+ h('p', { class: 'f-hint', id: 'lic-store-h', text: '勾上就一起从 Google 官方下载 Google Play 版系统镜像(同样受这份协议约束),只留商店的五个文件;新建的实例不带商店,要用的实例在「更多操作」里补全。已经有镜像压缩包 x86_64-34_r14.zip 或那五个文件的,直接导入,不用下载:' }),
+ h('div', { class: 'store-own' }, b.own));
+ b.el = h('div', { class: 'store-opt' }, b.choose, b.state, b.running, b.err, b.input);
+ } else {
+ b.pill = h('span', { class: 'pill tone-idle', text: '没有' });
+ b.get = btn('btn btn-secondary btn-sm', [icon('download'), '下载…'], () => { if (b.get.getAttribute('aria-disabled') !== 'true') openComponentFetch({ playStore: true }); });
+ b.choose = h('div', { class: 'diag-actions' }, b.get, b.own);
+ b.el = h('section', { class: 'store-box', 'aria-labelledby': 'store-box-t', tabindex: '-1' },
+ h('div', { class: 'store-head' }, h('strong', { id: 'store-box-t', text: 'Google Play 商店服务' }), h('span', { class: 'tag', text: '可选' }), h('span', { class: 'spacer' }), b.pill),
+ h('p', { class: 'store-note', text: '给实例补全 Google Play 商店要用。从 Google 官方下载 Google Play 版系统镜像(约 1.5 GB),只留商店的五个文件;或者用自己的镜像压缩包 x86_64-34_r14.zip、那五个文件导入。新建的实例不带商店。' }),
+ b.state, b.running, b.err, b.choose, b.input);
+ }
+ b.wanted = () => mode === 'fetch' && !!b.box.checked && !b.box.disabled && !(storeStatus() || {}).ready;
+ fillStoreBox(b);
+ return b;
+}
+
+export function fillStoreBox(b) {
+ const st = storeStatus();
+ const ready = !!(st && st.ready);
+ const imp = activeJob('playstore-import');
+ // 电脑检查 has no other place that shows a fetch outside a failed 组件 item: say it here too
+ const run = imp || (b.mode === 'check' ? activeJob('components') : null);
+ const last = imp ? null : latestJob('playstore-import');
+ const failed = b.local || (last && last.state === 'failed' ? last.error || {} : null);
+ b.choose.hidden = ready;
+ if (b.mode === 'check') {
+ setText(b.pill, ready ? '已就绪' : st ? '没有' : '未知');
+ b.pill.className = 'pill tone-' + (ready ? 'ok' : 'idle');
+ for (const x of [b.get, b.own]) attr(x, 'aria-disabled', run || upload ? true : null);
+ } else {
+ b.box.disabled = !!(imp || upload);
+ attr(b.own, 'aria-disabled', run || upload ? true : null);
+ }
+ b.state.hidden = !ready;
+ if (ready) setText(b.state, `${storeReadyText(st)}。要用的实例在「更多操作」里补全商店。`);
+ b.running.hidden = !(run || upload);
+ b.cancel.hidden = !run;
+ if (run) {
+ b.id = run.id;
+ setText(b.text, `${run.kind === 'components' ? '正在补全组件' : '正在导入'}:${progressText(run)}`);
+ } else if (upload) setText(b.text, `正在把 ${upload.n} 个文件交给后台(共 ${fmtBytes(upload.bytes)})…`);
+ const sig = failed && !ready && !run && !upload ? `${failed.message}|${failed.advice}` : '';
+ if (b.sig !== sig) {
+ b.sig = sig;
+ if (sig) showError(b.err, { message: `没有导入:${failed.message || '原因未知'}`, advice: A.importAdvice(failed) });
+ b.err.hidden = !sig;
+ }
+}
+
// 补全组件: pick the source, read the license that source serves, agree, the job starts. The dialog opens at once
// with the saved source, so a source that cannot be reached shows its error here and another one can be picked.
-export function openComponentFetch() {
+// opts.playStore ticks 「同时补全 Google Play 商店服务」 to start with (电脑检查's 下载…). Called as a click handler
+// too (opts is the event then).
+export function openComponentFetch(opts) {
if (licenseCtl) return;
- const running = activeJob('components');
+ const running = activeJob('components') || activeJob('playstore-import');
if (running) {
- toast({ tone: 'info', title: '组件正在补全', text: progressText(running) });
+ toast({ tone: 'info', title: running.kind === 'components' ? '组件正在补全' : '正在导入 Google Play 商店的文件', text: progressText(running) });
return;
}
const select = h('select', { class: 'input', id: 'lic-src' }, ...SOURCES.map(([value, text]) => h('option', { value, text })));
@@ -126,12 +240,19 @@ export function openComponentFetch() {
const agree = btn('btn btn-primary', '同意并下载', async () => {
const p = pick();
if (!shown || shown.sig !== p.sig) { load(); return; } // the choice changed after the text was read
- agree.disabled = true;
status.hidden = true;
+ // an import of the store's files holds the same lock: the server would answer busy
+ if (activeJob('playstore-import') || upload) {
+ showError(status, { message: '正在导入 Google Play 商店的文件', advice: '等它做完再下载' });
+ return;
+ }
+ agree.disabled = true;
const body = { source: p.source, mirror_urls: p.urls };
try {
await api.put('/api/components/source', body);
- await startJob(api.post('/api/components/fetch', { accept_license: true, ...body, license_sha256: shown.sha }));
+ const fetchBody = { accept_license: true, ...body, license_sha256: shown.sha };
+ if (store.wanted()) fetchBody.play_store = true;
+ await startJob(api.post('/api/components/fetch', fetchBody));
d.close();
} catch (e) {
agree.disabled = false;
@@ -142,13 +263,21 @@ export function openComponentFetch() {
custom.addEventListener('change', load);
custom.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); load(); } });
+ // the optional Google Play Store, under the license it is fetched under too
+ const store = storeBox('fetch', opts && opts.playStore === true);
const d = openDialog({
title: 'Android 软件开发套件许可协议', sub: '组件由 Google 提供。先选下载源,读完从它取到的许可协议再决定。', cls: 'dlg-update', light: true,
- body: h('div', { class: 'dlg-body diag-license-body' }, fields, status, note, text),
+ body: h('div', { class: 'dlg-body diag-license-body' }, fields, status, note, text, store.el),
actions: { left: btn('btn btn-ghost', '不同意', () => d.close()), cancel: false, ok: agree },
});
licenseCtl = d;
- d.onClose.push(() => { licenseCtl = null; seq++; });
+ const un = subscribe(() => fillStoreBox(store));
+ d.onClose.push(() => {
+ licenseCtl = null;
+ seq++;
+ un();
+ });
+ loadDiagnostics().catch(() => {});
agree.disabled = true;
api.get('/api/components/source').catch(() => ({})).then((saved) => {
if (licenseCtl !== d) return; // closed meanwhile
@@ -166,7 +295,7 @@ export function openDiagnostics() {
const componentError = h('div', { class: 'form-error', role: 'alert', hidden: true });
const toCheck = btn('btn btn-ghost', [icon('shield'), '去电脑检查补全'], () => { ctl.close(); openCheck(); });
const compHead = h('div', { class: 'diag-head' }, h('strong', { text: '组件' }), toCheck);
- const compNote = h('p', { class: 'f-hint', text: '缺组件时,在「电脑检查」的组件一项点「补全组件」:先选下载源、读许可协议,同意后才下载。' });
+ const compNote = h('p', { class: 'f-hint', text: '缺组件时,在「电脑检查」的组件一项点「补全组件」:先选下载源、读许可协议,同意后才下载。可选的 Google Play 商店服务也在「电脑检查」里补全。' });
const componentState = h('p', { class: 'diag-state', hidden: true });
const collectBtn = btn('btn btn-secondary', [icon('file'), '收集问题信息'], () => startJob(api.post('/api/diag/collect', {})).catch(toastError));
diff --git a/internal/ui/web/js/format.js b/internal/ui/web/js/format.js
index 24b2af8..81788c8 100644
--- a/internal/ui/web/js/format.js
+++ b/internal/ui/web/js/format.js
@@ -1,7 +1,9 @@
// Formatting for people (zh-CN).
-export const KIND = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', components: '补全组件', 'collect-info': '收集问题信息', 'disk-info': '磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘' };
-export const KIND_ICON = { start: 'power', stop: 'stop', install: 'download', create: 'plus', components: 'download', 'collect-info': 'file', 'disk-info': 'disk', 'disk-trim': 'disk', 'disk-compact': 'disk' };
+export const KIND = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', components: '补全组件', 'collect-info': '收集问题信息', 'disk-info': '磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘',
+ 'playstore-install': '补全 Google Play 商店', 'playstore-uninstall': '移除 Google Play 商店', 'playstore-import': '导入 Google Play 商店的文件' };
+export const KIND_ICON = { start: 'power', stop: 'stop', install: 'download', create: 'plus', components: 'download', 'collect-info': 'file', 'disk-info': 'disk', 'disk-trim': 'disk', 'disk-compact': 'disk',
+ 'playstore-install': 'plus', 'playstore-uninstall': 'x', 'playstore-import': 'download' };
export const STATE = { running: '进行中', done: '完成', failed: '失败', canceled: '已取消' };
export const STATE_TONE = { running: 'warn', done: 'ok', failed: 'err', canceled: 'idle' };
@@ -79,6 +81,21 @@ export function diskCompactText(r) {
return `磁盘文件从 ${fmtBytes(r.before_on_disk_bytes)} 变为 ${fmtBytes(r.after_on_disk_bytes)},${change}。`;
}
+// The Google Play Store jobs (API.md "Google Play 商店"): PlayStoreResult of an instance's install / uninstall (stopped:
+// it was off and is off again; restarted: it was running and booted cold), PlayStoreImportResult of the root's import.
+export function playStoreText(kind, r) {
+ if (!r) return '';
+ if (kind === 'playstore-import') {
+ if (r.skipped) return '五个文件本来就齐全,给的文件也核对无误,没有改动。';
+ const kept = (r.kept || []).length ? `,原有的 ${r.kept.length} 个留用` : '';
+ return `${r.from === 'zip' ? '已从镜像压缩包取出五个文件' : `已导入 ${(r.used || []).length} 个文件${kept}`},核对无误。`;
+ }
+ const done = kind === 'playstore-install' ? r.state === 'installed' : r.state === 'absent';
+ const how = r.stopped ? '实例照原样关着' : r.restarted ? '实例冷启动过一次' : '';
+ if (kind === 'playstore-install') return (done ? '商店已放进实例' : '商店只放进去了一部分,可以再补全一次') + (how ? `,${how}` : '') + '。';
+ return (done ? '已移除商店,换回了系统原来的占位应用' : '没有移除干净,可以再移除一次') + (how ? `,${how}` : '') + '。';
+}
+
export function uptime(started) {
const t = parseTime(started);
if (t == null) return '';
diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js
index e614ff8..90719f9 100644
--- a/internal/ui/web/js/forms.js
+++ b/internal/ui/web/js/forms.js
@@ -279,6 +279,8 @@ function infoRows(inst) {
['渲染器', inst.renderer || (inst.initialized ? '未知' : '首次开机后才知道'), inst.software_renderer ? '软件渲染(没有可用的显卡驱动)' : ''],
['截图共享内存', shm.shm || '—', shm.shm ? (shm.shm_ready ? '已就绪' : '未就绪') : ''],
['首次初始化', inst.initialized ? '已完成' : `还没做(首次开机时做,${MIN12})`],
+ // the record in mas-avd.json (InstanceInfo.play_store); the card's 「更多操作」 menu adds or removes it
+ ['Google Play 商店', inst.play_store ? '已补全' : '没有', inst.play_store ? '' : '要用的话在实例的「更多操作」菜单里补全'],
['路径', inst.path, 'mono', ibtn('folder', '打开实例目录', () => A.openFolder('instance', inst.index))],
];
}
diff --git a/internal/ui/web/js/panels.js b/internal/ui/web/js/panels.js
index e84fc40..af3f553 100644
--- a/internal/ui/web/js/panels.js
+++ b/internal/ui/web/js/panels.js
@@ -4,8 +4,8 @@ import { h, icon, btn, keyed, showError, setText, attr } from './dom.js';
import { api } from './api.js';
import { S, update, subscribe, instance, jobsSorted, runningCount } from './store.js';
import { openDialog } from './ui.js';
-import { openComponentFetch, activeJob, latestJob, progressText } from './diagnostics.js';
-import { KIND, KIND_ICON, STATE, STATE_TONE, clock, dur, label, parseTime, pct, stripInst, stopMethod, diskInfoText, diskTrimText, diskCompactText } from './format.js';
+import { openComponentFetch, activeJob, latestJob, progressText, loadDiagnostics, storeBox, fillStoreBox } from './diagnostics.js';
+import { KIND, KIND_ICON, STATE, STATE_TONE, clock, dur, label, parseTime, pct, stripInst, stopMethod, diskInfoText, diskTrimText, diskCompactText, playStoreText } from './format.js';
import * as A from './actions.js';
const advice = (text) => h('div', { class: 'advice' }, icon('bulb'), h('span', { text }));
@@ -114,8 +114,15 @@ function fillCheckRow(r, it) {
let checkCtl = null;
-export function openCheck() {
- if (checkCtl) return;
+// opts.playStore: opened to get the Google Play Store's files (an instance's 「补全 Google Play 商店」 without them):
+// that block comes into view with its first button focused. Called as a click handler too (opts is the event then).
+export function openCheck(opts) {
+ const toStore = !!(opts && opts.playStore === true);
+ if (checkCtl) {
+ if (toStore) checkCtl.toStore = true;
+ update();
+ return;
+ }
const sum = h('div', { class: 'check-sum' });
const box = h('div', { class: 'form-error', role: 'alert', hidden: true });
const list = h('ul', { class: 'check-list' });
@@ -125,20 +132,40 @@ export function openCheck() {
const passed = h('details', { class: 'passed tone-ok', hidden: true },
h('summary', null, icon('ok'), passText, h('span', { class: 'spacer' }), icon('chevron')), passList);
const comps = h('details', { class: 'comps', hidden: true });
+ // the optional Google Play Store's files (GET /api/components play_store): not a check, never blocks a boot
+ const store = storeBox('check');
+ store.el.hidden = true;
const when = h('span', { class: 'foot-note' });
const again = btn('btn btn-secondary', null, () => runCheck(true));
const ctl = openDialog({
title: '电脑检查', sub: '开机前会自动检查这些项目,没通过的项目会说明原因和处理办法。', cls: 'dlg-check', light: true,
- body: h('div', { class: 'dlg-body' }, sum, box, list, passed, comps),
+ body: h('div', { class: 'dlg-body' }, sum, box, list, passed, comps, store.el),
foot: h('footer', { class: 'dlg-foot' }, when, folderBtn('btn btn-ghost'), again),
});
checkCtl = ctl;
+ ctl.toStore = toStore;
+ // brings the store block into view once what is above it has its final height (the check result and the store state)
+ const showStore = () => {
+ if (!ctl.toStore || store.el.hidden || !S.check) return;
+ ctl.toStore = false;
+ requestAnimationFrame(() => {
+ if (ctl.closed) return;
+ store.el.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
+ const target = [store.get, store.own].find((x) => !x.closest('[hidden]') && x.getAttribute('aria-disabled') !== 'true');
+ (target || store.el).focus({ preventScroll: true });
+ store.el.classList.add('flash');
+ setTimeout(() => store.el.classList.remove('flash'), 1600);
+ });
+ };
const rows = new Map();
const passRows = new Map();
let sigs = [];
const render = () => {
const c = S.check;
+ store.el.hidden = !(S.components && S.components.play_store);
+ if (!store.el.hidden) fillStoreBox(store);
+ showStore();
if (sigs[0] !== S.checking) {
sigs[0] = S.checking;
again.replaceChildren(S.checking ? h('span', { class: 'spin' }) : icon('refresh'), S.checking ? '正在检查…' : '重新检查');
@@ -197,6 +224,7 @@ export function openCheck() {
render();
ctl.onClose.push(() => { un(); checkCtl = null; });
if (!S.check && !S.checking) runCheck(false);
+ loadDiagnostics().catch(() => {});
}
// ---- banner: the root cannot boot (a blocking check failed or components are missing)
@@ -318,14 +346,17 @@ function updateJob(r, j) {
r.prog.hidden = !running;
if (running) {
const p = j.progress;
- const n = pct(p);
+ // 补全 / 移除 Google Play 商店 report no percentage while they run (done 0 of 1000)
+ const n = j.kind === 'playstore-install' || j.kind === 'playstore-uninstall' ? null : pct(p);
setText(r.msg, (p && stripInst(p.message)) || (j.kind === 'stop' ? '正在关机…' : j.kind === 'install' ? '正在安装…'
- : j.kind === 'disk-info' ? '正在查看磁盘占用…' : j.kind === 'disk-trim' ? '正在触发磁盘回收…' : j.kind === 'disk-compact' ? '正在整理磁盘…' : '进行中…'));
+ : j.kind === 'disk-info' ? '正在查看磁盘占用…' : j.kind === 'disk-trim' ? '正在触发磁盘回收…' : j.kind === 'disk-compact' ? '正在整理磁盘…'
+ : j.kind === 'playstore-install' ? '正在补全 Google Play 商店…' : j.kind === 'playstore-uninstall' ? '正在移除 Google Play 商店…'
+ : j.kind === 'playstore-import' ? '正在核对文件…' : '进行中…'));
setText(r.pctEl, n == null ? '' : Math.round(n) + '%');
r.bar.classList.toggle('indet', n == null);
r.fill.style.width = n == null ? '' : n.toFixed(1) + '%';
}
- const cancelBackend = j.kind === 'components' || j.kind === 'collect-info';
+ const cancelBackend = j.kind === 'components' || j.kind === 'collect-info' || j.kind === 'playstore-import';
r.acts.hidden = !(running && (j.kind === 'start' || j.kind === 'disk-compact' || cancelBackend));
setText(r.cancel, j.kind === 'disk-compact' ? '取消整理' : cancelBackend ? '取消' : '取消等待');
r.cancelHint.hidden = j.kind === 'disk-compact' || cancelBackend;
@@ -376,6 +407,7 @@ function resultText(j) {
if (j.kind === 'disk-compact') return diskCompactText(r);
if (j.kind === 'components') return '组件补全完成。';
if (j.kind === 'collect-info') return r.zip_path ? `问题包:${r.zip_path}` : '问题包已生成。';
+ if (j.kind.startsWith('playstore-')) return playStoreText(j.kind, r);
if (!r.launched) return '模拟器本来就在运行,只重做了开机后的设置。';
return [`安卓开机 ${dur(r.boot_seconds || 0)},共 ${dur(r.total_seconds || 0)}`, r.first_boot && '完成了首次初始化',
r.zygote_restarted && '装了 Vulkan 补丁(安卓框架重启过一次)'].filter(Boolean).join(',') + '。';
diff --git a/internal/ui/web/js/playstore.js b/internal/ui/web/js/playstore.js
new file mode 100644
index 0000000..dc006b6
--- /dev/null
+++ b/internal/ui/web/js/playstore.js
@@ -0,0 +1,70 @@
+// 补全 / 移除 Google Play 商店 on one instance (API.md "Google Play 商店"): asked first, since the job boots the
+// instance cold once; then its card shows the progress and a toast the outcome. 补全 needs the root's five files:
+// without them it goes to 电脑检查's Google Play 商店服务 (下载 or 使用我自己的文件) instead.
+
+import { h, icon } from './dom.js';
+import { S, instance } from './store.js';
+import { openDialog, toast, toastError } from './ui.js';
+import { quoted } from './format.js';
+import { loadDiagnostics } from './diagnostics.js';
+import { openCheck } from './panels.js';
+import * as A from './actions.js';
+
+const asking = new Set(); // instances with the question on screen
+
+// action: install | uninstall
+export async function openPlayStore(i, action) {
+ if (!instance(i) || asking.has(i)) return;
+ const install = action === 'install';
+ if (install) {
+ // the root's files as 组件 says now (another window may have imported them; the server checks their hashes itself)
+ asking.add(i);
+ await loadDiagnostics().catch(() => {});
+ asking.delete(i);
+ const st = S.components && S.components.play_store;
+ if (st && !st.ready) {
+ needFiles();
+ return;
+ }
+ }
+ const inst = instance(i);
+ if (!inst) return;
+ const who = quoted(inst, i);
+ const desc = 'ps-desc-' + i;
+ const how = inst.running
+ ? '它正开着:会先关机,做完按原来的方式开回来。'
+ : '它现在关着:会在后台开机,做完再关上。';
+ const ok = h('button', { class: install ? 'btn btn-primary' : 'btn btn-danger', type: 'submit' }, icon(install ? 'plus' : 'x'),
+ h('span', { text: install ? '补全' : '移除' }));
+ asking.add(i);
+ const ctl = openDialog({
+ title: install ? '补全 Google Play 商店?' : '移除 Google Play 商店?', cls: 'dlg-store', form: true, desc,
+ body: h('div', { class: 'dlg-body' },
+ h('p', { id: desc, class: 'store-q' }, install
+ ? ['把这个目录里的 Google Play 商店(商店、Play 服务、GSF 和合作伙伴设置)装进', h('strong', { text: who }), ',藏起系统原来的占位应用。']
+ : ['从', h('strong', { text: who }), '里移除 Google Play 商店:卸掉 Google Play 装的商店和 Play 服务更新,删掉商店的文件,换回系统原来的占位应用。']),
+ h('div', { class: 'note-box tone-info' }, icon('info'), h('span', { text: `要冷启动一次,几分钟,期间这台实例不能用。${how}` }))),
+ actions: { ok },
+ onSubmit() {
+ ctl.close();
+ A.playStore(i, action, install ? installRefused : toastError);
+ },
+ });
+ ctl.onClose.push(() => asking.delete(i));
+}
+
+// The server's answer before the job (API.md: 422 not_ready with details.reason): the files are missing or damaged,
+// so go and get them; anything else (no execution bridge, arm64, busy) is a toast with its own advice.
+function installRefused(e) {
+ if (e && e.details && e.details.reason === 'playstore_files_missing') {
+ loadDiagnostics().catch(() => {});
+ needFiles();
+ return;
+ }
+ toastError(e);
+}
+
+function needFiles() {
+ openCheck({ playStore: true });
+ toast({ tone: 'info', title: '先补全 Google Play 商店服务', text: '这个目录还没有商店的五个文件:在下面下载,或者用自己的文件导入,再给实例补全。', timeout: 8000 });
+}
diff --git a/scripts/test_create_cancel.mjs b/scripts/test_create_cancel.mjs
index 3c6e352..9fc57cc 100644
--- a/scripts/test_create_cancel.mjs
+++ b/scripts/test_create_cancel.mjs
@@ -17,12 +17,13 @@ async function harness() {
return { id: 'j2', kind: 'stop', state: 'running', index: 3 };
} } },
'dom.js': Object.fromEntries(['h', 'icon', 'btn', 'ibtn', 'keyed', 'setText', 'attr'].map(k => [k, noop])),
- 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText'].map(k => [k, noop])),
+ 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText'].map(k => [k, noop])),
'ui.js': { toast: noop, toastError: e => { throw e; }, copyText: noop, openMenu: noop },
'forms.js': { openSettings: noop, openDelete: noop },
'proxy.js': { openProxy: noop, markOf: noop },
'viewer.js': { openViewer: noop, openLogs: noop },
'panels.js': { openActivity: noop, runCheck: noop },
+ 'playstore.js': { openPlayStore: noop },
};
async function load(name) {
if (cache.has(name)) return cache.get(name);
@@ -177,6 +178,7 @@ async function settingsHarness({ diagnostics = true, running = true, get, patch
'proxy.js': { openProxy: noop, markOf: () => null },
'viewer.js': { openViewer: noop, openLogs: noop },
'panels.js': { openActivity: noop },
+ 'playstore.js': { openPlayStore: noop },
};
async function load(name) {
if (cache.has(name)) return cache.get(name);
diff --git a/scripts/test_playstore_ui.mjs b/scripts/test_playstore_ui.mjs
new file mode 100644
index 0000000..1c9a7da
--- /dev/null
+++ b/scripts/test_playstore_ui.mjs
@@ -0,0 +1,435 @@
+// Run: node --experimental-vm-modules --test scripts/test_playstore_ui.mjs
+// The Google Play Store in the manager's page (ModAVD/ModAVD#125): the instance menu's 补全 / 移除, the question before
+// the job, the way to the root's files when they are missing, 补全组件's option and 「使用我自己的文件」, and a create
+// without the store. Runs the page's own modules, stubbing only their browser / HTTP neighbours.
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import { test } from 'node:test';
+import vm from 'node:vm';
+
+const base = new URL('../internal/ui/web/js/', import.meta.url);
+const noop = () => {};
+const turn = () => new Promise(resolve => setImmediate(resolve));
+// values made inside the page's realm compared by their content
+const plain = x => JSON.parse(JSON.stringify(x));
+const turns = async (n = 6) => { for (let k = 0; k < n; k++) await turn(); };
+
+// A small DOM: what h(), the dialogs and the form code touch.
+class Element {
+ constructor(tag) {
+ this.tagName = tag.toUpperCase();
+ this.childNodes = [];
+ this.attrs = new Map();
+ this.events = new Map();
+ this.dataset = {};
+ this.style = {};
+ this.checked = false;
+ this.disabled = false;
+ this.hidden = false;
+ this.value = '';
+ this.className = '';
+ this.classList = {
+ toggle: (name, on) => {
+ const names = new Set(this.className.split(' ').filter(Boolean));
+ if (on) names.add(name); else names.delete(name);
+ this.className = [...names].join(' ');
+ },
+ add: name => this.classList.toggle(name, true),
+ remove: name => this.classList.toggle(name, false),
+ };
+ }
+ append(...kids) {
+ for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false && kid !== '') this.childNodes.push(kid);
+ }
+ replaceChildren(...kids) { this.childNodes = []; this.append(...kids); }
+ get textContent() { return this.childNodes.map(x => x instanceof Element ? x.textContent : String(x)).join(''); }
+ set textContent(text) { this.childNodes = [String(text)]; }
+ setAttribute(key, value) {
+ this.attrs.set(key, String(value));
+ if (['id', 'name', 'type', 'class'].includes(key)) this[key === 'class' ? 'className' : key] = String(value);
+ }
+ getAttribute(key) { return this.attrs.get(key) ?? null; }
+ hasAttribute(key) { return this.attrs.has(key); }
+ removeAttribute(key) { this.attrs.delete(key); }
+ addEventListener(type, fn) { this.events.set(type, [...(this.events.get(type) || []), fn]); }
+ dispatch(type) { return Promise.all((this.events.get(type) || []).map(fn => fn())); }
+ querySelector(selector) { return all(this).find(x => selector.startsWith('#') ? x.id === selector.slice(1) : x.tagName.toLowerCase() === selector) || null; }
+ focus() {}
+ click() { this.clicked = (this.clicked || 0) + 1; }
+}
+const all = root => root.childNodes.flatMap(x => x instanceof Element ? [x, ...all(x)] : []);
+const byText = (root, text) => all(root).find(x => x.tagName === 'BUTTON' && x.textContent.includes(text));
+
+function h(tag, props, ...kids) {
+ const el = new Element(tag);
+ for (const [key, value] of Object.entries(props || {})) {
+ if (key === 'checked') el.checked = !!value;
+ else if (value == null || value === false) continue;
+ else if (key === 'text') el.textContent = value;
+ else if (key === 'value') el.value = value;
+ else if (key === 'hidden') el.hidden = value;
+ else if (key.startsWith('on') && typeof value === 'function') el.addEventListener(key.slice(2), value);
+ else el.setAttribute(key, value === true ? '' : value);
+ }
+ el.append(...kids);
+ return el;
+}
+const dom = {
+ h, icon: () => h('svg'), btn: (cls, kids, fn, more) => h('button', { class: cls, onclick: fn, ...more }, kids),
+ ibtn: (ic, text, fn) => h('button', { onclick: fn, 'aria-label': text }), keyed: noop, add: noop, hydrateIcons: noop,
+ attr: (el, key, value) => value == null || value === false ? el.removeAttribute(key) : el.setAttribute(key, value),
+ setText: (el, text) => { el.textContent = text == null ? '' : text; },
+ showError: (el, e) => { el.textContent = e.message + (e.advice ? ' / ' + e.advice : ''); el.hidden = false; },
+};
+
+// load(entry, stubs): the page module entry with every module named in stubs replaced (the others are the real ones).
+async function load(entry, stubs, context = {}) {
+ const ctx = vm.createContext({ requestAnimationFrame: noop, setTimeout, clearTimeout, FormData, File, URLSearchParams, console, ...context });
+ const cache = new Map();
+ async function get(name) {
+ if (cache.has(name)) return cache.get(name);
+ const values = typeof stubs[name] === 'function' ? stubs[name](cache) : stubs[name];
+ const extra = name === 'cards.js' ? '\nexport { menuItems };' : '';
+ const mod = values
+ ? new vm.SyntheticModule(Object.keys(values), function() {
+ for (const [k, v] of Object.entries(values)) this.setExport(k, v);
+ }, { context: ctx })
+ : new vm.SourceTextModule(await readFile(new URL(name, base), 'utf8') + extra, { context: ctx });
+ cache.set(name, mod);
+ await mod.link(specifier => get(specifier.replace(/^\.\//, '')));
+ return mod;
+ }
+ const mod = await get(entry);
+ await mod.evaluate();
+ const ns = name => cache.get(name)?.namespace;
+ return { mod: mod.namespace, ns, S: ns('store.js')?.S };
+}
+
+// ---- the instance menu (cards.js)
+
+async function menuFor(inst, jobs = []) {
+ const calls = [];
+ const m = await load('cards.js', {
+ 'api.js': { api: {} },
+ 'dom.js': dom,
+ 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText'].map(k => [k, noop])),
+ 'ui.js': { toast: noop, toastError: noop, copyText: noop, openMenu: noop },
+ 'forms.js': { openSettings: noop, openDelete: noop },
+ 'proxy.js': { openProxy: noop, markOf: noop },
+ 'viewer.js': { openViewer: noop, openLogs: noop },
+ 'panels.js': { openActivity: noop, runCheck: noop },
+ 'playstore.js': { openPlayStore: (i, action) => calls.push([i, action]) },
+ });
+ for (const j of jobs) m.S.jobs.set(j.id, j);
+ const items = m.mod.menuItems({ i: 3, inst: { index: 3, status: 'offline', running: false, initialized: true, bridge: 'installed', ...inst } }).filter(Boolean);
+ return { items, calls, store: items.find(x => /Google Play 商店/.test(x.label || '')) };
+}
+
+test('an instance without the store offers 补全 Google Play 商店, which asks playstore.js to install', async () => {
+ const { items, store, calls } = await menuFor({});
+ assert.equal(items.filter(x => /Google Play 商店/.test(x.label || '')).length, 1);
+ assert.equal(store.label, '补全 Google Play 商店…');
+ assert.ok(!store.disabled);
+ store.run();
+ assert.deepEqual(calls, [[3, 'install']]);
+});
+
+test('an instance with the store offers 移除 Google Play 商店 instead', async () => {
+ const { store, calls } = await menuFor({ play_store: true, status: 'online', running: true });
+ assert.equal(store.label, '移除 Google Play 商店…');
+ assert.ok(!store.disabled);
+ store.run();
+ assert.deepEqual(calls, [[3, 'uninstall']]);
+});
+
+test('without the execution bridge 补全 is off and says why; removing still works', async () => {
+ const off = await menuFor({ bridge: 'off' });
+ assert.ok(off.store.disabled);
+ assert.equal(off.store.hint, '要执行桥');
+ const had = await menuFor({ bridge: 'off', play_store: true });
+ assert.ok(!had.store.disabled);
+});
+
+test('the store items wait while the instance is being created, set up or busy with a job', async () => {
+ for (const [inst, jobs] of [[{ status: 'creating', running: true }, []], [{ status: 'create_failed' }, []],
+ [{}, [{ id: 'j9', kind: 'disk-compact', index: 3, state: 'running' }]],
+ [{ play_store: true, status: 'online', running: true }, [{ id: 'j8', kind: 'playstore-uninstall', index: 3, state: 'running' }]]]) {
+ const { store } = await menuFor(inst, jobs);
+ assert.ok(store.disabled, JSON.stringify(inst));
+ }
+});
+
+// ---- the question and the way to the files (playstore.js)
+
+async function playstore({ components, loaded } = {}) {
+ const r = { dialogs: [], toasts: [], errors: [], checks: [], posts: [], loads: 0 };
+ let S;
+ const m = await load('playstore.js', {
+ 'dom.js': dom,
+ 'ui.js': {
+ openDialog: o => {
+ const ctl = { options: o, onClose: [], closed: false, close() { if (!this.closed) { this.closed = true; for (const fn of this.onClose) fn(); } } };
+ r.dialogs.push(ctl);
+ return ctl;
+ },
+ toast: t => r.toasts.push(t), toastError: e => r.errors.push(e),
+ },
+ 'diagnostics.js': { loadDiagnostics: async () => { r.loads++; if (loaded) S.components = loaded; } },
+ 'panels.js': { openCheck: o => r.checks.push(o) },
+ 'actions.js': { playStore: (i, action, onErr) => r.posts.push({ i, action, onErr }) },
+ });
+ S = m.S;
+ S.list = { instances: [{ index: 3, name: 'mas_3', title: '明日方舟', status: 'online', running: true }] };
+ S.components = components ?? null;
+ return Object.assign(r, { open: m.mod.openPlayStore, S });
+}
+const files = ready => ({ play_store: { ready, source: ready ? 'download' : '', files: [] } });
+
+test('补全 without the root\'s five files goes to 电脑检查 and starts nothing', async () => {
+ const p = await playstore({ components: files(false) });
+ await p.open(3, 'install');
+ assert.equal(p.dialogs.length, 0);
+ assert.equal(p.posts.length, 0);
+ assert.deepEqual(plain(p.checks), [{ playStore: true }]);
+ assert.match(p.toasts[0].title, /先补全 Google Play 商店服务/);
+});
+
+test('补全 reads the files\' state first (the page may not have it, or an old one)', async () => {
+ const p = await playstore({ components: null, loaded: files(true) });
+ await p.open(3, 'install');
+ assert.equal(p.loads, 1);
+ assert.equal(p.dialogs.length, 1);
+});
+
+test('补全 asks first, says it boots cold, and only then starts the job', async () => {
+ const p = await playstore({ components: files(true) });
+ await p.open(3, 'install');
+ const d = p.dialogs[0];
+ assert.equal(d.options.title, '补全 Google Play 商店?');
+ assert.match(d.options.body.textContent, /冷启动一次/);
+ assert.match(d.options.body.textContent, /会先关机/); // it is running
+ assert.equal(p.posts.length, 0);
+ await p.open(3, 'install'); // a second click while it asks opens nothing more
+ assert.equal(p.dialogs.length, 1);
+ d.options.onSubmit();
+ assert.equal(d.closed, true);
+ assert.equal(p.posts.length, 1);
+ assert.equal(p.posts[0].action, 'install');
+});
+
+test('the server saying the files are missing leads to them; other refusals are a toast', async () => {
+ const p = await playstore({ components: files(true) });
+ await p.open(3, 'install');
+ p.dialogs[0].options.onSubmit();
+ const onErr = p.posts[0].onErr;
+ onErr({ code: 'not_ready', message: '没有五个文件', details: { reason: 'playstore_files_missing' } });
+ assert.deepEqual(plain(p.checks), [{ playStore: true }]);
+ assert.equal(p.errors.length, 0);
+ const noBridge = { code: 'not_ready', message: '没有执行桥', details: { reason: 'no_bridge' } };
+ onErr(noBridge);
+ assert.deepEqual(p.errors, [noBridge]);
+ assert.equal(p.checks.length, 1);
+});
+
+test('移除 asks with its own words and does not need the files', async () => {
+ const p = await playstore({ components: files(false) });
+ await p.open(3, 'uninstall');
+ assert.equal(p.checks.length, 0);
+ const d = p.dialogs[0];
+ assert.equal(d.options.title, '移除 Google Play 商店?');
+ assert.match(d.options.body.textContent, /换回系统原来的占位应用/);
+ d.options.onSubmit();
+ assert.equal(p.posts[0].action, 'uninstall');
+});
+
+// ---- the jobs (actions.js)
+
+async function actions(post) {
+ const r = { toasts: [], requests: [] };
+ const m = await load('actions.js', {
+ 'api.js': { api: {
+ post: async (path, body) => { r.requests.push(['POST', path, body]); return post ? post(path) : { id: 'j5', kind: 'playstore-install', index: 3, state: 'running' }; },
+ get: async path => { r.requests.push(['GET', path]); return { root: 'X', components: [], play_store: { ready: true } }; },
+ } },
+ 'ui.js': { toast: t => r.toasts.push(t), toastError: e => r.toasts.push({ error: e }), copyText: noop },
+ 'panels.js': { openActivity: noop, runCheck: noop },
+ });
+ m.S.list = { instances: [{ index: 3, name: 'mas_3', title: 'mas_3' }] };
+ return { ...r, A: m.mod, S: m.S };
+}
+
+test('补全 and 移除 post an empty body to their endpoints and watch the job', async () => {
+ const a = await actions();
+ await a.A.playStore(3, 'install', noop);
+ await a.A.playStore(3, 'uninstall', noop);
+ assert.deepEqual(plain(a.requests.map(x => x.slice(0, 3))), [['POST', '/api/instances/3/playstore-install', {}], ['POST', '/api/instances/3/playstore-uninstall', {}]]);
+ assert.ok(a.S.watch.has('j5'));
+});
+
+test('a refusal before the job goes to the caller\'s handler, not the generic toast', async () => {
+ const a = await actions(() => { throw Object.assign(new Error('没有五个文件'), { details: { reason: 'playstore_files_missing' } }); });
+ const seen = [];
+ await a.A.playStore(3, 'install', e => seen.push(e.details.reason));
+ assert.deepEqual(seen, ['playstore_files_missing']);
+ assert.equal(a.toasts.length, 0);
+});
+
+test('a refused import says what to give instead; the server\'s own advice wins', async () => {
+ const a = await actions();
+ assert.match(a.A.importAdvice({ code: 'components_rejected', details: { reason: 'mismatch' } }), /重新下载/);
+ assert.match(a.A.importAdvice({ code: 'components_rejected', details: { reason: 'unknown_file' } }), /x86_64-34_r14\.zip/);
+ assert.equal(a.A.importAdvice({ code: 'components_rejected', advice: '把五个文件一起给出', details: { reason: 'incomplete' } }), '把五个文件一起给出');
+ a.S.watch.add('j7');
+ a.A.onJob({ id: 'j7', kind: 'playstore-import', index: -1, state: 'running', created_at: '' });
+ a.A.onJob({ id: 'j7', kind: 'playstore-import', index: -1, state: 'failed', created_at: '', error: { code: 'components_rejected', message: 'a.apk 的 SHA-256 对不上', advice: '', details: { reason: 'mismatch' } } });
+ assert.equal(a.toasts[0].tone, 'err');
+ assert.match(a.toasts[0].title, /导入 Google Play 商店的文件没有成功/);
+ assert.match(a.toasts[0].advice, /重新下载/);
+ await turns();
+ assert.ok(a.requests.some(x => x[1] === '/api/components'), 'the store state is read again');
+});
+
+test('a finished install says the instance booted cold and what the store needs next', async () => {
+ const a = await actions();
+ a.S.watch.add('j6');
+ a.A.onJob({ id: 'j6', kind: 'playstore-install', index: 3, state: 'running', created_at: '' });
+ a.A.onJob({ id: 'j6', kind: 'playstore-install', index: 3, state: 'done', created_at: '',
+ result: { index: 3, action: 'install', state: 'installed', installed: true, restarted: true, stopped: false, warnings: [] } });
+ assert.match(a.toasts[0].title, /已补全 Google Play 商店/);
+ assert.match(a.toasts[0].text, /冷启动过一次/);
+ assert.match(a.toasts[0].text, /GSF ID/);
+});
+
+// ---- 补全组件's option and 使用我自己的文件 (diagnostics.js)
+
+async function diagnostics({ store = false, get } = {}) {
+ const r = { posts: [], puts: [], dialogs: [], toasts: [], jobs: [] };
+ const m = await load('diagnostics.js', {
+ 'dom.js': dom,
+ 'api.js': { api: {
+ get: async path => get?.(path) ?? (path.startsWith('/api/components/license') ? { license: 'TERMS', license_source: 'mock', license_sha256: 'ab'.repeat(32) }
+ : path === '/api/components' ? { root: 'X', components: [], play_store: { ready: store, source: store ? 'import' : '', version: store ? '14' : '', files: [] } } : {}),
+ post: async (path, body) => { r.posts.push({ path, body }); return { id: 'j' + r.posts.length, kind: path.endsWith('fetch') ? 'components' : 'playstore-import', index: -1, state: 'running' }; },
+ put: async (path, body) => { r.puts.push({ path, body }); return {}; },
+ } },
+ 'ui.js': {
+ openDialog: o => {
+ const ctl = { options: o, onClose: [], closed: false, close() { if (!this.closed) { this.closed = true; for (const fn of this.onClose) fn(); } } };
+ r.dialogs.push(ctl);
+ return ctl;
+ },
+ toast: t => r.toasts.push(t), toastError: noop, copyText: noop,
+ },
+ 'panels.js': { openCheck: noop },
+ 'actions.js': { onJob: j => r.jobs.push(j), notifyJob: noop, cancelJob: noop, importAdvice: e => e.advice || '' },
+ });
+ return { ...r, D: m.mod, S: m.S };
+}
+const file = (name, size = 3) => new File(['x'.repeat(size)], name);
+
+test('使用我自己的文件 sends each picked file as a "file" part and watches the import', async () => {
+ const d = await diagnostics();
+ const j = await d.D.importPlayStore([file('x86_64-34_r14.zip'), file('Phonesky.apk')]);
+ assert.equal(d.posts.length, 1);
+ assert.equal(d.posts[0].path, '/api/components/import-playstore');
+ assert.ok(d.posts[0].body instanceof FormData);
+ assert.deepEqual(d.posts[0].body.getAll('file').map(f => f.name), ['x86_64-34_r14.zip', 'Phonesky.apk']);
+ assert.equal(j.kind, 'playstore-import');
+ assert.ok(d.S.watch.has(j.id));
+});
+
+test('an import the server would refuse anyway is stopped before the upload', async () => {
+ const d = await diagnostics();
+ await assert.rejects(d.D.importPlayStore(Array.from({ length: 9 }, (_, k) => file(`f${k}.apk`))), /最多选 8 个/);
+ await assert.rejects(d.D.importPlayStore([file('Phonesky.apk'), file('empty.apk', 0)]), /empty\.apk 是空文件/);
+ d.S.jobs.set('j1', { id: 'j1', kind: 'components', index: -1, state: 'running', created_at: '' });
+ await assert.rejects(d.D.importPlayStore([file('Phonesky.apk')]), /正在补全或导入/);
+ assert.equal(d.posts.length, 0);
+});
+
+test('the option is off unless asked for, and moot once the files are there', async () => {
+ const d = await diagnostics();
+ assert.equal(d.D.storeBox('fetch', false).wanted(), false);
+ const on = d.D.storeBox('fetch', true);
+ assert.equal(on.wanted(), true);
+ d.S.components = { play_store: { ready: true, source: 'download', version: '14' } };
+ d.D.fillStoreBox(on);
+ assert.equal(on.wanted(), false);
+ assert.equal(on.choose.hidden, true);
+ assert.match(on.state.textContent, /已就绪(从 Google 官方下载,r14)/);
+});
+
+async function agreeIn(d) {
+ d.D.openComponentFetch({ playStore: true });
+ await turns();
+ const dlg = d.dialogs[0];
+ await dlg.options.actions.ok.dispatch('click');
+ await turns();
+ return d.posts.find(x => x.path === '/api/components/fetch');
+}
+
+test('补全组件 with the option ticked and no files of the user\'s own fetches the store along', async () => {
+ const d = await diagnostics();
+ const fetch = await agreeIn(d);
+ assert.equal(fetch.body.accept_license, true);
+ assert.equal(fetch.body.play_store, true);
+});
+
+test('补全组件 leaves the store out when it is not ticked or its files are there already', async () => {
+ const d = await diagnostics();
+ d.D.openComponentFetch();
+ await turns();
+ await d.dialogs[0].options.actions.ok.dispatch('click');
+ await turns();
+ assert.equal('play_store' in d.posts.find(x => x.path === '/api/components/fetch').body, false);
+ const ready = await diagnostics({ store: true });
+ assert.equal('play_store' in (await agreeIn(ready)).body, false);
+});
+
+test('补全组件 waits while an import of the store\'s files runs', async () => {
+ const d = await diagnostics();
+ d.D.openComponentFetch({ playStore: true });
+ await turns();
+ d.S.jobs.set('j3', { id: 'j3', kind: 'playstore-import', index: -1, state: 'running', created_at: '' });
+ await d.dialogs[0].options.actions.ok.dispatch('click');
+ await turns();
+ assert.equal(d.posts.length, 0);
+ assert.match(d.dialogs[0].options.body.textContent, /正在导入 Google Play 商店的文件/);
+});
+
+// ---- 新建 without the store (forms.js)
+
+test('新建 has no Google Play Store choice and never sends play_store', async () => {
+ const posts = [];
+ const dialogs = [];
+ const m = await load('forms.js', {
+ 'dom.js': dom,
+ 'api.js': { api: { post: async (path, body) => { posts.push({ path, body }); return { id: 'j1', kind: 'create', index: 0, state: 'running' }; } } },
+ 'ui.js': {
+ openDialog: o => {
+ const form = h('form', null, o.body, o.actions?.ok);
+ const inputs = all(form).filter(x => x.tagName === 'INPUT');
+ form.elements = {};
+ for (const name of new Set(inputs.filter(x => x.name).map(x => x.name))) {
+ Object.defineProperty(form.elements, name, { get: () => ({ value: inputs.find(x => x.name === name && x.checked)?.value ?? '' }) });
+ }
+ const ctl = { form, options: o, onClose: [], close() { this.closed = true; } };
+ dialogs.push(ctl);
+ return ctl;
+ },
+ toast: noop,
+ },
+ 'actions.js': { onJob: noop, copy: noop, openFolder: noop, stop: noop },
+ });
+ m.S.list = { instances: [], port_base: 20000 };
+ m.mod.openCreate();
+ const form = dialogs[0].form;
+ assert.equal(all(form).filter(x => x.type === 'checkbox').length, 0);
+ assert.doesNotMatch(dialogs[0].options.body.textContent, /Google Play/);
+ form.querySelector('#c-disk').value = '64';
+ await dialogs[0].options.onSubmit();
+ await turns();
+ assert.equal(posts.length, 1);
+ assert.equal(posts[0].path, '/api/instances');
+ assert.equal('play_store' in posts[0].body, false);
+});