From fdb56418a3abda8b6fed1334b9d2da29d97f5ee7 Mon Sep 17 00:00:00 2001 From: qiyinxi <52456734+qiyinxi@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:54:26 +0000 Subject: [PATCH 01/17] =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=95=B0=E6=8D=AE=E7=9B=98=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E5=8D=A0=E7=9A=84=E7=94=B5=E8=84=91=E7=A1=AC=E7=9B=98=20(#140)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规格行末尾加「占 12.3 GB」:数据盘文件在电脑上现在实际占的大小(data_space.host_bytes,只算写进去的),读不到就不写。 --- .github/workflows/ci.yml | 2 +- internal/ui/web/js/cards.js | 4 ++-- internal/ui/web/js/format.js | 10 ++++++---- scripts/test_card_cfg.mjs | 20 ++++++++++++++++++++ 4 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 scripts/test_card_cfg.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fdb188..ae97a9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - name: display coordinate tests run: node --test scripts/test_display_tap.mjs - name: disk UI tests - run: node --test scripts/test_disk_info.mjs scripts/test_compact_format.mjs + run: node --test scripts/test_disk_info.mjs scripts/test_compact_format.mjs scripts/test_card_cfg.mjs - name: gofmt shell: pwsh run: | diff --git a/internal/ui/web/js/cards.js b/internal/ui/web/js/cards.js index 141f46e..ce41bc0 100644 --- a/internal/ui/web/js/cards.js +++ b/internal/ui/web/js/cards.js @@ -143,7 +143,7 @@ function makeCard(i) { c.pmark = btn('pmark', [icon('shield'), h('span', { text: '代理' })], () => openProxy(i), { hidden: true }); c.sub = h('div', { class: 'card-sub' }); c.lmsg = h('div', { class: 'card-sub card-lmsg' }); - c.cfg = h('div', { class: 'card-cfg', title: '内存 · CPU 核数 · 帧率 · 数据盘(自动扩盘或指定大小,都按实际写入占电脑硬盘)' }); + c.cfg = h('div', { class: 'card-cfg', title: '内存 · CPU 核数 · 帧率 · 数据盘(指定大小时)· 数据盘现在实际占的电脑硬盘' }); c.pillText = h('span'); c.pill = h('span', { class: 'pill' }, c.pillText); c.adb = h('span', { class: 'adb-addr' }); @@ -192,7 +192,7 @@ function updateCard(c, inst) { const headless = inst.running && inst.window === false && v.key !== 'creating'; setText(c.sub, subtitle + (headless ? ' · 无头' : '') + (inst.diagnostics ? ' · 诊断模式' : '')); c.sub.title = headless ? '命令行开的,没有窗口,可以在这里关机' : ''; - setText(c.cfg, cfgText(inst.config)); + setText(c.cfg, cfgText(inst.config, inst.data_space)); let [pt, tone] = PILL[v.key]; // create's setup: our own job's progress, else what the process doing it (CLI, AUTO-MAS) left in the instance diff --git a/internal/ui/web/js/format.js b/internal/ui/web/js/format.js index 76f2a50..200dc2a 100644 --- a/internal/ui/web/js/format.js +++ b/internal/ui/web/js/format.js @@ -153,10 +153,12 @@ export function stopMethod(m) { return (STOP_METHOD[base] || `已关机(${base})`) + (term ? ',最后强行结束了模拟器进程' : ''); } -// 「内存 6 GB · 6 核 · 60 Hz · 盘 64 GB」, and without the disk for 自动扩盘 (the default, nothing to tell): what each -// number is, without a hover -export function cfgText(cfg) { +// 「内存 6 GB · 6 核 · 60 Hz · 盘 64 GB · 占 12.3 GB」: what each number is, without a hover. The disk size only for a +// set size (自动扩盘 is the default, nothing to tell); 占 is what the data disk takes on the computer now +// (space.host_bytes, only what was written), left out when unknown. +export function cfgText(cfg, space) { cfg = cfg || {}; const disk = cfg.data_disk_mode === 'manual' ? ` · 盘 ${cfg.data_partition_gb || '—'} GB` : ''; - return `内存 ${fmtMem(cfg.memory_mb)} · ${cfg.cpu || '—'} 核 · ${cfg.fps || '—'} Hz${disk}`; + const used = space && space.host_bytes >= 0 ? ` · 占 ${fmtBytes(space.host_bytes)}` : ''; + return `内存 ${fmtMem(cfg.memory_mb)} · ${cfg.cpu || '—'} 核 · ${cfg.fps || '—'} Hz${disk}${used}`; } diff --git a/scripts/test_card_cfg.mjs b/scripts/test_card_cfg.mjs new file mode 100644 index 0000000..88eb159 --- /dev/null +++ b/scripts/test_card_cfg.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const source = await readFile(new URL('../internal/ui/web/js/format.js', import.meta.url), 'utf8'); +const { cfgText } = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + +const cfg = { memory_mb: 4096, cpu: 4, fps: 30, data_disk_mode: 'auto', data_partition_gb: 256 }; + +test('the card shows what the data disk takes on the computer', () => { + assert.equal(cfgText(cfg, { host_bytes: 12.34 * 2 ** 30 }), '内存 4 GB · 4 核 · 30 Hz · 占 12.3 GB'); + assert.equal(cfgText({ ...cfg, data_disk_mode: 'manual', data_partition_gb: 64 }, { host_bytes: 512 * 2 ** 20 }), + '内存 4 GB · 4 核 · 30 Hz · 盘 64 GB · 占 512 MB'); +}); + +test('an unknown size is left out, not shown as a dash', () => { + assert.equal(cfgText(cfg, { host_bytes: -1 }), '内存 4 GB · 4 核 · 30 Hz'); + assert.equal(cfgText(cfg, null), '内存 4 GB · 4 核 · 30 Hz'); + assert.equal(cfgText(cfg), '内存 4 GB · 4 核 · 30 Hz'); +}); From 35e362c0f83936b5035e2f38a9bf34af0d312742 Mon Sep 17 00:00:00 2001 From: qiyinxi <52456734+qiyinxi@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:37:42 +0100 Subject: [PATCH 02/17] =?UTF-8?q?=E5=8F=91=E7=89=88=EF=BC=9AModAVDManager?= =?UTF-8?q?=20v0.9.0=EF=BC=88=E9=9A=8F=20ModAVD=200.40.1=EF=BC=89=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 没起名的实例显示「实例 N」;版本号和模拟器构建号分开 (#135) * 没起名的实例显示「实例 N」;版本号不再要求中间那位等于 mas-N - 界面:没起名的实例显示「实例 N」,新建和设置里的提示跟着改;卡片副标题照旧带文件名 mas_N。命令行 list 的名称列没起名时写「—」。盘上的 mas_N 不动。 - 版本:0.40 以后公开版本号单独往上加,模拟器构建号只写在 modavd-manifest.json 的 "build" 里。 - 认本地版本:清单的 build 等于 Pkg.BuildId 就用清单的版本;没有 build 的老清单照旧按中间那位核对;对不上时只有 mas-40 及以前才退回 0..0。 - 装包:清单的 build 只要求是正整数;核对模拟器时拿 Pkg.BuildId 和清单的 build 比(老包没有 build 就和中间那位比)。macOS 原生包同理。 * CI:decouple_test 只在 Windows 编、gofmt;没起名的卡片副标题照旧写编号 * 界面测试的 format.js 替身补上 ownTitle * 电脑检查去掉「检查对象」下拉框 (#136) #118 以后电脑检查不再查内存,所有实例结果都一样。删掉页面上的检查对象下拉框和相关提示, GET /api/check 去掉 index 参数和 instance_index、memory_mb、root_ready,缓存不再按内存区分, 删掉只为跳过 memory 项的 CheckResult.RootReady()。 * 模拟器内存写进开机守护日志,问题包加模拟器和占内存最多的程序 (#138) * 开机守护每 10 分钟把模拟器内存写进日志,问题包 summary 加模拟器进程的内存 * 问题包 summary 加「占内存最多的程序」一节 * 更新:根目录文件被别的程序占着时,换包前就停 (#137) MAA 会载入根目录的 extras\modavd_extras.dll,ModAVD 包里还要带上 mumu-shim\shell\sdk\external_renderer_ipc.dll(MAA 的 MuMu 路径填 <根目录>\mumu-shim 时载入)。以前要换的文件被载入着,要到换完 sdk\emulator 以后替换它时才失败,根目录停在换了一半的状态,当场恢复也一样失败,直到关掉 MAA 才能收尾。 现在换包前(备份之后、写「换上中」状态之前)把要替换和要删的根目录文件 逐个试着以写方式打开,被占用(共享冲突、锁冲突)就停:什么都没动,暂存的 新版留着,提示是哪个文件、先关掉 MAA 之类载入它的程序再装。正在跑更新的 程序自己不算(它照旧先挪进回收目录)。 只读、不存在的文件不算占用;macOS、Linux 上载入的库不挡替换,不做这项检查。 Refs ModAVD/ModAVDExtras#12 * 没起名的实例默认显示 emu-N;卡片规格行不再写「盘自动扩」 (#139) - 没起名的实例在界面显示 emu-N(原「实例 N」),新建和设置里的提示、占位跟着改;命令行 list 的名称列没起名时也写 emu-N。盘上的 mas_N 照旧不动。 - 卡片的规格行:自动扩盘是默认,不再写「盘自动扩」;指定大小的照旧写「盘 N GB」。 * 版本号改为 0.9.0 go generate ./cmd/modavd-gui 重新生成两份 syso,exe 文件属性里的版本号跟着变。 * 实例卡片显示数据盘实际占的电脑硬盘 (#140) 规格行末尾加「占 12.3 GB」:数据盘文件在电脑上现在实际占的大小(data_space.host_bytes,只算写进去的),读不到就不写。 * ModAVDManager v0.9.0 的说明和 SHA256SUMS 挑进 #140(实例卡片显示数据盘实际占的电脑硬盘)。 * 说明:随 ModAVD 0.40.1 进包(0.8.0 的更新只认版本中间那位等于构建号,这一版用 0.40.1) --- .github/workflows/ci.yml | 2 +- cmd/modavd-gui/rsrc_windows_amd64.syso | Bin 73388 -> 73388 bytes internal/buildinfo/buildinfo.go | 2 +- internal/cli/format.go | 4 +- internal/cli/format_test.go | 2 +- internal/emu/bootwatch.go | 40 +++++++ internal/emu/bootwatch_memory_test.go | 76 +++++++++++++ internal/emu/check.go | 14 --- internal/host/memory.go | 16 +++ internal/host/unsupported_other.go | 6 ++ internal/host/windows.go | 12 +++ internal/report/collect.go | 63 +++++++++++ internal/report/host.go | 3 + internal/report/host_windows.go | 3 + internal/report/report_test.go | 45 ++++++++ internal/root/root.go | 56 +++++++--- internal/root/root_test.go | 29 +++++ internal/ui/API.en.md | 4 +- internal/ui/API.md | 4 +- internal/ui/handlers.go | 41 ++----- internal/ui/ui.go | 1 - internal/ui/web/js/app.js | 3 +- internal/ui/web/js/cards.js | 8 +- internal/ui/web/js/format.js | 28 +++-- internal/ui/web/js/forms.js | 11 +- internal/ui/web/js/panels.js | 69 +++--------- internal/ui/web/js/store.js | 17 +-- internal/update/decouple_test.go | 82 ++++++++++++++ internal/update/inuse_dll_windows_test.go | 126 ++++++++++++++++++++++ internal/update/native_package_darwin.go | 4 +- internal/update/pkg.go | 33 ++++-- internal/update/state_other.go | 3 + internal/update/state_windows.go | 19 +++- internal/update/swap.go | 23 ++++ internal/update/version.go | 31 +++--- internal/win/mem.go | 68 ++++++++++++ release/0.9.0.SHA256SUMS.txt | 3 + release/0.9.0.md | 62 +++++++++++ rsrc_windows_amd64.syso | Bin 73420 -> 73420 bytes scripts/test_card_cfg.mjs | 20 ++++ scripts/test_create_cancel.mjs | 2 +- scripts/test_gles32_ui.mjs | 2 +- scripts/test_playstore_ui.mjs | 2 +- 43 files changed, 840 insertions(+), 199 deletions(-) create mode 100644 internal/emu/bootwatch_memory_test.go create mode 100644 internal/host/memory.go create mode 100644 internal/update/decouple_test.go create mode 100644 internal/update/inuse_dll_windows_test.go create mode 100644 internal/win/mem.go create mode 100644 release/0.9.0.SHA256SUMS.txt create mode 100644 release/0.9.0.md create mode 100644 scripts/test_card_cfg.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fdb188..ae97a9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - name: display coordinate tests run: node --test scripts/test_display_tap.mjs - name: disk UI tests - run: node --test scripts/test_disk_info.mjs scripts/test_compact_format.mjs + run: node --test scripts/test_disk_info.mjs scripts/test_compact_format.mjs scripts/test_card_cfg.mjs - name: gofmt shell: pwsh run: | diff --git a/cmd/modavd-gui/rsrc_windows_amd64.syso b/cmd/modavd-gui/rsrc_windows_amd64.syso index 0d42ad3f0086bfd8e5d01e260646ba876e500cf5..6c33eef5050957adcc8710c4267a1a617860cb55 100644 GIT binary patch delta 42 scmZ3pmu1afmWC~iJz|`k3=qIMeX1DaZ$``Q6U7;o8G-ax3C8cN00I*Wga7~l delta 42 scmZ3pmu1afmWC~iJz|_33=qIEeX1DaZ$^vl6U7;o8G-ax3C8cN00GGhfB*mh diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 0009b84..1ae633d 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -4,7 +4,7 @@ package buildinfo // Version of modavd itself, kept in the source rather than set with -ldflags -X: release builds must come out byte // for byte the same for the same source (Mirror 酱 ships per-file deltas, and a version stamped per package would // change the exe in every release). The ModAVD package version comes from the root at run time. -var Version = "0.8.0" +var Version = "0.9.0" // Protocol is the version of the machine-readable contract: the commands, the JSON written by `--json`, the exit codes // and the MCP tool schemas. Callers such as AUTO-MAS check it (`modavd version --json`) before relying on a field. diff --git a/internal/cli/format.go b/internal/cli/format.go index fb903ec..85809ac 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -185,8 +185,8 @@ func formatList(w io.Writer, r *emu.ListResult) { rows := [][]string{{"编号", "名称", "状态", "adb 地址", "内存", "核数", "帧率", "备注"}} for _, in := range r.Instances { title := in.Title - if title == "" { - title = in.Name + if title == "" || title == in.Name { // no title of its own: emu-N, as the manager shows it (mas_N is the file name) + title = fmt.Sprintf("emu-%d", in.Index) } rows = append(rows, []string{ strconv.Itoa(in.Index), title, statusLabel(in.Status, in.CreationProgress), in.AdbAddress, diff --git a/internal/cli/format_test.go b/internal/cli/format_test.go index 87ca79c..460eea2 100644 --- a/internal/cli/format_test.go +++ b/internal/cli/format_test.go @@ -69,7 +69,7 @@ func TestFormatListTable(t *testing.T) { !strings.Contains(table[1], "127.0.0.1:20001") || !strings.Contains(table[1], "6 GB") { t.Errorf("row 0 = %q", table[1]) } - if !strings.Contains(table[2], "mas_3") || !strings.Contains(table[2], "开机中") || + if strings.Contains(table[2], "mas_3") || !strings.Contains(table[2], "emu-3") || !strings.Contains(table[2], "开机中") || !strings.Contains(table[2], "正在开机,未初始化,软件渲染") || !strings.Contains(table[2], "4 GB") { t.Errorf("row 1 = %q", table[2]) } diff --git a/internal/emu/bootwatch.go b/internal/emu/bootwatch.go index 3c65c4c..b7dd6bc 100644 --- a/internal/emu/bootwatch.go +++ b/internal/emu/bootwatch.go @@ -18,11 +18,15 @@ package emu // The watch never waits: while another start or a stop runs it looks again at the next tick, and a start that sets up // a boot writes its record, so a boot is never set up twice. Its messages go to \logs\modavd-bootwatch.log (one // JSON object per line, as `--json` on stderr). +// +// Being the one process that lives as long as the emulator, the watch also writes the emulator's memory to that log +// every bootWatchMemoryInterval (logMemory). import ( "context" "encoding/json" "errors" + "fmt" "io" "io/fs" "os" @@ -261,8 +265,10 @@ func RunBootWatch(args []string, w io.Writer) int { } cancel() }() + watch.logMemory(time.Now()) for !q.Wait(bootWatchInterval) { watch.tick(ctx, time.Now()) + watch.logMemory(time.Now()) } m.Wait(10 * time.Second) log.Infof("实例 %d 的模拟器(qemu pid %d)已退出,开机守护结束", i, q.PID) @@ -277,6 +283,40 @@ type bootWatch struct { // missingSince: since when the private server has not known the instance (zero: it did at the last look), or // since the watch last registered it again (bootWatchReregisterAfter). missingSince time.Time + // memoryAt: when logMemory wrote its last line (zero: not yet). + memoryAt time.Time +} + +// bootWatchMemoryInterval: how often the watch writes the emulator's memory to its log. +const bootWatchMemoryInterval = 10 * time.Minute + +// The memory counters behind logMemory; tests replace them. +var ( + processMemory = host.GetProcessMemory + systemMemory = host.GetSystemMemory +) + +// logMemory writes one line with the memory of qemu and of the computer, at the watch's start and then every +// bootWatchMemoryInterval: a report that the computer got slow after hours of running can then be told apart into +// the emulator's commit growing (a leak on the host side) and the computer simply being short of memory. Nothing +// where the counters cannot be read. +func (w *bootWatch) logMemory(now time.Time) { + if !w.memoryAt.IsZero() && now.Sub(w.memoryAt) < bootWatchMemoryInterval { + return + } + pm, err := processMemory(w.q) + if err != nil { + return + } + w.memoryAt = now + const mb = 1 << 20 + msg := fmt.Sprintf("实例 %d 模拟器内存(qemu pid %d):提交 %d MB(最高 %d MB),工作集 %d MB", w.i, w.q.PID, + pm.Commit/mb, pm.PeakCommit/mb, pm.WorkingSet/mb) + if sm, err := systemMemory(); err == nil { + msg += fmt.Sprintf(";电脑:物理内存可用 %d / %d MB,提交 %d / %d MB", sm.AvailPhys/mb, sm.TotalPhys/mb, + sm.Committed/mb, sm.CommitLimit/mb) + } + w.m.Log.Infof("%s", msg) } // tick is one look of the watch at now: nothing while a start or a stop of the instance runs, or while the record does diff --git a/internal/emu/bootwatch_memory_test.go b/internal/emu/bootwatch_memory_test.go new file mode 100644 index 0000000..cbc9cac --- /dev/null +++ b/internal/emu/bootwatch_memory_test.go @@ -0,0 +1,76 @@ +//go:build windows + +package emu + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + "modavd/internal/host" + "modavd/internal/logx" +) + +// logMemory writes a line at the first look and then once per bootWatchMemoryInterval, with qemu's and the computer's +// numbers in MB; nothing while qemu's counters cannot be read, and only qemu's part without the computer's. +func TestBootWatchLogMemory(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + const mb = 1 << 20 + var perr, serr error + processMemory = func(p host.Process) (host.ProcessMemory, error) { + if p.PID != 4242 { + t.Errorf("asked for pid %d", p.PID) + } + return host.ProcessMemory{Commit: 6100 * mb, PeakCommit: 6200 * mb, WorkingSet: 4300 * mb}, perr + } + systemMemory = func() (host.SystemMemory, error) { + return host.SystemMemory{TotalPhys: 15728 * mb, AvailPhys: 4400 * mb, CommitLimit: 41574 * mb, Committed: 38600 * mb}, serr + } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + + perr = errors.New("gone") + w.logMemory(t0) + if len(*lines) != 0 { + t.Fatalf("logged without counters: %+v", *lines) + } + perr = nil + w.logMemory(t0) + w.logMemory(t0.Add(bootWatchMemoryInterval - time.Second)) + if len(*lines) != 1 { + t.Fatalf("lines = %+v, want one", *lines) + } + want := "实例 3 模拟器内存(qemu pid 4242):提交 6100 MB(最高 6200 MB),工作集 4300 MB;电脑:物理内存可用 4400 / 15728 MB,提交 38600 / 41574 MB" + if got := (*lines)[0]; got.level != logx.Info || got.msg != want { + t.Errorf("line = %v %q\nwant %q", got.level, got.msg, want) + } + serr = errors.New("no") + w.logMemory(t0.Add(bootWatchMemoryInterval)) + if len(*lines) != 2 || !strings.HasSuffix((*lines)[1].msg, "工作集 4300 MB") { + t.Errorf("without the computer's numbers: %+v", *lines) + } +} + +// The real counters read this test process and the computer. +func TestMemoryCounters(t *testing.T) { + p, ok := host.FindProcess(uint32(os.Getpid())) + if !ok { + t.Fatal("own process not found") + } + pm, err := host.GetProcessMemory(p) + if err != nil || pm.Commit == 0 || pm.WorkingSet == 0 || pm.PeakCommit < pm.Commit { + t.Errorf("process memory = %+v, %v", pm, err) + } + sm, err := host.GetSystemMemory() + if err != nil || sm.TotalPhys == 0 || sm.AvailPhys > sm.TotalPhys || sm.CommitLimit < sm.TotalPhys || + sm.Committed == 0 || sm.Committed > sm.CommitLimit { + t.Errorf("system memory = %+v, %v", sm, err) + } + if _, err := host.GetProcessMemory(host.Process{PID: p.PID, Created: p.Created + 1}); err == nil { + t.Error("a reused PID read as the process") + } +} diff --git a/internal/emu/check.go b/internal/emu/check.go index eae2861..7eacb37 100644 --- a/internal/emu/check.go +++ b/internal/emu/check.go @@ -79,20 +79,6 @@ func (m *Manager) Check(ctx context.Context, opt CheckOptions) (*CheckResult, er return res, nil } -// RootReady reports whether the checks shared by every instance passed. Memory -// depends on the selected instance and is checked again before every launch. -func (r *CheckResult) RootReady() bool { - for k, item := range r.Items { - if item.ID == "memory" { - continue - } - if _, failed := firstBlockingFailure(r.Items[k : k+1]); failed { - return false - } - } - return true -} - // ComponentStatuses is root's component list in the JSON shape of check's "components" (every field always present), // which `components status` returns too. Never nil. func ComponentStatuses(comps []root.ComponentStatus) []ComponentStatus { diff --git a/internal/host/memory.go b/internal/host/memory.go new file mode 100644 index 0000000..1d754af --- /dev/null +++ b/internal/host/memory.go @@ -0,0 +1,16 @@ +package host + +// ProcessMemory is the memory of one process, in bytes. +type ProcessMemory struct { + Commit uint64 // private commit charge (Task Manager's "Commit size") + PeakCommit uint64 // the highest Commit has been + WorkingSet uint64 // in physical memory now +} + +// SystemMemory is the memory of the computer, in bytes. +type SystemMemory struct { + TotalPhys uint64 + AvailPhys uint64 + CommitLimit uint64 // physical memory plus page files + Committed uint64 +} diff --git a/internal/host/unsupported_other.go b/internal/host/unsupported_other.go index cfc66c3..4cd9361 100644 --- a/internal/host/unsupported_other.go +++ b/internal/host/unsupported_other.go @@ -31,6 +31,12 @@ func SetAffinity(Process, uint64) error { return errUnsupported } // GetAffinity calls GetProcessAffinityMask on Windows. func GetAffinity(Process) (uint64, error) { return 0, errUnsupported } +// GetProcessMemory calls GetProcessMemoryInfo on Windows. +func GetProcessMemory(Process) (ProcessMemory, error) { return ProcessMemory{}, errUnsupported } + +// GetSystemMemory calls GlobalMemoryStatusEx on Windows. +func GetSystemMemory() (SystemMemory, error) { return SystemMemory{}, errUnsupported } + // L3Groups lists the L3 caches of the host. func L3Groups() []L3Group { return nil } diff --git a/internal/host/windows.go b/internal/host/windows.go index f51539c..1a69521 100644 --- a/internal/host/windows.go +++ b/internal/host/windows.go @@ -31,6 +31,18 @@ func FindProcess(pid uint32) (Process, bool) { return win.FindProcess(pid) } func ProcessName(pid uint32) string { return win.ProcessName(pid) } func KillTree(p Process) error { return win.KillTree(p) } +// GetProcessMemory reads the memory counters of p; an error when p is gone or cannot be opened. +func GetProcessMemory(p Process) (ProcessMemory, error) { + commit, peak, ws, err := p.Memory() + return ProcessMemory{Commit: commit, PeakCommit: peak, WorkingSet: ws}, err +} + +// GetSystemMemory reads the computer's physical memory and commit charge. +func GetSystemMemory() (SystemMemory, error) { + total, avail, limit, committed, err := win.SystemMemory() + return SystemMemory{TotalPhys: total, AvailPhys: avail, CommitLimit: limit, Committed: committed}, err +} + func StartDetached(exe string, args []string, opt StartOptions) (Process, error) { return win.StartDetached(exe, args, opt) } diff --git a/internal/report/collect.go b/internal/report/collect.go index 8750be1..26d270e 100644 --- a/internal/report/collect.go +++ b/internal/report/collect.go @@ -735,6 +735,62 @@ func otherRootsInstance(plan root.PortPlan, avd string, port int) bool { return err == nil && port != plan.Console(i) } +// topMemoryCount: how many programs topMemory lists. +const topMemoryCount = 10 + +// topMemory lists the programs that hold the most memory, processes of one name added up, by commit: when the +// computer is short of memory the list says whether it is the emulator or something else. No section when no +// process's memory could be read. +func (c *collector) topMemory(procs []Process) { + type sum struct { + name string + n int + commit, workingSet uint64 + } + byName := map[string]*sum{} + var total uint64 + for _, p := range procs { + if p.CommitBytes == 0 { + continue + } + key := strings.ToLower(p.Name) + s := byName[key] + if s == nil { + s = &sum{name: p.Name} + byName[key] = s + } + s.n++ + s.commit += p.CommitBytes + s.workingSet += p.WorkingSetBytes + total += p.CommitBytes + } + if len(byName) == 0 { + return + } + sums := make([]*sum, 0, len(byName)) + for _, s := range byName { + sums = append(sums, s) + } + sort.Slice(sums, func(a, b int) bool { + if sums[a].commit != sums[b].commit { + return sums[a].commit > sums[b].commit + } + return sums[a].name < sums[b].name + }) + c.section(fmt.Sprintf("占内存最多的程序(按提交排,前 %d 个;同名进程合在一起)", topMemoryCount)) + for k, s := range sums { + if k == topMemoryCount { + break + } + count := "" + if s.n > 1 { + count = fmt.Sprintf("(%d 个进程)", s.n) + } + c.L(fmt.Sprintf("%s%s:提交 %s,工作集 %s", s.name, count, formatSize(float64(s.commit)), formatSize(float64(s.workingSet)))) + } + c.L(fmt.Sprintf("读得到的 %d 个程序合计提交 %s", len(sums), formatSize(float64(total)))) +} + func (c *collector) runningAndAdb() error { c.section("正在运行的 qemu-system 进程") qs, err := c.h.Processes(func(name string) bool { return hasPrefixFold(name, "qemu-system") }) @@ -751,6 +807,10 @@ func (c *collector) runningAndAdb() error { ct = q.Started.Local().Format("2006-01-02 15:04:05") } c.L(fmt.Sprintf("pid %d 启动于 %s", q.PID, ct)) + if q.CommitBytes != 0 { + c.L(fmt.Sprintf(" 内存:提交 %s,工作集 %s(隔 10 分钟的记录在 modavd-bootwatch.log)", + formatSize(float64(q.CommitBytes)), formatSize(float64(q.WorkingSetBytes)))) + } cmd := q.CommandLine if cmd == "" { c.L(" qemu 命令行读不到(多半是模拟器以管理员身份运行),现场按 adb 设备列表推算") @@ -775,6 +835,9 @@ func (c *collector) runningAndAdb() error { } } c.running = running + if all, err := c.h.Processes(func(string) bool { return true }); err == nil { + c.topMemory(all) + } c.section("正在监听的 adb server") ls, err := c.adbListeners() diff --git a/internal/report/host.go b/internal/report/host.go index 357a12d..3ec140c 100644 --- a/internal/report/host.go +++ b/internal/report/host.go @@ -143,6 +143,9 @@ type Process struct { Started time.Time // zero when unknown CommandLine string // "" when unreadable ExePath string // "" when unreadable + // CommitBytes, WorkingSetBytes: private commit charge and working set; both 0 when unreadable. + CommitBytes uint64 + WorkingSetBytes uint64 } type Listener struct { diff --git a/internal/report/host_windows.go b/internal/report/host_windows.go index 3eb197c..bb9c316 100644 --- a/internal/report/host_windows.go +++ b/internal/report/host_windows.go @@ -480,6 +480,9 @@ func (h *winHost) Processes(match func(name string) bool) ([]Process, error) { } else { q.ExePath, _ = p.ImagePath() } + if pm, err := host.GetProcessMemory(p); err == nil { + q.CommitBytes, q.WorkingSetBytes = pm.Commit, pm.WorkingSet + } out = append(out, q) } return out, nil diff --git a/internal/report/report_test.go b/internal/report/report_test.go index fb122a1..7aa06d8 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -177,6 +177,51 @@ func TestCollectRunningInstancesRespectDefaultPlan(t *testing.T) { } } +// The memory section adds up processes of one name, sorts by commit, stops at topMemoryCount and leaves out +// processes whose memory could not be read; without any readable one there is no section. +func TestCollectTopMemory(t *testing.T) { + const mb = 1 << 20 + procs := []Process{ + {PID: 1, Name: "qemu-system-x86_64.exe", CommitBytes: 6144 * mb, WorkingSetBytes: 4096 * mb}, + {PID: 2, Name: "browser.exe", CommitBytes: 4096 * mb, WorkingSetBytes: 1024 * mb}, + {PID: 3, Name: "Browser.exe", CommitBytes: 4096 * mb, WorkingSetBytes: 1024 * mb}, + {PID: 4, Name: "unreadable.exe"}, + } + for k := 0; k < topMemoryCount; k++ { + procs = append(procs, Process{PID: uint32(10 + k), Name: fmt.Sprintf("small%02d.exe", k), CommitBytes: uint64(100-k) * mb}) + } + h := portProcessHost{Host: &fakeHost{sc: &scenario{}}, processes: procs} + c := &collector{h: h} + if err := c.runningAndAdb(); err != nil { + t.Fatal(err) + } + out := strings.Join(c.lines, "\n") + _, sec, ok := strings.Cut(out, "占内存最多的程序") + if !ok { + t.Fatalf("no memory section:\n%s", out) + } + sec, _, _ = strings.Cut(sec, "\n\n") + lines := strings.Split(strings.TrimSpace(sec), "\n")[1:] + if len(lines) != topMemoryCount+1 || + lines[0] != "browser.exe(2 个进程):提交 8.00 GB,工作集 2.00 GB" || + lines[1] != "qemu-system-x86_64.exe:提交 6.00 GB,工作集 4.00 GB" || + !strings.HasPrefix(lines[2], "small00.exe:") || !strings.HasPrefix(lines[topMemoryCount-1], "small07.exe:") || + !strings.HasPrefix(lines[topMemoryCount], "读得到的 12 个程序合计提交 ") { + t.Errorf("memory section:\n%s", strings.Join(lines, "\n")) + } + if strings.Contains(sec, "unreadable.exe") { + t.Error("lists a process without memory numbers") + } + + c = &collector{h: portProcessHost{Host: &fakeHost{sc: &scenario{}}, processes: procs[3:4]}} + if err := c.runningAndAdb(); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(c.lines, "\n"), "占内存最多的程序") { + t.Error("a memory section without numbers") + } +} + func TestCollectBridgeLogs(t *testing.T) { r := testutil.TempDir(t) dir := filepath.Join(r, root.LogsDir) diff --git a/internal/root/root.go b/internal/root/root.go index 67baea7..39d53c1 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -1016,30 +1016,58 @@ func ParseVersion(text string) (major, build, patch int, ok bool) { return 0, 0, 0, false } -// manifestVersion is modavd-manifest.json's "version", "" when the file is missing / unreadable / not an object or -// the value is not a string. Read like readProperties (the updater replaces it). -func (r *Root) manifestVersion() string { +// LastCoupledBuild is the last emulator build whose ModAVD version was 0.. (mas-40 = 0.40.x). Later +// versions are numbered on their own; the package says its emulator build in modavd-manifest.json's "build". +const LastCoupledBuild = 40 + +// manifestInfo is modavd-manifest.json's "version" ("" when missing or not a string) and "build" (hasBuild=false when +// missing or not an integer); both empty when the file is missing / unreadable / not an object. Read like +// readProperties (the updater replaces it). +func (r *Root) manifestInfo() (version string, build int, hasBuild bool) { data, err := ReadFileShared(r.ManifestPath()) if err != nil { - return "" + return "", 0, false } + data = bytes.TrimPrefix(data, []byte("\xEF\xBB\xBF")) v, err := jsonord.Parse(data) if err != nil { - return "" + return "", 0, false + } + o := v.Obj() + version, _ = o.Get("version").Str() + if n, ok := o.Get("build").Int(); ok { + return version, int(n), true + } + return version, 0, false +} + +// InstalledVersion is the installed package's ModAVD version. It is the manifest's "version" when the manifest +// belongs to the emulator in sdk\emulator: its "build" is ModBuild (a manifest without "build", from before the two +// were separate: its minor number is ModBuild), or ModBuild is unknown. Otherwise, for an emulator up to +// LastCoupledBuild (no manifest, or sdk\emulator replaced by hand) it is 0..0, as those were numbered; +// else unknown. +func (r *Root) InstalledVersion() (major, minor, patch int, ok bool) { + build, haveBuild := r.ModBuild() + text, declared, hasDeclared := r.manifestInfo() + if major, minor, patch, ok := ParseVersion(text); ok { + if !haveBuild || (hasDeclared && declared == build) || (!hasDeclared && minor == build) { + return major, minor, patch, true + } + } + if haveBuild && build <= LastCoupledBuild { + return 0, build, 0, true } - s, _ := v.Obj().Get("version").Str() - return s + return 0, 0, 0, false } -// VersionText is the ModAVD version for people, "0.35.0": the manifest's version when its build matches ModBuild, -// else 0..0; "" when ModBuild is unknown. Same rule as AUTO-MAS local_version_text. +// VersionText is the ModAVD version for people, "0.35.0" (InstalledVersion); "" when ModBuild or the version is +// unknown. func (r *Root) VersionText() string { - build, ok := r.ModBuild() - if !ok { + if _, ok := r.ModBuild(); !ok { return "" } - if major, b, patch, ok := ParseVersion(r.manifestVersion()); ok && b == build { - return fmt.Sprintf("%d.%d.%d", major, b, patch) + if major, minor, patch, ok := r.InstalledVersion(); ok { + return fmt.Sprintf("%d.%d.%d", major, minor, patch) } - return fmt.Sprintf("0.%d.0", build) + return "" } diff --git a/internal/root/root_test.go b/internal/root/root_test.go index bbea507..b5ffe49 100644 --- a/internal/root/root_test.go +++ b/internal/root/root_test.go @@ -992,6 +992,35 @@ func TestModBuildAndVersionText(t *testing.T) { t.Errorf("manifest %q: %q want %q", content, got, want) } } + // The manifest's "build" (the emulator build) decides, not the version's minor number: from 0.41 they differ. + for content, want := range map[string]string{ + `{"version": "v0.41.0", "build": 35}`: "0.41.0", + `{"version": "v0.35.2", "build": 35}`: "0.35.2", + `{"version": "v0.35.0", "build": 34}`: "0.35.0", // the manifest is another build's: emulator mas-35 is 0.35.0 + `{"version": "v0.35.0", "build": "35"}`: "0.35.0", // not an integer: as a manifest without build + } { + writeFile(t, manifest, []byte(content)) + if got := r.VersionText(); got != want { + t.Errorf("manifest %q: %q want %q", content, got, want) + } + } + // Past mas-40 there is no 0..0: only a manifest of that build gives the version. + writeFile(t, props, []byte("Pkg.BuildId=mas-41\n")) + for content, want := range map[string]string{ + `{"version": "v0.42.0", "build": 41}`: "0.42.0", + `{"version": "v0.41.0"}`: "0.41.0", + `{"version": "v0.42.0", "build": 40}`: "", + `{"version": "v0.42.0"}`: "", + } { + writeFile(t, manifest, []byte(content)) + if got := r.VersionText(); got != want { + t.Errorf("mas-41, manifest %q: %q want %q", content, got, want) + } + } + os.Remove(manifest) + if got := r.VersionText(); got != "" { + t.Errorf("mas-41 without manifest: %q", got) + } writeFile(t, props, []byte("Pkg.BuildId=12345\n")) writeFile(t, manifest, []byte(`{"version": "v0.35.0"}`)) if got := r.VersionText(); got != "" { diff --git a/internal/ui/API.en.md b/internal/ui/API.en.md index 2be30bc..f938e0b 100644 --- a/internal/ui/API.en.md +++ b/internal/ui/API.en.md @@ -74,7 +74,7 @@ HTTP 4xx / 5xx with the body `{"error":{"code":"…","message":"…","advice":" | GET | `/api/diag/report-config` | | `{"text":"…"}` | | GET | `/api/boss-keys` | | `{"supported","taken"}`: `supported` means this manager has boss keys (Windows, and the tray icon could be put up); `taken` are the keys known to be held by another program (registering or trying failed), `[]` when none | | POST | `/api/boss-keys/check` | `{key}` | `{"key","taken"}`: `key` as it would be stored (`"Ctrl+Alt+Shift+1"`; `""` and `"none"` mean no key and come back as `""`), `taken` means registering it now (released at once) failed; a key this manager has registered for another instance does not count. A malformed key is 400 `invalid_argument`, no boss keys is 422 `unsupported` | -| GET | `/api/check?index={i}&refresh=1` | both parameters may be omitted | The original fields of `CheckResult`, plus `instance_index`, `memory_mb` and `root_ready` (takes a few seconds: the hardware acceleration check and the Vulkan probe) | +| GET | `/api/check?refresh=1` | `refresh` may be omitted | `CheckResult` (takes a few seconds: the hardware acceleration check and the Vulkan probe) | | GET | `/api/jobs` | | `{"jobs":[Job]}`, newest first, at most 50 | | GET | `/api/jobs/{id}` | | `Job` | | POST | `/api/jobs/{id}/cancel` | | `Job` | @@ -90,7 +90,7 @@ HTTP 4xx / 5xx with the body `{"error":{"code":"…","message":"…","advice":" - Screenshot, input, listing apps and getting the GSF ID on an instance that is not running: 409 with code `busy`. - `InstanceInfo` carries `diagnostics: bool`, and `diagnostics_since` when it is on. When PATCH changes diagnostics, `UpdateResult` carries `diagnostics_cleanup: {files, bytes}`, reporting only what was actually deleted. The preview for turning it off includes old logs of other ordinary instances that must be evicted after rejoining the ordinary root-wide 1 GiB pool; active files are outside the scope of both the preview and the deletion. The state on disk may change between the preview and the commit, so the page goes by the result of the commit. If turning it off partly fails, the switch is already saved as off, and submitting `diagnostics: false` again continues the cleanup and the guest restoration. - When the diagnostics setting succeeds but the host logcat is collected by an external program, `UpdateResult.warnings` says that it was not taken over; the external process is not ended, and the call does not wait for it to switch before returning. The front end shows this notice. -- The `index` of the computer check selects the check target: it reads that instance's `config.memory_mb` for its next boot, returns 404 if it does not exist, and 400 `invalid_argument` if `index` is empty, repeated or an invalid number. When it is not given, the check uses the default 6144 MB and `instance_index` is `null`; when given, it returns that number. `memory_mb` is always the memory actually used by this check. The cache is keyed by that memory value; after an instance's memory is changed, the result of the old configuration is not reused, and `refresh=1` re-runs both the hardware acceleration and the graphics checks. `ready` includes this check's memory check, while `root_ready` looks only at the general blockers other than memory; on macOS, a general blocker that cannot be confirmed also counts as not passed. Insufficient memory for one check target should not disable other instances; at boot, the pre-check is still redone against the target instance's actual memory. +- The computer check covers this computer and this root folder: one result shared by every instance, and host memory is not checked (a boot the emulator cannot get memory for fails with `insufficient_memory`). The result is cached; `refresh=1` re-runs the hardware acceleration and the graphics checks. When `ready` is `false`, no instance can boot; on macOS, a blocker that cannot be confirmed also counts as not passed. ### Job diff --git a/internal/ui/API.md b/internal/ui/API.md index 1fdbd80..680acd1 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -74,7 +74,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | GET | `/api/diag/report-config` | | `{"text":"…"}` | | GET | `/api/boss-keys` | | `{"supported","taken"}`:`supported` 是这个管理器有老板键(Windows,托盘图标放成了);`taken` 是已知被别的程序占着的键(注册或试注册没成),没有是 `[]` | | POST | `/api/boss-keys/check` | `{key}` | `{"key","taken"}`:`key` 是存下来的写法(`"Ctrl+Alt+Shift+1"`;`""`、`"none"` 是不要,回 `""`),`taken` 是现在试注册(注册上马上注销)没成;这个管理器自己为别的实例注册着的不算。键写得不对 400 `invalid_argument`,没有老板键 422 `unsupported` | -| GET | `/api/check?index={i}&refresh=1` | 两个参数均可省略 | `CheckResult` 的原有字段,加 `instance_index`、`memory_mb`、`root_ready`(要几秒:硬件加速检查和 Vulkan 探测) | +| GET | `/api/check?refresh=1` | `refresh` 可省略 | `CheckResult`(要几秒:硬件加速检查和 Vulkan 探测) | | GET | `/api/jobs` | | `{"jobs":[Job]}`,新的在前,最多 50 个 | | GET | `/api/jobs/{id}` | | `Job` | | POST | `/api/jobs/{id}/cancel` | | `Job` | @@ -90,7 +90,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," - 截图、输入、列应用、取 GSF ID 遇到实例没开机:409,code 是 `busy`。 - `InstanceInfo` 带 `diagnostics: bool`,开启时带 `diagnostics_since`。PATCH 修改诊断时,`UpdateResult` 带 `diagnostics_cleanup: {files, bytes}`,只报实际删除量。关闭预览包含重新加入普通全根目录 1 GiB 池后需淘汰的其它普通实例旧日志;活跃文件不在预览和删除范围内。预览和提交之间现场可能变化,页面以提交结果为准。关闭部分失败时开关已保存为关,再次提交 `diagnostics: false` 会继续清理和客体恢复。 - 诊断设置成功但宿主 logcat 由外部程序采集时,`UpdateResult.warnings` 说明未接管;不结束外部进程,也不等待它切换后才返回。前端显示该提示。 -- 电脑检查的 `index` 指定检查目标:读取该实例下次开机的 `config.memory_mb`,不存在回 404,`index` 为空、重复或编号无效时回 400 `invalid_argument`。不指定时按默认 6144 MB 检查,`instance_index` 为 `null`;指定时返回该编号。`memory_mb` 总是本次检查实际使用的内存。缓存按该内存值区分;修改实例内存后不会复用旧配置结果,`refresh=1` 同时重新执行硬件加速和图形检查。`ready` 包含本次内存检查,`root_ready` 只看内存以外的通用阻断项;macOS 无法确认的通用阻断项也视为未通过。某个检查目标内存不足不应禁用其他实例,开机时仍按目标实例实际内存重新预检。 +- 电脑检查查的是这台电脑和这个根目录,所有实例共用一份结果,不查内存(模拟器分不到内存时开机报 `insufficient_memory`)。结果会缓存,`refresh=1` 重新执行硬件加速和图形检查。`ready` 为 `false` 时所有实例都开不了机;macOS 无法确认的阻断项也视为未通过。 ### Job diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index de97116..ee6a5f8 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "modavd/internal/avd" "modavd/internal/buildinfo" "modavd/internal/components" "modavd/internal/emu" @@ -507,37 +506,9 @@ func (s *server) handleCheck(w http.ResponseWriter, r *http.Request) { writeError(w, errs.New(errs.InvalidArgument, "检查参数不合法")) return } + // The check is the host's and the root's, the same for every instance: host memory is not checked. refresh := query.Get("refresh") == "1" - var index *int - memoryMB := avd.DefaultMemoryMB - if values, provided := query["index"]; provided { - if len(values) != 1 { - writeError(w, errs.New(errs.InvalidArgument, "只能指定一个检查实例")) - return - } - i, err := parseIndex(values[0]) - if err != nil { - writeError(w, err) - return - } - info, err := s.mgr.Info(r.Context(), i) - if err != nil { - writeError(w, err) - return - } - index = &i - if info.Config.MemoryMB > 0 { - memoryMB = info.Config.MemoryMB - } - } - writeResult := func(res *emu.CheckResult) { - writeJSON(w, http.StatusOK, struct { - *emu.CheckResult - InstanceIndex *int `json:"instance_index"` - MemoryMB int `json:"memory_mb"` - RootReady bool `json:"root_ready"` - }{res, index, memoryMB, res.RootReady()}) - } + writeResult := func(res *emu.CheckResult) { writeJSON(w, http.StatusOK, res) } asked := time.Now() c := &s.check if err := c.lock(r.Context()); err != nil { @@ -548,22 +519,22 @@ func (s *server) handleCheck(w http.ResponseWriter, r *http.Request) { // A refresh may share a refresh that finished while it waited, but a normal // check may have reused the hardware probes' caches. c.mu.Lock() - cached, cachedAt, cachedMemoryMB := c.res, c.at, c.memoryMB + cached, cachedAt := c.res, c.at cachedRefreshed := c.refreshed generation := c.generation c.mu.Unlock() - if cached != nil && cachedMemoryMB == memoryMB && (!refresh || (cachedRefreshed && cachedAt.After(asked))) { + if cached != nil && (!refresh || (cachedRefreshed && cachedAt.After(asked))) { writeResult(cached) return } - res, err := s.mgr.Check(r.Context(), emu.CheckOptions{MemoryMB: memoryMB, Refresh: refresh}) + res, err := s.mgr.Check(r.Context(), emu.CheckOptions{Refresh: refresh}) if err != nil { writeError(w, err) return } c.mu.Lock() if c.generation == generation { - c.res, c.at, c.memoryMB, c.refreshed = res, time.Now(), memoryMB, refresh + c.res, c.at, c.refreshed = res, time.Now(), refresh } c.mu.Unlock() writeResult(res) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 5858ffe..02c1951 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -486,7 +486,6 @@ type checkCache struct { mu sync.Mutex generation uint64 res *emu.CheckResult - memoryMB int refreshed bool at time.Time } diff --git a/internal/ui/web/js/app.js b/internal/ui/web/js/app.js index baf9871..b534b99 100644 --- a/internal/ui/web/js/app.js +++ b/internal/ui/web/js/app.js @@ -7,7 +7,7 @@ import { initUI } from './ui.js'; import { initGrid, renderGrid, viewState, setView } from './cards.js'; import { openCreate, openSettings, openDelete } from './forms.js'; import { openProxy } from './proxy.js'; -import { runCheck, syncCheckTarget, checkSummary, openCheck, openActivity, renderBanner } from './panels.js'; +import { runCheck, checkSummary, openCheck, openActivity, renderBanner } from './panels.js'; import { openViewer, openLogs } from './viewer.js'; import { loadUpdate, onUpdate, renderUpdateChip, openUpdate } from './update.js'; import * as A from './actions.js'; @@ -84,7 +84,6 @@ let opened = false; // ---- render function render() { - syncCheckTarget(); const root = (S.info && S.info.root) || S.list; const list = S.list; if (root) { diff --git a/internal/ui/web/js/cards.js b/internal/ui/web/js/cards.js index 4a6cc34..ce41bc0 100644 --- a/internal/ui/web/js/cards.js +++ b/internal/ui/web/js/cards.js @@ -3,7 +3,7 @@ import { h, icon, btn, ibtn, keyed, setText, attr } from './dom.js'; import { S, lastJob, rootBlocked } from './store.js'; -import { label, uptime, stripInst, pct, cfgText } from './format.js'; +import { label, ownTitle, uptime, stripInst, pct, cfgText } from './format.js'; import * as A from './actions.js'; import { openMenu } from './ui.js'; import { openSettings, openDelete } from './forms.js'; @@ -143,7 +143,7 @@ function makeCard(i) { c.pmark = btn('pmark', [icon('shield'), h('span', { text: '代理' })], () => openProxy(i), { hidden: true }); c.sub = h('div', { class: 'card-sub' }); c.lmsg = h('div', { class: 'card-sub card-lmsg' }); - c.cfg = h('div', { class: 'card-cfg', title: '内存 · CPU 核数 · 帧率 · 数据盘(自动扩盘或指定大小,都按实际写入占电脑硬盘)' }); + c.cfg = h('div', { class: 'card-cfg', title: '内存 · CPU 核数 · 帧率 · 数据盘(指定大小时)· 数据盘现在实际占的电脑硬盘' }); c.pillText = h('span'); c.pill = h('span', { class: 'pill' }, c.pillText); c.adb = h('span', { class: 'adb-addr' }); @@ -186,13 +186,13 @@ function updateCard(c, inst) { c.el.dataset.status = v.key; setText(c.title, (c.title.title = name)); const sub = []; - if (inst.title && inst.title !== inst.name) sub.push(inst.name); + if (ownTitle(inst)) sub.push(inst.name); if (live && inst.started_at) sub.push(uptime(inst.started_at)); const subtitle = sub.join(' · ') || `编号 ${inst.index}`; const headless = inst.running && inst.window === false && v.key !== 'creating'; setText(c.sub, subtitle + (headless ? ' · 无头' : '') + (inst.diagnostics ? ' · 诊断模式' : '')); c.sub.title = headless ? '命令行开的,没有窗口,可以在这里关机' : ''; - setText(c.cfg, cfgText(inst.config)); + setText(c.cfg, cfgText(inst.config, inst.data_space)); let [pt, tone] = PILL[v.key]; // create's setup: our own job's progress, else what the process doing it (CLI, AUTO-MAS) left in the instance diff --git a/internal/ui/web/js/format.js b/internal/ui/web/js/format.js index 55f740c..200dc2a 100644 --- a/internal/ui/web/js/format.js +++ b/internal/ui/web/js/format.js @@ -14,14 +14,25 @@ export function fmtMem(mb) { return mb % 1024 ? (mb / 1024).toFixed(1) + ' GB' : mb / 1024 + ' GB'; } +// The instance's own title; '' when it has none (a new instance's avd.ini.displayname is its file name mas_N). +export function ownTitle(inst) { + const t = inst && inst.title ? inst.title.trim() : ''; + return t && t !== inst.name ? t : ''; +} + +// 「明日方舟」, or 「emu-0」 when the instance has no title of its own. mas_N is the file name, not shown as a name. +export function defaultName(i) { + return `emu-${i}`; +} + export function label(inst) { if (!inst) return ''; - return inst.title && inst.title.trim() ? inst.title : inst.name; + return ownTitle(inst) || defaultName(inst.index); } -// 「明日方舟」, or 「mas_0」 when the instance has no title of its own. +// 「明日方舟」, or 「emu-0」 when the instance has no title of its own. export function quoted(inst, i) { - return inst ? `「${label(inst)}」` : `「实例 ${i}」`; + return inst ? `「${label(inst)}」` : `「${defaultName(i)}」`; } export function parseTime(s) { @@ -142,9 +153,12 @@ export function stopMethod(m) { return (STOP_METHOD[base] || `已关机(${base})`) + (term ? ',最后强行结束了模拟器进程' : ''); } -// 「内存 6 GB · 6 核 · 60 Hz · 盘 64 GB」 / 「… · 盘自动扩」 (自动扩盘): what each number is, without a hover -export function cfgText(cfg) { +// 「内存 6 GB · 6 核 · 60 Hz · 盘 64 GB · 占 12.3 GB」: what each number is, without a hover. The disk size only for a +// set size (自动扩盘 is the default, nothing to tell); 占 is what the data disk takes on the computer now +// (space.host_bytes, only what was written), left out when unknown. +export function cfgText(cfg, space) { cfg = cfg || {}; - const disk = cfg.data_disk_mode === 'manual' ? `盘 ${cfg.data_partition_gb || '—'} GB` : '盘自动扩'; - return `内存 ${fmtMem(cfg.memory_mb)} · ${cfg.cpu || '—'} 核 · ${cfg.fps || '—'} Hz · ${disk}`; + const disk = cfg.data_disk_mode === 'manual' ? ` · 盘 ${cfg.data_partition_gb || '—'} GB` : ''; + const used = space && space.host_bytes >= 0 ? ` · 占 ${fmtBytes(space.host_bytes)}` : ''; + return `内存 ${fmtMem(cfg.memory_mb)} · ${cfg.cpu || '—'} 核 · ${cfg.fps || '—'} Hz${disk}${used}`; } diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js index c6c07ab..592a6eb 100644 --- a/internal/ui/web/js/forms.js +++ b/internal/ui/web/js/forms.js @@ -4,7 +4,7 @@ import { h, icon, btn, ibtn, showError, attr } from './dom.js'; import { api } from './api.js'; import { S, update, subscribe, instance, lastJob } from './store.js'; import { openDialog, toast } from './ui.js'; -import { quoted, label, uptime, fmtBytes } from './format.js'; +import { quoted, label, ownTitle, defaultName, uptime, fmtBytes } from './format.js'; import * as A from './actions.js'; const MEM = [[3072, '3 GB'], [4096, '4 GB'], [5120, '5 GB'], [6144, '6 GB']]; @@ -168,7 +168,7 @@ export function openCreate() { const base = S.list.port_base || 20000; const sync = () => { const i = idx.value === '' ? free[0] : Number(idx.value); - titleHint.textContent = `不填就显示 mas_${i},以后可以随时改`; + titleHint.textContent = `不填就显示「${defaultName(i)}」,以后可以随时改`; idxHint.replaceChildren('编号决定端口,脚本连 ', h('b', { text: `127.0.0.1:${base + 1 + 10 * i}` })); }; idx.addEventListener('change', sync); @@ -214,9 +214,9 @@ export function openSettings(i, field) { const inst0 = instance(i); if (!inst0 || settingsOpen.has(i)) return; const cfg = inst0.config || {}; - const initTitle = inst0.title && inst0.title !== inst0.name ? inst0.title : ''; + const initTitle = ownTitle(inst0); const initDiagnostics = inst0.diagnostics === true; - const title = h('input', { class: 'input', id: 's-title', maxlength: '40', autocomplete: 'off', value: initTitle, placeholder: inst0.name }); + const title = h('input', { class: 'input', id: 's-title', maxlength: '40', autocomplete: 'off', value: initTitle, placeholder: defaultName(i) }); const diagnostics = h('input', { type: 'checkbox', id: 's-diagnostics', checked: initDiagnostics, 'aria-describedby': 's-diagnostics-h' }); const boss = bossKeyField(inst0.boss_key || '', () => dirty()); const runNote = h('div', { class: 'note-box tone-warn', role: 'status' }, icon('info'), h('span', { text: '实例运行中,内存、CPU、帧率、分辨率和数据盘在下次开机生效。诊断设置立即生效。' })); @@ -236,7 +236,7 @@ export function openSettings(i, field) { body: h('div', { class: 'dlg-body' }, runNote, h('h3', { class: 'sec-title', text: '可以修改' }), h('div', { class: 'fields' }, - inputRow('名称', title, hint(`不填就显示 ${inst0.name}`)), + inputRow('名称', title, hint(`不填就显示「${defaultName(i)}」`)), segRow('内存', 'memory', MEM, cfg.memory_mb), segRow('CPU', 'cpu', CPU, cfg.cpu), fps.rows, @@ -305,7 +305,6 @@ export function openSettings(i, field) { const r = await api.patch(`/api/instances/${i}`, pending); const k = S.list.instances.findIndex((x) => x.index === i); if (k >= 0 && r.instance) S.list.instances[k] = r.instance; - if ('memory_mb' in pending) S.checkIndex = i; if (r.boss_key_taken) { // saved, but another program has the key: say so (the settings page marks it too) return { tone: 'warn', title: '已保存,但老板键用不了', text: `${r.instance.boss_key} 被别的程序占了,在设置里换一个。` }; diff --git a/internal/ui/web/js/panels.js b/internal/ui/web/js/panels.js index 378abb7..4b3d6aa 100644 --- a/internal/ui/web/js/panels.js +++ b/internal/ui/web/js/panels.js @@ -2,10 +2,10 @@ import { h, icon, btn, keyed, showError, setText, attr } from './dom.js'; import { api } from './api.js'; -import { S, update, subscribe, instance, jobsSorted, runningCount, checkTarget, rootBlocked } from './store.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, gles32Text } from './format.js'; +import { KIND, KIND_ICON, STATE, STATE_TONE, clock, dur, label, defaultName, 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 })); @@ -13,48 +13,22 @@ const folderBtn = (cls) => btn(cls, [icon('folder'), '打开目录'], () => A.op // ---- check let refreshAgain = false; -let requestedKey = ''; -let activeKey = ''; -const targetKey = (t) => `${t.index ?? 'default'}:${t.memoryMB}`; -const targetName = () => { - const t = checkTarget(); - return t.instance ? `「${label(t.instance)}」的下次开机配置` : '新建实例的默认配置'; -}; - -// A saved setting or an instance-list event can change the target while a check runs. -export function syncCheckTarget() { - if (S.list && targetKey(checkTarget()) !== requestedKey) runCheck(false); -} +// The check is the host's and the root's, the same for every instance (host memory is not checked). // refresh: a new check, not the cached one. Asked while one runs (components just installed), it runs again after. export async function runCheck(refresh) { - const target = checkTarget(); - const key = targetKey(target); - if (S.check && S.checkTargetKey !== key) { - S.check = null; - S.checkedAt = 0; - update(); - } if (S.checking) { - if (refresh || key !== activeKey) refreshAgain = true; + if (refresh) refreshAgain = true; return; } - activeKey = requestedKey = key; S.checking = true; S.checkError = null; update(); try { - const query = new URLSearchParams(); - if (target.index != null) query.set('index', target.index); - if (refresh) query.set('refresh', '1'); - const c = await api.get('/api/check' + (query.size ? '?' + query : '')); - if (key === targetKey(checkTarget())) { - S.check = c; - S.checkTargetKey = key; - S.checkedAt = Date.now(); - } + S.check = await api.get('/api/check' + (refresh ? '?refresh=1' : '')); + S.checkedAt = Date.now(); } catch (e) { - if (key === targetKey(checkTarget())) S.checkError = e; + S.checkError = e; } S.checking = false; update(); @@ -70,7 +44,7 @@ export function checkSummary(c) { const blockers = items.filter((x) => x.blocking && x.ok === false); const hints = items.filter((x) => (x.ok === false && !x.blocking) || x.ok == null); const tone = !c.ready || blockers.length ? 'err' : hints.length ? 'warn' : 'ok'; - const text = tone === 'err' && c.root_ready === true ? '内存不足' : { err: '不能开机', warn: '有提示', ok: '就绪' }[tone]; + const text = { err: '不能开机', warn: '有提示', ok: '就绪' }[tone]; return { tone, text, blockers, hints, n: items.length }; } @@ -152,12 +126,6 @@ export function openCheck(opts) { return; } const sum = h('div', { class: 'check-sum' }); - const target = h('select', { class: 'select', id: 'check-target' }); - const targetHint = h('p', { class: 'f-hint' }); - target.addEventListener('change', () => { - S.checkIndex = target.value === '' ? null : Number(target.value); - runCheck(false); - }); const box = h('div', { class: 'form-error', role: 'alert', hidden: true }); const list = h('ul', { class: 'check-list' }); // when something did not pass, the passed items fold away under one line @@ -173,9 +141,7 @@ export function openCheck(opts) { const again = btn('btn btn-secondary', null, () => runCheck(true)); const ctl = openDialog({ title: '电脑检查', sub: '开机前会自动检查这些项目,没通过的项目会说明原因和处理办法。', cls: 'dlg-check', light: true, - body: h('div', { class: 'dlg-body' }, - h('div', { class: 'fields' }, h('label', { class: 'f-label', for: 'check-target', text: '检查对象' }), - h('div', { class: 'f-ctl' }, target, targetHint)), sum, box, list, passed, comps, store.el), + body: h('div', { class: 'dlg-body' }, sum, box, list, passed, comps, store.el), foot: h('footer', { class: 'dlg-foot' }, when, folderBtn('btn btn-ghost'), again), }); checkCtl = ctl; @@ -199,15 +165,6 @@ export function openCheck(opts) { const render = () => { const c = S.check; - const choices = [...(S.list?.instances || [])].sort((a, b) => a.index - b.index); - const choicesSig = JSON.stringify(choices.map((x) => [x.index, x.title, x.config?.memory_mb])); - if (target.sig !== choicesSig) { - target.sig = choicesSig; - target.replaceChildren(h('option', { value: '', text: '新建实例(默认 6 GB)' }), - ...choices.map((x) => h('option', { value: String(x.index), text: `${label(x)} · ${(x.config?.memory_mb || 6144) / 1024} GB` }))); - } - target.value = checkTarget().index == null ? '' : String(checkTarget().index); - setText(targetHint, `按${targetName()}检查;每次开机前会重新检查可用内存。`); store.el.hidden = !(S.components && S.components.play_store); if (!store.el.hidden) fillStoreBox(store); showStore(); @@ -236,7 +193,7 @@ export function openCheck(opts) { } const s = checkSummary(c); const [head, line] = { - err: ['当前检查配置暂时不能开机', `${s.blockers.length} 项必需检查没通过,按下面的建议处理后再检查一次。`], + err: ['暂时不能开机', `${s.blockers.length} 项必需检查没通过,按下面的建议处理后再检查一次。`], warn: ['可以开机,有提示', `${s.hints.length} 项需要留意,不影响开机。`], ok: ['这台电脑可以开机', `${s.n} 项检查都通过了。`], }[s.tone]; @@ -275,7 +232,7 @@ export function openCheck(opts) { // ---- banner: the root cannot boot (a blocking check failed or components are missing) export function renderBanner(el) { const c = S.check; - const sig = c && !c.ready ? JSON.stringify([c.items, c.components, targetName()]) : ''; + const sig = c && !c.ready ? JSON.stringify([c.items, c.components]) : ''; if (el.sig === sig) return; el.sig = sig; el.hidden = !sig; @@ -286,7 +243,7 @@ export function renderBanner(el) { el.replaceChildren(h('div', { class: 'banner', role: 'status' }, h('span', { class: 'banner-ic' }, icon('alert')), h('div', { class: 'banner-main' }, - h('h2', { text: rootBlocked() ? '这个目录现在还不能开机' : `${targetName()}暂不满足启动条件` }), + h('h2', { text: '这个目录现在还不能开机' }), first && h('p', { text: first.reason }), first && first.advice && advice(first.id === 'components' ? '请点下面的「补全组件」打开许可协议并下载,或运行 tools\\modavd.exe components fetch。' @@ -375,7 +332,7 @@ function updateJob(r, j) { // components / collect-info belong to the root (index -1): no instance name const rootJob = j.index < 0; const inst = rootJob ? null : instance(j.index); - const name = rootJob ? '' : inst ? label(inst) : `实例 ${j.index}`; + const name = rootJob ? '' : inst ? label(inst) : defaultName(j.index); if (r.name !== name) { r.name = name; r.title.replaceChildren(...[rootJob ? KIND[j.kind] || j.kind : `${KIND[j.kind] || j.kind}「${name}」`, diff --git a/internal/ui/web/js/store.js b/internal/ui/web/js/store.js index 9442288..f74693f 100644 --- a/internal/ui/web/js/store.js +++ b/internal/ui/web/js/store.js @@ -8,8 +8,6 @@ export const S = { watch: new Set(), // job ids started from this window (toast when they finish) logs: [], // {time, level, msg, job_id} check: null, // CheckResult - checkIndex: undefined, // undefined: first instance; null: new-instance defaults; number: selected instance - checkTargetKey: '', checking: false, checkError: null, checkedAt: 0, @@ -65,20 +63,9 @@ export function lastJob(i, running) { return best; } -// Memory belongs to one boot's configuration and is checked freshly by Start. -// Only common host/SDK failures may disable every instance's start button. +// A blocking check failed: no instance can boot from this root (the check is the same for every instance). export function rootBlocked() { - const c = S.check; - if (!c) return false; - if (typeof c.root_ready === 'boolean') return !c.root_ready; - const items = c.items || []; - return c.ready === false && (!items.length || items.some((x) => x.id !== 'memory' && x.blocking && x.ok !== true)); -} - -export function checkTarget() { - const instances = S.list?.instances || []; - const selected = S.checkIndex === null ? null : instance(S.checkIndex) || [...instances].sort((a, b) => a.index - b.index)[0]; - return { index: selected?.index ?? null, memoryMB: selected?.config?.memory_mb || 6144, instance: selected }; + return !!S.check && S.check.ready === false; } export function runningCount() { diff --git a/internal/update/decouple_test.go b/internal/update/decouple_test.go new file mode 100644 index 0000000..b323f68 --- /dev/null +++ b/internal/update/decouple_test.go @@ -0,0 +1,82 @@ +//go:build windows + +package update + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "modavd/internal/testutil" +) + +// From 0.41 the version is not 0..x: the package's manifest says the emulator build in "build", and +// the package check compares sdk\emulator's Pkg.BuildId with that, not with the version. +func TestVerifyPackageVersionNotTiedToBuild(t *testing.T) { + put := func(t *testing.T, version string, build any, noManifest bool) layout { + t.Helper() + l := layout{root: testutil.TempDir(t)} + files := pkgFiles(40, nil) + for rel, body := range files { + p := l.pkgAt(rel) + os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if noManifest { + return l + } + var m map[string]any + json.Unmarshal(manifestFor(40, files), &m) + m["version"] = version + if build == nil { + delete(m, "build") + } else { + m["build"] = build + } + b, _ := json.Marshal(m) + os.WriteFile(l.pkgAt(manifestName), b, 0o644) + return l + } + for _, tc := range []struct { + name string + version string + build any + target Version + tag string + wantErr string + }{ + {"new version, emulator mas-40", "v0.41.0", 40, Version{0, 41, 0}, "v0.41.0", ""}, + {"version far from the build", "v0.57.3", 40, Version{0, 57, 3}, "v0.57.3", ""}, + {"manifest names another build", "v0.41.0", 39, Version{0, 41, 0}, "v0.41.0", "包里模拟器的构建号(40)"}, + {"legacy manifest without build", "v0.40.0", nil, Version{0, 40, 0}, "v0.40.0", ""}, + {"legacy manifest without build, other version", "v0.41.0", nil, Version{0, 41, 0}, "v0.41.0", "包里模拟器的构建号(40)"}, + {"build not positive", "v0.41.0", 0, Version{0, 41, 0}, "v0.41.0", "构建号(0)不对"}, + {"version differs from the target", "v0.41.0", 40, Version{0, 42, 0}, "v0.42.0", "清单的版本"}, + } { + t.Run(tc.name, func(t *testing.T) { + l := put(t, tc.version, tc.build, false) + _, err := l.verifyPackage(tc.target, tc.tag) + if tc.wantErr == "" && err != nil || tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { + t.Fatalf("err = %v, want %q", err, tc.wantErr) + } + if tc.wantErr == "" { + if err := l.buildStage(tc.target); err != nil { + t.Fatalf("buildStage: %v", err) + } + } + }) + } + t.Run("legacy package without manifest", func(t *testing.T) { + l := put(t, "", nil, true) + if _, err := l.verifyPackage(Version{0, 40, 0}, "v37.2.10-mas40"); err != nil { + t.Fatal(err) + } + if _, err := l.verifyPackage(Version{0, 41, 0}, "v37.2.10-mas41"); err == nil { + t.Fatal("mas-40 emulator accepted as mas-41") + } + }) +} diff --git a/internal/update/inuse_dll_windows_test.go b/internal/update/inuse_dll_windows_test.go new file mode 100644 index 0000000..984f01d --- /dev/null +++ b/internal/update/inuse_dll_windows_test.go @@ -0,0 +1,126 @@ +//go:build windows + +package update + +import ( + "context" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "modavd/internal/errs" +) + +// loadDLL puts a copy of a small system DLL at path and loads it into this process, the way MAA loads +// mumu-shim\shell\sdk\external_renderer_ipc.dll; freed at the end of the test (or by the returned func). +func loadDLL(t *testing.T, path string) (free func()) { + t.Helper() + b, err := os.ReadFile(filepath.Join(os.Getenv("SystemRoot"), "System32", "msimg32.dll")) + if err != nil { + t.Skipf("no system DLL to load: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatal(err) + } + h, err := syscall.LoadLibrary(path) + if err != nil { + t.Fatalf("LoadLibrary %s: %v", path, err) + } + freed := false + free = func() { + if !freed { + freed = true + syscall.FreeLibrary(h) + } + } + t.Cleanup(free) + return free +} + +func TestFileInUseSeesLoadedDLL(t *testing.T) { + dir := t.TempDir() + dll := filepath.Join(dir, "external_renderer_ipc.dll") + free := loadDLL(t, dll) + if !fileInUse(dll) { + t.Error("a loaded DLL is not in use") + } + free() + if fileInUse(dll) { + t.Error("a freed DLL is still in use") + } + plain := filepath.Join(dir, "plain.txt") + os.WriteFile(plain, []byte("x"), 0o644) + readOnly := filepath.Join(dir, "readonly.txt") + os.WriteFile(readOnly, []byte("x"), 0o444) + for _, p := range []string{plain, readOnly, filepath.Join(dir, "missing.dll")} { + if fileInUse(p) { + t.Errorf("%s counts as in use", p) + } + } + if b, _ := os.ReadFile(plain); string(b) != "x" { + t.Errorf("the check changed the file: %q", b) + } +} + +// TestInstallOverThirdPartyShim: AUTO-MAS copies its own external_renderer_ipc.dll to the same path, so the root's +// file differs from the root manifest. A full package install takes the package's copy (the other one is kept in the +// backup until the install is done) and does not fail. +func TestInstallOverThirdPartyShim(t *testing.T) { + const shim = "mumu-shim/shell/sdk/external_renderer_ipc.dll" + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, map[string]string{shim: "shim 36"}) + if err := os.WriteFile(filepath.Join(r.Dir, filepath.FromSlash(shim)), []byte("AUTO-MAS copy"), 0o644); err != nil { + t.Fatal(err) + } + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, map[string]string{shim: "shim 36"}), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("install: %+v %v", st, err) + } + if got := read(t, r, shim); got != "shim 36" { + t.Errorf("shim = %q", got) + } +} + +// TestInstallBlockedByLoadedDLL: MAA holding the MuMu shell (or extras\modavd_extras.dll) loaded stops the install +// before anything is swapped, with a message that says which file and to close MAA; the stage stays installable and +// the next install, once MAA is closed, goes through. +func TestInstallBlockedByLoadedDLL(t *testing.T) { + const shim = "mumu-shim/shell/sdk/external_renderer_ipc.dll" + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, map[string]string{shim: "shim 36"}) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, map[string]string{shim: "shim 37"}), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + free := loadDLL(t, filepath.Join(r.Dir, filepath.FromSlash(shim))) + _, err := u.Install(context.Background()) + if errs.CodeOf(err) != errs.Busy || !strings.Contains(err.Error(), `mumu-shim\shell\sdk\external_renderer_ipc.dll`) || + !strings.Contains(err.Error(), "MAA") || !strings.Contains(err.Error(), "什么都没动") { + t.Fatalf("blocked: %v", err) + } + if st := u.Status(); st.Phase != phaseStaged || st.PreparedVersion != "v0.37.0" { + t.Fatalf("after: %+v", st) + } + for rel, want := range map[string]string{"sdk/emulator/emulator.exe": "emulator mas-36", "说明.txt": "ModAVD 0.36.0\n", + "tools/modavd-bridge.ps1": "# bridge 36\n"} { + if got := read(t, r, rel); got != want { + t.Errorf("%s = %q after the blocked install", rel, got) + } + } + if lexists(u.l.previous()) { + t.Error("previous\\ left behind") + } + free() + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("install after MAA closed: %+v %v", st, err) + } + if got := read(t, r, shim); got != "shim 37" { + t.Errorf("shim = %q", got) + } +} diff --git a/internal/update/native_package_darwin.go b/internal/update/native_package_darwin.go index 57773bc..9d93d92 100644 --- a/internal/update/native_package_darwin.go +++ b/internal/update/native_package_darwin.go @@ -81,7 +81,7 @@ func digestBytes(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToSt // Symlinks may contain '..', but every resolution step stays inside its move unit. func validateNativeManifest(m *nativeManifest) (Version, error) { v, ok := ParseVersion(m.Version) - if !ok || m.Schema != 1 || m.Format != nativeFormat || m.Platform != root.CurrentPlatform().PackageSuffix || m.Build != v.Build || len(m.Files) == 0 || len(m.Files) > maxEntries || len(m.Units) > len(nativeAllowedUnits) { + if !ok || m.Schema != 1 || m.Format != nativeFormat || m.Platform != root.CurrentPlatform().PackageSuffix || m.Build <= 0 || len(m.Files) == 0 || len(m.Files) > maxEntries || len(m.Units) > len(nativeAllowedUnits) { return Version{}, packageError("macOS 更新清单格式、平台或版本不正确") } units := map[string]bool{} @@ -482,7 +482,7 @@ func verifyDarwinPackageAtContext(ctx context.Context, m *nativeManifest, target if err := verifyNativeTreeAt(m, at); err != nil { return err } - if err := checkEmulator(at("sdk/emulator"), target); err != nil { + if err := checkEmulator(at("sdk/emulator"), m.Build, target); err != nil { return err } required := append(managerPaths(), "sdk/emulator/"+root.CurrentPlatform().Emulator, "sdk/emulator/qemu/"+root.CurrentPlatform().QemuDir+"/"+root.CurrentPlatform().QEMU, "sdk/emulator/qemu/"+root.CurrentPlatform().QemuDir+"/"+root.CurrentPlatform().QEMUHeadless) diff --git a/internal/update/pkg.go b/internal/update/pkg.go index bf35fb2..0ca12e6 100644 --- a/internal/update/pkg.go +++ b/internal/update/pkg.go @@ -521,17 +521,17 @@ func hashFile(p string) (string, error) { return hex.EncodeToString(h.Sum(nil)), nil } -// checkPackageManifest is AUTO-MAS check_package_manifest: version, build and platform match what is being installed. +// checkPackageManifest is AUTO-MAS check_package_manifest: version and platform match what is being installed, and +// "build" (the emulator build, not tied to the version since 0.41) is a positive integer when given. func checkPackageManifest(m *jsonord.Obj, target Version) error { v, ok := ParseVersion(manifestString(m, "version")) if !ok || v != target { return packageError("包里清单的版本(%s)和要装的版本(%s)不一致", displayVersion(manifestString(m, "version")), target.Display()) } if b := m.Get("build"); b.Kind() != jsonord.Null { - n, ok := b.Int() - if !ok || int(n) != target.Build { + if n, ok := b.Int(); !ok || n <= 0 { lit, _ := b.NumberLiteral() - return packageError("包里清单的构建号(%s)和版本 %s 的构建号(%d)不一致", lit, target.Display(), target.Build) + return packageError("包里清单的构建号(%s)不对", lit) } } p, ok := m.Get("platform").Str() @@ -546,9 +546,20 @@ func checkPackageManifest(m *jsonord.Obj, target Version) error { var buildIDRe = regexp.MustCompile(`^mas-([0-9]+)$`) -// checkEmulator is AUTO-MAS check_emulator: the package's sdk\emulator is complete, its Pkg.BuildId matches, and its -// qemu is our own build (it knows the free-page-reporting balloon option). -func checkEmulator(emu string, target Version) error { +// packageBuild is the emulator build the package in pkg\ says it carries: its manifest's "build", else (a legacy +// package without one) the target's minor number, as versions through 0.40 were numbered. +func (l layout) packageBuild(target Version) int { + if m, ok := readManifest(l.pkgAt(manifestName)); ok { + if n, ok := m.Get("build").Int(); ok && n > 0 { + return int(n) + } + } + return target.Minor +} + +// checkEmulator is AUTO-MAS check_emulator: the package's sdk\emulator is complete, its Pkg.BuildId is mas-, +// and its qemu is our own build (it knows the free-page-reporting balloon option). +func checkEmulator(emu string, want int, target Version) error { p := root.CurrentPlatform() qemu := filepath.Join(emu, "qemu", p.QemuDir, p.QEMU) if !isFile(filepath.Join(emu, p.Emulator)) || !isFile(qemu) { @@ -566,8 +577,8 @@ func checkEmulator(emu string, target Version) error { build = m[1] } } - if build != fmt.Sprint(target.Build) { - return packageError("包里模拟器的构建号(%s)和要装的 %s(构建号 %d)不一致", build, target.Display(), target.Build) + if build != fmt.Sprint(want) { + return packageError("包里模拟器的构建号(%s)和要装的 %s(构建号 %d)不一致", build, target.Display(), want) } if !fileContains(filepath.Join(emu, "qemu", p.QemuDir, p.QEMUHeadless), []byte("free-page-reporting")) { return packageError("包里的模拟器不是 ModAVD 的自编版") @@ -631,7 +642,7 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { } verified = "manifest" } - if err := checkEmulator(l.pkgAt("sdk/emulator"), target); err != nil { + if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { return "", err } return verified, nil @@ -639,7 +650,7 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { // buildStage moves pkg\sdk\emulator to .update\stage\emulator. func (l layout) buildStage(target Version) error { - if err := checkEmulator(l.pkgAt("sdk/emulator"), target); err != nil { + if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { return err } if err := removeTree(l.stage()); err != nil { diff --git a/internal/update/state_other.go b/internal/update/state_other.go index 0631eba..90f6af4 100644 --- a/internal/update/state_other.go +++ b/internal/update/state_other.go @@ -6,3 +6,6 @@ import "os" func isReparsePoint(st os.FileInfo) bool { return st.Mode()&os.ModeSymlink != 0 } func removeLink(p string) bool { return os.Remove(p) == nil } + +// fileInUse: a loaded library or an open file never stops a rename or an unlink here. +func fileInUse(string) bool { return false } diff --git a/internal/update/state_windows.go b/internal/update/state_windows.go index 07daeac..78ae678 100644 --- a/internal/update/state_windows.go +++ b/internal/update/state_windows.go @@ -3,11 +3,28 @@ package update import ( + "errors" "os" "syscall" ) -const fileAttributeReparsePoint = 0x400 +const ( + fileAttributeReparsePoint = 0x400 + errorSharingViolation = syscall.Errno(32) + errorLockViolation = syscall.Errno(33) +) + +// fileInUse: another process holds path so that it can be neither replaced nor deleted, e.g. a DLL MAA has loaded +// (a mapped image refuses write access) or a file opened without sharing. Opened for writing without truncating and +// closed at once; any other error (read-only, missing) is not "in use": the swap reports those itself. +func fileInUse(path string) bool { + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err == nil { + f.Close() + return false + } + return errors.Is(err, errorSharingViolation) || errors.Is(err, errorLockViolation) +} func isReparsePoint(st os.FileInfo) bool { sys, ok := st.Sys().(*syscall.Win32FileAttributeData) diff --git a/internal/update/swap.go b/internal/update/swap.go index cbd4005..4c21c01 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -243,6 +243,22 @@ func (l layout) applyRootFiles(p rootPlan) error { return nil } +// rootFileInUse is the first root file the plan replaces or deletes that another process holds (fileInUse), "" when +// none. The running program itself is left out: applyRootFiles renames it away first. +func (l layout) rootFileInUse(p rootPlan) string { + self := selfExe() + for _, rel := range append(append([]string{}, p.replace...), p.delete...) { + dst := l.at(rel) + if inFold(p.created, rel) || (self != "" && strings.EqualFold(dst, self)) || !isFile(dst) { + continue + } + if fileInUse(dst) { + return rel + } + } + return "" +} + // restoreRootFiles undoes applyRootFiles from previous\root. func (l layout) restoreRootFiles(p rootPlan) error { for _, rel := range p.created { @@ -312,6 +328,13 @@ func (u *Updater) swapIn() (string, error) { if err := l.backupRootFiles(plan); err != nil { return "", err } + // A root file another program holds (MAA loads extras\modavd_extras.dll and + // mumu-shim\shell\sdk\external_renderer_ipc.dll) cannot be replaced: found out after the renames, it would leave + // the root half swapped until that program exits. Checked while nothing has been changed yet. + if rel := l.rootFileInUse(plan); rel != "" { + l.backToStaged(st) + return "", swapBlocked("%s 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装", filepath.FromSlash(rel)) + } st.Set("phase", jsonord.NewString(phaseSwapping)) st.Set("plan", plan.value()) st.Set("swap", stepVal(stepBegin)) diff --git a/internal/update/version.go b/internal/update/version.go index 7e70e9d..39884f3 100644 --- a/internal/update/version.go +++ b/internal/update/version.go @@ -11,23 +11,24 @@ import ( "modavd/internal/root" ) -// Version is a ModAVD version: 0.. (the first number is the stage, 0 while in beta). Ordered as -// (Major, Build, Patch). AUTO-MAS update_source.ModAvdVersion. -type Version struct{ Major, Build, Patch int } +// Version is a ModAVD version: 0.. (the first number is the stage, 0 while in beta). Ordered as +// (Major, Minor, Patch). Through 0.40 the minor number was the emulator build (Pkg.BuildId mas-N); later versions +// are numbered on their own and the package says its emulator build in modavd-manifest.json's "build". +type Version struct{ Major, Minor, Patch int } // String is the version's identity, vX.Y.Z: state.json, comparisons, MirrorChyan's current_version. -func (v Version) String() string { return fmt.Sprintf("v%d.%d.%d", v.Major, v.Build, v.Patch) } +func (v Version) String() string { return fmt.Sprintf("v%d.%d.%d", v.Major, v.Minor, v.Patch) } // Display is X.Y.Z, for people. -func (v Version) Display() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Build, v.Patch) } +func (v Version) Display() string { return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) } -// Less orders versions as (Major, Build, Patch). +// Less orders versions as (Major, Minor, Patch). func (v Version) Less(o Version) bool { if v.Major != o.Major { return v.Major < o.Major } - if v.Build != o.Build { - return v.Build < o.Build + if v.Minor != o.Minor { + return v.Minor < o.Minor } return v.Patch < o.Patch } @@ -65,18 +66,10 @@ func displayVersion(text string) string { return text } -// LocalVersion is the installed package's version: the root manifest's when its build matches sdk\emulator's -// Pkg.BuildId (or that is unknown), else v0..0 (sdk\emulator replaced by hand), else unknown. +// LocalVersion is the installed package's version (root.InstalledVersion). func LocalVersion(r *root.Root) (Version, bool) { - build, haveBuild := r.ModBuild() - declared, haveDeclared := manifestVersion(r.Dir) - switch { - case haveDeclared && (!haveBuild || declared.Build == build): - return declared, true - case haveBuild: - return Version{0, build, 0}, true - } - return Version{}, false + major, minor, patch, ok := r.InstalledVersion() + return Version{major, minor, patch}, ok } func manifestVersion(dir string) (Version, bool) { diff --git a/internal/win/mem.go b/internal/win/mem.go new file mode 100644 index 0000000..2ae5992 --- /dev/null +++ b/internal/win/mem.go @@ -0,0 +1,68 @@ +//go:build windows + +package win + +import ( + "syscall" + "unsafe" +) + +var ( + procK32GetProcessMemoryInfo = modkernel32.NewProc("K32GetProcessMemoryInfo") + procGlobalMemoryStatusEx = modkernel32.NewProc("GlobalMemoryStatusEx") +) + +// processMemoryCountersEx is PROCESS_MEMORY_COUNTERS_EX. +type processMemoryCountersEx struct { + cb uint32 + pageFaultCount uint32 + peakWorkingSetSize uintptr + workingSetSize uintptr + quotaPeakPagedPoolUsage uintptr + quotaPagedPoolUsage uintptr + quotaPeakNonPagedPoolUsage uintptr + quotaNonPagedPoolUsage uintptr + pagefileUsage uintptr + peakPagefileUsage uintptr + privateUsage uintptr +} + +// Memory is GetProcessMemoryInfo of p, in bytes: commit is its private commit charge (Task Manager's "Commit size"), +// peakCommit the highest that has been, workingSet what it has in physical memory now. +func (p Process) Memory() (commit, peakCommit, workingSet uint64, err error) { + h, err := p.open(0) + if err != nil { + return 0, 0, 0, err + } + defer syscall.CloseHandle(h) + var c processMemoryCountersEx + c.cb = uint32(unsafe.Sizeof(c)) + if r, _, e := procK32GetProcessMemoryInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&c)), uintptr(c.cb)); r == 0 { + return 0, 0, 0, e + } + return uint64(c.privateUsage), uint64(c.peakPagefileUsage), uint64(c.workingSetSize), nil +} + +// memoryStatusEx is MEMORYSTATUSEX. +type memoryStatusEx struct { + length uint32 + memoryLoad uint32 + totalPhys uint64 + availPhys uint64 + totalPageFile uint64 + availPageFile uint64 + totalVirtual uint64 + availVirtual uint64 + availExtendedVirtual uint64 +} + +// SystemMemory is GlobalMemoryStatusEx, in bytes: physical memory in all and available, the commit limit (physical +// memory plus page files) and how much of it is committed. +func SystemMemory() (totalPhys, availPhys, commitLimit, committed uint64, err error) { + var s memoryStatusEx + s.length = uint32(unsafe.Sizeof(s)) + if r, _, e := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&s))); r == 0 { + return 0, 0, 0, 0, e + } + return s.totalPhys, s.availPhys, s.totalPageFile, s.totalPageFile - min(s.availPageFile, s.totalPageFile), nil +} diff --git a/release/0.9.0.SHA256SUMS.txt b/release/0.9.0.SHA256SUMS.txt new file mode 100644 index 0000000..c3fc022 --- /dev/null +++ b/release/0.9.0.SHA256SUMS.txt @@ -0,0 +1,3 @@ +5dbdd688052859a8e0b0cea5a0ac77a255ac2df9b3bd4b468fd251734f786259 ModAVD.exe +a0e10bf96063d8abab6a8502b10604cdab59743becdd8b0c29e6c5496d839b4e ModAVDManager-0.9.0-win-x64.zip +bc35ebd7d67759e2bf9bb8519d52027d6321f46758e1bc9d29c08c872c58f62c tools/modavd.exe diff --git a/release/0.9.0.md b/release/0.9.0.md new file mode 100644 index 0000000..6788ab3 --- /dev/null +++ b/release/0.9.0.md @@ -0,0 +1,62 @@ +# ModAVDManager v0.9.0 + +- `ModAVD.exe`:`5dbdd688052859a8e0b0cea5a0ac77a255ac2df9b3bd4b468fd251734f786259` +- `tools/modavd.exe`:`bc35ebd7d67759e2bf9bb8519d52027d6321f46758e1bc9d29c08c872c58f62c` + +随 ModAVD 0.40.1 进包。下面是相对 v0.8.0 的改动。这一版主要是:没起名的实例显示 `emu-N`,不再显示文件名 `mas_N`,卡片上能看到实例占了多少硬盘;公开版本号和模拟器构建号分开;整包更新遇到根目录文件被别的程序(比如 MAA)占着时,换包前就停,不再换到一半;问题包里能看出模拟器占了多少内存。 + +## 变更 + +- 没起名的实例显示 `emu-0`、`emu-1` 这样的名字,不再显示文件名 `mas_0`。新建和设置里名称框的占位、「不填就显示…」提示跟着改。卡片副标题照旧写编号,起了名的照旧带文件名。盘上的 `avd\mas_N.avd`、`mas_N.ini`、日志名都不变,老根目录不用搬。(ModAVDManager#135、ModAVDManager#139) +- 卡片上的规格不再写「盘自动扩」:自动扩盘是默认,不用写;指定了大小的照旧写「盘 N GB」。(ModAVDManager#139) +- 卡片上的规格末尾加上数据盘现在实际占的电脑硬盘,比如「占 12.3 GB」,只算写进去的;读不到就不写。(ModAVDManager#140) +- 电脑检查去掉「检查对象」下拉框:0.7.0 起开机前不再按实例查内存,所有实例的检查结果都一样。检查结果的标题是「暂时不能开机」,顶栏状态去掉「内存不足」。(ModAVDManager#136) +- 版本号和模拟器构建号分开:ModAVD 0.40.1 以后的公开版本号自己往上加,模拟器构建号(`mas-N`)只记在包里 `modavd-manifest.json` 的 `"build"`。管理器认本地版本、装包时核对模拟器都改看 `"build"`;没有 `"build"` 的老包照旧按版本中间那位核对。0.8.0 及更早的管理器还要求版本中间那位等于构建号,所以带它们的根目录要先更新到 0.40.1,以后的版本才能用「更新」装上。开机要求的最低构建号照旧按 `mas-N`。(ModAVDManager#135) +- 开机守护日志 `logs\modavd-bootwatch.log` 在开机后和之后每 10 分钟写一行模拟器的提交内存、工作集和电脑的剩余内存;问题包 `summary.txt` 里每个 qemu-system 进程多一行内存,另加一节「占内存最多的程序」(按提交排前 10 个,同名合并,只写程序名、进程数和内存,不写命令行和路径)。只在 Windows 上有。(ModAVDManager#138) + +## 修复 + +- 整包更新时,如果根目录里要换或要删的文件正被别的程序载入着(比如 MAA 载入的 `extras\modavd_extras.dll`),以前要到换完 `sdk\emulator` 以后替换它时才失败,根目录停在换了一半的状态,直到关掉那个程序才能收尾。现在换包前先逐个试一下:被占着就停,什么都没动,暂存的新版留着,提示是哪个文件、先关掉载入它的程序再装。只在 Windows 上检查。(ModAVDManager#137) + +## 给调用方(AUTO-MAS) + +- `protocol` 还是 2:没有删命令、参数和 `--json` 字段,没有新的错误码和退出码。调用方不用改。 +- `modavd list` 的人读表格:没起名的实例,名称列写 `emu-N`(以前写文件名 `mas_N`)。`--json` 的 `name`(`mas_N`)、`title` 不变。(ModAVDManager#135、ModAVDManager#139) +- `update install` 遇到根目录文件被占用时是 `busy`(退出码 5),消息「<文件> 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装」;状态退回「待安装」,关掉那个程序后再 `install` 就行。(ModAVDManager#137) +- 管理器自己的接口 `GET /api/check` 去掉了 `index` 参数和返回里的 `instance_index`、`memory_mb`、`root_ready`;`ready` 为 `false` 就是所有实例都开不了机。命令行 `check --memory` 和 MCP `check_host` 的 `memory_mb` 照旧接受、不起作用。(ModAVDManager#136) + +## 下载与核对 + +用 Go 1.26.5、`GOTOOLCHAIN=local` 运行仓库里的 `build.ps1`,能编出相同的字节。单独下载的包里还有 Go 的许可原文和文件清单。请用附件 `SHA256SUMS.txt` 核对 zip 和解压出的两个 exe,例如在 PowerShell 里运行 `Get-FileHash .\ModAVDManager-0.9.0-win-x64.zip -Algorithm SHA256`。 + +单独下载的管理器不会自己更新,新版本要手动替换;ModAVD 包里的那份随 ModAVD 整包一起更新。单独使用时请给 `--root` 或设置 `MODAVD_ROOT`,否则会提示找不到 ModAVD 根目录。不要把单独下载的 zip 解压到 ModAVD 根目录,覆盖整包里的文件。 + +## English + +Shipped in ModAVD 0.40.1. Changes since v0.8.0. This release mostly brings: instances without a name of their own show as `emu-N` instead of the file name `mas_N`, and the card shows how much disk each instance takes; the public version number is separate from the emulator build number; a whole-package update stops before swapping anything when another program (such as MAA) holds a root file, instead of stopping halfway; and problem reports show how much memory the emulator takes. + +### Changes + +- Instances without a name of their own show as `emu-0`, `emu-1` and so on instead of the file name `mas_0`. The placeholder and the hint of the name box in the create dialog and the settings follow. The card subtitle still shows the index, and named instances still show the file name. `avd\mas_N.avd`, `mas_N.ini` and the log names on disk do not change, so old roots need no moving. (ModAVDManager#135, ModAVDManager#139) +- The card no longer says "盘自动扩" (disk grows by itself): it is the default and needs no mention; a set size still shows as "盘 N GB". (ModAVDManager#139) +- The card ends with what the data disk takes on the computer's disk now, such as "占 12.3 GB" (takes 12.3 GB), counting only what was written; left out when it cannot be read. (ModAVDManager#140) +- The host check drops its "检查对象" (checked for) drop-down: since 0.7.0 nothing is checked per instance before a start, so every instance gets the same result. The result title is "暂时不能开机" (cannot start yet), and the top bar no longer has an "out of memory" state. (ModAVDManager#136) +- The version number is separate from the emulator build number: after ModAVD 0.40.1 the public version goes up on its own, and the emulator build (`mas-N`) is recorded only as `"build"` in the package's `modavd-manifest.json`. The manager reads `"build"` to tell the local version and to check the emulator when installing a package; old packages without `"build"` are checked against the middle number of the version as before. Managers 0.8.0 and older still require the middle number to equal the build, so a root with one of them has to update to 0.40.1 first before later versions install through "更新" (update). The minimum build needed to start is still in `mas-N`. (ModAVDManager#135) +- The boot watcher log `logs\modavd-bootwatch.log` gets a line after boot and every 10 minutes with the emulator's commit and working set and the computer's free memory; the problem report's `summary.txt` gets a memory line for each qemu-system process and a section with the 10 programs that commit the most memory (same names merged; only program names, process counts and memory, no command lines or paths). Windows only. (ModAVDManager#138) + +### Fixes + +- When a root file to be replaced or deleted was loaded by another program during a whole-package update (for example `extras\modavd_extras.dll` loaded by MAA), the update used to fail only when replacing it after `sdk\emulator` had been swapped, leaving the root half swapped until that program was closed. Now each file is tried before the swap: if one is held, the update stops with nothing changed, keeps the staged package, and says which file it is and to close the program that loaded it first. Windows only. (ModAVDManager#137) + +### For callers (AUTO-MAS) + +- `protocol` is still 2: no command, option or `--json` field was removed, and there are no new error codes or exit codes. Callers need no change. +- The human-readable table of `modavd list` shows `emu-N` in the name column for instances without a name of their own (the file name `mas_N` before). `name` (`mas_N`) and `title` in `--json` do not change. (ModAVDManager#135, ModAVDManager#139) +- `update install` returns `busy` (exit code 5) when a root file is held by another program, with the message " 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装" (the file is in use by another program, probably something like MAA that loaded it; nothing was changed; close it first); the state goes back to staged, and `install` works again once that program is closed. (ModAVDManager#137) +- The manager's own `GET /api/check` drops the `index` parameter and `instance_index`, `memory_mb` and `root_ready` from its result; `ready` being `false` means no instance can start. `check --memory` on the command line and `memory_mb` of MCP `check_host` are still accepted and have no effect. (ModAVDManager#136) + +### Download and verification + +Running the repository's `build.ps1` with Go 1.26.5 and `GOTOOLCHAIN=local` builds the same bytes. The standalone package also carries Go's license text and the file list. Check the zip and the two extracted exe files with the attached `SHA256SUMS.txt`, for example `Get-FileHash .\ModAVDManager-0.9.0-win-x64.zip -Algorithm SHA256` in PowerShell. + +The standalone manager does not update itself; replace it by hand for a new version. The copy in the ModAVD package is updated with the whole ModAVD package. When using it standalone, pass `--root` or set `MODAVD_ROOT`, or it reports that it cannot find the ModAVD root. Do not extract the standalone zip into a ModAVD root over the package's files. diff --git a/rsrc_windows_amd64.syso b/rsrc_windows_amd64.syso index 029beb4d2e6c8f9f3038da84409c091bccbd58fb..555b9cb6da54464fd4557293bf73af8da6163d99 100644 GIT binary patch delta 43 qcmX@Jm*vb}mWC~iJz|`k3=qJ%eX1CvG9#nq_MPGo`mO}ydsYAm-wRa$ delta 43 qcmX@Jm*vb}mWC~iJz|_33=qJveX1CvG9#nK_MPGo`mO}ydsYAmlM7J* diff --git a/scripts/test_card_cfg.mjs b/scripts/test_card_cfg.mjs new file mode 100644 index 0000000..88eb159 --- /dev/null +++ b/scripts/test_card_cfg.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const source = await readFile(new URL('../internal/ui/web/js/format.js', import.meta.url), 'utf8'); +const { cfgText } = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + +const cfg = { memory_mb: 4096, cpu: 4, fps: 30, data_disk_mode: 'auto', data_partition_gb: 256 }; + +test('the card shows what the data disk takes on the computer', () => { + assert.equal(cfgText(cfg, { host_bytes: 12.34 * 2 ** 30 }), '内存 4 GB · 4 核 · 30 Hz · 占 12.3 GB'); + assert.equal(cfgText({ ...cfg, data_disk_mode: 'manual', data_partition_gb: 64 }, { host_bytes: 512 * 2 ** 20 }), + '内存 4 GB · 4 核 · 30 Hz · 盘 64 GB · 占 512 MB'); +}); + +test('an unknown size is left out, not shown as a dash', () => { + assert.equal(cfgText(cfg, { host_bytes: -1 }), '内存 4 GB · 4 核 · 30 Hz'); + assert.equal(cfgText(cfg, null), '内存 4 GB · 4 核 · 30 Hz'); + assert.equal(cfgText(cfg), '内存 4 GB · 4 核 · 30 Hz'); +}); diff --git a/scripts/test_create_cancel.mjs b/scripts/test_create_cancel.mjs index 08d12be..e3ba6f5 100644 --- a/scripts/test_create_cancel.mjs +++ b/scripts/test_create_cancel.mjs @@ -17,7 +17,7 @@ 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', 'gles32Text'].map(k => [k, noop])), + 'format.js': Object.fromEntries(['label', 'ownTitle', 'defaultName', '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 }, diff --git a/scripts/test_gles32_ui.mjs b/scripts/test_gles32_ui.mjs index 82ae872..7f45182 100644 --- a/scripts/test_gles32_ui.mjs +++ b/scripts/test_gles32_ui.mjs @@ -120,7 +120,7 @@ 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', 'gles32Text'].map(k => [k, noop])), + 'format.js': Object.fromEntries(['label', 'ownTitle', 'defaultName', '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 }, diff --git a/scripts/test_playstore_ui.mjs b/scripts/test_playstore_ui.mjs index 2a7b768..e1ea34a 100644 --- a/scripts/test_playstore_ui.mjs +++ b/scripts/test_playstore_ui.mjs @@ -120,7 +120,7 @@ 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', 'gles32Text'].map(k => [k, noop])), + 'format.js': Object.fromEntries(['label', 'ownTitle', 'defaultName', '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 }, From fc0d77d1a102d63ec500e26ff5993e35fed7b469 Mon Sep 17 00:00:00 2001 From: qiyinxi <52456734+qiyinxi@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:00:35 +0000 Subject: [PATCH 03/17] =?UTF-8?q?=E5=85=B3=E7=AA=97=E5=8F=A3=E3=80=81?= =?UTF-8?q?=E5=AE=89=E5=8D=93=E9=87=8C=E5=85=B3=E6=9C=BA=E6=97=B6=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E6=8A=A5=E5=85=B3=E6=9C=BA=E4=B8=AD=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=98=BE=E7=A4=BA=E5=BC=80=E6=9C=BA=E4=B8=AD=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不经 modavd 的关机没有关机锁:安卓先停、qemu 晚几秒才退,这段原来落到 starting。 现在实例在跑、没开完、没有任何锁时,adb 已够不到客体、又没有这个 qemu 没做完的 modavd 开机(设置标记),就报 busy=stopping;status 照旧是 starting。 --- internal/cli/commands.go | 2 +- internal/emu/closing_test.go | 53 +++++++++++++++++++++++++++++++++ internal/emu/createinit_test.go | 4 +-- internal/emu/manager.go | 9 +++++- internal/emu/manager_test.go | 4 +-- internal/emu/status.go | 30 ++++++++++++++++--- internal/emu/types.go | 3 +- 7 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 internal/emu/closing_test.go diff --git a/internal/cli/commands.go b/internal/cli/commands.go index cd1fbbf..2dc2ec4 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -187,7 +187,7 @@ func init() { name: "status", args: "", summary: "一台实例的状态", details: "status:offline 已关机 / starting 开机中 / online 运行中 / creating 新建中 / create_failed 新建失败;" + "running 是 qemu 进程在不在,booted 是 Android 开完没有(新建中这两个可能是 true,以 status 为准);" + - "busy 是另一个 modavd 正在开机(starting)或关机(stopping)它;新建中 creation_progress 是做完的百分比(0–100,按做完的步骤算)。", + "busy 是另一个 modavd 正在开机(starting)或关机(stopping)它,它自己在关(关了窗口、安卓里关机)也报 stopping;新建中 creation_progress 是做完的百分比(0–100,按做完的步骤算)。", posNames: []string{"实例编号"}, minPos: 1, maxPos: 1, run: runStatus, human: humanOf(formatStatus), }, diff --git a/internal/emu/closing_test.go b/internal/emu/closing_test.go new file mode 100644 index 0000000..d0e1fcf --- /dev/null +++ b/internal/emu/closing_test.go @@ -0,0 +1,53 @@ +//go:build windows + +package emu + +import ( + "testing" + "time" + + "modavd/internal/win" +) + +// TestClosingItself: a running, not booted instance reads as going down by itself only when adb no longer reaches +// the guest and no unfinished modavd boot of this qemu is recorded (setup marker). +func TestClosingItself(t *testing.T) { + m := markerTestManager(t) + now := time.Now() + qemuAt := func(age time.Duration) win.Process { + return win.Process{PID: 4000, PPID: 3000, Name: "qemu-system-x86_64.exe", Created: filetimeOf(now.Add(-age))} + } + old, young := qemuAt(10*time.Minute), qemuAt(20*time.Second) + + if !m.closingItself(3, old, false, now) { + t.Error("old qemu, adb gone, no marker: want closing") + } + if m.closingItself(3, old, true, now) { + t.Error("adb still answers (guest reboot, booting): want not closing") + } + if !m.closingItself(3, young, false, now) { + t.Error("window closed right after the boot: want closing") + } + + // an interrupted modavd boot of this qemu (marker before its deadline): still booting + mk := setupMarker{QemuPID: old.PID, QemuCreated: old.Created, ValidUntil: now.Add(time.Hour).Unix()} + m.writeSetupMarker(3, mk) + if got, ok := m.readSetupMarker(3); !ok || got != mk { + t.Fatalf("marker not written: %+v %v", got, ok) + } + if m.closingItself(3, old, false, now) { + t.Error("unfinished modavd boot of this qemu: want not closing") + } + // the marker of another qemu (an earlier boot) does not count + mk.QemuPID = 4001 + m.writeSetupMarker(3, mk) + if !m.closingItself(3, old, false, now) { + t.Error("marker of another qemu: want closing") + } + // a marker past its deadline: that boot has long been in use + mk.QemuPID, mk.ValidUntil = old.PID, now.Add(-time.Minute).Unix() + m.writeSetupMarker(3, mk) + if !m.closingItself(3, old, false, now) { + t.Error("stale marker: want closing") + } +} diff --git a/internal/emu/createinit_test.go b/internal/emu/createinit_test.go index af7ce77..1f10e8c 100644 --- a/internal/emu/createinit_test.go +++ b/internal/emu/createinit_test.go @@ -257,7 +257,7 @@ func TestStatusCreatingThroughGetprop(t *testing.T) { savedScan, savedBooted := qemuScan, statusBooted t.Cleanup(func() { qemuScan, statusBooted = savedScan, savedBooted }) qemuScan = func([]int) map[int]win.Process { return map[int]win.Process{6: {PID: 4242}} } - statusBooted = func(m *Manager, _ context.Context, i int) bool { + statusBooted = func(m *Manager, _ context.Context, i int) (bool, bool) { // the setup is over while the guest shuts down m.removeCreateProgress(i) if err := m.setCreation(i, "", "", ""); err != nil { @@ -265,7 +265,7 @@ func TestStatusCreatingThroughGetprop(t *testing.T) { } l.Unlock() unlocked = true - return false + return false, false } info, err := m.Info(ctx, 6) if err != nil || !info.Running || info.Status != StatusCreating || info.Busy != "creating" || info.CreationProgress != 88 { diff --git a/internal/emu/manager.go b/internal/emu/manager.go index 80f550d..590fe9d 100644 --- a/internal/emu/manager.go +++ b/internal/emu/manager.go @@ -236,9 +236,16 @@ func (m *Manager) findQemu(i int) (host.Process, bool, error) { // bootCompleted: `getprop sys.boot_completed` says 1. reregister=false while the instance is booting (it has not // registered with the private server yet, which is normal then). func (m *Manager) bootCompleted(ctx context.Context, i int, timeout time.Duration, reregister bool) bool { + booted, _ := m.bootProbe(ctx, i, timeout, reregister) + return booted +} + +// bootProbe is bootCompleted plus answered: the getprop ran in the guest at all (adb exit 0), so the device was +// reachable even when it printed nothing. +func (m *Manager) bootProbe(ctx context.Context, i int, timeout time.Duration, reregister bool) (booted, answered bool) { res := m.ADB.Run(ctx, adb.RunOptions{Serial: m.Root.Ports().Serial(i), Timeout: timeout, Reregister: reregister}, "shell", "getprop", "sys.boot_completed") - return res.Code == 0 && strings.TrimSpace(res.Out) == "1" + return res.Code == 0 && strings.TrimSpace(res.Out) == "1", res.Code == 0 } // ---- guest commands ---------------------------------------------------------------------------------------------- diff --git a/internal/emu/manager_test.go b/internal/emu/manager_test.go index 75ce801..0bd2bec 100644 --- a/internal/emu/manager_test.go +++ b/internal/emu/manager_test.go @@ -122,9 +122,9 @@ func TestStopTargetsIncludesOnlyActiveCreationsAndQemu(t *testing.T) { } return map[int]win.Process{2: {PID: 4242}, 3: {PID: 4243}, 9: {PID: 4249}} } - statusBooted = func(*Manager, context.Context, int) bool { + statusBooted = func(*Manager, context.Context, int) (bool, bool) { t.Fatal("stop target discovery called adb") - return false + return false, false } targets, err := m.StopTargets(context.Background()) if err != nil || !slices.Equal(targets, []int{1, 2, 3}) || scans != 1 { diff --git a/internal/emu/status.go b/internal/emu/status.go index 239bc41..ec9ed32 100644 --- a/internal/emu/status.go +++ b/internal/emu/status.go @@ -23,9 +23,25 @@ import ( // depend on what runs on the machine either. var qemuScan func(indexes []int) map[int]host.Process -// statusBooted is the getprop of instanceInfo; a test replaces it. -var statusBooted = func(m *Manager, ctx context.Context, i int) bool { - return m.bootCompleted(ctx, i, statusAdbTimeout, true) +// statusBooted is the getprop of instanceInfo; a test replaces it. answered: adb reached the guest at all (the +// getprop ran, whatever it printed); false when the device is offline or unknown to the server. +var statusBooted = func(m *Manager, ctx context.Context, i int) (booted, answered bool) { + return m.bootProbe(ctx, i, statusAdbTimeout, true) +} + +// closingItself: a running, not booted instance that no modavd is starting, stopping or bridging is going down by +// itself (its window was closed, Android was powered off inside): no unfinished modavd boot of this qemu (setup +// marker; a start in progress holds the start lock anyway) and adb no longer reaches the guest. Measured 11-13 on a +// windowed instance: after WM_CLOSE the guest says sys.powerctl=shutdown, adb loses it at ~3 s and qemu exits at +// ~7.5 s; that gap read as "starting" (开机中). A guest reboot reads the same until its adbd is back (seconds), then +// "starting" again; so does a qemu launched outside modavd until its adbd is up. +func (m *Manager) closingItself(i int, p host.Process, answered bool, now time.Time) bool { + if answered { + return false + } + mk, ok := m.readSetupMarker(i) + use, _ := setupMarkerUse(mk, ok, p, now) + return !use } // qemus is the process scan of the queries: qemuScan when a test set it, else m.scanQemu. @@ -161,6 +177,7 @@ func (m *Manager) instanceInfo(ctx context.Context, in *avd.Instance, p *host.Pr if info.Creation == creationCreating { info.CreationProgress = m.createPercent(i) } + closing := false if p != nil { info.Running = true info.Window, _ = qemuWindowed(*p) @@ -169,17 +186,22 @@ func (m *Manager) instanceInfo(ctx context.Context, in *avd.Instance, p *host.Pr info.StartedAt = t.Format(time.RFC3339) info.StartedAtMS = t.UnixMilli() } + answered := true if booted != nil { info.Booted = *booted } else { - info.Booted = statusBooted(m, ctx, i) + info.Booted, answered = statusBooted(m, ctx, i) } + closing = !info.Booted && m.closingItself(i, *p, answered, time.Now()) // Only for a running instance: the name is per console port, not per root, so an offline instance would // report another root's mapping on the same port. info.Screencap.ShmReady = host.SharedMemoryExists(info.Screencap.Shm) } info.Status = statusOf(info.Running, info.Booted) info.Busy = busyOf(m.stopping(i), m.lockHeld(i, "start"), m.lockHeld(i, "bridge")) + if info.Busy == "" && closing { + info.Busy = "stopping" + } // "creating" overrides what the boots of that setup look like from outside switch info.Creation { case creationCreating: diff --git a/internal/emu/types.go b/internal/emu/types.go index ee439c1..80a67cc 100644 --- a/internal/emu/types.go +++ b/internal/emu/types.go @@ -57,7 +57,8 @@ type InstanceInfo struct { Window bool `json:"window"` // Busy: "starting" / "stopping" / "bridging" while some modavd process holds that lock of the instance (AUTO-MAS's // updater must not swap sdk\emulator then); "" otherwise. "bridging" covers the whole bridge install, including the - // stops and cold boots in it; callers that do not know a value treat it as busy. + // stops and cold boots in it; callers that do not know a value treat it as busy. "stopping" also when no lock is + // held but the instance is going down by itself (window closed, Android powered off; see closingItself). Busy string `json:"busy"` // Bridge (the arm64 execution bridge): "off" (instances..bridge is false), "pending" (the next start modavd // launches installs it), "installed" (the booted guest runs it) or "unknown". Only a booted guest is asked; an From 9f43cc8be228fce18807fa55a1e457fdbb358377 Mon Sep 17 00:00:00 2001 From: qiyinxi <52456734+qiyinxi@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:00:37 +0000 Subject: [PATCH 04/17] =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E8=BF=90=E8=A1=8C=E6=97=B6=E9=95=BF=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=E5=AE=9E=E4=BE=8B=E5=8E=BB=E6=8E=89=E3=80=8C?= =?UTF-8?q?=E6=AF=94=E5=A6=82=EF=BC=9A=E3=80=8D=E5=92=8C=E7=BC=96=E5=8F=B7?= =?UTF-8?q?=E9=80=89=E9=A1=B9=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 实例卡片不再显示运行时长,和「运行中」标签重复 * 新建实例:名称占位去掉「比如:」,去掉编号选项(一律自动) --- internal/ui/web/js/cards.js | 3 +-- internal/ui/web/js/forms.js | 22 +++++----------------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/internal/ui/web/js/cards.js b/internal/ui/web/js/cards.js index ce41bc0..54c11ef 100644 --- a/internal/ui/web/js/cards.js +++ b/internal/ui/web/js/cards.js @@ -3,7 +3,7 @@ import { h, icon, btn, ibtn, keyed, setText, attr } from './dom.js'; import { S, lastJob, rootBlocked } from './store.js'; -import { label, ownTitle, uptime, stripInst, pct, cfgText } from './format.js'; +import { label, ownTitle, stripInst, pct, cfgText } from './format.js'; import * as A from './actions.js'; import { openMenu } from './ui.js'; import { openSettings, openDelete } from './forms.js'; @@ -187,7 +187,6 @@ function updateCard(c, inst) { setText(c.title, (c.title.title = name)); const sub = []; if (ownTitle(inst)) sub.push(inst.name); - if (live && inst.started_at) sub.push(uptime(inst.started_at)); const subtitle = sub.join(' · ') || `编号 ${inst.index}`; const headless = inst.running && inst.window === false && v.key !== 'creating'; setText(c.sub, subtitle + (headless ? ' · 无头' : '') + (inst.diagnostics ? ' · 诊断模式' : '')); diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js index 592a6eb..dc7e34f 100644 --- a/internal/ui/web/js/forms.js +++ b/internal/ui/web/js/forms.js @@ -158,21 +158,10 @@ export function openCreate() { toast({ tone: 'info', title: '已经有 9 台实例了', text: '最多 9 台(编号 0–9,5 除外)。删掉不用的实例再新建。' }); return; } - const title = h('input', { class: 'input', id: 'c-title', maxlength: '40', autocomplete: 'off', placeholder: '比如:明日方舟 · 主号' }); - const titleHint = hint(''); + const title = h('input', { class: 'input', id: 'c-title', maxlength: '40', autocomplete: 'off', placeholder: '明日方舟 · 主号' }); + // the number is always picked by the server (the first free one); the dialog no longer offers a choice + const titleHint = hint(`不填就显示「${defaultName(free[0])}」,以后可以随时改`); const disk = diskField('c', 'auto', 64, 16); - const idx = h('select', { class: 'select', id: 'c-index' }, h('option', { value: '', text: `自动(编号 ${free[0]})` }), - free.map((i) => h('option', { value: String(i), text: `编号 ${i}` }))); - const idxHint = hint(''); - // the root's port plan (mas-avd.json portBase); a manager without port_base only knows 20000 - const base = S.list.port_base || 20000; - const sync = () => { - const i = idx.value === '' ? free[0] : Number(idx.value); - titleHint.textContent = `不填就显示「${defaultName(i)}」,以后可以随时改`; - idxHint.replaceChildren('编号决定端口,脚本连 ', h('b', { text: `127.0.0.1:${base + 1 + 10 * i}` })); - }; - idx.addEventListener('change', sync); - sync(); const fps = fpsField('c', 60); const box = errorBox(); const ok = h('button', { class: 'btn btn-primary', type: 'submit' }, icon('plus'), h('span', { text: '新建' })); @@ -184,8 +173,7 @@ export function openCreate() { segRow('CPU', 'cpu', CPU, 6), fps.rows, segRow('分辨率', 'resolution', RESOLUTION, 1080, hint('720p 更省资源')), - disk.rows, - inputRow('编号', idx, idxHint)), box), + disk.rows), box), actions: { ok }, onSubmit() { const f = ctl.form.elements; @@ -198,7 +186,7 @@ export function openCreate() { const job = await api.post('/api/instances', { title: title.value.trim() || undefined, memory_mb: Number(f.memory.value), cpu: Number(f.cpu.value), // no size: 自动扩盘 - fps: fpsValue, resolution: Number(f.resolution.value), data_partition_gb: d.auto ? undefined : d.gb, index: idx.value === '' ? undefined : Number(idx.value), + fps: fpsValue, resolution: Number(f.resolution.value), data_partition_gb: d.auto ? undefined : d.gb, }); A.onJob(job); S.watch.add(job.id); From 04ae48bbbd1fe5b21f65400b8f236f179dcd6fb4 Mon Sep 17 00:00:00 2001 From: qiyinxi <52456734+qiyinxi@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:06:13 +0000 Subject: [PATCH 05/17] =?UTF-8?q?=E8=A3=85=E6=89=A7=E8=A1=8C=E6=A1=A5?= =?UTF-8?q?=E6=97=B6=E5=87=86=E5=A4=87=E9=98=B6=E6=AE=B5=E7=9A=84=E5=BC=80?= =?UTF-8?q?=E6=9C=BA=E4=B8=80=E5=BE=8B=E6=97=A0=E5=A4=B4=EF=BC=8C=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E4=B8=80=E6=AC=A1=E6=89=8D=E5=B8=A6=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 带窗口开机的实例要先装 arm64 执行桥时,以前装桥前后的每次冷启动都沿用带窗口, 模拟器窗口会弹出、关掉、再弹出好几次。现在: - 开机:实例还没有执行桥的开机标记(一定要装桥)时,第一次开机就无头;有标记的 要开起来才知道装没装,第一次照样按要求开。 - 装桥的冷启动:系统还不可写(脚本退出码 3)后的那次只做准备,无头;脚本装好 (退出码 0)后要加载桥的那次按调用方要的方式开。 - 收尾:要窗口而实例最后还是无头的(准备阶段开的、或装桥没成),正常关机 (先 sync)后再冷启动一次,带窗口;运行中不切换(Mgr#33)照旧,不接管无头的 qemu。 - modavd bridge install 重开一台带窗口、没装桥的实例时同样处理;uninstall 中间那次 后面还要再冷启动的开机无头,第二次没来时补一次带窗口的。 - 更新执行桥、Play 商店、GLES 3.2 只冷启动一次,就是最后那次,不变;新建不变。 加了 TestBridgePrepLaunch、TestStartBridgePrepHeadless、 TestBridgeUninstallRestoresWindow,改了 TestBridgeKeepsWindowMode 的预期; README、API.md 跟着改。 --- README.en.md | 4 +- README.md | 4 +- internal/emu/bridge.go | 46 ++++++--- internal/emu/bridge_prep_test.go | 162 +++++++++++++++++++++++++++++++ internal/emu/bridge_test.go | 21 +++- internal/emu/start.go | 56 +++++++++-- internal/emu/types.go | 13 +++ internal/ui/API.md | 2 +- 8 files changed, 279 insertions(+), 29 deletions(-) create mode 100644 internal/emu/bridge_prep_test.go diff --git a/README.en.md b/README.en.md index c673656..f65eae1 100644 --- a/README.en.md +++ b/README.en.md @@ -66,7 +66,7 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args] | `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb auto\|N] [--diagnostics on\|off] [--boss-key combination\|none]` | Change settings. Diagnostics and the boss key apply immediately; other settings take effect at the next start. `--data-gb auto` makes the data disk automatic, `--data-gb N` gives it a fixed size (see below); the size can only be increased, and game data is untouched: the emulator (from 0.36) grows the disk image at start and grows the file system online after boot. A disk created before 0.36 at 16 GB or smaller can only be grown to just below the next backup group (a 16 GB disk to about 30 GB). `--boss-key` is this instance's boss key (see the ModAVD.exe manager section), for example `Ctrl+Alt+Shift+1`; `none` means no boss key; Windows only. | | `proxy [on\|off\|test] [--server URL\|auto] [--add PKG] [--remove PKG] [--apps PKG]` | Per-app proxy: the listed apps go through a proxy, the others direct (see below) | | `delete --yes` | Delete a stopped instance, together with its game data | -| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | Start (headless by default, with the window for `--window`), wait until boot finishes and the post-boot setup is done, and before returning confirm that files can be written to `/sdcard` (normally it is ready long before this; it waits up to 60 more seconds and only warns at the deadline). If the instance is shutting down (for example, right after a click on the emulator window's ×), it first waits for the shutdown to finish (up to 30 seconds) and then starts it. If the instance has no execution bridge yet, install it afterward; `--no-bridge` skips that this time. No switch while it runs: if the instance is already running and `--window` / `--headless` asks for the other way, it fails (exit code 5) without taking it over or restarting it; to switch, `stop` and then `start`. With neither, it is taken over as it runs | +| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | Start (headless by default, with the window for `--window`), wait until boot finishes and the post-boot setup is done, and before returning confirm that files can be written to `/sdcard` (normally it is ready long before this; it waits up to 60 more seconds and only warns at the deadline). If the instance is shutting down (for example, right after a click on the emulator window's ×), it first waits for the shutdown to finish (up to 30 seconds) and then starts it. If the instance has no execution bridge yet, install it afterward; `--no-bridge` skips that this time. The install takes a cold boot or two: with `--window`, the boots that only prepare it are headless, and after a normal shutdown the last boot has the window (when the instance has no boot marker yet, so the bridge is surely to be installed, the first boot is headless too; with the marker there, whether the bridge is installed is only known once it runs, so the first boot has the window). No switch while it runs: if the instance is already running and `--window` / `--headless` asks for the other way, it fails (exit code 5) without taking it over or restarting it; to switch, `stop` and then `start`. With neither, it is taken over as it runs | | `stop [--force] [--cancel-create]`, `stop --all [--force]` | Shut down: an app in the foreground is first sent home and given 2 s to save; then sync and ask Android for a clean shutdown (the foreground app is paused and stopped first), and if that fails, sync + kill; `--force` is sync + force-kill. Creating instances are refused unless `--cancel-create` is given. `--all` also stops creating instances and marks their results with `interrupted_create: true` | | `bridge install\|uninstall\|verify` | Install, uninstall or check the arm64 execution bridge; it is installed automatically at start, so you normally do not run it by hand (see below) | | `playstore install\|uninstall\|status` | Add the Google Play Store to an instance, remove it, or check it (see below) | @@ -100,7 +100,7 @@ Apple Silicon macOS uses the SDK's native `qemu-img`. Host usage counts allocate - Every time modavd (the CLI, MCP) and the manager start an instance, they check: if the instance has no bridge yet, they install it after the post-boot setup is done, with one or two shutdowns and cold boots in between; if it already has the bridge but the bridge's files differ from the ones in this package (the ModAVD package was updated), they replace them the same way after the setup, followed by one cold boot. This happens only when they started the emulator this time; when taking over an instance that is already running, nothing is touched. Because the update takes a cold boot, it is put off to a later start when, once the post-boot setup is done, an app is already in the foreground or an APK is being installed. If installing or updating fails it is only a warning, the instance keeps running, and it is tried again at the next start. Instances that AUTO-MAS starts by itself do not go through this step: to get the new files, shut the instance down, then start it once with ModAVD.exe or `modavd start`, or run `bridge install`. - Before moving a root, cold-start each existing bridge instance once at the original location with the updated manager or `modavd start`, then shut it down. Move the whole root, including each instance’s `modavd-bridge-backings.json`. If the old path is missing at the next boot, the manager verifies the size and SHA-256 of the new SDK’s system and vendor images before reconnecting them. If you already moved an older instance without this record, keep its overlays, restore the root and original images at their old location, then repeat the move. - To keep an instance without the bridge, pass `--no-bridge` on create, or run `bridge uninstall` later. Both record `instances..bridge` as `false` in `mas-avd.json`, so later starts no longer install it automatically; `bridge install` again brings it back. The `--no-bridge` of `start` applies to that one run only. -- `bridge` calls `tools\modavd-bridge.ps1` in the ModAVD package. `install`: shuts down and starts again if needed, and installs; if it is already installed, it updates only when the payload has changed. `uninstall`: removes the bridge, the boot marker, and the GL transport mode (asg) that was set when the bridge was installed; uninstall before going back to an earlier version. If the instance was off before, it is shut down again afterward; if it was running, its cold boots bring it back the way it ran, with its window or headless. `verify`: a read-only check; the instance must be running. +- `bridge` calls `tools\modavd-bridge.ps1` in the ModAVD package. `install`: shuts down and starts again if needed, and installs; if it is already installed, it updates only when the payload has changed. `uninstall`: removes the bridge, the boot marker, and the GL transport mode (asg) that was set when the bridge was installed; uninstall before going back to an earlier version. If the instance was off before, it is shut down again afterward; if it was running, its last cold boot brings it back the way it ran, with its window or headless; the boots in between that only prepare (those before the bridge is installed, and the uninstall's first cold boot when another follows) are headless. `verify`: a read-only check; the instance must be running. - The `bridge` field of `info` / `list` / `status` is `off` (marked as not wanting the bridge), `pending` (will be installed at the next start that modavd launches), `installed` (the bridge is present in the running guest) or `unknown`. It only queries a guest that has finished booting. For an instance that is off or still booting, the boot marker stays once the bridge is installed and is also there when installation failed, so when first-boot initialization has been done and the marker is present, the value is `unknown`; to know the exact result, check after the instance has started. - While the bridge is being installed (including the shutdowns and cold boots in it), `busy` is `bridging`; during this time `delete` and another `bridge` return instance busy (exit code 5). Callers should treat any `busy` value they do not recognize as busy. - An instance that was not started through modavd or the manager (another program launches the emulator directly) does not get the bridge automatically; to install it, run `bridge install` once. diff --git a/README.md b/README.md index b8c248b..ee7b8c6 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数] | `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb auto\|N] [--diagnostics on\|off] [--boss-key 组合\|none]` | 修改;诊断模式和老板键立即生效,其余设置下次开机生效。`--data-gb auto` 改成自动扩盘,`--data-gb N` 改成指定大小(见下);大小只能改大,游戏数据不动:模拟器(0.36 起)开机时扩磁盘镜像,开机后在线扩文件系统。0.36 之前按 16 GB 及以下建的盘只能扩到下一个备份组之前(16 GB 约到 30 GB)。`--boss-key` 是这台的老板键(见「管理器 ModAVD.exe」一节),比如 `Ctrl+Alt+Shift+1`,`none` 是不要,只有 Windows。 | | `proxy [on\|off\|test] [--server URL\|auto] [--add 包名] [--remove 包名] [--apps 包名]` | 按应用走代理:名单里的应用走代理,其余直连(见下) | | `delete --yes` | 删除已关机的实例,连同游戏数据 | -| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 | +| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装;装桥要冷启动一两次,带 `--window` 时这几次准备用的开机都无头,装好后正常关机,最后一次才带窗口开机(实例还没有开机标记、一定要装桥时,第一次开机就无头;有标记的要开起来才知道装没装,第一次照样带窗口)。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 | | `stop [--force] [--cancel-create]`、`stop --all [--force]` | 关机:前台开着应用时先按 HOME,等 2 秒让它保存;再 sync,让安卓正常关机(前台应用先收到暂停、停止),不成再 sync + kill;`--force` 是 sync + 强杀。新建中默认拒绝,`--cancel-create` 显式取消;`--all` 也关闭新建中的实例,结果标记 `interrupted_create: true` | | `bridge install\|uninstall\|verify` | 装、卸、检查 arm64 执行桥;开机时会自动装,一般不用手动跑(见下) | | `playstore install\|uninstall\|status` | 给实例补全、移除、查看 Google Play 商店(见下) | @@ -106,7 +106,7 @@ Apple Silicon macOS 使用 SDK 中原生的 `qemu-img`。磁盘占用按宿主 - modavd(命令行、MCP)和管理器每次开机都会看一眼:实例还没有桥,就在开机后的设置做完后装上,中间关机再冷启动一两次;已经有桥、但桥的文件和这个包里的不一样(更新过 ModAVD 包),就同样在设置做完后换上新的,再冷启动一次。只在这次是它拉起的模拟器时装或更新;接管已经在运行的实例时不动。更新要冷启动,所以开机后的设置做完时已经有应用在前台、或者正在装 APK,这次就先不更新。装不上、更新不上都只是警告,实例照常运行,下次开机再试。AUTO-MAS 自己开机的实例不经过这一步:要换新文件,先关掉实例,再用 ModAVD.exe 或 `modavd start` 开一次机,或者跑 `bridge install`。 - 搬根目录前,先在原位置用新版管理器或 `modavd start` 给每台已装桥的实例冷启动一次,再关机。把整个根目录一起搬走,包括实例目录里的 `modavd-bridge-backings.json`。下次开机发现旧路径失效时,管理器会用记录的大小和 SHA-256 核对新 SDK 里的 system / vendor 镜像。老实例已经搬走且没有记录时,请保留覆盖层,将原镜像和根目录恢复到原位置,再按上述步骤搬迁。 - 不要桥的实例:新建时带 `--no-bridge`,或者之后跑 `bridge uninstall`。两种都会在 `mas-avd.json` 里记下 `instances..bridge` 为 `false`,以后开机不再自动装;再 `bridge install` 就恢复。`start` 的 `--no-bridge` 只管这一次。 -- `bridge` 调用 ModAVD 包里的 `tools\modavd-bridge.ps1`。`install`:需要时关机再开机,装上;已经装了的只在载荷有变时更新。`uninstall`:卸掉桥、开机标记和装桥时改成的 GL 传输方式(asg);退回更早的版本之前要先卸。实例原来关着的,做完再关掉;开着的,中间冷启动后照原来的方式开回来,带窗口的还带窗口,无头的还无头。`verify`:只读核对,实例要开着。 +- `bridge` 调用 ModAVD 包里的 `tools\modavd-bridge.ps1`。`install`:需要时关机再开机,装上;已经装了的只在载荷有变时更新。`uninstall`:卸掉桥、开机标记和装桥时改成的 GL 传输方式(asg);退回更早的版本之前要先卸。实例原来关着的,做完再关掉;开着的,最后照原来的方式开回来,带窗口的还带窗口,无头的还无头;中间只做准备的开机(装桥时装好之前的几次,卸桥时后面还要再冷启动的那次)都无头。`verify`:只读核对,实例要开着。 - `info` / `list` / `status` 的 `bridge`:`off`(记了不要桥)、`pending`(下次由 modavd 拉起的开机会装)、`installed`(开着的客体里桥在)、`unknown`。只问已经开完机的客体;关着或还在开机的实例,开机标记在桥装好后一直留着,装失败时也在,所以首次开机做过、标记也在时是 `unknown`,要知道确切结果就开机后再查。 - 装桥期间(含其中的关机、冷启动)`busy` 为 `bridging`;这时 `delete`、再次 `bridge` 返回实例忙(退出码 5)。调用方不认识的 `busy` 取值一律当忙处理。 - 不经 modavd 或管理器开机的实例(别的程序直接拉起模拟器)不会自动装,要装就跑一次 `bridge install`。 diff --git a/internal/emu/bridge.go b/internal/emu/bridge.go index 30dc23b..9cfac8d 100644 --- a/internal/emu/bridge.go +++ b/internal/emu/bridge.go @@ -148,7 +148,7 @@ func (m *Manager) bridgePending(ctx context.Context, i int) bool { if !regularFile(bridgeMarker(m.Root, i)) { return false } - b := m.guestBridge(ctx, i) + b := bridgeGuest(m, ctx, i) return b != "" && b != bridgeProp } @@ -331,8 +331,10 @@ func (m *Manager) runBridgeScript(ctx context.Context, i int, action string, reb } // finishBridge is the last step of a start that launched the instance, when the marker is there and the bridge is not: -// the script's install, and a cold boot (same window and timeout, without this step) whenever the bridge has not taken -// effect yet. A script that fails is only a warning: the instance keeps running, arm64 apps on the stock translator, +// the script's install, and a cold boot (same timeout, without this step) whenever the bridge has not taken effect +// yet. The cold boots that only prepare (the system not writable yet) are headless; the one after the install, which +// should load the bridge, has the window when the start asked for one (and Start's afterStartBridge boots once more +// with it when the instance is left headless). A script that fails is only a warning: the instance keeps running, arm64 apps on the stock translator, // and the next start tries again. A cold boot that fails is the start's error (the instance is off or half up then). // Returns the result of the last start. Start calls it only when bridgeWanted, which already leaves arm64 guests out. func (m *Manager) finishBridge(ctx context.Context, i int, opt StartOptions, res *StartResult) (*StartResult, error) { @@ -366,7 +368,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re opt.Progress(Progress{Step: "bridge", Message: fmt.Sprintf("实例 %d 装 arm64 执行桥(第 %d 轮)", i, round), Done: 0, Total: progressTotal}) } - run, err := mm.runBridgeScript(ctx, i, "install", false) + run, err := bridgeScript(mm, ctx, i, "install", false) if err != nil && errs.As(err).Code == errs.Canceled { res.Warnings = append(res.Warnings, col.Messages()...) return nil, err @@ -380,7 +382,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re mm.Log.Warnf("实例 %d 没装上执行桥(退出码 %d:%s),下次开机再试;日志 %s", i, run.Code, run.Last, run.LogFile) break } - if mm.guestBridge(ctx, i) == bridgeProp { + if bridgeGuest(mm, ctx, i) == bridgeProp { break } // a second "not writable" in a row: another cold boot will not change that (an emulator without the marker @@ -393,7 +395,13 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re } else { notWritable = 0 } - r2, err := m.bridgeRestart(ctx, i, opt) + // The script installed (0): this cold boot loads the bridge and should be the last one, so it comes back the + // way the start was asked for. Not writable yet (3): another round follows, and this boot only prepares, + // headless. Should the bridge still be missing after a last boot with the window, the next round's boot is + // headless again, and Start gives the window back at the end (afterStartBridge). + bopt := opt + bopt.Window = opt.Window && run.Code == 0 + r2, err := m.bridgeRestart(ctx, i, bopt) if err != nil { res.Warnings = append(res.Warnings, col.Messages()...) return nil, errs.Wrap(errs.As(err).Code, err, fmt.Sprintf("实例 %d 装执行桥时关机再开机失败: %v", i, err)) @@ -401,7 +409,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re r2.Warnings = append(res.Warnings, r2.Warnings...) res = r2 } - if mm.guestBridge(ctx, i) == bridgeProp { + if bridgeGuest(mm, ctx, i) == bridgeProp { mm.Log.Infof("实例 %d 已装好执行桥", i) } else { mm.Log.Warnf("实例 %d 没装上执行桥,下次开机再试;日志见 logs 里的 modavd-bridge-%d-*.log", i, i) @@ -414,7 +422,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re // step had nothing to do). When the guest's files are not the package's payload (bridgeOutdated: the package was // updated since), the script's install replaces the files that differ (each is pushed under a temporary name, checked // against the payload's hash and renamed into place), and one cold boot (same window and timeout, without the bridge -// step) loads them. Decided under the bridge lock: another modavd holding it is installing or updating already. +// step: it is the start's last boot) loads them. Decided under the bridge lock: another modavd holding it is installing or updating already. // // The files the guest has keep working, so the update waits for a later start while an install holds the guest lock // or an app is in the foreground once the setup is done (someone began to use the instance meanwhile; the update @@ -564,7 +572,9 @@ func init() { bridgeStart = (*Manager).Start } // Bridge installs, uninstalls or verifies the execution bridge on instance i. Restarts are cold boots: a running // instance comes back the way it ran, with its window or headless (a start never switches, StartOptions.Window; -// headless when that cannot be told), and one that was off boots headless. +// headless when that cannot be told), and one that was off boots headless. The boots in between that only prepare +// (the install's start and its cold boots up to the one that loads the bridge, the uninstall's first cold boot when a +// second follows) are headless; only the last one has the window. // - install: places the marker; a running instance without the bridge is restarted, an off one started, and the // start installs (finishBridge). An instance that already has the bridge gets the script's install anyway, which // only updates a payload that differs from this package's; then it is restarted so the new one is loaded. @@ -641,8 +651,9 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu res.Restarted = true } if !wasRunning || res.Restarted { - // this start installs (installBridge, under the lock held above; its cold boots keep the window) - sr, err := bridgeStart(m, ctx, i, StartOptions{Window: windowed, bridgeLocked: true}) + // this start installs (installBridge, under the lock held above): it boots headless while it prepares, and + // its last boot comes back the way the instance ran (bridgePrep: it was running without the bridge) + sr, err := bridgeStart(m, ctx, i, StartOptions{Window: windowed, bridgeLocked: true, bridgePrep: res.Restarted}) if err != nil { return nil, err } @@ -704,9 +715,13 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu if err != nil { return res, err } + // headless: a boot that only prepares, with another cold boot after it (an installed bridge is taken off by one); + // should that one not come, the instance gets its window back below + headless := false if run.Code == 3 { // the first remount of this instance only turned verity off: boot cold and run it again - sr, err := m.bridgeRestart(ctx, i, StartOptions{Window: windowed}) + headless = windowed && installed + sr, err := m.bridgeRestart(ctx, i, StartOptions{Window: windowed && !headless}) if err != nil { return res, err } @@ -726,6 +741,13 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu } res.Warnings = append(res.Warnings, sr.Warnings...) res.Restarted = true + } else if headless { + // the second cold boot did not come (the script did not take it off): back the way it ran + sr, err := m.bridgeRestart(ctx, i, StartOptions{Window: true}) + if err != nil { + return res, err + } + res.Warnings = append(res.Warnings, sr.Warnings...) } res.Installed = bridgeGuest(m, ctx, i) == bridgeProp // An instance whose bridge was never installed (created by modavd, never booted by it) keeps the marker when diff --git a/internal/emu/bridge_prep_test.go b/internal/emu/bridge_prep_test.go new file mode 100644 index 0000000..8e2edb5 --- /dev/null +++ b/internal/emu/bridge_prep_test.go @@ -0,0 +1,162 @@ +//go:build windows + +package emu + +import ( + "context" + "testing" + + "modavd/internal/win" +) + +// TestBridgePrepLaunch: a start asked for a window launches headless only when the bridge is surely installed after +// the launch (its marker not there yet, or the caller says so); with the marker there it launches as asked. +func TestBridgePrepLaunch(t *testing.T) { + for _, c := range []struct { + name string + window, prep, bridge, marker bool + want bool + }{ + {"window, bridge wanted, no marker yet", true, false, true, false, true}, + {"window, marker there", true, false, true, true, false}, + {"window, marker there, caller knows", true, true, true, true, true}, + {"headless start", false, false, true, false, false}, + {"headless start, caller knows", false, true, true, true, false}, + {"no bridge", true, false, false, false, false}, + {"no bridge, caller knows", true, true, false, true, false}, + } { + opt := StartOptions{Window: c.window, bridgePrep: c.prep} + if got := bridgePrepLaunch(opt, c.bridge, c.marker); got != c.want { + t.Errorf("%s: %v, want %v", c.name, got, c.want) + } + } +} + +// TestStartBridgePrepHeadless: the end of a start that launched an instance whose bridge is still to be installed. +// With a window asked for, every boot that only prepares (the cold boot after a script run that found the system not +// writable yet) is headless, and the last boot, a cold boot after a normal stop, has the window: either the one after +// the install (which loads the bridge), or one more when the instance is left headless. A headless start stays +// headless throughout. The starts here are fakes (bridgeStart): no emulator, a temporary root. +func TestStartBridgePrepHeadless(t *testing.T) { + m, i := opsTestInstance(t) + if err := writeBridgeMarker(m.Root, i); err != nil { + t.Fatal(err) + } + start, guest, script := bridgeStart, bridgeGuest, bridgeScript + t.Cleanup(func() { bridgeStart, bridgeGuest, bridgeScript = start, guest, script }) + ctx := context.Background() + const stock = "libndk_translation.so" + for _, c := range []struct { + name string + window bool // the start asked for the window + launchWindow bool // how the start launched the first boot + locked bool // bridgeLocked (no update step: the guest is never asked for hashes) + codes []int // the script's exit codes, in order (0 after them) + loadedAfter int // the guest runs the bridge after this many cold boots (-1: never) + want []bool + }{ + // the production case: marker placed by this start, so the first boot is headless; verity off, install, load + {"window, prep launch, three rounds", true, false, false, []int{3, 0, 0}, 2, []bool{false, true}}, + // the marker was there: the first boot had the window (not known before), the prep cold boot does not + {"window, windowed launch, three rounds", true, true, false, []int{3, 0, 0}, 2, []bool{false, true}}, + {"window, prep launch, installed in one round", true, false, false, []int{0, 0}, 1, []bool{true}}, + // never writable: the prep boot is headless, then one more cold boot with the window + {"window, prep launch, not writable twice", true, false, false, []int{3, 3}, -1, []bool{false, true}}, + // the install's boot had the window, the bridge was still missing, the next round's boot only prepared + {"window, loaded late", true, false, false, []int{0, 3, 0}, 3, []bool{true, false, true}}, + // prep launch, and the guest has the bridge after all: one cold boot with the window + {"window, prep launch, nothing to install", true, false, true, nil, 0, []bool{true}}, + {"headless, three rounds", false, false, false, []int{3, 0, 0}, 2, []bool{false, false}}, + {"headless, not writable twice", false, false, false, []int{3, 3}, -1, []bool{false}}, + } { + var starts []StartOptions + bridgeStart = func(_ *Manager, _ context.Context, _ int, opt StartOptions) (*StartResult, error) { + starts = append(starts, opt) + // what Start reports for a launch (the instance was stopped before each of these) + return &StartResult{Launched: true, launchWindow: opt.Window && !opt.launchHeadless}, nil + } + bridgeGuest = func(*Manager, context.Context, int) string { + if c.loadedAfter >= 0 && len(starts) >= c.loadedAfter { + return bridgeProp + } + return stock + } + n := 0 + bridgeScript = func(*Manager, context.Context, int, string, bool) (bridgeRun, error) { + code := 0 + if n < len(c.codes) { + code = c.codes[n] + } + n++ + return bridgeRun{Code: code, Last: "done"}, nil + } + opt := StartOptions{Window: c.window, bridgeLocked: c.locked} + res, err := m.afterStartBridge(ctx, i, opt, true, &StartResult{Launched: true, launchWindow: c.launchWindow}) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + got := make([]bool, len(starts)) + for k, o := range starts { + got[k] = o.Window + if !o.NoBridge || o.Headless || o.bridgePrep || o.launchHeadless { + t.Errorf("%s: cold boot %d: NoBridge %v Headless %v bridgePrep %v launchHeadless %v", c.name, k+1, + o.NoBridge, o.Headless, o.bridgePrep, o.launchHeadless) + } + } + if !equalBools(got, c.want) { + t.Errorf("%s: cold boots with Window %v, want %v", c.name, got, c.want) + } + if res.launchWindow != c.window { + t.Errorf("%s: the instance ends with window %v, want %v", c.name, res.launchWindow, c.window) + } + } +} + +// TestBridgeUninstallRestoresWindow: the uninstall's first cold boot on a windowed instance with the bridge is +// headless because a second one should follow; when the script then does not take the bridge off (no second cold +// boot), the instance still gets its window back with one more. +func TestBridgeUninstallRestoresWindow(t *testing.T) { + m, i := opsTestInstance(t) + windowed, _ := startSleeper(t) // a readable command line without -no-window + findQemu, start, guest, script := bridgeFindQemu, bridgeStart, bridgeGuest, bridgeScript + t.Cleanup(func() { bridgeFindQemu, bridgeStart, bridgeGuest, bridgeScript = findQemu, start, guest, script }) + var starts []StartOptions + bridgeFindQemu = func(*Manager, int) (win.Process, bool, error) { return windowed, true, nil } + bridgeStart = func(_ *Manager, _ context.Context, _ int, opt StartOptions) (*StartResult, error) { + starts = append(starts, opt) + return &StartResult{Launched: true, launchWindow: opt.Window}, nil + } + bridgeGuest = func(*Manager, context.Context, int) string { return bridgeProp } + codes := []int{3, 1} + n := 0 + bridgeScript = func(*Manager, context.Context, int, string, bool) (bridgeRun, error) { + code := 0 + if n < len(codes) { + code = codes[n] + } + n++ + return bridgeRun{Code: code, Last: "done"}, nil + } + if _, err := m.Bridge(context.Background(), i, "uninstall"); err != nil { + t.Fatal(err) + } + got := make([]bool, len(starts)) + for k, o := range starts { + got[k] = o.Window + } + if want := []bool{false, true}; !equalBools(got, want) { + t.Fatalf("cold boots with Window %v, want %v", got, want) + } +} + +func equalBools(a, b []bool) bool { + if len(a) != len(b) { + return false + } + for k := range a { + if a[k] != b[k] { + return false + } + } + return true +} diff --git a/internal/emu/bridge_test.go b/internal/emu/bridge_test.go index 7cec406..a31b51f 100644 --- a/internal/emu/bridge_test.go +++ b/internal/emu/bridge_test.go @@ -170,9 +170,10 @@ func TestBridgeLockExcludes(t *testing.T) { } } -// TestBridgeKeepsWindowMode: every cold boot of modavd bridge (the install's on an instance without the bridge, the one -// after an update, both of an uninstall) brings a running instance back the way it ran, with its window or headless; -// an instance that was off is started headless. +// TestBridgeKeepsWindowMode: modavd bridge brings a running instance back the way it ran, with its window or headless +// (the install's start on an instance without the bridge, the cold boot after an update, the uninstall's last one); +// the boots that only prepare are headless (the uninstall's first cold boot when a second follows; the install's start +// is marked bridgePrep, Start then launches headless); an instance that was off is started headless. func TestBridgeKeepsWindowMode(t *testing.T) { m, i := opsTestInstance(t) windowed, _ := startSleeper(t) // a readable command line without -no-window @@ -235,9 +236,19 @@ func TestBridgeKeepsWindowMode(t *testing.T) { t.Errorf("%s, %s: %d starts, want %d", c.name, run.mode, len(starts), want) } for k, opt := range starts { - if opt.Window != run.window || opt.Headless { + want := run.window + if c.action == "uninstall" && run.qemu != nil && k == 0 { + // the cold boot between the uninstall's two script runs only prepares: headless + want = false + } + if opt.Window != want || opt.Headless { t.Errorf("%s, %s: start %d has Window %v Headless %v, want Window %v", c.name, run.mode, k+1, - opt.Window, opt.Headless, run.window) + opt.Window, opt.Headless, want) + } + // the install's start on an instance without the bridge prepares it (Start boots it headless and + // gives the window back on its last boot) + if prep := c.action == "install" && !c.installed && run.qemu != nil; opt.bridgePrep != prep { + t.Errorf("%s, %s: start %d has bridgePrep %v, want %v", c.name, run.mode, k+1, opt.bridgePrep, prep) } } } diff --git a/internal/emu/start.go b/internal/emu/start.go index c0a88f5..cddd4d3 100644 --- a/internal/emu/start.go +++ b/internal/emu/start.go @@ -112,24 +112,61 @@ func (m *Manager) Start(ctx context.Context, i int, opt StartOptions) (*StartRes // The execution bridge (bridge.go): an instance this start launches gets its boot marker first (bridgeMarker). bridge := opt.watchQemu == nil && !opt.NoBridge && m.bridgeWanted(i, inst.Config) opt.bridgeMarker = bridge + // A launch that is known to be followed by the bridge's install and cold boots only prepares the instance: it is + // headless, and the window comes with the last boot (afterStartBridge). + opt.launchHeadless = bridgePrepLaunch(opt, bridge, regularFile(bridgeMarker(m.Root, i))) res, err := mm.start(ctx, inst, opt, t0) if err != nil { return nil, err } res.Warnings = col.Messages() + return m.afterStartBridge(ctx, i, opt, bridge, res) +} + +// bridgePrepLaunch: a launch of this start (opt; bridge: Start wants the execution bridge; marker: its boot marker is +// there already) is headless although the caller asked for a window, because the bridge is surely installed after it, +// with a cold boot or two: the marker is not there yet (the instance never had the bridge: this launch places it), or +// the caller knows (bridgePrep). With the marker there, whether the guest has the bridge is only known once it runs, +// so that launch has the window as asked. +func bridgePrepLaunch(opt StartOptions, bridge, marker bool) bool { + return bridge && opt.Window && (opt.bridgePrep || !marker) +} + +// afterStartBridge is the end of Start (res: its boot, set up): the execution bridge's install or update, and the +// last boot with the window asked for when the instance runs headless after them. +func (m *Manager) afterStartBridge(ctx context.Context, i int, opt StartOptions, bridge bool, + res *StartResult) (*StartResult, error) { + var err error + switch { // The execution bridge (bridge.go): its marker is there and the guest still runs the stock translator. After the // whole setup, because installing takes a cold boot or two; and only on a boot this start launched, never on a // running instance it adopted (someone may be playing on it: the install restarts adbd and boots it cold). - if bridge && res.Launched && m.bridgePending(ctx, i) { + case bridge && res.Launched && m.bridgePending(ctx, i): if opt.bridgeLocked { - return m.installBridge(ctx, i, opt, res) + res, err = m.installBridge(ctx, i, opt, res) + } else { + res, err = m.finishBridge(ctx, i, opt, res) } - return m.finishBridge(ctx, i, opt, res) - } // The bridge is installed: its files are updated when they are not this package's payload (the package was updated // since), also only on a boot this start launched. `modavd bridge install` (bridgeLocked) updates by itself. - if bridge && res.Launched && !opt.bridgeLocked { - return m.updateBridge(ctx, i, opt, res) + case bridge && res.Launched && !opt.bridgeLocked: + res, err = m.updateBridge(ctx, i, opt, res) + } + if err != nil || res == nil { + return res, err + } + // Asked for the window, and the instance runs headless after the prep boots (or after a prep launch with nothing + // to install after all): one more cold boot, after a normal shutdown, gives it the window. A running instance + // does not switch (StartOptions.Window), so this is never an adopt. + if opt.Window && res.Launched && !res.launchWindow { + m.Log.Infof("ModAVD 实例 %d 准备阶段无头开机已做完,关机后按要求带窗口开机", i) + r, err := m.bridgeRestart(ctx, i, StartOptions{Window: true, Timeout: opt.Timeout, Progress: opt.Progress, + creating: opt.creating}) + if err != nil { + return nil, errs.Wrap(errs.As(err).Code, err, fmt.Sprintf("实例 %d 准备完以后带窗口开机失败: %v", i, err)) + } + r.Warnings = append(append([]string{}, res.Warnings...), r.Warnings...) + res = r } return res, nil } @@ -280,7 +317,11 @@ func (m *Manager) start(ctx context.Context, inst *avd.Instance, opt StartOption m.Log.Warnf("实例 %d 没放上执行桥的开机标记: %v", i, err) } } - plan, err := m.prepareLaunch(ctx, inst, opt.Window) + launchWindow := opt.Window && !opt.launchHeadless + if opt.launchHeadless { + m.Log.Infof("ModAVD 实例 %d 开机后要装 arm64 执行桥(要冷启动一到两次):准备阶段无头开机,装好后再带窗口开机", i) + } + plan, err := m.prepareLaunch(ctx, inst, launchWindow) if err != nil { return nil, err } @@ -301,6 +342,7 @@ func (m *Manager) start(ctx context.Context, inst *avd.Instance, opt StartOption launchedAt = time.Now() emulator = &p res.Launched = true + res.launchWindow = launchWindow mode = setupLaunched res.LogFile = plan.logPath // written before qemu shows up (a cancel may come first): until then it names qemu by its parent diff --git a/internal/emu/types.go b/internal/emu/types.go index 80a67cc..edd1a8c 100644 --- a/internal/emu/types.go +++ b/internal/emu/types.go @@ -198,6 +198,9 @@ type StartOptions struct { // Window: boot with the emulator window (default headless, like AUTO-MAS silent mode). Headless: the caller wants // it headless. An instance already running the other way is not adopted: errs.Busy, shut it down first (switching // would take a restart). With neither, a running instance is adopted as it runs and a launch is headless. + // Window is how the instance comes back at the end: the boots that only prepare it (the execution bridge's install + // and its cold boots, bridge.go) are headless, and the last one, a cold boot after a normal shutdown, has the + // window. Window bool Headless bool // Timeout for Android to finish booting; 0 = DefaultBootTimeout. @@ -218,6 +221,11 @@ type StartOptions struct { creating bool // bridgeMarker: Start wants the execution bridge on this instance, so a launch places its boot marker first. bridgeMarker bool + // bridgePrep: the caller (Bridge) knows this launch installs the execution bridge, so it is headless even with + // Window (the install's last cold boot gets the window). Start also decides it by itself (bridgePrepLaunch). + bridgePrep bool + // launchHeadless: set by Start for start: a launch is headless whatever Window says (a bridge prep boot). + launchHeadless bool } type Progress struct { @@ -253,6 +261,11 @@ type StartResult struct { SetupPending bool `json:"setup_pending"` Warnings []string `json:"warnings"` // never null; setup steps that failed (none of them stop the boot) HostIME bool `json:"host_ime"` + + // launchWindow: the qemu this call launched has the emulator window (false for a headless launch, and when it + // launched nothing). A start asked for a window launches headless while the execution bridge is still to be + // installed (bridgePrepLaunch); its last boot gets the window. + launchWindow bool } type StopOptions struct { diff --git a/internal/ui/API.md b/internal/ui/API.md index 680acd1..0cc445e 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -43,7 +43,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | PATCH | `/api/instances/{i}` | `{title?, memory_mb?, cpu?, fps?, resolution?, data_partition_gb?, data_disk_auto?: bool, diagnostics?: bool, boss_key?: string}` | `UpdateResult`;诊断和老板键立即生效,普通配置下次开机生效。`data_partition_gb` 把数据盘改成指定大小(只能改大),`data_disk_auto: true` 改成自动扩盘,两个不能同时给。给了 `boss_key` 时多一个 `boss_key_taken`(bool):新键当场试注册过,被别的程序占着是 `true`(见下「老板键和托盘」) | | GET | `/api/instances/{i}/diagnostics/preview` | | `{"index","files","bytes"}`:关闭诊断时预计清理的份数、字节,不改配置、不删文件 | | DELETE | `/api/instances/{i}?confirm=1` | 不带 `confirm=1` 回 400 `usage` | `DeleteResult` | -| POST | `/api/instances/{i}/start` | `{}` | 202 `Job`:一律带窗口开机(管理器不开无头的,无头开机只走命令行);实例已经以无头方式在运行时,任务失败,code 是 `busy`(运行中不切换) | +| POST | `/api/instances/{i}/start` | `{}` | 202 `Job`:一律带窗口开机(管理器不开无头的,无头开机只走命令行);实例要先装执行桥时,装桥这几次准备用的开机都无头,装好后正常关机,最后一次带窗口开机(同 `modavd start --window`);实例已经以无头方式在运行时,任务失败,code 是 `busy`(运行中不切换) | | POST | `/api/instances/{i}/stop` | `{force?: bool, cancel_create?: bool}` | 202 `Job`;新建中默认 busy,显式 `cancel_create: true` 才取消;`force` 只管是否跳过 Android 正常关机。被取消的新建在结果里标记 `interrupted_create: true` | | POST | `/api/instances/{i}/install` | `multipart/form-data`,文件字段 `apk` | 202 `Job`(服务端存到临时文件,任务结束删掉) | | POST | `/api/instances/{i}/disk-info` | `{}` | 202 `Job`(`kind` 是 `disk-info`):数据盘的占用,随时可以查(开机时 `allocated_bytes`、`reclaimable_bytes` 不返回,`needs_shutdown` 为 `true`);结果是 emu 的 `DiskInfoResult`。整理中或锁不可用时返回可读取的文件信息及 `warnings`,不运行 qemu-img;读不到宿主占用时省略 `on_disk_bytes` | From 9f8ccca8b95cd9c25a77f91250eb1656964e637a Mon Sep 17 00:00:00 2001 From: qiyinxi <52456734+qiyinxi@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:09:27 +0000 Subject: [PATCH 06/17] test: stop reading the local production root (#150) TestMatchesProductionInstance compared against the installed mas_1 and broke once the bridge added hw.gltransport=asg; it now uses a 10-16 snapshot in a temp root. The real-adb tests no longer fall back to the installed root's adb.exe; they need MODAVD_TEST_SDK. --- internal/adb/real_adb_test.go | 18 ++++++------- internal/avd/avd_test.go | 51 ++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/internal/adb/real_adb_test.go b/internal/adb/real_adb_test.go index d8d4b97..a349e90 100644 --- a/internal/adb/real_adb_test.go +++ b/internal/adb/real_adb_test.go @@ -38,15 +38,15 @@ const ( testScriptPort = 20076 ) -// testSDK is an SDK whose adb.exe the test may run (only run, never modified): $MODAVD_TEST_SDK, else the package's. +// testSDK is an SDK whose adb.exe the test may run (only run, never modified): $MODAVD_TEST_SDK only. There is no +// fallback to an installed root, so a plain `go test ./...` never reaches a real (production) root. func testSDK() string { - for _, sdk := range []string{os.Getenv("MODAVD_TEST_SDK"), `D:\ModAVD\sdk`} { - if sdk == "" { - continue - } - if st, err := os.Stat(filepath.Join(sdk, "platform-tools", "adb.exe")); err == nil && !st.IsDir() { - return sdk - } + sdk := os.Getenv("MODAVD_TEST_SDK") + if sdk == "" { + return "" + } + if st, err := os.Stat(filepath.Join(sdk, "platform-tools", "adb.exe")); err == nil && !st.IsDir() { + return sdk } return "" } @@ -57,7 +57,7 @@ func realRoot(t *testing.T) (*root.Root, string) { t.Helper() sdk := testSDK() if sdk == "" { - t.Skip("没有可用的 adb.exe(MODAVD_TEST_SDK / D:\\ModAVD\\sdk)") + t.Skip("没有可用的 adb.exe(设 MODAVD_TEST_SDK 指向一个 SDK 才跑)") } dir := tempDir(t) meta, _ := json.Marshal(map[string]any{ diff --git a/internal/avd/avd_test.go b/internal/avd/avd_test.go index 5083a52..e3727c7 100644 --- a/internal/avd/avd_test.go +++ b/internal/avd/avd_test.go @@ -480,17 +480,48 @@ func TestPropertiesMatchPython(t *testing.T) { compareWithPython(t, setup, []refOp{{"op": "list"}}) } -// TestMatchesProductionInstance: an instance created here with the options of D:\ModAVD's mas_1 (made by AUTO-MAS -// on 10-16; read only) is the same bytes. The template has changed once since: hw.mainKeys no -> yes (ModAVD #1). -// New instances default to 1080p since ModAVD #100; mas_1 is 720p, so its resolution is passed like the others. +// prodMas1Config is config.ini of a production mas_1 as AUTO-MAS created it on 10-16 (CRLF, no BOM). It is a +// snapshot: tests never read a real root. +var prodMas1Config = strings.Join([]string{ + "PlayStore.enabled=no", "abi.type=x86_64", "avd.ini.encoding=UTF-8", "disk.cachePartition=yes", + "disk.cachePartition.size=66MB", "fastboot.forceColdBoot=yes", "fastboot.forceFastBoot=no", + "hw.accelerometer=yes", "hw.arc=false", "hw.audioInput=no", "hw.audioOutput=no", "hw.battery=yes", + "hw.camera.back=none", "hw.camera.front=none", "hw.cpu.arch=x86_64", "hw.dPad=no", "hw.gps=no", + "hw.gpu.enabled=yes", "hw.gpu.mode=host", "hw.gsmModem=yes", "hw.gyroscope=yes", + "hw.initialOrientation=landscape", "hw.keyboard=yes", "hw.keyboard.lid=yes", "hw.lcd.depth=32", + "hw.lcd.vsync=30", "hw.mainKeys=no", "hw.sdCard=no", "hw.sensors.orientation=yes", "hw.sensors.proximity=no", + "hw.trackBall=no", "hw.useext4=yes", "showDeviceFrame=no", "tag.display=Google APIs", "tag.id=google_apis", + "target=android-34", "vm.heapSize=192M", "avd.id=mas_1", "avd.name=mas_1", "avd.ini.displayname=二号", + "disk.dataPartition.size=256G", "hw.cpu.ncore=4", "hw.ramSize=4096M", "hw.lcd.width=1280", + "hw.lcd.height=720", "hw.lcd.density=240", `image.sysdir.1=system-images\android-34\google_apis\x86_64\`, "", +}, "\r\n") + +// prodMas1Ini is the matching avd\mas_1.ini; path= is filled in with the snapshot root's instance directory. +const prodMas1Ini = "avd.ini.encoding=UTF-8\r\npath=%s\r\npath.rel=avd\\mas_1.avd\r\ntarget=android-34\r\n" + +// TestMatchesProductionInstance: an instance created here with the options of a production mas_1 (made by AUTO-MAS +// on 10-16, kept above as a snapshot in a temp root) is the same bytes. The template has changed once since: +// hw.mainKeys no -> yes (ModAVD #1). New instances default to 1080p since ModAVD #100; mas_1 is 720p, so its +// resolution is passed like the others. func TestMatchesProductionInstance(t *testing.T) { - const prod = `D:\ModAVD` - prodIni, err1 := os.ReadFile(prod + `\avd\mas_1.ini`) - prodCfg, err2 := os.ReadFile(prod + `\avd\mas_1.avd\config.ini`) - if err1 != nil || err2 != nil { - t.Skip("no production instance mas_1") + pr := newRoot(t) + prodCfg := []byte(prodMas1Config) + prodIni := []byte(fmt.Sprintf(prodMas1Ini, Dir(pr, 1))) + for path, data := range map[string][]byte{ + ConfigPath(pr, 1): prodCfg, + IniPath(pr, 1): prodIni, + // Production's instances.1 as created (later keys trimmed). + filepath.Join(pr.Dir, root.MetadataFile): []byte(`{"instances": {"1": {"memoryMb": 4096, "initialized": true, ` + + `"createdAt": "2026-10-16T07:41:39", "initializedAt": "2026-10-16T07:42:57"}}}`), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } } - pr, err := root.New(prod) + pr, err := root.New(pr.Dir) if err != nil { t.Fatal(err) } @@ -518,7 +549,7 @@ func TestMatchesProductionInstance(t *testing.T) { if got := readFile(t, ConfigPath(r, 1)); !bytes.Equal(got, wantCfg) { t.Errorf("config.ini differs\n got %q\nwant %q", got, wantCfg) } - wantIni := bytes.Replace(prodIni, []byte(`path=D:\ModAVD\avd\mas_1.avd`), []byte(`path=`+Dir(r, 1)), 1) + wantIni := []byte(fmt.Sprintf(prodMas1Ini, Dir(r, 1))) if got := readFile(t, IniPath(r, 1)); !bytes.Equal(got, wantIni) { t.Errorf("mas_1.ini differs\n got %q\nwant %q", got, wantIni) } From a1107bf716e68cc7b80ca3c0572eb00f90269afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A0=E6=AF=9B=E8=8F=9C?= <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:53:32 +0000 Subject: [PATCH 07/17] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=8C=E5=87=BB?= =?UTF-8?q?=E6=89=98=E7=9B=98=E9=9A=90=E8=97=8F=E5=92=8C=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E7=AA=97=E5=8F=A3=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 支持双击托盘隐藏和恢复管理器窗口 单击等系统双击间隔后恢复实例窗口,双击只切换独立 Edge 配置里的管理器窗口。退出时恢复隐藏窗口,并阻止晚到的回调再次隐藏。 Refs ModAVD/ModAVD#188 * 补充前台权限对照,区分消息注入与真实输入 记录 Windows 激活请求的返回值。用独立测试窗口授予本次测试进程前台权限,对照原生请求和托盘回调的激活结果,并核对窗口身份、消息应答与最近输入。 Refs ModAVD/ModAVD#188 --- README.en.md | 2 +- README.md | 2 +- internal/ui/API.en.md | 2 +- internal/ui/API.md | 2 +- internal/ui/bosskey.go | 42 +- internal/ui/tray_foreground_windows_test.go | 365 ++++++++++++++++++ internal/ui/tray_manager_edge_windows_test.go | 304 +++++++++++++++ internal/ui/tray_manager_windows_test.go | 154 ++++++++ internal/ui/tray_other.go | 2 + internal/ui/tray_windows.go | 2 +- internal/ui/ui.go | 7 +- internal/ui/window_visibility_windows.go | 64 +++ internal/win/edge_windows.go | 148 +++++++ internal/win/edge_windows_test.go | 115 ++++++ internal/win/tray.go | 132 ++++++- internal/win/tray_click_windows_test.go | 184 +++++++++ internal/win/win.go | 3 + 17 files changed, 1497 insertions(+), 33 deletions(-) create mode 100644 internal/ui/tray_foreground_windows_test.go create mode 100644 internal/ui/tray_manager_edge_windows_test.go create mode 100644 internal/ui/tray_manager_windows_test.go create mode 100644 internal/ui/window_visibility_windows.go create mode 100644 internal/win/edge_windows.go create mode 100644 internal/win/edge_windows_test.go create mode 100644 internal/win/tray_click_windows_test.go diff --git a/README.en.md b/README.en.md index f65eae1..38bfefe 100644 --- a/README.en.md +++ b/README.en.md @@ -279,7 +279,7 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis - Several instances on one key is not a conflict: a press hides them all when one of them shows its window, and shows them all when all are hidden. It looks at whether the windows show now, so windows hidden by AUTO-MAS or `modavd window` come back too. - Only instances running with their window are affected; headless ones have no window. Hidden windows keep rendering and the screenshot channel keeps working, they have no taskbar button, and they come back at the same position and size. - The manager registers the keys, so they work while the manager runs (left in the tray with its window closed counts). A key is registered only while an instance using it runs with its window, and released when none does. When another program holds a key, the settings page shows a warning icon next to it, saying on hover that another program holds the key and to pick another one; no dialog pops up. -- Tray (Windows only): the manager puts an icon in the notification area, and closing the window leaves the manager there. A left click on the icon brings every hidden window back; the right-click menu has "打开管理器" (open the manager) and "退出" (exit). Exiting shows the hidden windows first and waits for running tasks to finish. +- Tray (Windows only): closing the window leaves the manager in the tray. A single left click restores hidden instance windows after the system double-click interval. A double click hides or shows all windows on the manager's Edge profile, preserving page state, or opens one if none exists. The right-click menu's "打开管理器" (open the manager) restores existing windows first. "退出" (exit) waits for running tasks to finish. Exiting also restores hidden manager and instance windows. With the default browser, a double click only opens the manager. - Security: each launch uses a random token, which is exchanged for an HttpOnly cookie (its name includes the port, so managers for two root folders running at the same time do not overwrite each other); it validates Host (against DNS rebinding) and Origin; strict CSP. - One root folder gets one server; double-clicking again just reopens the window. The state file is in `%LOCALAPPDATA%\ModAVD\`, and the log is at `\logs\modavd-ui.log`. - After the window is closed: on Windows it stays in the tray (see above); on macOS it waits for the running tasks to finish and exits. The latter also applies when the tray icon cannot be put up. diff --git a/README.md b/README.md index ee7b8c6..07f17b7 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ Apple Silicon macOS 可运行 `ModAVD` 管理器;`ModAVD.app` 放在包根目 - 几台设成同一个键不算冲突:按下时,这几台里有一台的窗口显示着就全部藏起来,全都藏着就全部显示。看的是窗口现在显不显示,所以 AUTO-MAS、`modavd window` 藏起来的也能叫回来。 - 只管带窗口运行的实例,无头的没有窗口,不受影响。藏着的时候照旧渲染、照旧能截图,任务栏上没有它的按钮;叫回来以后位置、大小和藏之前一样。 - 由管理器注册,所以管理器开着(窗口关了、留在托盘也算)才管用。只在用这个键的实例有带窗口运行着的时候才注册,都关了就注销。被别的程序占了注册不上时,设置页那一项旁边标一个警告图标,悬停说明「这个键被别的程序占了,换一个」,不弹窗。 -- 托盘(只有 Windows):管理器在托盘里放一个图标,关掉窗口后管理器留在托盘。左键点图标把藏着的窗口都叫回来;右键菜单有「打开管理器」「退出」。退出前先把藏着的窗口都显示出来,等正在跑的任务做完再退出。 +- 托盘(只有 Windows):关掉窗口后管理器留在托盘。左键单击图标,等系统双击间隔过去后,把藏着的实例窗口都叫回来。双击切换管理器窗口的隐藏和显示,保留页面状态;同一管理器 Edge 配置的多个窗口一起切换,没有窗口时新开一个。右键「打开管理器」优先显示已有窗口,「退出」等正在跑的任务做完再退出。退出前也会恢复藏着的管理器和实例窗口。用默认浏览器打开时,双击只打开管理器。 - 安全:每次启动随机令牌,换成 HttpOnly cookie(名字带端口,两个根目录的管理器同时开不会互相顶掉);校验 Host(防 DNS 重绑定)和 Origin;严格的 CSP。 - 一个根目录只开一个服务,再双击只是把窗口重新打开;状态文件在 `%LOCALAPPDATA%\ModAVD\`,日志在 `<根>\logs\modavd-ui.log`。 - 关掉窗口后:Windows 上留在托盘(见上);macOS 上等正在跑的任务做完再退出。托盘图标放不成时也按后者。 diff --git a/internal/ui/API.en.md b/internal/ui/API.en.md index f938e0b..3bf164c 100644 --- a/internal/ui/API.en.md +++ b/internal/ui/API.en.md @@ -220,7 +220,7 @@ ModAVD/ModAVD#101, Windows only. - Each instance has one boss key: `InstanceInfo.boss_key` (`"Ctrl+Alt+Shift+1"`, `""` for none), stored as `instances..bossKey` in `mas-avd.json`. Never set, instances 0–9 use Ctrl+Alt+Shift+number (the digit row of the main keyboard) and instances above 9 have none. It needs at least one of Ctrl, Alt and Shift and never takes Win; the key can be a digit, a letter, F1–F24, Num0–Num9, Space, Insert, Delete, Home, End, PageUp, PageDown or an arrow key. - The manager registers them all with `RegisterHotKey`: each key once, and only while an instance using it runs with its window; when they are all off or headless, it is unregistered. It checks every 3 seconds from the processes and instance files (no adb), and at once after a setting changed. A key that cannot be registered goes into `taken` and is tried again each round, so it is registered once the other program lets go. - A press looks at the instances using that key that run with a window: if one of them shows its window, all are hidden; if all are hidden, all are shown (as `modavd window show|hide`, by whether the window shows now, whoever hid it). Hidden windows keep rendering and the screenshot channel keeps working; they have no taskbar button and keep their position and size. -- The tray icon: a left click shows every hidden window; the right-click menu has "打开管理器" (open the manager) and "退出" (exit). Before exiting (any way it exits), the hidden windows are shown first, then all boss keys are unregistered and the icon removed. +- The tray icon: a single left click restores hidden instance windows after the system double-click interval. A double click hides or shows manager windows, preserving page state, without restoring instance windows. All manager windows on the dedicated Edge profile toggle together. If none exists, a double click opens one. The right-click menu's "打开管理器" (open the manager) restores existing windows first. With the default browser fallback, a double click only opens the manager. Every orderly exit path, including the tray's "退出" (exit), cancellation, and CLI update handoff, restores hidden manager and instance windows before unregistering boss keys and removing the icon. - The boss key field of the settings page: click the field and press the combination. A change is tried at once with `POST /api/boss-keys/check`, and once more when `PATCH` saves it; opening the settings marks a key in `taken` from `GET /api/boss-keys` with a warning icon. The field is hidden when `supported` is `false`. `resolution` accepts only 1080 and 720. A new instance defaults to 1080; they correspond to 1920×1080 / DPI 280 and 1280×720 / DPI 240 respectively; a change keeps the orientation and takes effect at the next boot. `InstanceInfo.config.resolution` is the tier recognized from the current configured width and height; after a change while running, it means the setting for the next boot. diff --git a/internal/ui/API.md b/internal/ui/API.md index 0cc445e..d37f6d5 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -220,7 +220,7 @@ ModAVD/ModAVD#101,只有 Windows。 - 每台实例一个老板键:`InstanceInfo.boss_key`(`"Ctrl+Alt+Shift+1"`,`""` 是不要),存在 `mas-avd.json` 的 `instances..bossKey`;没写过时编号 0–9 是 Ctrl+Alt+Shift+编号(主键盘那排数字),超过 9 没有。至少带 Ctrl、Alt、Shift 中的一个,不收 Win;按键可以是数字、字母、F1–F24、Num0–Num9、Space、Insert、Delete、Home、End、PageUp、PageDown、方向键。 - 由管理器统一 `RegisterHotKey`:同一个键只注册一次,只在用它的实例里有带窗口运行着的时候注册,都关了或都是无头的就注销。每 3 秒按进程和实例文件对一遍(不走 adb),改了设置马上对一遍。注册不上的键进 `taken`,之后每轮再试,别的程序放了就注册上。 - 按下时,这个键管的实例里带窗口运行着的:有一台的窗口显示着就全部藏起来,全都藏着就全部显示(同 `modavd window show|hide`,按窗口现在显不显示,不管是谁藏的)。藏着照旧渲染、照旧能截图,任务栏上没有按钮,位置大小不变。 -- 托盘图标:左键点一下把藏着的窗口都显示出来;右键菜单「打开管理器」「退出」。退出前(任何方式退出都一样)先把藏着的窗口都显示出来,再注销全部老板键、去掉图标。 +- 托盘图标:左键单击等系统双击间隔过去后恢复藏着的实例窗口。双击切换管理器窗口的隐藏和显示,保留页面状态;双击不会恢复实例窗口。同一独立 Edge 配置的所有管理器窗口一起切换,没有窗口时打开一个。右键「打开管理器」优先显示已有窗口,默认浏览器回退时双击只打开管理器。任何正常退出路径(托盘「退出」、取消运行、交给命令行更新)都先恢复藏着的管理器和实例窗口,再注销老板键、去掉图标。 - 设置页的老板键一项:点一下输入框再按组合;改了先 `POST /api/boss-keys/check` 当场试一次,保存时 `PATCH` 再试一次;打开设置时按 `GET /api/boss-keys` 的 `taken` 在被占的键旁边标警告图标。`supported` 为 `false` 时不显示这一项。 `resolution` 只接受 1080 和 720。新建默认 1080,分别联动 1920×1080 / DPI 280 与 1280×720 / DPI 240;修改保留方向,下次开机生效。`InstanceInfo.config.resolution` 是按当前配置宽高识别的档位,运行中修改后表示下次开机的设置。 diff --git a/internal/ui/bosskey.go b/internal/ui/bosskey.go index 3bdd068..c22b387 100644 --- a/internal/ui/bosskey.go +++ b/internal/ui/bosskey.go @@ -29,10 +29,10 @@ type trayHost interface { Close() } -// trayEvents are the tray's callbacks, each on a goroutine of its own. +// trayEvents are delivered in order off the native window thread. type trayEvents struct { - hotKey func(id int) - click, open, exit func() + hotKey func(id int) + click, doubleClick, open, exit func() } type newTrayFunc func(tip string, ev trayEvents) (trayHost, error) @@ -164,7 +164,7 @@ func (b *bossKeys) close() { // manager then runs as before (it exits with its window) and has no boss keys. func (s *server) startTray(newTray newTrayFunc) { tip := "ModAVD 管理器 · " + s.root.Dir - host, err := newTray(tip, trayEvents{hotKey: s.onHotKey, click: s.onTrayClick, open: s.onTrayOpen, exit: s.onTrayExit}) + host, err := newTray(tip, trayEvents{hotKey: s.onHotKey, click: s.onTrayClick, doubleClick: s.onTrayDoubleClick, open: s.onTrayOpen, exit: s.onTrayExit}) if err != nil { s.log.Warnf("放不了托盘图标,老板键也用不了;关掉窗口管理器就退出: %v", err) return @@ -226,6 +226,9 @@ func (s *server) onHotKey(id int) { } s.toggleMu.Lock() defer s.toggleMu.Unlock() + if s.trayStopped { + return + } ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second) defer cancel() if _, err := s.mgr.ToggleBossKey(ctx, key); err != nil && s.ctx.Err() == nil { @@ -237,6 +240,9 @@ func (s *server) onHotKey(id int) { func (s *server) onTrayClick() { s.toggleMu.Lock() defer s.toggleMu.Unlock() + if s.trayStopped { + return + } ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second) defer cancel() if _, err := s.mgr.ShowHiddenWindows(ctx); err != nil && s.ctx.Err() == nil { @@ -244,9 +250,22 @@ func (s *server) onTrayClick() { } } -// onTrayOpen opens a manager window, like a second launch does. -func (s *server) onTrayOpen() { - if s.base == "" { +// onTrayDoubleClick toggles every manager Edge window, or opens one when none exists. +func (s *server) onTrayDoubleClick() { s.trayManagerWindow(true) } + +// onTrayOpen restores existing manager windows before opening a new one. +func (s *server) onTrayOpen() { s.trayManagerWindow(false) } + +func (s *server) trayManagerWindow(toggle bool) { + s.toggleMu.Lock() + defer s.toggleMu.Unlock() + if s.trayStopped || s.base == "" { + return + } + if found, err := s.managerWindows(toggle); err != nil { + s.log.Warnf("没能切换管理器窗口: %v", err) + return + } else if found { return } if _, err := s.launch(s.base + "?t=" + s.token); err != nil { @@ -269,6 +288,15 @@ func (s *server) stopTray() { return } s.toggleMu.Lock() + if s.trayStopped { + s.toggleMu.Unlock() + return + } + // Already-dispatched callbacks may arrive after Close. None may hide windows again after cleanup. + s.trayStopped = true + if _, err := s.managerWindows(false); err != nil { + s.log.Warnf("退出前没能显示管理器窗口: %v", err) + } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) if shown, err := s.mgr.ShowHiddenWindows(ctx); err != nil { s.log.Warnf("退出前没能显示藏起来的窗口: %s", errs.As(err).Message) diff --git a/internal/ui/tray_foreground_windows_test.go b/internal/ui/tray_foreground_windows_test.go new file mode 100644 index 0000000..1b31652 --- /dev/null +++ b/internal/ui/tray_foreground_windows_test.go @@ -0,0 +1,365 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "syscall" + "testing" + "time" + "unsafe" + + "modavd/internal/testutil" + "modavd/internal/win" +) + +type foregroundProbeState struct { + Command string `json:"command"` + Success bool `json:"success"` + Window uintptr `json:"window"` + Foreground uintptr `json:"foreground"` + ForegroundPID uint32 `json:"foreground_pid"` + LastInput uint32 `json:"last_input"` + InputAgeMS uint32 `json:"input_age_ms"` + LockTimeoutMS uint32 `json:"lock_timeout_ms"` +} + +func foregroundProbeSnapshot(command string, success bool, window uintptr) foregroundProbeState { + u := syscall.NewLazyDLL("user32.dll") + h, _, _ := u.NewProc("GetForegroundWindow").Call() + var pid uint32 + u.NewProc("GetWindowThreadProcessId").Call(h, uintptr(unsafe.Pointer(&pid))) + input := struct{ Size, Time uint32 }{Size: 8} + u.NewProc("GetLastInputInfo").Call(uintptr(unsafe.Pointer(&input))) + tick, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("GetTickCount64").Call() + var timeout uint32 + u.NewProc("SystemParametersInfoW").Call(0x2000, 0, uintptr(unsafe.Pointer(&timeout)), 0) + return foregroundProbeState{command, success, window, h, pid, input.Time, uint32(tick) - input.Time, timeout} +} + +// The broker is a separate, test-owned native window. Only it can grant the test process foreground permission +// while it is foreground. No input is synthesized and no global policy or third-party window is modified. +func TestTrayForegroundBrokerHelper(t *testing.T) { + if os.Getenv("MODAVD_FG_BROKER") != "1" { + t.Skip("foreground probe child only") + } + parent, err := strconv.ParseUint(os.Getenv("MODAVD_FG_PARENT"), 10, 32) + if err != nil { + t.Fatal(err) + } + tray, err := win.StartTray(win.TrayOptions{NoIcon: true}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + u := syscall.NewLazyDLL("user32.dll") + // A temporary native test window. One actual click gives this process foreground eligibility. + styleIndex := -16 + u.NewProc("SetWindowLongPtrW").Call(tray.HWND(), uintptr(styleIndex), 0x00cf0000) + title, _ := syscall.UTF16PtrFromString("ModAVD foreground test") + u.NewProc("SetWindowTextW").Call(tray.HWND(), uintptr(unsafe.Pointer(title))) + u.NewProc("SetWindowPos").Call(tray.HWND(), 0, 40, 40, 420, 180, 0x0004|0x0020) + send := func(command string, ok bool) { + _ = json.NewEncoder(os.Stdout).Encode(foregroundProbeSnapshot(command, ok, tray.HWND())) + } + take := func() bool { r, _, _ := u.NewProc("SetForegroundWindow").Call(tray.HWND()); return r != 0 } + send("ready", true) + in := bufio.NewScanner(os.Stdin) + for in.Scan() { + command := in.Text() + var r uintptr + switch command { + case "prompt": + topmost := -1 + r, _, _ = u.NewProc("SetWindowPos").Call(tray.HWND(), uintptr(topmost), 0, 0, 0, 0, 0x0040|0x0010|0x0002|0x0001) + case "plain": + normal := -2 + r, _, _ = u.NewProc("SetWindowPos").Call(tray.HWND(), uintptr(normal), 0, 0, 0, 0, 0x0010|0x0002|0x0001) + case "take": + send(command, take()) + continue + case "grant": + r, _, _ = u.NewProc("AllowSetForegroundWindow").Call(uintptr(parent)) + case "revoke": + r, _, _ = u.NewProc("AllowSetForegroundWindow").Call(uintptr(os.Getpid())) + case "quit": + send(command, true) + return + default: + t.Fatal("unknown probe command") + } + send(command, r != 0) + } +} + +func startForegroundBroker(t *testing.T) (int, uintptr, func(string) foregroundProbeState) { + t.Helper() + child := exec.Command(os.Args[0], "-test.run=^TestTrayForegroundBrokerHelper$") + child.SysProcAttr = &syscall.SysProcAttr{CreationFlags: win.HideConsole} + child.Env = append(os.Environ(), "MODAVD_FG_BROKER=1", "MODAVD_FG_PARENT="+strconv.Itoa(os.Getpid())) + in, err := child.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := child.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := child.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + fmt.Fprintln(in, "quit") + in.Close() + done := make(chan error, 1) + go func() { done <- child.Wait() }() + select { + case <-done: + case <-time.After(3 * time.Second): + _ = child.Process.Kill() + <-done + } + }) + scan := bufio.NewScanner(out) + if !scan.Scan() { + t.Fatal("no broker response") + } + var state foregroundProbeState + if err := json.Unmarshal(scan.Bytes(), &state); err != nil { + t.Fatalf("broker response %q: %v", scan.Text(), err) + } + t.Logf("broker ready %+v", state) + window := state.Window + command := func(command string) foregroundProbeState { + t.Helper() + if _, err := fmt.Fprintln(in, command); err != nil { + t.Fatal(err) + } + if !scan.Scan() { + t.Fatal("broker stopped") + } + var reply foregroundProbeState + if err := json.Unmarshal(scan.Bytes(), &reply); err != nil { + t.Fatal(err) + } + if reply.Command != command || !reply.Success { + t.Fatalf("broker %q: %+v", command, reply) + } + return reply + } + return child.Process.Pid, window, command +} + +func TestTrayManagerForegroundPermissions(t *testing.T) { + if os.Getenv("MODAVD_FOREGROUND_PROBE") != "1" { + t.Skip("MODAVD_FOREGROUND_PROBE=1 opens test windows") + } + name, err := syscall.UTF16PtrFromString(os.Getenv("MODAVD_INTERACTIVE_LOCK")) + if err != nil || os.Getenv("MODAVD_INTERACTIVE_LOCK") == "" { + t.Fatal("MODAVD_INTERACTIVE_LOCK is required") + } + lock, err := syscall.CreateFile(name, syscall.GENERIC_READ|syscall.GENERIC_WRITE, 0, nil, syscall.OPEN_ALWAYS, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { syscall.CloseHandle(lock) }) + if FindEdge() == "" { + t.Fatal("Edge is required") + } + t.Setenv("LOCALAPPDATA", testutil.TempDir(t)) + u := syscall.NewLazyDLL("user32.dll") + post := u.NewProc("PostMessageW") + allow := u.NewProc("AllowSetForegroundWindow") + send := u.NewProc("SendMessageTimeoutW") + wait := func(what string, cond func() bool) { + t.Helper() + until := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(until) { + t.Fatalf("timeout %s: %+v", what, foregroundProbeSnapshot(what, false, 0)) + } + time.Sleep(10 * time.Millisecond) + } + } + brokerPID, brokerWindow, command := startForegroundBroker(t) + take := func() { + t.Helper() + command("take") + wait("broker foreground", func() bool { return foregroundProbeSnapshot("", false, 0).Foreground == brokerWindow }) + } + window, err := LaunchWindow("about:blank#modavd-foreground-probe") + if err != nil || !window.Edge { + t.Fatalf("Edge launch: %v", err) + } + // Register cleanup before searching: a browser that starts slowly or has unexpected windows still belongs to + // this isolated profile and must be closed even when target discovery fails. + defer func() { + until := time.Now().Add(10 * time.Second) + for { + ws, e := win.EdgeProfileWindows(EdgeProfileDir()) + if e != nil { + t.Error(e) + } else { + for _, w := range ws { + _ = w.SetVisible(true) + post.Call(w.HWND(), 0x0010, 0, 0) + } + } + exited := false + select { + case <-window.Done: + exited = true + default: + } + if e == nil && len(ws) == 0 && exited { + break + } + if time.Now().After(until) { + t.Error("Edge did not close its isolated probe profile") + break + } + time.Sleep(50 * time.Millisecond) + } + removeProfile(t, EdgeProfileDir()) // wait for this test profile's crash-reporting file handles too + }() + var windows []win.EdgeWindow + wait("one Edge app", func() bool { + windows, err = win.EdgeProfileWindows(EdgeProfileDir()) + if err != nil { + t.Fatal(err) + } + return len(windows) == 1 + }) + target := windows[0] + // Showing the prompt does not activate it. Require a new actual input event while this window is foreground. + inputBefore := foregroundProbeSnapshot("before human click", false, brokerWindow).LastInput + command("prompt") + clickedUntil := time.Now().Add(90 * time.Second) + for { + state := foregroundProbeSnapshot("waiting for human click", false, brokerWindow) + if state.Foreground == brokerWindow && state.LastInput != inputBefore { + t.Logf("actual input observed on test broker: %+v", state) + break + } + if time.Now().After(clickedUntil) { + t.Fatal("test window was not clicked") + } + time.Sleep(50 * time.Millisecond) + } + command("plain") + take() + measure := func(phase string) foregroundProbeState { + t.Helper() + before := foregroundProbeSnapshot(phase, false, target.HWND()) + if !target.Visible() { + t.Fatal("foreground target is no longer a visible window of the identified Edge process") + } + accepted := target.Foreground() + var ignored uintptr + ack, _, _ := send.Call(target.HWND(), 0, 0, 0, 0x0002, 1000, uintptr(unsafe.Pointer(&ignored))) + after := foregroundProbeSnapshot(phase, accepted, target.HWND()) + b, _ := json.Marshal(before) + a, _ := json.Marshal(after) + t.Logf("native before=%s after=%s WM_NULL_ack=%v", b, a, ack != 0) + if ack == 0 { + t.Fatal("Edge activation queue did not acknowledge") + } + if before.LastInput != after.LastInput { + t.Fatal("external input changed probe conditions") + } + return after + } + command("revoke") + negative := measure("no explicit grant") + if negative.Success { + t.Log("no-grant denial was not reproduced; this run can verify granted activation but cannot prove a permission contrast") + allow.Call(uintptr(brokerPID)) + take() + } + h, fake := trayHarness(t) + h.s.base = "http://127.0.0.1:33431/" + h.s.managerWindows = changeManagerWindows + events := fake.ev + barrier := make(chan struct{}, 8) + tray, err := win.StartTray(win.TrayOptions{NoIcon: true, Events: win.TrayEvents{ + DoubleClick: events.doubleClick, HotKey: func(id int) { + if id == 1 { + events.open() + } else { + barrier <- struct{}{} + } + }, + }}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + flush := func() { + t.Helper() + post.Call(tray.HWND(), 0x0312, 2, 0) + select { + case <-barrier: + case <-time.After(3 * time.Second): + t.Fatal("tray callback did not finish") + } + } + double := func() { + for _, m := range []uintptr{0x0202, 0x0203, 0x0202} { + post.Call(tray.HWND(), 0x8001, 1, m) + } + flush() + } + lendBack := func() { + t.Helper() + r, _, _ := allow.Call(uintptr(brokerPID)) + if r == 0 { + t.Fatal("return foreground grant to broker failed") + } + take() + } + for round := 1; round <= 3; round++ { + command("grant") + granted := measure(fmt.Sprintf("round %d granted", round)) + if !granted.Success { + t.Fatal("authorized native request failed") + } + if round == 1 && !negative.Success { + if negative.LastInput != granted.LastInput { + t.Fatal("input changed between denied and granted requests; causal contrast is invalid") + } + t.Logf("permission contrast confirmed: same HWND %d, same last input %d, denied without grant and accepted with grant", target.HWND(), granted.LastInput) + } + wait("native foreground", func() bool { return foregroundProbeSnapshot("", false, 0).Foreground == target.HWND() }) + lendBack() + command("grant") + before := foregroundProbeSnapshot("posted double", false, target.HWND()) + double() + if target.Visible() { + t.Fatal("double did not hide window") + } + double() + if !target.Visible() { + t.Fatal("double did not show window") + } + wait("posted double foreground", func() bool { return foregroundProbeSnapshot("", false, 0).Foreground == target.HWND() }) + after := foregroundProbeSnapshot("posted double", true, target.HWND()) + if before.LastInput != after.LastInput { + t.Fatal("posted messages changed last input") + } + lendBack() + command("grant") + post.Call(tray.HWND(), 0x0312, 1, 0) + flush() + wait("open foreground", func() bool { return foregroundProbeSnapshot("", false, 0).Foreground == target.HWND() }) + t.Logf("round %d authorized native / posted double / open activated Edge HWND %d, last input %d", round, target.HWND(), after.LastInput) + lendBack() + } +} diff --git a/internal/ui/tray_manager_edge_windows_test.go b/internal/ui/tray_manager_edge_windows_test.go new file mode 100644 index 0000000..a6bb9fa --- /dev/null +++ b/internal/ui/tray_manager_edge_windows_test.go @@ -0,0 +1,304 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "fmt" + "net" + "net/http" + "os" + "os/exec" + "slices" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "modavd/internal/logx" + "modavd/internal/root" + "modavd/internal/testutil" + "modavd/internal/win" +) + +// TestTrayManagerEdgeProbe is opt-in because it opens real Edge windows. Use an isolated LOCALAPPDATA and set +// MODAVD_INTERACTIVE_LOCK to the shared desktop-test lock path. No emulator or existing browser profile is used. +func TestTrayManagerEdgeProbe(t *testing.T) { + if os.Getenv("MODAVD_TRAY_EDGE_PROBE") != "1" { + t.Skip("MODAVD_TRAY_EDGE_PROBE=1 opens real Edge windows") + } + lockPath := os.Getenv("MODAVD_INTERACTIVE_LOCK") + if lockPath == "" { + t.Fatal("MODAVD_INTERACTIVE_LOCK is required") + } + lockName, err := syscall.UTF16PtrFromString(lockPath) + if err != nil { + t.Fatal(err) + } + var lock syscall.Handle + deadline := time.Now().Add(3 * time.Minute) + for { + lock, err = syscall.CreateFile(lockName, syscall.GENERIC_READ|syscall.GENERIC_WRITE, 0, nil, syscall.OPEN_ALWAYS, 0, 0) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("interactive lock: %v", err) + } + time.Sleep(200 * time.Millisecond) + } + defer syscall.CloseHandle(lock) + t.Log("exclusive interactive lock acquired") + if FindEdge() == "" { + t.Fatal("Edge is required for this probe") + } + t.Setenv("LOCALAPPDATA", testutil.TempDir(t)) + profile, foreign := EdgeProfileDir(), EdgeProfileDir()+"-other" + user32 := syscall.NewLazyDLL("user32.dll") + post := user32.NewProc("PostMessageW") + foreground := user32.NewProc("GetForegroundWindow") + getWindows := func(profile string) []win.EdgeWindow { + t.Helper() + w, e := win.EdgeProfileWindows(profile) + if e != nil { + t.Fatal(e) + } + return w + } + wait := func(what string, condition func() bool) { + t.Helper() + end := time.Now().Add(15 * time.Second) + for !condition() { + if time.Now().After(end) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(50 * time.Millisecond) + } + } + // Restore before closing, and only ever touch the two profiles created by this test. + defer func() { + for _, p := range []string{profile, foreign} { + ws, e := win.EdgeProfileWindows(p) + if e != nil { + t.Error(e) + continue + } + for _, w := range ws { + _ = w.SetVisible(true) + post.Call(w.HWND(), 0x0010, 0, 0) + } // WM_CLOSE + // Keep the interactive lock until the test windows have actually gone, not just until WM_CLOSE was posted. + until := time.Now().Add(5 * time.Second) + for { + left, e := win.EdgeProfileWindows(p) + if e != nil { + t.Error(e) + break + } + if len(left) == 0 { + break + } + if time.Now().After(until) { + t.Errorf("test profile still has %d windows during cleanup", len(left)) + break + } + time.Sleep(50 * time.Millisecond) + } + } + }() + ln, err := net.Listen("tcp", "127.0.0.1:33430") + if err != nil { + t.Fatal(err) + } + var pages atomic.Int32 + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + pages.Add(1) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, "ModAVD tray probe") + })} + go srv.Serve(ln) + defer srv.Close() + base := "http://" + ln.Addr().String() + "/" + r, err := root.New(testutil.TempDir(t)) + if err != nil { + t.Fatal(err) + } + f := newFake() + s, err := newServer(Options{Root: r, newBackend: func(l *logx.Logger) backend { return fakeBackend{f, l} }}) + if err != nil { + t.Fatal(err) + } + defer s.jobCancel() + defer s.cancel() + s.base = base + var events trayEvents + s.startTray(func(tip string, ev trayEvents) (trayHost, error) { events = ev; return realNewTray(tip, ev) }) + if s.tray == nil { + t.Fatal("real tray did not start") + } + defer s.stopTray() + tray := s.tray.(winTray) + var launches atomic.Int32 + s.launch = func(url string) (*Window, error) { launches.Add(1); return LaunchWindow(url) } + first, err := LaunchWindow(base + "?one") + if err != nil || !first.Edge { + t.Fatalf("first launch: %v", err) + } + wait("first Edge window", func() bool { return len(getWindows(profile)) == 1 }) + second, err := LaunchWindow(base + "?two") + if err != nil || !second.Edge { + t.Fatalf("second launch: %v", err) + } + wait("two Edge windows", func() bool { return len(getWindows(profile)) == 2 }) + select { + case <-second.Done: + t.Log("second launch exited after handing window to existing profile process") + case <-time.After(5 * time.Second): + t.Fatal("second launch did not exercise process handoff") + } + other := exec.Command(FindEdge(), edgeArgs(base+"?foreign", foreign)...) + if err := other.Start(); err != nil { + t.Fatal(err) + } + go other.Wait() + wait("foreign Edge window", func() bool { return len(getWindows(foreign)) == 1 }) + handles := func(ws []win.EdgeWindow) []uintptr { + out := []uintptr{} + for _, w := range ws { + out = append(out, w.HWND()) + } + slices.Sort(out) + return out + } + owned := handles(getWindows(profile)) + // Posted messages are not real input. Windows may leave focus with another app even when visibility changed. + // Set MODAVD_TRAY_REQUIRE_FOREGROUND=1 for a controlled interactive acceptance run. + checkForeground := func() { + t.Helper() + until := time.Now().Add(time.Second) + var h uintptr + for { + h, _, _ = foreground.Call() + if slices.Contains(owned, h) { + t.Logf("foreground observed on owned HWND %d", h) + return + } + if time.Now().After(until) { + break + } + time.Sleep(10 * time.Millisecond) + } + if os.Getenv("MODAVD_TRAY_REQUIRE_FOREGROUND") == "1" { + t.Fatalf("foreground not observed; current HWND %d", h) + } + t.Logf("foreground not observed after posted messages; current HWND %d; visibility assertions remain required", h) + } + unrelated := handles(getWindows(foreign)) + t.Logf("owned HWNDs %v; foreign HWNDs %v", owned, unrelated) + wait("all pages loaded", func() bool { return pages.Load() >= 3 }) + loads := pages.Load() + double := func() { + for _, msg := range []uintptr{0x0202, 0x0203, 0x0202} { + post.Call(tray.HWND(), 0x8001, 1, msg) + } + } + check := func(visible bool) bool { + ws := getWindows(profile) + if len(ws) != 2 { + return false + } + for _, w := range ws { + if w.Visible() != visible { + return false + } + } + return true + } + for round := 1; round <= 3; round++ { + double() + wait("both manager windows hidden", func() bool { return check(false) }) + if round == 2 { + events.open() + } else { + double() + } + wait("both manager windows restored", func() bool { return check(true) }) + if !slices.Equal(handles(getWindows(profile)), owned) { + t.Fatal("toggle replaced manager HWNDs") + } + if !slices.Equal(handles(getWindows(foreign)), unrelated) || !getWindows(foreign)[0].Visible() { + t.Fatal("foreign profile affected") + } + checkForeground() + t.Logf("round %d: hidden/restored both windows; foreign unchanged", round) + } + // IsWindowVisible is true even while minimized. Wait for SW_RESTORE itself before foreground activation. + minimized := getWindows(profile)[0] + user32.NewProc("ShowWindowAsync").Call(minimized.HWND(), 6) // SW_MINIMIZE + wait("minimized manager window", minimized.Minimized) + events.open() + wait("minimized window restored", func() bool { return minimized.Visible() && !minimized.Minimized() }) + checkForeground() + t.Log("right-click open restored minimized window") + user32.NewProc("ShowWindowAsync").Call(minimized.HWND(), 3) // SW_MAXIMIZE + zoomed := func() bool { r, _, _ := user32.NewProc("IsZoomed").Call(minimized.HWND()); return r != 0 } + wait("maximized manager window", zoomed) + double() + wait("maximized window hidden", func() bool { return check(false) }) + double() + wait("maximized window restored", func() bool { return check(true) && zoomed() }) + t.Log("hide/show retained maximized placement") + if launches.Load() != 0 { + t.Fatal("restore opened an extra manager") + } + if pages.Load() != loads { + t.Fatalf("toggle reloaded page: %d -> %d", loads, pages.Load()) + } + f.mu.Lock() + single := f.showHidden + f.mu.Unlock() + if single != 0 { + t.Fatal("double click leaked instance restore") + } + // A single click really traverses the native timer to the server backend. + post.Call(tray.HWND(), 0x8001, 1, 0x0202) + wait("single-click instance restore", func() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.showHidden == 1 }) + for _, w := range getWindows(profile) { + post.Call(w.HWND(), 0x0010, 0, 0) + } + wait("manager windows closed", func() bool { return len(getWindows(profile)) == 0 }) + double() + wait("no-window double click opened manager", func() bool { return launches.Load() == 1 && len(getWindows(profile)) == 1 }) + t.Log("no-window double click opened one manager window") + double() + wait("new manager window hidden", func() bool { ws := getWindows(profile); return len(ws) == 1 && !ws[0].Visible() }) + s.stopTray() + wait("exit restored manager window", func() bool { ws := getWindows(profile); return len(ws) == 1 && ws[0].Visible() }) + events.doubleClick() + events.open() + if launches.Load() != 1 || !getWindows(profile)[0].Visible() { + t.Fatal("late callback changed window after exit") + } + t.Logf("exit restored window; page requests before close %d; isolated single click delivered once", loads) + // Record only process identities belonging to our profiles; no unrelated command line is logged. + ps, _ := win.ListProcesses() + for _, p := range ps { + if strings.EqualFold(p.Name, "msedge.exe") { + args, e := p.CommandLine() + if e == nil { + for _, a := range args { + if a == "--user-data-dir="+profile { + t.Logf("profile process PID %d created %d", p.PID, p.Created) + } + } + } + } + } +} diff --git a/internal/ui/tray_manager_windows_test.go b/internal/ui/tray_manager_windows_test.go new file mode 100644 index 0000000..4c0d973 --- /dev/null +++ b/internal/ui/tray_manager_windows_test.go @@ -0,0 +1,154 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" +) + +func TestTrayManagerWindowActions(t *testing.T) { + for _, tc := range []struct { + name string + found bool + failure bool + }{ + {"existing windows", true, false}, {"closed or default browser", false, false}, {"lookup failure", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + h, tray := trayHarness(t) + h.s.base = "http://127.0.0.1:33400/" + var modes []bool + h.s.managerWindows = func(toggle bool) (bool, error) { + modes = append(modes, toggle) + if tc.failure { + return false, errors.New("enumeration failed") + } + return tc.found, nil + } + launches := 0 + h.s.launch = func(url string) (*Window, error) { + launches++ + if url != h.s.base+"?t="+testToken { + t.Error("wrong login URL") + } + return &Window{}, nil + } + if tray.ev.doubleClick == nil { + t.Fatal("double click not wired") + } + tray.ev.doubleClick() + tray.ev.open() + if len(modes) != 2 || !modes[0] || modes[1] { + t.Fatalf("actions %v", modes) + } + want := 0 + if !tc.found && !tc.failure { + want = 2 + } + if launches != want { + t.Fatalf("launches %d, want %d", launches, want) + } + h.f.mu.Lock() + shown := h.f.showHidden + h.f.mu.Unlock() + if shown != 0 { + t.Fatal("manager action restored instance windows") + } + }) + } +} + +func TestTrayManagerRestoreOnEveryExit(t *testing.T) { + for _, reason := range []string{"context", "update handoff", "tray menu"} { + for _, hidden := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/hidden=%v", reason, hidden), func(t *testing.T) { + h, tray := trayHarness(t) + s := h.s + s.base = "http://127.0.0.1:33401/" + s.exitEvery = time.Millisecond + visible := true + s.managerWindows = func(toggle bool) (bool, error) { + if toggle { + visible = !visible + } else { + visible = true + } + return true, nil + } + if hidden { + tray.ev.doubleClick() + if visible { + t.Fatal("not hidden") + } + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch reason { + case "context": + cancel() + case "update handoff": + close(s.exitReq) + case "tray menu": + tray.ev.exit() + } + s.waitForExit(ctx, nil) + s.stopTray() + if !visible { + t.Fatal("exit left manager hidden") + } + tray.ev.doubleClick() + tray.ev.open() + tray.ev.click() + s.stopTray() + if !visible { + t.Fatal("late callback hid manager") + } + h.f.mu.Lock() + shown := h.f.showHidden + h.f.mu.Unlock() + if shown != 1 { + t.Fatalf("restore calls %d, want one exit cleanup", shown) + } + }) + } + } +} + +func TestTrayManagerConcurrentExit(t *testing.T) { + h, tray := trayHarness(t) + s := h.s + s.base = "http://127.0.0.1:33402/" + entered, release := make(chan struct{}), make(chan struct{}) + visible := true + s.managerWindows = func(toggle bool) (bool, error) { + if toggle { + close(entered) + <-release + visible = false + } else { + visible = true + } + return true, nil + } + var wg sync.WaitGroup + wg.Go(tray.ev.doubleClick) + <-entered + wg.Go(s.stopTray) + close(release) + wg.Wait() + if !visible { + t.Fatal("concurrent exit left manager hidden") + } + tray.ev.doubleClick() + if !visible { + t.Fatal("callback after cleanup hid manager") + } +} diff --git a/internal/ui/tray_other.go b/internal/ui/tray_other.go index 216e7b9..d5d469d 100644 --- a/internal/ui/tray_other.go +++ b/internal/ui/tray_other.go @@ -8,3 +8,5 @@ import "modavd/internal/errs" func realNewTray(string, trayEvents) (trayHost, error) { return nil, errs.New(errs.Unsupported, "当前平台没有托盘图标和老板键") } + +func changeManagerWindows(bool) (bool, error) { return false, nil } diff --git a/internal/ui/tray_windows.go b/internal/ui/tray_windows.go index 4bff7f6..ad226c6 100644 --- a/internal/ui/tray_windows.go +++ b/internal/ui/tray_windows.go @@ -8,7 +8,7 @@ import ( // realNewTray is the manager's win.Tray. func realNewTray(tip string, ev trayEvents) (trayHost, error) { t, err := win.StartTray(win.TrayOptions{Tip: tip, Events: win.TrayEvents{HotKey: ev.hotKey, Click: ev.click, - Open: ev.open, Exit: ev.exit}}) + DoubleClick: ev.doubleClick, Open: ev.open, Exit: ev.exit}}) if err != nil { return nil, err } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 02c1951..396ccf2 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -149,8 +149,11 @@ type server struct { bossKick chan struct{} trayExit chan struct{} // closed by 退出 in the icon's menu trayExitOnce sync.Once - toggleMu sync.Mutex // one boss key press or tray click at a time + toggleMu sync.Mutex // one tray action or exit cleanup at a time base string // "http://127.0.0.1:/", set once before serving + + trayStopped bool // protected by toggleMu + managerWindows func(toggle bool) (found bool, err error) // queries the Edge profile, never a launch PID } func newServer(opt Options) (*server, error) { @@ -217,6 +220,8 @@ func newServer(opt Options) (*server, error) { proxyEvery: proxyEvery, hub: newHub(), marks: newProxyMarks(), + + managerWindows: changeManagerWindows, } if s.openPath == nil { s.openPath = shellOpen diff --git a/internal/ui/window_visibility_windows.go b/internal/ui/window_visibility_windows.go new file mode 100644 index 0000000..09dc880 --- /dev/null +++ b/internal/ui/window_visibility_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "errors" + "fmt" + "time" + + "modavd/internal/win" +) + +func changeManagerWindows(toggle bool) (bool, error) { + return changeEdgeWindows(EdgeProfileDir(), toggle) +} + +// changeEdgeWindows runs under server.toggleMu. Each action re-reads the actual profile windows: a launch PID or a +// cached HWND is not their owner. Wait briefly for the asynchronous changes so the next action sees the new state. +func changeEdgeWindows(profile string, toggle bool) (bool, error) { + windows, err := win.EdgeProfileWindows(profile) + if err != nil || len(windows) == 0 { + return false, err + } + visible := true + if toggle { + for _, w := range windows { + if w.Visible() { + visible = false + break + } + } + } + var failures []error + for _, w := range windows { + if err := w.SetVisible(visible); err != nil { + failures = append(failures, err) + } + } + deadline := time.Now().Add(time.Second) + for { + settled := true + for _, w := range windows { + if w.Visible() != visible || (visible && w.Minimized()) { + settled = false + break + } + } + if settled { + break + } + if time.Now().After(deadline) { + failures = append(failures, fmt.Errorf("Edge 窗口未在一秒内响应显示切换")) + break + } + time.Sleep(10 * time.Millisecond) + } + if visible { + windows[0].Foreground() + } + return true, errors.Join(failures...) +} diff --git a/internal/win/edge_windows.go b/internal/win/edge_windows.go new file mode 100644 index 0000000..ce571e7 --- /dev/null +++ b/internal/win/edge_windows.go @@ -0,0 +1,148 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package win + +import ( + "fmt" + "path/filepath" + "strings" + "sync" + "syscall" + "unsafe" +) + +// EdgeWindow is a browser/app window of one identified Edge process. Helpers such as its message and GPU windows +// are excluded. The process identity is checked again before touching the HWND, including its creation time. +type EdgeWindow struct { + hwnd uintptr + process Process +} + +func (w EdgeWindow) HWND() uintptr { return w.hwnd } + +func (w EdgeWindow) valid() bool { + var pid uint32 + procGetWindowThreadProcessId.Call(w.hwnd, uintptr(unsafe.Pointer(&pid))) + return pid == w.process.PID && w.process.Alive() +} + +func (w EdgeWindow) Visible() bool { + if !w.valid() { + return false + } + r, _, _ := procIsWindowVisible.Call(w.hwnd) + return r != 0 +} + +// Minimized distinguishes an iconic but still visible window from a restored one. +func (w EdgeWindow) Minimized() bool { + if !w.valid() { + return false + } + r, _, _ := procIsIconic.Call(w.hwnd) + return r != 0 +} + +// SetVisible posts the change to Edge's thread rather than blocking on a hung browser. Showing a minimized window +// restores it, while a maximized window keeps its size. +func (w EdgeWindow) SetVisible(visible bool) error { + if !w.valid() { + return nil + } + cmd := uintptr(swHide) + if visible { + cmd = swShow + if w.Minimized() { + cmd = 9 + } // SW_RESTORE + } + if r, err := call(procShowWindowAsync, w.hwnd, cmd); r == 0 { + return fmt.Errorf("切换 Edge 窗口失败: %w", err) + } + return nil +} + +// Foreground reports whether Windows accepted the activation request. Activation of Edge's input queue may +// complete asynchronously. A background caller without foreground permission gets false; showing still works. +func (w EdgeWindow) Foreground() bool { + if !w.valid() { + return false + } + r, _, _ := procSetForegroundWindow.Call(w.hwnd) + return r != 0 +} + +var ( + procIsIconic = moduser32.NewProc("IsIconic") + edgeWindowMu sync.Mutex + edgeWindowTargets map[uint32]Process + edgeWindowsFound []EdgeWindow + edgeWindowEnumCb = sync.OnceValue(func() uintptr { return syscall.NewCallback(enumEdgeWindows) }) +) + +func enumEdgeWindows(hwnd, _ uintptr) uintptr { + var pid uint32 + procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid))) + p, ok := edgeWindowTargets[pid] + if !ok { + return 1 + } + // Chrome_WidgetWin_0 and titleless windows are browser helpers. Include owned top-level dialogs too: they + // belong to this dedicated profile and must not be left behind when the manager is hidden. + if windowText(procGetClassNameW, hwnd) == "Chrome_WidgetWin_1" && windowText(procGetWindowTextW, hwnd) != "" { + edgeWindowsFound = append(edgeWindowsFound, EdgeWindow{hwnd: hwnd, process: p}) + } + return 1 +} + +// edgeProfileMatches compares parsed switches rather than command-line substrings, so a sibling profile, URL or +// renderer argument cannot make an unrelated Edge window belong to the manager. +func edgeProfileMatches(args []string, profile string) bool { + value := "" + for i := 1; i < len(args); i++ { + if strings.HasPrefix(args[i], "--type=") { + return false + } + if v, ok := strings.CutPrefix(args[i], "--user-data-dir="); ok { + value = v + } else if args[i] == "--user-data-dir" && i+1 < len(args) { + i++ + value = args[i] + } + } + return value != "" && filepath.IsAbs(value) && strings.EqualFold(filepath.Clean(value), filepath.Clean(profile)) +} + +// EdgeProfileWindows finds all manager windows, including hidden ones and ones handed to an existing Edge. It +// never uses the short-lived process returned by LaunchWindow. Unreadable or exited processes are left alone. +func EdgeProfileWindows(profile string) ([]EdgeWindow, error) { + procs, err := ListProcesses() + if err != nil { + return nil, err + } + targets := map[uint32]Process{} + for _, p := range procs { + if !strings.EqualFold(p.Name, "msedge.exe") || p.Created == 0 { + continue + } + args, err := p.CommandLine() + if err == nil && edgeProfileMatches(args, profile) { + targets[p.PID] = p + } + } + return edgeProcessWindows(targets) +} + +func edgeProcessWindows(targets map[uint32]Process) ([]EdgeWindow, error) { + edgeWindowMu.Lock() + defer edgeWindowMu.Unlock() + edgeWindowTargets, edgeWindowsFound = targets, nil + defer func() { edgeWindowTargets, edgeWindowsFound = nil, nil }() + if r, err := call(procEnumWindows, edgeWindowEnumCb(), 0); r == 0 { + return nil, err + } + return edgeWindowsFound, nil +} diff --git a/internal/win/edge_windows_test.go b/internal/win/edge_windows_test.go new file mode 100644 index 0000000..fe19372 --- /dev/null +++ b/internal/win/edge_windows_test.go @@ -0,0 +1,115 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package win + +import ( + "fmt" + "os" + "slices" + "strings" + "testing" + "time" +) + +func TestEdgeProfileMatches(t *testing.T) { + profile := `C:\profiles\manager ui-edge2` + for _, tc := range []struct { + name string + args []string + want bool + }{ + {"equals", []string{"msedge.exe", "--user-data-dir=" + profile}, true}, + {"separate", []string{"msedge.exe", "--user-data-dir", profile}, true}, + {"case and clean", []string{"msedge.exe", `--user-data-dir=c:\PROFILES\other\..\manager ui-edge2\`}, true}, + {"sibling", []string{"msedge.exe", "--user-data-dir=" + profile + "-other"}, false}, + {"URL substring", []string{"msedge.exe", "--app=http://localhost/?--user-data-dir=" + profile}, false}, + {"renderer", []string{"msedge.exe", "--user-data-dir=" + profile, "--type=renderer"}, false}, + {"last switch", []string{"msedge.exe", "--user-data-dir=" + profile, `--user-data-dir=C:\other`}, false}, + {"relative", []string{"msedge.exe", "--user-data-dir=manager ui-edge2"}, false}, + {"missing", []string{"msedge.exe", "--user-data-dir"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := edgeProfileMatches(tc.args, profile); got != tc.want { + t.Fatalf("match = %v, want %v", got, tc.want) + } + }) + } +} + +func TestEdgeProcessWindows(t *testing.T) { + windows := testWindows(t, []testWindow{ + {class: "Chrome_WidgetWin_1", title: "manager one"}, + {class: "Chrome_WidgetWin_1", title: "manager two"}, + {class: "Chrome_WidgetWin_0", title: "helper"}, + {class: "Chrome_WidgetWin_1", title: ""}, + {class: "Chrome_WidgetWin_1", title: "dialog", owned: true}, + {class: fmt.Sprint("OtherWindow", os.Getpid()), title: "other app"}, + }) + p, ok := FindProcess(uint32(os.Getpid())) + if !ok { + t.Fatal("self not found") + } + found, err := edgeProcessWindows(map[uint32]Process{p.PID: p}) + if err != nil { + t.Fatal(err) + } + var got []uintptr + for _, w := range found { + got = append(got, w.HWND()) + } + slices.Sort(got) + want := []uintptr{windows[0], windows[1], windows[4]} + slices.Sort(want) + if !slices.Equal(got, want) { + t.Fatalf("windows %v, want %v", got, want) + } + for _, visible := range []bool{false, true} { + for _, w := range found { + if err := w.SetVisible(visible); err != nil { + t.Fatal(err) + } + } + if !waitFor(3*time.Second, func() bool { + for _, w := range found { + if w.Visible() != visible { + return false + } + } + return true + }) { + t.Fatalf("visibility did not become %v", visible) + } + } + // An HWND is not enough: a mismatched process creation time must prevent a recycled PID from being touched. + stale := found[0] + stale.process.Created++ + if err := stale.SetVisible(false); err != nil { + t.Fatal(err) + } + // SetVisible is asynchronous: keep observing long enough to catch an incorrectly posted hide. + until := time.Now().Add(150 * time.Millisecond) + for time.Now().Before(until) { + if !found[0].Visible() { + t.Fatal("stale identity hid window") + } + time.Sleep(5 * time.Millisecond) + } + for _, h := range []uintptr{windows[2], windows[3], windows[5]} { + r, _, _ := procIsWindowVisible.Call(h) + if r == 0 { + t.Fatalf("unrelated window %d hidden", h) + } + } + found, err = edgeProcessWindows(map[uint32]Process{}) + if err != nil || len(found) != 0 { + t.Fatalf("empty targets: %v %v", found, err) + } + // A nonexistent profile must not claim the user's ordinary Edge windows. + found, err = EdgeProfileWindows(`C:\modavd-test-nonexistent-profile-` + strings.Repeat("x", 40)) + if err != nil || len(found) != 0 { + t.Fatalf("unrelated Edge matched: %v %v", found, err) + } +} diff --git a/internal/win/tray.go b/internal/win/tray.go index 13f3b28..d8c2be7 100644 --- a/internal/win/tray.go +++ b/internal/win/tray.go @@ -28,18 +28,26 @@ type Tray struct { ownIcon bool hotkeys map[int]bool + // Only the window thread changes click state. KillTimer leaves queued WM_TIMER messages, so ids are not reused. + clickTimer, nextTimer uintptr + skipButtonUp bool + calls chan func() done chan struct{} closeOnce sync.Once + eventMu sync.Mutex + events []func() + eventWake chan struct{} } -// TrayEvents are called on their own goroutine, never on the window's thread, so they may take their time and call -// back into the Tray. +// TrayEvents run in delivery order on a worker goroutine, never on the window thread. They may call back into +// Tray, including Close. Close drops queued events and does not wait for a callback already running. type TrayEvents struct { - HotKey func(id int) // a registered hot key was pressed - Click func() // the icon was clicked (left button) - Open func() // menu: 打开管理器 - Exit func() // menu: 退出 + HotKey func(id int) // a registered hot key was pressed + Click func() // a single left click, after the system double-click interval + DoubleClick func() // a double left click; neither button-up also fires Click + Open func() // menu: 打开管理器 + Exit func() // menu: 退出 } // TrayOptions of StartTray. @@ -60,14 +68,17 @@ const ( // tryHotKeyID is the id of TryHotKey's register-and-release; ids of applications are 0x0000–0xBFFF. tryHotKeyID = 0xBFFF - wmNull = 0x0000 - wmDestroy = 0x0002 - wmHotKey = 0x0312 - wmLButtonUp = 0x0202 - wmRButtonUp = 0x0205 - wmApp = 0x8000 - wmTrayNotify = wmApp + 1 // the icon's callback message - wmTrayCall = wmApp + 2 // run the queued calls + wmNull = 0x0000 + wmDestroy = 0x0002 + wmHotKey = 0x0312 + wmTimer = 0x0113 + wmLButtonDblClk = 0x0203 + csDblClks = 0x0008 + wmLButtonUp = 0x0202 + wmRButtonUp = 0x0205 + wmApp = 0x8000 + wmTrayNotify = wmApp + 1 // the icon's callback message + wmTrayCall = wmApp + 2 // run the queued calls nimAdd, nimDelete = 0, 2 nifMessage, nifIcon, nifTip = 0x1, 0x2, 0x4 @@ -142,7 +153,7 @@ func StartTray(opt TrayOptions) (*Tray, error) { return nil, errors.New("托盘窗口已经开着") } t := &Tray{ev: opt.Events, noIcon: opt.NoIcon, hotkeys: map[int]bool{}, calls: make(chan func(), 64), - done: make(chan struct{})} + done: make(chan struct{}), eventWake: make(chan struct{}, 1)} tip, _ := syscall.UTF16FromString(opt.Tip) copy(t.tip[:len(t.tip)-1], tip) ready := make(chan error, 1) @@ -150,6 +161,7 @@ func StartTray(opt TrayOptions) (*Tray, error) { if err := <-ready; err != nil { return nil, err } + go t.eventLoop() return t, nil } @@ -159,7 +171,7 @@ func (t *Tray) loop(ready chan<- error) { runtime.LockOSThread() inst, _, _ := procGetModuleHandleW.Call(0) cls, _ := syscall.UTF16PtrFromString(trayClassName) - wc := wndClassEx{WndProc: trayProcCb(), Instance: inst, ClassName: cls} + wc := wndClassEx{Style: csDblClks, WndProc: trayProcCb(), Instance: inst, ClassName: cls} wc.Size = uint32(unsafe.Sizeof(wc)) if r, err := call(procRegisterClassExW, uintptr(unsafe.Pointer(&wc))); r == 0 && !errors.Is(err, errorClassAlreadyExists) { ready <- errors.New("注册托盘窗口类失败: " + err.Error()) @@ -234,16 +246,26 @@ func trayProc(hwnd, msg, wparam, lparam uintptr) uintptr { switch m := uint32(msg); { case m == wmHotKey: if f := t.ev.HotKey; f != nil { - go f(int(wparam)) + t.dispatch(func() { f(int(wparam)) }) } return 0 case m == wmTrayNotify: switch uint32(lparam) & 0xFFFF { case wmLButtonUp: - if f := t.ev.Click; f != nil { - go f() + if t.skipButtonUp { + t.skipButtonUp = false + } else { + t.deferClick() + } + case wmLButtonDblClk: + t.cancelClick() + t.skipButtonUp = true // the final button-up belongs to this double click + if f := t.ev.DoubleClick; f != nil { + t.dispatch(f) } case wmRButtonUp: + t.cancelClick() + t.skipButtonUp = false var f func() switch t.menu() { case menuOpen: @@ -252,7 +274,15 @@ func trayProc(hwnd, msg, wparam, lparam uintptr) uintptr { f = t.ev.Exit } if f != nil { - go f() + t.dispatch(f) + } + } + return 0 + case m == wmTimer: + if t.clickTimer != 0 && wparam == t.clickTimer { + t.cancelClick() + if f := t.ev.Click; f != nil { + t.dispatch(f) } } return 0 @@ -265,6 +295,7 @@ func trayProc(hwnd, msg, wparam, lparam uintptr) uintptr { } return 0 case m == wmDestroy: + t.cancelClick() procPostQuitMessage.Call(0) return 0 } @@ -272,6 +303,66 @@ func trayProc(hwnd, msg, wparam, lparam uintptr) uintptr { return r } +// dispatch never waits for a handler: it may itself be waiting for a call on the window thread. +func (t *Tray) dispatch(f func()) { + t.eventMu.Lock() + t.events = append(t.events, f) + t.eventMu.Unlock() + select { + case t.eventWake <- struct{}{}: + default: + } +} + +func (t *Tray) eventLoop() { + defer func() { t.eventMu.Lock(); t.events = nil; t.eventMu.Unlock() }() + for { + select { + case <-t.done: + return + default: + } + t.eventMu.Lock() + var f func() + if len(t.events) > 0 { + f = t.events[0] + t.events[0] = nil + t.events = t.events[1:] + } + t.eventMu.Unlock() + if f != nil { + f() + continue + } + select { + case <-t.done: + return + case <-t.eventWake: + } + } +} + +// deferClick keeps cancellation and delivery on the window thread. If SetTimer fails, drop this click rather than +// accidentally turn a double click into an instance restore. +func (t *Tray) deferClick() { + t.cancelClick() + if t.ev.Click == nil { + return + } + t.nextTimer++ + ms, _, _ := procGetDoubleClickTime.Call() + if r, _, _ := procSetTimer.Call(t.hwnd, t.nextTimer, ms+1, 0); r != 0 { + t.clickTimer = t.nextTimer + } +} + +func (t *Tray) cancelClick() { + if t.clickTimer != 0 { + procKillTimer.Call(t.hwnd, t.clickTimer) + t.clickTimer = 0 + } +} + // menu shows the right-click menu at the cursor and returns the chosen item (0: none). The window comes to the // foreground first, or the menu would not close when the user clicks elsewhere (TrackPopupMenu remarks). func (t *Tray) menu() int { @@ -376,6 +467,7 @@ func (t *Tray) TryHotKey(modifiers, vk uint32) error { func (t *Tray) Close() { t.closeOnce.Do(func() { t.call(func() { + t.cancelClick() for id := range t.hotkeys { procUnregisterHotKey.Call(t.hwnd, uintptr(id)) } diff --git a/internal/win/tray_click_windows_test.go b/internal/win/tray_click_windows_test.go new file mode 100644 index 0000000..313386d --- /dev/null +++ b/internal/win/tray_click_windows_test.go @@ -0,0 +1,184 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package win + +import ( + "testing" + "time" +) + +func trayClickWait() time.Duration { + ms, _, _ := moduser32.NewProc("GetDoubleClickTime").Call() + return time.Duration(ms) * time.Millisecond +} + +func TestTraySingleClickWaitsForDoubleClick(t *testing.T) { + clicks := make(chan struct{}, 4) + tray, err := StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{Click: func() { clicks <- struct{}{} }}}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + started := time.Now() + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonUp) + select { + case <-clicks: + if elapsed := time.Since(started); elapsed < trayClickWait() { + t.Fatalf("single click ran after %v, before double-click interval", elapsed) + } + case <-time.After(trayClickWait() + 2*time.Second): + t.Fatal("single click never ran") + } + select { + case <-clicks: + t.Fatal("single click repeated") + case <-time.After(trayClickWait() + 100*time.Millisecond): + } +} + +func TestTrayDoubleClickNeverLeaksSingleClick(t *testing.T) { + clicks := make(chan struct{}, 4) + tray, err := StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{Click: func() { clicks <- struct{}{} }}}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + for _, msg := range []uintptr{wmLButtonUp, 0x0203, wmLButtonUp} { + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, msg) + } + select { + case <-clicks: + t.Fatal("double click leaked a single click") + case <-time.After(trayClickWait() + 200*time.Millisecond): + } +} + +func TestTrayCloseCancelsPendingClick(t *testing.T) { + clicks := make(chan struct{}, 4) + tray, err := StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{Click: func() { clicks <- struct{}{} }}}) + if err != nil { + t.Fatal(err) + } + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonUp) + if !tray.call(func() {}) { + t.Fatal("tray closed early") + } + tray.Close() + select { + case <-clicks: + t.Fatal("pending click ran during or after close") + case <-time.After(trayClickWait() + 200*time.Millisecond): + } +} + +// A canceled timer may still have a queued WM_TIMER. Its id must never deliver a newer pending click. +func TestTrayStaleTimerAndNextSingleClick(t *testing.T) { + clicks, doubles := make(chan struct{}, 4), make(chan struct{}, 4) + tray, err := StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{Click: func() { clicks <- struct{}{} }, DoubleClick: func() { doubles <- struct{}{} }}}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + var old, current uintptr + tray.call(func() { + tray.deferClick() + old = tray.clickTimer + tray.cancelClick() + tray.deferClick() + current = tray.clickTimer + }) + if old == 0 || current == 0 || old == current { + t.Fatalf("timer ids %d %d", old, current) + } + procPostMessageW.Call(tray.HWND(), wmTimer, old, 0) + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonDblClk) + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonUp) + select { + case <-doubles: + case <-time.After(2 * time.Second): + t.Fatal("no double click") + } + select { + case <-clicks: + t.Fatal("stale timer or final button-up leaked click") + case <-time.After(trayClickWait() + 100*time.Millisecond): + } + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonUp) + select { + case <-clicks: + case <-time.After(trayClickWait() + 2*time.Second): + t.Fatal("next single click was swallowed") + } +} + +func TestTrayCallbacksAreOrderedAndCanClose(t *testing.T) { + entered, release, closed := make(chan struct{}), make(chan struct{}), make(chan struct{}) + order := make(chan int, 4) + var tray *Tray + var err error + tray, err = StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{HotKey: func(id int) { + if id == 1 { + close(entered) + <-release + } + order <- id + if id == 2 { + tray.Close() + close(closed) + } + }}}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + procPostMessageW.Call(tray.HWND(), wmHotKey, 1, 0) + <-entered + procPostMessageW.Call(tray.HWND(), wmHotKey, 2, 0) + procPostMessageW.Call(tray.HWND(), wmHotKey, 3, 0) + if !tray.call(func() {}) { + t.Fatal("callback blocked window thread") + } + select { + case id := <-order: + t.Fatalf("event %d overtook first callback", id) + case <-time.After(100 * time.Millisecond): + } + close(release) + select { + case <-closed: + case <-time.After(3 * time.Second): + t.Fatal("Close from callback deadlocked") + } + for _, want := range []int{1, 2} { + select { + case got := <-order: + if got != want { + t.Fatalf("got %d want %d", got, want) + } + case <-time.After(time.Second): + t.Fatal("missing callback") + } + } + select { + case id := <-order: + t.Fatalf("callback %d survived close", id) + case <-time.After(50 * time.Millisecond): + } +} + +func TestTrayWindowClassAllowsDoubleClicks(t *testing.T) { + tray, err := StartTray(TrayOptions{NoIcon: true}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + // GCL_STYLE is -26, sign-extended for GetClassLongPtrW. + index := -26 + style, _, _ := moduser32.NewProc("GetClassLongPtrW").Call(tray.HWND(), uintptr(index)) + if style&csDblClks == 0 { + t.Fatalf("window class style %#x lacks CS_DBLCLKS", style) + } +} diff --git a/internal/win/win.go b/internal/win/win.go index c69eecc..9f017c1 100644 --- a/internal/win/win.go +++ b/internal/win/win.go @@ -113,6 +113,9 @@ var ( procLoadImageW = moduser32.NewProc("LoadImageW") procLoadIconW = moduser32.NewProc("LoadIconW") procDestroyIcon = moduser32.NewProc("DestroyIcon") + procGetDoubleClickTime = moduser32.NewProc("GetDoubleClickTime") + procSetTimer = moduser32.NewProc("SetTimer") + procKillTimer = moduser32.NewProc("KillTimer") procGetSystemMetrics = moduser32.NewProc("GetSystemMetrics") procCreatePopupMenu = moduser32.NewProc("CreatePopupMenu") procAppendMenuW = moduser32.NewProc("AppendMenuW") From ab243cf654cd0aca776ccac550817dff28eeccf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A0=E6=AF=9B=E8=8F=9C?= <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:53:34 +0000 Subject: [PATCH 08/17] =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=9A=E6=8D=A2?= =?UTF-8?q?=E5=8C=85=E5=89=8D=E6=9F=A5=E5=87=BA=E5=8F=AA=E8=AF=BB=20DLL=20?= =?UTF-8?q?=E5=92=8C=E7=A6=81=E6=AD=A2=E5=88=A0=E9=99=A4=E7=9A=84=E5=8D=A0?= =?UTF-8?q?=E7=94=A8=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 换包前查出只读 DLL 和禁止删除的占用 目录链接下的占用检查、自更新和恢复统一比较真实路径。先查占用再备份,被挡住时保留已核对的暂存包。 * 占用检查把模块短名也解析成真实路径 --- internal/update/root_busy_windows_test.go | 468 ++++++++++++++++++++++ internal/update/state_windows.go | 141 ++++++- internal/update/swap.go | 17 +- 3 files changed, 610 insertions(+), 16 deletions(-) create mode 100644 internal/update/root_busy_windows_test.go diff --git a/internal/update/root_busy_windows_test.go b/internal/update/root_busy_windows_test.go new file mode 100644 index 0000000..9198684 --- /dev/null +++ b/internal/update/root_busy_windows_test.go @@ -0,0 +1,468 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + "unsafe" + + "modavd/internal/errs" + "modavd/internal/testutil" +) + +func TestRootBusyReadOnlyLoadedDLL(t *testing.T) { + path := filepath.Join(t.TempDir(), "loaded.dll") + free := loadDLL(t, path) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + for _, access := range []uint32{syscall.GENERIC_WRITE, 0x00010000} { + h, err := syscall.CreateFile(ptr, access, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, 0, 0) + t.Logf("loaded read-only DLL access=%#x: %v", access, err) + if err == nil { + syscall.CloseHandle(h) + } + } + if !fileInUse(path) { + t.Error("loaded read-only DLL was not detected") + } + st, err := os.Stat(path) + if err != nil || st.Mode().Perm()&0o200 != 0 { + t.Fatalf("read-only attribute changed: %v, %v", st, err) + } + free() + if fileInUse(path) { + t.Error("unloaded read-only DLL is busy") + } +} + +func TestRootBusyHandleSharing(t *testing.T) { + for _, tc := range []struct { + name string + share uint32 + busy bool + }{ + {"no sharing", 0, true}, + {"read and write only", syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE, true}, + {"all sharing", syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE | syscall.FILE_SHARE_DELETE, false}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(ptr, syscall.GENERIC_READ, tc.share, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + if got := fileInUse(path); got != tc.busy { + t.Errorf("busy = %v, want %v", got, tc.busy) + } + if err := syscall.CloseHandle(h); err != nil { + t.Fatal(err) + } + if fileInUse(path) { + t.Error("closed handle is still busy") + } + if got, err := os.ReadFile(path); err != nil || string(got) != "unchanged" { + t.Fatalf("probe changed file: %q %v", got, err) + } + }) + } + t.Run("os.Open", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "open.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if !fileInUse(path) { + t.Error("os.Open handle was not detected") + } + }) +} + +func TestRootBusyAccessDeniedIsNotUse(t *testing.T) { + dir := t.TempDir() + if fileInUse(dir) { + t.Error("directory access denial counts as busy") + } + path := filepath.Join(dir, "readonly.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + if f, err := os.OpenFile(path, os.O_WRONLY, 0); !errors.Is(err, syscall.ERROR_ACCESS_DENIED) { + if err == nil { + f.Close() + } + t.Fatalf("read-only fixture did not deny writing: %v", err) + } + if fileInUse(path) { + t.Error("read-only file without a handle counts as busy") + } +} + +// Sharing allows the probes but denies a backup reader. If a busy file is known, +// reaching backupRootFiles would fail here before the updater could report Busy. +func rootBusyBackupGuard(t *testing.T, path string) func() { + t.Helper() + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(ptr, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + open := true + close := func() { + if open { + open = false + syscall.CloseHandle(h) + } + } + t.Cleanup(close) + return close +} + +func TestRootBusyInstallChecksBeforeBackup(t *testing.T) { + const target = "extras/occupied.dll" + for _, mode := range []string{"loaded DLL", "read-only loaded DLL", "no delete sharing", "deleted file"} { + t.Run(mode, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + oldFiles := map[string]string{"0-backup.txt": "old backup", target: "old DLL"} + newFiles := map[string]string{"0-backup.txt": "new backup", target: "new DLL"} + if mode == "deleted file" { + delete(newFiles, target) + } + r := writeRoot(t, 36, oldFiles) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, newFiles), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + if _, err := u.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + path := u.l.at(target) + var release func() + if mode == "no delete sharing" || mode == "deleted file" { + opened, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + release = func() { opened.Close() } + t.Cleanup(release) + } else { + release = loadDLL(t, path) + if mode == "read-only loaded DLL" { + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + } + } + oldDLL, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + metadata, err := os.ReadFile(u.l.at("mas-avd.json")) + if err != nil { + t.Fatal(err) + } + closeGuard := rootBusyBackupGuard(t, u.l.at("0-backup.txt")) + if _, err := u.Install(context.Background()); errs.CodeOf(err) != errs.Busy || !strings.Contains(err.Error(), filepath.FromSlash(target)) { + t.Fatalf("backup ran before the busy check, or occupant missed: %v", err) + } + if st := u.Status(); st.Phase != phaseStaged || st.PreparedVersion != "v0.37.0" { + t.Fatalf("stage lost: %+v", st) + } + if err := u.l.checkStaged(u.l.readState()); err != nil { + t.Fatalf("stage no longer valid: %v", err) + } + if lexists(u.l.previous()) { + t.Error("busy install left a backup") + } + if got := read(t, r, "sdk/emulator/emulator.exe"); got != "emulator mas-36" { + t.Fatalf("old SDK changed: %q", got) + } + if got, err := os.ReadFile(path); err != nil || string(got) != string(oldDLL) { + t.Fatalf("occupied file changed: %v", err) + } + if got, err := os.ReadFile(u.l.at("mas-avd.json")); err != nil || string(got) != string(metadata) { + t.Fatalf("metadata changed: %v", err) + } + closeGuard() + release() + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("retry: %+v %v", st, err) + } + if mode == "deleted file" { + if lexists(path) { + t.Error("obsolete file survived retry") + } + } else if got := read(t, r, target); got != "new DLL" { + t.Fatalf("retry installed %q", got) + } + }) + } +} + +func TestRootBusySelfThroughDirectoryLink(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, action := range []string{"check", "apply", "restore"} { + t.Run(action, func(t *testing.T) { + dir := testutil.TempDir(t) + real := filepath.Join(dir, "root") + cli := filepath.Join(real, "tools", "modavd.exe") + if err := copyFile(self, cli); err != nil { + t.Fatal(err) + } + alias := filepath.Join(dir, "alias") + // The target exists, so this is a directory link on Windows too. + if err := os.Symlink(real, alias); err != nil { + t.Skipf("directory symlink unavailable: %v", err) + } + if st, err := os.Stat(filepath.Join(alias, "tools")); err != nil || !st.IsDir() { + t.Fatalf("invalid directory symlink: %v", err) + } + cmd := exec.Command(cli, "-test.run=^TestRootBusySelfAliasChild$", "-test.v") + cmd.Env = append(os.Environ(), "MODAVD_ROOT_BUSY_SELF="+alias, "MODAVD_ROOT_BUSY_ACTION="+action) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("self %s through link: %v\n%s", action, err, out) + } + }) + } +} + +func TestRootBusySelfAliasChild(t *testing.T) { + dir := os.Getenv("MODAVD_ROOT_BUSY_SELF") + if dir == "" { + t.Skip("self-update fixture only") + } + l := layout{root: dir} + const rel = "tools/modavd.exe" + plan := rootPlan{replace: []string{rel}} + switch action := os.Getenv("MODAVD_ROOT_BUSY_ACTION"); action { + case "check": + if busy := l.rootFileInUse(plan); busy != "" { + t.Fatalf("self treated as occupant: %s", busy) + } + case "apply", "restore": + source := l.pkgAt(rel) + if action == "restore" { + source = l.prevAt(rel) + } + if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(source, []byte(action), 0o600); err != nil { + t.Fatal(err) + } + var err error + if action == "apply" { + err = l.applyRootFiles(plan) + } else { + err = l.restoreRootFiles(plan) + } + if err != nil { + t.Fatalf("%s: %v", action, err) + } + if got, err := os.ReadFile(l.at(rel)); err != nil || string(got) != action { + t.Fatalf("wrong replacement: %q %v", got, err) + } + default: + t.Fatalf("unknown action: %s", action) + } +} + +func TestRootBusyDeniedACLIsNotUse(t *testing.T) { + path := filepath.Join(t.TempDir(), "denied.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + run := func(args ...string) { + t.Helper() + cmd := exec.Command("icacls.exe", append([]string{path}, args...)...) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("icacls: %v\n%s", err, out) + } + } + run("/deny", "*S-1-1-0:(WD,AD,DE)") + t.Cleanup(func() { run("/remove:d", "*S-1-1-0") }) + if f, err := os.OpenFile(path, os.O_WRONLY, 0); !errors.Is(err, syscall.ERROR_ACCESS_DENIED) { + if err == nil { + f.Close() + } + t.Fatalf("ACL fixture did not deny writing: %v", err) + } + if fileInUse(path) { + t.Error("ACL access denial counts as occupied") + } +} + +func TestRootBusyDeleteSharedReadOnlyFile(t *testing.T) { + const obsolete = "extras/obsolete.txt" + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, map[string]string{obsolete: "obsolete"}) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, nil), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + path := u.l.at(obsolete) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(ptr, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(h) + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("shared reader blocked obsolete file deletion: %+v %v", st, err) + } + if lexists(path) { + t.Error("obsolete file was not removed") + } +} + +func TestRootBusyExternalReadOnlyDLL(t *testing.T) { + for _, tc := range []struct { + name string + deep, short bool + }{{"deep=false", false, false}, {"deep=true", true, false}, {"short name", false, true}} { + t.Run(tc.name, func(t *testing.T) { + dir := testutil.TempDir(t) + dll := filepath.Join(dir, "external.dll") + if tc.deep { + dll = filepath.Join(dir, strings.Repeat("nested-", 14), strings.Repeat("nested-", 14), "external.dll") + } + if err := copyFile(filepath.Join(os.Getenv("SystemRoot"), "System32", "msimg32.dll"), dll); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dll, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(dll, 0o666) }) + loadPath := dll + if tc.short { + ptr, err := syscall.UTF16PtrFromString(dll) + if err != nil { + t.Fatal(err) + } + var short [32768]uint16 + proc := syscall.NewLazyDLL("kernel32.dll").NewProc("GetShortPathNameW") + n, _, err := proc.Call(uintptr(unsafe.Pointer(ptr)), uintptr(unsafe.Pointer(&short[0])), uintptr(len(short))) + if n == 0 || n >= uintptr(len(short)) { + t.Fatalf("GetShortPathName: %v", err) + } + loadPath = syscall.UTF16ToString(short[:n]) + if strings.EqualFold(loadPath, dll) { + t.Skip("test volume does not create 8.3 names") + } + } + ready := filepath.Join(dir, "ready") + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, self, "-test.run=^TestRootBusyDLLChild$") + cmd.Env = append(os.Environ(), "MODAVD_ROOT_BUSY_DLL="+loadPath, "MODAVD_ROOT_BUSY_READY="+ready) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + input, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + waited := false + t.Cleanup(func() { + input.Close() + if !waited { + cancel() + cmd.Wait() + } + }) + deadline := time.Now().Add(5 * time.Second) + for !lexists(ready) { + if time.Now().After(deadline) { + t.Fatal("DLL child never became ready") + } + time.Sleep(10 * time.Millisecond) + } + // Query through a valid directory link too, so mapped image and + // target filenames must resolve to the same actual file. + alias := filepath.Join(dir, "alias") + probe := dll + if err := os.Symlink(filepath.Dir(dll), alias); err == nil { + probe = filepath.Join(alias, filepath.Base(dll)) + } + if !fileInUse(probe) { + t.Error("another process's read-only DLL was missed") + } + input.Close() + err = cmd.Wait() + waited = true + if err != nil { + t.Fatalf("DLL child: %v\n%s", err, output.String()) + } + if fileInUse(probe) { + t.Error("exited DLL child still blocks the file") + } + }) + } +} + +func TestRootBusyDLLChild(t *testing.T) { + path := os.Getenv("MODAVD_ROOT_BUSY_DLL") + if path == "" { + t.Skip("DLL occupancy fixture only") + } + h, err := syscall.LoadLibrary(path) + if err != nil { + t.Fatal(err) + } + defer syscall.FreeLibrary(h) + if err := os.WriteFile(os.Getenv("MODAVD_ROOT_BUSY_READY"), nil, 0o600); err != nil { + t.Fatal(err) + } + var release [1]byte + os.Stdin.Read(release[:]) +} diff --git a/internal/update/state_windows.go b/internal/update/state_windows.go index 78ae678..1c01b11 100644 --- a/internal/update/state_windows.go +++ b/internal/update/state_windows.go @@ -5,25 +5,154 @@ package update import ( "errors" "os" + "strings" "syscall" + "unsafe" ) const ( fileAttributeReparsePoint = 0x400 errorSharingViolation = syscall.Errno(32) errorLockViolation = syscall.Errno(33) + deleteAccess = 0x00010000 ) -// fileInUse: another process holds path so that it can be neither replaced nor deleted, e.g. a DLL MAA has loaded -// (a mapped image refuses write access) or a file opened without sharing. Opened for writing without truncating and -// closed at once; any other error (read-only, missing) is not "in use": the swap reports those itself. +// Probe writes (loaded images) and deletes (handles without FILE_SHARE_DELETE). +// Other errors remain the swap's to report; no attributes or contents are changed. func fileInUse(path string) bool { - f, err := os.OpenFile(path, os.O_WRONLY, 0) - if err == nil { + f, writeErr := os.OpenFile(path, os.O_WRONLY, 0) + if writeErr == nil { f.Close() + } + if errors.Is(writeErr, errorSharingViolation) || errors.Is(writeErr, errorLockViolation) { + return true + } + ptr, err := syscall.UTF16PtrFromString(path) + if err == nil { + h, err := syscall.CreateFile(ptr, deleteAccess, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, + nil, syscall.OPEN_EXISTING, 0, 0) + if err == nil { + syscall.CloseHandle(h) + } else if errors.Is(err, errorSharingViolation) || errors.Is(err, errorLockViolation) { + return true + } + } + // Read-only masks a loaded image's write sharing violation, while DELETE + // alone still succeeds. Confirm an actual loaded image: an ordinary reader + // with delete sharing must not prevent removal of an obsolete read-only file. + if errors.Is(writeErr, syscall.ERROR_ACCESS_DENIED) { + st, err := os.Stat(path) + return err == nil && !st.IsDir() && st.Mode().Perm()&0o200 == 0 && loadedImageInUse(path) + } + return false +} + +var ( + occupancyKernel32 = syscall.NewLazyDLL("kernel32.dll") + enumProcessModulesEx = occupancyKernel32.NewProc("K32EnumProcessModulesEx") + getMappedFileNameW = occupancyKernel32.NewProc("K32GetMappedFileNameW") + getModuleFileNameExW = occupancyKernel32.NewProc("K32GetModuleFileNameExW") + getFinalPathNameW = occupancyKernel32.NewProc("GetFinalPathNameByHandleW") +) + +// loadedImageInUse is best effort, like the write/delete probes. A protected or +// exiting process that cannot be inspected leaves the existing swap/recovery path. +func loadedImageInUse(path string) bool { + target := finalImagePath(path) + if target == "" { + return false + } + snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0) + if err != nil { + return false + } + defer syscall.CloseHandle(snapshot) + var entry syscall.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + for err := syscall.Process32First(snapshot, &entry); err == nil; err = syscall.Process32Next(snapshot, &entry) { + if processHasImage(entry.ProcessID, target) { + return true + } + } + return false +} + +func finalImagePath(path string) string { + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + return "" + } + h, err := syscall.CreateFile(ptr, 0, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, + nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + return "" + } + var name [32768]uint16 + const volumeNameNT = 2 + n, _, _ := getFinalPathNameW.Call(uintptr(h), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name)), volumeNameNT) + syscall.CloseHandle(h) + if n == 0 || n >= uintptr(len(name)) { + return "" + } + // Resolve directory links and 8.3 names into the same NT device path. + return syscall.UTF16ToString(name[:n]) +} + +func processHasImage(pid uint32, target string) bool { + const processQueryInformation = 0x0400 + const processVMRead = 0x0010 + process, err := syscall.OpenProcess(processQueryInformation|processVMRead, false, pid) + if err != nil { + return false + } + defer syscall.CloseHandle(process) + modules := make([]syscall.Handle, 128) + var needed uint32 + const listModulesAll = 3 + const handleSize = uint32(unsafe.Sizeof(syscall.Handle(0))) + // The module list can grow between calls. Retry with the reported size, + // then leave an unstable process to the swap's normal error handling. + for attempt := 0; attempt < 3; attempt++ { + size := uint32(len(modules)) * handleSize + ok, _, _ := enumProcessModulesEx.Call(uintptr(process), uintptr(unsafe.Pointer(&modules[0])), uintptr(size), + uintptr(unsafe.Pointer(&needed)), listModulesAll) + if ok == 0 { + return false + } + if needed > size { + modules = make([]syscall.Handle, (needed+handleSize-1)/handleSize) + continue + } + // Toolhelp's MODULEENTRY32 path is limited to MAX_PATH. The mapped name + // accepts full paths and resolves links to the actual image on disk. + var name [32768]uint16 + for _, module := range modules[:needed/handleSize] { + n, _, err := getMappedFileNameW.Call(uintptr(process), uintptr(module), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name))) + // GetMappedFileName can report MORE_DATA for deep paths even with + // this full-sized buffer. Read the loader's path and resolve it then. + if n == 0 && errors.Is(err, syscall.ERROR_MORE_DATA) { + n, _, _ = getModuleFileNameExW.Call(uintptr(process), uintptr(module), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name))) + if n > 0 && n < uintptr(len(name)) && strings.EqualFold(finalImagePath(syscall.UTF16ToString(name[:n])), target) { + return true + } + continue + } + if n == 0 || n >= uintptr(len(name)) { + continue + } + image := syscall.UTF16ToString(name[:n]) + if strings.EqualFold(image, target) { + return true + } + // A mapped name can retain an 8.3 component such as RUNNER~1 even + // though the target's final name is expanded. Resolve both sides. + if strings.EqualFold(finalImagePath(`\\?\GLOBALROOT`+image), target) { + return true + } + } return false } - return errors.Is(err, errorSharingViolation) || errors.Is(err, errorLockViolation) + return false } func isReparsePoint(st os.FileInfo) bool { diff --git a/internal/update/swap.go b/internal/update/swap.go index 4c21c01..9cc813b 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -224,7 +224,7 @@ func (l layout) applyRootFiles(p rootPlan) error { continue } dst := l.at(rel) - if self != "" && strings.EqualFold(dst, self) && isFile(dst) { + if self != "" && strings.EqualFold(realOrSame(dst), self) && isFile(dst) { if err := l.toTrash(dst); err != nil { return err } @@ -249,7 +249,7 @@ func (l layout) rootFileInUse(p rootPlan) string { self := selfExe() for _, rel := range append(append([]string{}, p.replace...), p.delete...) { dst := l.at(rel) - if inFold(p.created, rel) || (self != "" && strings.EqualFold(dst, self)) || !isFile(dst) { + if inFold(p.created, rel) || (self != "" && strings.EqualFold(realOrSame(dst), self)) || !isFile(dst) { continue } if fileInUse(dst) { @@ -272,7 +272,7 @@ func (l layout) restoreRootFiles(p rootPlan) error { continue } dst := l.at(rel) - if self != "" && strings.EqualFold(dst, self) && isFile(dst) { + if self != "" && strings.EqualFold(realOrSame(dst), self) && isFile(dst) { if err := l.toTrash(dst); err != nil { return err } @@ -289,10 +289,7 @@ func selfExe() string { if err != nil { return "" } - if real, err := filepath.EvalSymlinks(exe); err == nil { - exe = real - } - return exe + return realOrSame(exe) } // ---- swap @@ -325,9 +322,6 @@ func (u *Updater) swapIn() (string, error) { if err != nil { return "", err } - if err := l.backupRootFiles(plan); err != nil { - return "", err - } // A root file another program holds (MAA loads extras\modavd_extras.dll and // mumu-shim\shell\sdk\external_renderer_ipc.dll) cannot be replaced: found out after the renames, it would leave // the root half swapped until that program exits. Checked while nothing has been changed yet. @@ -335,6 +329,9 @@ func (u *Updater) swapIn() (string, error) { l.backToStaged(st) return "", swapBlocked("%s 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装", filepath.FromSlash(rel)) } + if err := l.backupRootFiles(plan); err != nil { + return "", err + } st.Set("phase", jsonord.NewString(phaseSwapping)) st.Set("plan", plan.value()) st.Set("swap", stepVal(stepBegin)) From e2152c563f4600810f7aafd1bcbaa149e090c408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A0=E6=AF=9B=E8=8F=9C?= <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:04:17 +0000 Subject: [PATCH 09/17] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=8C=BA=E9=97=B4?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9A=84=E6=B8=85=E5=8D=95=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E3=80=81=E6=95=B4=E5=8C=85=E5=9B=9E=E9=80=80=E5=92=8C=E5=A4=A7?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=86=85=E5=AD=98=E5=8D=A0=E7=94=A8=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(update): 修正区间更新的清单来源和整包回退 清单始终从远端取。变更太多时只记录日志,本地创建、写入、关闭或复制失败时直接报错。 大文件分块写入临时文件,重试前重置内容,解包后关闭并删除。保留区间响应、超时、取消、CRC 和清单核对。 Refs ModAVD/ModAVD#182 * fix(update): 保留核对失败时的本地文件错误 读取清单、文件哈希和模拟器信息失败时直接报错,避免把本地错误当成包损坏后重新下载整包。构建暂存目录也要保留清单读取错误。 目标文件关闭失败时优先返回本地错误,避免被同时发生的 ZIP 读取错误盖掉。补充失败回归,保留坏包回退和旧版包兼容。 Refs ModAVD/ModAVD#182 * fix(update): 区分包里的目录和本地读取错误 先确认清单和模拟器所需的路径是不是普通文件。包里把文件做成目录时仍按坏包拒绝,删除无效缓存并恢复 Mirror 酱到 GitHub 的回退。 真实本地读取错误仍直接返回并保留有效缓存。旧版可选清单继续使用原有的 ZIP 校验回退。补齐整包、部分增量和旧版的成功、失败与缓存回归。 Refs ModAVD/ModAVD#182 --- internal/download/download.go | 69 +++-- internal/download/range_stream_test.go | 166 ++++++++++++ internal/update/caller_settings_test.go | 4 +- .../update/package_compat_windows_test.go | 223 ++++++++++++++++ internal/update/pkg.go | 126 +++++++-- internal/update/range.go | 136 +++++++--- internal/update/range_cleanup_windows_test.go | 142 ++++++++++ internal/update/range_hardening_test.go | 243 ++++++++++++++++++ .../update/range_local_errors_windows_test.go | 177 +++++++++++++ internal/update/range_test.go | 11 +- internal/update/update_test.go | 4 +- internal/update/updater.go | 4 +- 12 files changed, 1217 insertions(+), 88 deletions(-) create mode 100644 internal/download/range_stream_test.go create mode 100644 internal/update/package_compat_windows_test.go create mode 100644 internal/update/range_cleanup_windows_test.go create mode 100644 internal/update/range_hardening_test.go create mode 100644 internal/update/range_local_errors_windows_test.go diff --git a/internal/download/download.go b/internal/download/download.go index 47c4daf..cb6e58a 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -354,50 +354,79 @@ func (c *Client) File(ctx context.Context, o FileOptions) error { return nil } -// Range reads bytes [offset, offset+length) of a file of total bytes with one range request. The answer must be 206 -// with exactly that Content-Range; anything else (a server that ignores Range answers 200 with the whole file) is an -// Error of Kind NoRange, and its body is not read. +// Range reads a small range into memory. Use RangeTo for entries that can be large. func (c *Client) Range(ctx context.Context, rawURL string, offset, length, total int64, what string) ([]byte, error) { - if offset < 0 || length <= 0 || offset+length > total { - return nil, &Error{Message: fmt.Sprintf("要的区间不对(%d+%d / %d)", offset, length, total)} + var b bytes.Buffer + if err := c.RangeTo(ctx, rawURL, offset, length, total, what, &b); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// RangeTo writes bytes [offset, offset+length) to dst using a fixed-size buffer. The answer must be 206 with exactly +// that Content-Range and byte count. Rejected headers leave dst untouched. A failed transfer may leave a prefix; +// the caller must reset dst before retrying. Writer errors are returned unchanged and must not be retried as network errors. +func (c *Client) RangeTo(ctx context.Context, rawURL string, offset, length, total int64, what string, dst io.Writer) error { + if offset < 0 || length <= 0 || offset > total || length > total-offset { + return &Error{Message: fmt.Sprintf("要的区间不对(%d+%d / %d)", offset, length, total)} } end := offset + length - 1 resp, err := c.get(ctx, rawURL, fmt.Sprintf("bytes=%d-%d", offset, end), what) if err != nil { - return nil, err + return err } defer resp.close() want := fmt.Sprintf("bytes %d-%d/%d", offset, end, total) if got := resp.Header.Get("Content-Range"); resp.StatusCode != http.StatusPartialContent || got != want { - return nil, &Error{Message: fmt.Sprintf("服务器没按区间回(HTTP %d,Content-Range: %q,要的是 %s)", resp.StatusCode, got, want), + return &Error{Message: fmt.Sprintf("服务器没按区间回(HTTP %d,Content-Range: %q,要的是 %s)", resp.StatusCode, got, want), Kind: NoRange, Status: resp.StatusCode} } if resp.ContentLength >= 0 && resp.ContentLength != length { - return nil, sizeError("区间大小和预期不符(服务器给 %d 字节,应为 %d 字节)", resp.ContentLength, length) + return sizeError("区间大小和预期不符(服务器给 %d 字节,应为 %d 字节)", resp.ContentLength, length) } - buf := make([]byte, length+1) // one more to notice a longer body - done := 0 + buf := make([]byte, 64<<10) + var done int64 for { - n, rerr := resp.read(buf[done:], c.stallTimeout()) - done += n - if done > int(length) { - return nil, sizeError("区间大小和预期不符(收到的比 %d 字节多)", length) + if ctx.Err() != nil { + return canceled(ctx) + } + // One extra byte detects a chunked response that runs past the requested range, without writing it. + next := int64(len(buf)) + if length-done < next { + next = length - done + 1 + } + n, rerr := resp.read(buf[:next], c.stallTimeout()) + if int64(n) > length-done { + return sizeError("区间大小和预期不符(收到的比 %d 字节多)", length) + } + if n > 0 { + written, werr := dst.Write(buf[:n]) + if werr != nil { + return werr + } + if written != n { + return io.ErrShortWrite + } + done += int64(n) } if rerr == io.EOF { break } if rerr != nil { if ctx.Err() != nil { - return nil, canceled(ctx) + return canceled(ctx) } if resp.fired.Load() { - return nil, timeoutError("超时:%s(收数据,已收 %d 字节),%s 秒内没有进展", what, done, seconds(c.stallTimeout())) + return timeoutError("超时:%s(收数据,已收 %d 字节),%s 秒内没有进展", what, done, seconds(c.stallTimeout())) } - return nil, &Error{Message: "收数据出错:" + NetError(rerr, "")} + return &Error{Message: "收数据出错:" + NetError(rerr, "")} } } - if done != int(length) { - return nil, &Error{Message: fmt.Sprintf("区间没收全:应为 %d 字节,实际 %d 字节", length, done)} + if ctx.Err() != nil { + return canceled(ctx) } - return buf[:length], nil + if done != length { + return &Error{Message: fmt.Sprintf("区间没收全:应为 %d 字节,实际 %d 字节", length, done)} + } + return nil } diff --git a/internal/download/range_stream_test.go b/internal/download/range_stream_test.go new file mode 100644 index 0000000..ab3e286 --- /dev/null +++ b/internal/download/range_stream_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package download + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" + "time" + + "modavd/internal/errs" +) + +type rangeWriter struct { + count, max int + err error + cancel context.CancelFunc +} + +func (w *rangeWriter) Write(p []byte) (int, error) { + w.count += len(p) + w.max = max(w.max, len(p)) + if w.cancel != nil { + w.cancel() + } + if w.err != nil { + return 0, w.err + } + return len(p), nil +} + +func TestRangeToStreamsWithBoundedMemory(t *testing.T) { + const size = 12 << 20 + // A generated body has no size-dependent allocation to hide the client's allocation cost. + body := &watchedBody{Reader: io.LimitReader(zeroReader{}, size)} + d := Client{HTTP: &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 206, Header: http.Header{"Content-Range": {fmt.Sprintf("bytes 0-%d/%d", size-1, size)}}, ContentLength: size, Body: body}, nil + })}} + w := &rangeWriter{} + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + err := d.RangeTo(context.Background(), "https://example.invalid/pkg", 0, size, size, "test", w) + runtime.ReadMemStats(&after) + allocated := after.TotalAlloc - before.TotalAlloc + if err != nil || w.count != size || w.max > 64<<10 || !body.closed || allocated > 2<<20 { + t.Fatalf("stream: bytes %d, max chunk %d, allocated %d, closed %v, error %v", w.count, w.max, allocated, body.closed, err) + } + t.Logf("%d bytes transferred, max write %d, allocated %d", size, w.max, allocated) +} + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { clear(p); return len(p), nil } + +func TestRangeToProtocol(t *testing.T) { + for _, tc := range []struct { + name, cr, body, encoding string + status int + length int64 + kind Kind + want string + }{ + {"valid", "bytes 2-4/10", "cde", "", 206, 3, Network, "cde"}, + {"ignored", "", "abcdefghij", "", 200, 10, NoRange, ""}, + {"other range", "bytes 2-5/10", "cdef", "", 206, 4, NoRange, ""}, + {"wrong total", "bytes 2-4/11", "cde", "", 206, 3, NoRange, ""}, + {"wrong length", "bytes 2-4/10", "cd", "", 206, 2, Size, ""}, + {"encoding", "bytes 2-4/10", "cde", "gzip", 206, 3, Encoding, ""}, + {"short", "bytes 2-4/10", "cd", "", 206, -1, Network, "cd"}, + {"long", "bytes 2-4/10", "cdef", "", 206, -1, Size, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + body := &watchedBody{Reader: strings.NewReader(tc.body)} + d := Client{HTTP: &http.Client{Transport: roundTrip(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Range") != "bytes=2-4" || req.Header.Get("Accept-Encoding") != "identity" { + t.Errorf("headers: %v", req.Header) + } + return &http.Response{StatusCode: tc.status, Header: http.Header{"Content-Range": {tc.cr}, "Content-Encoding": {tc.encoding}}, ContentLength: tc.length, Body: body}, nil + })}} + var dst bytes.Buffer + err := d.RangeTo(context.Background(), "https://example.invalid/pkg", 2, 3, 10, "test", &dst) + var de *Error + if tc.name == "valid" { + if err != nil { + t.Fatal(err) + } + } else if !errors.As(err, &de) || de.Kind != tc.kind { + t.Fatalf("want kind %d, got %v", tc.kind, err) + } + if dst.String() != tc.want || !body.closed { + t.Fatalf("output %q, closed %v", dst.String(), body.closed) + } + }) + } +} + +func TestRangeToLocalErrorAndCancel(t *testing.T) { + local := errors.New("disk full") + for _, cancelWrite := range []bool{false, true} { + ctx, cancel := context.WithCancel(context.Background()) + body := &watchedBody{Reader: strings.NewReader("abc")} + d := Client{HTTP: &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 206, Header: http.Header{"Content-Range": {"bytes 0-2/3"}}, ContentLength: 3, Body: body}, nil + })}} + w := &rangeWriter{err: local} + if cancelWrite { + w.err, w.cancel = nil, cancel + } + err := d.RangeTo(ctx, "https://example.invalid/pkg", 0, 3, 3, "test", w) + cancel() + if (!cancelWrite && !errors.Is(err, local)) || (cancelWrite && errs.CodeOf(err) != errs.Canceled) || !body.closed { + t.Fatalf("cancel %v: closed %v, error %v", cancelWrite, body.closed, err) + } + } +} + +func TestRangeToStallClosesResponse(t *testing.T) { + closed := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Range", "bytes 0-5/6") + w.Header().Set("Content-Length", "6") + w.WriteHeader(206) + io.WriteString(w, "abc") + w.(http.Flusher).Flush() + <-r.Context().Done() + close(closed) + })) + defer srv.Close() + d := Client{HTTP: NewHTTPClient(0), StallTimeout: 30 * time.Millisecond} + err := d.RangeTo(context.Background(), srv.URL, 0, 6, 6, "test", io.Discard) + var de *Error + if !errors.As(err, &de) || de.Kind != Timeout { + t.Fatalf("want timeout, got %v", err) + } + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("response not canceled") + } +} + +func TestRangeToOverflow(t *testing.T) { + requests := 0 + d := Client{HTTP: &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + requests++ + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(""))}, nil + })}} + for _, bounds := range [][3]int64{{math.MaxInt64 - 1, 5, math.MaxInt64}, {-1, 1, 10}, {0, 0, 10}} { + if err := d.RangeTo(context.Background(), "https://example.invalid/pkg", bounds[0], bounds[1], bounds[2], "test", io.Discard); err == nil { + t.Fatal("invalid range accepted") + } + } + if requests != 0 { + t.Fatal("invalid range sent a request") + } +} diff --git a/internal/update/caller_settings_test.go b/internal/update/caller_settings_test.go index 72ec9d3..b994ad8 100644 --- a/internal/update/caller_settings_test.go +++ b/internal/update/caller_settings_test.go @@ -116,8 +116,8 @@ func TestCallerSettingsOverride(t *testing.T) { t.Fatalf("status: %+v", st) } st, err := u.Install(context.Background()) - // every file changed between the two builds: GitHub's range path falls back to the whole package (second notice) - if err != nil || !st.Updated || len(st.Notices) != 2 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") || !strings.Contains(st.Notices[1], "变了的文件太多") { + // The normal GitHub range threshold adds no notice; the bad CDK still does. + if err != nil || !st.Updated || len(st.Notices) != 1 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") { t.Fatalf("install: %+v %v", st, err) } if len(seen) == 0 || seen[len(seen)-1]["cdk"] != cdk { diff --git a/internal/update/package_compat_windows_test.go b/internal/update/package_compat_windows_test.go new file mode 100644 index 0000000..8e01020 --- /dev/null +++ b/internal/update/package_compat_windows_test.go @@ -0,0 +1,223 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "testing" + + "modavd/internal/errs" +) + +func compatArchive(t *testing.T, files map[string]string, incremental, manifest bool, deleted []string) []byte { + t.Helper() + payload := merged(files) + if manifest { + payload[manifestName] = string(manifestFor(37, files)) + } + if incremental { + modified := make([]string, 0, len(payload)) + for name := range payload { + modified = append(modified, name) + } + sort.Strings(modified) + changes, err := json.Marshal(map[string][]string{"modified": modified, "deleted": deleted}) + if err != nil { + t.Fatal(err) + } + payload["changes.json"] = string(changes) + } + return zipOf(t, 37, payload, true) +} + +func stageCachedPackage(t *testing.T, data []byte, incremental bool, tag, lockTarget string, baseFiles map[string]string) (*Updater, string, error) { + t.Helper() + r := writeRoot(t, 36, baseFiles) + u := New(r, nil, Options{}) + archive := u.l.downloadAt("cached.zip") + if err := os.MkdirAll(filepath.Dir(archive), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(archive, data, 0o644); err != nil { + t.Fatal(err) + } + rel := &release{source: SourceMirrorChyan, tag: tag, version: Version{0, 37, 0}, assetName: "cached.zip", url: "https://unused.invalid/cached.zip", size: int64(len(data)), sha256: hashBytes(data), incremental: incremental, base: Version{0, 36, 0}} + var held syscall.Handle + defer func() { + if held != 0 { + syscall.CloseHandle(held) + } + }() + if lockTarget != "" { + u.Opt.Progress = func(p Progress) { + if p.Stage != "verifying" || held != 0 { + return + } + name, err := syscall.UTF16PtrFromString(u.l.pkgAt(lockTarget)) + if err != nil { + t.Fatal(err) + } + held, err = syscall.CreateFile(name, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + } + } + err := u.stage(context.Background(), rel, &Status{}) + return u, archive, err +} + +func TestRequiredPackageDirectoriesRejectAndDeleteCache(t *testing.T) { + for _, mode := range []string{"full", "incremental", "legacy"} { + for _, target := range []string{manifestName, "sdk/emulator/source.properties", "sdk/emulator/qemu/windows-x86_64/qemu-system-x86_64-headless.exe"} { + t.Run(mode+"/"+target, func(t *testing.T) { + files := pkgFiles(37, nil) + delete(files, target) + files[target+"/child"] = "directory supplied by the package" + incremental := mode == "incremental" + manifest := mode != "legacy" && target != manifestName + tag := "v0.37.0" + if mode == "legacy" { + tag = "v37.2.10-mas37" + } + u, archive, err := stageCachedPackage(t, compatArchive(t, files, incremental, manifest, []string{target}), incremental, tag, "", nil) + if mode == "legacy" && target == manifestName { + if err != nil || stagedMode(u) != "full" || !lexists(archive) { + t.Fatalf("optional legacy manifest directory changed behavior: %v", err) + } + } else if errs.CodeOf(err) != errs.UpdateRejected || lexists(archive) || phaseOf(u.l.readState()) != phaseIdle { + t.Fatalf("package directory: error %v, code %s, cached %v, phase %s", err, errs.CodeOf(err), lexists(archive), phaseOf(u.l.readState())) + } + }) + } + } +} + +func TestPackageReadErrorsRetainFullAndIncrementalCache(t *testing.T) { + for _, incremental := range []bool{false, true} { + for _, target := range []string{manifestName, "sdk/emulator/emulator.exe"} { + name := "full/" + target + if incremental { + name = "incremental/" + target + } + t.Run(name, func(t *testing.T) { + data := compatArchive(t, pkgFiles(37, nil), incremental, true, nil) + u, archive, err := stageCachedPackage(t, data, incremental, "v0.37.0", target, nil) + var pe *os.PathError + if !errors.As(err, &pe) || errs.CodeOf(err) == errs.UpdateRejected || !lexists(archive) || lexists(u.l.stagedMarker()) { + t.Fatalf("local read: %v, cached %v", err, lexists(archive)) + } + // The read-denying handle is released before cleanup retries the parent-directory rename. + if err := u.l.abortStaging("test read failure"); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestWholeAndLegacyPackageSuccess(t *testing.T) { + for _, legacy := range []bool{false, true} { + name, tag := "full", "v0.37.0" + if legacy { + name, tag = "legacy", "v37.2.10-mas37" + } + t.Run(name, func(t *testing.T) { + files := pkgFiles(37, nil) + u, _, err := stageCachedPackage(t, compatArchive(t, files, false, !legacy, nil), false, tag, "", nil) + if err != nil || stagedMode(u) != "full" { + t.Fatalf("stage: %v", err) + } + if err := u.l.checkStaged(u.l.readState()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestIncrementalPackageReusesAddsAndDeletes(t *testing.T) { + files := pkgFiles(37, map[string]string{"tools/keep.ps1": "kept", "tools/added.ps1": "added"}) + payload := merged(files) + delete(payload, "tools/keep.ps1") + payload[manifestName] = string(manifestFor(37, files)) + modified := []string{manifestName} + for name := range payload { + if name != manifestName && name != "tools/added.ps1" { + modified = append(modified, name) + } + } + sort.Strings(modified) + changes, err := json.Marshal(map[string][]string{"added": {"tools/added.ps1"}, "modified": modified, "deleted": {"tools/deleted.ps1"}}) + if err != nil { + t.Fatal(err) + } + payload["changes.json"] = string(changes) + u, _, err := stageCachedPackage(t, zipOf(t, 37, payload, true), true, "v0.37.0", "", map[string]string{"tools/keep.ps1": "kept", "tools/deleted.ps1": "old"}) + if err != nil || stagedMode(u) != "incremental" { + t.Fatalf("stage: %v", err) + } + for name, want := range files { + path := u.l.pkgAt(name) + if suffix, ok := strings.CutPrefix(name, "sdk/emulator/"); ok { + path = filepath.Join(u.l.stageEmulator(), filepath.FromSlash(suffix)) + } + body, err := os.ReadFile(path) + if err != nil || string(body) != want { + t.Fatalf("%s: %q %v", name, body, err) + } + } + if lexists(u.l.pkgAt("tools/deleted.ps1")) || !lexists(u.l.at("tools/deleted.ps1")) { + t.Fatal("incremental deletion or installed-root isolation failed") + } + if err := u.l.checkStaged(u.l.readState()); err != nil { + t.Fatal(err) + } +} + +func TestMirrorPackageDirectoryFallsBackToGitHub(t *testing.T) { + for _, incremental := range []bool{false, true} { + name, updateType := "full", "full" + if incremental { + name, updateType = "incremental", "incremental" + } + t.Run(name, func(t *testing.T) { + withSettings(t, SourceMirrorChyan, "test-cdk") + target := "sdk/emulator/source.properties" + badFiles := pkgFiles(37, nil) + delete(badFiles, target) + badFiles[target+"/child"] = "not a properties file" + bad := compatArchive(t, badFiles, incremental, true, []string{target}) + good := zipOf(t, 37, pkgFiles(37, nil), false) + f := newSources(t, 37, good) + f.mirror = func(map[string]string) (int, any) { + return 0, map[string]any{"version_name": "v0.37.0", "url": f.srv.URL + "/dl/bad.zip", "filesize": len(bad), "sha256": hashBytes(bad), "update_type": updateType} + } + log := &dlLog{} + serve := log.serve(good, nil) + f.dl = func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/bad.zip") { + http.ServeContent(w, r, "bad.zip", zipTime, bytes.NewReader(bad)) + return + } + serve(w, r) + } + u := f.updater(writeRoot(t, 36, nil)) + st, err := u.Prepare(context.Background()) + if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" || log.fullGets() != 1 || len(st.Notices) != 1 || !strings.Contains(st.Notices[0], "Mirror 酱") { + t.Fatalf("fallback: %+v, error %v, full GETs %d", st, err, log.fullGets()) + } + }) + } +} diff --git a/internal/update/pkg.go b/internal/update/pkg.go index 0ca12e6..0e029dc 100644 --- a/internal/update/pkg.go +++ b/internal/update/pkg.go @@ -10,6 +10,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -167,6 +168,9 @@ func extractEntry(e zipEntry, dst string) error { } rc, err := e.f.Open() if err != nil { + if isDiskError(err) { + return err + } return packageError("包里的 %s 打不开: %v", e.rel, err) } defer rc.Close() @@ -174,10 +178,7 @@ func extractEntry(e zipEntry, dst string) error { if err != nil { return err } - _, cerr := io.Copy(out, rc) - if err := out.Close(); cerr == nil { - cerr = err - } + cerr := copyEntryContents(out, rc) if cerr != nil { os.Remove(dst) if os.IsPermission(cerr) || isDiskError(cerr) { @@ -188,9 +189,18 @@ func extractEntry(e zipEntry, dst string) error { return nil } +// copyEntryContents owns the output handle, including when reading the ZIP entry fails. +func copyEntryContents(out io.WriteCloser, source io.Reader) error { + _, err := io.Copy(out, source) + if closeErr := out.Close(); closeErr != nil { + return closeErr + } + return err +} + func isDiskError(err error) bool { - _, ok := err.(*os.PathError) - return ok + var pathErr *os.PathError + return errors.As(err, &pathErr) } // extractPackage unpacks a full package into pkg\; the entry count. @@ -421,16 +431,41 @@ func (l layout) buildPatchedPackage(r *root.Root, archive string, base Version) // ---- the manifest +// regularPackageFile distinguishes an absent or wrong-shaped package entry from a local metadata error. +func regularPackageFile(path string) (bool, error) { + st, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return st.Mode().IsRegular(), nil +} + +// readManifest is a best-effort installed-version probe. Staging callers use loadManifest to retain I/O errors. func readManifest(path string) (*jsonord.Obj, bool) { + m, ok, _ := loadManifest(path) + return m, ok +} + +func loadManifest(path string) (*jsonord.Obj, bool, error) { + regular, err := regularPackageFile(path) + if err != nil || !regular { + return nil, false, err + } b, err := root.ReadFileShared(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } if err != nil { - return nil, false + return nil, false, err } v, err := jsonord.Parse(trimBOM(b)) if err != nil || v.Kind() != jsonord.Object || v.Obj().Len() == 0 { - return nil, false + return nil, false, nil } - return v.Obj(), true + return v.Obj(), true, nil } func manifestString(m *jsonord.Obj, key string) string { @@ -475,9 +510,10 @@ func verifyTree(tree string, files map[string]fileSpec) ([]string, error) { want[strings.ToLower(rel)] = rel } var problems []string + var readErr error seen := map[string]bool{} err := walkFiles(tree, func(rel string, info os.FileInfo) { - if strings.EqualFold(rel, manifestName) { + if readErr != nil || strings.EqualFold(rel, manifestName) { return } key := strings.ToLower(rel) @@ -492,13 +528,21 @@ func verifyTree(tree string, files map[string]fileSpec) ([]string, error) { problems = append(problems, rel+" 大小不对") return } - if sum, err := hashFile(filepath.Join(tree, filepath.FromSlash(rel))); err != nil || sum != spec.sha256 { + sum, err := hashFile(filepath.Join(tree, filepath.FromSlash(rel))) + if err != nil { + readErr = err + return + } + if sum != spec.sha256 { problems = append(problems, rel+" 内容与清单不符") } }) if err != nil { return nil, err } + if readErr != nil { + return nil, readErr + } for key, rel := range want { if !seen[key] { problems = append(problems, "缺 "+rel) @@ -546,10 +590,9 @@ func checkPackageManifest(m *jsonord.Obj, target Version) error { var buildIDRe = regexp.MustCompile(`^mas-([0-9]+)$`) -// packageBuild is the emulator build the package in pkg\ says it carries: its manifest's "build", else (a legacy -// package without one) the target's minor number, as versions through 0.40 were numbered. -func (l layout) packageBuild(target Version) int { - if m, ok := readManifest(l.pkgAt(manifestName)); ok { +// manifestBuild is the emulator build from the already-read manifest, or the minor version for legacy packages. +func manifestBuild(m *jsonord.Obj, target Version) int { + if m != nil { if n, ok := m.Get("build").Int(); ok && n > 0 { return int(n) } @@ -561,12 +604,22 @@ func (l layout) packageBuild(target Version) int { // and its qemu is our own build (it knows the free-page-reporting balloon option). func checkEmulator(emu string, want int, target Version) error { p := root.CurrentPlatform() - qemu := filepath.Join(emu, "qemu", p.QemuDir, p.QEMU) - if !isFile(filepath.Join(emu, p.Emulator)) || !isFile(qemu) { - return packageError(`包里的 sdk\emulator 不完整(缺 emulator.exe 或 qemu)`) + for _, rel := range []string{p.Emulator, filepath.Join("qemu", p.QemuDir, p.QEMU), + "source.properties", filepath.Join("qemu", p.QemuDir, p.QEMUHeadless)} { + regular, err := regularPackageFile(filepath.Join(emu, rel)) + if err != nil { + return err + } + if !regular { + return packageError("包里的 sdk/emulator/%s 不存在或不是普通文件", filepath.ToSlash(rel)) + } } build := "读不到" - if b, err := os.ReadFile(filepath.Join(emu, "source.properties")); err == nil { + b, err := os.ReadFile(filepath.Join(emu, "source.properties")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err == nil { id := "" for _, line := range strings.Split(string(b), "\n") { if k, v, ok := strings.Cut(line, "="); ok && strings.TrimSpace(k) == "Pkg.BuildId" { @@ -580,26 +633,36 @@ func checkEmulator(emu string, want int, target Version) error { if build != fmt.Sprint(want) { return packageError("包里模拟器的构建号(%s)和要装的 %s(构建号 %d)不一致", build, target.Display(), want) } - if !fileContains(filepath.Join(emu, "qemu", p.QemuDir, p.QEMUHeadless), []byte("free-page-reporting")) { + ours, err := fileContains(filepath.Join(emu, "qemu", p.QemuDir, p.QEMUHeadless), []byte("free-page-reporting")) + if err != nil { + return err + } + if !ours { return packageError("包里的模拟器不是 ModAVD 的自编版") } return nil } -// fileContains scans p in 4 MiB chunks (with overlap) for needle; false when unreadable. -func fileContains(p string, needle []byte) bool { +// fileContains scans p in 4 MiB chunks (with overlap) for needle. A missing file has no match; other I/O errors propagate. +func fileContains(p string, needle []byte) (bool, error) { f, err := os.Open(p) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } if err != nil { - return false + return false, err } defer f.Close() buf := make([]byte, 4<<20) carry := []byte{} for { n, err := f.Read(buf) + if err != nil && err != io.EOF { + return false, err + } chunk := append(carry, buf[:n]...) if bytes.Contains(chunk, needle) { - return true + return true, nil } if len(chunk) >= len(needle) { carry = append([]byte{}, chunk[len(chunk)-len(needle)+1:]...) @@ -607,7 +670,7 @@ func fileContains(p string, needle []byte) bool { carry = chunk } if err != nil { - return false + return false, nil } } } @@ -615,7 +678,10 @@ func fileContains(p string, needle []byte) bool { // verifyPackage is AUTO-MAS verify_package: "manifest" when pkg\ matches its manifest, "zip" for a legacy package // without one (only its zip CRC and download hash vouch for it). func (l layout) verifyPackage(target Version, tag string) (string, error) { - m, ok := readManifest(l.pkgAt(manifestName)) + m, ok, err := loadManifest(l.pkgAt(manifestName)) + if err != nil { + return "", err + } verified := "zip" switch { case !ok && requiresManifest(tag): @@ -642,7 +708,7 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { } verified = "manifest" } - if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { + if err := checkEmulator(l.pkgAt("sdk/emulator"), manifestBuild(m, target), target); err != nil { return "", err } return verified, nil @@ -650,7 +716,11 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { // buildStage moves pkg\sdk\emulator to .update\stage\emulator. func (l layout) buildStage(target Version) error { - if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { + m, _, err := loadManifest(l.pkgAt(manifestName)) + if err != nil { + return err + } + if err := checkEmulator(l.pkgAt("sdk/emulator"), manifestBuild(m, target), target); err != nil { return err } if err := removeTree(l.stage()); err != nil { diff --git a/internal/update/range.go b/internal/update/range.go index b63e886..aadc76b 100644 --- a/internal/update/range.go +++ b/internal/update/range.go @@ -6,11 +6,12 @@ package update // unpacking the whole package would leave it. The manifest check that follows is the same as for a full download. // // archive/zip reads the package through remoteZip, an io.ReaderAt that holds only the pieces fetched so far. Anything -// that goes wrong on the way (the server ignores Range, too much changed, a fetched entry fails its CRC, the result -// fails the manifest) is a *rangeUnavailable: the caller drops pkg\ and downloads the whole package instead. +// that makes the range result unusable (unsupported ranges, too much changed, a bad CRC or manifest) returns a +// *rangeUnavailable: the caller drops pkg\ and downloads the whole package instead. Local creation, write and close errors stop the update. import ( "archive/zip" + "bytes" "context" "errors" "fmt" @@ -44,19 +45,22 @@ const ( rangeMaxDirectory = 32 << 20 ) -// rangeUnavailable: this update cannot be done by range; download the whole package. The reason goes into the notices. -type rangeUnavailable struct{ reason string } +// rangeUnavailable: download the whole package. Only failed attempts, not normal threshold decisions, add a notice. +type rangeUnavailable struct { + reason string + normal bool // a threshold decision, not a failed range attempt +} func (e *rangeUnavailable) Error() string { return e.reason } func unavailable(format string, args ...any) *rangeUnavailable { - return &rangeUnavailable{fmt.Sprintf(format, args...)} + return &rangeUnavailable{reason: fmt.Sprintf(format, args...)} } // segment is a piece of the remote package that has been fetched. type segment struct { - start int64 - data []byte + start, length int64 + reader io.ReaderAt } // remoteZip is the remote package as an io.ReaderAt for archive/zip: reads are served from the fetched segments. While @@ -76,7 +80,7 @@ var errNotFetched = errors.New("读到了没取回的位置") func (r *remoteZip) find(pos int64) *segment { for k := range r.segs { s := &r.segs[k] - if s.start <= pos && pos < s.start+int64(len(s.data)) { + if s.start <= pos && pos < s.start+s.length { return s } } @@ -95,7 +99,7 @@ func (r *remoteZip) gapEnd(pos int64) int64 { } func (r *remoteZip) add(start int64, data []byte) { - r.segs = append(r.segs, segment{start, data}) + r.segs = append(r.segs, segment{start, int64(len(data)), bytes.NewReader(data)}) } func (r *remoteZip) drop(start int64) { @@ -133,7 +137,15 @@ func (r *remoteZip) ReadAt(p []byte, off int64) (int, error) { r.add(pos, data) continue } - n += copy(p[n:], s.data[pos-s.start:]) + want := min(int64(len(p)-n), s.length-(pos-s.start)) + got, err := s.reader.ReadAt(p[n:n+int(want)], pos-s.start) + n += got + if err != nil { + return n, err + } + if int64(got) != want { + return n, io.ErrUnexpectedEOF + } } return n, nil } @@ -178,21 +190,34 @@ func (l layout) sameAsLocal(e zipEntry) bool { return h.Sum32() == e.f.CRC32 } -// rangeRead reads one range of r's package, retried like a download (but not when the server cannot serve ranges). +// rangeRead is bounded by rangeMaxDirectory for metadata and rangeMaxGroup for small entry groups. func (u *Updater) rangeRead(ctx context.Context, r *release, off, n int64, what string) ([]byte, error) { d := download.Client{HTTP: u.client(), StallTimeout: u.stallTimeout} + var data []byte + err := u.rangeRetry(ctx, off, n, what, func() (err error) { + data, err = d.Range(ctx, r.url, off, n, r.size, what) + return err + }) + return data, err +} + +// rangeRetry retries only transport failures. Local writer, seek and truncate errors stop immediately. +func (u *Updater) rangeRetry(ctx context.Context, off, n int64, what string, fetch func() error) error { var last error for attempt := 1; attempt <= downloadAttempts; attempt++ { - b, err := d.Range(ctx, r.url, off, n, r.size, what) + err := fetch() if err == nil { - return b, nil + return nil } if ctx.Err() != nil { - return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + return errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } - last = err var de *download.Error - if errors.As(err, &de) && (de.Kind == download.NoRange || de.Kind == download.Size) { + if !errors.As(err, &de) { + return err + } + last = err + if de.Kind == download.NoRange || de.Kind == download.Size { break } u.Log.Warnf("%s 第 %d 次没取到: %v", what, attempt, err) @@ -200,11 +225,11 @@ func (u *Updater) rangeRead(ctx context.Context, r *release, off, n int64, what select { case <-time.After(time.Duration(attempt) * time.Second): case <-ctx.Done(): - return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + return errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } } } - return nil, unavailable("按区间取 %d-%d 没成(%v)", off, off+n-1, last) + return unavailable("按区间取 %d-%d 没成(%v)", off, off+n-1, last) } // planRange reads the package's central directory and compares it with the root: which entries to fetch, which to @@ -239,7 +264,7 @@ func (u *Updater) planRange(ctx context.Context, r *release) (*rangePlan, error) if ctx.Err() != nil { return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } - if u.l.sameAsLocal(e) { + if !strings.EqualFold(e.rel, manifestName) && u.l.sameAsLocal(e) { plan.copy = append(plan.copy, e) continue } @@ -251,8 +276,8 @@ func (u *Updater) planRange(ctx context.Context, r *release) (*rangePlan, error) plan.fetch = append(plan.fetch, rangeEntry{e, start, length}) } if fetched := plan.fetchBytes(); rangeTooMuch(len(plan.fetch), fetched, r.size) { - return nil, unavailable("变了的文件太多(%d 个、约 %s,超过整包的 %.0f%% 或 %d 个文件)", len(plan.fetch), mib(fetched), - rangeMaxFetchRatio*100, rangeMaxFetchEntries) + return nil, &rangeUnavailable{reason: fmt.Sprintf("变了的文件太多(%d 个、约 %s,超过整包的 %.0f%% 或 %d 个文件)", + len(plan.fetch), mib(fetched), rangeMaxFetchRatio*100, rangeMaxFetchEntries), normal: true} } return plan, nil } @@ -327,19 +352,10 @@ func (u *Updater) stageByRange(ctx context.Context, r *release) (int, string, er var fetched int64 u.progress("downloading", label, 0, 0, total) for _, g := range groups { - data, err := u.rangeRead(ctx, r, g.start, g.length, r.assetName) - if err != nil { + if err := u.stageRangeGroup(ctx, r, g, plan.remote); err != nil { return 0, "", err } - plan.remote.add(g.start, data) - for _, e := range g.members { - if err := extractEntry(e.zipEntry, u.l.pkgAt(e.rel)); err != nil { - plan.remote.drop(g.start) - return 0, "", unavailable("取回的 %s 不对: %v", e.rel, errs.As(err).Message) - } - } - plan.remote.drop(g.start) - fetched += int64(len(data)) + fetched += g.length u.progress("downloading", label, min(float64(fetched)*100/float64(max(total, 1)), 100), min(fetched, total), total) } u.step("extracting", "正在拼新版(没变的文件从本地复制)") @@ -348,7 +364,7 @@ func (u *Updater) stageByRange(ctx context.Context, r *release) (int, string, er return 0, "", errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } if err := copyFile(u.l.at(e.rel), u.l.pkgAt(e.rel)); err != nil { - return 0, "", unavailable("复制本地的 %s 没成: %v", e.rel, err) + return 0, "", err } if k%200 == 0 || k == len(plan.copy)-1 { u.progress("extracting", "正在拼新版(没变的文件从本地复制)", float64(k+1)*100/float64(len(plan.copy)), 0, 0) @@ -366,6 +382,60 @@ func (u *Updater) stageByRange(ctx context.Context, r *release) (int, string, er return len(plan.fetch) + len(plan.copy), verified, nil } +// stageRangeGroup keeps small groups in memory. A large entry uses a private file under pkg so an interrupted +// staging attempt is cleaned by the existing staging recovery. Drop the ReaderAt segment before closing the file. +func (u *Updater) stageRangeGroup(ctx context.Context, r *release, g rangeGroup, remote *remoteZip) (err error) { + if g.length <= rangeMaxGroup { + data, err := u.rangeRead(ctx, r, g.start, g.length, r.assetName) + if err != nil { + return err + } + remote.add(g.start, data) + } else { + f, createErr := os.CreateTemp(u.l.pkg(), ".range-*") + if createErr != nil { + return createErr + } + defer func() { + // A local cleanup failure must also stop the update instead of starting a full download. + cerr := f.Close() + rerr := os.Remove(f.Name()) + if cerr != nil { + err = cerr + } + if rerr != nil { + err = rerr + } + }() + d := download.Client{HTTP: u.client(), StallTimeout: u.stallTimeout} + if err := u.rangeRetry(ctx, g.start, g.length, r.assetName, func() error { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + if err := f.Truncate(0); err != nil { + return err + } + return d.RangeTo(ctx, r.url, g.start, g.length, r.size, r.assetName, f) + }); err != nil { + return err + } + remote.segs = append(remote.segs, segment{g.start, g.length, f}) + } + defer remote.drop(g.start) + for _, e := range g.members { + if ctx.Err() != nil { + return errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + } + if err := extractEntry(e.zipEntry, u.l.pkgAt(e.rel)); err != nil { + if errs.CodeOf(err) != errs.UpdateRejected { + return err + } + return unavailable("取回的 %s 不对: %v", e.rel, errs.As(err).Message) + } + } + return nil +} + // rangeAllowed: GitHub's full package of a version that carries a manifest (the range path cannot check the package's // SHA-256, so the manifest has to vouch for every file), with no full download of it (finished or partial) to use. func (u *Updater) rangeAllowed(r *release) bool { diff --git a/internal/update/range_cleanup_windows_test.go b/internal/update/range_cleanup_windows_test.go new file mode 100644 index 0000000..2631704 --- /dev/null +++ b/internal/update/range_cleanup_windows_test.go @@ -0,0 +1,142 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "syscall" + "testing" +) + +type rangeEOFReader struct { + io.Reader + onEOF func() +} + +func (r *rangeEOFReader) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + if err == io.EOF && r.onEOF != nil { + r.onEOF() + r.onEOF = nil + } + return n, err +} +func (*rangeEOFReader) Close() error { return nil } + +func TestRangeLargeGroupCleanupErrorsStopFallback(t *testing.T) { + // Store the fixture so the range exceeds 8 MiB without spending time compressing test padding. + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + w, err := zw.CreateHeader(&zip.FileHeader{Name: "payload.bin", Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err := io.CopyN(w, bytes.NewReader(bytes.Repeat([]byte{7}, 9<<20)), 9<<20); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + for _, failure := range []string{"close", "remove"} { + t.Run(failure, func(t *testing.T) { + r := writeRoot(t, 36, nil) + f := newSources(t, 37, archive.Bytes()) + u := f.updater(r) + if err := os.MkdirAll(u.l.pkg(), 0o755); err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(archive.Bytes()), int64(archive.Len())) + if err != nil { + t.Fatal(err) + } + remote := &remoteZip{size: int64(archive.Len())} + var held syscall.Handle + defer func() { + if held != 0 { + syscall.CloseHandle(held) + } + }() + injected := false + zr.RegisterDecompressor(zip.Store, func(input io.Reader) io.ReadCloser { + return &rangeEOFReader{Reader: input, onEOF: func() { + // Inject the OS failure after extraction has read every byte, so only cleanup can report it. + for _, s := range remote.segs { + file, ok := s.reader.(*os.File) + if !ok { + continue + } + injected = true + if failure == "close" { + if err := file.Close(); err != nil { + t.Fatal(err) + } + } else { + name, err := syscall.UTF16PtrFromString(file.Name()) + if err != nil { + t.Fatal(err) + } + held, err = syscall.CreateFile(name, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, + nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + } + } + }} + }) + g := rangeGroup{length: int64(archive.Len()), members: []rangeEntry{{zipEntry: zipEntry{rel: "payload.bin", f: zr.File[0]}}}} + rel, err := u.githubLatest(context.Background()) + if err != nil { + t.Fatal(err) + } + err = u.stageRangeGroup(context.Background(), rel, g, remote) + var ru *rangeUnavailable + var pathErr *os.PathError + if !injected || err == nil || errors.As(err, &ru) || !errors.As(err, &pathErr) || len(remote.segs) != 0 { + t.Fatalf("cleanup %s: injected %v, error %v, remaining segments %d", failure, injected, err, len(remote.segs)) + } + if failure == "close" && !errors.Is(err, os.ErrClosed) { + t.Fatalf("want close error, got %v", err) + } + if held != 0 { + syscall.CloseHandle(held) + held = 0 + } + temps, err := filepath.Glob(filepath.Join(u.l.pkg(), ".range-*")) + if err != nil { + t.Fatal(err) + } + for _, name := range temps { + if err := os.Remove(name); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestRangeLargeGroupCreateErrorIsLocal(t *testing.T) { + r := writeRoot(t, 36, nil) + u := New(r, nil, Options{}) + if err := os.MkdirAll(u.l.dir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(u.l.pkg(), []byte("blocks creation"), 0o644); err != nil { + t.Fatal(err) + } + err := u.stageRangeGroup(context.Background(), &release{}, rangeGroup{length: rangeMaxGroup + 1}, &remoteZip{}) + var ru *rangeUnavailable + var pathErr *os.PathError + if err == nil || errors.As(err, &ru) || !errors.As(err, &pathErr) { + t.Fatalf("create error: %v", err) + } +} diff --git a/internal/update/range_hardening_test.go b/internal/update/range_hardening_test.go new file mode 100644 index 0000000..30be873 --- /dev/null +++ b/internal/update/range_hardening_test.go @@ -0,0 +1,243 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "fmt" + "hash/crc32" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + + "modavd/internal/errs" +) + +func TestRangeManifestAlwaysRemote(t *testing.T) { + c := newRangeCase(t) + manifest := manifestFor(37, c.newFiles) + forged := forgeCRC(bytes.Repeat([]byte("x"), len(manifest)), crc32.ChecksumIEEE(manifest)) + if bytes.Equal(forged, manifest) || crc32.ChecksumIEEE(forged) != crc32.ChecksumIEEE(manifest) { + t.Fatal("invalid collision fixture") + } + r := writeRoot(t, 36, c.big) + f := newSources(t, 37, c.zip) + u := f.updater(r) + if err := os.WriteFile(u.l.at(manifestName), forged, 0o644); err != nil { + t.Fatal(err) + } + rel, err := u.githubLatest(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, _, err = u.stageByRange(context.Background(), rel); err != nil { + t.Fatalf("remote manifest should allow staging despite the local CRC collision: %v", err) + } + got, err := os.ReadFile(u.l.pkgAt(manifestName)) + if err != nil || !bytes.Equal(got, manifest) { + t.Fatalf("manifest was not fetched from the package: %v", err) + } +} + +func TestRangeThresholdDoesNotAddNotice(t *testing.T) { + withSettings(t, SourceGitHub, "") + c := newRangeCase(t) + r := writeRoot(t, 36, nil) + f := newSources(t, 37, c.zip) + log := &dlLog{} + f.dl = log.serve(c.zip, nil) + u := f.updater(r) + st, err := u.Prepare(context.Background()) + if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" || len(st.Notices) != 0 || log.fullGets() != 1 { + t.Fatalf("normal full-package fallback: status %+v, error %v, full GETs %d", st, err, log.fullGets()) + } + st, err = u.Install(context.Background()) + if err != nil || !st.Updated || strings.Contains(st.Message, "改下整包") { + t.Fatalf("normal fallback in final message: %+v %v", st, err) + } +} + +func TestRangeLocalExtractionErrorDoesNotDownloadWhole(t *testing.T) { + for _, operation := range []string{"mkdir", "open", "copy"} { + t.Run(operation, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + c := newRangeCase(t) + r := writeRoot(t, 36, c.big) + f := newSources(t, 37, c.zip) + log := &dlLog{} + f.dl = log.serve(c.zip, nil) + u := f.updater(r) + injected := false + u.Opt.Progress = func(p Progress) { + if injected || p.Stage != "downloading" || p.Total == 0 { + return + } + injected = true + dst := u.l.pkgAt("sdk") + if operation == "mkdir" { + if err := os.WriteFile(dst, []byte("blocks the destination directory"), 0o644); err != nil { + t.Fatal(err) + } + } else { + name := "sdk/emulator/emulator.exe" + if operation == "copy" { + name = "sdk/emulator/lib/big0.dll" + } + if err := os.MkdirAll(u.l.pkgAt(name), 0o755); err != nil { + t.Fatal(err) + } + } + } + st, err := u.Prepare(context.Background()) + var unavailable *rangeUnavailable + if !injected || err == nil || errors.As(err, &unavailable) || log.fullGets() != 0 || len(st.Notices) != 0 || st.Phase == phaseStaged { + t.Fatalf("local %s error triggered a fallback: injected %v, status %+v, error %v, full GETs %d", operation, injected, st, err, log.fullGets()) + } + }) + } +} + +func TestRangeLargeEntryLifecycle(t *testing.T) { + const largePath = "sdk/emulator/lib/new-large.dll" + big := bigFiles(1, 4, 6<<20) + large := bigFiles(2, 1, 9<<20)["sdk/emulator/lib/big0.dll"] + files := pkgFiles(37, merged(big, map[string]string{largePath: large})) + archive := zipOf(t, 37, files, false) + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + t.Fatal(err) + } + var dataOffset int64 + for _, f := range zr.File { + if f.Name == largePath { + dataOffset, err = f.DataOffset() + } + } + if err != nil || dataOffset == 0 { + t.Fatal("large entry not found", err) + } + for _, mode := range []string{"success", "retry", "cancel", "corrupt", "local-create", "local-open"} { + t.Run(mode, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, big) + f := newSources(t, 37, archive) + u := f.updater(r) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + log := &dlLog{} + var requests, tempSeen atomic.Int32 + rangeBody := archive + if mode == "corrupt" { + rangeBody = append([]byte(nil), archive...) + rangeBody[dataOffset+12345] ^= 0x55 + } + serve := log.serve(archive, rangeBody) + f.dl = func(w http.ResponseWriter, req *http.Request) { + var start, end int64 + _, parseErr := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end) + if parseErr != nil || end-start+1 <= rangeMaxGroup { + serve(w, req) + return + } + attempt := requests.Add(1) + temps, err := filepath.Glob(filepath.Join(u.l.pkg(), ".range-*")) + if err != nil || len(temps) != 1 { + t.Errorf("large transfer has no private file: %v %v", temps, err) + } else { + tempSeen.Add(1) + } + if (mode == "retry" && attempt == 1) || mode == "cancel" { + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(archive))) + w.Header().Set("Content-Length", fmt.Sprint(end-start+1)) + w.WriteHeader(http.StatusPartialContent) + w.Write(bytes.Repeat([]byte{0x55}, 64<<10)) + w.(http.Flusher).Flush() + if mode == "cancel" { + cancel() + <-req.Context().Done() + } + return + } + if mode == "local-open" { + // Deny the next extracted output's open, after the large range was received. + if err := os.MkdirAll(u.l.pkgAt(largePath), 0o755); err != nil { + t.Error(err) + } + } + serve(w, req) + } + if mode == "local-create" { + u.Opt.Progress = func(p Progress) { + if p.Stage != "downloading" || p.Total == 0 || p.Done != 0 { + return + } + if err := os.Remove(u.l.pkg()); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(u.l.pkg(), []byte("cannot create a range file here"), 0o644); err != nil { + t.Fatal(err) + } + } + } + st, err := u.Prepare(ctx) + switch mode { + case "success", "retry", "corrupt": + wantMode, wantNotices, wantFull := "range", 0, 0 + if mode == "corrupt" { + wantMode, wantNotices, wantFull = "full", 1, 1 + } + if err != nil || st.Phase != phaseStaged || stagedMode(u) != wantMode || len(st.Notices) != wantNotices || log.fullGets() != wantFull { + t.Fatalf("prepare: %+v, error %v, full GETs %d", st, err, log.fullGets()) + } + // buildStage has moved sdk/emulator into stage/emulator. + got, err := os.ReadFile(filepath.Join(u.l.stageEmulator(), "lib", "new-large.dll")) + if err != nil || !bytes.Equal(got, []byte(large)) { + t.Fatalf("large staged output differs: %v", err) + } + if mode == "retry" && requests.Load() != 2 { + t.Fatalf("want two large attempts, got %d", requests.Load()) + } + case "cancel": + if errs.CodeOf(err) != errs.Canceled || log.fullGets() != 0 || requests.Load() != 1 { + t.Fatalf("cancel: %v, large attempts %d, full GETs %d", err, requests.Load(), log.fullGets()) + } + default: + if err == nil || log.fullGets() != 0 || st.Phase == phaseStaged { + t.Fatalf("local failure: %+v %v, full GETs %d", st, err, log.fullGets()) + } + } + if mode != "local-create" && tempSeen.Load() == 0 { + t.Fatal("large entry never used a private temporary file") + } + // Also catches leaked Windows handles: the existing abort and temp cleanup must remove the file. + temps, err := filepath.Glob(filepath.Join(u.l.pkg(), ".range-*")) + if err != nil || len(temps) != 0 { + t.Fatalf("range file leaked: %v %v", temps, err) + } + }) + } +} + +func TestRangeRetryDoesNotRetryLocalFailures(t *testing.T) { + r := writeRoot(t, 36, nil) + u := New(r, nil, Options{}) + for _, failure := range []error{io.ErrShortWrite, &os.PathError{Op: "write", Path: "range file", Err: syscall.Errno(112)}} { + attempts := 0 + err := u.rangeRetry(context.Background(), 0, 1, "test", func() error { attempts++; return failure }) + var ru *rangeUnavailable + if !errors.Is(err, failure) || attempts != 1 || errors.As(err, &ru) { + t.Fatalf("attempts %d, error %v", attempts, err) + } + } +} diff --git a/internal/update/range_local_errors_windows_test.go b/internal/update/range_local_errors_windows_test.go new file mode 100644 index 0000000..3d6d9d6 --- /dev/null +++ b/internal/update/range_local_errors_windows_test.go @@ -0,0 +1,177 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "io" + "modavd/internal/errs" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" +) + +func TestRangeVerificationReadErrorDoesNotDownloadWhole(t *testing.T) { + for _, target := range []string{manifestName, "sdk/emulator/emulator.exe"} { + t.Run(target, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + c := newRangeCase(t) + r := writeRoot(t, 36, c.big) + f := newSources(t, 37, c.zip) + log := &dlLog{} + serve := log.serve(c.zip, nil) + u := f.updater(r) + var mu sync.Mutex + var held syscall.Handle + unlock := func() { + mu.Lock() + defer mu.Unlock() + if held != 0 { + syscall.CloseHandle(held) + held = 0 + } + } + defer unlock() + f.dl = func(w http.ResponseWriter, req *http.Request) { + if req.Header.Get("Range") == "" { + unlock() + } + serve(w, req) + } + injected := false + u.Opt.Progress = func(p Progress) { + if injected || p.Stage != "verifying" { + return + } + injected = true + if _, err := os.Stat(u.l.pkgAt(target)); err != nil { + t.Fatal(err) + } + name, err := syscall.UTF16PtrFromString(u.l.pkgAt(target)) + if err != nil { + t.Fatal(err) + } + mu.Lock() + held, err = syscall.CreateFile(name, syscall.GENERIC_READ, + syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, + syscall.FILE_ATTRIBUTE_NORMAL, 0) + mu.Unlock() + if err != nil { + t.Fatal(err) + } + } + st, err := u.Prepare(context.Background()) + t.Logf("injected=%v error=%v code=%s mode=%s fullGets=%d notices=%v", injected, err, errs.CodeOf(err), stagedMode(u), log.fullGets(), st.Notices) + var pathErr *os.PathError + if !injected || !errors.As(err, &pathErr) || errs.CodeOf(err) == errs.UpdateRejected || log.fullGets() != 0 || len(st.Notices) != 0 { + t.Errorf("local verification read failure did not stop before whole download") + } + }) + } +} + +// entryFaultOutput exercises destination errors without changing global file operations. +type entryFaultOutput struct { + data bytes.Buffer + writeErr, closeErr error + closed int +} + +func (o *entryFaultOutput) Write(p []byte) (int, error) { + if o.writeErr != nil { + return 0, o.writeErr + } + return o.data.Write(p) +} +func (o *entryFaultOutput) Close() error { o.closed++; return o.closeErr } + +type entryReadError struct{ err error } + +func (r entryReadError) Read([]byte) (int, error) { return 0, r.err } + +func TestEntryCloseErrorTakesPrecedenceOverBadZIP(t *testing.T) { + disk := &os.PathError{Op: "close", Path: "output", Err: syscall.Errno(112)} + for _, tc := range []struct { + name string + readErr, writeErr, closeErr, want error + }{ + {"bad ZIP and close", zip.ErrChecksum, nil, disk, disk}, + {"bad ZIP alone", zip.ErrChecksum, nil, nil, zip.ErrChecksum}, + {"write error", nil, disk, nil, disk}, + {"close error", nil, nil, disk, disk}, + {"success", nil, nil, nil, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + out := &entryFaultOutput{writeErr: tc.writeErr, closeErr: tc.closeErr} + source := io.Reader(strings.NewReader("payload")) + if tc.readErr != nil { + source = io.MultiReader(source, entryReadError{tc.readErr}) + } + err := copyEntryContents(out, source) + if !errors.Is(err, tc.want) || out.closed != 1 { + t.Fatalf("error %v, want %v, closes %d", err, tc.want, out.closed) + } + }) + } +} + +func TestEmulatorVerificationReadErrorsStayLocal(t *testing.T) { + for _, target := range []string{"source.properties", "qemu/windows-x86_64/qemu-system-x86_64-headless.exe"} { + t.Run(target, func(t *testing.T) { + r := writeRoot(t, 37, nil) + path := filepath.Join(r.Dir, "sdk", "emulator", filepath.FromSlash(target)) + name, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + held, err := syscall.CreateFile(name, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(held) + version, _ := ParseVersion("v0.37.0") + err = checkEmulator(filepath.Join(r.Dir, "sdk", "emulator"), 37, version) + var pathErr *os.PathError + if !errors.As(err, &pathErr) || errs.CodeOf(err) == errs.UpdateRejected { + t.Fatalf("local emulator read: %v", err) + } + }) + } +} + +func TestStageBuildManifestReadErrorIsLocal(t *testing.T) { + r := writeRoot(t, 36, nil) + u := New(r, nil, Options{}) + archive := filepath.Join(r.Dir, "package.zip") + if err := os.WriteFile(archive, zipOf(t, 37, pkgFiles(37, nil), false), 0o644); err != nil { + t.Fatal(err) + } + if _, err := u.l.extractPackage(archive, "", nil); err != nil { + t.Fatal(err) + } + path, err := syscall.UTF16PtrFromString(u.l.pkgAt(manifestName)) + if err != nil { + t.Fatal(err) + } + held, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(held) + version, _ := ParseVersion("v0.37.0") + err = u.l.buildStage(version) + var pathErr *os.PathError + if !errors.As(err, &pathErr) || lexists(u.l.stageEmulator()) { + t.Fatalf("unreadable manifest was ignored when building stage: %v", err) + } +} diff --git a/internal/update/range_test.go b/internal/update/range_test.go index d36802e..306a2c6 100644 --- a/internal/update/range_test.go +++ b/internal/update/range_test.go @@ -290,8 +290,15 @@ func TestRangeFallsBack(t *testing.T) { u := f.updater(r) ctx := context.Background() st, err := u.Prepare(ctx) - if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" || len(st.Notices) != 1 || - !strings.Contains(st.Notices[0], tc.notice) || !strings.Contains(st.Notices[0], "改下整包") { + if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" { + t.Fatalf("prepare: %+v %v (mode %q)", st, err, stagedMode(u)) + } + wantNotices := 1 + if tc.name == "too much changed" { + wantNotices = 0 + } + if len(st.Notices) != wantNotices || (wantNotices > 0 && + (!strings.Contains(st.Notices[0], tc.notice) || !strings.Contains(st.Notices[0], "改下整包"))) { t.Fatalf("prepare: %+v %v (mode %q)", st, err, stagedMode(u)) } if log.fullGets() != 1 { diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 216945f..9c6fb98 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -443,8 +443,8 @@ func TestMirrorChyan(t *testing.T) { t.Fatalf("check query %v", seen[0]) } st, err := u.Install(context.Background()) - // every file changed between the two builds: GitHub's range path falls back to the whole package (second notice) - if err != nil || !st.Updated || len(st.Notices) != 2 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") || !strings.Contains(st.Notices[1], "变了的文件太多") { + // The normal GitHub range threshold adds no notice; the bad CDK still does. + if err != nil || !st.Updated || len(st.Notices) != 1 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") { t.Fatalf("install with a bad CDK: %+v %v", st, err) } if last := seen[len(seen)-1]; last["cdk"] != "cdk-secret-1" || last["current_version"] != "v0.36.0" { diff --git a/internal/update/updater.go b/internal/update/updater.go index f5ab045..f1f9849 100644 --- a/internal/update/updater.go +++ b/internal/update/updater.go @@ -516,7 +516,9 @@ func (u *Updater) stage(ctx context.Context, r *release, st *Status) (err error) case errors.As(err, &ru): note := rangeNotice(err) u.Log.Infof("%s", note) - st.Notices = append(st.Notices, note) + if !ru.normal { + st.Notices = append(st.Notices, note) + } default: return err } From 70ae4a4b214c12225353e74cf3bde3d02a3e5613 Mon Sep 17 00:00:00 2001 From: qiyinxi <52456734+qiyinxi@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:26:29 +0000 Subject: [PATCH 10/17] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9ATestPollTakesG?= =?UTF-8?q?uestArchFromListedConfig=20=E6=AF=94=E8=BE=83=E5=89=8D=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E5=89=A9=E4=BD=99=E7=A3=81=E7=9B=98=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=81=B6=E5=8F=91=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/emu/status_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/emu/status_test.go b/internal/emu/status_test.go index f698358..3b9b17b 100644 --- a/internal/emu/status_test.go +++ b/internal/emu/status_test.go @@ -85,8 +85,12 @@ func TestPollTakesGuestArchFromListedConfig(t *testing.T) { if err != nil || len(list.Instances) != 1 { t.Fatalf("%s: list: %+v %v", label, list, err) } - a, _ := json.Marshal(info) - b, _ := json.Marshal(list.Instances[0]) + // host_free_bytes is the host disk's free space at that moment: anything writing to the disk between two + // reads changes it (CI failed on it), and it has nothing to do with what this test compares + in, li := *info, list.Instances[0] + in.DataSpace.HostFreeBytes, li.DataSpace.HostFreeBytes = 0, 0 + a, _ := json.Marshal(in) + b, _ := json.Marshal(li) if string(a) != string(b) { t.Fatalf("%s: info and list differ:\n%s\n%s", label, a, b) } From eb1bc49bdc7b764898fb2a4d6e48ba27ccfee319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A0=E6=AF=9B=E8=8F=9C?= <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:08:31 +0000 Subject: [PATCH 11/17] =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=9A=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20junction=20=E5=85=A5=E5=8F=A3=E4=B8=8B=E7=9A=84?= =?UTF-8?q?=E8=87=AA=E6=9B=B4=E6=96=B0=E5=92=8C=E6=81=A2=E5=A4=8D=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/update/images_windows.go | 2 +- internal/update/path_identity_windows_test.go | 323 ++++++++++++++++++ internal/update/state_other.go | 7 +- internal/update/state_windows.go | 31 +- internal/update/swap.go | 2 +- 5 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 internal/update/path_identity_windows_test.go diff --git a/internal/update/images_windows.go b/internal/update/images_windows.go index 78bb9e4..77e940f 100644 --- a/internal/update/images_windows.go +++ b/internal/update/images_windows.go @@ -68,7 +68,7 @@ func (u *Updater) lockSDKImages() (release func(), err error) { } else if err != nil { return nil, swapBlocked("无法检查模拟器目录 %s(%v),什么都没动", dir, err) } - resolved, err := filepath.EvalSymlinks(dir) + resolved, err := resolvePath(dir) if err != nil { return nil, swapBlocked("无法确认模拟器目录 %s(%v),什么都没动", dir, err) } diff --git a/internal/update/path_identity_windows_test.go b/internal/update/path_identity_windows_test.go new file mode 100644 index 0000000..f82a822 --- /dev/null +++ b/internal/update/path_identity_windows_test.go @@ -0,0 +1,323 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "modavd/internal/jsonord" + "modavd/internal/root" + "modavd/internal/testutil" + "modavd/internal/win" +) + +func updatePathAlias(t *testing.T, target, kind string) string { + t.Helper() + if kind == "short name" { + ptr, err := syscall.UTF16PtrFromString(target) + if err != nil { + t.Fatal(err) + } + var buf [32768]uint16 + n, err := syscall.GetShortPathName(ptr, &buf[0], uint32(len(buf))) + if err != nil || n == 0 || n >= uint32(len(buf)) { + t.Fatalf("GetShortPathName: %d, %v", n, err) + } + alias := syscall.UTF16ToString(buf[:n]) + if strings.EqualFold(alias, target) { + t.Skip("test volume does not create 8.3 names") + } + return alias + } + alias := filepath.Join(testutil.TempDir(t), "alias") + if kind == "symlink" { + if err := os.Symlink(target, alias); err != nil { + t.Skipf("directory symlink unavailable: %v", err) + } + } else { + cmd := exec.Command("cmd.exe", "/d", "/c", "mklink", "/J", alias, target) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("mklink /J: %v\n%s", err, out) + } + } + st, err := os.Stat(alias) + if err != nil || !st.IsDir() { + t.Fatalf("invalid directory alias: %v", err) + } + return alias +} + +func TestSelfUpdatePathAliases(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"junction", "symlink", "short name"} { + for _, reverse := range []bool{false, true} { + for _, action := range []string{"check", "apply", "restore", "recover finish", "recover rollback"} { + name := kind + "/alias root/" + action + if reverse { + name = kind + "/alias image/" + action + } + t.Run(name, func(t *testing.T) { + real := filepath.Join(testutil.TempDir(t), "root with long name") + if err := copyFile(self, filepath.Join(real, "tools", "modavd.exe")); err != nil { + t.Fatal(err) + } + alias := updatePathAlias(t, real, kind) + rootPath, imageRoot := alias, real + if reverse { + rootPath, imageRoot = real, alias + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, filepath.Join(imageRoot, "tools", "modavd.exe"), "-test.run=^TestSelfUpdatePathAliasChild$", "-test.v") + cmd.Env = append(os.Environ(), "MODAVD_PATH_ALIAS_ROOT="+rootPath, "MODAVD_PATH_ALIAS_ACTION="+action) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("self update: %v\n%s", err, out) + } + }) + } + } + } +} + +func TestSelfUpdatePathAliasChild(t *testing.T) { + dir := os.Getenv("MODAVD_PATH_ALIAS_ROOT") + if dir == "" { + t.Skip("self-update fixture only") + } + l := layout{root: dir} + const rel = "tools/modavd.exe" + plan := rootPlan{replace: []string{rel}} + action := os.Getenv("MODAVD_PATH_ALIAS_ACTION") + if action == "check" { + if busy := l.rootFileInUse(plan); busy != "" { + t.Fatalf("self treated as occupant: %s", busy) + } + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, l.at("ModAVD.exe")); err != nil { + t.Fatal(err) + } + occupancyChild(t, l.at("ModAVD.exe")) + plan.replace = append(plan.replace, "ModAVD.exe") + if busy := l.rootFileInUse(plan); busy != "ModAVD.exe" { + t.Fatalf("different running image was exempted: %q", busy) + } + return + } + source := l.pkgAt(rel) + if action == "restore" || action == "recover rollback" { + source = l.prevAt(rel) + } + write := func(path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + write(source, action) + var err error + switch action { + case "apply": + err = l.applyRootFiles(plan) + case "restore": + err = l.restoreRootFiles(plan) + case "recover finish", "recover rollback": + write(l.at("mas-avd.json"), "{}") + if err := os.MkdirAll(l.at("sdk"), 0o755); err != nil { + t.Fatal(err) + } + st := newState(phaseSwapping, "plan", plan.value(), "swap", stepVal(stepOldMoved)) + target := jsonord.NewObject() + target.Obj().Set("version", jsonord.NewString("v0.37.0")) + st.Set("target", target) + if action == "recover finish" { + write(filepath.Join(l.stageEmulator(), "marker"), "new SDK") + staged := jsonord.NewObject() + staged.Obj().Set("verified", jsonord.NewBool(true)) + st.Set("staged", staged) + } else { + write(filepath.Join(l.previousEmulator(), "marker"), "old SDK") + } + if err := l.writeState(st); err != nil { + t.Fatal(err) + } + r, err := root.New(dir) + if err != nil { + t.Fatal(err) + } + u := New(r, nil, Options{}) + u.scan = ownScan(u) + if _, err := u.recover(); err != nil { + t.Fatal(err) + } + if phaseOf(l.readState()) != phaseIdle || !isDir(l.emulator()) { + t.Fatal("recovery did not commit its result") + } + wantSDK := "old SDK" + if action == "recover finish" { + wantSDK = "new SDK" + } + if got, err := os.ReadFile(filepath.Join(l.emulator(), "marker")); err != nil || string(got) != wantSDK { + t.Fatalf("wrong recovered SDK: %q, %v", got, err) + } + ok, _ := l.readState().Get("lastResult").Obj().Get("ok").BoolVal() + if ok != (action == "recover finish") { + t.Fatalf("wrong recovery result: ok=%v", ok) + } + default: + t.Fatalf("unknown action: %s", action) + } + if err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(l.at(rel)); err != nil || string(got) != action { + t.Fatalf("wrong replacement: %q, %v", got, err) + } +} + +func TestPathAliasProcessOwnership(t *testing.T) { + for _, kind := range []string{"junction", "symlink", "short name"} { + for _, reverse := range []bool{false, true} { + for _, rel := range []string{"tools/modavd.exe", "ModAVD.exe", "sdk/emulator/emulator.exe", ".update/previous/emulator/emulator.exe"} { + name := kind + "/alias root/" + rel + if reverse { + name = kind + "/alias image/" + rel + } + t.Run(name, func(t *testing.T) { + r := writeRoot(t, 36, nil) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, filepath.Join(r.Dir, filepath.FromSlash(rel))); err != nil { + t.Fatal(err) + } + alias := updatePathAlias(t, r.Dir, kind) + rootPath, imageRoot := alias, r.Dir + if reverse { + rootPath, imageRoot = r.Dir, alias + } + u := New(&root.Root{Dir: rootPath}, nil, Options{}) + if err := u.l.writeState(newState(phaseSwapping)); err != nil { + t.Fatal(err) + } + child := occupancyChild(t, filepath.Join(imageRoot, filepath.FromSlash(rel))) + program := rel == "tools/modavd.exe" || rel == "ModAVD.exe" + for _, programs := range []bool{false, true} { + uses, err := u.scanProcesses(programs, ownProcesses, win.Process.ImagePath) + if err != nil { + t.Fatal(err) + } + want := !program || programs + if !want { + if len(uses) != 0 { + t.Fatalf("program scan disabled: %+v", uses) + } + continue + } + if len(uses) != 1 || uses[0].PID != uint32(child.Process.Pid) || uses[0].RootProgram != program || uses[0].ShutdownHelper { + t.Fatalf("wrong process ownership: %+v", uses) + } + } + if program { + image := filepath.Join(imageRoot, filepath.FromSlash(rel)) + if !u.shutdownHelper(image, []string{image, "__bootwatch", imageRoot, "0", "123", "456"}) { + t.Error("same root's bootwatch was not recognized") + } + if u.shutdownHelper(image, []string{image, "__bootwatch", t.TempDir(), "0", "123", "456"}) { + t.Error("another root's bootwatch was recognized") + } + } + }) + } + } + } +} + +func TestPathAliasSDKInstall(t *testing.T) { + u, _ := imageUpdate(t) + alias := updatePathAlias(t, u.Root.Dir, "junction") + u.Root = &root.Root{Dir: alias} + u.l.root = alias + u.scan = ownScan(u) + if st, err := u.Install(context.Background()); err != nil || !st.Updated || st.Phase != phaseIdle { + t.Fatalf("install through junction: %+v, %v", st, err) + } +} + +func TestPathAliasReadOnlyLoadedImage(t *testing.T) { + dir := testutil.TempDir(t) + alias := updatePathAlias(t, dir, "junction") + path := filepath.Join(dir, "loaded.dll") + free := loadDLL(t, filepath.Join(alias, "loaded.dll")) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + for _, probe := range []string{path, filepath.Join(alias, "loaded.dll")} { + if !loadedImageInUse(probe) { + t.Error("loaded image query missed through junction") + } + if !fileInUse(probe) { + t.Error("read-only loaded image missed through junction") + } + } + free() + if fileInUse(path) { + t.Error("unloaded read-only image is still busy") + } +} + +func TestPathAliasDifferentRoot(t *testing.T) { + r := writeRoot(t, 36, nil) + alias := updatePathAlias(t, r.Dir, "junction") + u := New(&root.Root{Dir: alias}, nil, Options{}) + other := filepath.Join(testutil.TempDir(t), "tools", "modavd.exe") + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, other); err != nil { + t.Fatal(err) + } + occupancyChild(t, other) + if uses, err := u.scanProcesses(true, ownProcesses, win.Process.ImagePath); err != nil || len(uses) != 0 { + t.Fatalf("another root's program matched: %+v, %v", uses, err) + } + if u.shutdownHelper(other, []string{other, "__bootwatch", alias, "0", "123", "456"}) { + t.Fatal("another root's program was treated as a shutdown helper") + } +} + +func TestUpdatePathResolutionFailure(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + if _, err := resolvePath(missing); err == nil { + t.Fatal("missing path resolved successfully") + } + if got := realOrSame(missing); got != missing { + t.Fatalf("missing path fallback changed: %q", got) + } + if _, err := resolvePath("invalid\x00path"); err == nil { + t.Fatal("invalid path resolved successfully") + } +} diff --git a/internal/update/state_other.go b/internal/update/state_other.go index 90f6af4..4f2d117 100644 --- a/internal/update/state_other.go +++ b/internal/update/state_other.go @@ -2,10 +2,15 @@ package update -import "os" +import ( + "os" + "path/filepath" +) func isReparsePoint(st os.FileInfo) bool { return st.Mode()&os.ModeSymlink != 0 } func removeLink(p string) bool { return os.Remove(p) == nil } // fileInUse: a loaded library or an open file never stops a rename or an unlink here. func fileInUse(string) bool { return false } + +func resolvePath(path string) (string, error) { return filepath.EvalSymlinks(path) } diff --git a/internal/update/state_windows.go b/internal/update/state_windows.go index 1c01b11..330e693 100644 --- a/internal/update/state_windows.go +++ b/internal/update/state_windows.go @@ -77,25 +77,38 @@ func loadedImageInUse(path string) bool { return false } +// resolvePath returns an extended DOS path suitable for both comparisons and I/O. +// Handle queries resolve junctions and short names without EvalSymlinks. +func resolvePath(path string) (string, error) { + return finalPath(path, 0) +} + func finalImagePath(path string) string { + const volumeNameNT = 2 + name, _ := finalPath(path, volumeNameNT) + return name +} + +func finalPath(path string, flags uint32) (string, error) { ptr, err := syscall.UTF16PtrFromString(path) if err != nil { - return "" + return "", err } h, err := syscall.CreateFile(ptr, 0, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, - nil, syscall.OPEN_EXISTING, 0, 0) + nil, syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) if err != nil { - return "" + return "", err } var name [32768]uint16 - const volumeNameNT = 2 - n, _, _ := getFinalPathNameW.Call(uintptr(h), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name)), volumeNameNT) + n, _, err := getFinalPathNameW.Call(uintptr(h), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name)), uintptr(flags)) syscall.CloseHandle(h) - if n == 0 || n >= uintptr(len(name)) { - return "" + if n == 0 { + return "", err + } + if n >= uintptr(len(name)) { + return "", syscall.ERROR_INSUFFICIENT_BUFFER } - // Resolve directory links and 8.3 names into the same NT device path. - return syscall.UTF16ToString(name[:n]) + return syscall.UTF16ToString(name[:n]), nil } func processHasImage(pid uint32, target string) bool { diff --git a/internal/update/swap.go b/internal/update/swap.go index 9cc813b..6b2db1f 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -709,7 +709,7 @@ func l2abs(p string) string { } func realOrSame(p string) string { - if r, err := filepath.EvalSymlinks(p); err == nil { + if r, err := resolvePath(p); err == nil { return r } return l2abs(p) From 91847dd8d7a0e3e6348df7618510e3b0b7857ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A0=E6=AF=9B=E8=8F=9C?= <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:13:54 +0000 Subject: [PATCH 12/17] =?UTF-8?q?=E6=AF=8F=E5=88=86=E9=92=9F=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E6=A8=A1=E6=8B=9F=E5=99=A8=E5=86=85=E5=AD=98=EF=BC=8C?= =?UTF-8?q?=E5=8F=98=E5=8C=96=E5=A4=A7=E6=97=B6=E6=8F=90=E5=89=8D=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=B9=B6=E5=9C=A8=E9=80=80=E5=87=BA=E6=97=B6=E6=B1=87?= =?UTF-8?q?=E6=80=BB=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs ModAVD/ModAVD#159 --- internal/emu/bootwatch.go | 114 ++++++++-- internal/emu/bootwatch_memory_policy_test.go | 223 +++++++++++++++++++ internal/emu/bootwatch_memory_test.go | 61 ++++- internal/host/memory.go | 2 +- internal/report/collect.go | 2 +- internal/win/mem.go | 10 +- 6 files changed, 381 insertions(+), 31 deletions(-) create mode 100644 internal/emu/bootwatch_memory_policy_test.go diff --git a/internal/emu/bootwatch.go b/internal/emu/bootwatch.go index b7dd6bc..5cea526 100644 --- a/internal/emu/bootwatch.go +++ b/internal/emu/bootwatch.go @@ -20,7 +20,7 @@ package emu // JSON object per line, as `--json` on stderr). // // Being the one process that lives as long as the emulator, the watch also writes the emulator's memory to that log -// every bootWatchMemoryInterval (logMemory). +// at start, on a large change and at least every ten minutes (watchMemory). import ( "context" @@ -31,6 +31,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "strconv" "strings" "time" @@ -265,11 +266,25 @@ func RunBootWatch(args []string, w io.Writer) int { } cancel() }() - watch.logMemory(time.Now()) + memoryDone := make(chan struct{}) + go func() { + defer close(memoryDone) + if runtime.GOOS != "windows" { + return // these counters are not implemented on other hosts + } + if ctx.Err() == nil { + watch.logMemory(time.Now()) + } + // Start the timer after the first read, so its tenth tick is not just before that sample's ten-minute mark. + ticker := time.NewTicker(bootWatchMemorySampleInterval) + defer ticker.Stop() + watch.watchMemory(ctx, ticker.C) + }() for !q.Wait(bootWatchInterval) { watch.tick(ctx, time.Now()) - watch.logMemory(time.Now()) } + cancel() + <-memoryDone // write the summary before releasing the watch lock or ending the log m.Wait(10 * time.Second) log.Infof("实例 %d 的模拟器(qemu pid %d)已退出,开机守护结束", i, q.PID) return 0 @@ -283,12 +298,27 @@ type bootWatch struct { // missingSince: since when the private server has not known the instance (zero: it did at the last look), or // since the watch last registered it again (bootWatchReregisterAfter). missingSince time.Time - // memoryAt: when logMemory wrote its last line (zero: not yet). - memoryAt time.Time + // Owned only by watchMemory, independently of guest reboot setup in tick. + memory bootWatchMemory +} + +const ( + bootWatchMemorySampleInterval = time.Minute + bootWatchMemoryInterval = 10 * time.Minute + bootWatchMemoryChange = 256 << 20 +) + +type bootWatchMemorySample struct { + at time.Time + pm host.ProcessMemory } -// bootWatchMemoryInterval: how often the watch writes the emulator's memory to its log. -const bootWatchMemoryInterval = 10 * time.Minute +type bootWatchMemory struct { + last, logged *bootWatchMemorySample + samples, failures uint64 + maxCommit, maxWorkingSet uint64 + readErr error +} // The memory counters behind logMemory; tests replace them. var ( @@ -296,29 +326,81 @@ var ( systemMemory = host.GetSystemMemory ) -// logMemory writes one line with the memory of qemu and of the computer, at the watch's start and then every -// bootWatchMemoryInterval: a report that the computer got slow after hours of running can then be told apart into -// the emulator's commit growing (a leak on the host side) and the computer simply being short of memory. Nothing -// where the counters cannot be read. -func (w *bootWatch) logMemory(now time.Time) { - if !w.memoryAt.IsZero() && now.Sub(w.memoryAt) < bootWatchMemoryInterval { - return +// watchMemory continues sampling after the initial read. Guest reboot setup may block tick for minutes, so it must +// not own this timer. Cancellation means qemu has exited; do not reopen a PID to try to recover final counters then. +func (w *bootWatch) watchMemory(ctx context.Context, ticks <-chan time.Time) { + for { + select { + case <-ctx.Done(): + w.logMemorySummary() + return + case <-ticks: + if ctx.Err() == nil { + w.logMemory(time.Now()) // the read time, not a queued tick's old timestamp after suspend or slow I/O + } + } } +} + +// logMemory samples once. Only a first/recovered sample, an absolute change >256 MiB from the last written commit, +// or ten minutes since that line writes process and system counters. Failed reads do not move either baseline. +func (w *bootWatch) logMemory(now time.Time) { + m := &w.memory pm, err := processMemory(w.q) if err != nil { + m.failures++ + if m.readErr == nil { + w.m.Log.Warnf("实例 %d 模拟器内存采样失败(qemu pid %d):%v;一分钟后重试", w.i, w.q.PID, err) + } + m.readErr = err + return + } + recovered := m.readErr != nil + m.readErr = nil + m.last = &bootWatchMemorySample{at: now, pm: pm} + m.samples++ + m.maxCommit = max(m.maxCommit, pm.Commit) + m.maxWorkingSet = max(m.maxWorkingSet, pm.WorkingSet) + if m.logged != nil && !recovered && now.Sub(m.logged.at) < bootWatchMemoryInterval && + max(pm.Commit, m.logged.pm.Commit)-min(pm.Commit, m.logged.pm.Commit) <= bootWatchMemoryChange { return } - w.memoryAt = now + m.logged = m.last const mb = 1 << 20 - msg := fmt.Sprintf("实例 %d 模拟器内存(qemu pid %d):提交 %d MB(最高 %d MB),工作集 %d MB", w.i, w.q.PID, + msg := fmt.Sprintf("实例 %d 模拟器内存(qemu pid %d):提交 %d MB(OS 提交峰值 %d MB),工作集 %d MB", w.i, w.q.PID, pm.Commit/mb, pm.PeakCommit/mb, pm.WorkingSet/mb) if sm, err := systemMemory(); err == nil { msg += fmt.Sprintf(";电脑:物理内存可用 %d / %d MB,提交 %d / %d MB", sm.AvailPhys/mb, sm.TotalPhys/mb, sm.Committed/mb, sm.CommitLimit/mb) } + if recovered { + msg += ";内存采样已恢复" + } w.m.Log.Infof("%s", msg) } +// logMemorySummary reports observations, not an exact lifetime peak. GetProcessMemoryInfo is not guaranteed to +// yield counters after exit, even with a retained process handle. The OS peak below is exact only up to last.at; +// sampled maxima can miss changes between samples. Neither includes an unobserved interval before exit. +func (w *bootWatch) logMemorySummary() { + m := &w.memory + msg := fmt.Sprintf("实例 %d 模拟器内存退出汇总(qemu pid %d):成功采样 %d 次,读取失败 %d 次", w.i, w.q.PID, + m.samples, m.failures) + if m.last == nil { + msg += ";没有成功样本" + } else { + const mb = 1 << 20 + pm := m.last.pm + msg += fmt.Sprintf(";最后成功采样 %s:提交 %d MB,OS 提交峰值 %d MB,工作集 %d MB;采样最大提交 %d MB,工作集 %d MB", + m.last.at.Format(time.RFC3339), pm.Commit/mb, pm.PeakCommit/mb, pm.WorkingSet/mb, + m.maxCommit/mb, m.maxWorkingSet/mb) + } + if m.readErr != nil { + msg += fmt.Sprintf(";最近读取失败:%v", m.readErr) + } + w.m.Log.Infof("%s;精确退出峰值未知", msg) +} + // tick is one look of the watch at now: nothing while a start or a stop of the instance runs, or while the record does // not name q (no start has finished on it); otherwise, once Android has booted with another boot_id than the // record's, the start that sets the boot up again. diff --git a/internal/emu/bootwatch_memory_policy_test.go b/internal/emu/bootwatch_memory_policy_test.go new file mode 100644 index 0000000..0440b56 --- /dev/null +++ b/internal/emu/bootwatch_memory_policy_test.go @@ -0,0 +1,223 @@ +//go:build windows + +// Copyright (C) 2026 jinghero + +package emu + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "modavd/internal/host" +) + +// A minute's growth must be visible before the ten-minute heartbeat. +func TestBootWatchMemoryDeltaRegression(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + const mb = 1 << 20 + commit := uint64(1000 * mb) + processMemory = func(host.Process) (host.ProcessMemory, error) { + return host.ProcessMemory{Commit: commit, PeakCommit: commit, WorkingSet: 500 * mb}, nil + } + systemMemory = func() (host.SystemMemory, error) { return host.SystemMemory{}, nil } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + w.logMemory(t0) + commit += 257 * mb + w.logMemory(t0.Add(time.Minute)) + if len(*lines) != 2 { + t.Fatalf("257 MiB growth after a minute: %v, want two lines", *lines) + } +} + +func TestBootWatchMemoryPolicy(t *testing.T) { + const mb = 1 << 20 + type sample struct { + minute int + commit uint64 + lines int + } + for _, tc := range []struct { + name string + samples []sample + }{ + {"stable heartbeat", []sample{{0, 1000 * mb, 1}, {1, 1000 * mb, 1}, {9, 1000 * mb, 1}, {10, 1000 * mb, 2}}}, + {"cumulative strict threshold", []sample{{0, 1000 * mb, 1}, {1, 1128 * mb, 1}, {2, 1256 * mb, 1}, {3, 1256*mb + 1, 2}}}, + {"falling strict threshold", []sample{{0, 1000 * mb, 1}, {1, 744 * mb, 1}, {2, 744*mb - 1, 2}}}, + {"heartbeat resets on write", []sample{{0, 1000 * mb, 1}, {3, 1300 * mb, 2}, {10, 1300 * mb, 2}, {12, 1300 * mb, 2}, {13, 1300 * mb, 3}}}, + {"unsigned large difference", []sample{{0, 1, 1}, {1, 1 << 63, 2}, {2, 1, 3}}}, + } { + t.Run(tc.name, func(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + var commit uint64 + reads, systemReads := 0, 0 + processMemory = func(p host.Process) (host.ProcessMemory, error) { + if p.PID != 4242 || p.Created != 17 { + t.Fatalf("lost process identity: %+v", p) + } + reads++ + return host.ProcessMemory{Commit: commit, PeakCommit: commit, WorkingSet: commit}, nil + } + systemMemory = func() (host.SystemMemory, error) { + systemReads++ + return host.SystemMemory{}, nil + } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 17}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + for i, s := range tc.samples { + commit = s.commit + w.logMemory(t0.Add(time.Duration(s.minute) * time.Minute)) + if len(*lines) != s.lines || systemReads != s.lines || reads != i+1 { + t.Fatalf("minute %d: logs %v, process reads %d, system reads %d", s.minute, *lines, reads, systemReads) + } + } + }) + } +} + +func TestBootWatchMemorySummary(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + const mb = 1 << 20 + reads, systemReads := 0, 0 + var pm host.ProcessMemory + var readErr error + processMemory = func(host.Process) (host.ProcessMemory, error) { reads++; return pm, readErr } + systemMemory = func() (host.SystemMemory, error) { systemReads++; return host.SystemMemory{}, nil } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + pm = host.ProcessMemory{Commit: 1000 * mb, PeakCommit: 2000 * mb, WorkingSet: 200 * mb} + w.logMemory(t0) + pm = host.ProcessMemory{Commit: 1100 * mb, PeakCommit: 2200 * mb, WorkingSet: 350 * mb} + w.logMemory(t0.Add(time.Minute)) // sampled but not written + pm = host.ProcessMemory{Commit: 900 * mb, PeakCommit: 2300 * mb, WorkingSet: 300 * mb} + w.logMemory(t0.Add(2 * time.Minute)) + readErr = errors.New("denied") + pm = host.ProcessMemory{Commit: 99999 * mb} // partial result with error must never become history + w.logMemory(t0.Add(3 * time.Minute)) + w.logMemory(t0.Add(4 * time.Minute)) + w.logMemorySummary() + want := "实例 3 模拟器内存退出汇总(qemu pid 4242):成功采样 3 次,读取失败 2 次;最后成功采样 2026-11-12T20:02:00Z:提交 900 MB,OS 提交峰值 2300 MB,工作集 300 MB;采样最大提交 1100 MB,工作集 350 MB;最近读取失败:denied;精确退出峰值未知" + if len(*lines) != 3 || (*lines)[2].msg != want || reads != 5 || systemReads != 1 { + t.Fatalf("summary = %v, process reads %d, system reads %d", *lines, reads, systemReads) + } +} + +func TestBootWatchMemoryNoSuccessfulSample(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + processMemory = func(host.Process) (host.ProcessMemory, error) { + return host.ProcessMemory{}, errors.New("unreadable") + } + systemMemory = func() (host.SystemMemory, error) { + t.Fatal("queried system after failed sample") + return host.SystemMemory{}, nil + } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + w.logMemory(time.Now()) + w.logMemory(time.Now().Add(time.Minute)) + w.logMemorySummary() + if len(*lines) != 2 || !strings.Contains((*lines)[1].msg, "成功采样 0 次,读取失败 2 次;没有成功样本") || + !strings.HasSuffix((*lines)[1].msg, "精确退出峰值未知") || strings.Contains((*lines)[1].msg, "0 MB") { + t.Fatalf("unknown counters presented as numbers: %v", *lines) + } +} + +func TestBootWatchMemoryFailureRecovery(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + var readErr error + processMemory = func(host.Process) (host.ProcessMemory, error) { + return host.ProcessMemory{Commit: 1 << 30}, readErr + } + systemMemory = func() (host.SystemMemory, error) { return host.SystemMemory{}, nil } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + w.logMemory(t0) + readErr = errors.New("denied") + w.logMemory(t0.Add(10 * time.Minute)) + w.logMemory(t0.Add(11 * time.Minute)) + if w.memory.logged.at != t0 || w.memory.last.at != t0 { + t.Fatal("failure moved the successful/logged baseline") + } + readErr = nil + w.logMemory(t0.Add(12 * time.Minute)) + w.logMemory(t0.Add(21 * time.Minute)) + w.logMemory(t0.Add(22 * time.Minute)) + w.logMemorySummary() + if len(*lines) != 5 || !strings.HasSuffix((*lines)[2].msg, "内存采样已恢复") || + strings.Contains((*lines)[4].msg, "最近读取失败") || !strings.Contains((*lines)[4].msg, "读取失败 2 次") { + t.Fatalf("recovery/heartbeat = %v", *lines) + } +} + +// A guest read can block the reboot/stop path, but cannot block memory sampling or its exit summary. +func TestBootWatchMemoryIndependentOfGuest(t *testing.T) { + savedP, savedS, savedGuest := processMemory, systemMemory, guestBootRead + t.Cleanup(func() { processMemory, systemMemory, guestBootRead = savedP, savedS, savedGuest }) + rootManager, _ := opsTestManager(t) + i := 3 + if _, err := rootManager.Create(context.Background(), CreateRequest{filesOnly: true, Index: &i}); err != nil { + t.Fatal(err) + } + m, lines := recordingManager() + m.Root = rootManager.Root + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + m.writeBootRecord(3, bootRecord{QemuPID: w.q.PID, QemuCreated: w.q.Created, BootID: testBootA}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + entered := make(chan struct{}) + guestBootRead = func(int, bool) (bool, string, bool) { + close(entered) + <-ctx.Done() + return false, "", false + } + guestDone := make(chan struct{}) + go func() { defer close(guestDone); w.tick(ctx, time.Now()) }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("guest read never started") + } + read := make(chan struct{}, 2) + processMemory = func(host.Process) (host.ProcessMemory, error) { + read <- struct{}{} + return host.ProcessMemory{Commit: 1 << 30}, nil + } + systemMemory = func() (host.SystemMemory, error) { return host.SystemMemory{}, nil } + ticks := make(chan time.Time) + done := make(chan struct{}) + w.logMemory(time.Now()) + go func() { defer close(done); w.watchMemory(ctx, ticks) }() + awaitRead := func() { + t.Helper() + select { + case <-read: + case <-time.After(5 * time.Second): + t.Fatal("memory sample blocked by guest read") + } + } + awaitRead() + ticks <- time.Now().Add(time.Minute) + awaitRead() + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("memory worker did not stop") + } + <-guestDone + if w.memory.samples != 2 || len(read) != 0 || len(*lines) != 2 || !strings.Contains((*lines)[1].msg, "内存退出汇总") { + t.Fatalf("sampling/exit read count = %d, extra reads %d, logs %v", w.memory.samples, len(read), *lines) + } +} diff --git a/internal/emu/bootwatch_memory_test.go b/internal/emu/bootwatch_memory_test.go index cbc9cac..ef65c1f 100644 --- a/internal/emu/bootwatch_memory_test.go +++ b/internal/emu/bootwatch_memory_test.go @@ -5,7 +5,9 @@ package emu import ( "errors" "os" + "os/exec" "strings" + "syscall" "testing" "time" @@ -13,13 +15,13 @@ import ( "modavd/internal/logx" ) -// logMemory writes a line at the first look and then once per bootWatchMemoryInterval, with qemu's and the computer's -// numbers in MB; nothing while qemu's counters cannot be read, and only qemu's part without the computer's. +// Successful reads resume after failure; only emitted samples query the system counters. func TestBootWatchLogMemory(t *testing.T) { savedP, savedS := processMemory, systemMemory t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) const mb = 1 << 20 var perr, serr error + systemCalls := 0 processMemory = func(p host.Process) (host.ProcessMemory, error) { if p.PID != 4242 { t.Errorf("asked for pid %d", p.PID) @@ -27,6 +29,7 @@ func TestBootWatchLogMemory(t *testing.T) { return host.ProcessMemory{Commit: 6100 * mb, PeakCommit: 6200 * mb, WorkingSet: 4300 * mb}, perr } systemMemory = func() (host.SystemMemory, error) { + systemCalls++ return host.SystemMemory{TotalPhys: 15728 * mb, AvailPhys: 4400 * mb, CommitLimit: 41574 * mb, Committed: 38600 * mb}, serr } m, lines := recordingManager() @@ -35,26 +38,60 @@ func TestBootWatchLogMemory(t *testing.T) { perr = errors.New("gone") w.logMemory(t0) - if len(*lines) != 0 { - t.Fatalf("logged without counters: %+v", *lines) + if len(*lines) != 1 || (*lines)[0].level != logx.Warn || systemCalls != 0 { + t.Fatalf("failed read: %+v, system calls %d", *lines, systemCalls) } perr = nil - w.logMemory(t0) - w.logMemory(t0.Add(bootWatchMemoryInterval - time.Second)) - if len(*lines) != 1 { - t.Fatalf("lines = %+v, want one", *lines) + w.logMemory(t0.Add(time.Minute)) + w.logMemory(t0.Add(bootWatchMemoryInterval)) + if len(*lines) != 2 || systemCalls != 1 { + t.Fatalf("lines = %+v, system calls %d, want recovery only", *lines, systemCalls) } - want := "实例 3 模拟器内存(qemu pid 4242):提交 6100 MB(最高 6200 MB),工作集 4300 MB;电脑:物理内存可用 4400 / 15728 MB,提交 38600 / 41574 MB" - if got := (*lines)[0]; got.level != logx.Info || got.msg != want { + want := "实例 3 模拟器内存(qemu pid 4242):提交 6100 MB(OS 提交峰值 6200 MB),工作集 4300 MB;电脑:物理内存可用 4400 / 15728 MB,提交 38600 / 41574 MB;内存采样已恢复" + if got := (*lines)[1]; got.level != logx.Info || got.msg != want { t.Errorf("line = %v %q\nwant %q", got.level, got.msg, want) } serr = errors.New("no") - w.logMemory(t0.Add(bootWatchMemoryInterval)) - if len(*lines) != 2 || !strings.HasSuffix((*lines)[1].msg, "工作集 4300 MB") { + w.logMemory(t0.Add(time.Minute + bootWatchMemoryInterval)) + if len(*lines) != 3 || !strings.HasSuffix((*lines)[2].msg, "工作集 4300 MB") { t.Errorf("without the computer's numbers: %+v", *lines) } } +// Retaining the process object does not make post-exit memory a valid live snapshot. Windows can return success +// with most allocations already torn down, so the memory boundary must check exit as well as creation identity. +func TestMemoryRejectsRetainedExitedProcess(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(exe, "-test.run=^TestBootWatchHelperSleeper$") + cmd.Env = append(os.Environ(), "MODAVD_TEST_BOOTWATCH_SLEEPER=1") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) + p, ok := host.FindProcess(uint32(cmd.Process.Pid)) + if !ok { + t.Fatal("child not found") + } + h, err := syscall.OpenProcess(0x1000, false, p.PID) // PROCESS_QUERY_LIMITED_INFORMATION, retained through Wait + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(h) + if _, err := host.GetProcessMemory(p); err != nil { + t.Fatal(err) + } + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + cmd.Wait() + if pm, err := host.GetProcessMemory(p); err == nil { + t.Fatalf("accepted torn-down counters of exited process: %+v", pm) + } +} + // The real counters read this test process and the computer. func TestMemoryCounters(t *testing.T) { p, ok := host.FindProcess(uint32(os.Getpid())) diff --git a/internal/host/memory.go b/internal/host/memory.go index 1d754af..e2fc452 100644 --- a/internal/host/memory.go +++ b/internal/host/memory.go @@ -3,7 +3,7 @@ package host // ProcessMemory is the memory of one process, in bytes. type ProcessMemory struct { Commit uint64 // private commit charge (Task Manager's "Commit size") - PeakCommit uint64 // the highest Commit has been + PeakCommit uint64 // OS PeakPagefileUsage at this read, not a sampled maximum or a final exit counter WorkingSet uint64 // in physical memory now } diff --git a/internal/report/collect.go b/internal/report/collect.go index 26d270e..9aa10af 100644 --- a/internal/report/collect.go +++ b/internal/report/collect.go @@ -808,7 +808,7 @@ func (c *collector) runningAndAdb() error { } c.L(fmt.Sprintf("pid %d 启动于 %s", q.PID, ct)) if q.CommitBytes != 0 { - c.L(fmt.Sprintf(" 内存:提交 %s,工作集 %s(隔 10 分钟的记录在 modavd-bootwatch.log)", + c.L(fmt.Sprintf(" 内存:提交 %s,工作集 %s(每分钟采样,变化超过 256 MiB 或满 10 分钟时记录,退出汇总见 modavd-bootwatch.log)", formatSize(float64(q.CommitBytes)), formatSize(float64(q.WorkingSetBytes)))) } cmd := q.CommandLine diff --git a/internal/win/mem.go b/internal/win/mem.go index 2ae5992..3794269 100644 --- a/internal/win/mem.go +++ b/internal/win/mem.go @@ -28,7 +28,8 @@ type processMemoryCountersEx struct { } // Memory is GetProcessMemoryInfo of p, in bytes: commit is its private commit charge (Task Manager's "Commit size"), -// peakCommit the highest that has been, workingSet what it has in physical memory now. +// peakCommit is the OS PeakPagefileUsage at this read, workingSet what it has in physical memory now. +// Counters after process exit are not guaranteed, even if a handle keeps the process object alive. func (p Process) Memory() (commit, peakCommit, workingSet uint64, err error) { h, err := p.open(0) if err != nil { @@ -40,6 +41,13 @@ func (p Process) Memory() (commit, peakCommit, workingSet uint64, err error) { if r, _, e := procK32GetProcessMemoryInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&c)), uintptr(c.cb)); r == 0 { return 0, 0, 0, e } + // The API can succeed after exit with torn-down counters while a handle retains the process object. Check + // after the read as well, so exit during the call cannot masquerade as a successful live sample. + if _, exited, _, _, e := handleTimes(h); e != nil { + return 0, 0, 0, e + } else if exited != 0 { + return 0, 0, 0, errGone + } return uint64(c.privateUsage), uint64(c.peakPagefileUsage), uint64(c.workingSetSize), nil } From 3558252853c944037bf50fd38ba439e20810bceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=A0=E6=AF=9B=E8=8F=9C?= <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:40:26 +0000 Subject: [PATCH 13/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=99=A8=E7=A6=BB=E7=BA=BF=E6=9B=B4=E6=96=B0=E5=92=8C=E4=BA=A4?= =?UTF-8?q?=E6=8E=A5=E7=BB=93=E6=9E=9C=E9=87=8D=E5=A4=8D=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/modavd-gui/main.go | 28 ++++- cmd/modavd-gui/update_handoff.go | 55 +++++++++ cmd/modavd-gui/update_handoff_test.go | 73 ++++++++++++ internal/cli/commands.go | 36 ++++-- internal/cli/update_caller_test.go | 5 +- internal/cli/update_handoff_test.go | 93 ++++++++++++--- internal/ui/API.en.md | 8 +- internal/ui/API.md | 8 +- internal/ui/http.go | 2 + internal/ui/ui.go | 6 +- internal/ui/update.go | 62 +++++++++- internal/ui/update_apply.go | 6 +- internal/ui/update_apply_test.go | 7 +- internal/ui/update_handoff_test.go | 144 ++++++++++++++++++++++++ internal/ui/web/js/update.js | 20 ++-- internal/update/handoff.go | 127 ++++++++++++++++++--- internal/update/handoff_offline_test.go | 127 +++++++++++++++++++++ internal/update/handoff_test.go | 93 ++++++++++++--- internal/update/handoff_windows_test.go | 36 ++++++ scripts/test_update_handoff.mjs | 92 +++++++++++++++ 20 files changed, 951 insertions(+), 77 deletions(-) create mode 100644 cmd/modavd-gui/update_handoff.go create mode 100644 cmd/modavd-gui/update_handoff_test.go create mode 100644 internal/ui/update_handoff_test.go create mode 100644 internal/update/handoff_offline_test.go create mode 100644 internal/update/handoff_windows_test.go create mode 100644 scripts/test_update_handoff.mjs diff --git a/cmd/modavd-gui/main.go b/cmd/modavd-gui/main.go index 6b3512c..5c1dfbd 100644 --- a/cmd/modavd-gui/main.go +++ b/cmd/modavd-gui/main.go @@ -42,6 +42,7 @@ import ( "modavd/internal/logx" "modavd/internal/root" "modavd/internal/ui" + "modavd/internal/update" ) func main() { @@ -82,6 +83,9 @@ func helpText(fs *flag.FlagSet) string { b.WriteString(usage + "\n") fs.VisitAll(func(f *flag.Flag) { name := "--" + f.Name + if name == update.HandoffArg { + return + } if v := flagValueNames[f.Name]; v != "" { name += " " + v } @@ -128,6 +132,7 @@ func run(args []string) (code int) { fs.BoolVar(&chromeWindow, "chrome-window", false, "用独立 Chrome 应用窗口打开(默认使用系统浏览器)") } dev := fs.Bool("dev", false, "开发模式:提供 /dev/ 的模拟后端页面,没有窗口也不自动退出") + handoffArg := fs.String(strings.TrimPrefix(update.HandoffArg, "--"), "", "更新交接 ID") if err := fs.Parse(args); err != nil { // --help / -h / -help ask for the usage: an information box and exit code 0, not an error (ModAVD/ModAVD#115). if errors.Is(err, flag.ErrHelp) { @@ -145,6 +150,15 @@ func run(args []string) (code int) { fatal(fmt.Sprintf("端口 %d 不对(0–65535,0 = 随机)", *port)) return 2 } + var handoffID update.HandoffID + if *handoffArg != "" { + var err error + handoffID, err = update.ParseHandoffID(*handoffArg) + if err != nil { + fatal(err.Error()) + return 2 + } + } r, err := root.Resolve(*rootFlag) if err != nil { fatalErr("找不到 ModAVD 根目录", err) @@ -166,7 +180,7 @@ func run(args []string) (code int) { return 1 } if inst == nil { - return reopen(r, *noWindow, chromeWindow, log) + return reopen(r, *noWindow, chromeWindow, log, handoffID) } defer inst.release() @@ -179,6 +193,7 @@ func run(args []string) (code int) { OpenWindow: !*noWindow, ChromeWindow: chromeWindow, Dev: *dev, + HandoffID: handoffID, // the tray icon and the boss keys (Windows): closing the window leaves the manager in the tray Tray: runtime.GOOS == "windows", Ready: func(baseURL, token string) { @@ -224,7 +239,11 @@ func reopenArgs(r *root.Root, port int, noWindow, dev bool, log *logx.Logger) *u } // reopen is the second launch on a root: the running manager gets a new window. -func reopen(r *root.Root, noWindow, chromeWindow bool, log *logx.Logger) int { +func reopen(r *root.Root, noWindow, chromeWindow bool, log *logx.Logger, id update.HandoffID) int { + if id != "" { + // A failed delivery must not leave this result for a later launch. + defer update.TakeHandoff(r, id, time.Now()) + } st, err := readStateWait(r, 5*time.Second) if err != nil { log.Errorf("已有管理器在运行,但读不到它的地址: %v", err) @@ -235,6 +254,11 @@ func reopen(r *root.Root, noWindow, chromeWindow bool, log *logx.Logger) int { fatal("这个根目录的 ModAVD 管理器已经在运行,但找不到它的地址。\n\n" + advice + "\n\n根目录:" + r.Dir) return 1 } + if id != "" { + if err := forwardHandoff(st, id); err != nil { + log.Warnf("把本次更新结果交给已运行的管理器失败: %v", err) + } + } if noWindow { return 0 } diff --git a/cmd/modavd-gui/update_handoff.go b/cmd/modavd-gui/update_handoff.go new file mode 100644 index 0000000..4481923 --- /dev/null +++ b/cmd/modavd-gui/update_handoff.go @@ -0,0 +1,55 @@ +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "modavd/internal/update" +) + +// forwardHandoff delivers the restart's ID to the existing manager, even with --no-window. It never sends the +// local login cookie through an environment proxy or a redirect. +func forwardHandoff(st state, id update.HandoffID) error { + base, err := url.Parse(st.URL) + if err != nil || base.Scheme != "http" || base.Host != "127.0.0.1:"+strconv.Itoa(st.Port) || + base.User != nil || base.Path != "/" || base.RawQuery != "" || base.Fragment != "" || st.Port <= 0 || st.Port > 65535 { + return fmt.Errorf("管理器地址不对") + } + body, err := json.Marshal(struct { + ID update.HandoffID `json:"id"` + }{id}) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, st.URL+"api/update/handoff/receive", strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", strings.TrimSuffix(st.URL, "/")) + req.AddCookie(&http.Cookie{Name: "modavd_ui_" + strconv.Itoa(st.Port), Value: st.Token}) + client := &http.Client{Transport: &http.Transport{}, CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + defer client.CloseIdleConnections() + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("管理器接收更新结果失败(HTTP %d)", resp.StatusCode) + } + return nil +} diff --git a/cmd/modavd-gui/update_handoff_test.go b/cmd/modavd-gui/update_handoff_test.go new file mode 100644 index 0000000..8e3a8f5 --- /dev/null +++ b/cmd/modavd-gui/update_handoff_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync/atomic" + "testing" + + "modavd/internal/update" +) + +func TestForwardHandoffUsesOnlyLocalAuthenticatedEndpoint(t *testing.T) { + id := update.HandoffID(strings.Repeat("a", 32)) + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodPost || r.URL.Path != "/api/update/handoff/receive" || r.Header.Get("Origin") != "http://"+r.Host { + t.Errorf("request: %s %s, origin %s", r.Method, r.URL, r.Header.Get("Origin")) + } + _, port, _ := strings.Cut(r.Host, ":") + cookie, err := r.Cookie("modavd_ui_" + port) + if err != nil || cookie.Value != "local-token" { + t.Errorf("authentication: %v", err) + } + var body struct{ ID update.HandoffID } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID != id { + t.Errorf("body: %+v, %v", body, err) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + base, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(base.Port()) + st := state{URL: srv.URL + "/", Port: port, Token: "local-token"} + if err := forwardHandoff(st, id); err != nil || calls.Load() != 1 { + t.Fatalf("forward: %v, calls %d", err, calls.Load()) + } + for _, address := range []string{"http://elsewhere.invalid/", srv.URL + "/other/", srv.URL + "/?x=1", "http://user@" + base.Host + "/"} { + st.URL = address + if err := forwardHandoff(st, id); err == nil || calls.Load() != 1 { + t.Fatalf("accepted address %s: %v", address, err) + } + } +} + +func TestForwardHandoffDoesNotFollowRedirect(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect) + })) + defer srv.Close() + base, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(base.Port()) + err := forwardHandoff(state{URL: srv.URL + "/", Port: port}, update.HandoffID(strings.Repeat("a", 32))) + if err == nil || calls.Load() != 1 { + t.Fatalf("redirect: %v, calls %d", err, calls.Load()) + } +} + +func TestBadHandoffIDIsRejectedBeforeRootLookup(t *testing.T) { + errors, _ := showBoxes(t) + if code := run([]string{update.HandoffArg, "../bad"}); code != 2 || len(*errors) != 1 { + t.Fatalf("code %d, errors %q", code, *errors) + } +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 2dc2ec4..b3030ec 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -105,8 +105,9 @@ var ( optNoBridge = flagSpec{"no-bridge", kindBool, "", "不装 arm64 执行桥,以后开机也不装(默认 modavd / 管理器开机时自动装上)"} optAutoMAS = flagSpec{"automas-version", kindString, "V", "调用方 AUTO-MAS 的版本(新版要求更高的 AUTO-MAS 时不装)"} optAfterPID = flagSpec{"after-pid", kindInt, "PID", "管理器用:先等这个进程退出(最多 60 秒)再装,只和 install 一起用"} - optReopen = flagSpec{"reopen", kindString, "程序", "管理器用:装完(成不成都)把结果写进 .update\\manager-result.json,再启动这个程序(完整路径),只和 install 一起用"} + optReopen = flagSpec{"reopen", kindString, "程序", "管理器用:装完(成不成都)保存本次交接结果,再启动这个程序(完整路径),只和 install 一起用"} optReopenArg = flagSpec{"reopen-arg", kindStrings, "参数", "管理器用:启动 --reopen 的程序时带的参数,按顺序,可以给多次"} + optHandoffID = flagSpec{"handoff-id", kindString, "ID", "管理器用:本次交接的关联 ID,只和 --reopen 一起用;不给就生成"} optUpdateSource = flagSpec{"source", kindString, "github|mirrorchyan", "这次用的更新源,盖过「设置」里的,不保存;recover 不用"} optCDKDPAPI = flagSpec{"cdk-dpapi", kindString, "BASE64", "这次用的 Mirror 酱 CDK,不保存:调用方用 Windows DPAPI(CryptProtectData,不带 entropy)加密 UTF-8 的 CDK 后的 base64;" + @@ -383,7 +384,7 @@ func init() { run: runVersion, human: humanOf(formatVersion), }, { - name: "update", args: "[status|check|prepare|install|recover] [--source github|mirrorchyan] [--cdk-dpapi BASE64] [--automas-version V] [--package ZIP --sha256 HEX] [--after-pid PID --reopen 程序 [--reopen-arg 参数]...]", + name: "update", args: "[status|check|prepare|install|recover] [--source github|mirrorchyan] [--cdk-dpapi BASE64] [--automas-version V] [--package ZIP --sha256 HEX] [--after-pid PID --reopen 程序 [--reopen-arg 参数]... [--handoff-id ID]]", summary: "更新这个根目录的 ModAVD 包(模拟器、组件、脚本和管理器)", details: "按管理器「设置」里选的更新源(GitHub 或 Mirror 酱,Mirror 酱下载要填 CDK);" + "调用方有自己的设置时用 --source 和 --cdk-dpapi 传这一次的,只盖过「设置」、不保存,status 里的 source、cdk_set 是这次生效的。" + @@ -393,7 +394,7 @@ func init() { "install 需要时先 prepare,再换上 sdk\\emulator 和包里的其他文件,要先关掉所有实例、管理器(包括托盘里的)和 MCP 客户端;" + "recover 补完或退回上次没做完的换上。和 AUTO-MAS 用同一个 .update 目录,能接着对方没做完的做。" + "--automas-version 是调用方 AUTO-MAS 的版本,新版要求更高的 AUTO-MAS 时不装。" + - "--after-pid、--reopen、--reopen-arg 是管理器「设置」里点「更新」时用的:管理器下载核对好、关掉实例后启动这条命令并退出," + + "--after-pid、--reopen、--reopen-arg、--handoff-id 是管理器「设置」里点「更新」时用的:管理器下载核对好、关掉实例后启动这条命令并退出," + "命令等它退出再换上新版,然后重新打开管理器。" + "macOS 可用 --package ZIP --sha256 HEX 准备或安装本地完整签名包;摘要需从可信来源独立取得。" + "--json 时每个进度在 stderr 的日志行里多一个 progress 对象(checking、extracting、verifying、installing 开始时各一行,percent 为 -1;" + @@ -404,7 +405,7 @@ func init() { flags: []flagSpec{optUpdateSource, optCDKDPAPI, optAutoMAS, {name: "package", kind: kindString, arg: "ZIP", help: "macOS 本地完整更新包"}, {name: "sha256", kind: kindString, arg: "HEX", help: "本地更新包的独立可信 SHA-256"}, - optAfterPID, optReopen, optReopenArg}, final: true, + optAfterPID, optReopen, optReopenArg, optHandoffID}, final: true, run: runUpdate, human: humanOf(formatUpdateStatus), }, { @@ -1498,10 +1499,11 @@ func runUpdate(c *cmdCtx) (any, error) { return nil, errs.New(errs.Usage, "--package 和 --sha256 只用于 update prepare 或 install") } afterPID, reopen, reopenArgs := c.inv.intOpt("after-pid"), c.inv.strOpt("reopen"), c.inv.strsOpt("reopen-arg") - handoff := afterPID != 0 || reopen != "" || len(reopenArgs) > 0 + idArg := c.inv.strOpt("handoff-id") + handoff := afterPID != 0 || reopen != "" || len(reopenArgs) > 0 || idArg != "" switch { case handoff && action != "install": - return nil, errs.New(errs.Usage, "--after-pid、--reopen、--reopen-arg 只能和 install 一起用"). + return nil, errs.New(errs.Usage, "--after-pid、--reopen、--reopen-arg、--handoff-id 只能和 install 一起用"). WithAdvice(findCommand("update").usageAdvice()) case afterPID < 0: return nil, errs.Newf(errs.Usage, "--after-pid 要给进程号,收到 %d", afterPID) @@ -1509,6 +1511,20 @@ func runUpdate(c *cmdCtx) (any, error) { return nil, errs.New(errs.Usage, "--reopen-arg 要和 --reopen 一起用") case reopen != "" && !filepath.IsAbs(reopen): return nil, errs.Newf(errs.Usage, "--reopen 要给程序的完整路径,收到「%s」", reopen) + case idArg != "" && reopen == "": + return nil, errs.New(errs.Usage, "--handoff-id 要和 --reopen 一起用") + } + var id update.HandoffID + if reopen != "" { + var err error + if idArg != "" { + id, err = update.ParseHandoffID(idArg) + } else { + id, err = update.NewHandoffID() + } + if err != nil { + return nil, err + } } opt := update.Options{AutoMASVersion: c.inv.strOpt("automas-version"), PackagePath: c.inv.strOpt("package"), PackageSHA256: c.inv.strOpt("sha256")} @@ -1541,7 +1557,7 @@ func runUpdate(c *cmdCtx) (any, error) { return u.Prepare(c.ctx) case "install": if handoff { - return installHandoff(c, r, u, afterPID, reopen, reopenArgs) + return installHandoff(c, r, u, afterPID, reopen, reopenArgs, id) } return c.deps.updateInstall(c.ctx, u) case "recover": @@ -1577,7 +1593,7 @@ func updateCallerSettings(inv *invocation, action string, opt *update.Options) e // installHandoff is `update install` for the manager's 更新 (update/handoff.go): wait for the manager (afterPID) to exit, // install, then, with reopen, leave the result for the manager and start it again whatever happened, so the user is // never left without a manager. -func installHandoff(c *cmdCtx, r *root.Root, u *update.Updater, afterPID int, reopen string, args []string) (*update.Status, error) { +func installHandoff(c *cmdCtx, r *root.Root, u *update.Updater, afterPID int, reopen string, args []string, id update.HandoffID) (*update.Status, error) { var st *update.Status var err error if afterPID > 0 { @@ -1592,10 +1608,12 @@ func installHandoff(c *cmdCtx, r *root.Root, u *update.Updater, afterPID int, re st, err = c.deps.updateInstall(c.ctx, u) } if reopen != "" { - if serr := update.SaveHandoff(r, update.NewHandoffResult(st, err, time.Now())); serr != nil { + if serr := update.SaveHandoff(r, update.NewHandoffResult(id, st, err, time.Now())); serr != nil { c.log.Warnf("写不了给管理器的更新结果: %v", serr) } + args = append(args, update.HandoffArg, string(id)) if rerr := c.deps.reopen(reopen, args); rerr != nil { + update.TakeHandoff(r, id, time.Now()) c.log.Warnf("重新打开管理器(%s)失败: %v", reopen, rerr) } else { c.log.Infof("已重新打开管理器") diff --git a/internal/cli/update_caller_test.go b/internal/cli/update_caller_test.go index 7bb2d06..0c9dbe8 100644 --- a/internal/cli/update_caller_test.go +++ b/internal/cli/update_caller_test.go @@ -138,9 +138,12 @@ func TestUpdateCallerSettingsPassed(t *testing.T) { h.reopen = func(exe string, args []string) error { reopened = args; return nil } if res := h.run(t, nil, "update", "install", "--reopen", `C:\M\ModAVD.exe`, "--reopen-arg", "--root", "--reopen-arg", `C:\M`, "--reopen-arg", "--source", "--json"); res.code != 0 || - strings.Join(reopened, " ") != `--root C:\M --source` { + len(reopened) != 5 || strings.Join(reopened[:4], " ") != `--root C:\M --source --update-handoff` { t.Fatalf("--reopen-arg --root: exit %d: %s %v", res.code, res.stdout, reopened) } + if _, err := update.ParseHandoffID(reopened[4]); err != nil { + t.Fatal(err) + } h, opts, _ = updateHarness(t) if res := h.run(t, nil, "update", "install", "--source", "github", "--json"); res.code != 0 { t.Fatalf("exit %d: %s", res.code, res.stdout) diff --git a/internal/cli/update_handoff_test.go b/internal/cli/update_handoff_test.go index f9f81c8..c0c9c5c 100644 --- a/internal/cli/update_handoff_test.go +++ b/internal/cli/update_handoff_test.go @@ -4,6 +4,7 @@ package cli import ( "context" + "errors" "slices" "strings" "testing" @@ -14,6 +15,8 @@ import ( "modavd/internal/update" ) +const cliHandoffID update.HandoffID = "0123456789abcdef0123456789abcdef" + // handoffHarness: a root in a temp dir, and recording fakes for the three hand-off steps. func handoffHarness(t *testing.T) (*harness, *root.Root, *[]string) { t.Helper() @@ -36,13 +39,77 @@ func handoffHarness(t *testing.T) (*harness, *root.Root, *[]string) { return h, r, &calls } +func TestUpdateHandoffRejectsInvalidIDBeforeInstall(t *testing.T) { + for _, args := range [][]string{ + {"update", "install", "--handoff-id", string(cliHandoffID)}, + {"update", "check", "--handoff-id", string(cliHandoffID)}, + {"update", "install", "--reopen", `C:\ModAVD\ModAVD.exe`, "--handoff-id", "../bad"}, + } { + h, _, calls := handoffHarness(t) + if res := h.run(t, nil, args...); res.code != 2 || len(*calls) != 0 { + t.Fatalf("args %v: code %d, calls %v", args, res.code, *calls) + } + } +} + +func TestUpdateHandoffReopenFailureDiscardsOnlyOwnResult(t *testing.T) { + h, r, _ := handoffHarness(t) + other, err := update.NewHandoffID() + if err != nil { + t.Fatal(err) + } + if err := update.SaveHandoff(r, update.NewHandoffResult(other, nil, nil, time.Now())); err != nil { + t.Fatal(err) + } + h.reopen = func(string, []string) error { return errors.New("cannot start") } + res := h.run(t, nil, "update", "install", "--reopen", `C:\ModAVD\ModAVD.exe`, "--handoff-id", string(cliHandoffID)) + if res.code != 0 || !strings.Contains(res.stderr, "cannot start") { + t.Fatalf("install result changed: %+v", res) + } + if update.TakeHandoff(r, cliHandoffID, time.Now()) != nil { + t.Fatal("failed restart left a result") + } + if update.TakeHandoff(r, other, time.Now()) == nil { + t.Fatal("failed restart discarded another result") + } +} + +func TestUpdateHandoffGeneratesDistinctIDsForRetries(t *testing.T) { + h, r, _ := handoffHarness(t) + var ids []update.HandoffID + h.reopen = func(_ string, args []string) error { + if len(args) != 3 || args[0] != "--no-window" || args[1] != update.HandoffArg { + t.Fatalf("restart args: %q", args) + } + id, err := update.ParseHandoffID(args[2]) + if err != nil { + t.Fatal(err) + } + ids = append(ids, id) + return nil + } + for range 2 { + if res := h.run(t, nil, "update", "install", "--reopen", `C:\ModAVD\ModAVD.exe`, "--reopen-arg", "--no-window"); res.code != 0 { + t.Fatalf("exit %d", res.code) + } + } + if ids[0] == ids[1] { + t.Fatal("retry reused the previous handoff ID") + } + for _, id := range ids { + if result := update.TakeHandoff(r, id, time.Now()); result == nil || result.ID != id { + t.Fatalf("missing matching result for %s: %+v", id, result) + } + } +} + func TestUpdateHandoffOptionsOnlyWithInstall(t *testing.T) { for _, args := range [][]string{ {"update", "check", "--after-pid", "5"}, - {"update", "--reopen", `C:\x\ModAVD.exe`}, + {"update", "--handoff-id", string(cliHandoffID), "--reopen", `C:\x\ModAVD.exe`}, {"update", "prepare", "--reopen-arg", "--no-window"}, {"update", "install", "--reopen-arg", "--no-window"}, - {"update", "install", "--reopen", `ModAVD.exe`}, + {"update", "install", "--handoff-id", string(cliHandoffID), "--reopen", `ModAVD.exe`}, {"update", "install", "--after-pid", "-1"}, } { h, _, calls := handoffHarness(t) @@ -56,23 +123,23 @@ func TestUpdateHandoffOptionsOnlyWithInstall(t *testing.T) { func TestUpdateHandoffWaitsInstallsThenReopens(t *testing.T) { h, r, calls := handoffHarness(t) exe := `C:\ModAVD\ModAVD.exe` - res := h.run(t, nil, "update", "install", "--after-pid", "4242", "--reopen", exe, + res := h.run(t, nil, "update", "install", "--after-pid", "4242", "--handoff-id", string(cliHandoffID), "--reopen", exe, "--reopen-arg", "--root", "--reopen-arg", `C:\ModAVD`, "--reopen-arg", "--no-window", "--json") if res.code != 0 { t.Fatalf("exit %d: %s %s", res.code, res.stdout, res.stderr) } - want := []string{"wait 4242 " + update.AfterPIDWait.String(), "install", "reopen " + exe + ` --root C:\ModAVD --no-window`} + want := []string{"wait 4242 " + update.AfterPIDWait.String(), "install", "reopen " + exe + ` --root C:\ModAVD --no-window --update-handoff ` + string(cliHandoffID)} if !slices.Equal(*calls, want) { t.Fatalf("calls %v, want %v", *calls, want) } if d := envelope(t, res.stdout)["data"].(map[string]any); d["updated"] != true || d["current_version"] != "v0.40.0" { t.Errorf("data %v", d) } - got := update.TakeHandoff(r, time.Now()) + got := update.TakeHandoff(r, cliHandoffID, time.Now()) if got == nil || !got.OK || !got.Updated || got.Version != "v0.40.0" || got.Message != "已更新到 0.40.0" || got.Error != nil { t.Fatalf("handoff %+v", got) } - if update.TakeHandoff(r, time.Now()) != nil { + if update.TakeHandoff(r, cliHandoffID, time.Now()) != nil { t.Error("the result was not removed when taken") } } @@ -85,14 +152,14 @@ func TestUpdateHandoffManagerStillRunning(t *testing.T) { *calls = append(*calls, "wait") return false } - res := h.run(t, nil, "update", "install", "--after-pid", "4242", "--reopen", `C:\ModAVD\ModAVD.exe`, "--json") + res := h.run(t, nil, "update", "install", "--after-pid", "4242", "--handoff-id", string(cliHandoffID), "--reopen", `C:\ModAVD\ModAVD.exe`, "--json") if res.code != errs.ExitCode(errs.New(errs.Busy, "")) { t.Fatalf("exit %d: %s", res.code, res.stdout) } - if !slices.Equal(*calls, []string{"wait", `reopen C:\ModAVD\ModAVD.exe `}) { + if !slices.Equal(*calls, []string{"wait", `reopen C:\ModAVD\ModAVD.exe --update-handoff ` + string(cliHandoffID)}) { t.Fatalf("calls %v", *calls) } - got := update.TakeHandoff(r, time.Now()) + got := update.TakeHandoff(r, cliHandoffID, time.Now()) if got == nil || got.OK || got.Error == nil || got.Error.Code != errs.Busy || !strings.Contains(got.Message, "4242") { t.Fatalf("handoff %+v", got) } @@ -105,14 +172,14 @@ func TestUpdateHandoffInstallFailsStillReopens(t *testing.T) { return &update.Status{CurrentVersion: "v0.39.0", Notices: []string{}}, errs.New(errs.Busy, "0.40.0 已下载并核对好,但现在不能换上").WithAdvice("关掉实例再装") } - res := h.run(t, nil, "update", "install", "--after-pid", "7", "--reopen", `C:\ModAVD\ModAVD.exe`, "--json") + res := h.run(t, nil, "update", "install", "--after-pid", "7", "--handoff-id", string(cliHandoffID), "--reopen", `C:\ModAVD\ModAVD.exe`, "--json") if res.code != 5 { t.Fatalf("exit %d", res.code) } - if len(*calls) != 3 || (*calls)[2] != `reopen C:\ModAVD\ModAVD.exe ` { + if len(*calls) != 3 || (*calls)[2] != `reopen C:\ModAVD\ModAVD.exe --update-handoff `+string(cliHandoffID) { t.Fatalf("calls %v", *calls) } - got := update.TakeHandoff(r, time.Now()) + got := update.TakeHandoff(r, cliHandoffID, time.Now()) if got == nil || got.OK || got.Updated || got.Version != "v0.39.0" || got.Error.Advice != "关掉实例再装" || got.Message != "0.40.0 已下载并核对好,但现在不能换上" { t.Fatalf("handoff %+v", got) @@ -135,7 +202,7 @@ func TestUpdateInstallWithoutReopen(t *testing.T) { if !slices.Equal(*calls, []string{"install"}) { t.Fatalf("calls %v", *calls) } - if update.TakeHandoff(r, time.Now()) != nil { + if update.TakeHandoff(r, cliHandoffID, time.Now()) != nil { t.Error("a result was left without --reopen") } } diff --git a/internal/ui/API.en.md b/internal/ui/API.en.md index 3bf164c..38798be 100644 --- a/internal/ui/API.en.md +++ b/internal/ui/API.en.md @@ -82,6 +82,8 @@ HTTP 4xx / 5xx with the body `{"error":{"code":"…","message":"…","advice":" | GET | `/api/update` | | `UpdateView`, see below (does not use the network) | | POST | `/api/update/check` | `{}` | `UpdateView`: looks up the latest version from the configured update source (without a CDK); 409 `busy` while a check is in progress | | POST | `/api/update/settings` | `{source?: "github"\|"mirrorchyan", cdk?: string}` | `UpdateView`; a `cdk` of `""` clears it; the CDK is stored encrypted and never returned | +| POST | `/api/update/handoff/take` | `{id}` | 200 this handoff's `HandoffResult`, claimed once across all windows; `null` if already claimed or the ID does not match | +| POST | `/api/update/handoff/receive` | `{id}` | 200 `{received}`, used by a restarted launcher to deliver this handoff to an already running manager | | POST | `/api/update/apply` | `{}` | 202 `Job` (`kind` is `update`, a root job, `index` is `-1`): updates this root folder, see "Update" below; the result is `{handoff, version, stopped, message}` | `{i}` is the instance number (0–9, except 5). @@ -184,14 +186,14 @@ Some games require GLES 3.2 in their manifest, and the Play store judges ModAVD ### Update `UpdateView` -All the fields of `update.Status` (the same as the `data` of `modavd update --json`: `root`, `updatable`, `not_updatable_reason`, `current_version`, `current_build`, `source`, `cdk_set`, `checked`, `check_error`, `latest_version`, `latest_source`, `update_available`, `required_automas`, `release_url`, `phase`, `prepared_version`, `last_result`, `recovered`, `updated`, `message`, `notices`), plus `checking`, `checked_at` (RFC 3339, `""` if no check has been made since this start), `error` (the error object of the last check, `null` if none), `can_install` (`POST /api/update/apply` can be tried: the manager knows how to reopen itself), `install_by_cli` (the opposite of `can_install`: it can only be installed with the command line), and `handoff` (the result that the command line left after the last click on "Update" handed over to it, `{at, ok, updated, version, message, error: {code, message, advice} | null}`; `null` if this manager was not the one reopened by that run, or if the result is older than 30 minutes). +All the fields of `update.Status` (the same as the `data` of `modavd update --json`: `root`, `updatable`, `not_updatable_reason`, `current_version`, `current_build`, `source`, `cdk_set`, `checked`, `check_error`, `latest_version`, `latest_source`, `update_available`, `required_automas`, `release_url`, `phase`, `prepared_version`, `last_result`, `recovered`, `updated`, `message`, `notices`), plus `checking`, `checked_at` (RFC 3339, `""` if no check has been made since this start), `error` (the error object of the last check, `null` if none), `can_install` (`POST /api/update/apply` can be tried: the manager knows how to reopen itself), `install_by_cli` (the opposite of `can_install`: it can only be installed with the command line), and `handoff` (the result that the command line left after the last click on "Update" handed over to it, `{id, at, ok, updated, version, message, error: {code, message, advice} | null}`; `null` if no handoff ID was delivered, a window has already claimed the result, or the result is older than 30 minutes). - Only clicking "Check for updates" or "Update" uses the network; there is no automatic check. - The update source and the CDK are settings of the current system user, used by both the manager and the command line when updating; AUTO-MAS's own updater uses AUTO-MAS's settings. Windows uses `%LOCALAPPDATA%\ModAVD\update-settings.json` and DPAPI; macOS uses `~/Library/Application Support/ModAVD/update-settings.json`, with the random AES-GCM wrapping key of the CDK kept in the current user's Keychain. When the keychain is locked or access is not authorized, saving fails, no authorization prompt is shown, and no plaintext key is saved. - "Update" (`POST /api/update/apply`): the manager cannot replace itself while running, so it takes three steps. - 1. The job (`kind` is `update`, a root job) first downloads, checks against the manifest and stages the new version (same as `modavd update prepare`, with instances still running as usual); if it is already the latest, it stops there and the result has `handoff` `false`. + 1. The job (`kind` is `update`, a root job) first downloads, checks against the manifest and stages the new version (same as `modavd update prepare`, with instances still running as usual); if it is already the latest, it stops there and the result has `handoff` `false`. When Prepare returns `update_unavailable`, it continues offline only if the stage fingerprint and version records still match and the staged version is newer than the known installed version. Cancellation, a damaged stage, and other errors still end the job. 2. It first checks for situations that would make the package swap fail, and if there is one, no instance is stopped and the job fails: another process is running this root folder's `ModAVD.exe` or `tools\modavd.exe` (for example `modavd mcp`); an instance is being created; the staged version is not newer than the installed one (which counts as already the latest). Then it shuts down all running instances normally (same as `stop --all`, but without canceling an instance being created). - 3. It uses `host.StartOrphan` to start the root folder's `tools\modavd.exe --root --json update install --after-pid --reopen --reopen-arg …` (output is appended to `logs\modavd-update.log`). The job counts as successful only if it is still running half a second later, with the result `{handoff: true, version, stopped, message}`; from then on every other job gets 409, and the manager exits after about 1.5 seconds. The command line waits for the manager to exit, swaps in the new version, writes the result to `.update\manager-result.json`, and reopens the manager with the original arguments (`--root`, plus `--port`, `--no-window` and `--dev`); the new manager reads that result at startup and puts it into `handoff`. If the swap cannot be done (for example a program is holding a file), the manager is reopened all the same, and the staged new version is kept so it need not be downloaded again next time. + 3. It uses `host.StartOrphan` to start the root folder's `tools\modavd.exe --root --json update install --after-pid --reopen --reopen-arg …` (output is appended to `logs\modavd-update.log`). The job counts as successful only if it is still running half a second later, with the result `{handoff: true, version, stopped, message}`; from then on every other job gets 409, and the manager exits after about 1.5 seconds. The command line waits for the manager to exit, swaps in the new version, writes the result and this run's random ID to `.update\manager-results\.json`, and reopens the manager with the original arguments (`--root`, plus `--port`, `--no-window` and `--dev`); the command line passes the ID with `--update-handoff `, and the new manager reads only the matching result into `handoff`. If a manager is already running, the restart delivers this ID through `POST /api/update/handoff/receive` before opening the existing window. A page first claims the result with `POST /api/update/handoff/take`. Only the page that receives it shows a notification, and the server clears the claimed result. Ordinary launches ignore legacy result files without an ID. If the swap cannot be done (for example a program is holding a file), the manager is reopened all the same, and the staged new version is kept so it need not be downloaded again next time. - When the manager installs, it does not do the AUTO-MAS version check (the same as installing by hand with the command line); when `required_automas` is not empty, the page prompts the user to update AUTO-MAS first with AUTO-MAS. - Refused before starting, with no job (422): not Windows, or the manager does not know how to reopen itself (`unsupported`); the root folder has no `tools\modavd.exe`, or its version differs from the manager's (`not_ready`; an older command line does not recognize these options). When another job is running, 409 `busy`; while an update job is running, other jobs also get 409. - Native macOS update is done with the CLI's `update prepare/install --package ZIP --sha256 HEX`, selecting a local signed package; after staging, `install/recover` can run offline. Without a compatible `macos-arm64` remote asset, the check result is `update_unavailable`. The UI offers no install entry; for the package protocol see `internal/update/MACOS.md`. diff --git a/internal/ui/API.md b/internal/ui/API.md index d37f6d5..7054b07 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -82,6 +82,8 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | GET | `/api/update` | | `UpdateView`,见下(不联网) | | POST | `/api/update/check` | `{}` | `UpdateView`:按设置的更新源查最新版本(不带 CDK);检查进行中回 409 `busy` | | POST | `/api/update/settings` | `{source?: "github"\|"mirrorchyan", cdk?: string}` | `UpdateView`;`cdk` 为 `""` 时清除,CDK 加密保存,从不返回 | +| POST | `/api/update/handoff/take` | `{id}` | 200 本次交接的 `HandoffResult`,全管理器只领取一次;已领走或 ID 不匹配是 `null` | +| POST | `/api/update/handoff/receive` | `{id}` | 200 `{received}`,供重开启动把本次交接结果转交给已运行的管理器 | | POST | `/api/update/apply` | `{}` | 202 `Job`(`kind` 是 `update`,根任务,`index` 为 `-1`):更新这个根目录,见下「更新」;结果是 `{handoff, version, stopped, message}` | `{i}` 是实例编号(0–9,5 除外)。 @@ -184,14 +186,14 @@ Apple Silicon macOS 的组件下载/导入使用 API35 Google Play ARM64 r9, ### 更新 `UpdateView` -`update.Status` 的全部字段(和 `modavd update --json` 的 `data` 相同:`root`、`updatable`、`not_updatable_reason`、`current_version`、`current_build`、`source`、`cdk_set`、`checked`、`check_error`、`latest_version`、`latest_source`、`update_available`、`required_automas`、`release_url`、`phase`、`prepared_version`、`last_result`、`recovered`、`updated`、`message`、`notices`),加上 `checking`、`checked_at`(RFC 3339,本次启动后没查过是 `""`)、`error`(上次检查的错误对象,没有是 `null`)、`can_install`(可以试 `POST /api/update/apply`:管理器知道怎么重新打开自己)、`install_by_cli`(`can_install` 的反面:只能用命令行装)、`handoff`(上次点「更新」交给命令行以后命令行留下的结果 `{at, ok, updated, version, message, error: {code, message, advice} | null}`;这个管理器不是那次重新打开的、或者结果超过 30 分钟,是 `null`)。 +`update.Status` 的全部字段(和 `modavd update --json` 的 `data` 相同:`root`、`updatable`、`not_updatable_reason`、`current_version`、`current_build`、`source`、`cdk_set`、`checked`、`check_error`、`latest_version`、`latest_source`、`update_available`、`required_automas`、`release_url`、`phase`、`prepared_version`、`last_result`、`recovered`、`updated`、`message`、`notices`),加上 `checking`、`checked_at`(RFC 3339,本次启动后没查过是 `""`)、`error`(上次检查的错误对象,没有是 `null`)、`can_install`(可以试 `POST /api/update/apply`:管理器知道怎么重新打开自己)、`install_by_cli`(`can_install` 的反面:只能用命令行装)、`handoff`(上次点「更新」交给命令行以后命令行留下的结果 `{id, at, ok, updated, version, message, error: {code, message, advice} | null}`;没有收到本次交接 ID、结果已被任一窗口领取、或者结果超过 30 分钟,是 `null`)。 - 只有点「检查更新」或「更新」才联网;没有自动检查。 - 更新源和 CDK 是当前系统用户的设置,管理器和命令行更新时都按它来;AUTO-MAS 自己的更新器用 AUTO-MAS 的设置。Windows 使用 `%LOCALAPPDATA%\ModAVD\update-settings.json` 和 DPAPI;macOS 使用 `~/Library/Application Support/ModAVD/update-settings.json`,CDK 的随机 AES-GCM 包装密钥保存在当前用户的 Keychain。钥匙串锁定或未授权时保存失败,不弹授权框,不保存明文密钥。 - 「更新」(`POST /api/update/apply`):管理器不能在自己运行时换掉自己,所以分三步。 - 1. 任务(`kind` 是 `update`,根任务)先下载、按清单核对、暂存好新版(同 `modavd update prepare`,实例照常开着);已经是最新就到此为止,结果 `handoff` 为 `false`。 + 1. 任务(`kind` 是 `update`,根任务)先下载、按清单核对、暂存好新版(同 `modavd update prepare`,实例照常开着);已经是最新就到此为止,结果 `handoff` 为 `false`。Prepare 回 `update_unavailable` 时,只有暂存指纹和版本记录仍相符、且暂存版本比已安装的已知版本新,才离线继续;取消、损坏暂存和其他错误仍终止任务。 2. 先查会让换包失败的情况,有就一台实例都不关、任务失败:别的进程在跑这个根目录的 `ModAVD.exe` 或 `tools\modavd.exe`(比如 `modavd mcp`);有实例正在新建;暂存的版本不比装着的新(这时算已经是最新)。然后正常关掉所有在运行的实例(同 `stop --all`,但不取消正在新建的)。 - 3. 用 `host.StartOrphan` 启动根目录的 `tools\modavd.exe --root <根> --json update install --after-pid <管理器 pid> --reopen <管理器程序> --reopen-arg …`(输出追加到 `logs\modavd-update.log`)。半秒后它还在跑,任务才算成功,结果 `{handoff: true, version, stopped, message}`;从这时起别的任务一律回 409,约 1.5 秒后管理器退出。命令行等管理器退出,换上新版,把结果写进 `.update\manager-result.json`,再按原来的参数(`--root`,以及 `--port`、`--no-window`、`--dev`)重新打开管理器;新管理器启动时读走这份结果,放进 `handoff`。换不上(有程序占着等)也照样重新打开,暂存的新版留着,下次不用重下。 + 3. 用 `host.StartOrphan` 启动根目录的 `tools\modavd.exe --root <根> --json update install --after-pid <管理器 pid> --reopen <管理器程序> --reopen-arg …`(输出追加到 `logs\modavd-update.log`)。半秒后它还在跑,任务才算成功,结果 `{handoff: true, version, stopped, message}`;从这时起别的任务一律回 409,约 1.5 秒后管理器退出。命令行等管理器退出,换上新版,把结果和本次随机 ID 写进 `.update\manager-results\.json`,再按原来的参数(`--root`,以及 `--port`、`--no-window`、`--dev`)重新打开管理器;命令行通过 `--update-handoff ` 传递本次 ID,新管理器只读走对应的结果,放进 `handoff`。如果已有管理器在运行,这次启动通过 `POST /api/update/handoff/receive` 转交本次 ID,再打开已有窗口。页面先用 `POST /api/update/handoff/take` 领取结果,只有领到的窗口提示;已领取的结果从服务端清掉。普通启动不读取无 ID 的旧版结果文件。换不上(有程序占着等)也照样重新打开,暂存的新版留着,下次不用重下。 - 管理器装的时候不带 AUTO-MAS 的版本检查(和人用命令行装一样),页面在 `required_automas` 不为空时提示用 AUTO-MAS 的先更新 AUTO-MAS。 - 开始前就拒绝、不开任务(422):不是 Windows,或者管理器不知道怎么重新打开自己(`unsupported`);根目录没有 `tools\modavd.exe`,或者它的版本和管理器不一样(`not_ready`,旧版命令行不认这些选项)。有别的任务在跑时回 409 `busy`;更新任务在跑时,别的任务也回 409。 - macOS 原生更新由 CLI 的 `update prepare/install --package ZIP --sha256 HEX` 选择本地签名包;暂存后可离线 `install/recover`。没有兼容的 `macos-arm64` 远端资产时,检查结果为 `update_unavailable`。界面不提供安装入口;包协议见 `internal/update/MACOS.md`。 diff --git a/internal/ui/http.go b/internal/ui/http.go index 16dc53d..fa415e1 100644 --- a/internal/ui/http.go +++ b/internal/ui/http.go @@ -70,6 +70,8 @@ func (s *server) routes() { m.HandleFunc("POST /api/update/check", s.handleUpdateCheck) m.HandleFunc("POST /api/update/settings", s.handleUpdateSettings) m.HandleFunc("POST /api/update/apply", s.handleUpdateApply) + m.HandleFunc("POST /api/update/handoff/take", s.handleUpdateHandoffTake) + m.HandleFunc("POST /api/update/handoff/receive", s.handleUpdateHandoffReceive) s.mux = m } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 396ccf2..b12ecee 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -59,6 +59,8 @@ type Options struct { // Reopen is how the CLI starts this manager again after 设置 → 更新 swapped the new version in (update_apply.go); // nil: the manager cannot update itself (更新 is refused, checking still works). Reopen *Reopen + // HandoffID is set only by the CLI's restart after one update. + HandoffID update.HandoffID // Tray puts the manager's icon in the notification area and registers the instances' boss keys (bosskey.go; // Windows only, ignored elsewhere). The manager then stays after its window closed, in the tray, until 退出 in the // icon's menu (or the process is told to end). @@ -238,7 +240,7 @@ func newServer(opt Options) (*server, error) { if opt.proxyEvery > 0 { s.proxyEvery = opt.proxyEvery } - s.upd = &updatePanel{handoff: update.TakeHandoff(opt.Root, time.Now())} + s.upd = &updatePanel{handoff: update.TakeHandoff(opt.Root, opt.HandoffID, time.Now())} if h := s.upd.handoff; h != nil { lg.Infof("上次在设置里点的更新:%s", h.Message) } @@ -251,7 +253,7 @@ func newServer(opt Options) (*server, error) { if s.prepareUpdate == nil { r := opt.Root s.prepareUpdate = func(ctx context.Context, l *logx.Logger, progress func(update.Progress)) (*update.Status, error) { - return update.New(r, l, update.Options{Progress: progress}).Prepare(ctx) + return update.New(r, l, update.Options{Progress: progress}).PrepareHandoff(ctx) } } if s.cliVersion == nil { diff --git a/internal/ui/update.go b/internal/ui/update.go index 3a63159..ad9c499 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -27,7 +27,7 @@ type updateView struct { InstallByCLI bool `json:"install_by_cli"` // 更新 is not offered here (CanInstall false): only the CLI installs CanInstall bool `json:"can_install"` // POST /api/update/apply may be tried // Handoff is what the CLI reported after the last 更新 handed to it (the swap and the manager's restart); null when - // this manager was not started by such a hand-off, or the report is older than update.HandoffFresh. + // no matching hand-off was delivered, or a window has already claimed the notification. Handoff *update.HandoffResult `json:"handoff"` } @@ -37,7 +37,7 @@ type updatePanel struct { checked *update.Status // the last successful check at time.Time err *errorBody - handoff *update.HandoffResult // taken from .update when the manager started + handoff *update.HandoffResult // matching result received at startup or from a later CLI restart } func (p *updatePanel) busy() bool { @@ -81,6 +81,64 @@ func (s *server) handleUpdateGet(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.updateView()) } +// The page claims a notification before showing it. Every window shares this claim, so opening another window +// or reconnecting an event stream cannot show the same result again. +func (s *server) handleUpdateHandoffTake(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHandoffID(w, r) + if !ok { + return + } + p := s.upd + p.mu.Lock() + var res *update.HandoffResult + if p.handoff != nil && p.handoff.ID == id { + if p.handoff.Fresh(time.Now()) { + res = p.handoff + } + p.handoff = nil + } + p.mu.Unlock() + s.hub.broadcast("update", s.updateView()) + writeJSON(w, http.StatusOK, res) +} + +// A CLI restart can encounter an already running manager. That launch forwards only its own ID here, before +// opening the existing window. The ordinary launcher never asks this endpoint to read a file. +func (s *server) handleUpdateHandoffReceive(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHandoffID(w, r) + if !ok { + return + } + p := s.upd + p.mu.Lock() + res := update.TakeHandoff(s.root, id, time.Now()) + if res != nil { + p.handoff = res + } + p.mu.Unlock() + if res != nil { + s.hub.broadcast("update", s.updateView()) + } + writeJSON(w, http.StatusOK, struct { + Received bool `json:"received"` + }{res != nil}) +} + +func decodeHandoffID(w http.ResponseWriter, r *http.Request) (update.HandoffID, bool) { + var body struct { + ID string `json:"id"` + } + if !decode(w, r, &body) { + return "", false + } + id, err := update.ParseHandoffID(body.ID) + if err != nil { + writeError(w, err) + return "", false + } + return id, true +} + func (s *server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) { if !decode(w, r, &struct{}{}) { return diff --git a/internal/ui/update_apply.go b/internal/ui/update_apply.go index 0daf882..a137144 100644 --- a/internal/ui/update_apply.go +++ b/internal/ui/update_apply.go @@ -234,8 +234,12 @@ func (s *server) stopForUpdate(ctx context.Context, j *job, b backend, version s // handOff starts the CLI that swaps once we are gone and starts us again; it is not our descendant (host.StartOrphan), // so nothing that ends this process can end it. alive tells whether it still runs. func (s *server) handOff(lg *logx.Logger) (alive func() bool, err error) { + id, err := update.NewHandoffID() + if err != nil { + return nil, err + } args := []string{"--root", s.root.Dir, "--json", "update", "install", - "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", s.reopen.Exe} + "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", s.reopen.Exe, "--handoff-id", string(id)} for _, a := range s.reopen.Args { args = append(args, "--reopen-arg", a) } diff --git a/internal/ui/update_apply_test.go b/internal/ui/update_apply_test.go index 30e5988..ed1d307 100644 --- a/internal/ui/update_apply_test.go +++ b/internal/ui/update_apply_test.go @@ -156,7 +156,7 @@ func TestUpdateApplyHandsOff(t *testing.T) { } root := h.s.root.Dir want := []string{filepath.Join(root, "tools", "modavd.exe"), "--root", root, "--json", "update", "install", - "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", reopenExe, + "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", reopenExe, "--handoff-id", sp[0][11], "--reopen-arg", "--root", "--reopen-arg", root, "--reopen-arg", "--no-window", filepath.Join(root, "logs", updateLogName)} if !slices.Equal(sp[0], want) { @@ -324,7 +324,8 @@ func TestUpdateApplyRunsAlone(t *testing.T) { func TestUpdateHandoffShownAfterRestart(t *testing.T) { t.Setenv("LOCALAPPDATA", testutil.TempDir(t)) h := newHarness(t, func(o *Options) { - res := update.NewHandoffResult(&update.Status{Updated: true, CurrentVersion: "v0.40.0", Message: "已更新到 0.40.0"}, nil, time.Now()) + o.HandoffID = uiHandoffID + res := update.NewHandoffResult(uiHandoffID, &update.Status{Updated: true, CurrentVersion: "v0.40.0", Message: "已更新到 0.40.0"}, nil, time.Now()) if err := update.SaveHandoff(o.Root, res); err != nil { t.Fatal(err) } @@ -335,7 +336,7 @@ func TestUpdateHandoffShownAfterRestart(t *testing.T) { if hv == nil || hv["ok"] != true || hv["message"] != "已更新到 0.40.0" || hv["version"] != "v0.40.0" { t.Fatalf("handoff %v", v["handoff"]) } - if update.TakeHandoff(h.s.root, time.Now()) != nil { + if update.TakeHandoff(h.s.root, uiHandoffID, time.Now()) != nil { t.Error("the file was left for the next start") } } diff --git a/internal/ui/update_handoff_test.go b/internal/ui/update_handoff_test.go new file mode 100644 index 0000000..1f83ce2 --- /dev/null +++ b/internal/ui/update_handoff_test.go @@ -0,0 +1,144 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "modavd/internal/errs" + "modavd/internal/update" +) + +const uiHandoffID update.HandoffID = "0123456789abcdef0123456789abcdef" + +func TestUnrelatedManagerDoesNotTakeHandoff(t *testing.T) { + h := newHarness(t, func(o *Options) { + if err := update.SaveHandoff(o.Root, update.NewHandoffResult(uiHandoffID, nil, nil, time.Now())); err != nil { + t.Fatal(err) + } + }) + var v map[string]any + expect(t, h.do("GET", "/api/update", nil), http.StatusOK, &v) + if v["handoff"] != nil { + t.Fatalf("unrelated startup exposed an old handoff: %v", v["handoff"]) + } + if got := update.TakeHandoff(h.s.root, uiHandoffID, time.Now()); got == nil { + t.Fatal("unrelated startup consumed the owned result") + } +} + +func handoffHarness(t *testing.T) *harness { + t.Helper() + return newHarness(t, func(o *Options) { + o.HandoffID = uiHandoffID + if err := update.SaveHandoff(o.Root, update.NewHandoffResult(uiHandoffID, + &update.Status{Updated: true, CurrentVersion: "v0.40.0"}, nil, time.Now())); err != nil { + t.Fatal(err) + } + }) +} + +func TestUpdateHandoffClaimedOnceAcrossWindows(t *testing.T) { + h := handoffHarness(t) + // Wrong IDs cannot claim or clear the pending result. + other := strings.Repeat("a", 32) + expect(t, h.do("POST", "/api/update/handoff/take", `{"id":"`+other+`"}`), http.StatusOK, nil) + var count atomic.Int32 + var wg sync.WaitGroup + for range 12 { + wg.Go(func() { + resp := h.do("POST", "/api/update/handoff/take", `{"id":"`+string(uiHandoffID)+`"}`) + defer resp.Body.Close() + var result *update.HandoffResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || resp.StatusCode != http.StatusOK { + t.Errorf("claim: HTTP %d, %v", resp.StatusCode, err) + } else if result != nil { + if result.ID != uiHandoffID || !result.OK { + t.Errorf("wrong result: %+v", result) + } + count.Add(1) + } + }) + } + wg.Wait() + if count.Load() != 1 { + t.Fatalf("%d windows received the notification", count.Load()) + } + var v map[string]any + expect(t, h.do("GET", "/api/update", nil), http.StatusOK, &v) + if v["handoff"] != nil { + t.Fatal("reopened window can still see the notification") + } +} + +func TestUpdateHandoffDeliveredToRunningManager(t *testing.T) { + h := newHarness(t, nil) + other := update.HandoffID(strings.Repeat("a", 32)) + for _, id := range []update.HandoffID{uiHandoffID, other} { + if err := update.SaveHandoff(h.s.root, update.NewHandoffResult(id, nil, + errs.New(errs.Busy, "管理器没有退出"), time.Now())); err != nil { + t.Fatal(err) + } + } + var received map[string]any + expect(t, h.do("POST", "/api/update/handoff/receive", `{"id":"`+string(uiHandoffID)+`"}`), http.StatusOK, &received) + if received["received"] != true { + t.Fatalf("not received: %v", received) + } + // Retrying delivery cannot publish it again after a page claimed it. + var result *update.HandoffResult + expect(t, h.do("POST", "/api/update/handoff/take", `{"id":"`+string(uiHandoffID)+`"}`), http.StatusOK, &result) + if result == nil || result.Error == nil || result.Error.Code != errs.Busy { + t.Fatalf("wrong result: %+v", result) + } + expect(t, h.do("POST", "/api/update/handoff/receive", `{"id":"`+string(uiHandoffID)+`"}`), http.StatusOK, &received) + if received["received"] != false { + t.Fatalf("redelivered: %v", received) + } + if update.TakeHandoff(h.s.root, other, time.Now()) == nil { + t.Fatal("delivery consumed another handoff") + } +} + +func TestUpdateHandoffClaimAuthAndExpiry(t *testing.T) { + h := handoffHarness(t) + path := "/api/update/handoff/take" + body := `{"id":"` + string(uiHandoffID) + `"}` + expectError(t, h.do("POST", path, body, noCookie), http.StatusUnauthorized, "unauthorized") + expectError(t, h.do("POST", path, body, withHeader("Origin", "http://elsewhere.invalid")), http.StatusForbidden, "forbidden") + expectError(t, h.do("POST", path, `{"id":"../bad"}`), http.StatusBadRequest, errs.Usage) + h.s.upd.mu.Lock() + h.s.upd.handoff.At = time.Now().Add(-update.HandoffFresh - time.Second).Format(time.RFC3339) + h.s.upd.mu.Unlock() + var result *update.HandoffResult + expect(t, h.do("POST", path, body), http.StatusOK, &result) + if result != nil { + t.Fatalf("expired result: %+v", result) + } +} + +func TestLegacyHandoffIsIgnored(t *testing.T) { + h := newHarness(t, func(o *Options) { + if err := os.MkdirAll(filepath.Join(o.Root.Dir, ".update"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(o.Root.Dir, ".update", "manager-result.json"), + []byte(`{"at":"`+time.Now().Format(time.RFC3339)+`","ok":true}`), 0o644); err != nil { + t.Fatal(err) + } + }) + if h.s.upd.handoff != nil { + t.Fatal("legacy unowned result became a notification") + } +} diff --git a/internal/ui/web/js/update.js b/internal/ui/web/js/update.js index 6252043..e4885f5 100644 --- a/internal/ui/web/js/update.js +++ b/internal/ui/web/js/update.js @@ -29,13 +29,19 @@ export function onUpdate(v) { update(); } -// What the CLI did after the last 更新 (this manager is the one it started again): told once per page. -let handoffShown = ''; -function showHandoff(x) { - if (!x || handoffShown === x.at) return; - handoffShown = x.at; - if (x.ok) toast({ title: x.updated ? `已更新到 ${ver(x.version)}` : (x.message || '已经是最新版本'), timeout: 9000 }); - else toast({ tone: 'err', title: '新版没有换上', text: (x.error && x.error.message) || x.message, advice: x.error && x.error.advice, timeout: 15000 }); +// The server grants the notification to one page across all manager windows. +const handoffClaims = new Set(); +async function showHandoff(x) { + if (!x || !x.id || handoffClaims.has(x.id)) return; + handoffClaims.add(x.id); + try { + const claimed = await api.post('/api/update/handoff/take', { id: x.id }); + if (!claimed) return; + if (claimed.ok) toast({ title: claimed.updated ? `已更新到 ${ver(claimed.version)}` : (claimed.message || '已经是最新版本'), timeout: 9000 }); + else toast({ tone: 'err', title: '新版没有换上', text: (claimed.error && claimed.error.message) || claimed.message, advice: claimed.error && claimed.error.advice, timeout: 15000 }); + } catch { + handoffClaims.delete(x.id); + } } // 更新 may be clicked: this manager can come back after the swap, the root can be updated, a check found a newer diff --git a/internal/update/handoff.go b/internal/update/handoff.go index 5b3f839..368ec04 100644 --- a/internal/update/handoff.go +++ b/internal/update/handoff.go @@ -5,10 +5,13 @@ package update // // tools\modavd.exe update install --after-pid --reopen [--reopen-arg A ...] // -// and exits. The CLI waits for it to be gone, installs, writes what happened to .update\manager-result.json and -// starts the manager again; the manager takes the file on start and shows the result. +// and exits. Each hand-off has its own ID and result under .update\manager-results\. The CLI passes that ID to +// the manager it starts again; only that launch takes the result. import ( + "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "os" @@ -20,15 +23,80 @@ import ( ) const ( - handoffName = "manager-result.json" + handoffDir = "manager-results" + // HandoffArg is the manager's launch option for the result of one update. + HandoffArg = "--update-handoff" // HandoffFresh: a result older than this is not shown (a manager started long after a failed hand-off). HandoffFresh = 30 * time.Minute // AfterPIDWait is how long the CLI waits for the manager it is to replace to exit. AfterPIDWait = time.Minute ) +// HandoffID identifies one update hand-off, independently of its version or timestamp. +type HandoffID string + +func NewHandoffID() (HandoffID, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return HandoffID(hex.EncodeToString(b)), nil +} + +// ParseHandoffID accepts only the generated form, which is also safe as a filename. +func ParseHandoffID(s string) (HandoffID, error) { + if len(s) == 32 { + valid := true + for _, c := range s { + valid = valid && (c >= '0' && c <= '9' || c >= 'a' && c <= 'f') + } + if valid { + return HandoffID(s), nil + } + } + return "", errs.New(errs.Usage, "更新交接 ID 必须是 32 位小写十六进制字符") +} + +// PrepareHandoff prepares a manager update. If the source is unavailable, it can reuse an earlier stage, but +// only after checking its fingerprint under the update lock and comparing it to the installed version. +func (u *Updater) PrepareHandoff(ctx context.Context) (*Status, error) { + st, err := u.Prepare(ctx) + if errs.CodeOf(err) != errs.UpdateUnavailable { + return st, err + } + if ctx.Err() != nil { + return st, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + } + lock, lerr := u.begin(st) + if lerr != nil { + return st, lerr + } + defer lock.Unlock() + out := u.finish(st) + if !out.Updatable || out.Phase != phaseStaged { + return out, err + } + if verr := u.l.checkStaged(u.l.readState()); verr != nil { + return u.finish(st), verr + } + staged, haveStage := ParseVersion(out.PreparedVersion) + local, haveLocal := ParseVersion(out.CurrentVersion) + if !haveStage || !haveLocal || !local.Less(staged) { + return out, err + } + if ctx.Err() != nil { + return out, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + } + note := "查不了最新版本,使用已下载并核对好的 " + staged.Display() + out.CheckError, out.Message = errs.As(err).Message, note + out.Notices = append(out.Notices, note) + u.Log.Warnf("%s", note) + return out, nil +} + // HandoffResult is what the CLI tells the manager it starts again. type HandoffResult struct { + ID HandoffID `json:"id"` At string `json:"at"` // RFC 3339 OK bool `json:"ok"` Updated bool `json:"updated"` // a new version was swapped in @@ -44,11 +112,13 @@ type HandoffError struct { Advice string `json:"advice"` } -func handoffPath(r *root.Root) string { return filepath.Join(layout{root: r.Dir}.dir(), handoffName) } +func handoffPath(r *root.Root, id HandoffID) string { + return filepath.Join(layout{root: r.Dir}.dir(), handoffDir, string(id)+".json") +} // NewHandoffResult is the result of an install: st as Install returned it, err its error. -func NewHandoffResult(st *Status, err error, now time.Time) *HandoffResult { - res := &HandoffResult{At: now.Format(time.RFC3339), OK: err == nil} +func NewHandoffResult(id HandoffID, st *Status, err error, now time.Time) *HandoffResult { + res := &HandoffResult{ID: id, At: now.Format(time.RFC3339), OK: err == nil} if st != nil { res.Updated, res.Version, res.Message = st.Updated, st.CurrentVersion, st.Message } @@ -60,17 +130,24 @@ func NewHandoffResult(st *Status, err error, now time.Time) *HandoffResult { return res } -// SaveHandoff writes res for the manager the CLI starts next (atomically, replacing an older one). +// SaveHandoff atomically writes this hand-off's result without replacing another hand-off's file. func SaveHandoff(r *root.Root, res *HandoffResult) error { + if res == nil { + return errs.New(errs.Usage, "没有更新交接结果") + } + if _, err := ParseHandoffID(string(res.ID)); err != nil { + return err + } data, err := json.MarshalIndent(res, "", " ") if err != nil { return err } - path := handoffPath(r) + path := handoffPath(r, res.ID) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".tmp" + defer os.Remove(tmp) if err := writeFileSync(tmp, data); err != nil { return err } @@ -94,22 +171,38 @@ func (u *Updater) ProgramsInUse() ([]string, error) { return out, nil } -// TakeHandoff reads and removes the CLI's result; nil when there is none, it cannot be read, or it is older than -// HandoffFresh (then it is removed too). -func TakeHandoff(r *root.Root, now time.Time) *HandoffResult { - path := handoffPath(r) - data, err := os.ReadFile(path) +// TakeHandoff claims only id's result, once. Missing IDs never read a result. A stale, broken or mismatched owned +// result is discarded. An exclusive claim file prevents simultaneous consumers from taking the same result. +func TakeHandoff(r *root.Root, id HandoffID, now time.Time) *HandoffResult { + if _, err := ParseHandoffID(string(id)); err != nil { + return nil + } + path := handoffPath(r, id) + claimPath := path + ".claim" + claim, err := os.OpenFile(claimPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return nil } - _ = os.Remove(path) + claim.Close() + defer os.Remove(claimPath) + data, err := root.ReadFileShared(path) + if err != nil { + return nil + } + if err := os.Remove(path); err != nil { + return nil + } var res HandoffResult - if json.Unmarshal(data, &res) != nil { + if json.Unmarshal(data, &res) != nil || res.ID != id { return nil } - at, err := time.Parse(time.RFC3339, res.At) - if err != nil || now.Sub(at) > HandoffFresh || at.Sub(now) > time.Minute { + if !res.Fresh(now) { return nil } return &res } + +func (res *HandoffResult) Fresh(now time.Time) bool { + at, err := time.Parse(time.RFC3339, res.At) + return err == nil && now.Sub(at) <= HandoffFresh && at.Sub(now) <= time.Minute +} diff --git a/internal/update/handoff_offline_test.go b/internal/update/handoff_offline_test.go new file mode 100644 index 0000000..a8ea8b9 --- /dev/null +++ b/internal/update/handoff_offline_test.go @@ -0,0 +1,127 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "modavd/internal/errs" +) + +func TestPrepareHandoffOffline(t *testing.T) { + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, nil) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, nil), false)) + u := f.updater(r) + if _, err := u.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + f.srv.Close() + st, err := u.PrepareHandoff(context.Background()) + if err != nil || st.PreparedVersion != "v0.37.0" { + t.Fatalf("valid newer stage must remain usable offline: %+v, %v", st, err) + } + if st.CurrentVersion != "v0.36.0" || st.CheckError == "" || len(st.Notices) != 1 || st.Updated { + t.Fatalf("offline preparation lost its status: %+v", st) + } + // Retry uses the same verified stage; install still rechecks it and actually swaps the package offline. + if _, err := u.PrepareHandoff(context.Background()); err != nil { + t.Fatal(err) + } + st, err = u.Install(context.Background()) + if err != nil || !st.Updated || st.CurrentVersion != "v0.37.0" || st.Phase != phaseIdle { + t.Fatalf("offline install: %+v, %v", st, err) + } +} + +func TestPrepareHandoffOfflineRefusals(t *testing.T) { + for _, tc := range []struct { + name string + edit func(t *testing.T, u *Updater) + code string + }{ + {"no stage", func(t *testing.T, u *Updater) { + if err := u.l.abortStaging("test"); err != nil { + t.Fatal(err) + } + }, errs.UpdateUnavailable}, + {"changed file", func(t *testing.T, u *Updater) { + if err := os.WriteFile(filepath.Join(u.l.stageEmulator(), "emulator.exe"), []byte("changed"), 0o644); err != nil { + t.Fatal(err) + } + }, errs.UpdateRejected}, + {"missing marker", func(t *testing.T, u *Updater) { + if err := os.Remove(u.l.stagedMarker()); err != nil { + t.Fatal(err) + } + }, errs.UpdateRejected}, + {"equal version", func(t *testing.T, u *Updater) { setInstalledHandoffVersion(t, u, "v0.37.0") }, errs.UpdateUnavailable}, + {"older stage", func(t *testing.T, u *Updater) { setInstalledHandoffVersion(t, u, "v0.38.0") }, errs.UpdateUnavailable}, + {"unknown installed version", func(t *testing.T, u *Updater) { + if err := os.Remove(filepath.Join(u.Root.Dir, manifestName)); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(u.l.emulator(), "source.properties"), []byte("Pkg.BuildId=mas-999\n"), 0o644); err != nil { + t.Fatal(err) + } + }, errs.UpdateUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + u := newSources(t, 37, zipOf(t, 37, pkgFiles(37, nil), false)).updater(writeRoot(t, 36, nil)) + if _, err := u.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + tc.edit(t, u) + u.githubAPI += "/offline" // local fixture answers 404; no real source is contacted + st, err := u.PrepareHandoff(context.Background()) + if errs.CodeOf(err) != tc.code || st.Updated { + t.Fatalf("status %+v, error %v, want %s", st, err, tc.code) + } + if tc.code == errs.UpdateRejected && st.Phase != phaseIdle { + t.Fatalf("bad stage was retained: %+v", st) + } + }) + } +} + +func setInstalledHandoffVersion(t *testing.T, u *Updater, version string) { + t.Helper() + data := strings.ReplaceAll(string(manifestFor(36, pkgFiles(36, nil))), "v0.36.0", version) + if err := os.WriteFile(filepath.Join(u.Root.Dir, manifestName), []byte(data), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestPrepareHandoffKeepsCancellationAndOtherErrors(t *testing.T) { + withSettings(t, SourceGitHub, "") + u := newSources(t, 37, zipOf(t, 37, pkgFiles(37, nil), false)).updater(writeRoot(t, 36, nil)) + if _, err := u.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := u.PrepareHandoff(ctx); errs.CodeOf(err) != errs.Canceled { + t.Fatalf("canceled: %v", err) + } + u.Opt.AutoMASVersion = "1.0.0" + if _, err := u.PrepareHandoff(context.Background()); errs.CodeOf(err) != errs.NotReady { + t.Fatalf("version requirement: %v", err) + } + u.Opt.AutoMASVersion = "" + lock, err := u.lock() + if err != nil { + t.Fatal(err) + } + defer lock.Unlock() + if _, err := u.PrepareHandoff(context.Background()); errs.CodeOf(err) != errs.Busy { + t.Fatalf("update lock: %v", err) + } +} diff --git a/internal/update/handoff_test.go b/internal/update/handoff_test.go index e38e6d9..2d304b2 100644 --- a/internal/update/handoff_test.go +++ b/internal/update/handoff_test.go @@ -1,7 +1,11 @@ package update import ( + "encoding/json" "os" + "path/filepath" + "sync" + "sync/atomic" "testing" "time" @@ -9,35 +13,96 @@ import ( "modavd/internal/root" ) +const testHandoffID HandoffID = "0123456789abcdef0123456789abcdef" + func TestHandoffRoundTrip(t *testing.T) { r := &root.Root{Dir: t.TempDir()} now := time.Date(2026, 11, 9, 20, 0, 0, 0, time.Local) - if TakeHandoff(r, now) != nil { + if TakeHandoff(r, testHandoffID, now) != nil { t.Fatal("a result without a file") } st := &Status{Updated: true, CurrentVersion: "v0.40.0", Message: "已更新到 0.40.0"} - if err := SaveHandoff(r, NewHandoffResult(st, nil, now)); err != nil { + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, st, nil, now)); err != nil { t.Fatal(err) } - got := TakeHandoff(r, now.Add(time.Minute)) + got := TakeHandoff(r, testHandoffID, now.Add(time.Minute)) if got == nil || !got.OK || !got.Updated || got.Version != "v0.40.0" || got.Message != "已更新到 0.40.0" || got.Error != nil { t.Fatalf("%+v", got) } - if _, err := os.Stat(handoffPath(r)); !os.IsNotExist(err) { + if _, err := os.Stat(handoffPath(r, testHandoffID)); !os.IsNotExist(err) { t.Errorf("the file is still there: %v", err) } err := errs.New(errs.Busy, "现在不能换上").WithAdvice("先关实例") - if err := SaveHandoff(r, NewHandoffResult(nil, err, now)); err != nil { + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, nil, err, now)); err != nil { t.Fatal(err) } - got = TakeHandoff(r, now) + got = TakeHandoff(r, testHandoffID, now) if got == nil || got.OK || got.Updated || got.Message != "现在不能换上" || got.Error == nil || got.Error.Code != errs.Busy || got.Error.Advice != "先关实例" { t.Fatalf("%+v", got) } } +func TestHandoffOwnershipAndConcurrentTake(t *testing.T) { + r := &root.Root{Dir: t.TempDir()} + now := time.Now() + other, err := NewHandoffID() + if err != nil || other == testHandoffID { + t.Fatalf("new ID %q: %v", other, err) + } + for _, id := range []HandoffID{testHandoffID, other} { + if err := SaveHandoff(r, NewHandoffResult(id, nil, nil, now)); err != nil { + t.Fatal(err) + } + } + if TakeHandoff(r, "", now) != nil || TakeHandoff(r, HandoffID("../bad"), now) != nil { + t.Fatal("missing or invalid ID claimed a result") + } + var count atomic.Int32 + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + if res := TakeHandoff(r, testHandoffID, now); res != nil { + count.Add(1) + } + }) + } + wg.Wait() + if count.Load() != 1 { + t.Fatalf("concurrent consumers received %d results", count.Load()) + } + if got := TakeHandoff(r, other, now); got == nil || got.ID != other { + t.Fatalf("another handoff was consumed: %+v", got) + } + files, err := os.ReadDir(filepath.Dir(handoffPath(r, testHandoffID))) + if err != nil || len(files) != 0 { + t.Fatalf("result leftovers: %v, %v", files, err) + } +} + +func TestHandoffRejectsWrongPayloadAndInvalidIDs(t *testing.T) { + r := &root.Root{Dir: t.TempDir()} + for _, id := range []HandoffID{"", "../bad", "0123456789ABCDEF0123456789ABCDEF"} { + if err := SaveHandoff(r, NewHandoffResult(id, nil, nil, time.Now())); errs.CodeOf(err) != errs.Usage { + t.Fatalf("saved invalid ID %q: %v", id, err) + } + } + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, nil, nil, time.Now())); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(NewHandoffResult("", nil, nil, time.Now())) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(handoffPath(r, testHandoffID), data, 0o644); err != nil { + t.Fatal(err) + } + if TakeHandoff(r, testHandoffID, time.Now()) != nil { + t.Fatal("accepted a payload without the matching ID") + } +} + // Only the root's programs count (not the emulators the manager is about to stop), and never a shutdown helper. func TestProgramsInUse(t *testing.T) { u := New(&root.Root{Dir: t.TempDir()}, nil, Options{}) @@ -64,29 +129,29 @@ func TestHandoffStaleOrBroken(t *testing.T) { r := &root.Root{Dir: t.TempDir()} now := time.Date(2026, 11, 9, 20, 0, 0, 0, time.Local) for _, at := range []time.Time{now.Add(-HandoffFresh - time.Second), now.Add(2 * time.Minute)} { - if err := SaveHandoff(r, NewHandoffResult(&Status{}, nil, at)); err != nil { + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, &Status{}, nil, at)); err != nil { t.Fatal(err) } - if got := TakeHandoff(r, now); got != nil { + if got := TakeHandoff(r, testHandoffID, now); got != nil { t.Errorf("at %v: %+v", at, got) } - if _, err := os.Stat(handoffPath(r)); !os.IsNotExist(err) { + if _, err := os.Stat(handoffPath(r, testHandoffID)); !os.IsNotExist(err) { t.Errorf("at %v: not removed", at) } } - if err := SaveHandoff(r, NewHandoffResult(&Status{}, nil, now.Add(-HandoffFresh+time.Second))); err != nil { + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, &Status{}, nil, now.Add(-HandoffFresh+time.Second))); err != nil { t.Fatal(err) } - if TakeHandoff(r, now) == nil { + if TakeHandoff(r, testHandoffID, now) == nil { t.Error("a result just inside HandoffFresh was dropped") } - if err := os.WriteFile(handoffPath(r), []byte("{"), 0o644); err != nil { + if err := os.WriteFile(handoffPath(r, testHandoffID), []byte("{"), 0o644); err != nil { t.Fatal(err) } - if TakeHandoff(r, now) != nil { + if TakeHandoff(r, testHandoffID, now) != nil { t.Error("a broken file gave a result") } - if _, err := os.Stat(handoffPath(r)); !os.IsNotExist(err) { + if _, err := os.Stat(handoffPath(r, testHandoffID)); !os.IsNotExist(err) { t.Error("a broken file was not removed") } } diff --git a/internal/update/handoff_windows_test.go b/internal/update/handoff_windows_test.go new file mode 100644 index 0000000..d8bc5b0 --- /dev/null +++ b/internal/update/handoff_windows_test.go @@ -0,0 +1,36 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "os" + "testing" + "time" + + "modavd/internal/root" +) + +func TestHandoffLockedResultCanBeRetried(t *testing.T) { + r := &root.Root{Dir: t.TempDir()} + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, nil, nil, time.Now())); err != nil { + t.Fatal(err) + } + // os.Open does not share DELETE on Windows. A result that cannot be removed must not be reported yet. + f, err := os.Open(handoffPath(r, testHandoffID)) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if res := TakeHandoff(r, testHandoffID, time.Now()); res != nil { + t.Fatalf("reported an unremoved result: %+v", res) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if res := TakeHandoff(r, testHandoffID, time.Now()); res == nil { + t.Fatal("could not retry the result after unlocking it") + } +} diff --git a/scripts/test_update_handoff.mjs b/scripts/test_update_handoff.mjs new file mode 100644 index 0000000..31097de --- /dev/null +++ b/scripts/test_update_handoff.mjs @@ -0,0 +1,92 @@ +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later +// Run: node --experimental-vm-modules --test scripts/test_update_handoff.mjs +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import vm from 'node:vm'; + +const source = await readFile(new URL('../internal/ui/web/js/update.js', import.meta.url), 'utf8'); +const settle = () => new Promise(resolve => setImmediate(resolve)); + +async function page(post) { + const notices = []; + const noop = () => {}; + const stubs = { + './dom.js': Object.fromEntries(['h', 'icon', 'btn', 'attr', 'setText', 'showError'].map(k => [k, noop])), + './store.js': { S: {}, update: noop, subscribe: noop }, + './api.js': { api: { post } }, + './ui.js': { openDialog: noop, toast: value => notices.push(value), toastError: noop }, + './diagnostics.js': { openDiagnostics: noop, activeJob: noop, progressText: noop }, + './actions.js': { onJob: noop, notifyJob: noop }, + }; + const context = vm.createContext({}); + const module = new vm.SourceTextModule(source, { context }); + await module.link(specifier => { + const values = stubs[specifier]; + return new vm.SyntheticModule(Object.keys(values), function() { + for (const [key, value] of Object.entries(values)) this.setExport(key, value); + }, { context }); + }); + await module.evaluate(); + return { update: value => module.namespace.onUpdate({ handoff: value }), notices }; +} + +test('two windows claim once, then repeated events and reloads stay silent', async () => { + const result = { id: 'a'.repeat(32), at: 'same-second', ok: true, updated: true, version: 'v0.40.0' }; + let pending = result; + let calls = 0; + const post = async (path, body) => { + assert.equal(path, '/api/update/handoff/take'); + assert.equal(body.id, result.id); + calls++; + const claimed = pending; + pending = null; + return claimed; + }; + const first = await page(post); + const second = await page(post); + first.update(result); + first.update(result); + second.update(result); + await settle(); + assert.equal(first.notices.length + second.notices.length, 1); + assert.equal(first.notices[0].title, '已更新到 0.40.0'); + assert.equal(calls, 2); + first.update(result); + const reloaded = await page(post); + reloaded.update(result); + await settle(); + assert.equal(reloaded.notices.length, 0); +}); + +test('different IDs in the same second show their own success and error', async () => { + const results = [ + { id: 'a'.repeat(32), at: 'same-second', ok: true, message: 'already current' }, + { id: 'b'.repeat(32), at: 'same-second', ok: false, error: { message: 'busy', advice: 'close manager' } }, + ]; + const p = await page(async (_path, body) => results.find(x => x.id === body.id)); + for (const result of results) p.update(result); + await settle(); + assert.equal(p.notices.length, 2); + assert.equal(p.notices[0].title, 'already current'); + assert.equal(p.notices[1].text, 'busy'); + assert.equal(p.notices[1].advice, 'close manager'); +}); + +test('a failed request retries on the next update without showing unclaimed data', async () => { + const result = { id: 'a'.repeat(32), ok: true, message: 'done' }; + let calls = 0; + const p = await page(async () => { + if (++calls === 1) throw new Error('disconnected'); + return result; + }); + p.update({ ok: true }); // legacy payload has no owner + p.update(result); + await settle(); + assert.equal(p.notices.length, 0); + p.update(result); + await settle(); + assert.equal(calls, 2); + assert.equal(p.notices.length, 1); +}); From 86cebc5cfb5e537f9d446d4f55397ecca1a32849 Mon Sep 17 00:00:00 2001 From: qiyinxi <52456734+qiyinxi@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:09:53 +0000 Subject: [PATCH 14/17] =?UTF-8?q?=E5=BC=80=E6=9C=BA=E5=AE=88=E6=8A=A4?= =?UTF-8?q?=E6=8A=8A=E5=AE=9E=E4=BE=8B=E4=B9=9F=E7=99=BB=E8=AE=B0=E5=88=B0?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E7=9A=84=20adb=20server=EF=BC=885037?= =?UTF-8?q?=EF=BC=89=20(#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用默认 adb server 找设备的工具(adb devices、MAA 的自动检测)原来看不到 ModAVD 的实例。开机守护每 30 秒看一次:5037 上有 server 又没列出这台,就替它发 host:emulator 登记;不起 adb.exe、不替别人起 server。mas-avd.json 写 shareDefaultAdbServer: false 可以关掉。 Refs ModAVD/ModAVD#196 --- README.en.md | 4 +- README.md | 4 +- internal/adb/default_server.go | 108 ++++++++++++++++++++++ internal/adb/default_server_test.go | 121 +++++++++++++++++++++++++ internal/emu/bootwatch.go | 59 ++++++++++++ internal/emu/bootwatch_share_test.go | 128 +++++++++++++++++++++++++++ internal/emu/portplan_test.go | 2 + internal/root/root.go | 12 +++ 8 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 internal/adb/default_server.go create mode 100644 internal/adb/default_server_test.go create mode 100644 internal/emu/bootwatch_share_test.go diff --git a/README.en.md b/README.en.md index 38bfefe..8656feb 100644 --- a/README.en.md +++ b/README.en.md @@ -170,7 +170,9 @@ On Apple Silicon macOS, downloads and imports use Android 15 Google Play `arm64- **Conventions** (the same as AUTO-MAS): -- adb only uses the root folder's private server (`adbServerPort` in `mas-avd.json`, default 20050) and never touches 5037. +- modavd's own adb commands only use the root folder's private server (`adbServerPort` in `mas-avd.json`, default 20050). + - While an instance runs, its boot guardian looks at the default adb server (5037) every 30 seconds. When something runs that server and it does not know the instance, the guardian registers the instance there, where it shows as `emulator-`. Tools that find devices with `adb devices` (MAA's auto-detection, for example) then see ModAVD's instances too (ModAVD/ModAVD#196). + - Nothing happens while no server runs on 5037: the guardian never starts one. To turn the registration off, write `"shareDefaultAdbServer": false` in `mas-avd.json`. - Ports: the console port of instance i is 20000 + 10 × i, adb is 1 more and gRPC 2 more; the index is 0–9 without 5. - A root folder for development or testing can move to its own port range with `"portBase": 21000` in `mas-avd.json` (a key of modavd's own; AUTO-MAS does not know it). Its instances then do not fight over ports with the instances of the same index in the root folder in daily use on the same computer (for example the one AUTO-MAS manages), and are not taken for them. - `portBase` must be a multiple of 100 between 1100 and 49000, excluding 5500. Other adb servers scan the 5500 range automatically; ranges at 49100 and above overlap Windows' default ephemeral ports. Existing configurations in those ranges still support status and shutdown, but block new starts. Shut down all instances before changing the range. The default remains 20000. With another range, both `adbServerPort` and `scriptAdbServerPort` must be set, and neither can be 20049 or 20050 or fall on an instance port of the 20000 range. diff --git a/README.md b/README.md index 07f17b7..cf85785 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,9 @@ Apple Silicon macOS 的下载与导入使用 Android 15 Google Play `arm64-v8a` **约定**(和 AUTO-MAS 一致): -- adb 只走根目录的私有 server(`mas-avd.json` 的 `adbServerPort`,默认 20050),不碰 5037。 +- modavd 自己的 adb 命令只走根目录的私有 server(`mas-avd.json` 的 `adbServerPort`,默认 20050)。 + - 实例开着时,开机守护每 30 秒看一下默认的 adb server(5037)。有人开着它、它又不认识这台实例,就替实例登记过去,那边显示成 `emulator-<控制台端口>`。这样用 `adb devices` 找设备的工具(比如 MAA 的自动检测)也能看到 ModAVD 的实例(ModAVD/ModAVD#196)。 + - 5037 上没有 server 时什么都不做,不替别人起 server。不想登记就在 `mas-avd.json` 里写 `"shareDefaultAdbServer": false`。 - 端口:实例 i 的控制台端口是 20000 + 10 × i,adb 加 1、gRPC 加 2,编号 0–9、不含 5。 - 开发、测试用的根目录可以在 `mas-avd.json` 里写 `"portBase": 21000`,换一段端口(这是 modavd 自己的键,AUTO-MAS 不认)。这样它的实例不会和同一台电脑上日常用的根目录(比如 AUTO-MAS 管的那个)里同编号的实例抢端口,也不会被认成那一台。 - `portBase` 只能是 1100–49000 之间 100 的整数倍,且不能是 5500。5500 段会被别的 adb server 自动扫描;49100 及以上会与 Windows 默认临时端口重叠。已有这些配置仍可查状态和关机,但不能再开机,先关掉全部实例再换段。默认值仍是 20000。设了别的端口段,`adbServerPort`、`scriptAdbServerPort` 都要配上,而且不能是 20049、20050,也不能落在 20000 段的实例端口上。 diff --git a/internal/adb/default_server.go b/internal/adb/default_server.go new file mode 100644 index 0000000..09ff510 --- /dev/null +++ b/internal/adb/default_server.go @@ -0,0 +1,108 @@ +package adb + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "time" +) + +// DefaultServerPort is adb's own default server port, the one other tools use when they run `adb devices` +// (ModAVD/ModAVD#196). Our instances register with the root's private server only; ShareWithDefaultServer also +// registers them here, so that tools which find devices through the default server see them. A variable so that tests +// use a fake server instead: no test may reach the real one. +var DefaultServerPort = 5037 + +// defaultServerTimeout bounds one look at the default server (connect, request, reply). +const defaultServerTimeout = 3 * time.Second + +// ShareWithDefaultServer registers instance console (its adb port is console + 1) with the adb server on +// DefaultServerPort when that server runs and does not list emulator- yet. It never starts that server: the +// tool that starts it gets our instance at the next call (the boot watch calls this every bootWatchShareInterval). +// Returns whether it registered the instance now; nothing listening is not an error. +func ShareWithDefaultServer(ctx context.Context, console int) (bool, error) { + serials, err := serverDevices(ctx, DefaultServerPort, defaultServerTimeout) + if err != nil { + if errors.Is(err, errNoServer) { + return false, nil + } + return false, err + } + serial := "emulator-" + strconv.Itoa(console) + for _, s := range serials { + if s == serial { + return false, nil + } + } + if err := Register(ctx, DefaultServerPort, console+1, defaultServerTimeout); err != nil { + return false, err + } + return true, nil +} + +// errNoServer: nothing accepts connections on the port. +var errNoServer = errors.New("no adb server") + +// serverDevices sends host:devices to the adb server on port and returns the serials it lists (any state). +func serverDevices(ctx context.Context, port int, timeout time.Duration) ([]string, error) { + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + dctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var d net.Dialer + conn, err := d.DialContext(dctx, "tcp", addr) + if err != nil { + if ctx.Err() != nil { + return nil, canceled(ctx) + } + var op *net.OpError + if errors.As(err, &op) && op.Op == "dial" && dctx.Err() == nil { + return nil, errNoServer // refused: nothing listens + } + return nil, fmt.Errorf("连不上 %s: %w", addr, err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(timeout)) + const msg = "host:devices" + if _, err := fmt.Fprintf(conn, "%04x%s", len(msg), msg); err != nil { + return nil, fmt.Errorf("向 %s 发送 %s 失败: %w", addr, msg, err) + } + status := make([]byte, 4) + if _, err := io.ReadFull(conn, status); err != nil { + return nil, fmt.Errorf("%s 没有回 %s: %w", addr, msg, err) + } + body, err := readHexPrefixed(conn) + if err != nil { + return nil, fmt.Errorf("%s 回的 %s 读不全: %w", addr, msg, err) + } + if string(status) != "OKAY" { + return nil, fmt.Errorf("%s 拒绝了 %s: %s %s", addr, msg, status, body) + } + var serials []string + for _, line := range strings.Split(body, "\n") { + if serial, _, ok := strings.Cut(strings.TrimSpace(line), "\t"); ok && serial != "" { + serials = append(serials, serial) + } + } + return serials, nil +} + +// readHexPrefixed reads one adb host-protocol string: 4 hex digits of length, then that many bytes. +func readHexPrefixed(r io.Reader) (string, error) { + head := make([]byte, 4) + if _, err := io.ReadFull(r, head); err != nil { + return "", err + } + n, err := strconv.ParseUint(string(head), 16, 16) + if err != nil { + return "", fmt.Errorf("长度 %q 不是十六进制", head) + } + body := make([]byte, n) + if _, err := io.ReadFull(r, body); err != nil { + return "", err + } + return string(body), nil +} diff --git a/internal/adb/default_server_test.go b/internal/adb/default_server_test.go new file mode 100644 index 0000000..da3bfeb --- /dev/null +++ b/internal/adb/default_server_test.go @@ -0,0 +1,121 @@ +package adb + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "testing" + "time" +) + +// fakeDefaultServer is a stand-in for the default adb server on a free port of 127.0.0.1: it answers host:devices with +// devices and records every other request. DefaultServerPort points at it for the test. +func fakeDefaultServer(t *testing.T, devices string) <-chan string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + old := DefaultServerPort + DefaultServerPort = ln.Addr().(*net.TCPAddr).Port + t.Cleanup(func() { DefaultServerPort = old }) + if DefaultServerPort == 5037 { + t.Fatal("测试端口不能是 5037") + } + got := make(chan string, 8) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + c.SetDeadline(time.Now().Add(5 * time.Second)) + msg, err := readHexPrefixed(bufio.NewReader(c)) + if err != nil { + return + } + if msg == "host:devices" { + fmt.Fprintf(c, "OKAY%04x%s", len(devices), devices) + return + } + got <- msg + io.Copy(io.Discard, c) + }(conn) + } + }() + return got +} + +func TestShareWithDefaultServerRegistersMissingInstance(t *testing.T) { + got := fakeDefaultServer(t, "emulator-5554\tdevice\n127.0.0.1:16384\toffline\n") + registered, err := ShareWithDefaultServer(context.Background(), 31020) + if err != nil || !registered { + t.Fatalf("ShareWithDefaultServer = %v, %v,应登记", registered, err) + } + select { + case msg := <-got: + if msg != "host:emulator:31021" { + t.Fatalf("登记发的是 %q,应是 host:emulator:31021(adb 端口 = 控制台 + 1)", msg) + } + case <-time.After(3 * time.Second): + t.Fatal("假 server 没收到登记") + } +} + +func TestShareWithDefaultServerSkipsKnownInstance(t *testing.T) { + for _, state := range []string{"device", "offline", "unauthorized"} { + got := fakeDefaultServer(t, "emulator-31020\t"+state+"\n") + registered, err := ShareWithDefaultServer(context.Background(), 31020) + if err != nil || registered { + t.Fatalf("%s: ShareWithDefaultServer = %v, %v,已经列着的不该再登记", state, registered, err) + } + select { + case msg := <-got: + t.Fatalf("%s: 不该再发 %q", state, msg) + case <-time.After(300 * time.Millisecond): + } + } +} + +func TestShareWithDefaultServerWithoutServer(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() // nothing listens there now + old := DefaultServerPort + DefaultServerPort = port + t.Cleanup(func() { DefaultServerPort = old }) + registered, err := ShareWithDefaultServer(context.Background(), 31020) + if err != nil || registered { + t.Fatalf("没有 server 时 ShareWithDefaultServer = %v, %v,应什么都不做、不报错", registered, err) + } +} + +func TestServerDevicesRefusal(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + readHexPrefixed(bufio.NewReader(c)) + const why = "unknown host service" + fmt.Fprintf(c, "FAIL%04x%s", len(why), why) + }() + port := ln.Addr().(*net.TCPAddr).Port + if _, err := serverDevices(context.Background(), port, 2*time.Second); err == nil { + t.Fatal("FAIL 回复应报错") + } +} diff --git a/internal/emu/bootwatch.go b/internal/emu/bootwatch.go index 5cea526..bfaeab5 100644 --- a/internal/emu/bootwatch.go +++ b/internal/emu/bootwatch.go @@ -280,10 +280,18 @@ func RunBootWatch(args []string, w io.Writer) int { defer ticker.Stop() watch.watchMemory(ctx, ticker.C) }() + shareDone := make(chan struct{}) + go func() { + defer close(shareDone) + ticker := time.NewTicker(bootWatchShareInterval) + defer ticker.Stop() + watch.shareDefaultAdb(ctx, ticker.C) + }() for !q.Wait(bootWatchInterval) { watch.tick(ctx, time.Now()) } cancel() + <-shareDone <-memoryDone // write the summary before releasing the watch lock or ending the log m.Wait(10 * time.Second) log.Infof("实例 %d 的模拟器(qemu pid %d)已退出,开机守护结束", i, q.PID) @@ -300,6 +308,8 @@ type bootWatch struct { missingSince time.Time // Owned only by watchMemory, independently of guest reboot setup in tick. memory bootWatchMemory + // shareFailing: the last registration with the default adb server failed (warned once). Owned by shareDefaultAdb. + shareFailing bool } const ( @@ -401,6 +411,55 @@ func (w *bootWatch) logMemorySummary() { w.m.Log.Infof("%s;精确退出峰值未知", msg) } +// bootWatchShareInterval: how often the watch makes sure the default adb server (5037) knows the instance. A tool that +// starts that server finds the instance at most this long after. +var bootWatchShareInterval = 30 * time.Second + +// shareDefaultAdbServer is adb.ShareWithDefaultServer; tests replace it. +var shareDefaultAdbServer = adb.ShareWithDefaultServer + +// shareDefaultAdb registers the instance with the default adb server now and at every tick, until ctx ends (qemu +// exited). Its own goroutine: tick may block for minutes in a guest reboot setup. Skipped while a stop runs, before a +// start has finished on q, and when mas-avd.json turns it off; a failure is logged once until it works again. +func (w *bootWatch) shareDefaultAdb(ctx context.Context, ticks <-chan time.Time) { + for { + if ctx.Err() != nil { + return + } + w.shareDefaultAdbOnce(ctx) + select { + case <-ctx.Done(): + return + case <-ticks: + } + } +} + +// shareDefaultAdbOnce is one look of shareDefaultAdb; it reports whether it asked the default server. +func (w *bootWatch) shareDefaultAdbOnce(ctx context.Context) bool { + m, i := w.m, w.i + rec, ok := m.readBootRecord(i) + if !ok || !rec.names(w.q) || m.stopping(i) || !m.Root.ShareDefaultAdbServer() { + return false + } + console := m.Root.Ports().Console(i) + registered, err := shareDefaultAdbServer(ctx, console) + switch { + case err != nil: + if !w.shareFailing && ctx.Err() == nil { + m.Log.Warnf("实例 %d 没能登记到默认的 adb server(端口 %d),别的工具里可能看不到它: %v", i, + adb.DefaultServerPort, err) + } + w.shareFailing = true + case registered: + m.Log.Infof("实例 %d 已登记到默认的 adb server(端口 %d),那边是 emulator-%d", i, adb.DefaultServerPort, console) + w.shareFailing = false + default: + w.shareFailing = false + } + return true +} + // tick is one look of the watch at now: nothing while a start or a stop of the instance runs, or while the record does // not name q (no start has finished on it); otherwise, once Android has booted with another boot_id than the // record's, the start that sets the boot up again. diff --git a/internal/emu/bootwatch_share_test.go b/internal/emu/bootwatch_share_test.go new file mode 100644 index 0000000..da95b6d --- /dev/null +++ b/internal/emu/bootwatch_share_test.go @@ -0,0 +1,128 @@ +//go:build windows + +package emu + +import ( + "context" + "errors" + "strings" + "sync/atomic" + "testing" + "time" + + "modavd/internal/host" + "modavd/internal/jsonord" +) + +func stubShareDefaultAdb(t *testing.T, fn func(console int) (bool, error)) { + t.Helper() + old := shareDefaultAdbServer + shareDefaultAdbServer = func(_ context.Context, console int) (bool, error) { return fn(console) } + t.Cleanup(func() { shareDefaultAdbServer = old }) +} + +// The boot watch registers the instance with the default adb server only once a start has finished on its qemu, not +// while a stop runs, and not when mas-avd.json turns it off (ModAVD/ModAVD#196). A failure is warned once until a +// look works again. +func TestBootWatchShareDefaultAdbOnce(t *testing.T) { + m, logs := opsTestManager(t) + created, err := m.Create(context.Background(), CreateRequest{filesOnly: true}) + if err != nil { + t.Fatal(err) + } + i := created.Index + q := host.Process{PID: 4242, Created: 1_000_500} + console := m.Root.Ports().Console(i) + var calls atomic.Int32 + var fail atomic.Bool + var got atomic.Int32 + stubShareDefaultAdb(t, func(c int) (bool, error) { + calls.Add(1) + got.Store(int32(c)) + if fail.Load() { + return false, errors.New("boom") + } + return true, nil + }) + ctx := context.Background() + w := &bootWatch{m: m, i: i, q: q} + + if w.shareDefaultAdbOnce(ctx) { + t.Fatal("asked without a boot record") + } + m.writeBootRecord(i, bootRecord{QemuPID: 4100, QemuCreated: q.Created, BootID: testBootA}) + if w.shareDefaultAdbOnce(ctx) { + t.Fatal("asked with the record of another qemu") + } + m.writeBootRecord(i, bootRecord{QemuPID: q.PID, QemuCreated: q.Created, BootID: testBootA}) + if !w.shareDefaultAdbOnce(ctx) || calls.Load() != 1 || int(got.Load()) != console { + t.Fatalf("asked=%d console=%d, want 1 call for console %d", calls.Load(), got.Load(), console) + } + + l, ok, err := host.TryLock(m.lockPath(i, "stop")) + if err != nil || !ok { + t.Fatalf("stop lock: %v %v", ok, err) + } + if w.shareDefaultAdbOnce(ctx) { + t.Error("asked while a stop runs") + } + l.Unlock() + + fail.Store(true) + before := len(logs.Messages()) + for k := 0; k < 3; k++ { + if !w.shareDefaultAdbOnce(ctx) { + t.Fatal("a failing look should still ask") + } + } + if warned := logs.Messages()[before:]; len(warned) != 1 || !strings.Contains(warned[0], "没能登记到默认的 adb server") { + t.Fatalf("warnings after three failures: %q, want one", warned) + } + fail.Store(false) + w.shareDefaultAdbOnce(ctx) + fail.Store(true) + w.shareDefaultAdbOnce(ctx) + if n := len(logs.Messages()) - before; n != 2 { + t.Fatalf("a failure after a success should warn again: %d warnings", n) + } + + if err := m.Root.UpdateMetadata(func(o *jsonord.Obj) error { + o.Set("shareDefaultAdbServer", jsonord.NewBool(false)) + return nil + }); err != nil { + t.Fatal(err) + } + if w.shareDefaultAdbOnce(ctx) { + t.Error("asked although mas-avd.json turned it off") + } +} + +// The loop looks right away, again at every tick, and returns once ctx ends. +func TestBootWatchShareDefaultAdbLoop(t *testing.T) { + m := markerTestManager(t) + const i = 2 + q := host.Process{PID: 4242, Created: 1_000_500} + m.writeBootRecord(i, bootRecord{QemuPID: q.PID, QemuCreated: q.Created, BootID: testBootA}) + looks := make(chan struct{}, 8) + stubShareDefaultAdb(t, func(int) (bool, error) { looks <- struct{}{}; return false, nil }) + ctx, cancel := context.WithCancel(context.Background()) + ticks := make(chan time.Time) + done := make(chan struct{}) + go func() { defer close(done); (&bootWatch{m: m, i: i, q: q}).shareDefaultAdb(ctx, ticks) }() + wait := func(what string) { + select { + case <-looks: + case <-time.After(3 * time.Second): + t.Fatalf("no look %s", what) + } + } + wait("right away") + ticks <- time.Now() + wait("after a tick") + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("the loop did not return after cancel") + } +} diff --git a/internal/emu/portplan_test.go b/internal/emu/portplan_test.go index 6f43731..ad3d3ac 100644 --- a/internal/emu/portplan_test.go +++ b/internal/emu/portplan_test.go @@ -79,6 +79,8 @@ func TestMain(m *testing.M) { case "client": os.Exit(fakeAdbClientMain(os.Args[1:])) } + // No test may reach the machine's default adb server (5037): boot watches registering there are faked. + shareDefaultAdbServer = func(context.Context, int) (bool, error) { return false, nil } os.Exit(m.Run()) } diff --git a/internal/root/root.go b/internal/root/root.go index 39d53c1..738f51c 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -703,6 +703,18 @@ func (r *Root) ScriptAdbServerPort() int { return scriptAdbServerPort(r.runtimeMetadata()) } +// ShareDefaultAdbServer: also register running instances with the default adb server (port 5037) when one runs, so +// tools that look there (`adb devices`) find them (ModAVD/ModAVD#196). On unless mas-avd.json has +// "shareDefaultAdbServer": false. +func (r *Root) ShareDefaultAdbServer() bool { + v := r.runtimeMetadata().Get("shareDefaultAdbServer") + if v.Kind() != jsonord.Bool { + return true + } + b, _ := v.BoolVal() + return b +} + func adbServerPort(o *jsonord.Obj) int { if port, ok := metadataPort(o, "adbServerPort"); ok { return port From 0808cf083aefe85a4361bdbbad510aac37ad1310 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 13 Nov 2026 19:31:41 +0100 Subject: [PATCH 15/17] =?UTF-8?q?=E5=8F=91=E7=89=88=EF=BC=9AModAVDManager?= =?UTF-8?q?=20v0.10.0=EF=BC=88=E9=9A=8F=20ModAVD=200.41.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/buildinfo/buildinfo.go | 2 +- release/0.10.0.SHA256SUMS.txt | 3 ++ release/0.10.0.md | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 release/0.10.0.SHA256SUMS.txt create mode 100644 release/0.10.0.md diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 1ae633d..2a374db 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -4,7 +4,7 @@ package buildinfo // Version of modavd itself, kept in the source rather than set with -ldflags -X: release builds must come out byte // for byte the same for the same source (Mirror 酱 ships per-file deltas, and a version stamped per package would // change the exe in every release). The ModAVD package version comes from the root at run time. -var Version = "0.9.0" +var Version = "0.10.0" // Protocol is the version of the machine-readable contract: the commands, the JSON written by `--json`, the exit codes // and the MCP tool schemas. Callers such as AUTO-MAS check it (`modavd version --json`) before relying on a field. diff --git a/release/0.10.0.SHA256SUMS.txt b/release/0.10.0.SHA256SUMS.txt new file mode 100644 index 0000000..74a4c4f --- /dev/null +++ b/release/0.10.0.SHA256SUMS.txt @@ -0,0 +1,3 @@ +f001218c7372b376b1b4958c1522364d9ca8ac7139e1896cbe3df51e540b90b5 ModAVD.exe +f65f0d9c4371b59cc8708a208326beb757e2c0f35251bd4e740d5c760463a76c ModAVDManager-0.10.0-win-x64.zip +65ba42f70cdb3be631732db1d09f18fbaffaff0329e9699db5dbe57b4a27c977 tools/modavd.exe diff --git a/release/0.10.0.md b/release/0.10.0.md new file mode 100644 index 0000000..bb2b2d9 --- /dev/null +++ b/release/0.10.0.md @@ -0,0 +1,80 @@ +# ModAVDManager v0.10.0 + +- `ModAVD.exe`:`f001218c7372b376b1b4958c1522364d9ca8ac7139e1896cbe3df51e540b90b5` +- `tools/modavd.exe`:`65ba42f70cdb3be631732db1d09f18fbaffaff0329e9699db5dbe57b4a27c977` + +随 ModAVD 0.41.0 进包。下面是相对 v0.9.0 的改动。这一版主要是:开着的实例也登记到默认的 adb server(5037),`adb devices` 和 MAA 这类工具能直接看到;整包更新更稳(断网时能用已经下好的新版、目录链接进入的根目录能自更新、被只读 DLL 占着时换包前就停);关窗口时卡片不再误显示「开机中」;托盘双击能隐藏和恢复窗口。 + +## 变更 + +- 实例开着时,开机守护每 30 秒看一下默认的 adb server(5037)。有人开着它、它又不认识这台实例,就替实例登记过去,那边显示成 `emulator-<控制台端口>`。这样用 `adb devices` 找设备的工具也能看到 ModAVD 的实例,不用再手动 `adb connect`。5037 上没有 server 时什么都不做,不替别人起 server;不想登记就在 `mas-avd.json` 里写 `"shareDefaultAdbServer": false`。modavd 自己的 adb 命令照旧只走根目录的私有 server。(ModAVDManager#155,ModAVD/ModAVD#196) +- 双击托盘图标隐藏或恢复管理器窗口,页面和窗口状态保留;没有窗口时打开一个。右键「打开管理器」优先恢复已有窗口。(ModAVDManager#148,ModAVD/ModAVD#188) +- 实例卡片标题下面不再显示运行时长,详情的「状态」一行里照旧有。新建实例去掉「编号」选项,一律用第一个空闲编号;名称的占位文字去掉「比如:」。(ModAVDManager#145) +- 带窗口开机的实例要先装执行桥时,装桥那几次准备用的开机一律无头,装好后正常关机、最后一次才带窗口开,窗口不再弹出、关掉、再弹出好几次。(ModAVDManager#146) +- 开机守护改成每分钟采样一次模拟器内存:提交量比上次记的变化超过 256 MiB 时提前记一行,平稳时照旧每 10 分钟一行;模拟器退出时写一行汇总(最大提交、工作集、采样次数)。(ModAVDManager#153,ModAVD/ModAVD#159) + +## 修复 + +- 关掉模拟器窗口,或者在安卓里关机以后,卡片不再先显示好几秒「开机中」:客体已经够不到、又没有 modavd 在给它开机时,报「关机中」。客体自己重启时,adb 断开的那几秒也报「关机中」。(ModAVDManager#144) +- 整包更新: + - 根目录里只读的 DLL 被别的程序载入着,或者文件被不许删除的方式打开着时,以前的占用检查查不出来,换到一半才失败。现在换包前就报忙,什么都不动,暂存的新版留着,关掉那个程序再装就行。(ModAVDManager#149,ModAVD/ModAVD#191) + - 根目录经目录链接(junction、符号链接、8.3 短名)进入时,更新程序认得出自己,能完成自占用检查、替换和中断后的恢复,不再一直报忙。(ModAVDManager#152,ModAVD/ModAVD#195) + - 断网时,已经下好、核对过、又比装着的版本新的暂存,现在能接着装。管理器点「更新」后的结果提示,每次只在一个窗口里显示一次。(ModAVDManager#154,ModAVD/ModAVD#170) + - 按区间更新时清单一律从远端取;改动太多时照旧改下整包,原因只写日志。超过 8 MiB 的文件改成边下边写临时文件,不再整块放进内存。读本地文件出错时直接报错,不再误当成包坏了去重下整包。(ModAVDManager#147,ModAVD/ModAVD#182) + +## 说明 + +- 从 v0.9.0 在管理器里点「更新」升到这一版时,换包的还是旧版命令行,它不会把结果交给重新打开的新管理器,所以这一次不会弹「已更新到 …」的提示,实际已经装上了。之后的更新照常提示。 + +## 给调用方(AUTO-MAS) + +- `protocol` 还是 2:没有删命令、参数和 `--json` 字段,没有新的错误码和退出码。调用方不用改。 +- `status` / `list --json`:实例自己在关(关了窗口、安卓里关机)时,`status` 照旧是 `starting`,`busy` 是 `stopping`。AUTO-MAS 本来就把 `busy == "stopping"` 当「关闭中」。(ModAVDManager#144) +- `update install` 多一个管理器用的 `--handoff-id`,只和 `--reopen` 一起用;结果改存 `.update\manager-results\.json`。不用 `--reopen` 的调用不受影响。(ModAVDManager#154) +- 管理器自己的接口多了 `POST /api/update/handoff/take`、`POST /api/update/handoff/receive`,`handoff` 多一个 `id` 字段。(ModAVDManager#154) +- `mas-avd.json` 多一个可选键 `shareDefaultAdbServer`(默认开)。(ModAVDManager#155) + +## 下载与核对 + +用 Go 1.26.5、`GOTOOLCHAIN=local` 运行仓库里的 `build.ps1`,能编出相同的字节。单独下载的包里还有 Go 的许可原文和文件清单。请用附件 `SHA256SUMS.txt` 核对 zip 和解压出的两个 exe,例如在 PowerShell 里运行 `Get-FileHash .\ModAVDManager-0.10.0-win-x64.zip -Algorithm SHA256`。 + +单独下载的管理器不会自己更新,新版本要手动替换;ModAVD 包里的那份随 ModAVD 整包一起更新。单独使用时请给 `--root` 或设置 `MODAVD_ROOT`,否则会提示找不到 ModAVD 根目录。不要把单独下载的 zip 解压到 ModAVD 根目录,覆盖整包里的文件。 + +## English + +Shipped in ModAVD 0.41.0. Changes since v0.9.0. This release mostly brings: running instances are also registered with the default adb server (5037), so `adb devices` and tools like MAA see them; sturdier whole-package updates (an already downloaded package installs offline, roots entered through a directory link update themselves, a read-only DLL in use stops the update before the swap); the card no longer shows "starting" while a window is being closed; and double-clicking the tray icon hides and restores the window. + +### Changes + +- While an instance runs, its boot guardian looks at the default adb server (5037) every 30 seconds. When something runs that server and it does not know the instance, the guardian registers the instance there, where it shows as `emulator-`, so tools that find devices with `adb devices` see ModAVD's instances without a manual `adb connect`. Nothing happens while no server runs on 5037: it never starts one. To turn it off, write `"shareDefaultAdbServer": false` in `mas-avd.json`. modavd's own adb commands still use only the root's private server. (ModAVDManager#155, ModAVD/ModAVD#196) +- Double-clicking the tray icon hides or restores the manager window, keeping the page and the window state; without a window it opens one. "打开管理器" (open manager) in the tray menu restores an existing window first. (ModAVDManager#148, ModAVD/ModAVD#188) +- The instance card no longer shows the uptime under its title; the "状态" (status) row of the details still has it. Creating an instance no longer offers an index choice and always takes the first free index; the name placeholder drops "比如:" (e.g.). (ModAVDManager#145) +- When an instance started with a window first needs the arm64 bridge installed, the preparation starts for installing it are all headless; once it is installed the instance shuts down normally and only the last start opens the window, so the window no longer pops up and closes several times. (ModAVDManager#146) +- The boot guardian samples the emulator's memory every minute: a line is written early when the commit changed by more than 256 MiB since the last line, and every 10 minutes as before while it stays steady; when the emulator exits it writes a summary (largest commit, working set, sample counts). (ModAVDManager#153, ModAVD/ModAVD#159) + +### Fixes + +- After closing the emulator window or shutting down from inside Android, the card no longer shows "开机中" (starting) for several seconds: when the guest cannot be reached and no modavd is starting it, it reports "关机中" (stopping). A guest restarting by itself also shows "stopping" for the seconds adb is gone. (ModAVDManager#144) +- Whole-package updates: + - A read-only DLL in the root loaded by another program, or a file opened without delete sharing, was missed by the busy check and failed halfway through the swap. Now the update reports busy before the swap, changes nothing and keeps the staged package; close that program and install again. (ModAVDManager#149, ModAVD/ModAVD#191) + - With the root entered through a directory link (junction, symbolic link, 8.3 short name), the updater recognizes itself and completes the self-occupancy check, the swap and recovery after an interruption instead of reporting busy forever. (ModAVDManager#152, ModAVD/ModAVD#195) + - Offline, a staged package that was downloaded and verified and is newer than the installed version now installs. After "更新" (update) in the manager, the result is shown once, in one window. (ModAVDManager#154, ModAVD/ModAVD#170) + - Range updates always take the manifest from the server; with too many changes they still fall back to the whole package, with the reason only in the log. Files over 8 MiB go to a temporary file as they download instead of into memory. Local read errors are reported as such instead of being taken for a broken package and downloading the whole package again. (ModAVDManager#147, ModAVD/ModAVD#182) + +### Notes + +- When "更新" in a v0.9.0 manager updates to this version, the old command line does the swap and does not hand its result to the reopened manager, so this one time there is no "updated to …" message although the update was installed. Later updates show it as usual. + +### For callers (AUTO-MAS) + +- `protocol` is still 2: no command, option or `--json` field was removed, and there are no new error codes or exit codes. Callers need no change. +- `status` / `list --json`: while an instance is shutting itself down (window closed, shut down inside Android), `status` is still `starting` and `busy` is `stopping`. AUTO-MAS already treats `busy == "stopping"` as stopping. (ModAVDManager#144) +- `update install` has a new manager-only `--handoff-id`, only with `--reopen`; the result is stored in `.update\manager-results\.json`. Calls without `--reopen` are not affected. (ModAVDManager#154) +- The manager's own API adds `POST /api/update/handoff/take` and `POST /api/update/handoff/receive`, and `handoff` gets an `id` field. (ModAVDManager#154) +- `mas-avd.json` gets an optional key `shareDefaultAdbServer` (on by default). (ModAVDManager#155) + +### Download and verification + +Running the repository's `build.ps1` with Go 1.26.5 and `GOTOOLCHAIN=local` builds the same bytes. The standalone package also carries Go's license text and the file list. Check the zip and the two extracted exe files with the attached `SHA256SUMS.txt`, for example `Get-FileHash .\ModAVDManager-0.10.0-win-x64.zip -Algorithm SHA256` in PowerShell. + +The standalone manager does not update itself; replace it by hand for a new version. The copy in the ModAVD package is updated with the whole ModAVD package. When using it standalone, pass `--root` or set `MODAVD_ROOT`, or it reports that it cannot find the ModAVD root. Do not extract the standalone zip into a ModAVD root over the package's files. From dbcea41571327b031b4d5daf624951a08c90b39c Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 13 Nov 2026 20:05:26 +0100 Subject: [PATCH 16/17] =?UTF-8?q?v0.10.0=EF=BC=9A=E5=8F=91=E7=89=88?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E8=A1=A5=E4=B8=8A=E6=89=98=E7=9B=98=E5=8D=95?= =?UTF-8?q?=E5=87=BB=E7=9A=84=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- release/0.10.0.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release/0.10.0.md b/release/0.10.0.md index bb2b2d9..a44fa23 100644 --- a/release/0.10.0.md +++ b/release/0.10.0.md @@ -8,7 +8,7 @@ ## 变更 - 实例开着时,开机守护每 30 秒看一下默认的 adb server(5037)。有人开着它、它又不认识这台实例,就替实例登记过去,那边显示成 `emulator-<控制台端口>`。这样用 `adb devices` 找设备的工具也能看到 ModAVD 的实例,不用再手动 `adb connect`。5037 上没有 server 时什么都不做,不替别人起 server;不想登记就在 `mas-avd.json` 里写 `"shareDefaultAdbServer": false`。modavd 自己的 adb 命令照旧只走根目录的私有 server。(ModAVDManager#155,ModAVD/ModAVD#196) -- 双击托盘图标隐藏或恢复管理器窗口,页面和窗口状态保留;没有窗口时打开一个。右键「打开管理器」优先恢复已有窗口。(ModAVDManager#148,ModAVD/ModAVD#188) +- 双击托盘图标隐藏或恢复管理器窗口,页面和窗口状态保留;没有窗口时打开一个。单击照旧恢复实例窗口,只是要等系统的双击间隔过去才动,双击不再顺带恢复实例窗口。右键「打开管理器」优先恢复已有窗口。(ModAVDManager#148,ModAVD/ModAVD#188) - 实例卡片标题下面不再显示运行时长,详情的「状态」一行里照旧有。新建实例去掉「编号」选项,一律用第一个空闲编号;名称的占位文字去掉「比如:」。(ModAVDManager#145) - 带窗口开机的实例要先装执行桥时,装桥那几次准备用的开机一律无头,装好后正常关机、最后一次才带窗口开,窗口不再弹出、关掉、再弹出好几次。(ModAVDManager#146) - 开机守护改成每分钟采样一次模拟器内存:提交量比上次记的变化超过 256 MiB 时提前记一行,平稳时照旧每 10 分钟一行;模拟器退出时写一行汇总(最大提交、工作集、采样次数)。(ModAVDManager#153,ModAVD/ModAVD#159) @@ -47,7 +47,7 @@ Shipped in ModAVD 0.41.0. Changes since v0.9.0. This release mostly brings: runn ### Changes - While an instance runs, its boot guardian looks at the default adb server (5037) every 30 seconds. When something runs that server and it does not know the instance, the guardian registers the instance there, where it shows as `emulator-`, so tools that find devices with `adb devices` see ModAVD's instances without a manual `adb connect`. Nothing happens while no server runs on 5037: it never starts one. To turn it off, write `"shareDefaultAdbServer": false` in `mas-avd.json`. modavd's own adb commands still use only the root's private server. (ModAVDManager#155, ModAVD/ModAVD#196) -- Double-clicking the tray icon hides or restores the manager window, keeping the page and the window state; without a window it opens one. "打开管理器" (open manager) in the tray menu restores an existing window first. (ModAVDManager#148, ModAVD/ModAVD#188) +- Double-clicking the tray icon hides or restores the manager window, keeping the page and the window state; without a window it opens one. A single click still restores the instance windows, but only once the system's double-click time has passed, and a double click no longer restores them too. "打开管理器" (open manager) in the tray menu restores an existing window first. (ModAVDManager#148, ModAVD/ModAVD#188) - The instance card no longer shows the uptime under its title; the "状态" (status) row of the details still has it. Creating an instance no longer offers an index choice and always takes the first free index; the name placeholder drops "比如:" (e.g.). (ModAVDManager#145) - When an instance started with a window first needs the arm64 bridge installed, the preparation starts for installing it are all headless; once it is installed the instance shuts down normally and only the last start opens the window, so the window no longer pops up and closes several times. (ModAVDManager#146) - The boot guardian samples the emulator's memory every minute: a line is written early when the commit changed by more than 256 MiB since the last line, and every 10 minutes as before while it stays steady; when the emulator exits it writes a summary (largest commit, working set, sample counts). (ModAVDManager#153, ModAVD/ModAVD#159) From a30bf9524f3ebbcd754c986e2e49136aae6e5b0c Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Fri, 13 Nov 2026 20:13:02 +0100 Subject: [PATCH 17/17] =?UTF-8?q?v0.10.0=EF=BC=9A=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=A4=E4=B8=AA=20exe=20=E7=9A=84=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=B5=84=E6=BA=90=EF=BC=8C=E5=93=88=E5=B8=8C=E8=B7=9F?= =?UTF-8?q?=E7=9D=80=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/modavd-gui/rsrc_windows_amd64.syso | Bin 73388 -> 73396 bytes release/0.10.0.SHA256SUMS.txt | 6 +++--- release/0.10.0.md | 4 ++-- rsrc_windows_amd64.syso | Bin 73420 -> 73428 bytes 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/modavd-gui/rsrc_windows_amd64.syso b/cmd/modavd-gui/rsrc_windows_amd64.syso index 6c33eef5050957adcc8710c4267a1a617860cb55..1f612315460dabc4006e153421a6a03d00033ce0 100644 GIT binary patch delta 141 zcmZ3pmu1Ud7S5D5Mg|a=H<44C^MNcQgAI@)vr+dl6Ym-(und^E(9FxcotK%>Uzl;t z^jI-QeNHZ@B-iw*VvNd+T+?@gsAtpfiZS*x>oKrT&lhJ@Uzl;p z^jI-QeNIlOBoBlQ&lhJ@WVK|_V=$OLQ=IWTvjI?S QvIL_lL~Q#(3C7Q?07*C;TL1t6 diff --git a/release/0.10.0.SHA256SUMS.txt b/release/0.10.0.SHA256SUMS.txt index 74a4c4f..230a7b7 100644 --- a/release/0.10.0.SHA256SUMS.txt +++ b/release/0.10.0.SHA256SUMS.txt @@ -1,3 +1,3 @@ -f001218c7372b376b1b4958c1522364d9ca8ac7139e1896cbe3df51e540b90b5 ModAVD.exe -f65f0d9c4371b59cc8708a208326beb757e2c0f35251bd4e740d5c760463a76c ModAVDManager-0.10.0-win-x64.zip -65ba42f70cdb3be631732db1d09f18fbaffaff0329e9699db5dbe57b4a27c977 tools/modavd.exe +c2fd1a1437be4267c39e8e990a272b2b6140461877b89db3f8cf0c361d576762 ModAVD.exe +71a95dda149057090fd2ecfa406829a1ec77bbc56b3cf1caa298f067a29b27b1 ModAVDManager-0.10.0-win-x64.zip +c7fd4fe56022d17b0ef10d5cab80181df643bd3c5fd6f69dce8bcc52c84b599b tools/modavd.exe diff --git a/release/0.10.0.md b/release/0.10.0.md index a44fa23..66528ec 100644 --- a/release/0.10.0.md +++ b/release/0.10.0.md @@ -1,7 +1,7 @@ # ModAVDManager v0.10.0 -- `ModAVD.exe`:`f001218c7372b376b1b4958c1522364d9ca8ac7139e1896cbe3df51e540b90b5` -- `tools/modavd.exe`:`65ba42f70cdb3be631732db1d09f18fbaffaff0329e9699db5dbe57b4a27c977` +- `ModAVD.exe`:`c2fd1a1437be4267c39e8e990a272b2b6140461877b89db3f8cf0c361d576762` +- `tools/modavd.exe`:`c7fd4fe56022d17b0ef10d5cab80181df643bd3c5fd6f69dce8bcc52c84b599b` 随 ModAVD 0.41.0 进包。下面是相对 v0.9.0 的改动。这一版主要是:开着的实例也登记到默认的 adb server(5037),`adb devices` 和 MAA 这类工具能直接看到;整包更新更稳(断网时能用已经下好的新版、目录链接进入的根目录能自更新、被只读 DLL 占着时换包前就停);关窗口时卡片不再误显示「开机中」;托盘双击能隐藏和恢复窗口。 diff --git a/rsrc_windows_amd64.syso b/rsrc_windows_amd64.syso index 555b9cb6da54464fd4557293bf73af8da6163d99..1c2d80b15fdc172fb0d95034ead3ba3939752377 100644 GIT binary patch delta 141 zcmX@Jm*vV{7S5D5Mg|brH<44ClR=J=!3M~&*{FM&iT4TUzqXC z^jI-QeNIlOBoBlQpDxa*$ZE-;$6zr1pg7}qW&@zu QZV5(Jh}ia*5{$1|0kI$*g8%>k