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-