From fadf647e0586376fb0adb975f0c9a89ecb1457d0 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Sat, 7 Nov 2026 19:23:06 +0100 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8C=89=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E9=AA=8C=E5=BC=80=E5=85=B3=E3=80=8C=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=20GLES=203.2=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 有的游戏在清单里要求 GLES 3.2,Play 商店按 ro.opengles.version 把只报 3.1 的实例判成不兼容。 新增 modavd gles32 on|off|status 和管理器里「更多操作」的开关:在执行桥带来的可写系统覆盖层里, 给 /vendor/build.prop 加(或去掉)一行 ro.opengles.version=196610,冷启动一次后核对实例报的版本。 只改报的版本,实际渲染仍是 3.1;默认关,没有执行桥的实例不能开,开着时不能卸执行桥。 - 客体脚本 gles32.sh,改文件时保住 inode、权限和 SELinux 标签 - emu.GLES32:检查、锁、开机、冷启动、核对、关机照 PlayStore 的写法,记在 instances..gles32 - CLI gles32 命令、info 里的一行;界面的 gles32-on / gles32-off 任务、菜单项和确认框 - 单测:sh 脚本、假客体的编排、CLI、界面接口和页面模块 Refs ModAVD/ModAVD#131 --- README.en.md | 9 + README.md | 9 + internal/cli/cli.go | 1 + internal/cli/commands.go | 47 +++++ internal/cli/format.go | 17 ++ internal/cli/gles32_test.go | 104 ++++++++++ internal/emu/bridge.go | 5 + internal/emu/gles32.go | 334 +++++++++++++++++++++++++++++++ internal/emu/gles32_test.go | 282 ++++++++++++++++++++++++++ internal/emu/playstore.go | 2 +- internal/emu/status.go | 1 + internal/emu/types.go | 3 + internal/guest/gles32_sh_test.go | 288 ++++++++++++++++++++++++++ internal/guest/guest.go | 2 + internal/guest/scripts/gles32.sh | 80 ++++++++ internal/ui/API.md | 13 +- internal/ui/backend.go | 3 + internal/ui/gles32.go | 43 ++++ internal/ui/gles32_test.go | 102 ++++++++++ internal/ui/http.go | 2 + internal/ui/jobs.go | 10 +- internal/ui/web/dev/mock.js | 37 +++- internal/ui/web/js/actions.js | 11 +- internal/ui/web/js/cards.js | 18 +- internal/ui/web/js/format.js | 15 +- internal/ui/web/js/forms.js | 1 + internal/ui/web/js/gles32.js | 45 +++++ internal/ui/web/js/panels.js | 6 +- scripts/test_create_cancel.mjs | 4 +- scripts/test_gles32_ui.mjs | 280 ++++++++++++++++++++++++++ scripts/test_playstore_ui.mjs | 3 +- 31 files changed, 1762 insertions(+), 15 deletions(-) create mode 100644 internal/cli/gles32_test.go create mode 100644 internal/emu/gles32.go create mode 100644 internal/emu/gles32_test.go create mode 100644 internal/guest/gles32_sh_test.go create mode 100644 internal/guest/scripts/gles32.sh create mode 100644 internal/ui/gles32.go create mode 100644 internal/ui/gles32_test.go create mode 100644 internal/ui/web/js/gles32.js create mode 100644 scripts/test_gles32_ui.mjs diff --git a/README.en.md b/README.en.md index c9e19b5..a893553 100644 --- a/README.en.md +++ b/README.en.md @@ -70,6 +70,7 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args] | `stop [--force] [--cancel-create]`, `stop --all [--force]` | Shut down: an app in the foreground is first sent home and given 2 s to save; then sync and ask Android for a clean shutdown (the foreground app is paused and stopped first), and if that fails, sync + kill; `--force` is sync + force-kill. Creating instances are refused unless `--cancel-create` is given. `--all` also stops creating instances and marks their results with `interrupted_create: true` | | `bridge install\|uninstall\|verify` | Install, uninstall or check the arm64 execution bridge; it is installed automatically at start, so you normally do not run it by hand (see below) | | `playstore install\|uninstall\|status` | Add the Google Play Store to an instance, remove it, or check it (see below) | +| `gles32 on\|off\|status` | Experimental: make an instance report GLES 3.2 (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) | @@ -110,6 +111,14 @@ On Apple Silicon macOS, downloads and imports use Android 15 Google Play `arm64- - The device is not Play Protect certified. If signing in says so, get the instance's GSF ID with `playstore gsfid` (in the manager: 「复制 GSF ID」 in the instance's 「更多操作」 menu), given in decimal and hex (`decimal`, `hex` with `--json`), and register it at . The instance must be running; without an ID yet it fails with `not_ready` (`details.reason` `no_gsf_id`): wait a minute or two with the network on and try again. Games that check Play Integrity will most likely fail (the image is a userdebug build with test keys). - An instance that never had the store is not touched, and `mas-avd.json` gets no extra key. Once it is installed, `instances..playStore` is `true`, `false` after uninstall, and `"partial"` after an install that could not take back all it put in; `play_store` (installed) and `play_store_partial` (partly installed) in `info` / `list` follow it. +**Reporting GLES 3.2** (`gles32`, experimental, ModAVD#131): + +- Some games list GLES 3.2 in their manifest, and the Play Store hides them from a device whose `ro.opengles.version` says less (ModAVD reports 3.1). `gles32 on` makes the instance report 3.2 (196610) so the store lets them in. **It only changes the reported version; rendering stays 3.1**: games that use Vulkan work, an app that really renders with GLES 3.2 installs but does not start. Off by default, per instance. +- It needs the execution bridge: it edits `/vendor/build.prop` in the writable system overlay the bridge brings, and without the bridge it fails with `not_ready` (`details.reason` `gles32_no_bridge`). An instance with the switch on refuses `bridge uninstall` until `gles32 off`. +- `on` / `off` boot cold once and then check the version the instance reports; a mismatch is an error. An instance that was off is shut down again, a running one comes back the way it ran. `status` is read-only and needs the instance running: `on`, `off`, or `pending-on` / `pending-off` when the cold boot is still to come. +- With the Google Play Store on the instance, the store keeps what it knew of the device: in the instance, Settings → Apps → Google Play Store → Storage, clear data (the account stays signed in), and it judges compatibility again against the new version. +- Recorded as `instances..gles32`; `gles32` in `info` / `list` follows it. In the manager: 「开启 / 关闭报告 GLES 3.2(实验)」 in the instance's 「更多操作」 menu. + **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 ff7be03..61c2016 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数] | `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 商店(见下) | +| `gles32 on\|off\|status` | 实验功能:让实例报告 GLES 3.2(见下) | | `disk trim` | 磁盘回收:在开着的实例里对数据盘触发一次 FITRIM;成功只表示触发请求已返回,不等待或确认客体回收完成;模拟器太旧、不支持回收时返回不支持(退出码 4) | | `disk info` | 看数据盘的大小:逻辑大小、覆盖层在宿主上实际占的空间;关机且取得启动锁后才运行只读的 qcow2 元数据检查,给出已分配和估计可回收大小。整理中或锁不可用时,仅返回可读取的文件头、宿主占用和原因;不可读的数值省略,不修复镜像 | | `disk compact` | 整理已关机实例的数据盘;预计收益不足时跳过重写;转换后先校验再原子替换,原盘在替换成功前不动;报告前后占用和增减;大盘预算可达数十小时,正常整理基本不会超时,停止请取消;仅支持 NTFS/ReFS,拒绝快照或重写空间不足,详见[预算与结果契约](internal/emu/COMPACT.md) | @@ -116,6 +117,14 @@ Apple Silicon macOS 的下载与导入使用 Android 15 Google Play `arm64-v8a` - 设备没有通过 Play 保护机制认证。登录时提示未认证的,用 `playstore gsfid`(管理器里实例「更多操作」的「复制 GSF ID」)取这台实例的 GSF ID,十进制和十六进制都给(`--json` 是 `decimal`、`hex`),到 登记。实例要开着;还没有 ID 时报 `not_ready`(`details.reason` 为 `no_gsf_id`),开着网等一两分钟再试。查 Play Integrity 的游戏多半过不了(镜像是 userdebug、测试密钥)。 - 没装过商店的实例什么都不动,`mas-avd.json` 里也不多键。装上后记 `instances..playStore: true`,卸掉后是 `false`,装到一半撤不干净时是 `"partial"`;`info` / `list` 的 `play_store`(装上了)和 `play_store_partial`(只装了一部分)按这一项。 +**报告 GLES 3.2**(`gles32`,实验,ModAVD#131): + +- 有的游戏在清单里要求 GLES 3.2,Play 商店按 `ro.opengles.version` 把 ModAVD(报 3.1)判成不兼容。`gles32 on` 把实例报的版本改成 3.2(196610),让商店放行。**只改报的版本,实际渲染仍是 3.1**:走 Vulkan 的游戏能用,真要 GLES 3.2 渲染的应用会装得上、打不开。默认关,按实例开。 +- 要执行桥:改的是它带来的可写系统覆盖层里的 `/vendor/build.prop`,没有执行桥的报 `not_ready`(`details.reason` 为 `gles32_no_bridge`)。开着的实例不能 `bridge uninstall`,要先 `gles32 off`。 +- `on` / `off` 会冷启动一次,再核对实例报的版本,对不上就报错;原来关着的做完再关,开着的照原来的方式开回来。`status` 只读,实例要开着,给 `on`、`off`,或还没冷启动的 `pending-on` / `pending-off`。 +- 实例装着 Google Play 商店时,商店会记着旧的设备信息:到实例里 设置 → 应用 → Google Play 商店 → 存储,清除数据(账号不会退出),它才会按新版本重新判断兼容。 +- 记在 `instances..gles32`;`info` / `list` 的 `gles32` 按这一项。管理器里是实例「更多操作」的「开启 / 关闭报告 GLES 3.2(实验)」。 + **按应用走代理**(`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 70607b7..e775ae8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -123,6 +123,7 @@ type manager interface { Bridge(ctx context.Context, i int, action string) (*emu.BridgeResult, error) PlayStore(ctx context.Context, i int, action string) (*emu.PlayStoreResult, error) PlayStoreGSFID(ctx context.Context, i int) (*emu.PlayStoreGSFIDResult, error) + GLES32(ctx context.Context, i int, action string) (*emu.GLES32Result, 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 f5c6b55..ce68f15 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -284,6 +284,21 @@ func init() { final: true, run: runPlayStore, human: humanPlayStore, exit: exitPlayStore, }, + { + name: "gles32", args: " on|off|status", summary: "实验功能:让实例报告 GLES 3.2(只改报的版本)", + details: "实验功能。有的游戏在清单里要求 GLES 3.2,Play 商店按实例报的版本把 ModAVD(报 3.1)判成不兼容。" + + "on 把 ro.opengles.version 报成 196610(3.2),让商店放行;实际渲染仍是 3.1,走 Vulkan 的游戏能用," + + "真要 GLES 3.2 渲染的应用会装得上、打不开。默认关,按实例开。" + + "做法是在执行桥带来的可写系统镜像覆盖层里改 /vendor/build.prop,所以实例要有执行桥;" + + "没有的报 not_ready(details.reason 为 gles32_no_bridge)。改完会冷启动一次才生效,再核对实例报的版本,对不上就报错。" + + "实例原来关着的,做完再关掉(没做成也关掉);开着的,冷启动后照原来的方式开回来(带窗口或无头)。" + + "status:只读,实例要开着,on、off、pending-on、pending-off 四种(后两种是还没冷启动)。" + + "开着的实例不能卸执行桥,要先 off。装了 Google Play 商店的实例,商店会记着旧的设备信息:" + + "到 设置 → 应用 → Google Play 商店 → 存储,清除数据(账号不会退出)。list / info 里的 gles32 是开关的记录。", + posNames: []string{"实例编号", "on、off 或 status"}, minPos: 2, maxPos: 2, + final: true, + run: runGLES32, human: humanOf(formatGLES32), exit: exitGLES32, + }, { name: "disk", args: " trim|info|compact", summary: "磁盘回收:触发回收或整理把客体已删文件占的空间还给宿主;看数据盘大小", details: "trim:在客体的数据盘(/data)上触发一次回收(FITRIM,经 vold 空闲维护)," + @@ -1214,6 +1229,38 @@ func runPlayStore(c *cmdCtx) (any, error) { return m.PlayStore(c.ctx, i, action) } +func runGLES32(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 "on", "off", "status": + default: + return nil, errs.Newf(errs.Usage, "第二个参数只能是 on、off 或 status,收到「%s」", c.inv.pos[1]). + WithAdvice(findCommand("gles32").usageAdvice()) + } + m, err := c.mgr() + if err != nil { + return nil, err + } + return m.GLES32(c.ctx, i, action) +} + +// exitGLES32: 1 when the switch did not end up as asked (on after on, off after off). status and --json: 0, the +// result says it. +func exitGLES32(data any, json bool) int { + r, ok := data.(*emu.GLES32Result) + if json || !ok { + return 0 + } + if r.Action == "on" && r.State != "on" || r.Action == "off" && r.State != "off" { + return 1 + } + return 0 +} + func humanPlayStore(w io.Writer, data any) { switch r := data.(type) { case *emu.PlayStoreResult: diff --git a/internal/cli/format.go b/internal/cli/format.go index c274689..2d2510c 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -287,6 +287,9 @@ func formatInfo(w io.Writer, in *emu.InstanceInfo) { } kv("渲染器", r) } + if in.GLES32 { + kv("报告 GLES 3.2", "开(实验;实际渲染仍是 3.1)") + } if p := in.AppProxy; p != nil { state := "关" if p.Enabled { @@ -674,6 +677,20 @@ func formatPlayStoreGSFID(w io.Writer, r *emu.PlayStoreGSFIDResult) { "等一会儿让登记生效,把实例关机再开机,再去登录\n", emu.PlayStoreUncertifiedURL) } +func formatGLES32(w io.Writer, r *emu.GLES32Result) { + state := map[string]string{"on": "开", "off": "关", "pending-on": "已写入,要关机再开机才生效", "pending-off": "已去掉,要关机再开机才生效"}[r.State] + fmt.Fprintf(w, "实例 %d 报告 GLES 3.2 %s:%s(ro.opengles.version=%s)\n", r.Index, r.Action, state, r.Prop) + if r.Restarted { + fmt.Fprintln(w, "为了让改动生效,关机后又开了一次") + } + if r.Stopped { + fmt.Fprintln(w, "实例原来是关着的,做完已经关掉") + } + for _, n := range r.Notes { + fmt.Fprintln(w, n) + } +} + func formatDisk(w io.Writer, r *emu.DiskTrimResult) { fmt.Fprintf(w, "实例 %d 已触发磁盘回收(trim)\n", r.Index) } diff --git a/internal/cli/gles32_test.go b/internal/cli/gles32_test.go new file mode 100644 index 0000000..7fff5de --- /dev/null +++ b/internal/cli/gles32_test.go @@ -0,0 +1,104 @@ +//go:build windows + +package cli + +import ( + "context" + "slices" + "strings" + "testing" + + "modavd/internal/emu" + "modavd/internal/errs" +) + +// gles32Err, when set, is what the fake manager's GLES32 returns. +var gles32Err error + +func (f *fakeManager) GLES32(ctx context.Context, i int, action string) (*emu.GLES32Result, error) { + f.record("gles32 " + itoa(i) + " " + action) + if gles32Err != nil { + return nil, gles32Err + } + state := map[string]string{"on": "on", "off": "off", "status": "pending-on"}[action] + prop := map[string]string{"on": "196610", "off": "196609", "status": "196609"}[action] + return &emu.GLES32Result{Index: i, Action: action, State: state, Enabled: state == "on", Prop: prop, + Notes: []string{}, Warnings: []string{}}, nil +} + +// TestGLES32Command: gles32 on|off|status reaches the manager (any case); another action is a usage error; without +// --json the exit code says whether the switch ended up as asked; a refusal keeps its reason. +func TestGLES32Command(t *testing.T) { + h := newHarness() + for _, a := range []string{"on", "OFF", "status"} { + if r := h.run(t, nil, "gles32", "3", a); r.code != 0 { + t.Errorf("%s: exit %d %s", a, r.code, r.stderr) + } + } + if r := h.run(t, nil, "gles32", "3", "toggle"); r.code != 2 { + t.Errorf("toggle: exit %d", r.code) + } + if r := h.run(t, nil, "gles32", "3"); r.code != 2 { + t.Errorf("no action: exit %d", r.code) + } + if want := []string{"gles32 3 on", "gles32 3 off", "gles32 3 status"}; !slices.Equal(h.fake.calls, want) { + t.Errorf("calls %v", h.fake.calls) + } + if exitGLES32(&emu.GLES32Result{Action: "on", State: "pending-on"}, false) != 1 || + exitGLES32(&emu.GLES32Result{Action: "off", State: "on"}, false) != 1 || + exitGLES32(&emu.GLES32Result{Action: "status", State: "pending-on"}, false) != 0 || + exitGLES32(&emu.GLES32Result{Action: "on", State: "pending-on"}, true) != 0 { + t.Error("exit codes") + } + + r := h.run(t, nil, "gles32", "3", "on") + for _, want := range []string{"实例 3 报告 GLES 3.2 on:开", "ro.opengles.version=196610"} { + if !strings.Contains(r.stdout, want) { + t.Errorf("human: want %q in\n%s", want, r.stdout) + } + } + r = h.run(t, nil, "--json", "gles32", "3", "on") + data, _ := envelope(t, r.stdout)["data"].(map[string]any) + if r.code != 0 || data["state"] != "on" || data["enabled"] != true || data["prop"] != "196610" { + t.Errorf("json: exit %d, %s", r.code, r.stdout) + } + + gles32Err = errs.WithDetail(errs.New(errs.NotReady, "实例 3 没有执行桥"), errs.Reason, emu.GLES32ReasonNoBridge) + t.Cleanup(func() { gles32Err = nil }) + r = h.run(t, nil, "--json", "gles32", "3", "on") + e := envelope(t, r.stdout)["error"].(map[string]any) + if r.code != 4 || e["code"] != "not_ready" || e["details"].(map[string]any)["reason"] != "gles32_no_bridge" { + t.Errorf("refused: exit %d, %v", r.code, e) + } +} + +// TestGLES32Help: the help says what the switch does and does not. +func TestGLES32Help(t *testing.T) { + h := newHarness() + r := h.run(t, nil, "help", "gles32") + for _, want := range []string{"实验", "实际渲染仍是 3.1", "执行桥", "冷启动"} { + if !strings.Contains(r.stdout+r.stderr, want) { + t.Errorf("help lacks %q:\n%s%s", want, r.stdout, r.stderr) + } + } +} + +// TestInfoShowsGLES32: info has a line for the switch when it is on, and none when it is off. +func TestInfoShowsGLES32(t *testing.T) { + h := newHarness() + h.fake.info = func(i int) (*emu.InstanceInfo, error) { + in := sampleInstance(i) + in.GLES32 = i == 4 + return &in, nil + } + if r := h.run(t, nil, "info", "4"); r.code != 0 || !strings.Contains(r.stdout, "报告 GLES 3.2:开") { + t.Errorf("on: exit %d\n%s", r.code, r.stdout) + } + if r := h.run(t, nil, "info", "3"); r.code != 0 || strings.Contains(r.stdout, "GLES") { + t.Errorf("off: exit %d\n%s", r.code, r.stdout) + } + r := h.run(t, nil, "--json", "info", "4") + if data, _ := envelope(t, r.stdout)["data"].(map[string]any); data["gles32"] != true { + t.Errorf("json: %s", r.stdout) + } +} diff --git a/internal/emu/bridge.go b/internal/emu/bridge.go index a471c7f..0adc33a 100644 --- a/internal/emu/bridge.go +++ b/internal/emu/bridge.go @@ -682,6 +682,11 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu return nil, errs.Newf(errs.NotReady, "实例 %d 装着(或者装了一部分)Google Play 商店,它离不开执行桥带来的可写系统镜像", i). WithAdvice(fmt.Sprintf("要卸执行桥,先运行 modavd playstore %d uninstall", i)) } + if m.gles32Wanted(i) { + // the line in /vendor/build.prop lives in the overlay the bridge's marker makes the emulator boot with (gles32.go) + return nil, errs.Newf(errs.NotReady, "实例 %d 开着「报告 GLES 3.2」,它离不开执行桥带来的可写系统镜像", i). + WithAdvice(fmt.Sprintf("要卸执行桥,先运行 modavd gles32 %d off", 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/gles32.go b/internal/emu/gles32.go new file mode 100644 index 0000000..9bc6ac7 --- /dev/null +++ b/internal/emu/gles32.go @@ -0,0 +1,334 @@ +package emu + +// The reported OpenGL ES version (ModAVD/ModAVD#131, experimental). Some apps list GLES 3.2 in their manifest and the +// Play Store hides them from a device whose ro.opengles.version says less (ModAVD reports 3.1, 196609). The switch +// `modavd gles32 on` makes the guest report 3.2 (196610); the emulator still renders 3.1, so an app that really +// needs 3.2 installs and then does not run. Off unless asked for, per instance; new instances never get it. +// +// How: init.ranchu.rc sets the property in early-init, after build.prop was read, so a line in /vendor/build.prop +// is taken first. /vendor is writable only on instances with the execution bridge (their boot marker makes the +// emulator boot with a writable system, bridge.go), so the switch needs the bridge; the guest script +// (guest/scripts/gles32.sh) edits the file in the overlay, and the change shows after a cold boot: on and off boot +// the instance cold once and then check what the guest reports. +// +// The record (instances..gles32 in mas-avd.json) stops `bridge uninstall` while the switch is on, as it does for +// the Play Store: without the bridge the line could not be taken out again. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "modavd/internal/adb" + "modavd/internal/avd" + "modavd/internal/errs" + "modavd/internal/guest" + "modavd/internal/jsonord" +) + +const ( + // gles32MetaKey is instances..gles32 in mas-avd.json: true once `gles32 on` wrote the line, false after off. + // Absent on every instance that never had it. + gles32MetaKey = "gles32" + // gles32GuestDir is where the script is pushed to; removed after each run. + gles32GuestDir = "/data/local/tmp/modavd-gles32" + // gles32ScriptTimeout bounds one run of the guest script. + gles32ScriptTimeout = 2 * time.Minute + // gles32PushTimeout bounds the push of the script. + gles32PushTimeout = time.Minute +) + +// The results of the guest script's runs (the word after RESULT). +const ( + gles32ResultOn = "on" + gles32ResultOff = "off" + gles32ResultPendingOn = "pending-on" + gles32ResultPendingOff = "pending-off" + gles32ResultReboot = "reboot" + gles32ResultAlready = "already" + gles32ResultNotWriting = "not-writable" +) + +// GLES32ReasonNoBridge is the errs.Reason of the switch's refusal on an instance that does not boot with a writable +// system (errs.NotReady): the manager offers the bridge by it. +const GLES32ReasonNoBridge = "gles32_no_bridge" + +// GLES32PlayStoreNote is the note on the result when the instance has the Play Store: the store keeps what it knew of +// the device. +const GLES32PlayStoreNote = "Play 商店会记着旧的设备信息;要它按新版本重新判断兼容,到 设置 → 应用 → Google Play 商店 → 存储,清除数据(账号不会退出)" + +// GLES32Result is the result of `modavd gles32`. +type GLES32Result struct { + Index int `json:"index"` + Action string `json:"action"` + // State is what the guest has after the action: on (the line is in and the guest reports 196610), off (neither), + // pending-on / pending-off (the line and the reported version disagree: a cold boot is still to come). + State string `json:"state"` + Enabled bool `json:"enabled"` // State is on + Prop string `json:"prop"` // ro.opengles.version as the guest reports it + Restarted bool `json:"restarted"` // it was shut down and booted cold + Stopped bool `json:"stopped"` // it was off before and is off again + Notes []string `json:"notes"` + Warnings []string `json:"warnings"` +} + +// gles32Script is one run of the guest script (a hook for the tests). +var gles32Script = (*Manager).runGLES32Script + +// gles32Record: instances..gles32 is true. +func gles32Record(meta *jsonord.Obj) bool { + if meta == nil { + return false + } + b, ok := meta.Get(gles32MetaKey).BoolVal() + return ok && b +} + +// gles32Wanted: the switch is on (bridge uninstall refuses). +func (m *Manager) gles32Wanted(i int) bool { + return gles32Record(m.Root.InstanceMeta(i)) +} + +func (m *Manager) setGLES32Wanted(i int, on bool) error { + return m.Root.EditInstanceMeta(i, func(meta *jsonord.Obj) error { + meta.Set(gles32MetaKey, jsonord.NewBool(on)) + return nil + }) +} + +// gles32BridgeError is nil when instance i boots with a writable system (it has the bridge and its marker). +func (m *Manager) gles32BridgeError(i int, cfg *avd.Ini) error { + if !bridgeGuestSupported(cfg) { + return errs.WithDetail(errs.New(errs.Unsupported, "arm64 客体没有「报告 GLES 3.2」这一项"), + errs.Reason, PlayStoreReasonUnsupported) + } + if !m.bridgeWanted(i, cfg) || !regularFile(bridgeMarker(m.Root, i)) { + e := errs.Newf(errs.NotReady, "实例 %d 没有 arm64 执行桥,不按可写系统镜像开机,改不了它报的 GLES 版本", i). + WithAdvice(fmt.Sprintf("先运行 modavd bridge %d install 装上执行桥,再开这个开关", i)) + return errs.WithDetail(e, errs.Reason, GLES32ReasonNoBridge) + } + return nil +} + +// GLES32Check is what GLES32 checks before it touches anything, without a lock or a boot: the host, the action, the +// instance (there, not being created) and, for on and off, a writable system (the bridge). The manager calls it before +// it starts the job, so the button hears at once. +func (m *Manager) GLES32Check(i int, action string) error { + _, err := m.gles32Check(i, action) + return err +} + +func (m *Manager) gles32Check(i int, action string) (*avd.Instance, error) { + if err := requireWindows(); err != nil { + return nil, err + } + switch action { + case "on", "off", "status": + default: + return nil, errs.Newf(errs.Usage, "只能是 on、off 或 status,收到「%s」", action) + } + in, err := m.instance(i) + if err != nil { + return nil, err + } + if err := m.notCreated(i, "不能改它报的 GLES 版本"); err != nil { + return nil, err + } + if action != "status" { + if err := m.gles32BridgeError(i, in.Config); err != nil { + return nil, err + } + } + return in, nil +} + +// runGLES32Script runs the guest script's action on instance i (running) as root; the push directory is removed +// afterwards. +func (m *Manager) runGLES32Script(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 "+gles32GuestDir+" && mkdir -p "+gles32GuestDir, 0); res.Code != 0 { + return playStoreRun{}, errs.Newf(errs.LaunchFailed, "实例 %d 里建不了 %s:%s", i, gles32GuestDir, tail(res.Out, 300)) + } + defer sh("su 0 rm -rf "+gles32GuestDir, 0) + script, err := guest.LocalFile(filepath.Join(os.TempDir(), "modavd-guest"), guest.GLES32) + if err != nil { + return playStoreRun{}, errs.Wrap(errs.Internal, err, "写不了客体脚本 "+guest.GLES32) + } + res := m.ADB.Run(ctx, adb.RunOptions{Serial: serial, Timeout: gles32PushTimeout, Reregister: true}, + "push", script, gles32GuestDir+"/"+guest.GLES32) + if res.Code != 0 { + return playStoreRun{}, errs.Newf(errs.LaunchFailed, "实例 %d 推送 %s 失败:%s", i, guest.GLES32, tail(res.Out, 300)) + } + res = sh(fmt.Sprintf("su 0 sh %s/%s %s", gles32GuestDir, guest.GLES32, action), gles32ScriptTimeout) + run := parsePlayStoreRun(res.Out) + run.Ran = true + if run.Result == "" { + return run, errs.Newf(errs.LaunchFailed, "实例 %d 的 GLES 3.2 脚本(%s)没有跑完:%s", i, action, tail(res.Out, 300)) + } + return run, nil +} + +func gles32Failed(i int, action string, run playStoreRun) error { + msg := fmt.Sprintf("实例 %d 的 GLES 3.2 开关没做成(%s %s)", i, action, run.Result) + if len(run.Lines) > 0 { + msg += ":" + run.Lines[len(run.Lines)-1] + } + if run.Result == gles32ResultNotWriting { + e := errs.New(errs.NotReady, msg).WithAdvice(fmt.Sprintf("实例的系统镜像不可写:先运行 modavd bridge %d install,再试一次", i)) + return errs.WithDetail(e, errs.Reason, GLES32ReasonNoBridge) + } + return errs.New(errs.LaunchFailed, msg) +} + +// gles32Status runs the script's status and fills res from it (State, Enabled, Prop). +func (m *Manager) gles32Status(ctx context.Context, i int, res *GLES32Result) error { + run, err := gles32Script(m, ctx, i, "status") + if err != nil { + return err + } + switch run.Result { + case gles32ResultOn, gles32ResultOff, gles32ResultPendingOn, gles32ResultPendingOff: + default: + return gles32Failed(i, "status", run) + } + res.State, res.Enabled, res.Prop = run.Result, run.Result == gles32ResultOn, "" + for _, l := range run.Lines { + if p, ok := strings.CutPrefix(l, "prop "); ok { + res.Prop = strings.TrimSpace(p) + } else if l == "prop" { + res.Prop = "" + } + } + return nil +} + +// GLES32 turns the reported GLES 3.2 on or off for instance i, or checks (status) what the guest reports. on and off +// boot an instance that is off and stop it again, also when they fail (best effort: what stands in the way is a +// warning); their cold boot brings a running instance back the way it ran (window or headless). status needs it +// running. Everything GLES32Check refuses is refused before the instance is touched. After the cold boot the script's +// status must say what was asked for; if it does not, that is an error (the record stays as the script left the file). +func (m *Manager) GLES32(ctx context.Context, i int, action string) (*GLES32Result, error) { + if _, err := m.gles32Check(i, action); err != nil { + return nil, err + } + lock, err := m.playStoreLock(i) + if err != nil { + return nil, err + } + defer lock.Unlock() + + res := &GLES32Result{Index: i, Action: action, Notes: []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, playStoreNotRunning(i) + } + return res, m.gles32Status(ctx, i, res) + } + + warn := func(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + m.Log.Warnf("%s", msg) + res.Warnings = append(res.Warnings, msg) + } + // ours: the last start was this call's (one that is busy found another start under way, not to be stopped) + ours := false + boot := func() error { + sr, err := playStoreStart(m, ctx, i, StartOptions{Window: windowed, bridgeLocked: true}) + ours = errs.CodeOf(err) != errs.Busy + 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() + } + // fail ends a failed run: an instance that was off is shut down again, even after a cancel (best effort; what + // stands in the way goes into the warnings and the log, the error stays the one that failed). + fail := func(err error) (*GLES32Result, error) { + if wasRunning || !ours { + return res, err + } + w, serr := playStoreStop(m, context.WithoutCancel(ctx), i) + res.Warnings = append(res.Warnings, w...) + if serr != nil { + warn("实例 %d 原来是关着的,没做成后也没能关机:%s", i, errs.As(serr).Error()) + return res, err + } + res.Stopped = true + m.Log.Infof("实例 %d 原来是关着的,没做成后已经关机", i) + return res, err + } + if !wasRunning { + if err := boot(); err != nil { + return fail(err) + } + } + + want, pending := gles32ResultOn, gles32ResultPendingOn + if action == "off" { + want, pending = gles32ResultOff, gles32ResultPendingOff + } + run, err := gles32Script(m, ctx, i, action) + if err == nil && run.Result != gles32ResultReboot && run.Result != gles32ResultAlready { + err = gles32Failed(i, action, run) + } + if err != nil { + return fail(err) + } + // recorded once the file says so (bridge uninstall refuses from then on, while the line is there) + if err := m.setGLES32Wanted(i, action == "on"); err != nil { + return fail(err) + } + needBoot := run.Result == gles32ResultReboot + if !needBoot { + // the file already was as asked: a cold boot is due only when the guest has not been through one since + if err := m.gles32Status(ctx, i, res); err != nil { + return fail(err) + } + needBoot = res.State == pending + } + if needBoot { + if err := restart(); err != nil { + return fail(err) + } + } + if err := m.gles32Status(ctx, i, res); err != nil { + return fail(err) + } + if res.State != want { + return fail(errs.Newf(errs.LaunchFailed, "实例 %d 冷启动后 ro.opengles.version 是「%s」(%s),和「%s」对不上", i, res.Prop, res.State, action). + WithAdvice(fmt.Sprintf("运行 modavd gles32 %d status 再看一次;还不对就把这条 %s 的输出发给维护者", i, action))) + } + if m.playStoreWanted(i) { + res.Notes = append(res.Notes, GLES32PlayStoreNote) + } + 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 +} diff --git a/internal/emu/gles32_test.go b/internal/emu/gles32_test.go new file mode 100644 index 0000000..2266e92 --- /dev/null +++ b/internal/emu/gles32_test.go @@ -0,0 +1,282 @@ +//go:build windows + +package emu + +import ( + "context" + "os" + "slices" + "strings" + "testing" + + "modavd/internal/errs" +) + +// glHooks is playStoreHooks (the qemu scan, starts and stops) plus the GLES 3.2 script's scripted runs: results per +// action, in order, the last repeating; a status entry is "/"; "?" ran with no RESULT. +type glHooks struct { + playStoreHooks + results map[string][]string +} + +func (h *glHooks) install(t *testing.T) { + t.Helper() + h.playStoreHooks.install(t) + old := gles32Script + t.Cleanup(func() { gles32Script = old }) + used := map[string]int{} + gles32Script = func(_ *Manager, _ context.Context, _ int, action string) (playStoreRun, error) { + h.calls = append(h.calls, "gles "+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]++ + word, prop, _ := strings.Cut(rs[k], "/") + if word == "?" { + return playStoreRun{Ran: true}, errs.New(errs.LaunchFailed, "the script stopped without RESULT") + } + run := playStoreRun{Result: word, Ran: true, Lines: []string{}} + if action == "status" { + run.Lines = []string{"prop " + prop, "line yes"} + } + return run, nil + } +} + +func glInfo(t *testing.T, m *Manager, i int) bool { + t.Helper() + in, err := m.instance(i) + if err != nil { + t.Fatal(err) + } + return m.instanceInfo(context.Background(), in, nil, nil).GLES32 +} + +// TestGLES32OnOff: on an instance that is off, on boots it, runs the script, boots cold, checks and stops it; a +// running one comes back as it ran (window) and stays running; off does the same the other way. The record and +// InstanceInfo.GLES32 follow, and bridge uninstall refuses while it is on. +func TestGLES32OnOff(t *testing.T) { + m, i := opsTestInstance(t) + writeBridgeMarker(m.Root, i) + ctx := context.Background() + var h glHooks + h.results = map[string][]string{"on": {gles32ResultReboot}, "off": {gles32ResultReboot}, + "status": {"on/196610"}} + h.install(t) + + if err := m.GLES32Check(i, "on"); err != nil { + t.Fatalf("check: %v", err) + } + res, err := m.GLES32(ctx, i, "on") + if err != nil || !res.Enabled || res.State != "on" || res.Prop != "196610" || !res.Restarted || !res.Stopped { + t.Fatalf("on: %+v %v", res, err) + } + if want := []string{"start", "gles on", "stop", "start", "gles status", "stop"}; !slices.Equal(h.calls, want) { + t.Errorf("on calls %v, want %v", h.calls, want) + } + if !m.gles32Wanted(i) || !glInfo(t, m, i) || len(res.Notes) != 0 { + t.Errorf("wanted %v, info %v, notes %q", m.gles32Wanted(i), glInfo(t, m, i), res.Notes) + } + if _, err := m.Bridge(ctx, i, "uninstall"); errs.CodeOf(err) != errs.NotReady || !strings.Contains(err.Error(), "GLES") { + t.Errorf("bridge uninstall with the switch on: %v", err) + } + + // running, with a window: restarted the same way, left running + h.calls, h.running, h.starts = nil, true, nil + h.results["status"] = []string{"off/196609"} + res, err = m.GLES32(ctx, i, "off") + if err != nil || res.Enabled || res.State != "off" || !res.Restarted || res.Stopped { + t.Fatalf("off: %+v %v", res, err) + } + if want := []string{"gles off", "stop", "start", "gles status"}; !slices.Equal(h.calls, want) { + t.Errorf("off calls %v, want %v", h.calls, want) + } + if m.gles32Wanted(i) || glInfo(t, m, i) { + t.Error("still recorded after off") + } + for k, opt := range h.starts { + if opt.NoBridge || opt.Headless || !opt.bridgeLocked { + t.Errorf("start %d: %+v", k, opt) + } + } + + // status of a running instance only asks + h.calls = nil + h.results["status"] = []string{"pending-on/196609"} + res, err = m.GLES32(ctx, i, "status") + if err != nil || res.State != "pending-on" || res.Enabled || res.Prop != "196609" { + t.Fatalf("status: %+v %v", res, err) + } + if want := []string{"gles status"}; !slices.Equal(h.calls, want) { + t.Errorf("status calls %v", h.calls) + } +} + +// TestGLES32AlreadyAndPending: a file that already is as asked needs no boot when the guest agrees, one when it does +// not (an earlier run stopped before its cold boot). +func TestGLES32AlreadyAndPending(t *testing.T) { + m, i := opsTestInstance(t) + writeBridgeMarker(m.Root, i) + var h glHooks + h.running = true + h.results = map[string][]string{"on": {gles32ResultAlready}, "status": {"on/196610"}} + h.install(t) + res, err := m.GLES32(context.Background(), i, "on") + if err != nil || !res.Enabled || res.Restarted { + t.Fatalf("already: %+v %v", res, err) + } + if want := []string{"gles on", "gles status", "gles status"}; !slices.Equal(h.calls, want) { + t.Errorf("calls %v, want %v", h.calls, want) + } + + var p glHooks + p.running = true + p.results = map[string][]string{"on": {gles32ResultAlready}, "status": {"pending-on/196609", "on/196610"}} + p.install(t) + res, err = m.GLES32(context.Background(), i, "on") + if err != nil || !res.Enabled || !res.Restarted { + t.Fatalf("pending: %+v %v", res, err) + } + if want := []string{"gles on", "gles status", "stop", "start", "gles status"}; !slices.Equal(p.calls, want) { + t.Errorf("calls %v, want %v", p.calls, want) + } +} + +// TestGLES32PlayStoreNote: an instance with the Play Store gets the note about the store's old device info. +func TestGLES32PlayStoreNote(t *testing.T) { + m, i := opsTestInstance(t) + writeBridgeMarker(m.Root, i) + if err := m.setPlayStoreWanted(i, true); err != nil { + t.Fatal(err) + } + var h glHooks + h.running = true + h.results = map[string][]string{"on": {gles32ResultReboot}, "status": {"on/196610"}} + h.install(t) + res, err := m.GLES32(context.Background(), i, "on") + if err != nil || len(res.Notes) != 1 || res.Notes[0] != GLES32PlayStoreNote || !strings.Contains(res.Notes[0], "清除数据") { + t.Fatalf("notes: %+v %v", res, err) + } +} + +// TestGLES32Refused: on and off need the bridge (refused before anything is touched, with the reason the manager +// offers a way out by), status a running instance, the action a known word; a script that cannot write is not ready. +func TestGLES32Refused(t *testing.T) { + m, i := opsTestInstance(t) + ctx := context.Background() + var h glHooks + h.install(t) + os.Remove(bridgeMarker(m.Root, i)) // a files-only create placed it + reason := func(err error) string { return errs.DetailsOf(err)[errs.Reason] } + for _, action := range []string{"on", "off"} { + for _, call := range []func() error{ + func() error { _, err := m.GLES32(ctx, i, action); return err }, + func() error { return m.GLES32Check(i, action) }, + } { + if err := call(); errs.CodeOf(err) != errs.NotReady || reason(err) != GLES32ReasonNoBridge || !strings.Contains(err.Error(), "执行桥") { + t.Errorf("%s without the bridge: %v %v", action, err, errs.DetailsOf(err)) + } + } + } + if len(h.guestCalls()) != 0 || m.gles32Wanted(i) { + t.Errorf("touched: %v", h.calls) + } + if _, err := m.GLES32(ctx, i, "status"); errs.CodeOf(err) != errs.Unsupported { + t.Errorf("status of an instance that is off: %v", err) + } + if _, err := m.GLES32(ctx, i, "toggle"); errs.CodeOf(err) != errs.Usage { + t.Errorf("unknown action: %v", err) + } + if err := m.GLES32Check(7, "on"); errs.CodeOf(err) != errs.NotFound { + t.Errorf("an instance that is not there: %v", err) + } + if len(h.guestCalls()) != 0 { + t.Errorf("calls %v", h.calls) + } + + writeBridgeMarker(m.Root, i) + h.results = map[string][]string{"on": {gles32ResultNotWriting}} + if _, err := m.GLES32(ctx, i, "on"); errs.CodeOf(err) != errs.NotReady || reason(err) != GLES32ReasonNoBridge { + t.Errorf("not writable: %v", err) + } + if m.gles32Wanted(i) { + t.Error("recorded after a script that wrote nothing") + } +} + +// TestGLES32FailureStopsAgain: an instance that was off is shut down again when the script fails, when it stops +// without RESULT, when the cold boot fails and when the check after it disagrees; the last is an error that names the +// version the guest reports. A running one is left running. +func TestGLES32FailureStopsAgain(t *testing.T) { + m, i := opsTestInstance(t) + writeBridgeMarker(m.Root, i) + ctx := context.Background() + cases := []struct { + name string + results map[string][]string + start int // the n-th start fails (1-based; 0: none) + want []string + }{ + {"script fails", map[string][]string{"on": {"failed"}}, 0, []string{"start", "gles on", "stop"}}, + {"no RESULT", map[string][]string{"on": {"?"}}, 0, []string{"start", "gles on", "stop"}}, + {"check disagrees", map[string][]string{"on": {gles32ResultReboot}, "status": {"pending-on/196609"}}, 0, + []string{"start", "gles on", "stop", "start", "gles status", "stop"}}, + {"cold boot fails", map[string][]string{"on": {gles32ResultReboot}, "status": {"on/196610"}}, 2, + []string{"start", "gles on", "stop", "start", "stop"}}, + } + for _, c := range cases { + var h glHooks + h.results = c.results + h.install(t) + if c.start > 0 { + n := 0 + inner := playStoreStart + playStoreStart = func(m *Manager, ctx context.Context, i int, opt StartOptions) (*StartResult, error) { + n++ + if n == c.start { + h.calls = append(h.calls, "start") + return nil, errs.New(errs.LaunchFailed, "boom") + } + return inner(m, ctx, i, opt) + } + } + res, err := m.GLES32(ctx, i, "on") + if err == nil || res == nil || !res.Stopped || !slices.Equal(h.calls, c.want) { + t.Errorf("%s: %+v %v, calls %v want %v", c.name, res, err, h.calls, c.want) + } + if c.name == "check disagrees" && (err == nil || !strings.Contains(err.Error(), "196609")) { + t.Errorf("%s: the error does not name the version: %v", c.name, err) + } + } + + var h glHooks + h.running = true + h.results = map[string][]string{"on": {"failed"}} + h.install(t) + if res, err := m.GLES32(ctx, i, "on"); err == nil || res.Stopped || slices.Contains(h.calls, "stop") { + t.Errorf("a running instance: %+v %v %v", res, err, h.calls) + } +} + +// TestGLES32TakesBridgeLock: one held elsewhere makes the switch busy. +func TestGLES32TakesBridgeLock(t *testing.T) { + m, i := opsTestInstance(t) + writeBridgeMarker(m.Root, i) + var h glHooks + h.install(t) + l, err := m.playStoreLock(i) + if err != nil { + t.Fatal(err) + } + defer l.Unlock() + for _, action := range []string{"on", "off", "status"} { + if _, err := m.GLES32(context.Background(), i, action); errs.CodeOf(err) != errs.Busy { + t.Errorf("%s while the bridge lock is held: %v", action, err) + } + } + if len(h.guestCalls()) != 0 { + t.Errorf("calls %v", h.calls) + } +} diff --git a/internal/emu/playstore.go b/internal/emu/playstore.go index a327f9b..3c23b69 100644 --- a/internal/emu/playstore.go +++ b/internal/emu/playstore.go @@ -368,7 +368,7 @@ func (m *Manager) playStoreLock(i int) (*host.FileLock, error) { return nil, err } if !ok { - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在装卸执行桥或 Google Play 商店,稍后再试", i), + return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在装卸执行桥、Google Play 商店或改 GLES 3.2 开关,稍后再试", i), map[string]string{errs.Reason: "locked", errs.Lock: "bridge"}) } return lock, nil diff --git a/internal/emu/status.go b/internal/emu/status.go index 8079930..4912332 100644 --- a/internal/emu/status.go +++ b/internal/emu/status.go @@ -208,6 +208,7 @@ func (m *Manager) instanceInfo(ctx context.Context, in *avd.Instance, p *host.Pr info.AppProxy = appProxySummary(meta) store := playStoreRecord(meta) info.PlayStore, info.PlayStorePartial = store == playStoreInstalled, store == playStorePartial + info.GLES32 = gles32Record(meta) } info.Bridge = m.bridgeState(ctx, i, in.Config, info.Booted, info.Initialized) return info diff --git a/internal/emu/types.go b/internal/emu/types.go index bd26fa0..a6dbdea 100644 --- a/internal/emu/types.go +++ b/internal/emu/types.go @@ -67,6 +67,9 @@ type InstanceInfo struct { // PlayStorePartial: an install failed and could not take back all it put in (the record is "partial"); uninstall // removes what is there, another install completes it. PlayStorePartial bool `json:"play_store_partial"` + // GLES32: the guest is told to report OpenGL ES 3.2 (instances..gles32, set by `gles32 on`; gles32.go). + // Read from that record only; `gles32 status` asks the guest. + GLES32 bool `json:"gles32"` // Creation is "creating" while create sets the instance up (its headless first boot), "failed" when that stopped // (CreationError says why, CreationLog is that boot's log); "" for a ready instance. See crud.go. Creation string `json:"creation"` diff --git a/internal/guest/gles32_sh_test.go b/internal/guest/gles32_sh_test.go new file mode 100644 index 0000000..c11fd61 --- /dev/null +++ b/internal/guest/gles32_sh_test.go @@ -0,0 +1,288 @@ +package guest + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "modavd/internal/testutil" +) + +const ( + glMark = "# modavd: report GLES 3.2 (ModAVD/ModAVD#131)" + glLine = "ro.opengles.version=196610" +) + +// glGuest is a fake guest for gles32.sh under a POSIX sh (Git's on Windows): MODAVD_GLES_ROOT is a temporary directory +// standing for /, remount is a stub, and getprop answers what /prop holds (nothing when the file is missing). +type glGuest struct { + t *testing.T + sh string + root string + env []string + prop string + build string // the guest path of build.prop in the fake + scriptP string +} + +func newGLGuest(t *testing.T) *glGuest { + t.Helper() + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("no sh") + } + for _, tool := range []string{"grep", "tail", "cat"} { + 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 := &glGuest{t: t, sh: sh, root: filepath.ToSlash(filepath.Join(base, "root"))} + g.build = g.root + "/vendor/build.prop" + bin := filepath.Join(base, "bin") + for _, d := range []string{bin, g.root + "/vendor"} { + if err := os.MkdirAll(filepath.FromSlash(d), 0o755); err != nil { + t.Fatal(err) + } + } + 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("remount", `echo remount >> "$MODAVD_GLES_ROOT/remount.log"; exit 0`) + stub("getprop", `[ "$1" = ro.opengles.version ] && cat "$MODAVD_GLES_ROOT/prop" 2>/dev/null; exit 0`) + g.env = append(os.Environ(), "MODAVD_GLES_ROOT="+g.root, "MODAVD_PS_STUBS="+filepath.ToSlash(bin)) + g.scriptP = filepath.Join(base, "gles32.sh") + b, err := Script(GLES32) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(g.scriptP, b, 0o644); err != nil { + t.Fatal(err) + } + return g +} + +func (g *glGuest) 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 *glGuest) read(p string) string { + b, err := os.ReadFile(filepath.FromSlash(p)) + if err != nil { + return "" + } + return string(b) +} + +// setProp is what getprop ro.opengles.version answers ("" for an empty answer). +func (g *glGuest) setProp(v string) { g.write(g.root+"/prop", v) } + +func (g *glGuest) run(action string) (string, string, int) { + g.t.Helper() + cmd := exec.Command(g.sh, "-c", stubbedSh, "sh", filepath.ToSlash(g.scriptP), action) + cmd.Env = g.env + out, err := cmd.CombinedOutput() + code := 0 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } + 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), code +} + +const glOriginal = "ro.vendor.build.id=ABC\nro.product.vendor.model=sdk\n# a comment\nro.opengles.version.note=x\n" + +func TestGLES32ScriptEmbedded(t *testing.T) { + b, err := Script(GLES32) + if err != nil || len(b) == 0 || strings.ContainsRune(string(b), '\r') { + t.Fatalf("%d bytes, %v", len(b), err) + } +} + +// TestGLES32ScriptOnOff: on remounts and appends the marker and the line, the file's other content is untouched; a +// second on finds it; off takes out both lines and nothing else; a second off finds nothing. +func TestGLES32ScriptOnOff(t *testing.T) { + g := newGLGuest(t) + g.write(g.build, glOriginal) + + r, out, code := g.run("on") + if r != "reboot" || code != 0 { + t.Fatalf("on: %s %d\n%s", r, code, out) + } + if want := glOriginal + glMark + "\n" + glLine + "\n"; g.read(g.build) != want { + t.Errorf("build.prop after on:\n%q\nwant\n%q", g.read(g.build), want) + } + if !strings.Contains(g.read(g.root+"/remount.log"), "remount") { + t.Error("remount was not run") + } + if g.exists("/vendor/.modavd-w") { + t.Error("the write probe is left") + } + + if r, out, _ := g.run("on"); r != "already" { + t.Fatalf("second on: %s\n%s", r, out) + } + if want := glOriginal + glMark + "\n" + glLine + "\n"; g.read(g.build) != want { + t.Errorf("build.prop after the second on: %q", g.read(g.build)) + } + + r, out, code = g.run("off") + if r != "reboot" || code != 0 { + t.Fatalf("off: %s %d\n%s", r, code, out) + } + if g.read(g.build) != glOriginal { + t.Errorf("build.prop after off:\n%q\nwant\n%q", g.read(g.build), glOriginal) + } + if g.exists("/vendor/build.prop.modavd-tmp") { + t.Error("the temporary copy is left") + } + if r, out, _ := g.run("off"); r != "already" { + t.Fatalf("second off: %s\n%s", r, out) + } + if g.read(g.build) != glOriginal { + t.Errorf("build.prop after the second off: %q", g.read(g.build)) + } +} + +// TestGLES32ScriptOffKeepsOthers: off takes out the marker and the line after it only; a 196610 line without our +// marker, lines around the pair, a file without a final newline and a missing last newline are kept as they are. +func TestGLES32ScriptOffKeepsOthers(t *testing.T) { + g := newGLGuest(t) + g.write(g.build, "a=1\n"+glLine+"\n"+glMark+"\n"+glLine+"\nb=2\n c = 3 \nlast=4") + if r, out, _ := g.run("off"); r != "reboot" { + t.Fatalf("off: %s\n%s", r, out) + } + if want := "a=1\n" + glLine + "\nb=2\n c = 3 \nlast=4\n"; g.read(g.build) != want { + t.Errorf("build.prop:\n%q\nwant\n%q", g.read(g.build), want) + } + + // no final newline: on starts its pair on a line of its own + g = newGLGuest(t) + g.write(g.build, "a=1") + if r, out, _ := g.run("on"); r != "reboot" { + t.Fatalf("on: %s\n%s", r, out) + } + if want := "a=1\n" + glMark + "\n" + glLine + "\n"; g.read(g.build) != want { + t.Errorf("build.prop:\n%q\nwant\n%q", g.read(g.build), want) + } + + // only the marker decides: our line without it is not ours + g = newGLGuest(t) + g.write(g.build, glLine+"\n") + if r, _, _ := g.run("off"); r != "already" { + t.Errorf("off with a foreign line: %s", r) + } + if g.read(g.build) != glLine+"\n" { + t.Errorf("a foreign line was changed: %q", g.read(g.build)) + } +} + +func (g *glGuest) exists(p string) bool { + _, err := os.Lstat(filepath.FromSlash(g.root + p)) + return err == nil +} + +// TestGLES32ScriptKeepsFile: the file is written in place, not replaced: a hard link made to it before sees the change +// (an mv or sed -i would leave the link with the old content). +func TestGLES32ScriptKeepsFile(t *testing.T) { + g := newGLGuest(t) + g.write(g.build, glOriginal) + link := g.root + "/vendor/build.prop.link" + if err := os.Link(filepath.FromSlash(g.build), filepath.FromSlash(link)); err != nil { + t.Skip("no hard links here: " + err.Error()) + } + if r, out, _ := g.run("on"); r != "reboot" { + t.Fatalf("on: %s\n%s", r, out) + } + if !strings.HasSuffix(g.read(link), glLine+"\n") { + t.Errorf("the link does not see on: %q", g.read(link)) + } + if r, out, _ := g.run("off"); r != "reboot" { + t.Fatalf("off: %s\n%s", r, out) + } + if g.read(link) != glOriginal { + t.Errorf("the link does not see off: %q", g.read(link)) + } +} + +// TestGLES32ScriptStatus: the four results, the two lines before them. +func TestGLES32ScriptStatus(t *testing.T) { + cases := []struct { + marked bool + prop string + want string + }{ + {true, "196610", "on"}, + {false, "196609", "off"}, + {false, "", "off"}, + {true, "196609", "pending-on"}, + {true, "", "pending-on"}, + {false, "196610", "pending-off"}, + } + for _, c := range cases { + g := newGLGuest(t) + data := glOriginal + if c.marked { + data += glMark + "\n" + glLine + "\n" + } + g.write(g.build, data) + g.setProp(c.prop) + r, out, code := g.run("status") + line := "line no" + if c.marked { + line = "line yes" + } + if r != c.want || code != 0 || !strings.Contains(out, "prop "+c.prop+"\n") || !strings.Contains(out, line+"\n") { + t.Errorf("marked %v prop %q: %s (want %s)\n%s", c.marked, c.prop, r, c.want, out) + } + if g.read(g.build) != data { + t.Errorf("status changed the file") + } + } + // no build.prop at all + g := newGLGuest(t) + os.Remove(filepath.FromSlash(g.build)) + if r, out, _ := g.run("status"); r != "off" || !strings.Contains(out, "line no") { + t.Errorf("without build.prop: %s\n%s", r, out) + } +} + +// TestGLES32ScriptNotWritable: /vendor that cannot be written to is not-writable (exit code 3) for on and off, and +// nothing is changed. +func TestGLES32ScriptNotWritable(t *testing.T) { + g := newGLGuest(t) + // a file where the partition's directory should be: touch inside it fails + os.RemoveAll(filepath.FromSlash(g.root + "/vendor")) + g.write(g.root+"/vendor", "not a directory") + for _, action := range []string{"on", "off"} { + r, out, code := g.run(action) + if r != "not-writable" || code != 3 { + t.Errorf("%s: %s %d\n%s", action, r, code, out) + } + } +} + +func TestGLES32ScriptUsage(t *testing.T) { + g := newGLGuest(t) + if r, _, code := g.run("bogus"); r != "failed" || code != 2 { + t.Errorf("usage: %s %d", r, code) + } +} diff --git a/internal/guest/guest.go b/internal/guest/guest.go index 582bf7e..ce5bf4a 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" + // GLES32 switches the OpenGL ES version the guest reports to 3.2 (modavd only, experimental, ModAVD/ModAVD#131). + GLES32 = "gles32.sh" // PlayStore installs, checks and removes the Google Play Store (modavd only, not in AUTO-MAS). PlayStore = "playstore.sh" ) diff --git a/internal/guest/scripts/gles32.sh b/internal/guest/scripts/gles32.sh new file mode 100644 index 0000000..e94aa88 --- /dev/null +++ b/internal/guest/scripts/gles32.sh @@ -0,0 +1,80 @@ +#!/system/bin/sh +# ModAVD: report GLES 3.2 (experimental, ModAVD/ModAVD#131), run as root by modavd (internal/emu/gles32.go): +# sh gles32.sh on|off|status +# Some apps list GLES 3.2 in their manifest and Play hides them from a device whose ro.opengles.version says less. This +# only changes the version the guest reports (196610 = 3.2, the emulator renders 3.1); nothing renders differently. +# init.ranchu.rc sets ro.opengles.version in early-init, after build.prop was read, so a line in /vendor/build.prop +# is taken first. /vendor is writable (adb remount's overlay) only on instances with the execution bridge. The line +# goes in after a marker comment, which is all that tells it from anything else in the file. A change shows after the +# next cold boot. Each run ends with one line "RESULT " that modavd reads: +# on: reboot (written; cold boot, then status) | already | not-writable +# off: reboot (taken out) | already | not-writable +# status: lines "prop " and "line yes|no", then +# on | off | pending-on (the line is there, the property is not 196610 yet) | pending-off (the line is gone, +# the property still is 196610) +# Exit code 0 unless the result is not-writable (3) or the usage is wrong (2). MODAVD_GLES_ROOT prefixes every +# absolute path (tests only; unset in the guest). +R=${MODAVD_GLES_ROOT:-} +F=$R/vendor/build.prop +MARK='# modavd: report GLES 3.2 (ModAVD/ModAVD#131)' +LINE='ro.opengles.version=196610' +WANT=196610 + +result() { echo "RESULT $1"; exit "${2:-0}"; } + +writable() { + remount >/dev/null 2>&1 + if ! touch "$R/vendor/.modavd-w" 2>/dev/null; then + echo "/vendor is not writable" + result not-writable 3 + fi + rm -f "$R/vendor/.modavd-w" +} + +# marked: our line is in the file +marked() { [ -f "$F" ] && grep -qxF "$MARK" "$F"; } + +case "$1" in +on) + writable + marked && result already + # appended to the file itself (its inode, mode and SELinux label stay); a file without a final newline gets one first + if [ -s "$F" ] && [ -n "$(tail -c 1 "$F")" ]; then echo >> "$F" || result not-writable 3; fi + { echo "$MARK"; echo "$LINE"; } >> "$F" || result not-writable 3 + sync + echo "wrote $LINE" + result reboot + ;; +off) + writable + marked || result already + tmp=$F.modavd-tmp + # the marker line and the one after it go; everything else is copied as it is + skip=0 + while IFS= read -r l || [ -n "$l" ]; do + if [ "$skip" = 1 ]; then skip=0; continue; fi + if [ "$l" = "$MARK" ]; then skip=1; continue; fi + printf '%s\n' "$l" + done < "$F" > "$tmp" || { rm -f "$tmp"; echo "cannot read $F"; result not-writable 3; } + # over the file itself, not moved: its inode, mode and SELinux label stay + cat "$tmp" > "$F" || { rm -f "$tmp"; echo "cannot write $F"; result not-writable 3; } + rm -f "$tmp" + sync + echo "took out $LINE" + result reboot + ;; +status) + prop=$(getprop ro.opengles.version 2>/dev/null) + echo "prop $prop" + if marked; then echo "line yes"; line=1; else echo "line no"; line=0; fi + if [ "$prop" = "$WANT" ]; then isw=1; else isw=0; fi + if [ $line = 1 ] && [ $isw = 1 ]; then result on + elif [ $line = 0 ] && [ $isw = 0 ]; then result off + elif [ $line = 1 ]; then result pending-on + else result pending-off; fi + ;; +*) + echo "usage: sh $0 on|off|status" + result failed 2 + ;; +esac diff --git a/internal/ui/API.md b/internal/ui/API.md index b84709e..14c4c89 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -48,6 +48,8 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | POST | `/api/instances/{i}/resume` | `{}` | 202 `Job`(`kind` 是 `create`):重做新建失败的那台的初始化 | | POST | `/api/instances/{i}/playstore-install` | `{}` | 202 `Job`(`kind` 是 `playstore-install`):把根目录里的五个文件装进这台实例(同 `modavd playstore install`:放进执行桥的可写覆盖层、藏起占位应用、冷启动一次;关着的做完再关上,开着的照原来的方式开回来,要几分钟);结果是 `PlayStoreResult`。开始前先检查,不行立即回错误、不开任务:根目录还没有五个文件(或文件不全、损坏)是 422 `not_ready`,`details.reason` 为 `playstore_files_missing`(页面引导去「补全组件」勾选或导入);实例没有执行桥是 422 `not_ready`,`details.reason` 为 `no_bridge`;arm64 客体是 422 `unsupported`(`unsupported_guest`);新建中 409 `busy`;不存在 404。任务里客体脚本发现系统不可写也是 `not_ready` / `no_bridge`。任务中途失败时客体脚本撤回这次放进去的文件;撤不干净(或脚本没说完就断了)的,实例的 `play_store_partial` 变成 `true`,页面同时给「补全」和「移除」。实例原来关着的,失败后也关上 | | POST | `/api/instances/{i}/playstore-uninstall` | `{}` | 202 `Job`(`kind` 是 `playstore-uninstall`):从实例里移除商店(去掉 Google Play 装的更新、删掉五个文件、冷启动后恢复占位应用;同 `playstore uninstall`);结果是 `PlayStoreResult`。不要五个文件,也不检查执行桥;只装了一部分的实例也用它移除 | +| POST | `/api/instances/{i}/gles32-on` | `{}` | 202 `Job`(`kind` 是 `gles32-on`):实验功能,让实例把 `ro.opengles.version` 报成 196610(GLES 3.2),Google Play 就不会把清单里要求 GLES 3.2 的游戏判成不兼容(同 `modavd gles32 on`)。只改报的版本,实际渲染仍是 3.1。在执行桥带来的可写系统镜像覆盖层里改 `/vendor/build.prop`,冷启动一次生效;关着的做完再关上,开着的照原来的方式开回来,要几分钟;冷启动后核对客体报的版本,对不上任务失败。结果是 `GLES32Result`。开始前先检查,不行立即回错误、不开任务:实例没有执行桥是 422 `not_ready`,`details.reason` 为 `gles32_no_bridge`;arm64 客体是 422 `unsupported`;新建中 409 `busy`;不存在 404。任务里客体脚本发现系统不可写也是 `not_ready` / `gles32_no_bridge`。开着时 `bridge uninstall` 拒绝 | +| POST | `/api/instances/{i}/gles32-off` | `{}` | 202 `Job`(`kind` 是 `gles32-off`):去掉上面写进去的行,冷启动后实例改回报 3.1(同 `gles32 off`);结果是 `GLES32Result`。检查同 `gles32-on` | | GET | `/api/instances/{i}/playstore-gsfid` | | `{"index","decimal","hex"}`:开着的实例的 GSF ID(同 `modavd playstore gsfid --json`;十进制是 GSF 记的,十六进制是同一个数,负数按补码),几秒钟。用来到 https://www.google.com/android/uncertified/ 登记。没开机(按最近的列表)或实例上有任务在跑是 409 `busy`;还没有 ID 是 422 `not_ready`,`details.reason` 为 `no_gsf_id`(开着网等一两分钟再试) | | POST | `/api/instances/{i}/input` | `{type:"tap", x, y}`(坐标是这台实例显示的像素(1280×720 或 1920×1080,竖屏时宽高对调))或 `{type:"key", key:"back"\|"home"\|"recents"}` | `{}` | | GET | `/api/instances/{i}/screenshot` | | `image/png`(`adb exec-out screencap -p`),`Cache-Control: no-store`;没开机回 409 | @@ -91,11 +93,11 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," "result":null,"error":null,"warnings":[]} ``` -- `kind`:`start` / `stop` / `install` / `create` / `components` / `collect-info` / `disk-info` / `disk-trim` / `disk-compact` / `playstore-install` / `playstore-uninstall` / `playstore-import`(0.3.0 起没有重启,也没有显示 / 隐藏窗口)。 +- `kind`:`start` / `stop` / `install` / `create` / `components` / `collect-info` / `disk-info` / `disk-trim` / `disk-compact` / `playstore-install` / `playstore-uninstall` / `playstore-import` / `gles32-on` / `gles32-off`(0.3.0 起没有重启,也没有显示 / 隐藏窗口)。 - `state`:`running` / `done` / `failed` / `canceled`。 - `progress.step`:开机用 emu 的 `launch` / `boot` / `tune` / `ime` / `first_boot` / `vulkan_fix` / `done`;关机(含等被取消的开机停下)是 `stop`,安装是 `install`,这两种做完是 `done`;磁盘任务进行中是 `disk_info` / `disk_trim` / `disk_compact`、做完是 `done`(进行中没有百分比,`total` 为 0,页面显示不定长进度条;做完 `done` = `total` = 1000);装执行桥的每一轮是 `bridge`;商店的装、卸进行中是 `playstore-install` / `playstore-uninstall`(没有百分比)、做完是 `done`;导入时核对文件是 `verifying`、解压镜像是 `extracting`(`done` / `total` 是字节);新建是 `create`、其中几次开机的上述各步(开机做完是 `setup`)、`create_stop`、`done`,几次开机合成一条不回退的进度。`total` 一律 1000。 - 安装任务的 `result.apk` 是用户上传的文件名,不是服务端的临时路径(临时文件任务结束就删了)。 -- `progress` 在没有进度时为 `null`;`result` 是 `StartResult` / `StopResult` / `InstallResult` / `DiskInfoResult` / `DiskTrimResult` / `DiskCompactResult` / `InstanceInfo`(新建)/ `PlayStoreResult` / `PlayStoreImportResult`,没完成时为 `null`;`error` 失败时是错误对象,否则 `null`。 +- `progress` 在没有进度时为 `null`;`result` 是 `StartResult` / `StopResult` / `InstallResult` / `DiskInfoResult` / `DiskTrimResult` / `DiskCompactResult` / `InstanceInfo`(新建)/ `PlayStoreResult` / `PlayStoreImportResult` / `GLES32Result`,没完成时为 `null`;`error` 失败时是错误对象,否则 `null`。 `DiskCompactResult` 保留非负的 `freed_bytes`。`before_on_disk_bytes` 和 `after_on_disk_bytes` 是整理前后宿主实际占用,新增的 `on_disk_delta_bytes` 等于后者减前者,正值表示占用增大。`skipped=true` 表示检查通过、预计收益不足,未重写原盘;此时前后占用相等、变化和释放量均为零。结果还包含 `reclaim_threshold_bytes` 和可省略的 `estimated_reclaimable_bytes`,详见 [整理预算与结果契约](../emu/COMPACT.md)。 - 同一实例同时只能有一个任务在跑;第二个回 409 `busy`。**例外**:开机任务进行中可以发关机(和 emu 一致:开机中照样能关)。 @@ -154,6 +156,13 @@ Apple Silicon macOS 的组件下载/导入使用 API35 Google Play ARM64 r9, - `PlayStoreResult`(`playstore-install` / `playstore-uninstall` 任务的结果,同 `modavd playstore … --json`):`{"index","action","state","installed","files","restarted","stopped","warnings"}`;`state` 是做完后客体里的情况:`installed`、`absent` 或 `partial`(还有没放到位的 `.new` 也算 `partial`)。 - `InstanceInfo.play_store`(bool):这台实例装着商店(按 `mas-avd.json` 的记录,装成功后为 `true`,移除后 `false`;不问客体)。按钮据此显示「补全 Google Play 商店」或「移除 Google Play 商店」,装着时再加一项「复制 GSF ID」(开机后可用);`bridge` 为 `off` 的实例装不了(会回 `no_bridge`)。 - `InstanceInfo.play_store_partial`(bool):上次补全中途失败、没能全部撤回(`mas-avd.json` 记的是 `"partial"`)。这时 `play_store` 为 `false`,页面「补全」和「移除」都给;`bridge uninstall` 照样拒绝。 + +### 报告 GLES 3.2(实验) + +有的游戏在清单里要求 GLES 3.2,Play 商店按 `ro.opengles.version` 把 ModAVD(报 3.1)判成不兼容。这个开关只把实例报的版本改成 3.2,实际渲染仍是 3.1:走 Vulkan 的游戏能用,真要 GLES 3.2 渲染的应用会装得上、打不开。默认关,按实例开;要执行桥(改的是它带来的可写系统镜像覆盖层)。 + +- `GLES32Result`(`gles32-on` / `gles32-off` 任务的结果,同 `modavd gles32 … --json`):`{"index","action","state","enabled","prop","restarted","stopped","notes","warnings"}`;`state` 是做完后客体里的情况:`on`(行在,客体报 196610)、`off`(行不在,客体不报 196610),还有没冷启动完的 `pending-on` / `pending-off`;`prop` 是客体的 `ro.opengles.version`;`notes` 在实例装着 Play 商店时有一条:商店会记着旧的设备信息,要到 设置 → 应用 → Google Play 商店 → 存储 清除数据(账号不会退出)。 +- `InstanceInfo.gles32`(bool):开关的记录(`mas-avd.json` 的 `gles32`;不问客体)。界面据此在「更多操作」里显示「开启 / 关闭报告 GLES 3.2(实验)」;`bridge` 为 `off` 的实例开不了(会回 `gles32_no_bridge`),已经开着的仍可关。 - 页面选文件和装 APK 一样:``(可多选),每个文件追加一个 `file` 字段,用 `FormData` 发 `multipart/form-data`。 ### 更新 `UpdateView` diff --git a/internal/ui/backend.go b/internal/ui/backend.go index 397d078..01e6020 100644 --- a/internal/ui/backend.go +++ b/internal/ui/backend.go @@ -36,6 +36,9 @@ type backend interface { PlayStoreCheck(i int, action string) error // PlayStoreGSFID is `modavd playstore gsfid`: a few seconds on a running instance. PlayStoreGSFID(ctx context.Context, i int) (*emu.PlayStoreGSFIDResult, error) + // GLES32 is `modavd gles32 on|off|status`; GLES32Check what it refuses before touching the instance (no bridge ...). + GLES32(ctx context.Context, i int, action string) (*emu.GLES32Result, error) + GLES32Check(i int, action string) error // AdbRun is Manager.ADB.Run (the screenshot: exec-out screencap -p with Raw). AdbRun(ctx context.Context, opt adb.RunOptions, args ...string) adb.Result // Serial is instance i's adb serial in the root's port plan. diff --git a/internal/ui/gles32.go b/internal/ui/gles32.go new file mode 100644 index 0000000..c28bc61 --- /dev/null +++ b/internal/ui/gles32.go @@ -0,0 +1,43 @@ +package ui + +// The reported OpenGL ES version (ModAVD/ModAVD#131, experimental) in the manager: an instance's 「报告 GLES 3.2」 +// switch (emu GLES32). The switch's state shows on each instance (InstanceInfo.gles32). + +import ( + "context" + "fmt" + "net/http" +) + +// handleGLES32 is POST /api/instances/{i}/gles32-on and gles32-off: a job running emu's GLES32. What it refuses before +// touching the instance (no bridge, not there, being created) is answered at once, with errs.Reason in the details, +// so the page can say why. +func (s *server) handleGLES32(action string) http.HandlerFunc { + kind, doing, done := kindGLES32On, "正在开启报告 GLES 3.2(会冷启动一次)", "已开启报告 GLES 3.2" + if action == "off" { + kind, doing, done = kindGLES32Off, "正在关闭报告 GLES 3.2(会冷启动一次)", "已关闭报告 GLES 3.2" + } + return func(w http.ResponseWriter, r *http.Request) { + i, ok := pathIndex(w, r) + if !ok || !decode(w, r, &struct{}{}) { + return + } + if err := s.checkFree(kind, i); err != nil { + writeError(w, err) + return + } + if err := s.mgr.GLES32Check(i, action); err != nil { + writeError(w, err) + return + } + s.respondJob(w)(s.startJob(kind, i, func(ctx context.Context, j *job, b backend) (any, error) { + s.setProgress(j, jobProgress{Step: kind, Message: fmt.Sprintf("实例 %d %s", i, doing), Total: progressTotal}) + res, err := b.GLES32(ctx, i, action) + if err != nil { + return nil, err + } + s.setProgress(j, jobProgress{Step: "done", Message: fmt.Sprintf("实例 %d %s", i, done), Done: progressTotal, Total: progressTotal}) + return res, nil + })) + } +} diff --git a/internal/ui/gles32_test.go b/internal/ui/gles32_test.go new file mode 100644 index 0000000..ac87e09 --- /dev/null +++ b/internal/ui/gles32_test.go @@ -0,0 +1,102 @@ +//go:build windows + +package ui + +import ( + "context" + "net/http" + "slices" + "strconv" + "sync" + "testing" + + "modavd/internal/emu" + "modavd/internal/errs" +) + +// glFake is the switch's side of fakeBackend (its struct is shared with other tests): what was called and what the +// check refuses with. +var glFake struct { + mu sync.Mutex + calls []string // "check on 3", "run on 3" + checkErr error +} + +func resetGLFake(t *testing.T) { + t.Helper() + reset := func() { + glFake.mu.Lock() + glFake.calls, glFake.checkErr = nil, nil + glFake.mu.Unlock() + } + reset() + t.Cleanup(reset) +} + +func glCalls() []string { + glFake.mu.Lock() + defer glFake.mu.Unlock() + return slices.Clone(glFake.calls) +} + +func (b fakeBackend) GLES32Check(i int, action string) error { + glFake.mu.Lock() + defer glFake.mu.Unlock() + glFake.calls = append(glFake.calls, "check "+action+" "+strconv.Itoa(i)) + return glFake.checkErr +} + +func (b fakeBackend) GLES32(ctx context.Context, i int, action string) (*emu.GLES32Result, error) { + glFake.mu.Lock() + glFake.calls = append(glFake.calls, "run "+action+" "+strconv.Itoa(i)) + glFake.mu.Unlock() + prop := map[string]string{"on": "196610", "off": "196609"}[action] + return &emu.GLES32Result{Index: i, Action: action, State: action, Enabled: action == "on", Prop: prop, Restarted: true, + Notes: []string{}, Warnings: []string{}}, nil +} + +// TestGLES32InstanceJobs: gles32-on / gles32-off check first, then run as the instance's job (kind, index, result); +// what the check refuses is answered at once with its reason and nothing runs. +func TestGLES32InstanceJobs(t *testing.T) { + resetGLFake(t) + h := newHarness(t, nil) + for _, action := range []string{"on", "off"} { + var j jobView + expect(t, h.do("POST", "/api/instances/3/gles32-"+action, nil), http.StatusAccepted, &j) + if j.Kind != "gles32-"+action || j.Index != 3 { + t.Fatalf("job %+v", j) + } + done := h.waitJob(j.ID, stateIs(stateDone)) + res, _ := done.Result.(map[string]any) + if res["action"] != action || res["state"] != action || res["restarted"] != true { + t.Errorf("result %v", done.Result) + } + } + if want := []string{"check on 3", "run on 3", "check off 3", "run off 3"}; !slices.Equal(glCalls(), want) { + t.Errorf("calls %v, want %v", glCalls(), want) + } + + resetGLFake(t) + glFake.mu.Lock() + glFake.checkErr = errs.WithDetail(errs.New(errs.NotReady, "没有执行桥").WithAdvice("先装执行桥"), errs.Reason, emu.GLES32ReasonNoBridge) + glFake.mu.Unlock() + e := expectError(t, h.do("POST", "/api/instances/3/gles32-on", nil), http.StatusUnprocessableEntity, errs.NotReady) + if e.Details[errs.Reason] != emu.GLES32ReasonNoBridge || e.Advice != "先装执行桥" { + t.Errorf("refused: %+v", e) + } + if want := []string{"check on 3"}; !slices.Equal(glCalls(), want) { + t.Errorf("calls %v", glCalls()) + } + expectError(t, h.do("POST", "/api/instances/x/gles32-on", nil), http.StatusBadRequest, errs.InvalidArgument) + expectError(t, h.do("POST", "/api/instances/3/gles32-on", `{"force":true}`), http.StatusBadRequest, errs.Usage) +} + +// TestGLES32JobLabels: the jobs have their words in the activity list. +func TestGLES32JobLabels(t *testing.T) { + if kindLabel(kindGLES32On) != "开启报告 GLES 3.2" || kindLabel(kindGLES32Off) != "关闭报告 GLES 3.2" { + t.Error("labels") + } + if !bootJob(kindGLES32On) || !bootJob(kindGLES32Off) { + t.Error("the switch's jobs boot the instance (they must wait for the sdk jobs)") + } +} diff --git a/internal/ui/http.go b/internal/ui/http.go index f180f6a..4cd074b 100644 --- a/internal/ui/http.go +++ b/internal/ui/http.go @@ -40,6 +40,8 @@ func (s *server) routes() { m.HandleFunc("POST /api/instances/{i}/playstore-install", s.handlePlayStore("install")) m.HandleFunc("POST /api/instances/{i}/playstore-uninstall", s.handlePlayStore("uninstall")) m.HandleFunc("GET /api/instances/{i}/playstore-gsfid", s.handlePlayStoreGSFID) + m.HandleFunc("POST /api/instances/{i}/gles32-on", s.handleGLES32("on")) + m.HandleFunc("POST /api/instances/{i}/gles32-off", s.handleGLES32("off")) m.HandleFunc("POST /api/instances/{i}/input", s.handleInput) m.HandleFunc("GET /api/instances/{i}/screenshot", s.handleScreenshot) m.HandleFunc("GET /api/instances/{i}/log", s.handleLog) diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index 4e8d47d..e22a9fe 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -33,6 +33,10 @@ const ( kindPlayStoreUninstall = "playstore-uninstall" kindPlayStoreImport = "playstore-import" + // the reported GLES 3.2 (gles32.go): an instance's switch on and off + kindGLES32On = "gles32-on" + kindGLES32Off = "gles32-off" + stateRunning = "running" stateDone = "done" stateFailed = "failed" @@ -69,6 +73,10 @@ func kindLabel(kind string) string { return "移除 Google Play 商店" case kindPlayStoreImport: return "导入 Google Play 商店的文件" + case kindGLES32On: + return "开启报告 GLES 3.2" + case kindGLES32Off: + return "关闭报告 GLES 3.2" } return kind } @@ -79,7 +87,7 @@ func sdkJob(kind string) bool { return kind == kindComponents || kind == kindPla func bootJob(kind string) bool { switch kind { - case kindStart, kindCreate, "restart", kindPlayStoreInstall, kindPlayStoreUninstall: + case kindStart, kindCreate, "restart", kindPlayStoreInstall, kindPlayStoreUninstall, kindGLES32On, kindGLES32Off: return true } return false diff --git a/internal/ui/web/dev/mock.js b/internal/ui/web/dev/mock.js index a17c7b7..79fff5f 100644 --- a/internal/ui/web/dev/mock.js +++ b/internal/ui/web/dev/mock.js @@ -55,7 +55,7 @@ function mk(i, title, o = {}) { path: `${ROOT}\\avd\\mas_${i}.avd`, app_proxy: null, // the execution bridge (off | pending | installed | unknown) and the Google Play Store's record - bridge: o.bridge || (initialized ? 'installed' : 'pending'), play_store: !!o.store, play_store_partial: false, + bridge: o.bridge || (initialized ? 'installed' : 'pending'), play_store: !!o.store, play_store_partial: false, gles32: false, }; Object.defineProperty(inst, '_', { value: { scene: o.scene ?? i, mode: 'game', win: false, fail: o.fail || null, logs: initialized || st !== 'offline', tap: null }, enumerable: false }); db.insts.set(i, inst); @@ -312,6 +312,31 @@ function runImport(j, files) { }, 700); } +// 开启 / 关闭报告 GLES 3.2 on an instance: boots it cold once (an instance that was off is booted for it and stopped again). +function runGLES32(inst, j, action) { + const i = inst.index; + const was = inst.running; + const set = (o) => { Object.assign(inst, o); emit('instances', list()); }; + const off = () => { set({ status: 'offline', running: false, booted: false, busy: '', pid: 0, started_at: '' }); inst.screencap.shm_ready = false; }; + const boot = () => set({ status: 'starting', running: true, busy: 'starting', pid: 10000 + Math.floor(Math.random() * 9000), started_at: iso() }); + const up = () => { set({ status: 'online', booted: true, busy: '' }); inst.screencap.shm_ready = true; }; + const doing = action === 'on' ? '正在开启报告 GLES 3.2(会冷启动一次)' : '正在关闭报告 GLES 3.2(会冷启动一次)'; + j.progress = { step: j.kind, message: `实例 ${i} ${doing}`, done: 0, total: 1000 }; + emit('job', j); + log('info', `实例 ${i} ${doing}`, j.id); + const steps = []; + if (!was) steps.push([600, boot], [2500, up]); + steps.push([1200, () => log('info', `实例 ${i} ${action === 'on' ? '写入' : '去掉'} /vendor/build.prop 里的 ro.opengles.version`, j.id)], + [800, () => set({ busy: 'stopping' })], [1600, off], [700, boot], [2600, up]); + if (!was) steps.push([800, () => set({ busy: 'stopping' })], [1400, off]); + steps.push([600, () => { + inst.gles32 = action === 'on'; + finishJob(j, { index: i, action, state: action, enabled: action === 'on', prop: action === 'on' ? '196610' : '196609', restarted: true, stopped: !was, + notes: inst.play_store ? ['Play 商店会记着旧的设备信息;要它按新版本重新判断兼容,到 设置 → 应用 → Google Play 商店 → 存储,清除数据(账号不会退出)'] : [], warnings: [] }); + }]); + run(j, steps); +} + // 补全 / 移除 Google Play 商店 on an instance: boots it cold once (an instance that was off is booted for it and stopped // again); the card follows the instance through the stop and the boot. function runPlayStore(inst, j, action) { @@ -1024,6 +1049,16 @@ async function handle(url, init) { runPlayStore(inst, j, install ? 'install' : 'uninstall'); return json(j, 202); } + if (act === 'gles32-on' || act === 'gles32-off') { + const on = act === 'gles32-on'; + if (busyJob || inst.status === 'creating') return errResp('busy', `魔改 AVD 实例 ${i} 正在做别的事`, '等它做完再试'); + if (inst.bridge === 'off') { + return fail(STATUS_OF.not_ready, 'not_ready', `实例 ${i} 没有 arm64 执行桥,不按可写系统镜像开机,改不了它报的 GLES 版本`, `先运行 modavd bridge ${i} install 装上执行桥,再开这个开关`, { reason: 'gles32_no_bridge' }); + } + const j = newJob(act, i); + runGLES32(inst, j, on ? 'on' : 'off'); + return json(j, 202); + } if (act === 'playstore-gsfid') { if (inst.status !== 'online') return errResp('busy', `实例 ${i} 没有开机`, '开机后才能取 GSF ID'); if (busyJob) return errResp('busy', `魔改 AVD 实例 ${i} 正在做别的事`, '等它做完再试'); diff --git a/internal/ui/web/js/actions.js b/internal/ui/web/js/actions.js index f4c34de..2e57649 100644 --- a/internal/ui/web/js/actions.js +++ b/internal/ui/web/js/actions.js @@ -3,12 +3,12 @@ import { api } from './api.js'; import { S, update, instance } from './store.js'; import { toast, toastError, copyText } from './ui.js'; -import { quoted, dur, diskInfoText, diskTrimText, diskCompactText, playStoreText } from './format.js'; +import { quoted, dur, diskInfoText, diskTrimText, diskCompactText, playStoreText, gles32Text } from './format.js'; import { openActivity, runCheck } from './panels.js'; const VERB = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', 'disk-info': '查看磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘', components: '补全组件', 'collect-info': '收集问题信息', 'playstore-install': '补全 Google Play 商店', 'playstore-uninstall': '移除 Google Play 商店', - 'playstore-import': '导入 Google Play 商店的文件' }; + 'playstore-import': '导入 Google Play 商店的文件', 'gles32-on': '开启报告 GLES 3.2', 'gles32-off': '关闭报告 GLES 3.2' }; // what the store needs once it is in an instance: registering its GSF ID (playstore.js openGSFID, the page is // playstore.js UNCERTIFIED; not imported from there, as playstore.js imports this module) const GSF = '登录时提示设备未经 Play 保护机制认证的,在实例的「更多操作」里点「复制 GSF ID」,到 https://www.google.com/android/uncertified/ 登记。'; @@ -77,6 +77,10 @@ function notify(job) { } else if (job.kind === 'playstore-uninstall') { toast({ tone: r.state === 'absent' ? 'ok' : 'warn', title: r.state === 'absent' ? `${who}已移除 Google Play 商店` : `${who}的 Google Play 商店没移除干净`, text: playStoreText(job.kind, r), action: r.state === 'absent' ? null : activity, timeout: 6000 }); + } else if (job.kind === 'gles32-on' || job.kind === 'gles32-off') { + const done = job.kind === 'gles32-on' ? r.state === 'on' : r.state === 'off'; + toast({ tone: done ? 'ok' : 'warn', title: done ? `${who}${VERB[job.kind]}了` : `${who}的${VERB[job.kind]}没有生效`, + text: gles32Text(job.kind, r), action: done ? null : activity, timeout: (r.notes || []).length ? 14000 : 7000 }); } else if (job.kind === 'playstore-import') { toast({ title: 'Google Play 商店的文件已就绪', text: `${playStoreText(job.kind, r)}要用的实例在「更多操作」里补全商店。`, timeout: 9000 }); } @@ -126,6 +130,9 @@ export async function diskCompact(i) { // 补全 / 移除 Google Play 商店 (action install | uninstall): a job that boots the instance cold once (playstore.js asks first) export const playStore = (i, action, onErr) => run(i, 'playstore-' + action, {}, onErr); +// 开启 / 关闭报告 GLES 3.2 (action on | off): a job that boots the instance cold once (gles32.js asks first) +export const gles32 = (i, action, onErr) => run(i, 'gles32-' + action, {}, onErr); + export const cancelJob = safe(async (id) => onJob(await api.post(`/api/jobs/${encodeURIComponent(id)}/cancel`))); export const openFolder = safe((what, index) => api.post('/api/open', index == null ? { what } : { what, index })); export const input = safe((i, body) => api.post(`/api/instances/${i}/input`, body)); diff --git a/internal/ui/web/js/cards.js b/internal/ui/web/js/cards.js index 88ec1f0..92eb308 100644 --- a/internal/ui/web/js/cards.js +++ b/internal/ui/web/js/cards.js @@ -11,6 +11,7 @@ import { openProxy, markOf } from './proxy.js'; import { openViewer, openLogs } from './viewer.js'; import { openActivity } from './panels.js'; import { openPlayStore, openGSFID } from './playstore.js'; +import { openGLES32, GLES32_NOTE } from './gles32.js'; const cards = new Map(); let grid; @@ -88,9 +89,11 @@ export function viewState(inst) { const PILL = { online: ['运行中', 'ok'], starting: ['开机中', 'warn'], stopping: ['关机中', 'warn'], offline: ['已关机', 'idle'], error: ['开机失败', 'err'], creating: ['新建中', 'warn'], createfail: ['新建失败', 'err'] }; const FAIL = { start: '开机失败', stop: '关机失败', install: '安装失败', create: '新建失败', 'disk-info': '查看磁盘占用失败', 'disk-trim': '回收磁盘空间失败', 'disk-compact': '整理磁盘失败', - 'playstore-install': '补全 Google Play 商店失败', 'playstore-uninstall': '移除 Google Play 商店失败' }; + 'playstore-install': '补全 Google Play 商店失败', 'playstore-uninstall': '移除 Google Play 商店失败', + 'gles32-on': '开启报告 GLES 3.2 失败', 'gles32-off': '关闭报告 GLES 3.2 失败' }; // 补全 / 移除 Google Play 商店: stops and boots the instance cold; the card is busy with it the whole time -const STORE_JOB = { 'playstore-install': ['补全商店中', '正在补全 Google Play 商店…'], 'playstore-uninstall': ['移除商店中', '正在移除 Google Play 商店…'] }; +const STORE_JOB = { 'playstore-install': ['补全商店中', '正在补全 Google Play 商店…'], 'playstore-uninstall': ['移除商店中', '正在移除 Google Play 商店…'], + 'gles32-on': ['改版本中', '正在开启报告 GLES 3.2…'], 'gles32-off': ['改版本中', '正在关闭报告 GLES 3.2…'] }; function skeletonFor(v) { const sk = (cls) => h('div', { class: 'sk ' + cls }); @@ -309,6 +312,7 @@ function menuItems(c) { { sep: true }, { label: '安装 APK…', icon: 'download', disabled: inst.status !== 'online' || v.kind === 'install', run: () => pickApk(i) }, ...storeItems(inst, i, fresh || busy || !!v.kind), + glesItem(inst, i, fresh || busy || !!v.kind), // 磁盘占用 reads only (on or off); 回收 needs the guest booted; 整理 rewrites the data disk file, so the instance must be off. // Any job running on the instance blocks all three (the server would answer busy). { label: '磁盘占用', icon: 'info', disabled: !!v.kind, run: () => A.diskInfo(i) }, @@ -339,6 +343,16 @@ function storeItems(inst, i, held) { return inst.play_store_partial ? [add, remove] : [add]; } +// 报告 GLES 3.2(实验): InstanceInfo.gles32. Boots the instance cold once (gles32.js asks first); the line lives in the +// execution bridge's writable system overlay, so without the bridge it cannot be turned on. Turning it off stays +// possible whatever the bridge says. held: as for the store's items. +function glesItem(inst, i, held) { + const on = !!inst.gles32; + const noBridge = !on && inst.bridge === 'off'; + return { label: on ? '关闭报告 GLES 3.2(实验)…' : '开启报告 GLES 3.2(实验)…', icon: on ? 'x' : 'plus', disabled: held || noBridge, + hint: noBridge ? '要执行桥' : '只改报的版本,渲染仍是 3.1', title: GLES32_NOTE, run: () => openGLES32(i, on ? 'off' : 'on') }; +} + // ---- thumbnails: about every 5 s, only while the card view is on screen, the window has focus and no dialog covers // the grid (the first frame too). Every one is an `adb exec-out screencap`, so a failing card backs off up to 30 s and // a 409 (not booted after all) waits for the next instances event. diff --git a/internal/ui/web/js/format.js b/internal/ui/web/js/format.js index 81788c8..28143b8 100644 --- a/internal/ui/web/js/format.js +++ b/internal/ui/web/js/format.js @@ -1,9 +1,9 @@ // Formatting for people (zh-CN). export const KIND = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', components: '补全组件', 'collect-info': '收集问题信息', 'disk-info': '磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘', - 'playstore-install': '补全 Google Play 商店', 'playstore-uninstall': '移除 Google Play 商店', 'playstore-import': '导入 Google Play 商店的文件' }; + 'playstore-install': '补全 Google Play 商店', 'playstore-uninstall': '移除 Google Play 商店', 'playstore-import': '导入 Google Play 商店的文件', 'gles32-on': '开启报告 GLES 3.2', 'gles32-off': '关闭报告 GLES 3.2' }; export const KIND_ICON = { start: 'power', stop: 'stop', install: 'download', create: 'plus', components: 'download', 'collect-info': 'file', 'disk-info': 'disk', 'disk-trim': 'disk', 'disk-compact': 'disk', - 'playstore-install': 'plus', 'playstore-uninstall': 'x', 'playstore-import': 'download' }; + 'playstore-install': 'plus', 'playstore-uninstall': 'x', 'playstore-import': 'download', 'gles32-on': 'plus', 'gles32-off': 'x' }; export const STATE = { running: '进行中', done: '完成', failed: '失败', canceled: '已取消' }; export const STATE_TONE = { running: 'warn', done: 'ok', failed: 'err', canceled: 'idle' }; @@ -96,6 +96,17 @@ export function playStoreText(kind, r) { return (done ? '已移除商店,换回了系统原来的占位应用' : '没有移除干净,可以再移除一次') + (how ? `,${how}` : '') + '。'; } +// The GLES 3.2 jobs (API.md "报告 GLES 3.2"): GLES32Result of an instance's on / off (stopped: it was off and is off +// again; restarted: it was running and booted cold; prop: what the guest reports now). +export function gles32Text(kind, r) { + if (!r) return ''; + const on = kind === 'gles32-on'; + const done = on ? r.state === 'on' : r.state === 'off'; + const how = r.stopped ? '实例照原样关着' : r.restarted ? '实例冷启动过一次' : ''; + const base = on ? (done ? '实例现在报 GLES 3.2' : '还没有生效,可以再开一次') : (done ? '实例改回报 GLES 3.1' : '还没有改回来,可以再关一次'); + return base + (r.prop ? `(ro.opengles.version=${r.prop})` : '') + (how ? `,${how}` : '') + '。' + (done && (r.notes || []).length ? r.notes.join('') : ''); +} + export function uptime(started) { const t = parseTime(started); if (t == null) return ''; diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js index 5727aec..56ccf46 100644 --- a/internal/ui/web/js/forms.js +++ b/internal/ui/web/js/forms.js @@ -282,6 +282,7 @@ function infoRows(inst) { // the record in mas-avd.json (InstanceInfo.play_store, play_store_partial); the card's 「更多操作」 menu adds or removes it inst.play_store_partial ? ['Google Play 商店', '只装了一部分', '上次补全中途失败,没能全部撤回:在「更多操作」里移除,或者再补全一次'] : ['Google Play 商店', inst.play_store ? '已补全' : '没有', inst.play_store ? '' : '要用的话在实例的「更多操作」菜单里补全'], + ['报告 GLES 3.2', inst.gles32 ? '开(实验)' : '关', inst.gles32 ? '只改报的版本,实际渲染仍是 3.1;在「更多操作」里关闭' : '实验功能,要用的话在实例的「更多操作」菜单里开启'], ['路径', inst.path, 'mono', ibtn('folder', '打开实例目录', () => A.openFolder('instance', inst.index))], ]; } diff --git a/internal/ui/web/js/gles32.js b/internal/ui/web/js/gles32.js new file mode 100644 index 0000000..04664f5 --- /dev/null +++ b/internal/ui/web/js/gles32.js @@ -0,0 +1,45 @@ +// 开启 / 关闭「报告 GLES 3.2(实验)」 on one instance (API.md "报告 GLES 3.2"): asked first, since the job boots the +// instance cold once; then its card shows the progress and a toast the outcome. It only changes the OpenGL ES version +// the guest reports (so Google Play lets apps that list GLES 3.2 in); the rendering stays 3.1. + +import { h, icon } from './dom.js'; +import { instance } from './store.js'; +import { openDialog, toastError } from './ui.js'; +import { quoted } from './format.js'; +import * as A from './actions.js'; + +// what the switch does and does not do, shown on the menu item's hint and in the question +export const GLES32_NOTE = '实验功能:只改报告的版本,实际渲染仍是 3.1。走 Vulkan 的游戏能用;真要 GLES 3.2 渲染的应用会装得上、打不开。'; + +const asking = new Set(); // instances with the question on screen + +// action: on | off +export function openGLES32(i, action) { + if (!instance(i) || asking.has(i)) return; + const on = action === 'on'; + const inst = instance(i); + const who = quoted(inst, i); + const desc = 'gl-desc-' + i; + const how = inst.running + ? '它正开着:会先关机,做完按原来的方式开回来。' + : '它现在关着:会在后台开机,做完再关上。'; + const ok = h('button', { class: 'btn btn-primary', type: 'submit' }, icon(on ? 'plus' : 'x'), h('span', { text: on ? '开启' : '关闭' })); + asking.add(i); + const ctl = openDialog({ + title: on ? '开启「报告 GLES 3.2」?' : '关闭「报告 GLES 3.2」?', cls: 'dlg-store', form: true, desc, + body: h('div', { class: 'dlg-body' }, + h('p', { id: desc, class: 'store-q' }, on + ? ['让', h('strong', { text: who }), '把 OpenGL ES 版本报成 3.2(原来是 3.1),Google Play 就不会再把要求 GLES 3.2 的游戏判成不兼容。'] + : ['让', h('strong', { text: who }), '把 OpenGL ES 版本改回报 3.1。']), + h('div', { class: 'note-box tone-warn' }, icon('alert'), h('span', { text: GLES32_NOTE })), + inst.play_store && h('div', { class: 'note-box tone-info' }, icon('info'), + h('span', { text: 'Play 商店会记着旧的设备信息:改完后到实例里 设置 → 应用 → Google Play 商店 → 存储,清除数据(账号不会退出),它才会按新版本重新判断兼容。' })), + h('div', { class: 'note-box tone-info' }, icon('info'), h('span', { text: `要冷启动一次,几分钟,期间这台实例不能用。${how}` }))), + actions: { ok }, + onSubmit() { + ctl.close(); + A.gles32(i, action, toastError); + }, + }); + ctl.onClose.push(() => asking.delete(i)); +} diff --git a/internal/ui/web/js/panels.js b/internal/ui/web/js/panels.js index af3f553..dee0490 100644 --- a/internal/ui/web/js/panels.js +++ b/internal/ui/web/js/panels.js @@ -5,7 +5,7 @@ import { api } from './api.js'; import { S, update, subscribe, instance, jobsSorted, runningCount } from './store.js'; import { openDialog } from './ui.js'; import { openComponentFetch, activeJob, latestJob, progressText, loadDiagnostics, storeBox, fillStoreBox } from './diagnostics.js'; -import { KIND, KIND_ICON, STATE, STATE_TONE, clock, dur, label, parseTime, pct, stripInst, stopMethod, diskInfoText, diskTrimText, diskCompactText, playStoreText } from './format.js'; +import { KIND, KIND_ICON, STATE, STATE_TONE, clock, dur, label, parseTime, pct, stripInst, stopMethod, diskInfoText, diskTrimText, diskCompactText, playStoreText, gles32Text } from './format.js'; import * as A from './actions.js'; const advice = (text) => h('div', { class: 'advice' }, icon('bulb'), h('span', { text })); @@ -347,10 +347,11 @@ function updateJob(r, j) { if (running) { const p = j.progress; // 补全 / 移除 Google Play 商店 report no percentage while they run (done 0 of 1000) - const n = j.kind === 'playstore-install' || j.kind === 'playstore-uninstall' ? null : pct(p); + const n = j.kind === 'playstore-install' || j.kind === 'playstore-uninstall' || j.kind.startsWith('gles32-') ? null : pct(p); setText(r.msg, (p && stripInst(p.message)) || (j.kind === 'stop' ? '正在关机…' : j.kind === 'install' ? '正在安装…' : j.kind === 'disk-info' ? '正在查看磁盘占用…' : j.kind === 'disk-trim' ? '正在触发磁盘回收…' : j.kind === 'disk-compact' ? '正在整理磁盘…' : j.kind === 'playstore-install' ? '正在补全 Google Play 商店…' : j.kind === 'playstore-uninstall' ? '正在移除 Google Play 商店…' + : j.kind === 'gles32-on' ? '正在开启报告 GLES 3.2…' : j.kind === 'gles32-off' ? '正在关闭报告 GLES 3.2…' : j.kind === 'playstore-import' ? '正在核对文件…' : '进行中…')); setText(r.pctEl, n == null ? '' : Math.round(n) + '%'); r.bar.classList.toggle('indet', n == null); @@ -408,6 +409,7 @@ function resultText(j) { if (j.kind === 'components') return '组件补全完成。'; if (j.kind === 'collect-info') return r.zip_path ? `问题包:${r.zip_path}` : '问题包已生成。'; if (j.kind.startsWith('playstore-')) return playStoreText(j.kind, r); + if (j.kind.startsWith('gles32-')) return gles32Text(j.kind, r); if (!r.launched) return '模拟器本来就在运行,只重做了开机后的设置。'; return [`安卓开机 ${dur(r.boot_seconds || 0)},共 ${dur(r.total_seconds || 0)}`, r.first_boot && '完成了首次初始化', r.zygote_restarted && '装了 Vulkan 补丁(安卓框架重启过一次)'].filter(Boolean).join(',') + '。'; diff --git a/scripts/test_create_cancel.mjs b/scripts/test_create_cancel.mjs index 240abef..cf26b9a 100644 --- a/scripts/test_create_cancel.mjs +++ b/scripts/test_create_cancel.mjs @@ -17,13 +17,14 @@ async function harness() { return { id: 'j2', kind: 'stop', state: 'running', index: 3 }; } } }, 'dom.js': Object.fromEntries(['h', 'icon', 'btn', 'ibtn', 'keyed', 'setText', 'attr'].map(k => [k, noop])), - 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText'].map(k => [k, noop])), + 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText', 'gles32Text'].map(k => [k, noop])), 'ui.js': { toast: noop, toastError: e => { throw e; }, copyText: noop, openMenu: noop }, 'forms.js': { openSettings: noop, openDelete: noop }, 'proxy.js': { openProxy: noop, markOf: noop }, 'viewer.js': { openViewer: noop, openLogs: noop }, 'panels.js': { openActivity: noop, runCheck: noop }, 'playstore.js': { openPlayStore: noop, openGSFID: noop }, + 'gles32.js': { openGLES32: noop, GLES32_NOTE: '' }, }; async function load(name) { if (cache.has(name)) return cache.get(name); @@ -179,6 +180,7 @@ async function settingsHarness({ diagnostics = true, running = true, get, patch 'viewer.js': { openViewer: noop, openLogs: noop }, 'panels.js': { openActivity: noop }, 'playstore.js': { openPlayStore: noop, openGSFID: noop }, + 'gles32.js': { openGLES32: noop, GLES32_NOTE: '' }, }; async function load(name) { if (cache.has(name)) return cache.get(name); diff --git a/scripts/test_gles32_ui.mjs b/scripts/test_gles32_ui.mjs new file mode 100644 index 0000000..82ae872 --- /dev/null +++ b/scripts/test_gles32_ui.mjs @@ -0,0 +1,280 @@ +// Run: node --experimental-vm-modules --test scripts/test_gles32_ui.mjs +// 报告 GLES 3.2(实验)in the manager's page (ModAVD/ModAVD#131): the instance menu's item, the question before the job, +// the job's toast and texts. Runs the page's own modules, stubbing only their browser / HTTP neighbours. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import vm from 'node:vm'; + +const base = new URL('../internal/ui/web/js/', import.meta.url); +const noop = () => {}; +const turn = () => new Promise(resolve => setImmediate(resolve)); +// values made inside the page's realm compared by their content +const plain = x => JSON.parse(JSON.stringify(x)); +const turns = async (n = 6) => { for (let k = 0; k < n; k++) await turn(); }; + +// A small DOM: what h(), the dialogs and the form code touch. +class Element { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.childNodes = []; + this.attrs = new Map(); + this.events = new Map(); + this.dataset = {}; + this.style = {}; + this.checked = false; + this.disabled = false; + this.hidden = false; + this.value = ''; + this.className = ''; + this.classList = { + toggle: (name, on) => { + const names = new Set(this.className.split(' ').filter(Boolean)); + if (on) names.add(name); else names.delete(name); + this.className = [...names].join(' '); + }, + add: name => this.classList.toggle(name, true), + remove: name => this.classList.toggle(name, false), + }; + } + append(...kids) { + for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false && kid !== '') this.childNodes.push(kid); + } + replaceChildren(...kids) { this.childNodes = []; this.append(...kids); } + get textContent() { return this.childNodes.map(x => x instanceof Element ? x.textContent : String(x)).join(''); } + set textContent(text) { this.childNodes = [String(text)]; } + setAttribute(key, value) { + this.attrs.set(key, String(value)); + if (['id', 'name', 'type', 'class'].includes(key)) this[key === 'class' ? 'className' : key] = String(value); + } + getAttribute(key) { return this.attrs.get(key) ?? null; } + hasAttribute(key) { return this.attrs.has(key); } + removeAttribute(key) { this.attrs.delete(key); } + addEventListener(type, fn) { this.events.set(type, [...(this.events.get(type) || []), fn]); } + dispatch(type) { return Promise.all((this.events.get(type) || []).map(fn => fn())); } + querySelector(selector) { return all(this).find(x => selector.startsWith('#') ? x.id === selector.slice(1) : x.tagName.toLowerCase() === selector) || null; } + focus() {} + click() { this.clicked = (this.clicked || 0) + 1; } +} +const all = root => root.childNodes.flatMap(x => x instanceof Element ? [x, ...all(x)] : []); +const byText = (root, text) => all(root).find(x => x.tagName === 'BUTTON' && x.textContent.includes(text)); + +function h(tag, props, ...kids) { + const el = new Element(tag); + for (const [key, value] of Object.entries(props || {})) { + if (key === 'checked') el.checked = !!value; + else if (value == null || value === false) continue; + else if (key === 'text') el.textContent = value; + else if (key === 'value') el.value = value; + else if (key === 'hidden') el.hidden = value; + else if (key.startsWith('on') && typeof value === 'function') el.addEventListener(key.slice(2), value); + else el.setAttribute(key, value === true ? '' : value); + } + el.append(...kids); + return el; +} +const dom = { + h, icon: () => h('svg'), btn: (cls, kids, fn, more) => h('button', { class: cls, onclick: fn, ...more }, kids), + ibtn: (ic, text, fn) => h('button', { onclick: fn, 'aria-label': text }), keyed: noop, add: noop, hydrateIcons: noop, + attr: (el, key, value) => value == null || value === false ? el.removeAttribute(key) : el.setAttribute(key, value), + setText: (el, text) => { el.textContent = text == null ? '' : text; }, + showError: (el, e) => { el.textContent = e.message + (e.advice ? ' / ' + e.advice : ''); el.hidden = false; }, +}; + +// load(entry, stubs): the page module entry with every module named in stubs replaced (the others are the real ones). +async function load(entry, stubs, context = {}) { + const ctx = vm.createContext({ requestAnimationFrame: noop, setTimeout, clearTimeout, FormData, File, URLSearchParams, console, ...context }); + const cache = new Map(); + const sources = new Map(); // name -> the file's text (a promise), read once + async function get(name) { + if (cache.has(name)) return cache.get(name); + const values = typeof stubs[name] === 'function' ? stubs[name](cache) : stubs[name]; + const extra = name === 'cards.js' ? '\nexport { menuItems };' : ''; + let text = ''; + if (!values) { + if (!sources.has(name)) sources.set(name, readFile(new URL(name, base), 'utf8')); + text = await sources.get(name); + // two modules importing this one while its file was read: one instance (the page's S is shared that way) + if (cache.has(name)) return cache.get(name); + } + const mod = values + ? new vm.SyntheticModule(Object.keys(values), function() { + for (const [k, v] of Object.entries(values)) this.setExport(k, v); + }, { context: ctx }) + : new vm.SourceTextModule(text + extra, { context: ctx }); + cache.set(name, mod); + await mod.link(specifier => get(specifier.replace(/^\.\//, ''))); + return mod; + } + const mod = await get(entry); + await mod.evaluate(); + const ns = name => cache.get(name)?.namespace; + return { mod: mod.namespace, ns, S: ns('store.js')?.S }; +} + + +// ---- the instance menu (cards.js) + +async function menuFor(inst, jobs = []) { + const calls = []; + const m = await load('cards.js', { + 'api.js': { api: {} }, + 'dom.js': dom, + 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText', 'gles32Text'].map(k => [k, noop])), + 'ui.js': { toast: noop, toastError: noop, copyText: noop, openMenu: noop }, + 'forms.js': { openSettings: noop, openDelete: noop }, + 'proxy.js': { openProxy: noop, markOf: noop }, + 'viewer.js': { openViewer: noop, openLogs: noop }, + 'panels.js': { openActivity: noop, runCheck: noop }, + 'playstore.js': { openPlayStore: noop, openGSFID: noop }, + 'gles32.js': { openGLES32: (i, action) => calls.push([i, action]), GLES32_NOTE: '只改报告的版本' }, + }); + for (const j of jobs) m.S.jobs.set(j.id, j); + const items = m.mod.menuItems({ i: 3, inst: { index: 3, status: 'offline', running: false, initialized: true, bridge: 'installed', ...inst } }).filter(Boolean); + return { items, calls, gl: items.find(x => /GLES 3\.2/.test(x.label || '')) }; +} + +test('an instance with the switch off offers 开启, which asks gles32.js to turn it on; it sits after the store items', async () => { + const { items, gl, calls } = await menuFor({}); + assert.equal(items.filter(x => /GLES 3\.2/.test(x.label || '')).length, 1); + assert.equal(gl.label, '开启报告 GLES 3.2(实验)…'); + assert.ok(!gl.disabled); + assert.match(gl.hint, /渲染仍是 3\.1/); + assert.ok(items.indexOf(gl) > items.findIndex(x => /Google Play 商店/.test(x.label || ''))); + gl.run(); + assert.deepEqual(calls, [[3, 'on']]); +}); + +test('an instance with the switch on offers 关闭 instead', async () => { + const { gl, calls } = await menuFor({ gles32: true, status: 'online', running: true }); + assert.equal(gl.label, '关闭报告 GLES 3.2(实验)…'); + assert.ok(!gl.disabled); + gl.run(); + assert.deepEqual(calls, [[3, 'off']]); +}); + +test('without the execution bridge 开启 is off and says why; 关闭 still works', async () => { + const off = await menuFor({ bridge: 'off' }); + assert.ok(off.gl.disabled); + assert.equal(off.gl.hint, '要执行桥'); + const had = await menuFor({ bridge: 'off', gles32: true }); + assert.ok(!had.gl.disabled); +}); + +test('the item waits while the instance is being created, set up or busy with a job', async () => { + for (const [inst, jobs] of [[{ status: 'creating', running: true }, []], [{ status: 'create_failed' }, []], + [{}, [{ id: 'j9', kind: 'disk-compact', index: 3, state: 'running' }]], + [{ gles32: true }, [{ id: 'j8', kind: 'gles32-off', index: 3, state: 'running' }]]]) { + const { gl } = await menuFor(inst, jobs); + assert.ok(gl.disabled, JSON.stringify(inst)); + } +}); + +// ---- the question (gles32.js) + +async function dialog() { + const r = { dialogs: [], errors: [], posts: [] }; + const m = await load('gles32.js', { + 'dom.js': dom, + 'ui.js': { + openDialog: o => { + const ctl = { options: o, onClose: [], closed: false, close() { if (!this.closed) { this.closed = true; for (const fn of this.onClose) fn(); } } }; + r.dialogs.push(ctl); + return ctl; + }, + toastError: e => r.errors.push(e), + }, + 'actions.js': { gles32: (i, action, onErr) => r.posts.push({ i, action, onErr }) }, + }); + m.S.list = { instances: [{ index: 3, name: 'mas_3', title: '明日方舟', status: 'online', running: true }, { index: 4, name: 'mas_4', title: '星铁', status: 'offline', running: false, play_store: true }] }; + return Object.assign(r, { open: m.mod.openGLES32, S: m.S }); +} + +test('开启 asks first, says it is an experiment and boots cold, and only then starts the job', async () => { + const p = await dialog(); + p.open(3, 'on'); + const d = p.dialogs[0]; + assert.equal(d.options.title, '开启「报告 GLES 3.2」?'); + const text = d.options.body.textContent; + assert.match(text, /实验功能/); + assert.match(text, /实际渲染仍是 3\.1/); + assert.match(text, /冷启动一次/); + assert.match(text, /会先关机/); // it is running + assert.ok(!/清除数据/.test(text)); // no store on it + assert.equal(p.posts.length, 0); + p.open(3, 'on'); // a second click while it asks opens nothing more + assert.equal(p.dialogs.length, 1); + d.options.onSubmit(); + assert.equal(d.closed, true); + assert.equal(p.posts.length, 1); + assert.deepEqual(plain({ i: p.posts[0].i, action: p.posts[0].action }), { i: 3, action: 'on' }); + p.open(3, 'on'); // asking again is possible once it closed + assert.equal(p.dialogs.length, 2); +}); + +test('an instance with the Play Store is told to clear the store\'s data; a closed one is booted in the background', async () => { + const p = await dialog(); + p.open(4, 'on'); + const text = p.dialogs[0].options.body.textContent; + assert.match(text, /Google Play 商店 → 存储,清除数据/); + assert.match(text, /账号不会退出/); + assert.match(text, /后台开机/); +}); + +test('关闭 asks with its own words; a refusal of the server is a toast', async () => { + const p = await dialog(); + p.open(3, 'off'); + const d = p.dialogs[0]; + assert.equal(d.options.title, '关闭「报告 GLES 3.2」?'); + d.options.onSubmit(); + assert.equal(p.posts[0].action, 'off'); + const e = { code: 'not_ready', message: '没有执行桥', details: { reason: 'gles32_no_bridge' } }; + p.posts[0].onErr(e); + assert.deepEqual(p.errors, [e]); +}); + +// ---- the outcome (actions.js, format.js) + +async function actions() { + const r = { toasts: [], posts: [] }; + const m = await load('actions.js', { + 'api.js': { api: { post: async (path, body) => { r.posts.push({ path, body }); return { id: 'j1', kind: 'gles32-on', state: 'running', index: 3 }; }, get: async () => ({}) } }, + 'ui.js': { toast: t => r.toasts.push(t), toastError: e => r.toasts.push({ error: e }), copyText: noop }, + 'panels.js': { openActivity: noop, runCheck: noop }, + }); + m.S.list = { instances: [{ index: 3, name: 'mas_3', title: '明日方舟', status: 'online', running: true }] }; + return Object.assign(r, { mod: m.mod, S: m.S }); +} + +test('gles32 posts to gles32-on / gles32-off as an instance job', async () => { + const a = await actions(); + await a.mod.gles32(3, 'on'); + await a.mod.gles32(3, 'off'); + assert.deepEqual(plain(a.posts.map(x => x.path)), ['/api/instances/3/gles32-on', '/api/instances/3/gles32-off']); +}); + +test('a finished 开启 says what the guest reports, and the store note when there is one', async () => { + const a = await actions(); + const note = 'Play 商店会记着旧的设备信息;要它按新版本重新判断兼容,到 设置 → 应用 → Google Play 商店 → 存储,清除数据(账号不会退出)'; + a.mod.notifyJob({ id: 'j1', kind: 'gles32-on', index: 3, state: 'done', result: { state: 'on', enabled: true, prop: '196610', restarted: true, stopped: false, notes: [note] } }); + const t = a.toasts[0]; + assert.equal(t.tone, 'ok'); + assert.match(t.title, /开启报告 GLES 3\.2了/); + assert.match(t.text, /ro\.opengles\.version=196610/); + assert.match(t.text, /冷启动过一次/); + assert.match(t.text, /清除数据/); +}); + +test('a 开启 that did not take is a warning with the activity link; a failed job names its kind', async () => { + const a = await actions(); + a.mod.notifyJob({ id: 'j2', kind: 'gles32-on', index: 3, state: 'done', result: { state: 'pending-on', prop: '196609', stopped: true, notes: [] } }); + assert.equal(a.toasts[0].tone, 'warn'); + assert.match(a.toasts[0].text, /还没有生效/); + assert.ok(a.toasts[0].action); + a.mod.notifyJob({ id: 'j3', kind: 'gles32-off', index: 3, state: 'failed', error: { message: '冷启动后对不上', advice: '再看一次' } }); + assert.equal(a.toasts[1].tone, 'err'); + assert.match(a.toasts[1].title, /关闭报告 GLES 3\.2没有成功/); + a.mod.notifyJob({ id: 'j4', kind: 'gles32-off', index: 3, state: 'done', result: { state: 'off', prop: '196609', restarted: true, notes: [] } }); + assert.equal(a.toasts[2].tone, 'ok'); + assert.match(a.toasts[2].text, /改回报 GLES 3\.1/); +}); diff --git a/scripts/test_playstore_ui.mjs b/scripts/test_playstore_ui.mjs index c6e08d1..2a7b768 100644 --- a/scripts/test_playstore_ui.mjs +++ b/scripts/test_playstore_ui.mjs @@ -120,13 +120,14 @@ async function menuFor(inst, jobs = []) { const m = await load('cards.js', { 'api.js': { api: {} }, 'dom.js': dom, - 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText'].map(k => [k, noop])), + 'format.js': Object.fromEntries(['label', 'uptime', 'stripInst', 'pct', 'cfgText', 'quoted', 'dur', 'diskInfoText', 'diskTrimText', 'diskCompactText', 'playStoreText', 'gles32Text'].map(k => [k, noop])), 'ui.js': { toast: noop, toastError: noop, copyText: noop, openMenu: noop }, 'forms.js': { openSettings: noop, openDelete: noop }, 'proxy.js': { openProxy: noop, markOf: noop }, 'viewer.js': { openViewer: noop, openLogs: noop }, 'panels.js': { openActivity: noop, runCheck: noop }, 'playstore.js': { openPlayStore: (i, action) => calls.push([i, action]), openGSFID: i => calls.push([i, 'gsfid']) }, + 'gles32.js': { openGLES32: noop, GLES32_NOTE: '' }, }); for (const j of jobs) m.S.jobs.set(j.id, j); const items = m.mod.menuItems({ i: 3, inst: { index: 3, status: 'offline', running: false, initialized: true, bridge: 'installed', ...inst } }).filter(Boolean);