From 6d29766a1d35eeec67363f6f0cc4216fe8b73da6 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 6 Nov 2026 13:04:28 +0100 Subject: [PATCH 1/9] =?UTF-8?q?Google=20Play=20=E5=95=86=E5=BA=97=EF=BC=9A?= =?UTF-8?q?create=20--play-store=20=E5=92=8C=20playstore=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建时可选带上 Google Play 商店(默认不带),已有实例用 playstore install|uninstall|status。 商店和 Google 签名的 Play 服务、GSF、合作伙伴设置取自 Google 官方的 google_apis_playstore r14 镜像(internal/playstore 只读解析它的 system.img:GPT、liblp、ext4),每个文件钉了 SHA-256; 客体脚本核对后放进执行桥的可写覆盖层,藏起镜像自带的占位应用 LicenseChecker。要有执行桥; 装了商店的实例拒绝 bridge uninstall。组件下载(components fetch --play-store)和界面另提交。 Refs ModAVD/ModAVD#125 --- README.en.md | 12 +- README.md | 12 +- internal/cli/cli.go | 1 + internal/cli/commands.go | 58 ++- internal/cli/format.go | 20 + internal/cli/run_test.go | 7 + internal/emu/bridge.go | 5 + internal/emu/createinit.go | 26 +- internal/emu/crud.go | 16 + internal/emu/playstore.go | 383 +++++++++++++++++++ internal/emu/playstore_test.go | 283 ++++++++++++++ internal/emu/types.go | 3 + internal/guest/guest.go | 2 + internal/guest/guest_test.go | 2 +- internal/guest/playstore_sh_test.go | 210 +++++++++++ internal/guest/scripts/playstore.sh | 119 ++++++ internal/playstore/files.go | 79 ++++ internal/playstore/image.go | 565 ++++++++++++++++++++++++++++ internal/playstore/image_test.go | 438 +++++++++++++++++++++ internal/root/components.go | 33 ++ 20 files changed, 2263 insertions(+), 11 deletions(-) create mode 100644 internal/emu/playstore.go create mode 100644 internal/emu/playstore_test.go create mode 100644 internal/guest/playstore_sh_test.go create mode 100644 internal/guest/scripts/playstore.sh create mode 100644 internal/playstore/files.go create mode 100644 internal/playstore/image.go create mode 100644 internal/playstore/image_test.go diff --git a/README.en.md b/README.en.md index 806f689..203a7e0 100644 --- a/README.en.md +++ b/README.en.md @@ -62,13 +62,14 @@ 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] [--play-store] [--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. `--play-store` puts the Google Play Store in after the bridge (off by default; see below). `--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` | Install, uninstall or check the Google Play Store (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) | @@ -93,6 +94,15 @@ 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** (`playstore`, `create --play-store`, ModAVD#125): + +- The instances' Google APIs image has Google Play services but no store. The store comes from Google's official `system-images;android-34;google_apis_playstore;x86_64` r14, the same release as the instances' image. After the license agreement, `components fetch --play-store` downloads that image and reads five files out of its `system.img`, read-only, into `sdk\playstore\`, then deletes the temporary `system.img`: the store, the Google-signed Play services, GSF and Partner Setup, and the store's fs-verity certificate, each checked against a built-in SHA-256. The component is optional: `components status` and `check` do not count it as required. +- 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. `create --play-store` cannot be combined with `--no-bridge`, and 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 created without `--play-store` is not touched, and `mas-avd.json` gets no extra key. With it, `instances..playStore` is `true`, and `false` after uninstall. + **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. diff --git a/README.md b/README.md index 7b630fc..e718a51 100644 --- a/README.md +++ b/README.md @@ -62,13 +62,14 @@ 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] [--play-store] [--timeout 1200]`、`create --resume N` | 新建,并在后台无头开机一次把初始化做完再关机(见下)。默认带 arm64 执行桥;`--no-bridge` 记下这台不要桥,以后开机也不装。`--play-store` 在执行桥之后放入 Google Play 商店(默认不带,见下)。`--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) | @@ -99,6 +100,15 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数] - 装桥期间(含其中的关机、冷启动)`busy` 为 `bridging`;这时 `delete`、再次 `bridge` 返回实例忙(退出码 5)。调用方不认识的 `busy` 取值一律当忙处理。 - 不经 modavd 或管理器开机的实例(别的程序直接拉起模拟器)不会自动装,要装就跑一次 `bridge install`。 +**Google Play 商店**(`playstore`、`create --play-store`,ModAVD#125): + +- 实例用的 Google APIs 镜像有 Play 服务、没有商店。商店取自 Google 官方的 `system-images;android-34;google_apis_playstore;x86_64` r14(和实例的镜像同一期):`components fetch --play-store` 同意许可协议后下载这个镜像,从里面的 `system.img` 只读取出五个文件,放进 `sdk\playstore\`,然后删掉临时解出的 `system.img`。这五个文件是商店、Google 签名的 Play 服务、GSF、合作伙伴设置和商店的 fs-verity 证书,每个都核对写死的 SHA-256。这一项是可选的,`components status`、`check` 不把它算进必需组件。 +- 只放商店不够:镜像自带的 Play 服务和 GSF 是测试证书签的,和商店签名对不上,Google 的组件会拒绝工作,所以一起换成 Play 版镜像里同版本的。 +- 装在执行桥带来的可写系统覆盖层里,所以实例要有执行桥。`create --play-store` 和 `--no-bridge` 不能一起用;装了商店的实例不能 `bridge uninstall`,要先 `playstore uninstall`。镜像自带的同名占位应用(LicenseChecker)会被藏起来,卸载时恢复。 +- `install`:放入文件后冷启动一次。`uninstall`:先停用商店,去掉 Google Play 装的更新、删掉放进去的文件,冷启动一次后恢复并启用占位应用。实例原来关着的,做完再关掉;开着的,照原来的方式开回来。`status` 是只读的,要实例开着。 +- 设备没有通过 Play 保护机制认证。登录时提示未认证的,按[文档](https://modavd.github.io/guide/playstore)登记这台实例的 GSF ID。查 Play Integrity 的游戏多半过不了(镜像是 userdebug、测试密钥)。 +- 不带 `--play-store` 新建的实例什么都不动,`mas-avd.json` 里也不多键。带了的记 `instances..playStore: true`,卸掉后是 `false`。 + **按应用走代理**(`proxy`): - 名单里的应用(按包名)的 TCP、UDP 和 DNS 都走设好的代理,别的应用照旧直连。不用 VPN:客体里一个 root 守护进程(`modavd-proxyd`,随 `modavd.exe` 带进去)按应用的 uid 用策略路由和透明代理接走它们的流量,应用和系统都看不到 VPN。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index abfe819..8ae3713 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..ebf7611 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -79,8 +79,10 @@ var ( optShellTO = flagSpec{"timeout", kindInt, "秒", "命令最多执行多少秒(不给用默认值)"} optCheckMB = flagSpec{"memory", kindString, "M", "按这个实例内存判断电脑内存够不够(默认 6G)"} optNoBridge = flagSpec{"no-bridge", kindBool, "", "不装 arm64 执行桥,以后开机也不装(默认 modavd / 管理器开机时自动装上)"} - optAutoMAS = flagSpec{"automas-version", kindString, "V", "调用方 AUTO-MAS 的版本(新版要求更高的 AUTO-MAS 时不装)"} - optBootNoBridge = flagSpec{"no-bridge", kindBool, "", + optPlayStore = flagSpec{"play-store", kindBool, "", + "自带 Google Play 商店(默认不带):要有执行桥,并且先用 components fetch --play-store 补全它的组件"} + optAutoMAS = flagSpec{"automas-version", kindString, "V", "调用方 AUTO-MAS 的版本(新版要求更高的 AUTO-MAS 时不装)"} + optBootNoBridge = flagSpec{"no-bridge", kindBool, "", "这次开机不自动装执行桥(实例有执行桥的开机标记、桥还没装时默认会装)"} optAcceptLicense = flagSpec{"accept-license", kindBool, "", "不询问,直接视为同意《Android 软件开发套件许可协议》(自动化用;协议全文照样取回并打印,取不到照样退出);--json 时必须给"} @@ -130,15 +132,15 @@ func init() { run: runStatus, human: humanOf(formatStatus), }, { - name: "create", args: "[--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30|60] [--resolution 1080|720] [--no-bridge] [--timeout 1200] | --resume N", + name: "create", args: "[--index N] [--title T] [--memory 6G] [--cpu N] [--data-gb N] [--fps 30|60] [--resolution 1080|720] [--no-bridge] [--play-store] [--timeout 1200] | --resume N", summary: "新建一台实例,后台开机做完初始化后关机", details: "不给的选项用默认值:内存 6 GB、6 核、数据盘 64 GB、60 帧、1080p(1920×1080 / DPI 280)。内存和核数只能取列出的档位,不会自动凑整。" + "建好文件后在后台无头开机一次,做完开机设置、宿主输入法、首次初始化和 arm64 执行桥(只对内置名单里的游戏生效," + "其余应用照旧;--no-bridge 不装,以后也不装),再先 sync 后正常关机,一般要几分钟,进度逐步打印(「新建中 35%」,按做完的步骤算);" + - "之后第一次开机和平常一样快。" + + "之后第一次开机和平常一样快。--play-store 在执行桥之后再放入 Google Play 商店(取自 Google 官方的 Google Play 版系统镜像,见 playstore 命令)。" + "这期间实例的状态是 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}, + flags: []flagSpec{optIndex, optTitle, optMemory, optCPU, optDataGB, optFPS, optResolution, optNoBridge, optPlayStore, optCreateTimeout, optResume}, final: true, run: runCreate, human: humanOf(formatCreate), }, @@ -245,6 +247,17 @@ 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 补全;" + + "装在执行桥带来的可写系统镜像覆盖层里,所以实例要有执行桥。install 放入商店和 Google 签名的 Play 服务、GSF、合作伙伴设置," + + "藏起镜像自带的同名占位应用,再冷启动一次生效;uninstall 去掉 Google Play 装的更新、删掉这些文件,冷启动后恢复占位应用。" + + "实例原来关着的,做完再关掉;开着的,冷启动后照原来的方式开回来(带窗口或无头)。status:只读,实例要开着。" + + "装了商店的实例不能卸执行桥,要先卸商店。新建时带 --play-store 也能装(默认不带)。", + 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 空闲维护)," + @@ -475,6 +488,7 @@ func buildCreateRequest(inv *invocation) (emu.CreateRequest, error) { req.FPS = intPtrOpt(inv, "fps") req.Resolution = intPtrOpt(inv, "resolution") req.NoBridge = inv.boolOpt("no-bridge") + req.PlayStore = inv.boolOpt("play-store") if inv.isSet("timeout") { n := inv.intOpt("timeout") if n <= 0 { @@ -483,7 +497,7 @@ func buildCreateRequest(inv *invocation) (emu.CreateRequest, error) { req.Timeout = time.Duration(n) * time.Second } if inv.isSet("resume") { - for _, o := range []string{"index", "title", "memory", "cpu", "data-gb", "fps", "resolution", "no-bridge"} { + for _, o := range []string{"index", "title", "memory", "cpu", "data-gb", "fps", "resolution", "no-bridge", "play-store"} { if inv.isSet(o) { return req, errs.Newf(errs.Usage, "--resume 只重做初始化,不能再给 --%s", o) } @@ -1146,6 +1160,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..2e60fc1 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -642,6 +642,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/run_test.go b/internal/cli/run_test.go index 4152b81..35de67a 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -166,6 +166,13 @@ func (f *fakeManager) Bridge(ctx context.Context, i int, action string) (*emu.Br return &emu.BridgeResult{Index: i, Action: action, Installed: action == "install", Warnings: []string{}}, nil } +func (f *fakeManager) PlayStore(ctx context.Context, i int, action string) (*emu.PlayStoreResult, error) { + f.record("playstore " + itoa(i) + " " + action) + 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 +} + func (f *fakeManager) DiskTrim(ctx context.Context, i int) (*emu.DiskTrimResult, error) { f.record("disk-trim " + itoa(i)) return &emu.DiskTrimResult{Index: i, DiscardMaxBytes: 2147483648}, nil diff --git a/internal/emu/bridge.go b/internal/emu/bridge.go index db6213b..8b005d0 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/createinit.go b/internal/emu/createinit.go index fea857f..afca09b 100644 --- a/internal/emu/createinit.go +++ b/internal/emu/createinit.go @@ -56,6 +56,8 @@ var ( createStop = (*Manager).stopAndWait createCheck = (*Manager).checkCreated createRunning = func(m *Manager, i int) (bool, error) { _, ok, err := m.findQemu(i); return ok, err } + // createPlayStore puts the Google Play Store in during the setup of an instance created with it (playstore.go). + createPlayStore = (*Manager).putPlayStore ) // creation is the creation state of instance i: "" (ready, or made before 0.2.0), "creating" or "failed", with the @@ -152,8 +154,15 @@ const ( stageBridge = 12.0 // its second: the install and the shutdown stageBridgeCheck = 1.0 // its last: the check that finds it installed stageShutdown = 6.0 + stagePlayStore = 6.0 // pushing the Google Play Store's 230 MB and copying them into the overlay ) +// withPlayStoreStage is plan with the Google Play Store's step before the shutdown (the last stage). +func withPlayStoreStage(plan []createStage) []createStage { + n := len(plan) + return append(append(plan[:n-1:n-1], createStage{"play_store", stagePlayStore}), plan[n-1]) +} + // createPlan is the stages of a setup: one boot (boot is its wait; with the first-boot setup unless the instance is // initialized), the bridge rounds of a new instance when the bridge is installed, the shutdown. An instance whose // first remount is done (a resume) needs one round fewer: the shutdown passes over the rest. @@ -295,7 +304,7 @@ func (p *createProgress) boot(ev Progress) { // one boot's setup is over: so is everything before the next bridge round or the shutdown. Not "ready": // the instance is still being created. step, msg = "setup", fmt.Sprintf("实例 %d 这次开机后的设置做完了", p.i) - if k := p.next("bridge", "create_stop"); k >= 0 { + if k := p.next("bridge", "play_store", "create_stop"); k >= 0 { p.advance(k) } default: @@ -353,13 +362,18 @@ func (m *Manager) setupNew(ctx context.Context, i int, cfg *avd.Ini, req CreateR } } bridge := m.bridgeWanted(i, cfg) + store := bridge && m.playStoreWanted(i) // Create refuses --play-store with --no-bridge // the progress is also kept on disk, for status / list in other processes (AUTO-MAS, the manager) defer m.removeCreateProgress(i) boot := stageBoot if req.Resume { boot = stageColdBoot // the instance has booted before } - prog := &createProgress{i: i, plan: createPlan(boot, !m.initialized(i), bridge), + plan := createPlan(boot, !m.initialized(i), bridge) + if store { + plan = withPlayStoreStage(plan) + } + prog := &createProgress{i: i, plan: plan, bootLog: func() string { if mk, ok := m.readSetupMarker(i); ok { return mk.LogFile // written at the launch, before the boot wait @@ -376,10 +390,18 @@ func (m *Manager) setupNew(ctx context.Context, i int, cfg *avd.Ini, req CreateR if bridge { what += "、arm64 执行桥" } + if store { + what += "、Google Play 商店" + } prog.stage("create", fmt.Sprintf("实例 %d:后台无头开机,做完%s后正常关机(一般几分钟)", i, what)) m.Log.Infof("实例 %d 新建后的初始化:后台无头开机,做完%s后正常关机", i, what) sr, err := createStart(m, cctx, i, StartOptions{creating: true, NoBridge: !bridge, Progress: prog.boot}) + if err == nil && store { + // after the bridge's rounds (its overlay is writable by now); the files take effect at the next boot + prog.stage("play_store", fmt.Sprintf("实例 %d 放入 Google Play 商店", i)) + err = createPlayStore(m, cctx, i) + } if err == nil { err = createCheck(m, cctx, i, bridge, sr) } diff --git a/internal/emu/crud.go b/internal/emu/crud.go index 398c883..99920e1 100644 --- a/internal/emu/crud.go +++ b/internal/emu/crud.go @@ -50,6 +50,15 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (_ *InstanceInf if err != nil { return nil, err } + if req.PlayStore { + // before any file is written: the store goes into the bridge's writable overlay, from the component + if req.NoBridge { + return nil, errs.New(errs.InvalidArgument, "带 Google Play 商店要有 arm64 执行桥(可写系统镜像),不能和 --no-bridge 一起用") + } + if err := m.playStoreReady(); err != nil { + return nil, err + } + } // A root used by modavd alone has no mas-avd.json yet (AUTO-MAS writes it when the root is added). if _, err := m.Root.EnsureMetadata(); err != nil { return nil, err @@ -116,6 +125,13 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (_ *InstanceInf m.Log.Warnf("实例 %d 没放上执行桥的开机标记: %v", i, err) } } + if req.PlayStore { + // recorded before the setup, so a resume of a failed one puts the store in too + if err := m.setPlayStoreWanted(i, true); err != nil { + lock.Unlock() + return nil, err + } + } if req.filesOnly { lock.Unlock() info := m.instanceInfo(ctx, in, nil, nil) diff --git a/internal/emu/playstore.go b/internal/emu/playstore.go new file mode 100644 index 0000000..845e646 --- /dev/null +++ b/internal/emu/playstore.go @@ -0,0 +1,383 @@ +package emu + +// The Google Play Store (ModAVD/ModAVD#125, package playstore). Off unless asked for: create --play-store, or +// `modavd playstore install` on an existing instance. Its files (root.PlayStoreComponent, sdk\playstore, fetched +// by `components fetch --play-store` after the license) 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, +// and is put in after the bridge's rounds. The files take effect at the next boot: a create's setup ends with its +// shutdown anyway, an install on an existing instance boots it 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 the store was asked for (create + // --play-store, playstore install), 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" +) + +// 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 and the create step 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 with +// what to run. +func (m *Manager) playStoreReady() error { + if playStoreFilesReady(m.playStoreDir()) { + return nil + } + return errs.Newf(errs.NotReady, "还没有补全「%s」组件(%s)", root.PlayStoreName, m.playStoreDir()). + WithAdvice("先运行 modavd components fetch --play-store:同意《Android 软件开发套件许可协议》后," + + "从 Google 下载 Google Play 版系统镜像(约 1.5 GB),取出商店的文件") +} + +// 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.New(errs.Unsupported, "arm64 客体没有 Google Play 商店这一项") + } + if !m.bridgeWanted(i, cfg) || !regularFile(bridgeMarker(m.Root, i)) { + return errs.Newf(errs.NotReady, "实例 %d 没有 arm64 执行桥,不按可写系统镜像开机,放不进 Google Play 商店", i). + WithAdvice(fmt.Sprintf("先运行 modavd bridge %d install 装上执行桥,再装商店", i)) + } + return 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 +} + +// putPlayStore is create's step: the guest script's install on the running instance i. The files take effect at the +// next boot (the setup's shutdown comes next). +func (m *Manager) putPlayStore(ctx context.Context, i int) error { + run, err := playStoreScript(m, ctx, i, "install") + if err != nil { + return err + } + if run.Result != playStoreInstalled { + return playStoreFailed(i, "install", run) + } + m.Log.Infof("实例 %d 已放好 Google Play 商店(下次开机生效)", i) + return nil +} + +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] + } + e := errs.New(errs.LaunchFailed, msg) + if run.Result == playStoreNotWritable { + e = errs.New(errs.NotReady, msg).WithAdvice(fmt.Sprintf("实例的系统镜像不可写:先运行 modavd bridge %d install,再试一次", i)) + } + return e +} + +// 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. +func (m *Manager) PlayStore(ctx context.Context, i int, action string) (*PlayStoreResult, 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" { + if err := m.playStoreBridgeError(i, in.Config); err != nil { + return nil, err + } + if err := m.playStoreReady(); 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..05990ec --- /dev/null +++ b/internal/emu/playstore_test.go @@ -0,0 +1,283 @@ +//go:build windows + +package emu + +import ( + "context" + "os" + "slices" + "strings" + "testing" + + "modavd/internal/errs" + "modavd/internal/win" +) + +// playStoreHooks replaces what the store's code does to a guest for one test: the component check, the guest script +// (scripted results per action, in order), and for PlayStore the qemu scan, starts and stops. +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", "put" + starts []StartOptions +} + +func (h *playStoreHooks) install(t *testing.T) { + t.Helper() + ready, script, find, start, stop, put := playStoreFilesReady, playStoreScript, playStoreFindQemu, playStoreStart, playStoreStop, createPlayStore + t.Cleanup(func() { + playStoreFilesReady, playStoreScript, playStoreFindQemu, playStoreStart, playStoreStop, createPlayStore = ready, script, find, start, stop, put + }) + playStoreFilesReady = func(string) bool { h.calls = append(h.calls, "ready"); 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) (win.Process, bool, error) { + if h.running { + return win.Process{PID: 4242, Name: "qemu-system-x86_64-headless.exe"}, true, nil + } + return win.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 + } + createPlayStore = func(m *Manager, ctx context.Context, i int) error { + h.calls = append(h.calls, "put") + return m.putPlayStore(ctx, i) + } +} + +// TestCreateWithoutPlayStore: a create that does not ask for the store (the default) does nothing about it: no +// component check, no guest script, no step in the plan, no key in mas-avd.json. +func TestCreateWithoutPlayStore(t *testing.T) { + m, _ := opsTestManager(t) + var h createHooks + h.install(t) + var ps playStoreHooks + 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.Contains(steps, "play_store") { + 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) { + t.Error("wanted without asking") + } +} + +// TestCreatePlayStoreRefused: --play-store with --no-bridge, or without the component, is refused before any file is +// written (no instance, no store calls beyond the check). +func TestCreatePlayStoreRefused(t *testing.T) { + m, _ := opsTestManager(t) + var h createHooks + h.install(t) + var ps playStoreHooks + ps.install(t) + i := 6 + _, err := m.Create(context.Background(), CreateRequest{Index: &i, PlayStore: true, NoBridge: true}) + opsSkipIfNotImplemented(t, err) + if errs.CodeOf(err) != errs.InvalidArgument || !strings.Contains(err.Error(), "--no-bridge") { + t.Errorf("with --no-bridge: %v", err) + } + _, err = m.Create(context.Background(), CreateRequest{Index: &i, PlayStore: true}) + if errs.CodeOf(err) != errs.NotReady || !strings.Contains(errs.As(err).Advice, "components fetch --play-store") { + t.Errorf("without the component: %v", err) + } + if left := instanceFilesOnDisk(m.Root, 6); len(left) != 0 || len(h.opts) != 0 { + t.Errorf("files %v, boots %d", left, len(h.opts)) + } + if !slices.Equal(ps.calls, []string{"ready"}) { + t.Errorf("store calls %v", ps.calls) + } +} + +// TestCreatePlayStore: --play-store puts the store in after the setup's boot (the bridge's rounds), before the check +// and the shutdown, with its own progress step; a failed install fails the create, and --resume puts it in again. +func TestCreatePlayStore(t *testing.T) { + m, _ := opsTestManager(t) + var h createHooks + var ps playStoreHooks + h.start = func(m *Manager, ctx context.Context, i int, opt StartOptions) (*StartResult, error) { + ps.calls = append(ps.calls, "boot") + return &StartResult{HostIME: true}, nil + } + h.check = nil + h.install(t) + ps.ready = true + ps.results = map[string][]string{"install": {playStoreNotWritable, playStoreInstalled}} + ps.install(t) + var steps []string + i := 6 + _, err := m.Create(context.Background(), CreateRequest{Index: &i, PlayStore: true, + Progress: func(p Progress) { steps = append(steps, p.Step) }}) + opsSkipIfNotImplemented(t, err) + if errs.CodeOf(err) != errs.NotReady || !strings.Contains(err.Error(), "新建失败") { + t.Fatalf("an install that cannot write: %v", err) + } + if !m.playStoreWanted(6) || h.stopped != 1 { + t.Fatalf("wanted %v, stopped %d", m.playStoreWanted(6), h.stopped) + } + ps.calls, steps = nil, nil + info, err := m.Create(context.Background(), CreateRequest{Index: &i, Resume: true, + Progress: func(p Progress) { steps = append(steps, p.Step) }}) + if err != nil || info.Status != StatusOffline { + t.Fatalf("resume: %+v %v", info, err) + } + if want := []string{"boot", "put", "script install"}; !slices.Equal(ps.calls, want) { + t.Errorf("resume calls %v, want %v", ps.calls, want) + } + if k := slices.Index(steps, "play_store"); k < 0 || slices.Index(steps, "create_stop") < k { + t.Errorf("progress steps %v", steps) + } + if len(h.opts) == 0 || h.opts[len(h.opts)-1].NoBridge { + t.Errorf("setup boots %+v, want the bridge", h.opts) + } +} + +// 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. +func TestPlayStoreInstallUninstall(t *testing.T) { + m, i := opsTestInstance(t) + writeBridgeMarker(m.Root, i) + ctx := context.Background() + var ps playStoreHooks + ps.ready = true + ps.results = map[string][]string{"install": {playStoreInstalled}, "status": {playStoreInstalled}, + "uninstall": {playStoreReboot, playStoreGone}} + ps.install(t) + + 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) || len(res.Files) != 2 { + t.Errorf("wanted %v, files %q", m.playStoreWanted(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) { + t.Error("still wanted 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) + var ps playStoreHooks + ps.ready = true + ps.install(t) + l, ok, err := win.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 slices.Contains(ps.calls, "start") || slices.ContainsFunc(ps.calls, func(c string) bool { return strings.HasPrefix(c, "script") }) { + t.Errorf("calls %v", ps.calls) + } +} + +// TestPlayStoreRefused: install needs the bridge and the component, status a running instance, the action a known +// word; a script that cannot write is not ready, one that stops early a failure; nothing is recorded then. +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 + if _, err := m.PlayStore(ctx, i, "install"); errs.CodeOf(err) != errs.NotReady || !strings.Contains(err.Error(), "执行桥") { + t.Errorf("without the bridge marker: %v", err) + } + writeBridgeMarker(m.Root, i) + if _, err := m.PlayStore(ctx, i, "install"); errs.CodeOf(err) != errs.NotReady || !strings.Contains(err.Error(), "组件") { + t.Errorf("without the component: %v", err) + } + 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) + } + ps.ready = true + ps.results = map[string][]string{"install": {playStoreNotWritable}} + if _, err := m.PlayStore(ctx, i, "install"); errs.CodeOf(err) != errs.NotReady || !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 _, err := m.PlayStore(ctx, i, "uninstall"); err == nil || m.playStoreWanted(i) { + t.Errorf("after the failures: wanted %v, %v", m.playStoreWanted(i), 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/types.go b/internal/emu/types.go index e1ffaa4..a27d2ef 100644 --- a/internal/emu/types.go +++ b/internal/emu/types.go @@ -122,6 +122,9 @@ type CreateRequest struct { // NoBridge: no execution bridge (bridge.go), recorded for good. By default a new instance gets the bridge's boot // marker and create's setup installs it. NoBridge bool + // PlayStore: put the Google Play Store in (playstore.go), after the bridge; off by default. Needs the bridge and + // the component (components fetch --play-store), both checked before any file is written. + PlayStore bool // Timeout bounds create's whole setup (createinit.go); 0 = DefaultCreateTimeout. Timeout time.Duration // Progress, when set, is told about each step of the setup (same events as StartOptions.Progress, one 0–1000 bar). 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/guest_test.go b/internal/guest/guest_test.go index 51fcf86..ea8439e 100644 --- a/internal/guest/guest_test.go +++ b/internal/guest/guest_test.go @@ -15,7 +15,7 @@ import ( ) func TestScriptsHaveNoCR(t *testing.T) { - for _, name := range []string{CPUInfoBind, IdlePoll, WifiFastpath, VulkanFix} { + for _, name := range []string{CPUInfoBind, IdlePoll, WifiFastpath, VulkanFix, PlayStore} { b, err := Script(name) if err != nil { t.Fatalf("%s: %v", name, err) diff --git a/internal/guest/playstore_sh_test.go b/internal/guest/playstore_sh_test.go new file mode 100644 index 0000000..dc1de63 --- /dev/null +++ b/internal/guest/playstore_sh_test.go @@ -0,0 +1,210 @@ +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") + } + sep := string(os.PathListSeparator) + g.env = append(os.Environ(), "MODAVD_PS_ROOT="+g.root, "PATH="+bin+sep+os.Getenv("PATH")) + return g +} + +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) +} + +// 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, 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..e7dd0a2 100644 --- a/internal/root/components.go +++ b/internal/root/components.go @@ -10,6 +10,8 @@ import ( "runtime" "strings" "unicode" + + "modavd/internal/playstore" ) // ---- Google components ----------------------------------------------------------------------------------------- @@ -90,6 +92,37 @@ 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" + 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 { From 609aaf7e11fae0573625bb2d05c067b03f6316ef Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 6 Nov 2026 13:21:56 +0100 Subject: [PATCH 2/9] =?UTF-8?q?Google=20Play=20=E5=95=86=E5=BA=97=EF=BC=9A?= =?UTF-8?q?components=20fetch=20--play-store=20=E5=92=8C=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补全组件多一项可选的 Google Play 商店:同一套许可协议、下载源、锁和校验;下 Google Play 版 系统镜像(downloads 里用单独的文件名,不和 Google APIs 镜像的 zip 撞名),只解出 system.img, 取出五个文件放进 sdk\playstore,随即删掉镜像。界面:新建对话框加「Google Play 商店」开关 (默认不勾),组件没补全时先走许可协议和补全,完成后自动新建;组件列表多一行可选的商店。 Refs ModAVD/ModAVD#125 --- internal/cli/commands.go | 17 +- internal/cli/components_test.go | 10 + internal/cli/run_test.go | 10 +- internal/components/download.go | 3 + internal/components/fetch.go | 29 ++- internal/components/install.go | 73 ++++++- internal/components/playstore_windows_test.go | 182 ++++++++++++++++++ internal/root/components.go | 4 + internal/ui/API.md | 6 +- internal/ui/diag_backend.go | 17 +- internal/ui/diag_backend_test.go | 9 +- internal/ui/diag_jobs_test.go | 27 ++- internal/ui/diag_source_test.go | 2 +- internal/ui/diagnostics.go | 3 +- internal/ui/handlers.go | 6 +- internal/ui/http_test.go | 7 +- internal/ui/web/dev/mock.js | 3 + internal/ui/web/js/diagnostics.js | 39 +++- internal/ui/web/js/forms.js | 42 +++- 19 files changed, 446 insertions(+), 43 deletions(-) create mode 100644 internal/components/playstore_windows_test.go diff --git a/internal/cli/commands.go b/internal/cli/commands.go index ebf7611..e29ca73 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -87,8 +87,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,超时换下一个镜像"} @@ -104,8 +106,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 @@ -214,12 +216,14 @@ 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]", + name: "components", args: "[status] | source [auto|googledownloads_cn|tencent|google] | fetch [--accept-license] [--mirror ID] [--mirror-url URL] [--keep-downloads] [--play-store]", summary: "查看或补全 Google 组件(adb 和系统镜像)", details: "status(默认)查看组件,和 check 里的组件一样。fetch 补全缺少的组件:" + "先从镜像取《Android 软件开发套件许可协议》全文并显示,输入 y 同意才继续,否则什么也不下;" + "再依次试 googledownloads.cn、腾讯云镜像、dl.google.com,下载缺少的 adb 和系统镜像(约 1.5 GB,中断了再运行会接着下)," + "校验后装进 sdk\\。已经齐全的跳过;不完整的旧目录改名为 <目录>.incomplete-<时间> 留着,不删。" + + "--play-store 再补全可选的 Google Play 商店(新建时 --play-store、playstore 命令要用):下 Google Play 版系统镜像," + + "从里面的 system.img 只取出商店、Play 服务、GSF、合作伙伴设置和商店的证书,核对 SHA-256 后放进 sdk\\playstore,解出的镜像随即删掉。status 不把它算进必需组件。" + "不带参数时默认查看组件;source 查看或修改记住的下载源(auto、googledownloads_cn、tencent、google)。" + "--json 时必须给 --accept-license(仅 fetch)。只有 Windows 有组件清单,darwin、linux 返回 unsupported。" + "退出码:没同意许可协议是 2(license_declined),路径有中文、空间不够、不是魔改 AVD 的文件夹是 4," + @@ -1016,7 +1020,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 diff --git a/internal/cli/components_test.go b/internal/cli/components_test.go index 050aa35..f96bf35 100644 --- a/internal/cli/components_test.go +++ b/internal/cli/components_test.go @@ -152,6 +152,9 @@ func TestComponentsFetchOptions(t *testing.T) { if res.code != 0 { t.Fatalf("exit %d: %s", res.code, res.stdout) } + if got.PlayStore { + t.Fatal("PlayStore without --play-store") + } if !got.AcceptLicense || !got.JSON || !got.KeepDownloads || got.Mirror != "TENCENT" || strings.Join(got.MirrorURLs, " ") != "http://a.invalid/ http://b.invalid/,http://c.invalid/" || got.ConnectTimeout != 7*time.Second || got.StallTimeout != 8*time.Second || got.ManifestTimeout != 9*time.Second || @@ -180,6 +183,13 @@ func TestComponentsFetchOptions(t *testing.T) { if notices != 1 || progress != 2 { t.Fatalf("%d notices, %d progress lines: %s", notices, progress, res.stderr) } + // --play-store asks for the store too; status does not take it + if res = h.run(t, nil, "--json", "components", "fetch", "--accept-license", "--play-store"); res.code != 0 || !got.PlayStore { + t.Fatalf("--play-store: exit %d, %+v", res.code, got) + } + if res = h.run(t, nil, "components", "status", "--play-store"); res.code != 2 { + t.Fatalf("status --play-store: exit %d", res.code) + } // --quiet hides progress, never the license. res = h.run(t, nil, "--quiet", "components", "fetch", "--accept-license") if res.code != 0 || !strings.Contains(res.stderr, "《许可协议》全文") || strings.Contains(res.stderr, "进度行") { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 35de67a..158df88 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -987,7 +987,7 @@ func TestCreateFlags(t *testing.T) { return &in, nil } r := h.run(t, nil, "create", "--index", "6", "--timeout", "600", "--no-bridge", "--resolution", "720") - if r.code != 0 || len(got) != 1 || *got[0].Index != 6 || got[0].Timeout != 600*time.Second || !got[0].NoBridge || + if r.code != 0 || len(got) != 1 || *got[0].Index != 6 || got[0].Timeout != 600*time.Second || !got[0].NoBridge || got[0].PlayStore || got[0].Resolution == nil || *got[0].Resolution != 720 || got[0].Resume || !strings.Contains(r.stderr, "新建中 2%:后台无头开机") || !strings.Contains(r.stderr, "新建中 35%:实例 6 宿主输入法") || strings.Contains(r.stderr, "新建中 100%") || !strings.Contains(r.stderr, "实例 6 新建完成") { @@ -997,6 +997,14 @@ func TestCreateFlags(t *testing.T) { if r.code != 0 || len(got) != 2 || !got[1].Resume || *got[1].Index != 6 { t.Fatalf("resume: exit %d, %+v", r.code, got) } + // --play-store asks for the store; --resume keeps what the create recorded and takes no --play-store + if r = h.run(t, nil, "create", "--play-store", "--json"); r.code != 0 || len(got) != 3 || !got[2].PlayStore { + t.Fatalf("--play-store: exit %d, %+v", r.code, got) + } + if r = h.run(t, nil, "create", "--resume", "6", "--play-store"); r.code != 2 || len(got) != 3 { + t.Fatalf("--resume --play-store: exit %d", r.code) + } + got = got[:2] for _, bad := range [][]string{{"create", "--resume", "6", "--memory", "4G"}, {"create", "--resume", "6", "--index", "6"}, {"create", "--resume", "6", "--resolution", "1080"}, {"create", "--timeout", "0"}, {"create", "--resume", "5"}} { if r := h.run(t, nil, bad...); r.code != 2 { 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 a4206f1..3944340 100644 --- a/internal/components/fetch.go +++ b/internal/components/fetch.go @@ -31,6 +31,7 @@ import ( "modavd/internal/download" "modavd/internal/errs" "modavd/internal/logx" + "modavd/internal/playstore" "modavd/internal/root" "modavd/internal/win" ) @@ -52,6 +53,10 @@ var ( fetchGOOS = runtime.GOOS componentCatalogue = root.SDKComponents diskFree = win.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..6c8246c 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,78 @@ 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): only the system image is unpacked +// from the Play image's zip, into sdk\.staging\; the store's files are read out of it (playstore.Extract, each +// checked against its pinned size and SHA-256), the 3 GB image is deleted, and the files go into place like a +// component's directory. +func (f *fetcher) installPlayStore(ctx context.Context, c root.SDKComponent, zipPath string) (setAside string, err error) { + staging := filepath.Join(f.stagingRoot(), c.ID) + f.where = "从 " + filepath.Base(zipPath) + " 解出 " + playstore.ImageMember + f.removeStaging(staging) + out := filepath.Join(staging, filepath.FromSlash(c.ArchiveRoot)) + if err := os.MkdirAll(out, 0o755); err != nil { + return "", errs.Wrap(errs.Internal, err, "建不了 "+out+":"+err.Error()) + } + defer f.removeStaging(staging) + img := filepath.Join(staging, "system.img") + if err := f.extractMember(ctx, c, zipPath, playstore.ImageMember, img); err != nil { + return "", err + } + f.where = "从 system.img 取出 Google Play 商店的文件" + f.say(" 从系统镜像里取出商店、Play 服务、GSF、合作伙伴设置…") + if err := playStoreExtract(img, playstore.Files, out); err != nil { + return "", rejected("从 Google Play 版系统镜像里取商店的文件失败:%v", err) + } + if err := os.Remove(img); err != nil { + return "", errs.Wrap(errs.Internal, err, "删不掉临时解出的 "+img+":"+err.Error()) + } + if err := os.WriteFile(filepath.Join(out, "source.properties"), []byte(c.Properties), 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_windows_test.go b/internal/components/playstore_windows_test.go new file mode 100644 index 0000000..79d03cf --- /dev/null +++ b/internal/components/playstore_windows_test.go @@ -0,0 +1,182 @@ +package components + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "modavd/internal/playstore" + "modavd/internal/root" +) + +const ( + storeImage = "the Play image's system.img" + storeAPK = "Phonesky.apk from the test image" + storeProps = "Pkg.Revision=14\n" +) + +// storeSpec is the Google Play Store component as in the real table, with test files: the zip at the Play image's +// URL (whose last part is the Google APIs image's), its own name in downloads\, two pinned files. +func storeSpec(data []byte) root.SDKComponent { + return root.SDKComponent{ + ID: "play-store", Name: root.PlayStoreName, FetchName: "Google Play 商店(测试)", Version: "14", + Target: root.PlayStoreDir, StatusFiles: []string{"Phonesky.apk"}, + 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", + KeyFiles: []string{"Phonesky.apk", "cert.der"}, KeyHashes: map[string]string{"Phonesky.apk": sum256(storeAPK), + "cert.der": sum256("cert")}, ExtractedBytes: 1 << 20, Properties: storeProps, + } +} + +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 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 two files, or fails with fail. +func storeHooks(t *testing.T, spec root.SDKComponent, fail error) *[]string { + t.Helper() + oldCat, oldExtract := playStoreCatalogue, playStoreExtract + t.Cleanup(func() { playStoreCatalogue, playStoreExtract = oldCat, oldExtract }) + playStoreCatalogue = func() root.SDKComponent { return spec } + 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 len(files) != len(playstore.Files) { + t.Errorf("extract asked for %d files", len(files)) + } + if fail != nil { + return fail + } + putFile(t, filepath.Join(dir, "Phonesky.apk"), storeAPK) + putFile(t, filepath.Join(dir, "cert.der"), "cert") + return nil + } + return &calls +} + +// 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, the files and source.properties in sdk\playstore, the +// image, the zip and sdk\.staging 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(filepath.Join(r.SDK(), root.PlayStoreDir)) || len(out.res.Components) != 2 { + t.Fatalf("without PlayStore: calls %v, components %+v", *calls, out.res.Components) + } + + o := opts(m) + o.PlayStore = true + out = fetch(t, nil, r, o) + if out.err != nil { + t.Fatal(out.err) + } + dir := filepath.Join(r.SDK(), root.PlayStoreDir) + 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 { + t.Errorf("source.properties = %q", got) + } + if entries, _ := os.ReadDir(dir); len(entries) != 3 { + t.Errorf("sdk\\playstore has %d entries, want the two files and source.properties", len(entries)) + } + 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 entries, _ := os.ReadDir(downloadsDir(r)); len(entries) != 1 { + t.Errorf("downloads %v", entries) + } + 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()) + } + + // 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("cert")} + 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(filepath.Join(r.SDK(), root.PlayStoreDir)) || 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) + } +} diff --git a/internal/root/components.go b/internal/root/components.go index e7dd0a2..fa82c49 100644 --- a/internal/root/components.go +++ b/internal/root/components.go @@ -49,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 @@ -116,6 +117,9 @@ func PlayStoreComponent() SDKComponent { 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" diff --git a/internal/ui/API.md b/internal/ui/API.md index bd1ee88..ed5d091 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -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?, play_store?: bool}` | 202 `Job`(`kind` 是 `create`:后台开机做完初始化再关机,要几分钟;没给 `index` 时服务端先选好编号,`Job.index` 就是新实例的;结果是 `InstanceInfo`)。`play_store: true` 在执行桥之后放入 Google Play 商店(默认不带);它的组件还没补全时立即报 `not_ready`,先用下面的补全接口带 `play_store` 补上 | | 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` | @@ -55,11 +55,11 @@ 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` | | 组件状态;最后一项是可选的 Google Play 商店(`id` 为 `play-store`,`optional: true`,文件在就是 `installed`) | | 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 版系统镜像,只留商店等 5 个文件) | | 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 探测) | diff --git a/internal/ui/diag_backend.go b/internal/ui/diag_backend.go index 0ac906e..31eb283 100644 --- a/internal/ui/diag_backend.go +++ b/internal/ui/diag_backend.go @@ -35,7 +35,7 @@ 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 + return &DiagComponentsResult{Root: b.r.Dir, Components: append(toDiagComponents(diagComponents(b.r)), playStoreComponent(b.r))}, nil } func (b *realDiagBackend) License(ctx context.Context, source components.SourceSettings) (*DiagLicenseResult, error) { @@ -46,7 +46,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 +56,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 @@ -99,6 +100,18 @@ 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: there when its files are +// (Present; the pinned hashes are checked where it is used, create and playstore install). +func playStoreComponent(r *root.Root) DiagComponent { + c := root.PlayStoreComponent() + dir := c.Dir(r.RuntimeSDK()) + d := DiagComponent{ID: c.ID, Name: c.Name, Optional: true, Installed: c.Present(dir)} + if d.Installed { + d.Version = root.Revision(dir) + } + 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..a9b5aba 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) } } @@ -42,14 +42,17 @@ func TestRealDiagBackendPassesSourceAndLicenseHash(t *testing.T) { var lines []string log := logx.New(func(level logx.Level, msg string) { lines = append(lines, level.String()+" "+msg) }) b := newRealDiagBackend(&root.Root{Dir: `C:\root`}, log) - for _, s := range []components.SourceSettings{ + for k, s := range []components.SourceSettings{ {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, k == 1, s, fakeHash, nil); err != nil { t.Fatal(err) } } + if len(got) != 2 || got[0].PlayStore || !got[1].PlayStore { + t.Fatalf("PlayStore not passed: %+v", got) + } if len(got) != 2 || got[0].Mirror != "google" || len(got[0].MirrorURLs) != 0 || got[1].Mirror != "" || strings.Join(got[1].MirrorURLs, ",") != "https://a.example/x/,https://b.example/y/" { t.Fatalf("sources: %+v", got) diff --git a/internal/ui/diag_jobs_test.go b/internal/ui/diag_jobs_test.go index d4feccd..06462e0 100644 --- a/internal/ui/diag_jobs_test.go +++ b/internal/ui/diag_jobs_test.go @@ -21,6 +21,7 @@ type fakeDiag struct { fetchReturn chan struct{} canceled chan struct{} zipPath string // what Collect returns (C:\tmp\diag.zip when empty) + playStore bool // the last Fetch asked for the Google Play Store too } // the license fakeDiag shows, and the license_sha256 a fetch after reading it carries @@ -39,7 +40,10 @@ 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, playStore bool, _ components.SourceSettings, _ string, progress func(DiagProgress)) (*DiagFetchResult, error) { + f.mu.Lock() + f.playStore = playStore + f.mu.Unlock() if !accept { return nil, errs.New(errs.Usage, "必须同意") } @@ -114,6 +118,27 @@ func TestDiagnosticsEndpointsAndGuards(t *testing.T) { h.waitJob(j.ID, stateIs(stateCanceled)) } +// TestComponentsFetchPlayStore: play_store in the body asks the backend for the Google Play Store too; without it, not. +func TestComponentsFetchPlayStore(t *testing.T) { + done := make(chan struct{}) + close(done) + d := &fakeDiag{fetchReturn: done} + h := diagHarness(t, d, nil) + for _, c := range []struct { + extra string + want bool + }{{"", false}, {`,"play_store":true`, true}} { + var j jobView + expect(t, h.do("POST", "/api/components/fetch", fetchBody(c.extra)), http.StatusAccepted, &j) + h.waitJob(j.ID, stateIs(stateDone)) + d.mu.Lock() + got := d.playStore + d.mu.Unlock() + if got != c.want { + t.Errorf("%q: playStore %v", c.extra, got) + } + } +} func TestDiagnosticsCollectSectionsAndPlaceholder(t *testing.T) { d := &fakeDiag{} h := diagHarness(t, d, nil) 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..cde7abc 100644 --- a/internal/ui/diagnostics.go +++ b/internal/ui/diagnostics.go @@ -85,7 +85,8 @@ 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) } diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index aa8e3fa..086b6d2 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -61,12 +61,13 @@ func (s *server) handleCreate(w http.ResponseWriter, r *http.Request) { DataPartitionGB *int `json:"data_partition_gb"` FPS *int `json:"fps"` Resolution *int `json:"resolution"` + PlayStore bool `json:"play_store"` // with the Google Play Store (off by default; needs its component) } if !decode(w, r, &body) { return } req := emu.CreateRequest{Index: body.Index, MemoryMB: body.MemoryMB, CPU: body.CPU, - DataPartitionGB: body.DataPartitionGB, FPS: body.FPS, Resolution: body.Resolution} + DataPartitionGB: body.DataPartitionGB, FPS: body.FPS, Resolution: body.Resolution, PlayStore: body.PlayStore} if body.Title != nil { req.Title = *body.Title } @@ -413,6 +414,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 +440,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_test.go b/internal/ui/http_test.go index eb28656..6119f5a 100644 --- a/internal/ui/http_test.go +++ b/internal/ui/http_test.go @@ -134,7 +134,8 @@ func TestCreateUpdateDelete(t *testing.T) { } return h.waitJob(v.ID, stateIs(stateDone)) } - j := create(map[string]any{"title": "测试机", "index": 2, "memory_mb": 4096, "cpu": 4, "data_partition_gb": 32, "fps": 30, "resolution": 720}) + j := create(map[string]any{"title": "测试机", "index": 2, "memory_mb": 4096, "cpu": 4, "data_partition_gb": 32, "fps": 30, "resolution": 720, + "play_store": true}) if res, _ := j.Result.(map[string]any); j.Index != 2 || res["title"] != "测试机" { t.Fatalf("created %+v", j) } @@ -153,10 +154,10 @@ func TestCreateUpdateDelete(t *testing.T) { } r := c[0] if r.Title != "测试机" || *r.Index != 2 || *r.MemoryMB != 4096 || *r.CPU != 4 || *r.DataPartitionGB != 32 || *r.FPS != 30 || r.Resolution == nil || *r.Resolution != 720 || - r.Progress == nil || r.Resume { + r.Progress == nil || r.Resume || !r.PlayStore { t.Fatalf("create request %+v", r) } - if r := c[1]; r.Title != "" || r.Index == nil || *r.Index != auto1.Index || r.MemoryMB != nil || r.CPU != nil || r.DataPartitionGB != nil || r.FPS != nil || r.Resolution != nil { + if r := c[1]; r.Title != "" || r.Index == nil || *r.Index != auto1.Index || r.MemoryMB != nil || r.CPU != nil || r.DataPartitionGB != nil || r.FPS != nil || r.Resolution != nil || r.PlayStore { t.Fatalf("empty create request %+v", r) } if r := c[3]; !r.Resume || r.Index == nil || *r.Index != 3 { diff --git a/internal/ui/web/dev/mock.js b/internal/ui/web/dev/mock.js index e79c90a..858060a 100644 --- a/internal/ui/web/dev/mock.js +++ b/internal/ui/web/dev/mock.js @@ -220,6 +220,8 @@ function checkResult() { { id: 'system-image', name: '系统镜像 Android 14 Google APIs x86_64', version: '14', build: 'r07', installed: true, optional: false, local_sdk: false }, { id: 'launcher', name: '轻量桌面 µLauncher', version: '0.6.1', build: '', installed: true, optional: true, local_sdk: false }, { id: 'ime', name: '宿主输入法', version: '1.2.0', build: '', installed: true, optional: true, local_sdk: false }, + // the Google Play Store: optional, only after a fetch with play_store + { id: 'play-store', name: 'Google Play 商店', version: db.playStore ? '14' : '', build: '', installed: !!db.playStore, optional: true, local_sdk: false }, ]; if (db.check === 'warn') { Object.assign(items[4], { ok: false, reason: '没有找到能用 Vulkan 的显卡(只有 Microsoft Basic Render Driver)', advice: '安装或更新显卡驱动(NVIDIA / AMD / Intel 官网驱动自带 Vulkan)。没有可用的显卡时模拟器只能软件渲染,游戏会占满 CPU,性能会很差' }); @@ -774,6 +776,7 @@ async function handle(url, init) { 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', '组件补全正在进行'); + if (body.play_store === true) db.playStore = true; const j = newJob('components', -1); runDiag(j, 'components'); return json(j, 202); } if (p === '/api/diag/collect' && method === 'POST') { diff --git a/internal/ui/web/js/diagnostics.js b/internal/ui/web/js/diagnostics.js index cb3d9c4..bbf594f 100644 --- a/internal/ui/web/js/diagnostics.js +++ b/internal/ui/web/js/diagnostics.js @@ -62,13 +62,30 @@ function licenseURL(source, urls) { let licenseCtl = null; +// afterJob calls fn once the job id is done (not when it fails or is canceled: its own toast says so). +function afterJob(id, fn) { + let fired = false; + let un = null; + const check = () => { + const j = S.jobs.get(id); + if (fired || !j || j.state === 'running') return; + fired = true; + if (un) un(); + if (j.state === 'done') fn(); + }; + un = subscribe(check); + check(); +} + // 补全组件: 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 also fetches the optional Google Play Store (create's 「Google Play 商店」); opts.then runs once that +// job is done. +export function openComponentFetch(opts = {}) { if (licenseCtl) return; const running = activeJob('components'); if (running) { - toast({ tone: 'info', title: '组件正在补全', text: progressText(running) }); + toast({ tone: 'info', title: '组件正在补全', text: opts.then ? `等它做完再新建:${progressText(running)}` : progressText(running) }); return; } const select = h('select', { class: 'input', id: 'lic-src' }, ...SOURCES.map(([value, text]) => h('option', { value, text }))); @@ -131,7 +148,11 @@ export function openComponentFetch() { 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 (opts.playStore) fetchBody.play_store = true; + const j = await startJob(api.post('/api/components/fetch', fetchBody)); + started = true; + if (opts.then) afterJob(j.id, opts.then); d.close(); } catch (e) { agree.disabled = false; @@ -142,13 +163,21 @@ export function openComponentFetch() { custom.addEventListener('change', load); custom.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); load(); } }); + let started = false; + const sub = opts.playStore + ? '组件由 Google 提供,Google Play 商店取自 Google 官方的 Google Play 版系统镜像(约 1.5 GB),同样受这份协议约束。先选下载源,读完从它取到的许可协议再决定。' + : '组件由 Google 提供。先选下载源,读完从它取到的许可协议再决定。'; const d = openDialog({ - title: 'Android 软件开发套件许可协议', sub: '组件由 Google 提供。先选下载源,读完从它取到的许可协议再决定。', cls: 'dlg-update', light: true, + title: 'Android 软件开发套件许可协议', sub, cls: 'dlg-update', light: true, body: h('div', { class: 'dlg-body diag-license-body' }, fields, status, note, text), actions: { left: btn('btn btn-ghost', '不同意', () => d.close()), cancel: false, ok: agree }, }); licenseCtl = d; - d.onClose.push(() => { licenseCtl = null; seq++; }); + d.onClose.push(() => { + licenseCtl = null; + seq++; + if (opts.then && !started) toast({ tone: 'info', title: '没有新建实例', text: '要带 Google Play 商店,得先同意许可协议、补全它的组件。' }); + }); agree.disabled = true; api.get('/api/components/source').catch(() => ({})).then((saved) => { if (licenseCtl !== d) return; // closed meanwhile diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js index e614ff8..b4792cd 100644 --- a/internal/ui/web/js/forms.js +++ b/internal/ui/web/js/forms.js @@ -6,6 +6,7 @@ import { S, update, subscribe, instance, lastJob } from './store.js'; import { openDialog, toast } from './ui.js'; import { quoted, label, uptime, fmtBytes } from './format.js'; import * as A from './actions.js'; +import { openComponentFetch } from './diagnostics.js'; const MEM = [[3072, '3 GB'], [4096, '4 GB'], [5120, '5 GB'], [6144, '6 GB']]; const CPU = [[2, '2 核'], [4, '4 核'], [6, '6 核']]; @@ -76,6 +77,8 @@ export function openCreate() { const idx = h('select', { class: 'select', id: 'c-index' }, h('option', { value: '', text: `自动(编号 ${free[0]})` }), free.map((i) => h('option', { value: String(i), text: `编号 ${i}` }))); const idxHint = hint(''); + // the Google Play Store (ModAVD#125): off unless asked for + const store = h('input', { type: 'checkbox', id: 'c-store', 'aria-describedby': 'c-store-h' }); // the root's port plan (mas-avd.json portBase); a manager without port_base only knows 20000 const base = S.list.port_base || 20000; const sync = () => { @@ -103,9 +106,11 @@ export function openCreate() { segRow('帧率', 'fps', FPS, 60, hint('30 更省资源')), segRow('分辨率', 'resolution', RESOLUTION, 1080, hint('720p 更省资源')), inputRow('数据盘上限', h('span', { class: 'with-suffix' }, disk, h('span', { class: 'suffix', text: 'GB' })), diskHint, diskErr), - inputRow('编号', idx, idxHint)), box), + inputRow('编号', idx, idxHint), + inputRow('Google Play 商店', h('span', { class: 'checkbox p-switch' }, store), + hint('默认不带。勾上后,在执行桥之后放入从 Google 官方 Google Play 版系统镜像取出的商店;第一次要先同意许可协议、下载约 1.5 GB。', 'c-store-h'))), box), actions: { ok }, - onSubmit() { + async onSubmit() { const f = ctl.form.elements; const n = Number(disk.value); if (!disk.value.trim() || !Number.isInteger(n) || n < 16 || n > 512) { @@ -117,20 +122,39 @@ export function openCreate() { disk.focus(); return; } - submit(ctl, ok, '正在新建…', box, async () => { - // a job: the setup boots the instance headless for a few minutes; its card shows the progress - const job = await api.post('/api/instances', { - title: title.value.trim() || undefined, memory_mb: Number(f.memory.value), cpu: Number(f.cpu.value), - fps: Number(f.fps.value), resolution: Number(f.resolution.value), data_partition_gb: n, index: idx.value === '' ? undefined : Number(idx.value), - }); + const body = { + title: title.value.trim() || undefined, memory_mb: Number(f.memory.value), cpu: Number(f.cpu.value), + fps: Number(f.fps.value), resolution: Number(f.resolution.value), data_partition_gb: n, index: idx.value === '' ? undefined : Number(idx.value), + }; + if (store.checked) body.play_store = true; + // a job: the setup boots the instance headless for a few minutes; its card shows the progress + const post = async () => { + const job = await api.post('/api/instances', body); A.onJob(job); S.watch.add(job.id); return { title: `正在新建实例 ${job.index}`, text: `后台无头开机做完初始化后关机,${CREATE_TIME};关掉窗口也会在后台做完。` }; - }); + }; + if (store.checked && !(await playStoreReady())) { + // the component first (license, source, download, as 补全组件); the create follows once that job is done + ctl.close(); + openComponentFetch({ playStore: true, then: () => post().then(toast, (e) => toast({ tone: 'err', title: '没有新建实例', text: e.message, advice: e.advice })) }); + return; + } + submit(ctl, ok, '正在新建…', box, post); }, }); } +// playStoreReady: the Google Play Store component is there (what 组件 lists; create checks its files' hashes itself). +async function playStoreReady() { + try { + const c = await api.get('/api/components'); + return (c.components || []).some((x) => x.id === 'play-store' && x.installed); + } catch { + return false; + } +} + // ---- settings // field: 'memory' focuses the memory choice (the card's 「调小内存」 link) export function openSettings(i, field) { From e180f91ea3fb145358273e8731fbc01cfa7f1d14 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 6 Nov 2026 13:26:26 +0100 Subject: [PATCH 3/9] =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=E6=97=B6=E8=A6=81=E8=A1=A5=E5=85=A8=E5=95=86=E5=BA=97?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E6=89=8D=E5=8A=A0=E8=BD=BD=20diagnostics.js?= =?UTF-8?q?=EF=BC=8Cforms.js=20=E4=B8=8D=E5=A4=9A=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs ModAVD/ModAVD#125 --- internal/ui/web/js/forms.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js index b4792cd..0722791 100644 --- a/internal/ui/web/js/forms.js +++ b/internal/ui/web/js/forms.js @@ -6,7 +6,6 @@ import { S, update, subscribe, instance, lastJob } from './store.js'; import { openDialog, toast } from './ui.js'; import { quoted, label, uptime, fmtBytes } from './format.js'; import * as A from './actions.js'; -import { openComponentFetch } from './diagnostics.js'; const MEM = [[3072, '3 GB'], [4096, '4 GB'], [5120, '5 GB'], [6144, '6 GB']]; const CPU = [[2, '2 核'], [4, '4 核'], [6, '6 核']]; @@ -137,6 +136,8 @@ export function openCreate() { if (store.checked && !(await playStoreReady())) { // the component first (license, source, download, as 补全组件); the create follows once that job is done ctl.close(); + // loaded only here: forms.js stays free of the diagnostics module's imports + const { openComponentFetch } = await import('./diagnostics.js'); openComponentFetch({ playStore: true, then: () => post().then(toast, (e) => toast({ tone: 'err', title: '没有新建实例', text: e.message, advice: e.advice })) }); return; } From 5ba51241054bf415344b4de9679bf72194170d35 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 6 Nov 2026 13:28:38 +0100 Subject: [PATCH 4/9] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9AGoogle=20Play=20?= =?UTF-8?q?=E5=95=86=E5=BA=97=E7=9A=84=E6=B5=8B=E8=AF=95=E6=8C=AA=E5=88=B0?= =?UTF-8?q?=E5=8D=95=E7=8B=AC=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=8C=E5=88=AB?= =?UTF-8?q?=E7=9A=84=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6=E5=8F=AA=E6=94=B9?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs ModAVD/ModAVD#125 --- internal/cli/components_test.go | 10 ---- internal/cli/playstore_test.go | 80 +++++++++++++++++++++++++++++ internal/cli/run_test.go | 17 +----- internal/guest/guest_test.go | 2 +- internal/guest/playstore_sh_test.go | 8 +++ internal/ui/diag_backend_test.go | 7 +-- internal/ui/diag_jobs_test.go | 27 +--------- internal/ui/http_test.go | 7 ++- internal/ui/playstore_test.go | 74 ++++++++++++++++++++++++++ 9 files changed, 170 insertions(+), 62 deletions(-) create mode 100644 internal/cli/playstore_test.go create mode 100644 internal/ui/playstore_test.go diff --git a/internal/cli/components_test.go b/internal/cli/components_test.go index f96bf35..050aa35 100644 --- a/internal/cli/components_test.go +++ b/internal/cli/components_test.go @@ -152,9 +152,6 @@ func TestComponentsFetchOptions(t *testing.T) { if res.code != 0 { t.Fatalf("exit %d: %s", res.code, res.stdout) } - if got.PlayStore { - t.Fatal("PlayStore without --play-store") - } if !got.AcceptLicense || !got.JSON || !got.KeepDownloads || got.Mirror != "TENCENT" || strings.Join(got.MirrorURLs, " ") != "http://a.invalid/ http://b.invalid/,http://c.invalid/" || got.ConnectTimeout != 7*time.Second || got.StallTimeout != 8*time.Second || got.ManifestTimeout != 9*time.Second || @@ -183,13 +180,6 @@ func TestComponentsFetchOptions(t *testing.T) { if notices != 1 || progress != 2 { t.Fatalf("%d notices, %d progress lines: %s", notices, progress, res.stderr) } - // --play-store asks for the store too; status does not take it - if res = h.run(t, nil, "--json", "components", "fetch", "--accept-license", "--play-store"); res.code != 0 || !got.PlayStore { - t.Fatalf("--play-store: exit %d, %+v", res.code, got) - } - if res = h.run(t, nil, "components", "status", "--play-store"); res.code != 2 { - t.Fatalf("status --play-store: exit %d", res.code) - } // --quiet hides progress, never the license. res = h.run(t, nil, "--quiet", "components", "fetch", "--accept-license") if res.code != 0 || !strings.Contains(res.stderr, "《许可协议》全文") || strings.Contains(res.stderr, "进度行") { diff --git a/internal/cli/playstore_test.go b/internal/cli/playstore_test.go new file mode 100644 index 0000000..270536c --- /dev/null +++ b/internal/cli/playstore_test.go @@ -0,0 +1,80 @@ +package cli + +import ( + "context" + "slices" + "testing" + + "modavd/internal/components" + "modavd/internal/emu" + "modavd/internal/root" +) + +func (f *fakeManager) PlayStore(ctx context.Context, i int, action string) (*emu.PlayStoreResult, error) { + f.record("playstore " + itoa(i) + " " + action) + 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 +} + +// TestCreatePlayStoreFlag: create asks for the store only with --play-store; --resume takes no --play-store. +func TestCreatePlayStoreFlag(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 || got[0].PlayStore { + t.Fatalf("without --play-store: exit %d, %+v", r.code, got) + } + if r := h.run(t, nil, "create", "--play-store", "--json"); r.code != 0 || len(got) != 2 || !got[1].PlayStore { + t.Fatalf("--play-store: exit %d, %+v", r.code, got) + } + if r := h.run(t, nil, "create", "--resume", "6", "--play-store"); r.code != 2 || len(got) != 2 { + t.Fatalf("--resume --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) + } +} + +// 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. +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") + } +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 158df88..4152b81 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -166,13 +166,6 @@ func (f *fakeManager) Bridge(ctx context.Context, i int, action string) (*emu.Br return &emu.BridgeResult{Index: i, Action: action, Installed: action == "install", Warnings: []string{}}, nil } -func (f *fakeManager) PlayStore(ctx context.Context, i int, action string) (*emu.PlayStoreResult, error) { - f.record("playstore " + itoa(i) + " " + action) - 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 -} - func (f *fakeManager) DiskTrim(ctx context.Context, i int) (*emu.DiskTrimResult, error) { f.record("disk-trim " + itoa(i)) return &emu.DiskTrimResult{Index: i, DiscardMaxBytes: 2147483648}, nil @@ -987,7 +980,7 @@ func TestCreateFlags(t *testing.T) { return &in, nil } r := h.run(t, nil, "create", "--index", "6", "--timeout", "600", "--no-bridge", "--resolution", "720") - if r.code != 0 || len(got) != 1 || *got[0].Index != 6 || got[0].Timeout != 600*time.Second || !got[0].NoBridge || got[0].PlayStore || + if r.code != 0 || len(got) != 1 || *got[0].Index != 6 || got[0].Timeout != 600*time.Second || !got[0].NoBridge || got[0].Resolution == nil || *got[0].Resolution != 720 || got[0].Resume || !strings.Contains(r.stderr, "新建中 2%:后台无头开机") || !strings.Contains(r.stderr, "新建中 35%:实例 6 宿主输入法") || strings.Contains(r.stderr, "新建中 100%") || !strings.Contains(r.stderr, "实例 6 新建完成") { @@ -997,14 +990,6 @@ func TestCreateFlags(t *testing.T) { if r.code != 0 || len(got) != 2 || !got[1].Resume || *got[1].Index != 6 { t.Fatalf("resume: exit %d, %+v", r.code, got) } - // --play-store asks for the store; --resume keeps what the create recorded and takes no --play-store - if r = h.run(t, nil, "create", "--play-store", "--json"); r.code != 0 || len(got) != 3 || !got[2].PlayStore { - t.Fatalf("--play-store: exit %d, %+v", r.code, got) - } - if r = h.run(t, nil, "create", "--resume", "6", "--play-store"); r.code != 2 || len(got) != 3 { - t.Fatalf("--resume --play-store: exit %d", r.code) - } - got = got[:2] for _, bad := range [][]string{{"create", "--resume", "6", "--memory", "4G"}, {"create", "--resume", "6", "--index", "6"}, {"create", "--resume", "6", "--resolution", "1080"}, {"create", "--timeout", "0"}, {"create", "--resume", "5"}} { if r := h.run(t, nil, bad...); r.code != 2 { diff --git a/internal/guest/guest_test.go b/internal/guest/guest_test.go index ea8439e..51fcf86 100644 --- a/internal/guest/guest_test.go +++ b/internal/guest/guest_test.go @@ -15,7 +15,7 @@ import ( ) func TestScriptsHaveNoCR(t *testing.T) { - for _, name := range []string{CPUInfoBind, IdlePoll, WifiFastpath, VulkanFix, PlayStore} { + for _, name := range []string{CPUInfoBind, IdlePoll, WifiFastpath, VulkanFix} { b, err := Script(name) if err != nil { t.Fatalf("%s: %v", name, err) diff --git a/internal/guest/playstore_sh_test.go b/internal/guest/playstore_sh_test.go index dc1de63..d6325e4 100644 --- a/internal/guest/playstore_sh_test.go +++ b/internal/guest/playstore_sh_test.go @@ -97,6 +97,14 @@ func (g *psGuest) files(tamper bool) { 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() diff --git a/internal/ui/diag_backend_test.go b/internal/ui/diag_backend_test.go index a9b5aba..7bf9d95 100644 --- a/internal/ui/diag_backend_test.go +++ b/internal/ui/diag_backend_test.go @@ -42,17 +42,14 @@ func TestRealDiagBackendPassesSourceAndLicenseHash(t *testing.T) { var lines []string log := logx.New(func(level logx.Level, msg string) { lines = append(lines, level.String()+" "+msg) }) b := newRealDiagBackend(&root.Root{Dir: `C:\root`}, log) - for k, s := range []components.SourceSettings{ + for _, s := range []components.SourceSettings{ {Mirror: "google", Set: true}, {MirrorURLs: []string{"https://a.example/x/", "https://b.example/y/"}, Set: true}, } { - if _, err := b.Fetch(context.Background(), true, k == 1, s, fakeHash, nil); err != nil { + if _, err := b.Fetch(context.Background(), true, false, s, fakeHash, nil); err != nil { t.Fatal(err) } } - if len(got) != 2 || got[0].PlayStore || !got[1].PlayStore { - t.Fatalf("PlayStore not passed: %+v", got) - } if len(got) != 2 || got[0].Mirror != "google" || len(got[0].MirrorURLs) != 0 || got[1].Mirror != "" || strings.Join(got[1].MirrorURLs, ",") != "https://a.example/x/,https://b.example/y/" { t.Fatalf("sources: %+v", got) diff --git a/internal/ui/diag_jobs_test.go b/internal/ui/diag_jobs_test.go index 06462e0..2a6ee0f 100644 --- a/internal/ui/diag_jobs_test.go +++ b/internal/ui/diag_jobs_test.go @@ -21,7 +21,6 @@ type fakeDiag struct { fetchReturn chan struct{} canceled chan struct{} zipPath string // what Collect returns (C:\tmp\diag.zip when empty) - playStore bool // the last Fetch asked for the Google Play Store too } // the license fakeDiag shows, and the license_sha256 a fetch after reading it carries @@ -40,10 +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, playStore bool, _ components.SourceSettings, _ string, progress func(DiagProgress)) (*DiagFetchResult, error) { - f.mu.Lock() - f.playStore = playStore - f.mu.Unlock() +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, "必须同意") } @@ -118,27 +114,6 @@ func TestDiagnosticsEndpointsAndGuards(t *testing.T) { h.waitJob(j.ID, stateIs(stateCanceled)) } -// TestComponentsFetchPlayStore: play_store in the body asks the backend for the Google Play Store too; without it, not. -func TestComponentsFetchPlayStore(t *testing.T) { - done := make(chan struct{}) - close(done) - d := &fakeDiag{fetchReturn: done} - h := diagHarness(t, d, nil) - for _, c := range []struct { - extra string - want bool - }{{"", false}, {`,"play_store":true`, true}} { - var j jobView - expect(t, h.do("POST", "/api/components/fetch", fetchBody(c.extra)), http.StatusAccepted, &j) - h.waitJob(j.ID, stateIs(stateDone)) - d.mu.Lock() - got := d.playStore - d.mu.Unlock() - if got != c.want { - t.Errorf("%q: playStore %v", c.extra, got) - } - } -} func TestDiagnosticsCollectSectionsAndPlaceholder(t *testing.T) { d := &fakeDiag{} h := diagHarness(t, d, nil) diff --git a/internal/ui/http_test.go b/internal/ui/http_test.go index 6119f5a..eb28656 100644 --- a/internal/ui/http_test.go +++ b/internal/ui/http_test.go @@ -134,8 +134,7 @@ func TestCreateUpdateDelete(t *testing.T) { } return h.waitJob(v.ID, stateIs(stateDone)) } - j := create(map[string]any{"title": "测试机", "index": 2, "memory_mb": 4096, "cpu": 4, "data_partition_gb": 32, "fps": 30, "resolution": 720, - "play_store": true}) + j := create(map[string]any{"title": "测试机", "index": 2, "memory_mb": 4096, "cpu": 4, "data_partition_gb": 32, "fps": 30, "resolution": 720}) if res, _ := j.Result.(map[string]any); j.Index != 2 || res["title"] != "测试机" { t.Fatalf("created %+v", j) } @@ -154,10 +153,10 @@ func TestCreateUpdateDelete(t *testing.T) { } r := c[0] if r.Title != "测试机" || *r.Index != 2 || *r.MemoryMB != 4096 || *r.CPU != 4 || *r.DataPartitionGB != 32 || *r.FPS != 30 || r.Resolution == nil || *r.Resolution != 720 || - r.Progress == nil || r.Resume || !r.PlayStore { + r.Progress == nil || r.Resume { t.Fatalf("create request %+v", r) } - if r := c[1]; r.Title != "" || r.Index == nil || *r.Index != auto1.Index || r.MemoryMB != nil || r.CPU != nil || r.DataPartitionGB != nil || r.FPS != nil || r.Resolution != nil || r.PlayStore { + if r := c[1]; r.Title != "" || r.Index == nil || *r.Index != auto1.Index || r.MemoryMB != nil || r.CPU != nil || r.DataPartitionGB != nil || r.FPS != nil || r.Resolution != nil { t.Fatalf("empty create request %+v", r) } if r := c[3]; !r.Resume || r.Index == nil || *r.Index != 3 { diff --git a/internal/ui/playstore_test.go b/internal/ui/playstore_test.go new file mode 100644 index 0000000..7525818 --- /dev/null +++ b/internal/ui/playstore_test.go @@ -0,0 +1,74 @@ +package ui + +import ( + "context" + "net/http" + "sync" + "testing" + + "modavd/internal/components" + "modavd/internal/logx" + "modavd/internal/root" +) + +// 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 +} + +// TestPlayStoreRequests: play_store in a create body reaches the create request, and in a fetch body the backend; +// without it, neither asks for the store. +func TestPlayStoreRequests(t *testing.T) { + d := &storeDiag{} + h := newHarness(t, func(o *Options) { o.newDiagBackend = func(*logx.Logger) diagBackend { return d } }) + for _, body := range []any{map[string]any{"index": 2}, map[string]any{"index": 3, "play_store": true}} { + var v jobView + expect(t, h.do("POST", "/api/instances", body), http.StatusAccepted, &v) + h.waitJob(v.ID, stateIs(stateDone)) + } + 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)) + } + h.f.mu.Lock() + c := h.f.created + h.f.mu.Unlock() + if len(c) != 2 || c[0].PlayStore || !c[1].PlayStore { + t.Fatalf("create requests %+v", c) + } + d.mu.Lock() + defer d.mu.Unlock() + if len(d.asked) != 2 || d.asked[0] || !d.asked[1] { + t.Fatalf("fetches asked %v", d.asked) + } +} + +// TestRealDiagBackendPassesPlayStore: the real adapter hands components.Fetch the play_store choice. +func TestRealDiagBackendPassesPlayStore(t *testing.T) { + old := diagFetch + t.Cleanup(func() { diagFetch = old }) + 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 + } + b := newRealDiagBackend(&root.Root{Dir: `C:\root`}, 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) + } +} From 5daaa2597498bf51e505144e6afec155248d7875 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 6 Nov 2026 14:03:27 +0100 Subject: [PATCH 5/9] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E4=BF=AE=20#89?= =?UTF-8?q?=20=E7=9A=84=20CI=E2=80=94=E2=80=94=E9=9D=9E=20Windows=20?= =?UTF-8?q?=E7=9A=84=20go=20vet=20=E5=92=8C=20Windows=20=E4=B8=8A=E7=9A=84?= =?UTF-8?q?=E5=AE=A2=E4=BD=93=E8=84=9A=E6=9C=AC=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli、ui 的 Google Play 商店测试用到的 fakeManager、fakeDiag 只在 Windows 的测试文件里,这两个文件加上 windows 构建标签,macOS、Ubuntu 的 go vet 不再报 undefined。 - TestPlayStoreScript 在 Windows 上挂,是因为 Git 的 bin\sh.exe 会把自己的 usr\bin 放到 PATH 最前面,真的 chcon、chown 盖过了测试的桩。现在由 sh 自己把桩目录(用它自己的路径写法)放到 PATH 最前面再跑脚本,两种 sh 都过。 - emu 的商店测试改用 internal/host,不直接用 internal/win。 --- internal/cli/playstore_test.go | 2 ++ internal/emu/playstore_test.go | 10 +++++----- internal/guest/playstore_sh_test.go | 11 ++++++++--- internal/ui/playstore_test.go | 2 ++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/cli/playstore_test.go b/internal/cli/playstore_test.go index 270536c..408812b 100644 --- a/internal/cli/playstore_test.go +++ b/internal/cli/playstore_test.go @@ -1,3 +1,5 @@ +//go:build windows + package cli import ( diff --git a/internal/emu/playstore_test.go b/internal/emu/playstore_test.go index 05990ec..5947b5c 100644 --- a/internal/emu/playstore_test.go +++ b/internal/emu/playstore_test.go @@ -10,7 +10,7 @@ import ( "testing" "modavd/internal/errs" - "modavd/internal/win" + "modavd/internal/host" ) // playStoreHooks replaces what the store's code does to a guest for one test: the component check, the guest script @@ -41,11 +41,11 @@ func (h *playStoreHooks) install(t *testing.T) { used[action]++ return playStoreRun{Result: rs[k], Lines: []string{"play /product/priv-app/Phonesky/Phonesky.apk", "stub hidden", "other"}}, nil } - playStoreFindQemu = func(*Manager, int) (win.Process, bool, error) { + playStoreFindQemu = func(*Manager, int) (host.Process, bool, error) { if h.running { - return win.Process{PID: 4242, Name: "qemu-system-x86_64-headless.exe"}, true, nil + return host.Process{PID: 4242, Name: "qemu-system-x86_64-headless.exe"}, true, nil } - return win.Process{}, false, nil + return host.Process{}, false, nil } playStoreStart = func(_ *Manager, _ context.Context, _ int, opt StartOptions) (*StartResult, error) { h.calls = append(h.calls, "start") @@ -213,7 +213,7 @@ func TestPlayStoreTakesBridgeLock(t *testing.T) { var ps playStoreHooks ps.ready = true ps.install(t) - l, ok, err := win.TryLock(m.lockPath(i, "bridge")) + l, ok, err := host.TryLock(m.lockPath(i, "bridge")) if err != nil || !ok { t.Fatalf("TryLock: %v %v", ok, err) } diff --git a/internal/guest/playstore_sh_test.go b/internal/guest/playstore_sh_test.go index d6325e4..4da3025 100644 --- a/internal/guest/playstore_sh_test.go +++ b/internal/guest/playstore_sh_test.go @@ -61,11 +61,16 @@ esac`) for _, name := range []string{"am", "remount", "chcon", "chown"} { stub(name, "exit 0") } - sep := string(os.PathListSeparator) - g.env = append(os.Environ(), "MODAVD_PS_ROOT="+g.root, "PATH="+bin+sep+os.Getenv("PATH")) + // 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 { @@ -116,7 +121,7 @@ func (g *psGuest) run(action string) (string, string) { if err := os.WriteFile(script, b, 0o644); err != nil { g.t.Fatal(err) } - cmd := exec.Command(g.sh, filepath.ToSlash(script), action, g.dir) + cmd := exec.Command(g.sh, "-c", stubbedSh, "sh", filepath.ToSlash(script), action, g.dir) cmd.Env = g.env out, _ := cmd.CombinedOutput() result := "" diff --git a/internal/ui/playstore_test.go b/internal/ui/playstore_test.go index 7525818..aa496da 100644 --- a/internal/ui/playstore_test.go +++ b/internal/ui/playstore_test.go @@ -1,3 +1,5 @@ +//go:build windows + package ui import ( From 28a84468e68b2fd8d71abf991b373e07d9b18cd1 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 6 Nov 2026 14:24:13 +0100 Subject: [PATCH 6/9] =?UTF-8?q?Google=20Play=20=E5=95=86=E5=BA=97=EF=BC=9A?= =?UTF-8?q?=E6=A0=B9=E7=9B=AE=E5=BD=95=E5=8F=AA=E7=95=99=E4=BA=94=E4=B8=AA?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E4=B9=9F=E5=8F=AF=E4=BB=A5=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=87=AA=E5=B7=B1=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components fetch --play-store 下完只留五个文件:从 system.img 取出后,解出的镜像不管成没成都删掉,压缩包照旧装好即删;source.properties 记下来源 ModAVD.Source=download。已经有了(下载的或导入的)就跳过。 - 新增 components.ImportPlayStore:用户给 Google 官方的 x86_64-34_r14.zip(名字不限,按仓库清单核对大小和 SHA-1,再像下载的一样取文件),或者五个文件本身(名字不限,复制时算 SHA-256,核对的是复制出来的那份)。先按大小认文件,认不出的在读任何内容之前就拒收;哈希对不上、缺文件也拒收,什么都不改。errs.Reason 是 unknown_file / mismatch / incomplete。没给的文件从原有的 sdk\playstore 里取(核对过才用),原目录已经齐全时只核对给的文件、不改动。和 fetch 共用锁、日志和 sdk\.staging,只读用户的文件。 - PlayStoreImportFiles:命令行给文件夹时取里面同名的五个文件。 - PlayStoreState:五个文件在不在(按大小)、来源、版本,给 components status 和管理器用。 - 进度多一个 verifying 阶段(核对用户的文件)。 - 测试:下载后只剩五个文件和 source.properties;下载的压缩包 SHA-1 不对时不留 zip 和 .part、不解压;导入压缩包、导入文件、拒收的各种情况(含已齐全时给伪造文件)、保留原有文件、锁和非根目录。变异检查:去掉文件的 SHA-256 核对、去掉压缩包的 SHA-1 核对、把认不出的文件跳过而不是拒收、下载校验失败时不删 .part、下载不核对 SHA-1,测试都会挂。 --- internal/components/install.go | 66 ++- internal/components/playstore.go | 449 ++++++++++++++++++ internal/components/playstore_windows_test.go | 332 ++++++++++++- internal/components/progress.go | 14 +- 4 files changed, 812 insertions(+), 49 deletions(-) create mode 100644 internal/components/playstore.go diff --git a/internal/components/install.go b/internal/components/install.go index 6c8246c..9630ffc 100644 --- a/internal/components/install.go +++ b/internal/components/install.go @@ -77,36 +77,62 @@ func (f *fetcher) install(ctx context.Context, c root.SDKComponent, zipPath stri return f.place(c, filepath.Join(staging, filepath.FromSlash(c.ArchiveRoot))) } -// installPlayStore is install for the Google Play Store (root.PlayStoreComponent): only the system image is unpacked -// from the Play image's zip, into sdk\.staging\; the store's files are read out of it (playstore.Extract, each -// checked against its pinned size and SHA-256), the 3 GB image is deleted, and the files go into place like a -// component's directory. +// 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 := filepath.Join(f.stagingRoot(), c.ID) - f.where = "从 " + filepath.Base(zipPath) + " 解出 " + playstore.ImageMember - f.removeStaging(staging) - out := filepath.Join(staging, filepath.FromSlash(c.ArchiveRoot)) - if err := os.MkdirAll(out, 0o755); err != nil { - return "", errs.Wrap(errs.Internal, err, "建不了 "+out+":"+err.Error()) + staging, out, err := f.playStoreStaging(c) + if err != nil { + return "", err } defer f.removeStaging(staging) - img := filepath.Join(staging, "system.img") - if err := f.extractMember(ctx, c, zipPath, playstore.ImageMember, img); err != nil { + if err := f.playStoreFromZip(ctx, c, zipPath, staging, out); err != nil { return "", err } - f.where = "从 system.img 取出 Google Play 商店的文件" - f.say(" 从系统镜像里取出商店、Play 服务、GSF、合作伙伴设置…") - if err := playStoreExtract(img, playstore.Files, out); err != nil { - return "", rejected("从 Google Play 版系统镜像里取商店的文件失败:%v", 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()) } - if err := os.Remove(img); err != nil { - return "", errs.Wrap(errs.Internal, err, "删不掉临时解出的 "+img+":"+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()) } - if err := os.WriteFile(filepath.Join(out, "source.properties"), []byte(c.Properties), 0o644); err != nil { + 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 "", rejected("%s 的文件校验失败", c.FetchName) } return f.place(c, out) } 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-