From 302e43f184d2448c6774b85605e0ad85f5b66190 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Mon, 9 Nov 2026 16:55:19 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=AF=B4=E6=98=8E=E7=85=A7=E5=AE=9E=E5=86=99?= =?UTF-8?q?=EF=BC=9AAUTO-MAS=20=E7=94=A8=E8=87=AA=E5=B7=B1=E7=9A=84?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=99=A8=E5=92=8C=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原文说 AUTO-MAS 调用 modavd update、按这里的更新源和 CDK 下载,两处都不对: AUTO-MAS 到 v5.8.0 还是用自己的更新器,更新源和 CDK 读它自己的设置。 改成写清这里只能检查新版本、用和不用 AUTO-MAS 各自怎么更新、这里的设置 管什么。按钮照旧不可用。 --- internal/ui/web/js/update.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/ui/web/js/update.js b/internal/ui/web/js/update.js index 9404380..9c83936 100644 --- a/internal/ui/web/js/update.js +++ b/internal/ui/web/js/update.js @@ -1,6 +1,7 @@ // 设置 → ModAVD 更新 (API.md "更新"): version and update state of this root, the update source and the Mirror 酱 CDK, -// a check on request. Installing is not offered here yet (AUTO-MAS installs, through `modavd update`, with these -// settings); the button is shown and disabled. +// a check on request. Installing is not offered here yet: AUTO-MAS still updates with its own updater, source and +// CDK, and the two updaters share no lock; the button is shown and disabled. These settings are used by the check +// and by `modavd update`. import { h, icon, btn, attr, setText, showError } from './dom.js'; import { S, update, subscribe } from './store.js'; @@ -87,12 +88,12 @@ export function openUpdate() { h('div', { class: 'f-ctl' }, h('div', { class: 'upd-cdk' }, cdk, save, clear), cdkState), h('label', { class: 'f-label', for: 'upd-comp', text: '组件下载源' }), h('div', { class: 'f-ctl' }, componentSource, componentURLsRow, h('p', { class: 'f-hint', text: '「补全组件」和 modavd components fetch 都从这里选的源下载;选了就保存。' }))); - const note = h('p', { class: 'f-hint', text: '更新现在由 AUTO-MAS 发起(它调用 modavd update),会按这里选的更新源和 CDK 下载;这里先不能直接更新。' }); + const note = h('p', { class: 'f-hint', text: '这里暂时只能检查新版本。用 AUTO-MAS 的,在 AUTO-MAS 里更新(它用 AUTO-MAS 设置里的更新源和 CDK);不用 AUTO-MAS 的,关掉所有实例和管理器,把新版整包覆盖解压到这个文件夹,avd 文件夹不会被动到。这里的更新源和 CDK 只用于「检查更新」和命令行 modavd update。' }); const diagBtn = btn('btn btn-secondary', [icon('shield'), '组件与问题反馈'], () => { ctl.close(); openDiagnostics(); }); const when = h('span', { class: 'foot-note' }); const checkBtn = btn('btn btn-secondary', null, () => run(api.post('/api/update/check'))); const installBtn = btn('btn btn-primary', [icon('download'), '更新'], null, - { disabled: true, 'aria-disabled': 'true', title: '暂时由 AUTO-MAS 发起更新' }); + { disabled: true, 'aria-disabled': 'true', title: '这里暂时不能直接更新' }); for (const r of radios) r.addEventListener('change', () => r.checked && post({ source: r.value }, r.value === 'github' ? '改用 GitHub 更新' : '改用 Mirror 酱更新')); ctl = openDialog({ From 240e74370fcd43e7464003eb25a99cdd0d0d2190 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Mon, 9 Nov 2026 17:05:33 +0100 Subject: [PATCH 2/6] =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=83=BD=E7=94=A8=20AUTO-MAS=EF=BC=9A?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E7=BB=84=E4=BB=B6=E3=80=81=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E5=8C=85=E5=92=8C=E9=85=8D=E7=BD=AE=E4=BF=A1=E6=81=AF=E7=9A=84?= =?UTF-8?q?=E6=96=87=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补全组件完成:先说可以新建实例了,再说用 AUTO-MAS 的可以去添加。 - 问题包没找到 AUTO-MAS:只请用 AUTO-MAS 的附上它的日志,不用的不用管 (管理器、命令行、summary.txt 三处);隐私说明里 AUTO-MAS 日志写成 找到它时才有;qemu 命令行读不到时不再说多半是 AUTO-MAS 以管理员运行。 - 复制配置信息:「AUTO-MAS 版本」「用的脚本」两行并成「怎么用的 (自己玩 / AUTO-MAS 及版本 / MAA、M9A 等脚本)」;没开实例时不再叫人 在 AUTO-MAS 里开着实例再双击运行。 - portBase 的建议写「默认值」,不写「和 AUTO-MAS 一样」;换包恢复失败的 提示先说下次更新时恢复。 对照旧脚本输出的测试照原来的做法,把这些有意改动的行列进对照表,基准文件不动。 --- internal/cli/commands.go | 2 +- internal/cli/components_test.go | 2 +- internal/cli/format.go | 2 +- internal/cli/report_test.go | 3 ++ internal/components/fetch.go | 4 +-- internal/components/fetch_windows_test.go | 2 +- internal/report/collect.go | 8 ++--- internal/report/config.go | 6 ++-- internal/report/parity_windows_test.go | 40 +++++++++++++++++++---- internal/report/report.go | 13 +++++--- internal/root/root.go | 2 +- internal/ui/web/js/diagnostics.js | 2 +- internal/update/swap.go | 2 +- 13 files changed, 62 insertions(+), 26 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index c1c25ed..89441b5 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -933,7 +933,7 @@ func humanCollectInfo(w io.Writer, data any) { fmt.Fprintln(w, l) } if !r.MasFound { - fmt.Fprintln(w, "没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志") + fmt.Fprintln(w, report.MasMissingNote) } if len(r.Problems) > 0 { fmt.Fprintf(w, "有 %d 节没收集成功,原因写在 zip 里的 summary.txt,照样提交就行。\n", len(r.Problems)) diff --git a/internal/cli/components_test.go b/internal/cli/components_test.go index 050aa35..4725106 100644 --- a/internal/cli/components_test.go +++ b/internal/cli/components_test.go @@ -186,7 +186,7 @@ func TestComponentsFetchOptions(t *testing.T) { t.Fatalf("quiet: %q", res.stderr) } if !strings.Contains(res.stdout, "已装好:平台工具(adb)(版本 37.0.1)") || !strings.Contains(res.stdout, "原来不完整的目录改名为") || - !strings.Contains(res.stdout, "完成,可以在 AUTO-MAS 里添加这个文件夹了。") { + !strings.Contains(res.stdout, "完成,可以新建实例了;用 AUTO-MAS 的,也可以在 AUTO-MAS 里添加这个文件夹了。") { t.Fatalf("summary %q", res.stdout) } } diff --git a/internal/cli/format.go b/internal/cli/format.go index 6d9914a..e6c4768 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -602,7 +602,7 @@ func formatComponents(w io.Writer, data any) { if !fetched { fmt.Fprint(w, "组件都已齐全,不需要下载。") } - fmt.Fprintln(w, "完成,可以在 AUTO-MAS 里添加这个文件夹了。") + fmt.Fprintln(w, "完成,可以新建实例了;用 AUTO-MAS 的,也可以在 AUTO-MAS 里添加这个文件夹了。") } } diff --git a/internal/cli/report_test.go b/internal/cli/report_test.go index 603c587..7324fb1 100644 --- a/internal/cli/report_test.go +++ b/internal/cli/report_test.go @@ -54,6 +54,9 @@ func TestHumanCollectInfoMatchesScript(t *testing.T) { t.Fatalf("%s: the script's privacy note head not found once", c.name) } want = strings.Replace(want, scriptPrivacyHead+"\n", head+"\n", 1) + // and for what the script said as if every user had AUTO-MAS (report's notAllMAS) + want = strings.Replace(want, "实例配置、AUTO-MAS 日志(mas 文件夹);", "实例配置;找到 AUTO-MAS 时还有它的日志(mas 文件夹);", 1) + want = strings.Replace(want, "没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志\n", report.MasMissingNote+"\n", 1) res := &report.CollectResult{ ZipPath: `{{BASE}}\out\魔改AVD问题信息-20260115-120000.zip`, ZipSize: "{{SIZE}}", PrivacyNote: report.PrivacyNote, MasFound: c.mas, Problems: c.problems, diff --git a/internal/components/fetch.go b/internal/components/fetch.go index f4f0864..3ea6a9a 100644 --- a/internal/components/fetch.go +++ b/internal/components/fetch.go @@ -459,7 +459,7 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) { if f.darwin() { f.say("组件都已齐全,不需要下载。当前 macOS 版本暂未开放实例创建与启动。") } else { - f.say("组件都已齐全,不需要下载。完成,可以在 AUTO-MAS 里添加这个文件夹了。") + f.say("组件都已齐全,不需要下载。完成,可以新建实例了;用 AUTO-MAS 的,也可以在 AUTO-MAS 里添加这个文件夹了。") } return res, nil } @@ -534,7 +534,7 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) { if f.darwin() { f.say("组件补全完成。当前 macOS 版本暂未开放实例创建与启动。") } else { - f.say("完成,可以在 AUTO-MAS 里添加这个文件夹了。") + f.say("完成,可以新建实例了;用 AUTO-MAS 的,也可以在 AUTO-MAS 里添加这个文件夹了。") } return res, nil } diff --git a/internal/components/fetch_windows_test.go b/internal/components/fetch_windows_test.go index 08058da..3cec353 100644 --- a/internal/components/fetch_windows_test.go +++ b/internal/components/fetch_windows_test.go @@ -471,7 +471,7 @@ func TestFetchInstallsThenSkips(t *testing.T) { t.Errorf("the download source line is not before the license:\n%s", logged) } for _, s := range []string{"参数:AcceptLicense=true", " URL " + m.url() + "pt.zip", "下载结束:", "退出码 0", - "完成,可以在 AUTO-MAS 里添加这个文件夹了。"} { + "完成,可以新建实例了;用 AUTO-MAS 的,也可以在 AUTO-MAS 里添加这个文件夹了。"} { if !strings.Contains(logged, s) { t.Errorf("log lacks %q:\n%s", s, logged) } diff --git a/internal/report/collect.go b/internal/report/collect.go index cdb35b2..d7e84e8 100644 --- a/internal/report/collect.go +++ b/internal/report/collect.go @@ -293,7 +293,7 @@ func collect(ctx context.Context, rootDir string, h Host, opt CollectOptions) (r } if !c.masFound { c.L("") - c.L("提示:没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志。") + c.L("提示:" + MasMissingNote) } res := &CollectResult{Stage: stage, Problems: append([]string{}, c.problems...), MasFound: c.masFound, @@ -747,7 +747,7 @@ func (c *collector) runningAndAdb() error { c.L(fmt.Sprintf("pid %d 启动于 %s", q.PID, ct)) cmd := q.CommandLine if cmd == "" { - c.L(" qemu 命令行读不到(多半是 AUTO-MAS 以管理员运行),现场按 adb 设备列表推算") + c.L(" qemu 命令行读不到(多半是模拟器以管理员身份运行),现场按 adb 设备列表推算") c.cmdlineUnreadable = true continue } @@ -1458,7 +1458,7 @@ func (c *collector) autoMAS() error { } } if loc == "" { - c.L("没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志") + c.L("没找到 AUTO-MAS 安装目录(不用 AUTO-MAS 的不用管)") return nil } c.masFound = true @@ -1608,7 +1608,7 @@ func (c *collector) live() error { targets := append([]liveTarget(nil), c.running...) if c.cmdlineUnreadable { // fallback: the emulator- serials of the device list, mapped to instance names with the port plan - c.L("qemu 命令行读不到(多半是 AUTO-MAS 以管理员运行),现场按 adb 设备列表推算") + c.L("qemu 命令行读不到(多半是模拟器以管理员身份运行),现场按 adb 设备列表推算") res := c.adbDevicesCommand() if res.TimedOut { c.L(" adb devices:超时(20 秒),已放弃") diff --git a/internal/report/config.go b/internal/report/config.go index 4f24d4e..0592e90 100644 --- a/internal/report/config.go +++ b/internal/report/config.go @@ -46,13 +46,13 @@ func GameName(pkg string) (string, bool) { var notGames = []string{"de.jrpie.android.launcher"} // The lines the user fills in. -var configTail = []string{"AUTO-MAS 版本:", "用的脚本(MAA / M9A / …):", "效果(能跑 / 有问题,问题是…):"} +var configTail = []string{"怎么用的(自己玩 / AUTO-MAS 及版本 / MAA、M9A 等脚本):", "效果(能跑 / 有问题,问题是…):"} const ( configTitle = "【魔改 AVD 试用配置】" - configNotOpen = "游戏包:没有实例开着,读不到。在 AUTO-MAS 里开着实例时再双击运行一次,就会列出来" + configNotOpen = "游戏包:没有实例开着,读不到。开着实例时再生成一次,就会列出来" emuCheckWait = 20 * time.Second - clipboardOK = "已复制到剪贴板:粘贴到群里,再把最后三行补上就行。" + clipboardOK = "已复制到剪贴板:粘贴到群里,再把最后两行补上就行。" clipboardError = "复制到剪贴板失败(%s),请手动选中上面的文字复制。" ) diff --git a/internal/report/parity_windows_test.go b/internal/report/parity_windows_test.go index 7dff44f..53082a7 100644 --- a/internal/report/parity_windows_test.go +++ b/internal/report/parity_windows_test.go @@ -161,11 +161,23 @@ func modavdPrivacyHead() string { return head } +// notAllMAS: what the scripts said as if every user had AUTO-MAS, and what modavd says instead (many use ModAVD +// without it). Applied in order, to the scripts' lines. +var notAllMAS = []struct{ script, modavd string }{ + {"提示:没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志。", "提示:" + MasMissingNote}, + {"没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志", "没找到 AUTO-MAS 安装目录(不用 AUTO-MAS 的不用管)"}, + {"(多半是 AUTO-MAS 以管理员运行)", "(多半是模拟器以管理员身份运行)"}, + {"实例配置、AUTO-MAS 日志(mas 文件夹);", "实例配置;找到 AUTO-MAS 时还有它的日志(mas 文件夹);"}, + {"在 AUTO-MAS 里开着实例时再双击运行一次", "开着实例时再生成一次"}, + {"再把最后三行补上就行。", "再把最后两行补上就行。"}, +} + // modavdLine is what modavd writes on purpose instead of a line of collect-info.ps1's summary.txt: // - the PowerShell that ran the script: modavd's version and Go version; // - the adb.exe the live section runs: modavd speaks the adb host protocol itself (no adb.exe, never a server of // its own), to the server that listens on the port; -// - the first line of the privacy note: who does not upload is the manager or the CLI, not a script. +// - the first line of the privacy note: who does not upload is the manager or the CLI, not a script; +// - notAllMAS. func (f *fixture) modavdLine(line string) string { if line == "PowerShell:{{PSVERSION}}" { return "modavd:" + buildinfo.Version + "(" + runtime.Version() + ")" @@ -173,6 +185,9 @@ func (f *fixture) modavdLine(line string) string { if line == scriptPrivacyHead { return modavdPrivacyHead() } + for _, r := range notAllMAS { + line = strings.Replace(line, r.script, r.modavd, 1) + } if strings.HasPrefix(line, "adb:") && strings.Contains(line, " -P "+strconv.Itoa(f.adbPort)+"(") { var pid uint32 for _, l := range f.sc.Listeners { @@ -366,14 +381,17 @@ func TestCollectMatchesScript(t *testing.T) { } } - // the end of the console output: zip path, the privacy note word for word but for its first line + // the end of the console output: zip path, the privacy note word for word but for modavdLine's console, _ := f.golden(t, name, "collect-console.txt") if !containsLine(lines(console), "文件:"+res.ZipPath) { t.Errorf("zip %s, the script printed:\n%s", res.ZipPath, console) } - scriptNote := scriptPrivacyHead + strings.TrimPrefix(PrivacyNote, modavdPrivacyHead()) - if !strings.Contains(console, scriptNote) || res.PrivacyNote != PrivacyNote { - t.Error("the privacy note is not the one collect-info.ps1 prints, but for its first line") + var consoleLines []string + for _, l := range lines(console) { + consoleLines = append(consoleLines, f.modavdLine(l)) + } + if !strings.Contains(strings.Join(consoleLines, "\r\n"), PrivacyNote) || res.PrivacyNote != PrivacyNote { + t.Error("the privacy note is not the one collect-info.ps1 prints, but for modavdLine's") } if strings.Contains(PrivacyNote, "脚本") || !strings.HasPrefix(PrivacyNote, "隐私提示:问题包只保存在本机,") { t.Errorf("privacy note head %q", modavdPrivacyHead()) @@ -407,7 +425,17 @@ func TestReportConfigMatchesScript(t *testing.T) { } // the script prints a blank line, the lines, a blank line and what happened to the clipboard want, _ := f.golden(t, name, "report-config.txt") - wl := lines(want) + var wl []string + for _, l := range lines(want) { + // the script asked for the AUTO-MAS version and the scripts on two lines; modavd asks how it is used + if l == "用的脚本(MAA / M9A / …):" { + continue + } + if l == "AUTO-MAS 版本:" { + l = configTail[0] + } + wl = append(wl, f.modavdLine(l)) + } if len(wl) < 3 || wl[0] != "" || wl[len(wl)-2] != "" { t.Fatalf("unexpected golden shape: %q", wl) } diff --git a/internal/report/report.go b/internal/report/report.go index 07b55e1..0993c32 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -28,15 +28,20 @@ import ( "modavd/internal/root" ) -// PrivacyNote is the head of summary.txt and the last thing printed: collect-info.ps1's $PrivacyNote word for word, -// but for the first line. The script's said "脚本不会上传"; from ModAVD 0.38.2 the package has no such script any -// more (ModAVD/ModAVD#45), and what collects and what could upload is the manager or the CLI, so it says those. +// PrivacyNote is the head of summary.txt and the last thing printed: collect-info.ps1's $PrivacyNote, but for the +// first line and the AUTO-MAS logs. The script's said "脚本不会上传"; from ModAVD 0.38.2 the package has no such +// script any more (ModAVD/ModAVD#45), and what collects and what could upload is the manager or the CLI, so it says +// those. The script listed the AUTO-MAS logs as if every user had AUTO-MAS; they are there only when it is found. const PrivacyNote = "隐私提示:问题包只保存在本机,管理器和命令行都不会上传它。\r\n" + - "- 里面有:summary.txt(电脑和模拟器的情况)、模拟器日志、实例配置、AUTO-MAS 日志(mas 文件夹);有实例在运行时还有现场截图和安卓状态(live 文件夹);模拟器近 7 天崩溃过的话还有崩溃转储(crash 文件夹)。不收磁盘镜像,不收 AUTO-MAS 的配置和账号数据,也不会有意收集账号密码。\r\n" + + "- 里面有:summary.txt(电脑和模拟器的情况)、模拟器日志、实例配置;找到 AUTO-MAS 时还有它的日志(mas 文件夹);有实例在运行时还有现场截图和安卓状态(live 文件夹);模拟器近 7 天崩溃过的话还有崩溃转储(crash 文件夹)。不收磁盘镜像,不收 AUTO-MAS 的配置和账号数据,也不会有意收集账号密码。\r\n" + "- 这些文件里都有本机路径,路径里可能有 Windows 用户名;截图和 AUTO-MAS 日志里可能有游戏账号名。\r\n" + "- 可以公开:在 GitHub Issue(https://github.com/ModAVD/ModAVD/issues)里贴 summary.txt 和日志,或者上传删掉 crash 文件夹后的问题包。上传前看一眼,不想公开的内容先删掉。\r\n" + "- 不要公开:crash 文件夹(崩溃转储,含模拟器进程的线程栈和少量内存片段)。带 crash 文件夹的完整问题包请发邮件到 qiyinxi@foxmail.com,邮件里写上 Issue 编号。" +// MasMissingNote: shown after collecting when no AUTO-MAS installation was found. Many users do not use AUTO-MAS, so +// it asks only those who do for its logs. +const MasMissingNote = "没找到 AUTO-MAS 安装目录。用 AUTO-MAS 的,反馈时请另附上它 debug 文件夹里的日志;不用 AUTO-MAS 的不用管。" + // ---- .NET number formatting -------------------------------------------------------------------------------------- // formatN is '{0:N}' -f v of Windows PowerShell 5.1 (.NET Framework, en-US / zh-CN): the double cut to 15 diff --git a/internal/root/root.go b/internal/root/root.go index e90e05f..3b8e21c 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -162,7 +162,7 @@ func (r *Root) checkPorts(allowLegacy bool) *errs.Error { legacy := p.Base == 5500 || p.Base > maxPortBase if problem != "" && !(allowLegacy && legacy) { return errs.New(errs.InvalidArgument, problem).WithAdvice(fmt.Sprintf( - "先关掉这个根目录的所有实例,再把 portBase 改成 %d(和 AUTO-MAS 一样),或者 %d–%d 之间 100 的整数倍,避开 5500(开发和测试用的根目录用 21000);原配置仍可查状态和关机", + "先关掉这个根目录的所有实例,再把 portBase 改成 %d(默认值),或者 %d–%d 之间 100 的整数倍,避开 5500(开发和测试用的根目录用 21000);原配置仍可查状态和关机", DefaultPortBase, minPortBase, maxPortBase)) } if p.Base == DefaultPortBase { diff --git a/internal/ui/web/js/diagnostics.js b/internal/ui/web/js/diagnostics.js index d7bf663..b333872 100644 --- a/internal/ui/web/js/diagnostics.js +++ b/internal/ui/web/js/diagnostics.js @@ -391,7 +391,7 @@ function collectRows(j) { () => api.post('/api/open', { what: 'collect-info', job: j.id }).catch(toastError)))]; if (r.sections?.length) rows.push(h('ul', { class: 'diag-sections' }, ...r.sections.map((x) => h('li', { class: x.error ? 'tone-err' : 'tone-ok', text: `${x.name}:${x.error || '完成'}` })))); else if (r.problems?.length) rows.push(h('p', { class: 'f-hint', text: `有 ${r.problems.length} 节没收集成功,原因写在包里的 summary.txt,照样提交就行。` })); - if (r.mas_found === false) rows.push(h('p', { class: 'f-hint', text: '没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志。' })); + if (r.mas_found === false) rows.push(h('p', { class: 'f-hint', text: '没找到 AUTO-MAS 安装目录。用 AUTO-MAS 的,反馈时请另附上它 debug 文件夹里的日志;不用 AUTO-MAS 的不用管。' })); return rows; } diff --git a/internal/update/swap.go b/internal/update/swap.go index a947cf3..c39c2fe 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -361,7 +361,7 @@ func (u *Updater) recoverAfter(err error) error { msg, rerr := u.recoverState() switch { case rerr != nil: - msg = fmt.Sprintf("当场恢复也失败了(%v),下次更新或 AUTO-MAS 启动时会再按记录恢复", rerr) + msg = fmt.Sprintf("当场恢复也失败了(%v),下次更新时会再按记录恢复(用 AUTO-MAS 的,它启动时也会)", rerr) case msg == "": msg = "状态已经完整" } From 67b6abf7bdc4f7af8b8d0be84ee729ce99b4d236 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Mon, 9 Nov 2026 17:14:57 +0100 Subject: [PATCH 3/6] =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E9=87=8C=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=8F=AB=20ModAVD=EF=BC=8C=E4=B8=8D=E5=86=8D=E7=94=A8?= =?UTF-8?q?=E3=80=8C=E9=AD=94=E6=94=B9=20AVD=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 所有语言都只用 ModAVD 这个名字:日志、报错、命令行帮助、问题包和配置信息的 标题都换掉,问题包文件名改成 ModAVD-问题信息-<时间>.zip。和中文相邻时加空格。 AUTO-MAS 只看错误码,不看消息文字,改名不影响对接。对照旧脚本输出的测试把 旧名字列进有意改动的对照表;对照 AUTO-MAS 原逻辑的 Python 参考和 jsonord 的 样例跟着换了文字,pycases.json 用 gen_pycases.py 重新生成。 --- README.en.md | 2 +- README.md | 4 +-- cmd/modavd-gui/main.go | 4 +-- internal/adb/adb.go | 6 ++-- internal/adb/adb_test.go | 4 +-- internal/adb/real_adb_test.go | 6 ++-- internal/avd/avd.go | 12 ++++---- internal/avd/avd_test.go | 14 ++++----- internal/avd/testdata/instances_ref.py | 8 ++--- internal/cli/commands.go | 8 ++--- internal/cli/format.go | 2 +- internal/cli/report_test.go | 5 ++-- internal/components/fetch.go | 6 ++-- internal/components/playstore.go | 6 ++-- internal/emu/adbcmd.go | 2 +- internal/emu/bootdiag_windows_test.go | 2 +- internal/emu/bridge.go | 6 ++-- internal/emu/check.go | 22 +++++++------- internal/emu/check_darwin.go | 2 +- internal/emu/check_test.go | 6 ++-- internal/emu/createinit.go | 8 ++--- internal/emu/crud.go | 22 +++++++------- internal/emu/crud_test.go | 2 +- internal/emu/disk.go | 16 +++++----- internal/emu/disk_lock.go | 2 +- internal/emu/firstboot.go | 10 +++---- internal/emu/install.go | 8 ++--- internal/emu/launch.go | 10 +++---- internal/emu/launch_test.go | 2 +- internal/emu/playstore.go | 2 +- internal/emu/start.go | 16 +++++----- internal/emu/stop.go | 38 ++++++++++++------------ internal/emu/stop_test.go | 14 ++++----- internal/emu/window.go | 10 +++---- internal/jsonord/testdata/gen_pycases.py | 2 +- internal/jsonord/testdata/pycases.json | 4 +-- internal/mcp/mcp_test.go | 4 +-- internal/report/collect.go | 6 ++-- internal/report/config.go | 2 +- internal/report/parity_windows_test.go | 19 +++++++----- internal/root/root.go | 18 +++++------ internal/root/root_test.go | 6 ++-- internal/ui/fake_test.go | 12 ++++---- internal/ui/proxy_test.go | 2 +- internal/ui/ui.go | 2 +- internal/ui/web/dev/mock.js | 38 ++++++++++++------------ internal/update/pkg.go | 2 +- internal/update/updater.go | 2 +- 48 files changed, 205 insertions(+), 201 deletions(-) diff --git a/README.en.md b/README.en.md index 4ffb23b..1e5fb46 100644 --- a/README.en.md +++ b/README.en.md @@ -259,7 +259,7 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis ## Updating ModAVD -`modavd update` updates the whole ModAVD package in the root folder (the emulator, components and scripts, and also `ModAVD.exe` and `tools\modavd.exe` themselves). It does the same thing as AUTO-MAS's "ModAVD one-click update" (`魔改 AVD 一键更新`), and uses the same `\.update\` folder and state file: if an update is interrupted partway on either side, the other can finish it or roll it back. Under the current integration agreement, AUTO-MAS keeps its own updater for now; whether and when it switches to calling this command is up to the AUTO-MAS project's own maintenance, and this is not a feature or promise already delivered by this repository. +`modavd update` updates the whole ModAVD package in the root folder (the emulator, components and scripts, and also `ModAVD.exe` and `tools\modavd.exe` themselves). It does the same thing as AUTO-MAS's ModAVD one-click update, and uses the same `\.update\` folder and state file: if an update is interrupted partway on either side, the other can finish it or roll it back. Under the current integration agreement, AUTO-MAS keeps its own updater for now; whether and when it switches to calling this command is up to the AUTO-MAS project's own maintenance, and this is not a feature or promise already delivered by this repository. - It does only the step you explicitly ask for; it never goes online automatically and never downloads in the background: - `modavd update` (`status`) looks at local state only; `check` looks up the latest version; diff --git a/README.md b/README.md index ecf5311..f1a4254 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ -[ModAVD(魔改 AVD)](https://github.com/ModAVD/ModAVD) 的管理程序:图形管理器 `ModAVD.exe`,以及命令行和 MCP 服务 `modavd.exe`。Go 写,只用标准库。目前只支持 Windows x64,平台相关的部分已经拆成平台层,其他平台的安排见 [ModAVD#27](https://github.com/ModAVD/ModAVD/issues/27)。 +[ModAVD](https://github.com/ModAVD/ModAVD) 的管理程序:图形管理器 `ModAVD.exe`,以及命令行和 MCP 服务 `modavd.exe`。Go 写,只用标准库。目前只支持 Windows x64,平台相关的部分已经拆成平台层,其他平台的安排见 [ModAVD#27](https://github.com/ModAVD/ModAVD/issues/27)。 > **版本提示(`main` 与 Release)**:本文按仓库 `main` 的实现编写。当前 `main` 的版本是 `0.3.0`,最新独立 Release 是 `v0.1.0`(对应 ModAVD `0.36.0`),`v0.2.0` 已起草、还没发布;`0.2.0` 起的新建后台初始化、`update` 命令和退出码 9 都不在 `v0.1.0` 下载包里。使用已发布包时,请以对应 Release 的说明和包内版本为准。 @@ -261,7 +261,7 @@ claude mcp add --transport stdio --scope user modavd '--' D:\ModAVD\tools\modavd ## 更新 ModAVD -`modavd update` 更新根目录里的整个 ModAVD 包(模拟器、组件、脚本,也包括 `ModAVD.exe` 和 `tools\modavd.exe` 自己),和 AUTO-MAS 的「魔改 AVD 一键更新」做的是同一件事,用的也是同一个 `<根>\.update\` 目录和状态文件:哪边更新到一半被打断,另一边都能接着补完或退回。按当前对接约定,AUTO-MAS 目前保留自己的更新器;是否以及何时改成调用这个命令,由 AUTO-MAS 项目自己的维护决定,这不是本仓库已经交付的功能或承诺。 +`modavd update` 更新根目录里的整个 ModAVD 包(模拟器、组件、脚本,也包括 `ModAVD.exe` 和 `tools\modavd.exe` 自己),和 AUTO-MAS 里的 ModAVD 一键更新做的是同一件事,用的也是同一个 `<根>\.update\` 目录和状态文件:哪边更新到一半被打断,另一边都能接着补完或退回。按当前对接约定,AUTO-MAS 目前保留自己的更新器;是否以及何时改成调用这个命令,由 AUTO-MAS 项目自己的维护决定,这不是本仓库已经交付的功能或承诺。 - 只做明确要求的那一步,不自动联网、不在后台下载: - `modavd update`(`status`)只看本地;`check` 查最新版本; diff --git a/cmd/modavd-gui/main.go b/cmd/modavd-gui/main.go index 3565eb2..487db61 100644 --- a/cmd/modavd-gui/main.go +++ b/cmd/modavd-gui/main.go @@ -111,7 +111,7 @@ func run(args []string) (code int) { fs := flag.NewFlagSet("ModAVD", flag.ContinueOnError) fs.SetOutput(io.Discard) - rootFlag := fs.String("root", "", "魔改 AVD 根目录") + rootFlag := fs.String("root", "", "ModAVD 根目录") port := fs.Int("port", 0, "监听端口(0 = 随机)") noWindow := fs.Bool("no-window", false, "不打开窗口") dev := fs.Bool("dev", false, "开发模式:提供 /dev/ 的模拟后端页面,没有窗口也不自动退出") @@ -134,7 +134,7 @@ func run(args []string) (code int) { } r, err := root.Resolve(*rootFlag) if err != nil { - fatalErr("找不到魔改 AVD 根目录", err) + fatalErr("找不到 ModAVD 根目录", err) return errs.ExitCode(err) } diff --git a/internal/adb/adb.go b/internal/adb/adb.go index c08c31c..9a89c64 100644 --- a/internal/adb/adb.go +++ b/internal/adb/adb.go @@ -224,7 +224,7 @@ var ( func (c *Client) portConflict(k serverKind, port int, pid uint32, name string) *errs.Error { return errs.Newf(errs.PortConflict, - "魔改 AVD 的%s adb 端口 %d 被 %s(PID %d)占用,"+serverConflictReason+"。请在 %s 里把 %s 改成一个没被占用的端口"+ + "ModAVD 的%s adb 端口 %d 被 %s(PID %d)占用,"+serverConflictReason+"。请在 %s 里把 %s 改成一个没被占用的端口"+ "(不能用 5037 和各实例的控制台 / adb / gRPC 端口,也不能和 %s 相同),或者先关掉占用它的程序", k.label, port, name, pid, c.Root.MetadataPath(), k.key, k.otherKey) } @@ -321,7 +321,7 @@ func (c *Client) ensure(ctx context.Context, k serverKind, port int, timeout tim if err := prepareServerRoot(c.Root); err != nil { return false, err } - c.Log.Infof("启动魔改 AVD %s adb server(端口 %d)", k.label, port) + c.Log.Infof("启动 ModAVD %s adb server(端口 %d)", k.label, port) // StartOrphan rather than StartDetached: an MCP client ends modavd's whole process tree when it disconnects, and // while the start-server client (our child for ~2 s) is alive the server it forks is part of that tree. Losing // the server is not harmless: the emulator registers with it only once, at boot. Without a configured orphan @@ -329,7 +329,7 @@ func (c *Client) ensure(ctx context.Context, k serverKind, port int, timeout tim client, err := host.StartOrphan(c.Root.AdbExe(), []string{"-P", strconv.Itoa(port), "start-server"}, host.StartOptions{Env: c.Env()}) if err != nil { - return false, errs.Wrap(errs.Internal, err, fmt.Sprintf("启动魔改 AVD %s adb server(端口 %d)失败: %s", + return false, errs.Wrap(errs.Internal, err, fmt.Sprintf("启动 ModAVD %s adb server(端口 %d)失败: %s", k.label, port, errs.As(err).Message)) } return c.waitListening(ctx, k, port, timeout, client) diff --git a/internal/adb/adb_test.go b/internal/adb/adb_test.go index 8b66619..56d953d 100644 --- a/internal/adb/adb_test.go +++ b/internal/adb/adb_test.go @@ -351,14 +351,14 @@ func TestPortConflictMessage(t *testing.T) { dir := tempDir(t) c := New(&root.Root{Dir: dir}, nil) e := c.portConflict(privateServer, 20050, 1234, "foo.exe") - want := "魔改 AVD 的私有 adb 端口 20050 被 foo.exe(PID 1234)占用,它不是 adb。请在 " + c.Root.MetadataPath() + + want := "ModAVD 的私有 adb 端口 20050 被 foo.exe(PID 1234)占用,它不是 adb。请在 " + c.Root.MetadataPath() + " 里把 adbServerPort 改成一个没被占用的端口(不能用 5037 和各实例的控制台 / adb / gRPC 端口,也不能和 " + "scriptAdbServerPort 相同),或者先关掉占用它的程序" if e.Code != errs.PortConflict || e.Error() != want { t.Errorf("private conflict:\n got %s %q\nwant %q", e.Code, e.Error(), want) } e = c.portConflict(scriptServer, 20049, 1, "x.exe") - if !strings.HasPrefix(e.Message, "魔改 AVD 的脚本专用 adb 端口 20049 被 x.exe(PID 1)占用") || + if !strings.HasPrefix(e.Message, "ModAVD 的脚本专用 adb 端口 20049 被 x.exe(PID 1)占用") || !strings.Contains(e.Message, "里把 scriptAdbServerPort 改成") || !strings.Contains(e.Message, "也不能和 adbServerPort 相同") { t.Errorf("script conflict: %q", e.Message) } diff --git a/internal/adb/real_adb_test.go b/internal/adb/real_adb_test.go index e003776..c6b2c22 100644 --- a/internal/adb/real_adb_test.go +++ b/internal/adb/real_adb_test.go @@ -186,7 +186,7 @@ func TestRealAdb(t *testing.T) { t.Fatalf("EnsureServer = %v, %v", ok, err) } t.Logf("EnsureServer took %v", time.Since(start)) - if n := logs.count(fmt.Sprintf("启动魔改 AVD 私有 adb server(端口 %d)", testServerPort)); n != 1 { + if n := logs.count(fmt.Sprintf("启动 ModAVD 私有 adb server(端口 %d)", testServerPort)); n != 1 { t.Errorf("start log lines = %d", n) } pid, up, err := win.ListenerPID(testServerPort) @@ -205,7 +205,7 @@ func TestRealAdb(t *testing.T) { if err != nil || !ok { t.Errorf("second EnsureServer = %v, %v", ok, err) } - if n := logs.count("启动魔改 AVD 私有 adb server"); n != 1 { + if n := logs.count("启动 ModAVD 私有 adb server"); n != 1 { t.Errorf("second EnsureServer started again (%d log lines)", n) } // PrepareForClose with the server up asks it first; it does not know emulator-20080, and no qemu of this @@ -277,7 +277,7 @@ func TestRealAdb(t *testing.T) { if err != nil || !ok { t.Fatalf("EnsureScriptServer = %v, %v", ok, err) } - if n := logs.count(fmt.Sprintf("启动魔改 AVD 脚本专用 adb server(端口 %d)", testScriptPort)); n != 1 { + if n := logs.count(fmt.Sprintf("启动 ModAVD 脚本专用 adb server(端口 %d)", testScriptPort)); n != 1 { t.Errorf("script start log lines = %d", n) } pid, _, _ := win.ListenerPID(testScriptPort) diff --git a/internal/avd/avd.go b/internal/avd/avd.go index 4924829..a590af2 100644 --- a/internal/avd/avd.go +++ b/internal/avd/avd.go @@ -426,7 +426,7 @@ func Get(r *root.Root, i int) (*Instance, error) { return nil, notFound(i) } -func notFound(i int) error { return errs.Newf(errs.NotFound, "魔改 AVD 实例 %d 不存在", i) } +func notFound(i int) error { return errs.Newf(errs.NotFound, "ModAVD 实例 %d 不存在", i) } // lockInstance takes root.LockInstanceConfig of i. noDir: the avd directory does not exist (no lock taken, nothing on // disk to guard); err is nil then. @@ -509,10 +509,10 @@ func ValidateOptions(memoryMB, cpu, dataGB *int) (int, int, int, error) { data = *dataGB } if !slices.Contains(MemoryChoicesMB, memory) { - return 0, 0, 0, errs.New(errs.InvalidArgument, "魔改 AVD 的内存只能选 "+joinInts(MemoryChoicesMB, "%d GB", 1024)) + return 0, 0, 0, errs.New(errs.InvalidArgument, "ModAVD 的内存只能选 "+joinInts(MemoryChoicesMB, "%d GB", 1024)) } if !slices.Contains(CPUChoices, cores) { - return 0, 0, 0, errs.New(errs.InvalidArgument, "魔改 AVD 的 CPU 核数只能选 "+joinInts(CPUChoices, "%d", 1)) + return 0, 0, 0, errs.New(errs.InvalidArgument, "ModAVD 的 CPU 核数只能选 "+joinInts(CPUChoices, "%d", 1)) } if data < MinDataPartitionGB || data > MaxDataPartitionGB { return 0, 0, 0, errs.Newf(errs.InvalidArgument, "数据盘上限只能在 %d–%d GB 之间", MinDataPartitionGB, @@ -528,7 +528,7 @@ func ValidateFPS(fps *int) (int, error) { value = *fps } if !slices.Contains(FPSChoices, value) { - return 0, errs.New(errs.InvalidArgument, "魔改 AVD 的帧率只能选 "+joinInts(FPSChoices, "%d", 1)) + return 0, errs.New(errs.InvalidArgument, "ModAVD 的帧率只能选 "+joinInts(FPSChoices, "%d", 1)) } return value, nil } @@ -714,7 +714,7 @@ func pathExists(path string) bool { } // FreeIndex is the smallest valid index that has no instance and whose three ports in plan busy() reports free. -// errs.Busy "魔改 AVD 实例已满(端口段 20000–20100 最多 9 台)" (the root's plan) when there is none. A nil busy skips +// errs.Busy "ModAVD 实例已满(端口段 20000–20100 最多 9 台)" (the root's plan) when there is none. A nil busy skips // the port check. func FreeIndex(plan root.PortPlan, existing map[int]bool, busy func(ports []int) []int) (int, error) { for i := 0; i <= root.MaxIndex; i++ { @@ -727,7 +727,7 @@ func FreeIndex(plan root.PortPlan, existing map[int]bool, busy func(ports []int) } return i, nil } - return 0, errs.Newf(errs.Busy, "魔改 AVD 实例已满(端口段 %d–%d 最多 9 台)", plan.Base, plan.Base+100) + return 0, errs.Newf(errs.Busy, "ModAVD 实例已满(端口段 %d–%d 最多 9 台)", plan.Base, plan.Base+100) } type UpdateOptions struct { diff --git a/internal/avd/avd_test.go b/internal/avd/avd_test.go index c4cffdb..d0d3360 100644 --- a/internal/avd/avd_test.go +++ b/internal/avd/avd_test.go @@ -635,7 +635,7 @@ func TestListAndGet(t *testing.T) { os.Remove(r.AvdHome()) _, err := Get(r, 0) wantCode(t, err, errs.NotFound) - if err.Error() != "魔改 AVD 实例 0 不存在" { + if err.Error() != "ModAVD 实例 0 不存在" { t.Fatalf("message: %v", err) } for _, i := range []int{4, 0, 9} { @@ -727,9 +727,9 @@ func TestValidate(t *testing.T) { memory, cpu, data *int msg string }{ - {ip(1000), nil, nil, "魔改 AVD 的内存只能选 3 GB / 4 GB / 5 GB / 6 GB"}, - {ip(0), ip(3), ip(1), "魔改 AVD 的内存只能选 3 GB / 4 GB / 5 GB / 6 GB"}, - {nil, ip(3), ip(1), "魔改 AVD 的 CPU 核数只能选 2 / 4 / 6"}, + {ip(1000), nil, nil, "ModAVD 的内存只能选 3 GB / 4 GB / 5 GB / 6 GB"}, + {ip(0), ip(3), ip(1), "ModAVD 的内存只能选 3 GB / 4 GB / 5 GB / 6 GB"}, + {nil, ip(3), ip(1), "ModAVD 的 CPU 核数只能选 2 / 4 / 6"}, {nil, nil, ip(15), "数据盘上限只能在 16–512 GB 之间"}, {nil, nil, ip(513), "数据盘上限只能在 16–512 GB 之间"}, } { @@ -753,7 +753,7 @@ func TestValidate(t *testing.T) { } _, err = ValidateFPS(ip(0)) wantCode(t, err, errs.InvalidArgument) - if err.Error() != "魔改 AVD 的帧率只能选 30 / 60" { + if err.Error() != "ModAVD 的帧率只能选 30 / 60" { t.Errorf("fps message %q", err.Error()) } } @@ -805,7 +805,7 @@ func TestFreeIndex(t *testing.T) { } _, err = FreeIndex(def, all, func(ports []int) []int { return ports }) wantCode(t, err, errs.Busy) - if err.Error() != "魔改 AVD 实例已满(端口段 20000–20100 最多 9 台)" { + if err.Error() != "ModAVD 实例已满(端口段 20000–20100 最多 9 台)" { t.Fatalf("message %v", err) } // another plan: the ports asked about and the message are the plan's @@ -815,7 +815,7 @@ func TestFreeIndex(t *testing.T) { t.Fatalf("plan 21000: FreeIndex = %d %v, asked %v", i, err, asked) } _, err = FreeIndex(root.PortPlan{Base: 21000}, all, func(ports []int) []int { return ports }) - if err == nil || err.Error() != "魔改 AVD 实例已满(端口段 21000–21100 最多 9 台)" { + if err == nil || err.Error() != "ModAVD 实例已满(端口段 21000–21100 最多 9 台)" { t.Fatalf("plan 21000: message %v", err) } all[7] = true diff --git a/internal/avd/testdata/instances_ref.py b/internal/avd/testdata/instances_ref.py index adcebfc..f49f1e4 100644 --- a/internal/avd/testdata/instances_ref.py +++ b/internal/avd/testdata/instances_ref.py @@ -328,12 +328,12 @@ def validate_options(memory_mb, cpu, data_partition_gb): ) if memory not in MEMORY_CHOICES_MB: raise ValueError( - "魔改 AVD 的内存只能选 " + "ModAVD 的内存只能选 " + " / ".join(f"{value // 1024} GB" for value in MEMORY_CHOICES_MB) ) if cores not in CPU_CHOICES: raise ValueError( - "魔改 AVD 的 CPU 核数只能选 " + " / ".join(str(v) for v in CPU_CHOICES) + "ModAVD 的 CPU 核数只能选 " + " / ".join(str(v) for v in CPU_CHOICES) ) low, high = DATA_PARTITION_RANGE_GB if not low <= data_gb <= high: @@ -345,7 +345,7 @@ def validate_fps(fps): value = DEFAULT_FPS if fps is None else int(fps) if value not in FPS_CHOICES: raise ValueError( - "魔改 AVD 的帧率只能选 " + " / ".join(str(v) for v in FPS_CHOICES) + "ModAVD 的帧率只能选 " + " / ".join(str(v) for v in FPS_CHOICES) ) return value @@ -432,7 +432,7 @@ def write_instance_settings(root, idx, changes): """AvdManager.write_instance_settings for cpu / memoryMb / fps (no display keys, no expected values).""" instance = list_instances(root).get(str(idx)) if instance is None: - raise RuntimeError(f"魔改 AVD 实例 {idx} 不存在") + raise RuntimeError(f"ModAVD 实例 {idx} 不存在") fps = validate_fps(changes["fps"]) if "fps" in changes else None memory, cpu, _ = validate_options( changes.get("memoryMb", memory_for(instance_meta(root, idx))), diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 89441b5..22ec6a6 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -124,7 +124,7 @@ func init() { "status 列出这五个文件在不在、从哪来(download / import),不把它算进必需组件。" + "不带参数时默认查看组件;source 查看或修改记住的下载源(auto、googledownloads_cn、tencent、google)。" + "--json 时必须给 --accept-license(仅 fetch)。只有 Windows 有组件清单,darwin、linux 返回 unsupported。" + - "退出码:没同意许可协议是 2(license_declined),路径有中文、空间不够、不是魔改 AVD 的文件夹是 4," + + "退出码:没同意许可协议是 2(license_declined),路径有中文、空间不够、不是 ModAVD 的文件夹是 4," + "正在补全是 5,下不成是 9(components_unavailable),下载或解压后没通过校验、导入的文件被拒收也是 9(components_rejected," + "--json 的 error.details.reason 是 unknown_file、mismatch 或 incomplete)。" if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" { @@ -236,7 +236,7 @@ func init() { { name: "collect-info", args: "[--out-dir 目录] [--stage-dir 目录] [--keep-stage] [--no-explorer]", summary: "收集问题信息,打成 zip", - details: "打成「魔改AVD问题信息-<时间>.zip」,默认放桌面,再打开资源管理器选中它(--no-explorer 不打开)。" + + details: "打成「ModAVD-问题信息-<时间>.zip」,默认放桌面,再打开资源管理器选中它(--no-explorer 不打开)。" + "--stage-dir 是暂存目录的父目录(默认 %TEMP%),--keep-stage 保留暂存目录。每一节单独收,出错写进 summary.txt 接着收下一节。" + "只读:不收磁盘镜像,不收 AUTO-MAS 的配置和账号数据,不起 adb server、不碰 5037。目前只支持 Windows。", flags: []flagSpec{optOutDir, optStageDir, optKeepStage, optNoExplorer}, final: true, @@ -805,7 +805,7 @@ func stopAll(c *cmdCtx, m manager, opt emu.StopOptions) (*StopAllResult, error) c.log.Infof("没有正在运行的实例") return res, nil } - c.log.Infof("同时正常关闭 %d 台魔改 AVD 实例(安卓正常关机):%s", len(targets), joinInts(targets, "、")) + c.log.Infof("同时正常关闭 %d 台 ModAVD 实例(安卓正常关机):%s", len(targets), joinInts(targets, "、")) type outcome struct { r *emu.StopResult err error @@ -830,7 +830,7 @@ func stopAll(c *cmdCtx, m manager, opt emu.StopOptions) (*StopAllResult, error) if c.ctx.Err() != nil && errs.CodeOf(e) != errs.Canceled { e = errs.Wrap(errs.Canceled, e, "已取消") } - c.log.Warnf("魔改 AVD 实例 %d 关机失败: %s", targets[k], errs.As(e).Error()) + c.log.Warnf("ModAVD 实例 %d 关机失败: %s", targets[k], errs.As(e).Error()) res.Failed = append(res.Failed, StopFailure{Index: targets[k], Error: errorInfo(e)}) continue } diff --git a/internal/cli/format.go b/internal/cli/format.go index e6c4768..1bf4b69 100644 --- a/internal/cli/format.go +++ b/internal/cli/format.go @@ -801,7 +801,7 @@ func formatHelp(w io.Writer, r *HelpResult) { } return } - fmt.Fprintln(w, "modavd:魔改 AVD 实例管理(新建、删除、修改、列出、开机、关机)和 MCP 服务") + fmt.Fprintln(w, "modavd:ModAVD 实例管理(新建、删除、修改、列出、开机、关机)和 MCP 服务") fmt.Fprintf(w, "\n用法:%s\n\n命令:\n", r.Usage) // Usage and summary on separate lines: create's usage alone is ~80 columns. for _, e := range r.Commands { diff --git a/internal/cli/report_test.go b/internal/cli/report_test.go index 7324fb1..7e07542 100644 --- a/internal/cli/report_test.go +++ b/internal/cli/report_test.go @@ -54,11 +54,12 @@ func TestHumanCollectInfoMatchesScript(t *testing.T) { t.Fatalf("%s: the script's privacy note head not found once", c.name) } want = strings.Replace(want, scriptPrivacyHead+"\n", head+"\n", 1) - // and for what the script said as if every user had AUTO-MAS (report's notAllMAS) + // and for what it says differently on purpose (report's reworded) + want = strings.Replace(want, "魔改AVD问题信息-", "ModAVD-问题信息-", 1) want = strings.Replace(want, "实例配置、AUTO-MAS 日志(mas 文件夹);", "实例配置;找到 AUTO-MAS 时还有它的日志(mas 文件夹);", 1) want = strings.Replace(want, "没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志\n", report.MasMissingNote+"\n", 1) res := &report.CollectResult{ - ZipPath: `{{BASE}}\out\魔改AVD问题信息-20260115-120000.zip`, ZipSize: "{{SIZE}}", + ZipPath: `{{BASE}}\out\ModAVD-问题信息-20260115-120000.zip`, ZipSize: "{{SIZE}}", PrivacyNote: report.PrivacyNote, MasFound: c.mas, Problems: c.problems, } var b bytes.Buffer diff --git a/internal/components/fetch.go b/internal/components/fetch.go index 3ea6a9a..596f1bb 100644 --- a/internal/components/fetch.go +++ b/internal/components/fetch.go @@ -334,7 +334,7 @@ func Fetch(ctx context.Context, r *root.Root, o Options) (*Result, error) { return nil, errs.New(errs.Unsupported, "这个系统还没有组件清单").WithAdvice("目前支持 Windows 和 Apple Silicon macOS") } if r == nil { - return nil, errs.New(errs.NotFound, "找不到魔改 AVD 根目录") + return nil, errs.New(errs.NotFound, "找不到 ModAVD 根目录") } if err := o.CheckTimeouts(); err != nil { return nil, err @@ -389,7 +389,7 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) { if f.o.PlayStore { what += "、Google Play 商店" } - f.say("魔改 AVD:补全 Google 组件(" + what + ")") + f.say("ModAVD:补全 Google 组件(" + what + ")") f.say("文件夹:" + r.Dir) free := "-1" if f.o.TestFreeSpaceMB != nil { @@ -413,7 +413,7 @@ func (f *fetcher) run(ctx context.Context) (res *Result, err error) { if f.darwin() { advice = "请完整解压 Apple Silicon 发行包,再在包根目录运行 modavd components fetch" } - return nil, errs.Newf(errs.NotReady, "这里没有 %s,不像是魔改 AVD 的文件夹:%s", filepath.Join("sdk", "emulator", root.CurrentPlatform().Emulator), r.Dir). + return nil, errs.Newf(errs.NotReady, "这里没有 %s,不像是 ModAVD 的文件夹:%s", filepath.Join("sdk", "emulator", root.CurrentPlatform().Emulator), r.Dir). WithAdvice(advice) } if r.LocalSDKRoot() != "" { diff --git a/internal/components/playstore.go b/internal/components/playstore.go index d5d5b4e..38fae24 100644 --- a/internal/components/playstore.go +++ b/internal/components/playstore.go @@ -184,7 +184,7 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op return nil, errs.New(errs.Unsupported, "这个系统还没有组件清单").WithAdvice("目前支持 Windows 和 Apple Silicon macOS") } if r == nil { - return nil, errs.New(errs.NotFound, "找不到魔改 AVD 根目录") + return nil, errs.New(errs.NotFound, "找不到 ModAVD 根目录") } if len(files) == 0 { return nil, errs.New(errs.Usage, "没有给要导入的文件"). @@ -197,7 +197,7 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op f := &fetcher{r: r, o: o, sdk: r.SDK()} emulator := filepath.Join("sdk", "emulator", root.CurrentPlatform().Emulator) if _, err := os.Stat(filepath.Join(f.sdk, "emulator", root.CurrentPlatform().Emulator)); err != nil { - return nil, errs.Newf(errs.NotReady, "这里没有 %s,不像是魔改 AVD 的文件夹:%s", emulator, r.Dir) + return nil, errs.Newf(errs.NotReady, "这里没有 %s,不像是 ModAVD 的文件夹:%s", emulator, r.Dir) } if r.LocalSDKRoot() != "" { return nil, errs.New(errs.NotReady, "这个根目录在 mas-avd.json 里设了 sdkRoot(开发用的本地 SDK),不导入组件"). @@ -230,7 +230,7 @@ func ImportPlayStore(ctx context.Context, r *root.Root, files []ImportFile, o Op } f.note(fmt.Sprintf("退出码 %d", errs.ExitCode(err))) }() - f.say("魔改 AVD:用自己的文件补全 Google Play 商店") + f.say("ModAVD:用自己的文件补全 Google Play 商店") f.say("文件夹:" + r.Dir) if f.darwin() { if err := f.platformPaths(); err != nil { diff --git a/internal/emu/adbcmd.go b/internal/emu/adbcmd.go index f4f1650..dc57038 100644 --- a/internal/emu/adbcmd.go +++ b/internal/emu/adbcmd.go @@ -29,7 +29,7 @@ func (m *Manager) Shell(ctx context.Context, i int, command string, timeout time if _, ok, err := m.qemuOf(i); err != nil { return nil, err } else if !ok { - return nil, errs.Newf(errs.Unsupported, "魔改 AVD 实例 %d 没有在运行", i) + return nil, errs.Newf(errs.Unsupported, "ModAVD 实例 %d 没有在运行", i) } res := m.shell(ctx, i, command, timeout) if err := ctxErr(ctx); err != nil { diff --git a/internal/emu/bootdiag_windows_test.go b/internal/emu/bootdiag_windows_test.go index b052e6b..606d11c 100644 --- a/internal/emu/bootdiag_windows_test.go +++ b/internal/emu/bootdiag_windows_test.go @@ -35,7 +35,7 @@ func TestSaveBootFailure(t *testing.T) { return m.saveBootFailure(ctx, i, err, running) } - failure := errs.New(errs.Timeout, "魔改 AVD 实例 6 启动超时") + failure := errs.New(errs.Timeout, "ModAVD 实例 6 启动超时") diagDirs := func() []string { matches, _ := filepath.Glob(filepath.Join(r.LogsDir(), "mas_6-*-bootfail")) return matches diff --git a/internal/emu/bridge.go b/internal/emu/bridge.go index 331c7ad..30dc23b 100644 --- a/internal/emu/bridge.go +++ b/internal/emu/bridge.go @@ -359,7 +359,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re // The script's notes go to this log; the warnings of the cold boots come with their results (m, not mm, starts them). col := &logx.Collector{} mm := m.withLog(col.Sink()) - mm.Log.Infof("魔改 AVD 实例 %d 有执行桥的开机标记,桥还没装:现在装(中间要关机再开机一到两次)", i) + mm.Log.Infof("ModAVD 实例 %d 有执行桥的开机标记,桥还没装:现在装(中间要关机再开机一到两次)", i) notWritable := 0 for round := 1; round <= bridgeInstallRounds; round++ { if opt.Progress != nil { @@ -455,7 +455,7 @@ func (m *Manager) updateBridgeLocked(ctx context.Context, i int, opt StartOption res.Warnings = append(res.Warnings, col.Messages()...) return res, nil } - mm.Log.Infof("魔改 AVD 实例 %d 的执行桥文件和根目录里的载荷不一样(多半是更新过 ModAVD 包):现在更新,之后冷启动一次", i) + mm.Log.Infof("ModAVD 实例 %d 的执行桥文件和根目录里的载荷不一样(多半是更新过 ModAVD 包):现在更新,之后冷启动一次", i) if opt.Progress != nil { opt.Progress(Progress{Step: "bridge", Message: fmt.Sprintf("实例 %d 更新 arm64 执行桥", i), Done: 0, Total: progressTotal}) @@ -603,7 +603,7 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu return nil, err } if !ok { - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在装或卸执行桥,稍后再试", i), map[string]string{errs.Reason: "locked", errs.Lock: "bridge"}) + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正在装或卸执行桥,稍后再试", i), map[string]string{errs.Reason: "locked", errs.Lock: "bridge"}) } defer bridgeLock.Unlock() res := &BridgeResult{Index: i, Action: action, ExitCode: -1, Warnings: []string{}} diff --git a/internal/emu/check.go b/internal/emu/check.go index 97bb9ef..cdeb830 100644 --- a/internal/emu/check.go +++ b/internal/emu/check.go @@ -39,7 +39,7 @@ const ( "「Windows 虚拟机监控程序平台」,然后重启" vulkanAdvice = "安装或更新显卡驱动(NVIDIA / AMD / Intel 官网驱动自带 Vulkan)。没有可用的显卡时模拟器只能" + "软件渲染,游戏会占满 CPU,性能会很差" - testPackageAdvice = `请把魔改 AVD 内测包解压到这个目录(解压后目录里应有 sdk\emulator)` + testPackageAdvice = `请把 ModAVD 内测包解压到这个目录(解压后目录里应有 sdk\emulator)` // How long a result of the same check is reused within one process (MCP server): AUTO-MAS // _ACCEL_CACHE_SECONDS, VULKAN_PROBE_CACHE_SECONDS, VULKAN_PROBE_TIMEOUT_CACHE_SECONDS. @@ -132,7 +132,7 @@ func (m *Manager) precheckBeforeLaunch(ctx context.Context, memoryMB int) error return err } if vulkan.OK != nil && !*vulkan.OK { - m.Log.Warnf("魔改 AVD 开机前检查:%s", itemMessage(vulkan)) + m.Log.Warnf("ModAVD 开机前检查:%s", itemMessage(vulkan)) } items = append(items, vulkan) m.Log.Infof("%s", precheckSummary(items)) @@ -179,7 +179,7 @@ func precheckError(it CheckItem, missingErr func() error) error { } } -// precheckSummary is AUTO-MAS's one log line: "魔改 AVD 开机前检查:目录路径通过(<根目录>);组件通过(...);...". +// precheckSummary is AUTO-MAS's one log line: "ModAVD 开机前检查:目录路径通过(<根目录>);组件通过(...);...". func precheckSummary(items []CheckItem) string { parts := make([]string, len(items)) for k, it := range items { @@ -192,7 +192,7 @@ func precheckSummary(items []CheckItem) string { } parts[k] = fmt.Sprintf("%s%s(%s)", it.Title, verdict, it.Reason) } - return "魔改 AVD 开机前检查:" + strings.Join(parts, ";") + return "ModAVD 开机前检查:" + strings.Join(parts, ";") } func pathIsASCII(s string) bool { @@ -307,7 +307,7 @@ func emulatorItem(f emulatorFacts) CheckItem { if !f.Present { return CheckItem{ ID: "emulator", Title: title, OK: checkOK(false), Blocking: true, - Reason: "这个目录里还没有魔改 AVD 内测包", Advice: platformPackageAdvice(), + Reason: "这个目录里还没有 ModAVD 内测包", Advice: platformPackageAdvice(), } } version := f.Version @@ -317,7 +317,7 @@ func emulatorItem(f emulatorFacts) CheckItem { if !f.SelfBuilt { return CheckItem{ ID: "emulator", Title: title, OK: checkOK(false), Blocking: true, - Reason: fmt.Sprintf("这里的模拟器(%s)不是魔改 AVD 内测包里的版本,只支持内测包", version), + Reason: fmt.Sprintf("这里的模拟器(%s)不是 ModAVD 内测包里的版本,只支持内测包", version), Advice: platformPackageAdvice(), } } @@ -334,7 +334,7 @@ func emulatorItem(f emulatorFacts) CheckItem { return CheckItem{ ID: "emulator", Title: title, OK: checkOK(false), Blocking: true, Reason: fmt.Sprintf("内测包版本过旧(%s)", shown), - Advice: fmt.Sprintf("请把魔改 AVD 更新到 0.%d.0 或更新的版本,实例和游戏数据不受影响", root.MinModBuild), + Advice: fmt.Sprintf("请把 ModAVD 更新到 0.%d.0 或更新的版本,实例和游戏数据不受影响", root.MinModBuild), } } @@ -373,7 +373,7 @@ func diskItem(drive string, free uint64, err error) CheckItem { return CheckItem{ ID: "disk", Title: "磁盘", OK: checkOK(false), Blocking: true, Reason: fmt.Sprintf("%s,低于开机所需的 %d GB:数据盘随游戏写入增长,盘写满时实例数据会损坏", text, minFreeDiskGBToBoot), - Advice: fmt.Sprintf("清理 %s 的空间,或把魔改 AVD 根目录放到空间更大的盘", drive), + Advice: fmt.Sprintf("清理 %s 的空间,或把 ModAVD 根目录放到空间更大的盘", drive), } } @@ -443,11 +443,11 @@ func accelItem(res accelResult, summary string) CheckItem { backend, advice := "Windows 虚拟机监控程序平台", accelAdvice if runtime.GOOS == "darwin" { backend = "Hypervisor.framework" - advice = "请在 Apple Silicon Mac 本机运行 macos-arm64 版魔改 AVD,确认模拟器带 com.apple.security.hypervisor 签名权限;用 emulator -accel-check 检查 HVF" + advice = "请在 Apple Silicon Mac 本机运行 macos-arm64 版 ModAVD,确认模拟器带 com.apple.security.hypervisor 签名权限;用 emulator -accel-check 检查 HVF" } return CheckItem{ ID: "acceleration", Title: "硬件虚拟化", OK: checkOK(false), Blocking: true, - Reason: fmt.Sprintf("硬件虚拟化(%s)不可用,魔改 AVD 无法启动(检查结果:%s)", backend, summary), + Reason: fmt.Sprintf("硬件虚拟化(%s)不可用,ModAVD 无法启动(检查结果:%s)", backend, summary), Advice: advice, } } @@ -561,7 +561,7 @@ func accelOKText(summary string) string { func platformPackageAdvice() string { if runtime.GOOS == "darwin" { - return "请把魔改 AVD 的 macos-arm64 内测包解压到这个目录(解压后应有 sdk/emulator)" + return "请把 ModAVD 的 macos-arm64 内测包解压到这个目录(解压后应有 sdk/emulator)" } return testPackageAdvice } diff --git a/internal/emu/check_darwin.go b/internal/emu/check_darwin.go index 1d4f7cb..b66c965 100644 --- a/internal/emu/check_darwin.go +++ b/internal/emu/check_darwin.go @@ -16,7 +16,7 @@ import ( // A directory named arm64-v8a must not silently make an x86 image compatible. func (m *Manager) platformCheckItems() []CheckItem { it := CheckItem{ID: "guest-architecture", Title: "客体架构", OK: checkOK(false), Blocking: true, - Advice: "请使用 macos-arm64 版魔改 AVD 和对应的 Apple Silicon 系统镜像"} + Advice: "请使用 macos-arm64 版 ModAVD 和对应的 Apple Silicon 系统镜像"} p := root.CurrentPlatform() if !p.Supported() || p.GuestABI != "arm64-v8a" || p.GuestArch != "arm64" { it.Reason = "当前 macOS 管理器仅支持 Apple Silicon 和 arm64-v8a 客体" diff --git a/internal/emu/check_test.go b/internal/emu/check_test.go index 08fedfb..aecfc02 100644 --- a/internal/emu/check_test.go +++ b/internal/emu/check_test.go @@ -131,11 +131,11 @@ func TestComponentsItem(t *testing.T) { func TestEmulatorItem(t *testing.T) { it := emulatorItem(emulatorFacts{}) - if it.OK == nil || *it.OK || it.Reason != "这个目录里还没有魔改 AVD 内测包" || it.Advice != testPackageAdvice { + if it.OK == nil || *it.OK || it.Reason != "这个目录里还没有 ModAVD 内测包" || it.Advice != testPackageAdvice { t.Fatalf("absent = %+v", it) } it = emulatorItem(emulatorFacts{Present: true, Version: "37.1.11"}) - if it.OK == nil || *it.OK || it.Reason != "这里的模拟器(37.1.11)不是魔改 AVD 内测包里的版本,只支持内测包" { + if it.OK == nil || *it.OK || it.Reason != "这里的模拟器(37.1.11)不是 ModAVD 内测包里的版本,只支持内测包" { t.Fatalf("google build = %+v", it) } it = emulatorItem(emulatorFacts{Present: true}) @@ -240,7 +240,7 @@ func TestPrecheckSummary(t *testing.T) { {Title: "硬件虚拟化", OK: nil, Reason: "没装"}, {Title: "显卡 Vulkan", OK: checkOK(false), Reason: "没有"}, }) - want := `魔改 AVD 开机前检查:目录路径通过(D:\r);硬件虚拟化未查(没装);显卡 Vulkan未通过(没有)` + want := `ModAVD 开机前检查:目录路径通过(D:\r);硬件虚拟化未查(没装);显卡 Vulkan未通过(没有)` if got != want { t.Fatalf("summary\n got %s\nwant %s", got, want) } diff --git a/internal/emu/createinit.go b/internal/emu/createinit.go index 097e83c..7976f55 100644 --- a/internal/emu/createinit.go +++ b/internal/emu/createinit.go @@ -112,7 +112,7 @@ func (m *Manager) notCreated(i int, what string) error { state, message, logFile := m.creation(i) switch state { case creationCreating: - return errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在新建(后台开机做初始化),%s", i, what). + return errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正在新建(后台开机做初始化),%s", i, what). WithAdvice("等它做完;进度用 modavd status 查看"), map[string]string{errs.Reason: "locked", errs.Lock: "create"}) case creationFailed: advice := fmt.Sprintf("用 modavd create --resume %d 重新初始化,或者 modavd delete %d --yes 删掉", i, i) @@ -123,7 +123,7 @@ func (m *Manager) notCreated(i int, what string) error { message = "初始化没做完" } // the reason last: a setup error has its own brackets - return errs.Newf(errs.NotReady, "魔改 AVD 实例 %d 新建时初始化失败,%s:%s", i, what, message).WithAdvice(advice) + return errs.Newf(errs.NotReady, "ModAVD 实例 %d 新建时初始化失败,%s:%s", i, what, message).WithAdvice(advice) } return nil } @@ -425,7 +425,7 @@ func (m *Manager) setupNew(ctx context.Context, i int, cfg *avd.Ini, req CreateR if logFile != "" { advice += ";日志见 " + logFile } - return nil, &errs.Error{Code: e.Code, Message: fmt.Sprintf("魔改 AVD 实例 %d 新建失败:%s", i, e.Message), + return nil, &errs.Error{Code: e.Code, Message: fmt.Sprintf("ModAVD 实例 %d 新建失败:%s", i, e.Message), Advice: advice, Cause: err} } prog.stage("done", fmt.Sprintf("实例 %d 新建完成,已关机;第一次开机不用再做初始化", i)) @@ -450,7 +450,7 @@ func (m *Manager) stopCreateLeftover(ctx context.Context, i int) error { defer cancel() if err := createStop(m, sctx, i); err != nil { e := errs.As(err) - return errs.Wrap(e.Code, err, fmt.Sprintf("魔改 AVD 实例 %d 上次新建时开的模拟器没能正常关掉: %s", i, e.Message)). + return errs.Wrap(e.Code, err, fmt.Sprintf("ModAVD 实例 %d 上次新建时开的模拟器没能正常关掉: %s", i, e.Message)). WithAdvice(fmt.Sprintf("用 modavd stop %d 关掉它,再重试", i)) } return nil diff --git a/internal/emu/crud.go b/internal/emu/crud.go index 38b12d2..37cba15 100644 --- a/internal/emu/crud.go +++ b/internal/emu/crud.go @@ -104,7 +104,7 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (_ *InstanceInf lock.Unlock() return nil, err } - m.Log.Infof("已新建魔改 AVD 实例 %d(内存 %d MB / %d 核 / 数据盘 %d GB / 帧率 %d)", i, memory, cpu, dataGB, fps) + m.Log.Infof("已新建 ModAVD 实例 %d(内存 %d MB / %d 核 / 数据盘 %d GB / 帧率 %d)", i, memory, cpu, dataGB, fps) if req.NoBridge { // recorded, so no start installs it (bridge.go) if err := m.setBridgeWanted(i, false); err != nil { @@ -189,7 +189,7 @@ func (m *Manager) resumeCreate(ctx context.Context, req CreateRequest) (*Instanc // with the lock in hand, a recorded "creating" is one that was killed (creation() would see our own lock) if state := m.creationRecorded(i); state != creationFailed && state != creationCreating { lock.Unlock() - return nil, errs.Newf(errs.Unsupported, "魔改 AVD 实例 %d 不是新建失败的实例,没有要重做的初始化", i). + return nil, errs.Newf(errs.Unsupported, "ModAVD 实例 %d 不是新建失败的实例,没有要重做的初始化", i). WithAdvice(fmt.Sprintf("直接 modavd start %d", i)) } return m.setupNew(ctx, i, in.Config, req, lock) @@ -233,7 +233,7 @@ func (m *Manager) Update(ctx context.Context, i int, req UpdateRequest) (*Update warnings = col.Messages() applied["diagnostics"] = *req.Diagnostics } - m.Log.Infof("已写入魔改 AVD 实例 %d 的设置: %s", i, formatApplied(applied)) + m.Log.Infof("已写入 ModAVD 实例 %d 的设置: %s", i, formatApplied(applied)) in, err := m.instance(i) if err != nil { return nil, err @@ -246,7 +246,7 @@ func (m *Manager) Update(ctx context.Context, i int, req UpdateRequest) (*Update if ok { p = &q if nextBoot { - m.Log.Infof("魔改 AVD 实例 %d 正在运行,硬件改动下次开机生效", i) + m.Log.Infof("ModAVD 实例 %d 正在运行,硬件改动下次开机生效", i) } } return &UpdateResult{Instance: m.instanceInfo(ctx, in, p, nil), Applied: applied, AppliesNextBoot: p != nil && nextBoot, @@ -312,10 +312,10 @@ func (m *Manager) Delete(ctx context.Context, i int) (*DeleteResult, error) { return nil, err } else if running { unlock() - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 未关闭,无法删除", i), map[string]string{errs.Reason: "running"}) + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 未关闭,无法删除", i), map[string]string{errs.Reason: "running"}) } if len(leftovers) > 0 { - m.Log.Warnf("魔改 AVD 实例 %d 不完整(只剩:%s),可能是上次删除或新建到一半,把这些残留删掉", i, + m.Log.Warnf("ModAVD 实例 %d 不完整(只剩:%s),可能是上次删除或新建到一半,把这些残留删掉", i, strings.Join(leftovers, "、")) } if err := m.prepareDiagnosticDelete(ctx, i); err != nil { @@ -329,16 +329,16 @@ func (m *Manager) Delete(ctx context.Context, i int) (*DeleteResult, error) { // Confirm it is gone: a later create must not find a half-deleted instance under this index. if left := instanceFilesOnDisk(m.Root, i); len(left) > 0 { unlock() - return nil, errs.Newf(errs.Internal, "魔改 AVD 实例 %d 没有删干净,还剩:%s", i, strings.Join(left, "、")). + return nil, errs.Newf(errs.Internal, "ModAVD 实例 %d 没有删干净,还剩:%s", i, strings.Join(left, "、")). WithAdvice(fmt.Sprintf("关掉占用这些文件的程序后再运行一次 modavd delete %d --yes,会接着删", i)) } // The lock files can only go once nobody holds them (an open file cannot be deleted). unlock() m.removeLockFiles(i) if len(leftovers) > 0 { - m.Log.Infof("已清掉魔改 AVD 实例 %d 的残留", i) + m.Log.Infof("已清掉 ModAVD 实例 %d 的残留", i) } else { - m.Log.Infof("已删除魔改 AVD 实例 %d", i) + m.Log.Infof("已删除 ModAVD 实例 %d", i) } return &DeleteResult{Index: i, Name: in.Name, Path: in.Dir}, nil } @@ -393,14 +393,14 @@ func (m *Manager) prepareDiagnosticDelete(ctx context.Context, i int) error { return err } -// tryInstanceLock takes lock kind of instance i now or fails with errs.Busy ("魔改 AVD 实例 0 正在启动,无法删除"). +// tryInstanceLock takes lock kind of instance i now or fails with errs.Busy ("ModAVD 实例 0 正在启动,无法删除"). func (m *Manager) tryInstanceLock(i int, kind, what string) (*host.FileLock, error) { l, ok, err := m.tryOperationLock(i, kind) if err != nil { return nil, err } if !ok { - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d %s,无法删除", i, what), map[string]string{errs.Reason: "locked", errs.Lock: kind}) + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d %s,无法删除", i, what), map[string]string{errs.Reason: "locked", errs.Lock: kind}) } return l, nil } diff --git a/internal/emu/crud_test.go b/internal/emu/crud_test.go index 347a539..ced75cc 100644 --- a/internal/emu/crud_test.go +++ b/internal/emu/crud_test.go @@ -76,7 +76,7 @@ func TestDeleteHalfInstances(t *testing.T) { t.Fatalf("explicit create on leftovers: %v", err) } deleted("directory without config.ini", 0) - if !strings.Contains(strings.Join(logs.Messages(), "\n"), "魔改 AVD 实例 0 不完整(只剩:") { + if !strings.Contains(strings.Join(logs.Messages(), "\n"), "ModAVD 实例 0 不完整(只剩:") { t.Errorf("leftovers not reported: %q", logs.Messages()) } create(0) // the index is usable again diff --git a/internal/emu/disk.go b/internal/emu/disk.go index 59eb7a0..4e8511f 100644 --- a/internal/emu/disk.go +++ b/internal/emu/disk.go @@ -96,14 +96,14 @@ func (m *Manager) DiskTrim(ctx context.Context, i int) (*DiskTrimResult, error) return nil, err } else if !running { return nil, errs.Newf(errs.Unsupported, - "魔改 AVD 实例 %d 没有在运行,请先开机再回收磁盘空间", i) + "ModAVD 实例 %d 没有在运行,请先开机再回收磁盘空间", i) } if !m.bootCompleted(ctx, i, statusAdbTimeout, true) { if err := ctxErr(ctx); err != nil { return nil, err } return nil, errs.Newf(errs.Unsupported, - "魔改 AVD 实例 %d 还没开机完成,请开机后再回收磁盘空间", i) + "ModAVD 实例 %d 还没开机完成,请开机后再回收磁盘空间", i) } // Gate on the emulator actually advertising discard on /data: an old build @@ -115,7 +115,7 @@ func (m *Manager) DiskTrim(ctx context.Context, i int) (*DiskTrimResult, error) if !ok || maxBytes <= 0 { return nil, errs.Newf(errs.Unsupported, "这个模拟器版本不支持回收磁盘空间(客体数据盘没有 discard)"). - WithAdvice("请把魔改 AVD 更新到带磁盘回收的版本,实例和游戏数据不受影响") + WithAdvice("请把 ModAVD 更新到带磁盘回收的版本,实例和游戏数据不受影响") } // vold idle maintenance runs fstrim on the mounted file systems. (The exact @@ -463,9 +463,9 @@ func (m *Manager) lockInstanceForCompact(i int) (func(), error) { if err := m.compactBusy(i); err != nil { return nil, err } - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 的磁盘状态正在读取,请稍后再整理", i), map[string]string{errs.Reason: "locked", errs.Lock: "disk"}) + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 的磁盘状态正在读取,请稍后再整理", i), map[string]string{errs.Reason: "locked", errs.Lock: "disk"}) } - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d %s,无法整理磁盘", i, kw.what), map[string]string{errs.Reason: "locked", errs.Lock: kw.kind}) + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d %s,无法整理磁盘", i, kw.what), map[string]string{errs.Reason: "locked", errs.Lock: kw.kind}) } locks = append(locks, l) } @@ -714,7 +714,7 @@ func (m *Manager) DiskCompact(ctx context.Context, i int) (*DiskCompactResult, e if _, running, err := m.qemuOf(i); err != nil { return nil, err } else if running { - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 未关闭,无法整理磁盘", i). + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 未关闭,无法整理磁盘", i). WithAdvice(fmt.Sprintf("先 modavd stop %d,关机后再整理", i)), map[string]string{errs.Reason: "running"}) } if err := ctxErr(ctx); err != nil { @@ -770,7 +770,7 @@ func (m *Manager) DiskCompact(ctx context.Context, i int) (*DiskCompactResult, e return nil, err } if estimate != nil && *estimate < threshold { - m.Log.Infof("魔改 AVD 实例 %d 预计可回收 %d 字节,低于 %d 字节,跳过磁盘重写", i, *estimate, threshold) + m.Log.Infof("ModAVD 实例 %d 预计可回收 %d 字节,低于 %d 字节,跳过磁盘重写", i, *estimate, threshold) return compactResult(i, overlay, before, before, estimate, threshold, true), nil } // Without an allocation count, reserve for the complete virtual image and @@ -834,6 +834,6 @@ func (m *Manager) DiskCompact(ctx context.Context, i int) (*DiskCompactResult, e return nil, err } - m.Log.Infof("魔改 AVD 实例 %d 整理磁盘完成:占用 %s → %s", i, gib(before), gib(after)) + m.Log.Infof("ModAVD 实例 %d 整理磁盘完成:占用 %s → %s", i, gib(before), gib(after)) return compactResult(i, overlay, before, after, estimate, threshold, false), nil } diff --git a/internal/emu/disk_lock.go b/internal/emu/disk_lock.go index a3755f3..9240d5f 100644 --- a/internal/emu/disk_lock.go +++ b/internal/emu/disk_lock.go @@ -14,7 +14,7 @@ func (m *Manager) compactBusy(i int) error { return errs.Wrap(errs.Internal, err, "实例磁盘锁文件打不开: "+m.lockPath(i, "disk")) } if held { - return errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在整理磁盘,请等整理结束后重试", i), map[string]string{errs.Reason: "locked", errs.Lock: "disk"}) + return errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正在整理磁盘,请等整理结束后重试", i), map[string]string{errs.Reason: "locked", errs.Lock: "disk"}) } return nil } diff --git a/internal/emu/firstboot.go b/internal/emu/firstboot.go index 988045b..c52211e 100644 --- a/internal/emu/firstboot.go +++ b/internal/emu/firstboot.go @@ -79,7 +79,7 @@ var debloatPackages = []string{ // Bluetooth off, no immersive-mode hint, phone apps disabled, the light launcher, the renderer. A failed item leaves // initialized=false and the next boot tries again. func (m *Manager) firstBootInit(ctx context.Context, i int) { - m.Log.Infof("魔改 AVD 实例 %d 首次开机,正在初始化", i) + m.Log.Infof("ModAVD 实例 %d 首次开机,正在初始化", i) var failures []string // Wi-Fi is not turned off here: every boot turns it on with the fastpath (setupNetwork) @@ -105,7 +105,7 @@ func (m *Manager) firstBootInit(ctx context.Context, i int) { } software := strings.Contains(strings.ToLower(renderer), "swiftshader") if software { - m.Log.Warnf("魔改 AVD 实例 %d 正在用软件渲染(%s):没有可用的显卡驱动,游戏会占满 CPU,请更新显卡驱动", i, renderer) + m.Log.Warnf("ModAVD 实例 %d 正在用软件渲染(%s):没有可用的显卡驱动,游戏会占满 CPU,请更新显卡驱动", i, renderer) } if ctx.Err() != nil { // the commands above were cut short: do not record a half run, the next boot does it again @@ -113,12 +113,12 @@ func (m *Manager) firstBootInit(ctx context.Context, i int) { } if err := m.writeInitMeta(i, len(failures) == 0, launcher, renderer, software, failures); err != nil { - m.Log.Warnf("魔改 AVD 实例 %d 初始化结果没写进 mas-avd.json,下次开机重做: %v", i, err) + m.Log.Warnf("ModAVD 实例 %d 初始化结果没写进 mas-avd.json,下次开机重做: %v", i, err) } if len(failures) > 0 { - m.Log.Warnf("魔改 AVD 实例 %d 初始化有失败项,下次开机重试: %s", i, pyList(failures)) + m.Log.Warnf("ModAVD 实例 %d 初始化有失败项,下次开机重试: %s", i, pyList(failures)) } else { - m.Log.Infof("魔改 AVD 实例 %d 初始化完成(桌面: %s)", i, launcher) + m.Log.Infof("ModAVD 实例 %d 初始化完成(桌面: %s)", i, launcher) } } diff --git a/internal/emu/install.go b/internal/emu/install.go index 71bfd73..eb7046a 100644 --- a/internal/emu/install.go +++ b/internal/emu/install.go @@ -59,10 +59,10 @@ func (m *Manager) InstallAPK(ctx context.Context, i int, apkPath string) (*Insta if err := ctxErr(ctx); err != nil { return nil, err } - return nil, errs.Newf(errs.Unsupported, "魔改 AVD 实例 %d 没有开机,请先开机再安装 APK", i) + return nil, errs.Newf(errs.Unsupported, "ModAVD 实例 %d 没有开机,请先开机再安装 APK", i) } - m.Log.Infof("魔改 AVD 实例 %d 安装 APK: %s", i, path) + m.Log.Infof("ModAVD 实例 %d 安装 APK: %s", i, path) res := m.ADB.Run(ctx, adb.RunOptions{Serial: m.Root.Ports().Serial(i), Timeout: apkInstallTimeout, Reregister: true}, "install", "-r", path) // The guest is done with the install: a waiting start may restart zygote now (reading the manifest below is local). @@ -72,14 +72,14 @@ func (m *Manager) InstallAPK(ctx context.Context, i int, apkPath string) (*Insta } last := installResultLine(res.Out) if res.Code != 0 || !strings.HasPrefix(last, "Success") { - m.Log.Warnf("魔改 AVD 实例 %d 安装 %s 失败: %s", i, name, res.Out) + m.Log.Warnf("ModAVD 实例 %d 安装 %s 失败: %s", i, name, res.Out) why := last if why == "" { why = "adb 返回 " + strconv.Itoa(res.Code) } return nil, errs.Newf(errs.Internal, "安装 %s 失败:%s", name, why) } - m.Log.Infof("魔改 AVD 实例 %d 已安装 %s", i, name) + m.Log.Infof("ModAVD 实例 %d 已安装 %s", i, name) out := &InstallResult{Index: i, APK: path, Output: last} if man, err := apk.ReadManifest(path); err == nil && man != nil { diff --git a/internal/emu/launch.go b/internal/emu/launch.go index b4482a8..0a54c53 100644 --- a/internal/emu/launch.go +++ b/internal/emu/launch.go @@ -120,10 +120,10 @@ func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryM port := m.Root.Ports().Console(i) busy, err := host.BusyPorts([]int{port, port + 1, port + 2}) if err != nil { - return nil, errs.Wrap(errs.Internal, err, fmt.Sprintf("魔改 AVD 实例 %d 读不出系统的 TCP 监听表: %v", i, err)) + return nil, errs.Wrap(errs.Internal, err, fmt.Sprintf("ModAVD 实例 %d 读不出系统的 TCP 监听表: %v", i, err)) } if len(busy) > 0 { - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 要用的端口 %s 已被占用,请先关闭占用它们的程序", + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 要用的端口 %s 已被占用,请先关闭占用它们的程序", i, joinInts(busy, ", ")), map[string]string{errs.Reason: "port_in_use"}) } @@ -166,7 +166,7 @@ func (m *Manager) launchPlanFor(ctx context.Context, inst *avd.Instance, memoryM if n, ok := inst.CPU(); ok { cpu = strconv.Itoa(n) } - m.Log.Infof("启动魔改 AVD 实例 %d(%s,%dp %d Hz,%d MB / %s 核,气球开,不带声卡,ANDROID_EMU_VK_HIDE_REBAR=%s): emulator %s", + m.Log.Infof("启动 ModAVD 实例 %d(%s,%dp %d Hz,%d MB / %s 核,气球开,不带声卡,ANDROID_EMU_VK_HIDE_REBAR=%s): emulator %s", i, mode, inst.Resolution(), inst.FPS(), memoryMB, cpu, hideRebar, strings.Join(args, " ")) return &launchPlan{args: args, logPath: logPath, memoryMB: memoryMB}, nil } @@ -212,7 +212,7 @@ func otherRootQemu(q host.Process, cmdline []string, i, console int, ours func(h // otherRootBusy is the error of a start whose ports the same-numbered instance of another root holds. func otherRootBusy(i int, p host.Process, where string) *errs.Error { - return errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 的端口被另一个根目录的同编号实例占用(pid %d,%s)", i, p.PID, where). + return errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 的端口被另一个根目录的同编号实例占用(pid %d,%s)", i, p.PID, where). WithAdvice("先在那个根目录把它关掉再启动"), map[string]string{errs.Reason: "port_other_root"}) } @@ -272,7 +272,7 @@ func (m *Manager) startEmulator(i int, plan *launchPlan) (host.Process, error) { }) if err != nil { return host.Process{}, errs.Wrap(errs.LaunchFailed, err, - fmt.Sprintf("魔改 AVD 实例 %d 启动失败(%s 没能运行): %v", i, root.CurrentPlatform().Emulator, err)) + fmt.Sprintf("ModAVD 实例 %d 启动失败(%s 没能运行): %v", i, root.CurrentPlatform().Emulator, err)) } return p, nil } diff --git a/internal/emu/launch_test.go b/internal/emu/launch_test.go index 8bb65c0..d530af2 100644 --- a/internal/emu/launch_test.go +++ b/internal/emu/launch_test.go @@ -194,7 +194,7 @@ func TestOtherRootBusy(t *testing.T) { if errs.DetailsOf(err)[errs.Reason] != "port_other_root" { t.Errorf("details: %v", errs.DetailsOf(err)) } - if err.Code != errs.Busy || err.Message != `魔改 AVD 实例 3 的端口被另一个根目录的同编号实例占用(pid 4242,D:\ModAVD\avd\mas_3.avd)` || + if err.Code != errs.Busy || err.Message != `ModAVD 实例 3 的端口被另一个根目录的同编号实例占用(pid 4242,D:\ModAVD\avd\mas_3.avd)` || err.Advice == "" { t.Errorf("otherRootBusy = %+v", err) } diff --git a/internal/emu/playstore.go b/internal/emu/playstore.go index 3c23b69..d89f398 100644 --- a/internal/emu/playstore.go +++ b/internal/emu/playstore.go @@ -368,7 +368,7 @@ func (m *Manager) playStoreLock(i int) (*host.FileLock, error) { return nil, err } if !ok { - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在装卸执行桥、Google Play 商店或改 GLES 3.2 开关,稍后再试", i), + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正在装卸执行桥、Google Play 商店或改 GLES 3.2 开关,稍后再试", i), map[string]string{errs.Reason: "locked", errs.Lock: "bridge"}) } return lock, nil diff --git a/internal/emu/start.go b/internal/emu/start.go index a00947e..4fe9ffa 100644 --- a/internal/emu/start.go +++ b/internal/emu/start.go @@ -224,7 +224,7 @@ func (m *Manager) start(ctx context.Context, inst *avd.Instance, opt StartOption } } if running { - m.Log.Infof("魔改 AVD 实例 %d 已在运行(pid %d)", i, p.PID) + m.Log.Infof("ModAVD 实例 %d 已在运行(pid %d)", i, p.PID) prog.emit("launch", fmt.Sprintf("实例 %d 已在运行(pid %d),不重新开机", i, p.PID), progressLaunch) adopted, adoptedAt = &p, time.Now() mk, ok := m.readSetupMarker(i) @@ -317,7 +317,7 @@ func (m *Manager) start(ctx context.Context, inst *avd.Instance, opt StartOption if relaunchAfterAdoptedGone(round, opt.watchQemu != nil, err, time.Since(adoptedAt), stopSeen) { // Most likely a guest that was powering off and no longer answered adb, so guestShuttingDown could not // tell: boot the instance as asked. Its setup marker, if any, names a boot that is over now. - m.Log.Infof("魔改 AVD 实例 %d 接管的模拟器(pid %d)没等安卓开机完成就退出了(多半是正在关机),重新开机", + m.Log.Infof("ModAVD 实例 %d 接管的模拟器(pid %d)没等安卓开机完成就退出了(多半是正在关机),重新开机", i, adopted.PID) continue } @@ -373,7 +373,7 @@ func (m *Manager) start(ctx context.Context, inst *avd.Instance, opt StartOption m.removeSetupMarker(i) } if gone { - return nil, errs.Newf(errs.LaunchFailed, "魔改 AVD 实例 %d 在开机后设置期间退出了(模拟器 pid %d):%s", i, + return nil, errs.Newf(errs.LaunchFailed, "ModAVD 实例 %d 在开机后设置期间退出了(模拟器 pid %d):%s", i, qemu.PID, orText(logTail(res.LogFile, 8), "日志见 "+m.Root.LogsDir())) } // Callers write to /sdcard right after start (10-31 measurements): it is not there yet at sys.boot_completed, @@ -468,7 +468,7 @@ func (m *Manager) waitBoot(ctx context.Context, i int, timeout time.Duration, em return host.Process{}, err } if !time.Now().Before(deadline) { - return host.Process{}, errs.Newf(errs.Timeout, "魔改 AVD 实例 %d 启动超时(%.0f 秒内安卓没有启动完成),日志见 %s", + return host.Process{}, errs.Newf(errs.Timeout, "ModAVD 实例 %d 启动超时(%.0f 秒内安卓没有启动完成),日志见 %s", i, timeout.Seconds(), m.Root.LogsDir()) } if !found && emulator != nil && !emulator.Alive() { @@ -476,7 +476,7 @@ func (m *Manager) waitBoot(ctx context.Context, i int, timeout time.Duration, em if c, ok := emulator.ExitCode(); ok { code = strconv.FormatUint(uint64(c), 10) } - return host.Process{}, errs.Newf(errs.LaunchFailed, "魔改 AVD 实例 %d 启动失败(emulator.exe 退出码 %s):%s", + return host.Process{}, errs.Newf(errs.LaunchFailed, "ModAVD 实例 %d 启动失败(emulator.exe 退出码 %s):%s", i, code, logTail(logPath, 8)) } if adopted != nil && stopSeen != nil && !*stopSeen && m.lockHeld(i, "stop") { @@ -485,7 +485,7 @@ func (m *Manager) waitBoot(ctx context.Context, i int, timeout time.Duration, em // both the process snapshot and the PID + creation time check must agree, so a failed snapshot is no exit if !found && adopted != nil && !adopted.Alive() { return host.Process{}, errs.Wrap(errs.LaunchFailed, errAdoptedGone, - fmt.Sprintf("魔改 AVD 实例 %d 的模拟器(pid %d)在等安卓开机时退出了", i, adopted.PID)) + fmt.Sprintf("ModAVD 实例 %d 的模拟器(pid %d)在等安卓开机时退出了", i, adopted.PID)) } if now := time.Now(); !now.Before(nextReport) { elapsed := now.Sub(started) @@ -551,13 +551,13 @@ func poweringOff(v string) bool { // scans for the instance's qemu again: found when someone launched it meanwhile (the start adopts that one). Busy when // p is still there at the end. func (m *Manager) waitShutdown(ctx context.Context, i int, p host.Process, prog *progressor) (host.Process, bool, error) { - m.Log.Infof("魔改 AVD 实例 %d 正在关机(模拟器 pid %d 还没退出),等它关完再开机", i, p.PID) + m.Log.Infof("ModAVD 实例 %d 正在关机(模拟器 pid %d 还没退出),等它关完再开机", i, p.PID) prog.emit("launch", fmt.Sprintf("实例 %d 正在关机,等它关完再开机", i), progressLaunch/2) if !waitQemuExit(ctx, p, shutdownExitWait) { if err := ctxErr(ctx); err != nil { return host.Process{}, false, err } - return host.Process{}, false, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在关机,等了 %.0f 秒模拟器(pid %d)还没退出", i, + return host.Process{}, false, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正在关机,等了 %.0f 秒模拟器(pid %d)还没退出", i, shutdownExitWait.Seconds(), p.PID).WithAdvice(fmt.Sprintf("稍后再开机;一直关不掉的话先 modavd stop %d", i)), map[string]string{errs.Reason: "stopping"}) } return m.findQemu(i) diff --git a/internal/emu/stop.go b/internal/emu/stop.go index 01534a6..933ee5c 100644 --- a/internal/emu/stop.go +++ b/internal/emu/stop.go @@ -108,7 +108,7 @@ func (m *Manager) Stop(ctx context.Context, i int, opt StopOptions) (*StopResult state, _, _ := mm.creation(i) creating := state == creationCreating && !opt.creating if creating && !opt.CancelCreate { - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在新建(后台开机做初始化),不能关机", i). + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正在新建(后台开机做初始化),不能关机", i). WithAdvice(fmt.Sprintf("等新建做完;进度用 modavd status 查看;确实要打断请用 modavd stop %d --cancel-create", i)), map[string]string{errs.Reason: "locked", errs.Lock: "create"}) } if creating { @@ -130,7 +130,7 @@ func (m *Manager) Stop(ctx context.Context, i int, opt StopOptions) (*StopResult // already_offline would be a success the start then overrides by booting the instance. Wait a little for its // qemu, still holding the stop lock: a start that has not got past its stop-lock check yet then cannot launch // before this stop is over, and no other stop can get in between (see the header comment). - mm.Log.Infof("魔改 AVD 实例 %d 正在开机,等模拟器起来再关(最多 %.0f 秒)", i, startingQemuWait.Seconds()) + mm.Log.Infof("ModAVD 实例 %d 正在开机,等模拟器起来再关(最多 %.0f 秒)", i, startingQemuWait.Seconds()) var found, starting bool p, found, starting, err = waitStartingQemu(ctx, startingQemuWait, startingQemuPoll, func() (host.Process, bool, error) { return mm.qemuOf(i) }, func() bool { return mm.lockHeld(i, "start") }) @@ -138,7 +138,7 @@ func (m *Manager) Stop(ctx context.Context, i int, opt StopOptions) (*StopResult return nil, err } if !found && starting { - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正在开机,模拟器还没起来,稍后再关", i). + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正在开机,模拟器还没起来,稍后再关", i). WithAdvice("开机前的检查(硬件加速、Vulkan 等)可能要一两分钟"), map[string]string{errs.Reason: "starting"}) } ok = found @@ -147,7 +147,7 @@ func (m *Manager) Stop(ctx context.Context, i int, opt StopOptions) (*StopResult // path, sync included, even if that runs past the budget. launching = found && justLaunched(p, started, mm.booting(i)) if found && !launching { - mm.Log.Warnf("魔改 AVD 实例 %d 等开机时找到的模拟器不是刚拉起的(pid %d),按正常关机处理", i, p.PID) + mm.Log.Warnf("ModAVD 实例 %d 等开机时找到的模拟器不是刚拉起的(pid %d),按正常关机处理", i, p.PID) } } if !ok { @@ -278,12 +278,12 @@ func runStop(ctx context.Context, log *logx.Logger, i int, force bool, started t if err := ops.forceKill(forceKillWait); err != nil { return "force", synced, err } - log.Infof("魔改 AVD 实例 %d 已强制关闭", i) + log.Infof("ModAVD 实例 %d 已强制关闭", i) return "force", synced, nil } if problem != "" { - log.Warnf("魔改 AVD 实例 %d 私有 adb 用不了,不能正常关机也不能 sync,直接用控制台关: %s", i, problem) + log.Warnf("ModAVD 实例 %d 私有 adb 用不了,不能正常关机也不能 sync,直接用控制台关: %s", i, problem) return endQemu(ctx, log, i, "console_kill", false, consoleKillTimeout+closeTimeout, forceKillWait, ops) } // The foreground app goes home first and gets homeWait; the sync below takes what it wrote along (see the header @@ -298,13 +298,13 @@ func runStop(ctx context.Context, log *logx.Logger, i int, force bool, started t } reason := ops.graceful(gracefulWindow(time.Since(started))) if reason == "" { - log.Infof("魔改 AVD 实例 %d 正常关机完成(%.1f s)", i, time.Since(started).Seconds()) + log.Infof("ModAVD 实例 %d 正常关机完成(%.1f s)", i, time.Since(started).Seconds()) return "graceful", true, nil } if err := stopCanceled(ctx, i); err != nil { return "graceful", false, err } - log.Warnf("魔改 AVD 实例 %d 正常关机没成(%s),退回 sync + 控制台 kill", i, reason) + log.Warnf("ModAVD 实例 %d 正常关机没成(%s),退回 sync + 控制台 kill", i, reason) synced := syncBeforeKill(log, i, "", fallbackSyncTimeout, ops.sync) return endQemu(ctx, log, i, "sync_kill", presynced || synced, fallbackKillTimeout(time.Since(started)), fallbackForceKillWait, ops) @@ -315,13 +315,13 @@ func runStop(ctx context.Context, log *logx.Logger, i int, force bool, started t func sendHome(ctx context.Context, log *logx.Logger, i int, home func(time.Duration) (bool, string)) error { sent, failure := home(homeTimeout) if failure != "" { - log.Warnf("魔改 AVD 实例 %d 关机前按 HOME 失败,照常关机: %s", i, failure) + log.Warnf("ModAVD 实例 %d 关机前按 HOME 失败,照常关机: %s", i, failure) return nil } if !sent { return nil } - log.Infof("魔改 AVD 实例 %d 前台的应用已退到后台,等 %.0f 秒让它保存再关机", i, homeWait.Seconds()) + log.Infof("ModAVD 实例 %d 前台的应用已退到后台,等 %.0f 秒让它保存再关机", i, homeWait.Seconds()) select { case <-ctx.Done(): return stopCanceled(ctx, i) @@ -336,9 +336,9 @@ func syncBeforeShutdown(log *logx.Logger, i int, sync func(time.Duration) (bool, started := time.Now() ok, output := sync(shutdownSyncTimeout) if ok { - log.Debugf("魔改 AVD 实例 %d 发关机命令前 sync 完成(%.0f ms)", i, float64(time.Since(started).Microseconds())/1000) + log.Debugf("ModAVD 实例 %d 发关机命令前 sync 完成(%.0f ms)", i, float64(time.Since(started).Microseconds())/1000) } else { - log.Warnf("魔改 AVD 实例 %d 发关机命令前 sync 失败,照常关机: %s", i, output) + log.Warnf("ModAVD 实例 %d 发关机命令前 sync 失败,照常关机: %s", i, output) } return ok } @@ -348,7 +348,7 @@ func syncBeforeShutdown(log *logx.Logger, i int, sync func(time.Duration) (bool, // name, not console_kill: that one means the private adb of a booted guest was unusable and data may be lost; here // there is no guest data yet), never synced. func runLaunchingStop(ctx context.Context, log *logx.Logger, i int, killTimeout time.Duration, ops stopOps) (string, bool, error) { - log.Infof("魔改 AVD 实例 %d 的模拟器刚起来,安卓还没开机,不做正常关机和 sync,直接从控制台关", i) + log.Infof("ModAVD 实例 %d 的模拟器刚起来,安卓还没开机,不做正常关机和 sync,直接从控制台关", i) return endQemu(ctx, log, i, "launching_kill", false, killTimeout, forceKillWait, ops) } @@ -366,13 +366,13 @@ func endQemu(ctx context.Context, log *logx.Logger, i int, method string, synced if err := stopCanceled(ctx, i); err != nil { return method, synced, err } - log.Warnf("魔改 AVD 实例 %d 未在 %.0f 秒内退出,强制结束", i, killTimeout.Seconds()) + log.Warnf("ModAVD 实例 %d 未在 %.0f 秒内退出,强制结束", i, killTimeout.Seconds()) method += "+terminate" if err := ops.forceKill(forceWait); err != nil { return method, synced, err } } - log.Infof("魔改 AVD 实例 %d 已关闭", i) + log.Infof("ModAVD 实例 %d 已关闭", i) return method, synced, nil } @@ -385,9 +385,9 @@ func syncBeforeKill(log *logx.Logger, i int, problem string, timeout time.Durati ok, output = sync(timeout) } if ok { - log.Infof("魔改 AVD 实例 %d 关机前 sync 完成(%.0f ms)", i, float64(time.Since(started).Microseconds())/1000) + log.Infof("ModAVD 实例 %d 关机前 sync 完成(%.0f ms)", i, float64(time.Since(started).Microseconds())/1000) } else { - log.Warnf("魔改 AVD 实例 %d 关机前 sync 失败,照常关机: %s", i, output) + log.Warnf("ModAVD 实例 %d 关机前 sync 失败,照常关机: %s", i, output) } return ok } @@ -508,7 +508,7 @@ func (q qemuKill) run(ctx context.Context, log *logx.Logger, i int, pid uint32, } log.Debugf("结束实例 %d 的 qemu(pid %d)时 Windows 答复: %v(它已经在退出)", i, pid, killErr) } - log.Warnf("魔改 AVD 实例 %d 的 qemu(pid %d)已强制结束,还在退出(等了 %.0f 秒);稍后用 modavd status %d 确认它已退出", + log.Warnf("ModAVD 实例 %d 的 qemu(pid %d)已强制结束,还在退出(等了 %.0f 秒);稍后用 modavd status %d 确认它已退出", i, pid, wait.Seconds(), i) return nil } @@ -524,7 +524,7 @@ func terminateRefused(p host.Process) bool { // qemuNotEnded is the error of a stop after which qemu pid still runs because Windows refused to terminate it // (killErr). func qemuNotEnded(i int, pid uint32, killErr error) error { - return errs.Wrap(errs.Internal, killErr, fmt.Sprintf("魔改 AVD 实例 %d 的 qemu(pid %d)没能结束:%v", i, pid, killErr)). + return errs.Wrap(errs.Internal, killErr, fmt.Sprintf("ModAVD 实例 %d 的 qemu(pid %d)没能结束:%v", i, pid, killErr)). WithAdvice("它可能是以管理员身份启动的(例如 AUTO-MAS 以管理员身份运行时);用管理员权限再关一次,或在任务管理器里结束它") } diff --git a/internal/emu/stop_test.go b/internal/emu/stop_test.go index 1cdfbc5..562633d 100644 --- a/internal/emu/stop_test.go +++ b/internal/emu/stop_test.go @@ -173,7 +173,7 @@ func TestRunStopSendsAppHome(t *testing.T) { f = &stopFake{homeFailure: "返回码 1: error: closed", syncOK: true} method, _, err, warns = runStopFake(t, context.Background(), false, f) if err != nil || method != "graceful" || len(warns) != 1 || - warns[0] != "魔改 AVD 实例 3 关机前按 HOME 失败,照常关机: 返回码 1: error: closed" || + warns[0] != "ModAVD 实例 3 关机前按 HOME 失败,照常关机: 返回码 1: error: closed" || f.at["sync"].Sub(f.at["home"]) >= homeWait { t.Fatalf("failed: %s %v %q", method, err, warns) } @@ -196,7 +196,7 @@ func TestRunStopSyncBeforeShutdownFails(t *testing.T) { if got := strings.Join(f.calls, ","); got != "prepare,home,sync,graceful" { t.Fatalf("calls = %s", got) } - if len(warns) != 1 || warns[0] != "魔改 AVD 实例 3 发关机命令前 sync 失败,照常关机: error: closed" { + if len(warns) != 1 || warns[0] != "ModAVD 实例 3 发关机命令前 sync 失败,照常关机: error: closed" { t.Fatalf("warnings = %q", warns) } } @@ -218,7 +218,7 @@ func TestRunStopFallbackSyncKill(t *testing.T) { if want := stopBudget - gracefulDeadline - fallbackForceKillWait; f.killTimeout > want || f.killTimeout < want-time.Second { t.Fatalf("kill timeout %v, want about %v", f.killTimeout, want) } - if len(warns) != 1 || warns[0] != "魔改 AVD 实例 3 正常关机没成(24 秒内没有自己退出),退回 sync + 控制台 kill" { + if len(warns) != 1 || warns[0] != "ModAVD 实例 3 正常关机没成(24 秒内没有自己退出),退回 sync + 控制台 kill" { t.Fatalf("warnings = %q", warns) } } @@ -260,7 +260,7 @@ func TestRunStopFallbackTerminate(t *testing.T) { if len(warns) != 5 || !strings.Contains(warns[0], "发关机命令前 sync 失败,照常关机: error: closed") || !strings.Contains(warns[2], "关机前 sync 失败,照常关机: error: closed") || warns[3] != "实例 3 控制台 kill 失败,改为强制结束: 连不上控制台 20030" || - warns[4] != "魔改 AVD 实例 3 未在 3 秒内退出,强制结束" { + warns[4] != "ModAVD 实例 3 未在 3 秒内退出,强制结束" { t.Fatalf("warnings = %q", warns) } } @@ -400,7 +400,7 @@ func TestRunStopConsoleOnly(t *testing.T) { if f.killTimeout != consoleKillTimeout+closeTimeout || f.forceKillFor != forceKillWait { t.Fatalf("timeouts kill %v force %v", f.killTimeout, f.forceKillFor) } - if !strings.HasPrefix(warns[0], "魔改 AVD 实例 3 私有 adb 用不了,不能正常关机也不能 sync,直接用控制台关: 私有 adb server") { + if !strings.HasPrefix(warns[0], "ModAVD 实例 3 私有 adb 用不了,不能正常关机也不能 sync,直接用控制台关: 私有 adb server") { t.Fatalf("warnings = %q", warns) } f = &stopFake{problem: "x"} @@ -496,7 +496,7 @@ func TestQemuNotEnded(t *testing.T) { cause := errors.New("Access is denied.") err := qemuNotEnded(3, 4242, cause) if errs.CodeOf(err) != errs.Internal || errs.ExitCode(err) != 1 || !errors.Is(err, cause) || - !strings.HasPrefix(err.Error(), "魔改 AVD 实例 3 的 qemu(pid 4242)没能结束:Access is denied.。") || + !strings.HasPrefix(err.Error(), "ModAVD 实例 3 的 qemu(pid 4242)没能结束:Access is denied.。") || !strings.Contains(err.Error(), "管理员") { t.Fatalf("denied: %v (%s)", err, errs.CodeOf(err)) } @@ -538,7 +538,7 @@ func (k *killScript) run(t *testing.T, ctx context.Context, wait time.Duration) func TestQemuKillRun(t *testing.T) { ctx := context.Background() denied := errors.New("结束qemu-system-x86_64.exe(PID 4242)失败: Access is denied.") - stillExiting := "魔改 AVD 实例 3 的 qemu(pid 4242)已强制结束,还在退出(等了 2 秒);稍后用 modavd status 3 确认它已退出" + stillExiting := "ModAVD 实例 3 的 qemu(pid 4242)已强制结束,还在退出(等了 2 秒);稍后用 modavd status 3 确认它已退出" cases := []struct { name string script killScript diff --git a/internal/emu/window.go b/internal/emu/window.go index a243da4..68fc5d7 100644 --- a/internal/emu/window.go +++ b/internal/emu/window.go @@ -36,10 +36,10 @@ func windowConflict(i int, opt StartOptions, windowed bool) error { const asIs = "(不指定 --window / --headless 就照原样接管)" switch { case opt.Window && !windowed: - return errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正以无头方式运行,运行中不能换成带窗口", i). + return errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正以无头方式运行,运行中不能换成带窗口", i). WithAdvice(fmt.Sprintf("先关机再换:modavd stop %d,再 modavd start %d --window%s", i, i, asIs)), map[string]string{errs.Reason: "window_mismatch"}) case opt.Headless && windowed: - return errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正带窗口运行,运行中不能换成无头", i). + return errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正带窗口运行,运行中不能换成无头", i). WithAdvice(fmt.Sprintf("先关机再换:modavd stop %d,再 modavd start %d --headless%s", i, i, asIs)), map[string]string{errs.Reason: "window_mismatch"}) } return nil @@ -79,15 +79,15 @@ func (m *Manager) SetWindowVisible(ctx context.Context, i int, visible bool) (*W if errs.CodeOf(err) == errs.Unsupported { return nil, err } - m.Log.Warnf("魔改 AVD 实例 %d %s窗口失败: %v", i, verb, err) + m.Log.Warnf("ModAVD 实例 %d %s窗口失败: %v", i, verb, err) return res, nil } res.Windows = n switch { case n > 0: - m.Log.Infof("魔改 AVD 实例 %d 已%s窗口", i, verb) + m.Log.Infof("ModAVD 实例 %d 已%s窗口", i, verb) case visible: - m.Log.Infof("魔改 AVD 实例 %d 是无头开机的,没有窗口可显示;要窗口请关机后带窗口重新启动", i) + m.Log.Infof("ModAVD 实例 %d 是无头开机的,没有窗口可显示;要窗口请关机后带窗口重新启动", i) } return res, nil } diff --git a/internal/jsonord/testdata/gen_pycases.py b/internal/jsonord/testdata/gen_pycases.py index 884ed22..7df2dec 100644 --- a/internal/jsonord/testdata/gen_pycases.py +++ b/internal/jsonord/testdata/gen_pycases.py @@ -15,7 +15,7 @@ "[]", '{"a": {}, "b": [], "c": [{}, []], "d": {"e": {"f": []}}}', '[[], {}, [[]], [{}], {"x": []}]', - '{"名称": "魔改 AVD ✓ 😀", "x": "\\u00e9\\ud83d\\ude00", "": "empty key"}', + '{"名称": "模拟器 ✓ 😀", "x": "\\u00e9\\ud83d\\ude00", "": "empty key"}', '"\\u0000\\u0001\\u001f\\u007f\\b\\f\\n\\r\\t \\u0080 \\u00a0 \\u2028 \\u2029 \\ufffd \\ufeff"', '"a\\"b\\\\c\\/d"', '{"q": "say \\"hi\\"", "p": "C:\\\\sdk-src\\\\sdk-mas35\\\\", "u": "\\u4e2d\\u6587"}', diff --git a/internal/jsonord/testdata/pycases.json b/internal/jsonord/testdata/pycases.json index 4d3f4b9..ff2e08d 100644 --- a/internal/jsonord/testdata/pycases.json +++ b/internal/jsonord/testdata/pycases.json @@ -17,8 +17,8 @@ "out": "[\n [],\n {},\n [\n []\n ],\n [\n {}\n ],\n {\n \"x\": []\n }\n]" }, { -"in": "eyLlkI3np7AiOiAi6a2U5pS5IEFWRCDinJMg8J+YgCIsICJ4IjogIlx1MDBlOVx1ZDgzZFx1ZGUwMCIsICIiOiAiZW1wdHkga2V5In0=", -"out": "{\n \"\u540d\u79f0\": \"\u9b54\u6539 AVD \u2713 \ud83d\ude00\",\n \"x\": \"\u00e9\ud83d\ude00\",\n \"\": \"empty key\"\n}" +"in": "eyLlkI3np7AiOiAi5qih5ouf5ZmoIOKckyDwn5iAIiwgIngiOiAiXHUwMGU5XHVkODNkXHVkZTAwIiwgIiI6ICJlbXB0eSBrZXkifQ==", +"out": "{\n \"\u540d\u79f0\": \"\u6a21\u62df\u5668 \u2713 \ud83d\ude00\",\n \"x\": \"\u00e9\ud83d\ude00\",\n \"\": \"empty key\"\n}" }, { "in": "Ilx1MDAwMFx1MDAwMVx1MDAxZlx1MDA3ZlxiXGZcblxyXHQgXHUwMDgwIFx1MDBhMCBcdTIwMjggXHUyMDI5IFx1ZmZmZCBcdWZlZmYi", diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index bda2438..d809553 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -102,7 +102,7 @@ func (f *fake) List(ctx context.Context) (*emu.ListResult, error) { func (f *fake) Info(ctx context.Context, i int) (*emu.InstanceInfo, error) { f.record("info %d", i) if i == 9 { - return nil, errs.Newf(errs.NotFound, "魔改 AVD 实例 %d 不存在", i).WithAdvice("先用 list_instances 看有哪些实例") + return nil, errs.Newf(errs.NotFound, "ModAVD 实例 %d 不存在", i).WithAdvice("先用 list_instances 看有哪些实例") } in := sampleInstance(i) in.Diagnostics = f.diagnostics @@ -877,7 +877,7 @@ func TestToolErrors(t *testing.T) { {"get_instance", map[string]any{"index": 3.5}, errs.Usage, "参数 index 必须是整数"}, {"get_instance", map[string]any{"index": 3, "root": `D:\ModAVD`}, errs.Usage, "不认识的参数: root"}, {"list_instances", map[string]any{"all": true}, errs.Usage, "list_instances 不接受参数"}, - {"get_instance", map[string]any{"index": 9}, errs.NotFound, "魔改 AVD 实例 9 不存在。先用 list_instances 看有哪些实例"}, + {"get_instance", map[string]any{"index": 9}, errs.NotFound, "ModAVD 实例 9 不存在。先用 list_instances 看有哪些实例"}, {"delete_instance", map[string]any{"index": 2, "confirm": false}, errs.Usage, "confirm 设为 true"}, {"delete_instance", map[string]any{"index": 2}, errs.Usage, "缺少参数 confirm"}, {"update_instance", map[string]any{"index": 2}, errs.Usage, "至少给一项"}, diff --git a/internal/report/collect.go b/internal/report/collect.go index d7e84e8..1f69f42 100644 --- a/internal/report/collect.go +++ b/internal/report/collect.go @@ -43,7 +43,7 @@ var ( const ( nativeTimeout = 20 * time.Second // Invoke-Native -TimeoutSec default accelTimeout = 30 * time.Second - collectZipStart = "魔改AVD问题信息-" + collectZipStart = "ModAVD-问题信息-" ) // CollectOptions for Collect; the first three are the script's -OutDir, -StageDir and -KeepStage. @@ -238,9 +238,9 @@ func collect(ctx context.Context, rootDir string, h Host, opt CollectOptions) (r }() c := &collector{ctx: ctx, root: rootDir, h: h, opt: opt, stage: stage, start: start, adbPort: root.DefaultAdbServerPort} - c.progress("魔改 AVD 问题信息收集") + c.progress("ModAVD 问题信息收集") c.progress("ModAVD 目录:" + rootDir) - c.L("魔改 AVD 问题信息") + c.L("ModAVD 问题信息") c.L(PrivacyNote) c.L("生成时间:" + start.Format("2006-01-02 15:04:05 -07:00")) // the script names the PowerShell that ran it here diff --git a/internal/report/config.go b/internal/report/config.go index 0592e90..5a15919 100644 --- a/internal/report/config.go +++ b/internal/report/config.go @@ -49,7 +49,7 @@ var notGames = []string{"de.jrpie.android.launcher"} var configTail = []string{"怎么用的(自己玩 / AUTO-MAS 及版本 / MAA、M9A 等脚本):", "效果(能跑 / 有问题,问题是…):"} const ( - configTitle = "【魔改 AVD 试用配置】" + configTitle = "【ModAVD 试用配置】" configNotOpen = "游戏包:没有实例开着,读不到。开着实例时再生成一次,就会列出来" emuCheckWait = 20 * time.Second clipboardOK = "已复制到剪贴板:粘贴到群里,再把最后两行补上就行。" diff --git a/internal/report/parity_windows_test.go b/internal/report/parity_windows_test.go index 53082a7..146b1c2 100644 --- a/internal/report/parity_windows_test.go +++ b/internal/report/parity_windows_test.go @@ -161,15 +161,18 @@ func modavdPrivacyHead() string { return head } -// notAllMAS: what the scripts said as if every user had AUTO-MAS, and what modavd says instead (many use ModAVD -// without it). Applied in order, to the scripts' lines. -var notAllMAS = []struct{ script, modavd string }{ +// reworded: what the scripts said that modavd says differently on purpose, applied in order to the scripts' lines: +// what they said as if every user had AUTO-MAS (many use ModAVD without it), and the old Chinese name for ModAVD +// (it is ModAVD in every language). +var reworded = []struct{ script, modavd string }{ {"提示:没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志。", "提示:" + MasMissingNote}, {"没找到 AUTO-MAS 安装目录,反馈时请附上 AUTO-MAS 的 debug 文件夹里的日志", "没找到 AUTO-MAS 安装目录(不用 AUTO-MAS 的不用管)"}, {"(多半是 AUTO-MAS 以管理员运行)", "(多半是模拟器以管理员身份运行)"}, {"实例配置、AUTO-MAS 日志(mas 文件夹);", "实例配置;找到 AUTO-MAS 时还有它的日志(mas 文件夹);"}, {"在 AUTO-MAS 里开着实例时再双击运行一次", "开着实例时再生成一次"}, {"再把最后三行补上就行。", "再把最后两行补上就行。"}, + {"魔改AVD问题信息-", "ModAVD-问题信息-"}, + {"魔改 AVD", "ModAVD"}, } // modavdLine is what modavd writes on purpose instead of a line of collect-info.ps1's summary.txt: @@ -177,7 +180,7 @@ var notAllMAS = []struct{ script, modavd string }{ // - the adb.exe the live section runs: modavd speaks the adb host protocol itself (no adb.exe, never a server of // its own), to the server that listens on the port; // - the first line of the privacy note: who does not upload is the manager or the CLI, not a script; -// - notAllMAS. +// - reworded. func (f *fixture) modavdLine(line string) string { if line == "PowerShell:{{PSVERSION}}" { return "modavd:" + buildinfo.Version + "(" + runtime.Version() + ")" @@ -185,7 +188,7 @@ func (f *fixture) modavdLine(line string) string { if line == scriptPrivacyHead { return modavdPrivacyHead() } - for _, r := range notAllMAS { + for _, r := range reworded { line = strings.Replace(line, r.script, r.modavd, 1) } if strings.HasPrefix(line, "adb:") && strings.Contains(line, " -P "+strconv.Itoa(f.adbPort)+"(") { @@ -383,13 +386,13 @@ func TestCollectMatchesScript(t *testing.T) { // the end of the console output: zip path, the privacy note word for word but for modavdLine's console, _ := f.golden(t, name, "collect-console.txt") - if !containsLine(lines(console), "文件:"+res.ZipPath) { - t.Errorf("zip %s, the script printed:\n%s", res.ZipPath, console) - } var consoleLines []string for _, l := range lines(console) { consoleLines = append(consoleLines, f.modavdLine(l)) } + if !containsLine(consoleLines, "文件:"+res.ZipPath) { + t.Errorf("zip %s, the script printed:\n%s", res.ZipPath, console) + } if !strings.Contains(strings.Join(consoleLines, "\r\n"), PrivacyNote) || res.PrivacyNote != PrivacyNote { t.Error("the privacy note is not the one collect-info.ps1 prints, but for modavdLine's") } diff --git a/internal/root/root.go b/internal/root/root.go index 3b8e21c..3ffd991 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -254,10 +254,10 @@ func Resolve(flag string) (*Root, error) { func resolve(flag, env, exe string) (*Root, error) { if s := cleanArg(flag); s != "" { - return openDir(s, "--root 指定的") + return openDir(s, "--root 指定的 ") } if s := cleanArg(env); s != "" { - return openDir(s, "环境变量 "+EnvRoot+" 指定的") + return openDir(s, "环境变量 "+EnvRoot+" 指定的 ") } if exe != "" { dir := filepath.Dir(exe) @@ -267,7 +267,7 @@ func resolve(flag, env, exe string) (*Root, error) { } } } - return nil, errs.New(errs.Usage, "找不到魔改 AVD 根目录").WithAdvice( + return nil, errs.New(errs.Usage, "找不到 ModAVD 根目录").WithAdvice( "用 --root 指定根目录(里面有 sdk 文件夹和 mas-avd.json),或设置环境变量 " + EnvRoot + ";modavd.exe 放在根目录或根目录的 tools 文件夹里时会自动找到") } @@ -287,14 +287,14 @@ func looksLikeRoot(dir string) bool { func openDir(dir, what string) (*Root, error) { abs, err := filepath.Abs(dir) if err != nil { - return nil, errs.Wrap(errs.Usage, err, fmt.Sprintf("%s魔改 AVD 根目录写法不对: %s", what, dir)) + return nil, errs.Wrap(errs.Usage, err, fmt.Sprintf("%sModAVD 根目录写法不对: %s", what, dir)) } fi, err := os.Stat(abs) if err != nil { - return nil, errs.Wrap(errs.NotFound, err, fmt.Sprintf("%s魔改 AVD 根目录不存在: %s", what, abs)) + return nil, errs.Wrap(errs.NotFound, err, fmt.Sprintf("%sModAVD 根目录不存在: %s", what, abs)) } if !fi.IsDir() { - return nil, errs.Newf(errs.NotFound, "%s魔改 AVD 根目录不是文件夹: %s", what, abs) + return nil, errs.Newf(errs.NotFound, "%sModAVD 根目录不是文件夹: %s", what, abs) } return &Root{Dir: abs}, nil } @@ -345,7 +345,7 @@ func (r *Root) QemuImgExe() (string, error) { exe := filepath.Join(r.RuntimeSDK(), "emulator", "qemu-img.exe") if fi, err := os.Stat(exe); err != nil || fi.IsDir() { return exe, errs.Newf(errs.NotReady, "找不到 qemu-img: %s", exe). - WithAdvice("魔改 AVD 的模拟器组件不完整,请更新组件后再试") + WithAdvice("ModAVD 的模拟器组件不完整,请更新组件后再试") } return exe, nil } @@ -677,7 +677,7 @@ func (r *Root) LockInstanceConfig(i int) (*InstanceLock, error) { return nil, errs.Wrap(errs.Internal, err, fmt.Sprintf("实例 %s 的锁文件打不开: %s(%v)", AvdName(i), path, err)) } if !ok { - return nil, errs.WithDetails(errs.Newf(errs.Busy, "魔改 AVD 实例 %d 正被另一个 modavd 改动(新建、改设置或删除),等了 %.0f 秒仍没改完", + return nil, errs.WithDetails(errs.Newf(errs.Busy, "ModAVD 实例 %d 正被另一个 modavd 改动(新建、改设置或删除),等了 %.0f 秒仍没改完", i, configLockWait.Seconds()), map[string]string{errs.Reason: "locked", errs.Lock: "config"}) } return &InstanceLock{l: l}, nil @@ -782,7 +782,7 @@ const ( const ( FetchComponentsAdvice = "在管理器里点「补全组件」,或运行 tools\\modavd.exe components fetch;它会先显示《Android 软件开发套件许可协议》,输入 y 同意后" + "从官方渠道下载 adb 和系统镜像(约 1.5 GB,中断了再运行会接着下),完成后再检查一次" - PackageAdvice = "模拟器要用魔改 AVD 的发布包:从 https://github.com/ModAVD/ModAVD/releases 下载 " + + PackageAdvice = "模拟器要用 ModAVD 的发布包:从 https://github.com/ModAVD/ModAVD/releases 下载 " + "ModAVD-<版本>-win-x64.zip,整个解压到这个目录" ) diff --git a/internal/root/root_test.go b/internal/root/root_test.go index 6322084..bbea507 100644 --- a/internal/root/root_test.go +++ b/internal/root/root_test.go @@ -270,7 +270,7 @@ func TestResolve(t *testing.T) { _, err := resolve(filepath.Join(base, "missing"), "", "") wantCode(t, err, errs.NotFound) - if !strings.Contains(err.Error(), "--root 指定的魔改 AVD 根目录不存在") { + if !strings.Contains(err.Error(), "--root 指定的 ModAVD 根目录不存在") { t.Fatalf("message: %v", err) } _, err = resolve("", filepath.Join(base, "missing"), "") @@ -389,7 +389,7 @@ func TestUpdateMetadataFormatAndAbort(t *testing.T) { writeFile(t, r.MetadataPath(), []byte(sampleMetadata)) err := r.UpdateMetadata(func(o *jsonord.Obj) error { o.Set("adbServerPort", jsonord.NewInt(20089)) - o.Set("名称", jsonord.NewString("魔改 \"AVD\"")) + o.Set("名称", jsonord.NewString("模拟器 \"AVD\"")) return nil }) if err != nil { @@ -397,7 +397,7 @@ func TestUpdateMetadataFormatAndAbort(t *testing.T) { } want := "{\r\n \"components\": {\r\n \"emulator\": \"37.2.10\"\r\n },\r\n \"zeta\": [\r\n 1000.0,\r\n 0,\r\n" + " {}\r\n ],\r\n \"instances\": {\r\n \"0\": {\r\n \"memoryMb\": 6144,\r\n \"initialized\": true\r\n" + - " }\r\n },\r\n \"sdkRoot\": \"\",\r\n \"adbServerPort\": 20089,\r\n \"名称\": \"魔改 \\\"AVD\\\"\"\r\n}" + " }\r\n },\r\n \"sdkRoot\": \"\",\r\n \"adbServerPort\": 20089,\r\n \"名称\": \"模拟器 \\\"AVD\\\"\"\r\n}" if got := string(readFile(t, r.MetadataPath())); got != want { t.Fatalf("got %q\nwant %q", got, want) } diff --git a/internal/ui/fake_test.go b/internal/ui/fake_test.go index fa2a986..e801259 100644 --- a/internal/ui/fake_test.go +++ b/internal/ui/fake_test.go @@ -137,7 +137,7 @@ func (b fakeBackend) Info(ctx context.Context, i int) (*emu.InstanceInfo, error) } in, ok := b.f.instances[i] if !ok { - return nil, errs.Newf(errs.NotFound, "魔改 AVD 实例 %d 不存在", i) + return nil, errs.Newf(errs.NotFound, "ModAVD 实例 %d 不存在", i) } return &in, nil } @@ -174,7 +174,7 @@ func (b fakeBackend) Update(ctx context.Context, i int, req emu.UpdateRequest) ( b.f.mu.Unlock() in, ok := b.f.get(i) if !ok { - return nil, errs.Newf(errs.NotFound, "魔改 AVD 实例 %d 不存在", i) + return nil, errs.Newf(errs.NotFound, "ModAVD 实例 %d 不存在", i) } applied := map[string]any{} if req.FPS != nil { @@ -189,7 +189,7 @@ func (b fakeBackend) Update(ctx context.Context, i int, req emu.UpdateRequest) ( func (b fakeBackend) PreviewDiagnostics(ctx context.Context, i int) (*emu.DiagnosticsPreview, error) { if _, ok := b.f.get(i); !ok { - return nil, errs.Newf(errs.NotFound, "魔改 AVD 实例 %d 不存在", i) + return nil, errs.Newf(errs.NotFound, "ModAVD 实例 %d 不存在", i) } return &emu.DiagnosticsPreview{Index: i, DiagnosticsCleanup: emu.DiagnosticsCleanup{Files: 2, Bytes: 1024}}, nil } @@ -198,7 +198,7 @@ func (b fakeBackend) Delete(ctx context.Context, i int) (*emu.DeleteResult, erro b.f.mu.Lock() defer b.f.mu.Unlock() if _, ok := b.f.instances[i]; !ok { - return nil, errs.Newf(errs.NotFound, "魔改 AVD 实例 %d 不存在", i) + return nil, errs.Newf(errs.NotFound, "ModAVD 实例 %d 不存在", i) } delete(b.f.instances, i) b.f.deleted = append(b.f.deleted, i) @@ -211,7 +211,7 @@ func (b fakeBackend) Start(ctx context.Context, i int, opt emu.StartOptions) (*e gate := b.f.startGate b.f.mu.Unlock() if _, ok := b.f.get(i); !ok { - return nil, errs.Newf(errs.NotFound, "魔改 AVD 实例 %d 不存在", i) + return nil, errs.Newf(errs.NotFound, "ModAVD 实例 %d 不存在", i) } opt.Progress(emu.Progress{Step: "launch", Message: "实例已拉起", Done: 50, Total: 1000}) b.log.Warnf("测试警告 %d", i) @@ -267,7 +267,7 @@ func (b fakeBackend) AppProxy(ctx context.Context, i int) (*emu.AppProxyResult, defer b.f.mu.Unlock() b.f.proxyAsked = append(b.f.proxyAsked, i) if _, ok := b.f.instances[i]; !ok { - return nil, errs.Newf(errs.NotFound, "魔改 AVD 实例 %d 不存在", i) + return nil, errs.Newf(errs.NotFound, "ModAVD 实例 %d 不存在", i) } return b.f.proxyOf(i), nil } diff --git a/internal/ui/proxy_test.go b/internal/ui/proxy_test.go index c6b6c69..05261b5 100644 --- a/internal/ui/proxy_test.go +++ b/internal/ui/proxy_test.go @@ -253,7 +253,7 @@ func TestProxyPackages(t *testing.T) { } // emu says it does not run (no recent list): the same 409 h.f.mu.Lock() - h.f.shellErr = errs.New(errs.Unsupported, "魔改 AVD 实例 3 没有在运行") + h.f.shellErr = errs.New(errs.Unsupported, "ModAVD 实例 3 没有在运行") h.f.mu.Unlock() h.s.hub.dropCached() expectError(t, h.do("GET", "/api/instances/3/packages", nil), 409, errs.Busy) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 196719f..3236f1d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -117,7 +117,7 @@ type server struct { func newServer(opt Options) (*server, error) { if opt.Root == nil { - return nil, errs.New(errs.Usage, "没有指定魔改 AVD 根目录") + return nil, errs.New(errs.Usage, "没有指定 ModAVD 根目录") } lg := opt.Log if lg == nil { diff --git a/internal/ui/web/dev/mock.js b/internal/ui/web/dev/mock.js index cc5f9c1..4eb3e63 100644 --- a/internal/ui/web/dev/mock.js +++ b/internal/ui/web/dev/mock.js @@ -232,7 +232,7 @@ function checkResult() { } if (db.check === 'notready' && !db.componentsReady) Object.assign(items[1], { ok: false, reason: '这个目录里缺少:平台工具(adb)、系统镜像 Android 14 Google APIs x86_64', advice: '在管理器里点「补全组件」,或运行 tools\\modavd.exe components fetch;它会先显示《Android 软件开发套件许可协议》,输入 y 同意后从官方渠道下载 adb 和系统镜像(约 1.5 GB,中断了再运行会接着下),完成后再检查一次' }); if (db.check === 'notready') { - Object.assign(items[3], { ok: false, reason: '硬件虚拟化(Windows 虚拟机监控程序平台)不可用,魔改 AVD 无法启动(检查结果:WHPX is not installed)', advice: '先在 BIOS/UEFI 里打开 VT-x/SVM,再在「启用或关闭 Windows 功能」里勾选「Windows 虚拟机监控程序平台」,然后重启' }); + Object.assign(items[3], { ok: false, reason: '硬件虚拟化(Windows 虚拟机监控程序平台)不可用,ModAVD 无法启动(检查结果:WHPX is not installed)', advice: '先在 BIOS/UEFI 里打开 VT-x/SVM,再在「启用或关闭 Windows 功能」里勾选「Windows 虚拟机监控程序平台」,然后重启' }); Object.assign(items[4], { ok: null, reason: '查不了 Vulkan:探测程序 8 秒内没有结束' }); Object.assign(items[2], { reason: '内测包 0.35.0(37.2.10)' }); // the host IME is optional and stays missing, also after a fetch (it is not a downloaded component) @@ -278,7 +278,7 @@ function runImport(j, files) { const steps = []; for (const x of named) if (x.zip || x.slot >= 0) steps.push(['verifying', `校验 ${x.f.name}${x.zip ? '(SHA-1)' : `(${STORE_FILES[x.slot][0]})`}`, x.zip ? STORE_ZIP : STORE_FILES[x.slot][2]]); if (zip) steps.push(['extracting', `解压 ${zip.f.name}`, 3101687808]); - log('info', '魔改 AVD:用自己的文件补全 Google Play 商店', j.id); + log('info', 'ModAVD:用自己的文件补全 Google Play 商店', j.id); let k = 0; const timer = setInterval(() => { if (j.state !== 'running') { clearInterval(timer); return; } @@ -470,7 +470,7 @@ function runDiag(j, kind = j.kind, store = false) { // play_store: Google's Play image too, of which only the five files stay if (store) stages.push(['downloading', 'Google Play 商店(取自 Android 14 Google Play x86_64 镜像 r14)', 420, 1441, 25], ['downloading', 'Google Play 商店(取自 Android 14 Google Play x86_64 镜像 r14)', 1180, 1441, 27], ['extracting', 'Google Play 商店(取自 Android 14 Google Play x86_64 镜像 r14)', 2958, 2958, 0]); - if (kind === 'components') ['魔改 AVD:补全 Google 组件(adb、Android 14 系统镜像)', '已在管理器里同意许可协议。'].forEach((m) => log('info', m, j.id)); + if (kind === 'components') ['ModAVD:补全 Google 组件(adb、Android 14 系统镜像)', '已在管理器里同意许可协议。'].forEach((m) => log('info', m, j.id)); let n = 0; const timer = setInterval(() => { if (j.state !== 'running') { clearInterval(timer); return; } @@ -487,7 +487,7 @@ function runDiag(j, kind = j.kind, store = false) { db.componentsReady = true; if (store) db.store = { ready: true, source: 'download' }; finishJob(j, { root: ROOT, license: 'Android SDK License', license_source: 'Google 中国下载(mock)', components: componentList(), downloads: [] }); - } else finishJob(j, { zip_path: `${ROOT}\\logs\\魔改AVD问题信息-mock.zip`, zip_size: '1.2 MB', problems: [], mas_found: true, entries: ['summary.txt'], + } else finishJob(j, { zip_path: `${ROOT}\\logs\\ModAVD-问题信息-mock.zip`, zip_size: '1.2 MB', problems: [], mas_found: true, entries: ['summary.txt'], privacy_note: '隐私提示(mock):问题包只保存在本机,不会自动上传。\r\n- 可以公开:summary.txt 和日志。\r\n- 不要公开:crash 文件夹。' }); } }, 500); @@ -880,7 +880,7 @@ async function handle(url, init) { if (db.insts.has(i)) return errResp('already_exists', `实例 mas_${i} 已存在`); await sleep(500); const inst = mk(i, body.title, { mem: body.memory_mb, cpu: body.cpu, fps: body.fps, resolution: body.resolution || 1080, disk: body.data_partition_gb, initialized: false, scene: i }); - log('info', `已新建魔改 AVD 实例 ${i}(内存 ${inst.config.memory_mb} MB / ${inst.config.cpu} 核 / 数据盘 ${inst.config.data_partition_gb} GB / 帧率 ${inst.config.fps})`); + log('info', `已新建 ModAVD 实例 ${i}(内存 ${inst.config.memory_mb} MB / ${inst.config.cpu} 核 / 数据盘 ${inst.config.data_partition_gb} GB / 帧率 ${inst.config.fps})`); emit('instances', list()); return json(pub(inst), 201); } @@ -971,7 +971,7 @@ async function handle(url, init) { if (!m) return errResp('not_found', '没有这个接口:' + p); const i = Number(m[1]); const inst = db.insts.get(i); - if (!inst) return errResp('not_found', `魔改 AVD 实例 ${i} 不存在`, '先新建这个实例'); + if (!inst) return errResp('not_found', `ModAVD 实例 ${i} 不存在`, '先新建这个实例'); const act = m[2]; const busyJob = runningJobOf(i); @@ -994,7 +994,7 @@ async function handle(url, init) { } if (method === 'DELETE') { if (url.searchParams.get('confirm') !== '1') return errResp('usage', '删除要带 confirm=1'); - if (inst.running || busyJob) return errResp('busy', `魔改 AVD 实例 ${i} 未关闭,无法删除`, '先关机再删除'); + if (inst.running || busyJob) return errResp('busy', `ModAVD 实例 ${i} 未关闭,无法删除`, '先关机再删除'); await sleep(700); db.insts.delete(i); emit('instances', list()); @@ -1002,9 +1002,9 @@ async function handle(url, init) { } } if (act === 'start') { - if (busyJob) return errResp('busy', `魔改 AVD 实例 ${i} ${busyJob.kind === 'stop' ? '正在关机' : '正在开机或做开机后设置'}`, '等它做完再试'); + if (busyJob) return errResp('busy', `ModAVD 实例 ${i} ${busyJob.kind === 'stop' ? '正在关机' : '正在开机或做开机后设置'}`, '等它做完再试'); // the manager boots with the window only; one running headless is not adopted (no switch while it runs) - if (inst.running && !inst._.win) return errResp('busy', `魔改 AVD 实例 ${i} 正以无头方式运行,运行中不能换成带窗口`, `先关机再换:modavd stop ${i},再 modavd start ${i} --window`); + if (inst.running && !inst._.win) return errResp('busy', `ModAVD 实例 ${i} 正以无头方式运行,运行中不能换成带窗口`, `先关机再换:modavd stop ${i},再 modavd start ${i} --window`); if (inst.status === 'online') { const j = newJob('start', i); run(j, [[400, () => step(j, 'launch', `实例 ${i} 已在运行(pid ${inst.pid}),不重新开机`, 50)], [600, () => step(j, 'boot', `实例 ${i} 安卓已开机`, 610)], @@ -1016,7 +1016,7 @@ async function handle(url, init) { return json(j, 202); } if (act === 'stop') { - if (busyJob && busyJob.kind !== 'start') return errResp('busy', `魔改 AVD 实例 ${i} 正在${busyJob.kind === 'stop' ? '关机' : '安装 APK'}`, '等它做完再试'); + if (busyJob && busyJob.kind !== 'start') return errResp('busy', `ModAVD 实例 ${i} 正在${busyJob.kind === 'stop' ? '关机' : '安装 APK'}`, '等它做完再试'); const j = newJob('stop', i); if (!inst.running) { setTimeout(() => finishJob(j, { index: i, name: inst.name, method: 'already_offline', synced: false, seconds: 0.1, warnings: [] }), 300); @@ -1024,8 +1024,8 @@ async function handle(url, init) { return json(j, 202); } if (act === 'install') { - if (inst.status !== 'online') return errResp('busy', `魔改 AVD 实例 ${i} 没有开机`, '开机后再安装'); - if (busyJob) return errResp('busy', `魔改 AVD 实例 ${i} 正在做别的事`, '等它做完再试'); + if (inst.status !== 'online') return errResp('busy', `ModAVD 实例 ${i} 没有开机`, '开机后再安装'); + if (busyJob) return errResp('busy', `ModAVD 实例 ${i} 正在做别的事`, '等它做完再试'); const f = init.body instanceof FormData ? init.body.get('apk') : null; if (!f) return errResp('usage', '没有收到 APK 文件'); const j = newJob('install', i); @@ -1036,7 +1036,7 @@ async function handle(url, init) { if (act === 'playstore-install' || act === 'playstore-uninstall') { const install = act === 'playstore-install'; const reason = (code, message, advice, r) => fail(STATUS_OF[code], code, message, advice, { reason: r }); - if (busyJob || inst.status === 'creating') return errResp('busy', `魔改 AVD 实例 ${i} 正在做别的事`, '等它做完再试'); + if (busyJob || inst.status === 'creating') return errResp('busy', `ModAVD 实例 ${i} 正在做别的事`, '等它做完再试'); if (sdkJob()) return errResp('busy', `任务 ${sdkJob().kind === 'components' ? '补全组件' : '导入 Google Play 商店的文件'} 正在运行(任务 ${sdkJob().id}),等它做完再试`); if (install && !db.store.ready) { return reason('not_ready', `这个根目录还没有 Google Play 商店的五个文件,或者文件不全、已损坏(${ROOT}\\sdk\\playstore)`, @@ -1051,7 +1051,7 @@ async function handle(url, init) { } if (act === 'gles32-on' || act === 'gles32-off') { const on = act === 'gles32-on'; - if (busyJob || inst.status === 'creating') return errResp('busy', `魔改 AVD 实例 ${i} 正在做别的事`, '等它做完再试'); + if (busyJob || inst.status === 'creating') return errResp('busy', `ModAVD 实例 ${i} 正在做别的事`, '等它做完再试'); if (inst.bridge === 'off') { return fail(STATUS_OF.not_ready, 'not_ready', `实例 ${i} 没有 arm64 执行桥,不按可写系统镜像开机,改不了它报的 GLES 版本`, `先运行 modavd bridge ${i} install 装上执行桥,再开这个开关`, { reason: 'gles32_no_bridge' }); } @@ -1061,7 +1061,7 @@ async function handle(url, init) { } if (act === 'playstore-gsfid') { if (inst.status !== 'online') return errResp('busy', `实例 ${i} 没有开机`, '开机后才能取 GSF ID'); - if (busyJob) return errResp('busy', `魔改 AVD 实例 ${i} 正在做别的事`, '等它做完再试'); + if (busyJob) return errResp('busy', `ModAVD 实例 ${i} 正在做别的事`, '等它做完再试'); if (db.nogsf) { return fail(STATUS_OF.not_ready, 'not_ready', `实例 ${i} 还没有 GSF ID`, 'Google 服务框架联网登记后才有:开着网等一两分钟,再试一次', { reason: 'no_gsf_id' }); } @@ -1084,14 +1084,14 @@ async function handle(url, init) { } else if (act === 'disk-trim') { step(j, 'disk_trim', `实例 ${i} 正在触发磁盘回收`, 0); setTimeout(() => { - if (inst.status !== 'online') return failJob(j, 'unsupported', `魔改 AVD 实例 ${i} 没有在运行,请先开机再回收磁盘空间`, ''); + if (inst.status !== 'online') return failJob(j, 'unsupported', `ModAVD 实例 ${i} 没有在运行,请先开机再回收磁盘空间`, ''); d.alloc = Math.max(3 * GB, d.alloc - 1.5 * GB); finishJob(j, { index: i, discard_max_bytes: 2 * GB }); }, 1800); } else { step(j, 'disk_compact', `实例 ${i} 正在整理磁盘(重写数据盘文件,要几分钟)`, 0); setTimeout(() => { - if (inst.running) return failJob(j, 'busy', `魔改 AVD 实例 ${i} 未关闭,无法整理磁盘`, `先 modavd stop ${i},关机后再整理`); + if (inst.running) return failJob(j, 'busy', `ModAVD 实例 ${i} 未关闭,无法整理磁盘`, `先 modavd stop ${i},关机后再整理`); const before = d.onDisk; d.onDisk = Math.min(before, d.alloc + 0.1 * GB); finishJob(j, { index: i, overlay: `${inst.path}\\userdata-qemu.img.qcow2`, before_on_disk_bytes: Math.round(before), after_on_disk_bytes: Math.round(d.onDisk), freed_bytes: Math.round(before - d.onDisk) }); @@ -1100,7 +1100,7 @@ async function handle(url, init) { return json(j, 202); } if (act === 'input') { - if (inst.status !== 'online') return errResp('busy', `魔改 AVD 实例 ${i} 没有开机`); + if (inst.status !== 'online') return errResp('busy', `ModAVD 实例 ${i} 没有开机`); if (body.type === 'tap') { inst._.tap = { x: body.x, y: body.y, t: now() }; if (inst._.mode !== 'game') inst._.mode = 'game'; @@ -1111,7 +1111,7 @@ async function handle(url, init) { } if (act === 'screenshot') { window.__mockShots[i] = (window.__mockShots[i] || 0) + 1; - if (inst.status !== 'online') return errResp('busy', `魔改 AVD 实例 ${i} 没有开机`); + if (inst.status !== 'online') return errResp('busy', `ModAVD 实例 ${i} 没有开机`); if (db.shotfail) return errResp('internal', '截图失败:adb exec-out screencap 退出码 1', ''); const blob = await screenshot(inst); return new Response(blob, { status: 200, headers: { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' } }); diff --git a/internal/update/pkg.go b/internal/update/pkg.go index 08075b7..bf35fb2 100644 --- a/internal/update/pkg.go +++ b/internal/update/pkg.go @@ -570,7 +570,7 @@ func checkEmulator(emu string, target Version) error { return packageError("包里模拟器的构建号(%s)和要装的 %s(构建号 %d)不一致", build, target.Display(), target.Build) } if !fileContains(filepath.Join(emu, "qemu", p.QemuDir, p.QEMUHeadless), []byte("free-page-reporting")) { - return packageError("包里的模拟器不是魔改 AVD 的自编版") + return packageError("包里的模拟器不是 ModAVD 的自编版") } return nil } diff --git a/internal/update/updater.go b/internal/update/updater.go index b931076..3e72b5d 100644 --- a/internal/update/updater.go +++ b/internal/update/updater.go @@ -125,7 +125,7 @@ func (u *Updater) notUpdatable() string { return "这个根目录在 mas-avd.json 里设了 sdkRoot(开发用的本地 SDK),不更新" } if !isFile(filepath.Join(u.l.emulator(), root.CurrentPlatform().Emulator)) { - return `这个根目录里没有 sdk\emulator,请先放好魔改 AVD` + return `这个根目录里没有 sdk\emulator,请先放好 ModAVD` } return "" } From 80f864257c2bdd55be1171015887953e3ed15a90 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Mon, 9 Nov 2026 18:58:32 +0100 Subject: [PATCH 4/6] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E6=9B=B4=E6=96=B0=E3=80=8D=E8=83=BD=E7=94=A8=E4=BA=86?= =?UTF-8?q?=EF=BC=9A=E7=AE=A1=E7=90=86=E5=99=A8=E4=B8=8B=E8=BD=BD=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9=E3=80=81=E5=85=B3=E5=AE=9E=E4=BE=8B=EF=BC=8C=E4=BA=A4?= =?UTF-8?q?=E7=BB=99=E5=91=BD=E4=BB=A4=E8=A1=8C=E6=8D=A2=E4=B8=8A=E5=90=8E?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理器不能在自己运行时换掉自己,所以分三步: - 更新任务(kind update,根任务,独占)先下载、核对、暂存新版,实例照常开着; 已经是最新就到此为止。 - 正常关掉所有在运行的实例(不取消正在新建的,关不掉就失败)。 - 用 StartOrphan 启动根目录的 tools\modavd.exe update install --after-pid <管理器 pid> --reopen <管理器程序> --reopen-arg ...,管理器随后退出。 命令行等管理器退出、换上新版,把结果写进 .update\manager-result.json,再按 原来的参数重新打开管理器;换不上也照样打开,暂存的新版留着。新管理器启动时 读走这份结果,页面提示一次。tools\modavd.exe 不存在或版本和管理器不同时, 开任务前就拒绝。 --- README.en.md | 3 +- README.md | 3 +- cmd/modavd-gui/main.go | 23 +++ internal/cli/cli.go | 16 ++ internal/cli/commands.go | 57 +++++- internal/cli/run_test.go | 8 + internal/cli/update_handoff_test.go | 141 +++++++++++++ internal/ui/API.md | 16 +- internal/ui/backend.go | 2 + internal/ui/fake_test.go | 20 +- internal/ui/http.go | 1 + internal/ui/http_test.go | 6 +- internal/ui/jobs.go | 10 + internal/ui/ui.go | 37 +++- internal/ui/update.go | 20 +- internal/ui/update_apply.go | 232 ++++++++++++++++++++++ internal/ui/update_apply_test.go | 293 ++++++++++++++++++++++++++++ internal/ui/web/js/actions.js | 10 +- internal/ui/web/js/update.js | 86 ++++++-- internal/update/handoff.go | 97 +++++++++ internal/update/handoff_test.go | 71 +++++++ 21 files changed, 1119 insertions(+), 33 deletions(-) create mode 100644 internal/cli/update_handoff_test.go create mode 100644 internal/ui/update_apply.go create mode 100644 internal/ui/update_apply_test.go create mode 100644 internal/update/handoff.go create mode 100644 internal/update/handoff_test.go diff --git a/README.en.md b/README.en.md index 1e5fb46..79f4022 100644 --- a/README.en.md +++ b/README.en.md @@ -266,7 +266,8 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis - `prepare` downloads, unpacks, verifies file by file against the `modavd-manifest.json` in the package, and stages the result; instances can be running; - `install` runs `prepare` first if needed, then swaps in `sdk\emulator` and the other files in the package. All instances, manager windows and MCP clients must be closed first; if any program is using the files, it changes nothing (exit code 5), and the staged new version is kept so the next run can swap it in directly; - `recover` finishes or rolls back a swap that was left unfinished last time (it also runs first at the start of every step). -- The update source is chosen in the manager's "Settings" (`设置`): GitHub (the latest Release of `ModAVD/ModAVD`, the full package) or MirrorChyan (downloads need a CDK, and it can provide delta packages; on errors it downloads from GitHub instead and says so in the result's `notices`). The CDK is encrypted with Windows DPAPI and stored in `%LOCALAPPDATA%\ModAVD\update-settings.json`; only this Windows user can decrypt it, and it is not written to logs and does not appear on the command line or in API responses. For now the manager can only change the settings and check for updates; the "Update" (`更新`) button is not available. +- The update source is chosen in the manager's "Settings" (`设置`): GitHub (the latest Release of `ModAVD/ModAVD`, the full package) or MirrorChyan (downloads need a CDK, and it can provide delta packages; on errors it downloads from GitHub instead and says so in the result's `notices`). The CDK is encrypted with Windows DPAPI and stored in `%LOCALAPPDATA%\ModAVD\update-settings.json`; only this Windows user can decrypt it, and it is not written to logs and does not appear on the command line or in API responses. AUTO-MAS's own updater uses AUTO-MAS's settings. +- "Update" (`更新`) in the manager's "Settings": the manager downloads, verifies and stages the new version (instances keep running meanwhile), shuts down every running instance normally, then starts `tools\modavd.exe update install --after-pid --reopen [--reopen-arg ARG]...` and exits. The CLI waits for the manager to exit, swaps the new version in, writes the result to `.update\manager-result.json` and starts the manager again with its original arguments; the new manager shows the result when it opens. If the swap cannot be done, the manager is started again all the same, and the downloaded version is kept so it does not have to be downloaded again. The CLI's output goes to `logs\modavd-update.log`. `--after-pid`, `--reopen` and `--reopen-arg` only go with `install` and are meant for the manager. - Only files from the package are swapped: `avd\`, `mas-avd.json` (only `components.emulator` is changed and an `updateHistory` entry is recorded), `sdk\platform-tools`, `sdk\system-images`, `logs\` and so on are never touched; `sdk\emulator` is replaced as a whole. A root folder that sets `sdkRoot` in `mas-avd.json` is not updated. - `--automas-version` is the version of the calling AUTO-MAS: if the first line of the new version's announcement requires a higher AUTO-MAS, it does not install. - Exit codes: being unable to check (offline, rate-limited, a GitHub error) is 9 (`update_unavailable`), a downloaded package that fails verification is also 9 (`update_rejected`), and a program in use or an update already in progress is 5. diff --git a/README.md b/README.md index f1a4254..6eb1f09 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,8 @@ claude mcp add --transport stdio --scope user modavd '--' D:\ModAVD\tools\modavd - `prepare` 下载、解包、按包里的 `modavd-manifest.json` 逐文件核对、暂存好,实例开着也行; - `install` 需要时先 `prepare`,再换上 `sdk\emulator` 和包里的其他文件。要先关掉所有实例、管理器窗口和 MCP 客户端,有程序在用就什么都不动(退出码 5),暂存的新版留着,下次直接换; - `recover` 补完或退回上次没做完的换上(每一步开始前也会先做)。 -- 更新源在管理器的「设置」里选:GitHub(`ModAVD/ModAVD` 的最新 Release,整包)或 Mirror 酱(填了 CDK 才能下载,能拿到增量包;出错时改从 GitHub 下,并在结果的 `notices` 里写明)。CDK 用 Windows DPAPI 加密存在 `%LOCALAPPDATA%\ModAVD\update-settings.json`,只有这个 Windows 用户能解开,不写日志、不出现在命令行和接口返回里。管理器里暂时只能改设置和检查更新,「更新」按钮不可用。 +- 更新源在管理器的「设置」里选:GitHub(`ModAVD/ModAVD` 的最新 Release,整包)或 Mirror 酱(填了 CDK 才能下载,能拿到增量包;出错时改从 GitHub 下,并在结果的 `notices` 里写明)。CDK 用 Windows DPAPI 加密存在 `%LOCALAPPDATA%\ModAVD\update-settings.json`,只有这个 Windows 用户能解开,不写日志、不出现在命令行和接口返回里。AUTO-MAS 自己的更新器用 AUTO-MAS 的设置。 +- 管理器「设置」里点「更新」:管理器先下载、核对、暂存好(这时实例照常用),再正常关掉所有在运行的实例,然后启动 `tools\modavd.exe update install --after-pid <管理器 pid> --reopen <管理器程序> [--reopen-arg 参数]...` 并退出。命令行等管理器退出、换上新版,把结果写进 `.update\manager-result.json`,再按原来的参数重新打开管理器,新管理器打开时提示更新结果。换不上也照样重新打开,下载好的新版留着,下次不用重下。命令行的输出在 `logs\modavd-update.log`。`--after-pid`、`--reopen`、`--reopen-arg` 只和 `install` 一起用,是给管理器用的。 - 只换包里的文件:`avd\`、`mas-avd.json`(只改 `components.emulator` 并记一条 `updateHistory`)、`sdk\platform-tools`、`sdk\system-images`、`logs\` 等一律不碰;`sdk\emulator` 整个换掉。根目录在 `mas-avd.json` 里设了 `sdkRoot` 的不更新。 - `--automas-version` 是调用方 AUTO-MAS 的版本:新版的公告第一行要求更高的 AUTO-MAS 时不装。 - 退出码:查不了(断网、限流、GitHub 出错)是 9(`update_unavailable`),下载的包没通过核对也是 9(`update_rejected`),有程序在用、正在更新是 5。 diff --git a/cmd/modavd-gui/main.go b/cmd/modavd-gui/main.go index 487db61..04e01de 100644 --- a/cmd/modavd-gui/main.go +++ b/cmd/modavd-gui/main.go @@ -27,6 +27,7 @@ import ( "os/signal" "runtime" "runtime/debug" + "strconv" "strings" "syscall" "time" @@ -174,6 +175,7 @@ func run(args []string) (code int) { if *dev { opt.IdleExit = -1 } + opt.Reopen = reopenArgs(r, *port, *noWindow, *dev, log) if err := ui.Run(ctx, opt); err != nil { log.Errorf("%v", err) // let go of the root first: the message box stays up until it is clicked away, and a new launch meanwhile @@ -185,6 +187,27 @@ func run(args []string) (code int) { return 0 } +// reopenArgs is how the CLI starts this manager again after 设置 → 更新: this executable on the same root, with the +// options that change how it runs. Nil when the executable's path is unknown (更新 is then refused). +func reopenArgs(r *root.Root, port int, noWindow, dev bool, log *logx.Logger) *ui.Reopen { + exe, err := os.Executable() + if err != nil { + log.Warnf("找不到管理器自己的路径,设置里不能更新: %v", err) + return nil + } + args := []string{"--root", r.Dir} + if port != 0 { + args = append(args, "--port", strconv.Itoa(port)) + } + if noWindow { + args = append(args, "--no-window") + } + if dev { + args = append(args, "--dev") + } + return &ui.Reopen{Exe: exe, Args: args} +} + // reopen is the second launch on a root: the running manager gets a new window. func reopen(r *root.Root, noWindow bool, log *logx.Logger) int { st, err := readStateWait(r, 5*time.Second) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e775ae8..15bf424 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -33,6 +33,7 @@ // servers emu.ServersResult // version VersionResult {"version","protocol","go"} (no root needed) // update [status|check|prepare|install|recover] update.Status (the ModAVD package of the root; see update) +// update install --after-pid P --reopen EXE [--reopen-arg A]... the same, for the manager's 更新 (update/handoff.go) // mcp MCP server on stdin / stdout // help [command] HelpResult // @@ -93,6 +94,7 @@ import ( "io" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -104,6 +106,7 @@ import ( "modavd/internal/logx" "modavd/internal/mcp" "modavd/internal/root" + "modavd/internal/update" ) // manager is the part of *emu.Manager the commands use; tests substitute a fake. @@ -152,6 +155,10 @@ type deps struct { serveMCP func(ctx context.Context, in io.Reader, out io.Writer, opt mcp.Options) error fetchComponents func(ctx context.Context, r *root.Root, opt components.Options) (*components.Result, error) stdinTerminal func(io.Reader) bool + // the manager's update hand-off (update install --after-pid / --reopen) + waitExit func(pid int, timeout time.Duration) bool // true when pid is gone + updateInstall func(ctx context.Context, u *update.Updater) (*update.Status, error) + reopen func(exe string, args []string) error } func defaultDeps() deps { @@ -167,6 +174,15 @@ func defaultDeps() deps { serveMCP: mcp.Serve, fetchComponents: components.Fetch, stdinTerminal: readerIsTerminal, + waitExit: func(pid int, timeout time.Duration) bool { + p, ok := host.FindProcess(uint32(pid)) + return !ok || p.Wait(timeout) + }, + updateInstall: func(ctx context.Context, u *update.Updater) (*update.Status, error) { return u.Install(ctx) }, + reopen: func(exe string, args []string) error { + _, err := host.StartDetached(exe, args, host.StartOptions{Dir: filepath.Dir(exe)}) + return err + }, } } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 22ec6a6..2926ea0 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -80,6 +80,9 @@ var ( optCheckMB = flagSpec{"memory", kindString, "M", "按这个实例内存判断电脑内存够不够(默认 6G)"} 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 一起用"} + optReopenArg = flagSpec{"reopen-arg", kindStrings, "参数", "管理器用:启动 --reopen 的程序时带的参数,按顺序,可以给多次"} optBootNoBridge = flagSpec{"no-bridge", kindBool, "", "这次开机不自动装执行桥(实例有执行桥的开机标记、桥还没装时默认会装)"} optAcceptLicense = flagSpec{"accept-license", kindBool, "", @@ -348,16 +351,18 @@ func init() { run: runVersion, human: humanOf(formatVersion), }, { - name: "update", args: "[status|check|prepare|install|recover] [--automas-version V]", + name: "update", args: "[status|check|prepare|install|recover] [--automas-version V] [--after-pid PID --reopen 程序 [--reopen-arg 参数]...]", summary: "更新这个根目录的 ModAVD 包(模拟器、组件、脚本和管理器)", details: "按管理器「设置」里选的更新源(GitHub 或 Mirror 酱,Mirror 酱下载要填 CDK)。不自动联网,只做给出的那一步:" + "status(默认)只看本地;check 查最新版本;prepare 下载、按清单核对、暂存好,实例开着也行;" + "install 需要时先 prepare,再换上 sdk\\emulator 和包里的其他文件,要先关掉所有实例、管理器窗口和 MCP 客户端;" + "recover 补完或退回上次没做完的换上。和 AUTO-MAS 用同一个 .update 目录,能接着对方没做完的做。" + "--automas-version 是调用方 AUTO-MAS 的版本,新版要求更高的 AUTO-MAS 时不装。" + + "--after-pid、--reopen、--reopen-arg 是管理器「设置」里点「更新」时用的:管理器下载核对好、关掉实例后启动这条命令并退出," + + "命令等它退出再换上新版,然后重新打开管理器。" + "查不了是退出码 9(update_unavailable),下载没通过核对也是 9(update_rejected),有程序在用是 5。", posNames: []string{"status、check、prepare、install 或 recover"}, maxPos: 1, - flags: []flagSpec{optAutoMAS}, final: true, + flags: []flagSpec{optAutoMAS, optAfterPID, optReopen, optReopenArg}, final: true, run: runUpdate, human: humanOf(formatUpdateStatus), }, { @@ -1431,6 +1436,19 @@ func runUpdate(c *cmdCtx) (any, error) { return nil, errs.Newf(errs.Usage, "只能是 status、check、prepare、install 或 recover,收到「%s」", c.inv.pos[0]). WithAdvice(findCommand("update").usageAdvice()) } + afterPID, reopen, reopenArgs := c.inv.intOpt("after-pid"), c.inv.strOpt("reopen"), c.inv.strsOpt("reopen-arg") + handoff := afterPID != 0 || reopen != "" || len(reopenArgs) > 0 + switch { + case handoff && action != "install": + return nil, errs.New(errs.Usage, "--after-pid、--reopen、--reopen-arg 只能和 install 一起用"). + WithAdvice(findCommand("update").usageAdvice()) + case afterPID < 0: + return nil, errs.Newf(errs.Usage, "--after-pid 要给进程号,收到 %d", afterPID) + case len(reopenArgs) > 0 && reopen == "": + return nil, errs.New(errs.Usage, "--reopen-arg 要和 --reopen 一起用") + case reopen != "" && !filepath.IsAbs(reopen): + return nil, errs.Newf(errs.Usage, "--reopen 要给程序的完整路径,收到「%s」", reopen) + } r, err := c.deps.resolveRoot(c.inv.root) if err != nil { return nil, err @@ -1450,13 +1468,46 @@ func runUpdate(c *cmdCtx) (any, error) { case "prepare": return u.Prepare(c.ctx) case "install": - return u.Install(c.ctx) + if handoff { + return installHandoff(c, r, u, afterPID, reopen, reopenArgs) + } + return c.deps.updateInstall(c.ctx, u) case "recover": return u.Recover(c.ctx) } return u.Status(), nil } +// 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) { + var st *update.Status + var err error + if afterPID > 0 { + c.log.Infof("等管理器(pid %d)退出", afterPID) + if !c.deps.waitExit(afterPID, update.AfterPIDWait) { + st = u.Status() + err = errs.Newf(errs.Busy, "管理器(pid %d)%d 秒内没有退出,新版没有换上", afterPID, int(update.AfterPIDWait/time.Second)). + WithAdvice("关掉管理器窗口后再在「设置」里点「更新」;下载好的新版留着,不用重新下载") + } + } + if err == nil { + st, err = c.deps.updateInstall(c.ctx, u) + } + if reopen != "" { + if serr := update.SaveHandoff(r, update.NewHandoffResult(st, err, time.Now())); serr != nil { + c.log.Warnf("写不了给管理器的更新结果: %v", serr) + } + if rerr := c.deps.reopen(reopen, args); rerr != nil { + c.log.Warnf("重新打开管理器(%s)失败: %v", reopen, rerr) + } else { + c.log.Infof("已重新打开管理器") + } + } + return st, err +} + func formatUpdateStatus(w io.Writer, r *update.Status) { cur := "未知" if r.CurrentVersion != "" { diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 4152b81..3dbb46c 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -23,6 +23,7 @@ import ( "modavd/internal/logx" "modavd/internal/mcp" "modavd/internal/root" + "modavd/internal/update" "modavd/internal/win" ) @@ -264,6 +265,10 @@ type harness struct { fetch func(ctx context.Context, r *root.Root, opt components.Options) (*components.Result, error) stdin string terminal bool // stdin is a console (components fetch may ask for a source); false like a pipe + // update install --after-pid / --reopen (the manager's 更新) + waitExit func(pid int, timeout time.Duration) bool + updateInstall func(ctx context.Context, u *update.Updater) (*update.Status, error) + reopen func(exe string, args []string) error } type result struct { @@ -290,6 +295,9 @@ func (h *harness) run(t *testing.T, ctx context.Context, args ...string) result serveMCP: h.mcpCall, fetchComponents: h.fetch, stdinTerminal: func(io.Reader) bool { return h.terminal }, + waitExit: h.waitExit, + updateInstall: h.updateInstall, + reopen: h.reopen, } if d.resolveRoot == nil { d.resolveRoot = func(string) (*root.Root, error) { return nil, errs.New(errs.NotFound, "没有根目录") } diff --git a/internal/cli/update_handoff_test.go b/internal/cli/update_handoff_test.go new file mode 100644 index 0000000..f9f81c8 --- /dev/null +++ b/internal/cli/update_handoff_test.go @@ -0,0 +1,141 @@ +//go:build windows + +package cli + +import ( + "context" + "slices" + "strings" + "testing" + "time" + + "modavd/internal/errs" + "modavd/internal/root" + "modavd/internal/update" +) + +// 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() + r := &root.Root{Dir: t.TempDir()} + h := newHarness() + h.resolve = func(string) (*root.Root, error) { return r, nil } + var calls []string + h.waitExit = func(pid int, timeout time.Duration) bool { + calls = append(calls, "wait "+itoa(pid)+" "+timeout.String()) + return true + } + h.updateInstall = func(ctx context.Context, u *update.Updater) (*update.Status, error) { + calls = append(calls, "install") + return &update.Status{Updated: true, CurrentVersion: "v0.40.0", Message: "已更新到 0.40.0", Notices: []string{}}, nil + } + h.reopen = func(exe string, args []string) error { + calls = append(calls, "reopen "+exe+" "+strings.Join(args, " ")) + return nil + } + return h, r, &calls +} + +func TestUpdateHandoffOptionsOnlyWithInstall(t *testing.T) { + for _, args := range [][]string{ + {"update", "check", "--after-pid", "5"}, + {"update", "--reopen", `C:\x\ModAVD.exe`}, + {"update", "prepare", "--reopen-arg", "--no-window"}, + {"update", "install", "--reopen-arg", "--no-window"}, + {"update", "install", "--reopen", `ModAVD.exe`}, + {"update", "install", "--after-pid", "-1"}, + } { + h, _, calls := handoffHarness(t) + res := h.run(t, nil, append(args, "--json")...) + if res.code != 2 || len(*calls) != 0 { + t.Errorf("%v: exit %d, calls %v, stderr %q", args, res.code, *calls, res.stderr) + } + } +} + +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, + "--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`} + 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()) + 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 { + t.Error("the result was not removed when taken") + } +} + +// The manager does not exit: nothing is installed, the result says so and the manager is still started (a second +// launch only brings up the running one's window), so the user is never left without one. +func TestUpdateHandoffManagerStillRunning(t *testing.T) { + h, r, calls := handoffHarness(t) + h.waitExit = func(pid int, timeout time.Duration) bool { + *calls = append(*calls, "wait") + return false + } + res := h.run(t, nil, "update", "install", "--after-pid", "4242", "--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 `}) { + t.Fatalf("calls %v", *calls) + } + got := update.TakeHandoff(r, 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) + } +} + +func TestUpdateHandoffInstallFailsStillReopens(t *testing.T) { + h, r, calls := handoffHarness(t) + h.updateInstall = func(ctx context.Context, u *update.Updater) (*update.Status, error) { + *calls = append(*calls, "install") + 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") + if res.code != 5 { + t.Fatalf("exit %d", res.code) + } + if len(*calls) != 3 || (*calls)[2] != `reopen C:\ModAVD\ModAVD.exe ` { + t.Fatalf("calls %v", *calls) + } + got := update.TakeHandoff(r, 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) + } +} + +// Without --reopen nothing is left for a manager and nothing is started; plain install neither waits. +func TestUpdateInstallWithoutReopen(t *testing.T) { + h, r, calls := handoffHarness(t) + if res := h.run(t, nil, "update", "install", "--after-pid", "9", "--json"); res.code != 0 { + t.Fatalf("exit %d", res.code) + } + if !slices.Equal(*calls, []string{"wait 9 " + update.AfterPIDWait.String(), "install"}) { + t.Fatalf("calls %v", *calls) + } + *calls = nil + if res := h.run(t, nil, "update", "install", "--json"); res.code != 0 { + t.Fatalf("exit %d", res.code) + } + if !slices.Equal(*calls, []string{"install"}) { + t.Fatalf("calls %v", *calls) + } + if update.TakeHandoff(r, time.Now()) != nil { + t.Error("a result was left without --reopen") + } +} diff --git a/internal/ui/API.md b/internal/ui/API.md index 14c4c89..e449098 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -76,6 +76,7 @@ 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/apply` | `{}` | 202 `Job`(`kind` 是 `update`,根任务,`index` 为 `-1`):更新这个根目录,见下「更新」;结果是 `{handoff, version, stopped, message}` | `{i}` 是实例编号(0–9,5 除外)。 @@ -93,7 +94,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," "result":null,"error":null,"warnings":[]} ``` -- `kind`:`start` / `stop` / `install` / `create` / `components` / `collect-info` / `disk-info` / `disk-trim` / `disk-compact` / `playstore-install` / `playstore-uninstall` / `playstore-import` / `gles32-on` / `gles32-off`(0.3.0 起没有重启,也没有显示 / 隐藏窗口)。 +- `kind`:`start` / `stop` / `install` / `create` / `components` / `collect-info` / `disk-info` / `disk-trim` / `disk-compact` / `playstore-install` / `playstore-uninstall` / `playstore-import` / `gles32-on` / `gles32-off` / `update`(0.3.0 起没有重启,也没有显示 / 隐藏窗口)。 - `state`:`running` / `done` / `failed` / `canceled`。 - `progress.step`:开机用 emu 的 `launch` / `boot` / `tune` / `ime` / `first_boot` / `vulkan_fix` / `done`;关机(含等被取消的开机停下)是 `stop`,安装是 `install`,这两种做完是 `done`;磁盘任务进行中是 `disk_info` / `disk_trim` / `disk_compact`、做完是 `done`(进行中没有百分比,`total` 为 0,页面显示不定长进度条;做完 `done` = `total` = 1000);装执行桥的每一轮是 `bridge`;商店的装、卸进行中是 `playstore-install` / `playstore-uninstall`(没有百分比)、做完是 `done`;导入时核对文件是 `verifying`、解压镜像是 `extracting`(`done` / `total` 是字节);新建是 `create`、其中几次开机的上述各步(开机做完是 `setup`)、`create_stop`、`done`,几次开机合成一条不回退的进度。`total` 一律 1000。 - 安装任务的 `result.apk` 是用户上传的文件名,不是服务端的临时路径(临时文件任务结束就删了)。 @@ -117,7 +118,7 @@ event: log data: {"time":"…","level":"info|warn|error","msg":"…","job_id":"j12"} 不属于任务的消息 job_id 是 "" event: update -data: 检查、设置有变化时 +data: 检查、设置有变化时,以及更新任务下载完以后 event: proxy data: {"marks":[ProxyMark]} 按应用走代理的卡片标记,全量;连上时发一次(有过的话),之后有变化时 @@ -167,10 +168,15 @@ 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`)、`install_by_cli`(现在恒为 `true`:管理器里不能安装,由 AUTO-MAS 调 `modavd update install`)。 +`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`)。 -- 只有点「检查更新」才联网;没有自动检查。 -- 更新源和 CDK 是本机这个 Windows 用户的设置(`%LOCALAPPDATA%\ModAVD\update-settings.json`),命令行更新时按它来。 +- 只有点「检查更新」或「更新」才联网;没有自动检查。 +- 更新源和 CDK 是本机这个 Windows 用户的设置(`%LOCALAPPDATA%\ModAVD\update-settings.json`),管理器和命令行更新时都按它来;AUTO-MAS 自己的更新器用 AUTO-MAS 的设置。 +- 「更新」(`POST /api/update/apply`):管理器不能在自己运行时换掉自己,所以分三步。 + 1. 任务(`kind` 是 `update`,根任务)先下载、按清单核对、暂存好新版(同 `modavd update prepare`,实例照常开着);已经是最新就到此为止,结果 `handoff` 为 `false`。 + 2. 正常关掉所有在运行的实例(同 `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}`;约 1.5 秒后管理器退出。命令行等管理器退出,换上新版,把结果写进 `.update\manager-result.json`,再按原来的参数(`--root`,以及 `--port`、`--no-window`、`--dev`)重新打开管理器;新管理器启动时读走这份结果,放进 `handoff`。换不上(有程序占着等)也照样重新打开,暂存的新版留着,下次不用重下。 +- 开始前就拒绝、不开任务(422):管理器不知道怎么重新打开自己(`unsupported`);根目录没有 `tools\modavd.exe`,或者它的版本和管理器不一样(`not_ready`,旧版命令行不认这些选项)。有别的任务在跑时回 409 `busy`;更新任务在跑时,别的任务也回 409。 ## 生命周期 diff --git a/internal/ui/backend.go b/internal/ui/backend.go index 01e6020..e24e2e8 100644 --- a/internal/ui/backend.go +++ b/internal/ui/backend.go @@ -22,6 +22,8 @@ type backend interface { Delete(ctx context.Context, i int) (*emu.DeleteResult, error) Start(ctx context.Context, i int, opt emu.StartOptions) (*emu.StartResult, error) Stop(ctx context.Context, i int, opt emu.StopOptions) (*emu.StopResult, error) + // StopTargets is what `stop --all` stops: the running or actively creating instances (设置 → 更新 stops them). + StopTargets(ctx context.Context) ([]int, error) Check(ctx context.Context, opt emu.CheckOptions) (*emu.CheckResult, error) Shell(ctx context.Context, i int, command string, timeout time.Duration) (*emu.ShellResult, error) InstallAPK(ctx context.Context, i int, apkPath string) (*emu.InstallResult, error) diff --git a/internal/ui/fake_test.go b/internal/ui/fake_test.go index e801259..dbd6a41 100644 --- a/internal/ui/fake_test.go +++ b/internal/ui/fake_test.go @@ -65,6 +65,7 @@ type fake struct { probeErr error startGate chan struct{} // Start waits here (after its first progress) when set stopGate chan struct{} + stopErr error // Stop fails with it (after its gate) shot adb.Result shotDelay time.Duration @@ -232,7 +233,7 @@ func (b fakeBackend) Start(ctx context.Context, i int, opt emu.StartOptions) (*e func (b fakeBackend) Stop(ctx context.Context, i int, opt emu.StopOptions) (*emu.StopResult, error) { b.f.mu.Lock() b.f.stops = append(b.f.stops, opt) - gate := b.f.stopGate + gate, stopErr := b.f.stopGate, b.f.stopErr b.f.mu.Unlock() if gate != nil { select { @@ -241,11 +242,28 @@ func (b fakeBackend) Stop(ctx context.Context, i int, opt emu.StopOptions) (*emu return nil, canceledErr(ctx) } } + if stopErr != nil { + return nil, stopErr + } b.f.put(i, emu.StatusOffline) return &emu.StopResult{Index: i, Name: root.AvdName(i), Method: "graceful", Synced: true, Seconds: 1.5, Warnings: []string{}}, nil } +// StopTargets: every instance that is not offline, ascending. +func (b fakeBackend) StopTargets(ctx context.Context) ([]int, error) { + b.f.mu.Lock() + defer b.f.mu.Unlock() + out := []int{} + for i, in := range b.f.instances { + if in.Status != emu.StatusOffline { + out = append(out, i) + } + } + sort.Ints(out) + return out, nil +} + func (b fakeBackend) Check(ctx context.Context, opt emu.CheckOptions) (*emu.CheckResult, error) { b.f.checks.Add(1) return &emu.CheckResult{RootInfo: fakeRoot, Ready: true, Items: []emu.CheckItem{}, Components: []emu.ComponentStatus{}}, nil diff --git a/internal/ui/http.go b/internal/ui/http.go index 4cd074b..21d70ce 100644 --- a/internal/ui/http.go +++ b/internal/ui/http.go @@ -67,6 +67,7 @@ func (s *server) routes() { m.HandleFunc("GET /api/update", s.handleUpdateGet) m.HandleFunc("POST /api/update/check", s.handleUpdateCheck) m.HandleFunc("POST /api/update/settings", s.handleUpdateSettings) + m.HandleFunc("POST /api/update/apply", s.handleUpdateApply) s.mux = m } diff --git a/internal/ui/http_test.go b/internal/ui/http_test.go index eb28656..9784771 100644 --- a/internal/ui/http_test.go +++ b/internal/ui/http_test.go @@ -317,7 +317,8 @@ func TestUpdateSettings(t *testing.T) { h := newHarness(t, nil) var v map[string]any expect(t, h.do("GET", "/api/update", nil), http.StatusOK, &v) - if v["source"] != "github" || v["cdk_set"] != false || v["install_by_cli"] != true || v["checking"] != false { + if v["source"] != "github" || v["cdk_set"] != false || v["install_by_cli"] != true || v["can_install"] != false || + v["checking"] != false || v["handoff"] != nil { t.Fatalf("GET /api/update: %v", v) } resp := h.do("POST", "/api/update/settings", `{"source":"mirrorchyan","cdk":"cdk-xyz-1"}`) @@ -332,7 +333,8 @@ func TestUpdateSettings(t *testing.T) { } expectError(t, h.do("POST", "/api/update/settings", `{}`), 400, errs.Usage) expectError(t, h.do("POST", "/api/update/settings", `{"source":"ftp"}`), 400, errs.InvalidArgument) - expectError(t, h.do("POST", "/api/update/apply", `{}`), 404, errs.NotFound) + // a manager that cannot start itself again (no Options.Reopen) does not install + expectError(t, h.do("POST", "/api/update/apply", `{}`), 422, errs.Unsupported) } func readBody(t *testing.T, resp *http.Response) string { diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index e22a9fe..9830614 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -77,6 +77,8 @@ func kindLabel(kind string) string { return "开启报告 GLES 3.2" case kindGLES32Off: return "关闭报告 GLES 3.2" + case kindUpdate: + return "更新 ModAVD" } return kind } @@ -231,6 +233,14 @@ func (s *server) startJob(kind string, i int, fn jobFunc) (jobView, error) { if o.view.State != stateRunning { continue } + // 设置 → 更新 stops every instance and replaces the whole package: it runs alone + if kind == kindUpdate || o.view.Kind == kindUpdate { + s.jobs.mu.Unlock() + if kind == kindUpdate { + return jobView{}, errs.Newf(errs.Busy, "%s%s还没做完(任务 %s),等它做完再更新", jobTarget(o.view.Index), kindLabel(o.view.Kind), o.view.ID) + } + return jobView{}, errs.Newf(errs.Busy, "正在更新 ModAVD(任务 %s),等它做完再试", o.view.ID) + } if (sdkJob(kind) && o.view.Index != rootJobIndex && bootJob(o.view.Kind)) || (i != rootJobIndex && sdkJob(o.view.Kind) && bootJob(kind)) { s.jobs.mu.Unlock() diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 3236f1d..70d7bc7 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -31,6 +31,7 @@ import ( "modavd/internal/errs" "modavd/internal/logx" "modavd/internal/root" + "modavd/internal/update" ) // Options of Run. @@ -50,8 +51,14 @@ type Options struct { Ready func(baseURL, token string) // IdleExit: exit after this long without any event stream connected; 0 = 60 s, negative = never. IdleExit time.Duration + // 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 // Test hooks. + prepareUpdate func(ctx context.Context, lg *logx.Logger, progress func(update.Progress)) (*update.Status, error) + cliVersion func(exe string) (string, error) + spawn func(exe string, args []string, logPath string) error newBackend func(log *logx.Logger) backend newDiagBackend func(log *logx.Logger) diagBackend token string @@ -113,6 +120,14 @@ type server struct { check checkCache upd *updatePanel // update.go marks *proxyMarks // proxy.go + + // 设置 → 更新 (update_apply.go): how we come back, the steps, and the exit the hand-off asks for + reopen *Reopen + prepareUpdate func(ctx context.Context, lg *logx.Logger, progress func(update.Progress)) (*update.Status, error) + cliVersion func(exe string) (string, error) + spawn func(exe string, args []string, logPath string) error + exitReq chan struct{} + exitOnce sync.Once } func newServer(opt Options) (*server, error) { @@ -192,7 +207,24 @@ func newServer(opt Options) (*server, error) { if opt.proxyEvery > 0 { s.proxyEvery = opt.proxyEvery } - s.upd = &updatePanel{} + s.upd = &updatePanel{handoff: update.TakeHandoff(opt.Root, time.Now())} + if h := s.upd.handoff; h != nil { + lg.Infof("上次在设置里点的更新:%s", h.Message) + } + s.reopen, s.prepareUpdate, s.cliVersion, s.spawn = opt.Reopen, opt.prepareUpdate, opt.cliVersion, opt.spawn + 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) + } + } + if s.cliVersion == nil { + s.cliVersion = realCLIVersion + } + if s.spawn == nil { + s.spawn = realSpawn + } + s.exitReq = make(chan struct{}) s.mgr = newBackend(lg.Tee(s.eventLogSink(""))) s.ctx, s.cancel = context.WithCancel(context.Background()) s.jobCtx, s.jobCancel = context.WithCancel(context.Background()) @@ -295,6 +327,9 @@ func (s *server) waitForExit(ctx context.Context, w *Window) { s.jobs.waitIdle(jobDrainOnCancel) } return + case <-s.exitReq: + // 设置 → 更新 handed the swap to the CLI, which waits for us to exit (its job has ended) + return case <-edgeDone: edgeDone = nil if time.Since(w.Started) < edgeHandoffWindow { diff --git a/internal/ui/update.go b/internal/ui/update.go index fb6ffa9..a160f96 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -1,9 +1,9 @@ package ui // The update part of the manager's settings (API.md "更新"): the root's ModAVD package version and update state, the -// update source (GitHub / MirrorChyan) and the MirrorChyan CDK, and a check on request. Installing is not offered -// here yet: AUTO-MAS keeps its own updater for now and will drive `modavd update` later, which uses these settings. -// Nothing goes online unless the user clicks 检查更新. +// update source (GitHub / MirrorChyan) and the MirrorChyan CDK, a check on request, and 更新 (update_apply.go). The +// source and CDK are also `modavd update`'s; AUTO-MAS's own updater uses AUTO-MAS's settings. Nothing goes online +// unless the user clicks 检查更新 or 更新. import ( "context" @@ -21,9 +21,13 @@ const updateCheckTimeout = time.Minute type updateView struct { *update.Status Checking bool `json:"checking"` - CheckedAt string `json:"checked_at"` // RFC 3339, "" when not checked since the manager started - Error *errorBody `json:"error"` // the last check's failure - InstallByCLI bool `json:"install_by_cli"` + CheckedAt string `json:"checked_at"` // RFC 3339, "" when not checked since the manager started + Error *errorBody `json:"error"` // the last check's failure + 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. + Handoff *update.HandoffResult `json:"handoff"` } type updatePanel struct { @@ -32,6 +36,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 } func (p *updatePanel) busy() bool { @@ -59,7 +64,8 @@ func (s *server) updateView() updateView { } st.Message = c.Message } - v := updateView{Status: st, Checking: p.checking, Error: p.err, InstallByCLI: true} + v := updateView{Status: st, Checking: p.checking, Error: p.err, InstallByCLI: s.reopen == nil, CanInstall: s.reopen != nil, + Handoff: p.handoff} if !p.at.IsZero() { v.CheckedAt = p.at.Format(time.RFC3339) } diff --git a/internal/ui/update_apply.go b/internal/ui/update_apply.go new file mode 100644 index 0000000..b93affc --- /dev/null +++ b/internal/ui/update_apply.go @@ -0,0 +1,232 @@ +package ui + +// 设置 → 更新 (API.md "更新"): the manager updates its own root. It cannot replace itself while it runs, so the job +// (kind "update") prepares (downloads, verifies and stages the new version; instances may keep running meanwhile), +// stops the running instances, then hands the swap to the root's tools\modavd.exe +// +// modavd --root --json update install --after-pid --reopen --reopen-arg ... +// +// (update/handoff.go) and the manager exits. The CLI swaps once we are gone, leaves its result in .update and starts +// the manager again, which shows that result (updateView.Handoff). + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "modavd/internal/buildinfo" + "modavd/internal/emu" + "modavd/internal/errs" + "modavd/internal/host" + "modavd/internal/logx" + "modavd/internal/update" +) + +const ( + kindUpdate = "update" + // handoffExitDelay: how long after the hand-off job ended the manager exits, so the page sees the job end and + // closes its window first. + handoffExitDelay = 1500 * time.Millisecond + cliVersionTimeout = 15 * time.Second + // updateLogName: the CLI's output during the hand-off, under logs\ (appended). + updateLogName = "modavd-update.log" +) + +// Reopen is how the CLI starts this manager again after the swap: our executable and the arguments it was started +// with that matter (the root, --port, --no-window, --dev). Nil: the manager cannot update itself (tests, or when +// ModAVD.exe could not tell its own path). +type Reopen struct { + Exe string + Args []string +} + +// updateResult is the update job's result. +type updateResult struct { + Handoff bool `json:"handoff"` // the CLI took over: the manager exits now and is started again after the swap + Version string `json:"version"` // the version handed over, vX.Y.Z ("" when already up to date) + Stopped []int `json:"stopped"` // the instances stopped for the swap + Message string `json:"message"` +} + +func (s *server) updateCLI() string { return filepath.Join(s.root.Dir, "tools", "modavd.exe") } + +// applyReady is what the 更新 refuses at once, before a job: no way to come back, no CLI of our version to hand to. +func (s *server) applyReady() error { + cliAdvice := `关掉管理器后运行 tools\modavd.exe update install` + if s.reopen == nil { + return errs.New(errs.Unsupported, "这个管理器不能自己更新").WithAdvice(cliAdvice) + } + cli := s.updateCLI() + if fi, err := os.Stat(cli); err != nil || fi.IsDir() { + return errs.New(errs.NotReady, `根目录里没有 tools\modavd.exe,管理器没法交给它换上新版`). + WithAdvice("从 ModAVD 的发布页下载新版整包,关掉所有实例和管理器后整个覆盖解压") + } + v, err := s.cliVersion(cli) + if err != nil { + return errs.Wrap(errs.NotReady, err, `运行不了 tools\modavd.exe:`+err.Error()).WithAdvice(cliAdvice) + } + if v != buildinfo.Version { + return errs.Newf(errs.NotReady, `tools\modavd.exe 的版本(%s)和管理器(%s)不一样,不能从管理器更新`, v, buildinfo.Version). + WithAdvice(cliAdvice) + } + return nil +} + +func (s *server) handleUpdateApply(w http.ResponseWriter, r *http.Request) { + if !decode(w, r, &struct{}{}) { + return + } + if err := s.applyReady(); err != nil { + writeError(w, err) + return + } + s.respondJob(w)(s.startRootJob(kindUpdate, s.updateJob)) +} + +// updateJob: prepare, stop the instances, hand off; then the manager exits (handoffExit). +func (s *server) updateJob(ctx context.Context, j *job, b backend) (any, error) { + lg := logx.New(s.jobLogSink(j)) + st, err := s.prepareUpdate(ctx, lg, func(p update.Progress) { s.setDiagProgress(j, updateProgress(p)) }) + if st != nil && st.Checked { + s.upd.mu.Lock() + s.upd.checked, s.upd.at, s.upd.err = st, time.Now(), nil + s.upd.mu.Unlock() + } + s.hub.broadcast("update", s.updateView()) + if err != nil { + return nil, err + } + if st.PreparedVersion == "" { + msg := st.Message + if msg == "" { + msg = "已经是最新版本" + } + return &updateResult{Stopped: []int{}, Message: msg}, nil + } + shown := strings.TrimPrefix(st.PreparedVersion, "v") + stopped, err := s.stopForUpdate(ctx, j, b, shown) + if err != nil { + return nil, err + } + s.setProgress(j, jobProgress{Step: "handoff", Message: "正在交给命令行换上 " + shown}) + if err := s.handOff(lg); err != nil { + return nil, errs.Wrap(errs.LaunchFailed, err, `启动不了 tools\modavd.exe 来换上新版:`+errs.As(err).Message). + WithAdvice(shown + ` 已下载并核对好;关掉管理器后运行 tools\modavd.exe update install,不用重新下载`) + } + go s.handoffExit(j) + return &updateResult{Handoff: true, Version: st.PreparedVersion, Stopped: stopped, + Message: "管理器会关闭,换上 " + shown + " 后自动重新打开"}, nil +} + +// updateProgress is an update step as the job's progress line (a download's line has the percentage and sizes). +func updateProgress(p update.Progress) DiagProgress { + msg := p.Message + if p.Stage == "downloading" && p.Total > 0 { + msg = fmt.Sprintf("%s:%.0f%%(%.1f / %.1f MB)", p.Message, p.Percent, float64(p.Done)/(1<<20), float64(p.Total)/(1<<20)) + } + return DiagProgress{Stage: p.Stage, Message: msg, Done: p.Done, Total: p.Total} +} + +// stopForUpdate stops every running instance of the root at once, the way `modavd stop --all` does, but never one +// still being created (that stop fails, and so does the update: the user finishes or cancels the creation first). +func (s *server) stopForUpdate(ctx context.Context, j *job, b backend, version string) ([]int, error) { + targets, err := b.StopTargets(ctx) + if err != nil { + return nil, err + } + if len(targets) == 0 { + return []int{}, nil + } + names := make([]string, len(targets)) + for k, i := range targets { + names[k] = strconv.Itoa(i) + } + s.setProgress(j, jobProgress{Step: "stopping", Message: "正在正常关掉实例 " + strings.Join(names, "、")}) + fails := make([]error, len(targets)) + var wg sync.WaitGroup + for k, i := range targets { + wg.Add(1) + go func() { + defer wg.Done() + _, fails[k] = b.Stop(ctx, i, emu.StopOptions{}) + }() + } + wg.Wait() + if err := ctx.Err(); err != nil { + return targets, errs.Wrap(errs.Canceled, err, "已取消") + } + var failed []string + for k, e := range fails { + if e != nil { + failed = append(failed, fmt.Sprintf("实例 %d:%s", targets[k], errs.As(e).Message)) + } + } + if len(failed) > 0 { + return targets, errs.Newf(errs.Busy, "有实例没关掉,新版没有换上(%s)", strings.Join(failed, ";")). + WithAdvice(version + " 已下载并核对好;处理好后再点「更新」,不用重新下载") + } + return targets, nil +} + +// 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. +func (s *server) handOff(lg *logx.Logger) error { + args := []string{"--root", s.root.Dir, "--json", "update", "install", + "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", s.reopen.Exe} + for _, a := range s.reopen.Args { + args = append(args, "--reopen-arg", a) + } + logPath := filepath.Join(s.root.LogsDir(), updateLogName) + lg.Infof("交给命令行换上新版:%s %s(输出在 %s)", s.updateCLI(), strings.Join(args, " "), logPath) + return s.spawn(s.updateCLI(), args, logPath) +} + +// handoffExit ends the manager once the job's end has been sent (the CLI waits for us to exit). +func (s *server) handoffExit(j *job) { + <-j.done + select { + case <-time.After(handoffExitDelay): + case <-s.ctx.Done(): + } + s.log.Infof("新版交给命令行去换,管理器退出") + s.requestExit() +} + +func (s *server) requestExit() { s.exitOnce.Do(func() { close(s.exitReq) }) } + +// ---- the real hand-off steps (Options can replace them in tests) + +// realCLIVersion is ` --json version`'s version (no window: it is a console program). +func realCLIVersion(exe string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), cliVersionTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, exe, "--json", "version") + host.HideConsoleCmd(cmd) + out, err := cmd.Output() + if err != nil { + return "", err + } + var env struct { + OK bool `json:"ok"` + Data struct { + Version string `json:"version"` + } `json:"data"` + } + if json.Unmarshal(out, &env) != nil || !env.OK || env.Data.Version == "" { + return "", fmt.Errorf("看不懂它的输出:%q", strings.TrimSpace(string(out))) + } + return env.Data.Version, nil +} + +func realSpawn(exe string, args []string, logPath string) error { + _, err := host.StartOrphan(exe, args, host.StartOptions{Dir: filepath.Dir(exe), OutputPath: logPath}) + return err +} diff --git a/internal/ui/update_apply_test.go b/internal/ui/update_apply_test.go new file mode 100644 index 0000000..6430e8d --- /dev/null +++ b/internal/ui/update_apply_test.go @@ -0,0 +1,293 @@ +//go:build windows + +package ui + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + "modavd/internal/buildinfo" + "modavd/internal/emu" + "modavd/internal/errs" + "modavd/internal/logx" + "modavd/internal/testutil" + "modavd/internal/update" +) + +// applyFakes are the update job's three outside steps. +type applyFakes struct { + mu sync.Mutex + prepared *update.Status + prepErr error + prepGate chan struct{} // prepare waits here when set + prepares int + version string // what tools\modavd.exe says it is + verErr error + spawnErr error + spawns [][]string // exe, then the args, then the log path +} + +func (a *applyFakes) spawned() [][]string { + a.mu.Lock() + defer a.mu.Unlock() + return slices.Clone(a.spawns) +} + +const reopenExe = `C:\ModAVD\ModAVD.exe` + +// applyHarness: a manager that can update itself (Reopen set, a tools\modavd.exe of our version), with fakes for the +// download, the CLI and the spawn. +func applyHarness(t *testing.T, a *applyFakes) *harness { + t.Helper() + t.Setenv("LOCALAPPDATA", testutil.TempDir(t)) + if a.version == "" { + a.version = buildinfo.Version + } + h := newHarness(t, func(o *Options) { + o.Reopen = &Reopen{Exe: reopenExe, Args: []string{"--root", o.Root.Dir, "--no-window"}} + o.prepareUpdate = func(ctx context.Context, lg *logx.Logger, progress func(update.Progress)) (*update.Status, error) { + a.mu.Lock() + a.prepares++ + gate := a.prepGate + a.mu.Unlock() + progress(update.Progress{Stage: "downloading", Message: "从 GitHub 下载 ModAVD-0.40.0-win-x64.zip", Percent: 50, + Done: 100 << 20, Total: 200 << 20}) + if gate != nil { + select { + case <-gate: + case <-ctx.Done(): + return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + } + } + return a.prepared, a.prepErr + } + o.cliVersion = func(exe string) (string, error) { return a.version, a.verErr } + o.spawn = func(exe string, args []string, logPath string) error { + a.mu.Lock() + defer a.mu.Unlock() + a.spawns = append(a.spawns, append(append([]string{exe}, args...), logPath)) + return a.spawnErr + } + if err := os.MkdirAll(filepath.Join(o.Root.Dir, "tools"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(o.Root.Dir, "tools", "modavd.exe"), []byte("cli"), 0o644); err != nil { + t.Fatal(err) + } + }) + return h +} + +func preparedStatus() *update.Status { + return &update.Status{Updatable: true, CurrentVersion: "v0.39.0", Checked: true, LatestVersion: "v0.40.0", + LatestSource: "github", UpdateAvailable: true, PreparedVersion: "v0.40.0", Phase: "staged", + Message: "0.40.0 已下载并核对好,可以安装", Notices: []string{}} +} + +func (h *harness) apply() jobView { + h.t.Helper() + var v jobView + expect(h.t, h.do("POST", "/api/update/apply", `{}`), http.StatusAccepted, &v) + if v.Kind != kindUpdate || v.Index != rootJobIndex { + h.t.Fatalf("job %+v", v) + } + return v +} + +func (h *harness) exited(within time.Duration) bool { + select { + case <-h.s.exitReq: + return true + case <-time.After(within): + return false + } +} + +// The whole hand-off: download, the running instance stopped (never a creation cancelled), the CLI started with our +// pid and how to start us again, then the manager exits. +func TestUpdateApplyHandsOff(t *testing.T) { + a := &applyFakes{prepared: preparedStatus()} + h := applyHarness(t, a) + h.f.put(3, emu.StatusOnline) + var v map[string]any + expect(t, h.do("GET", "/api/update", nil), http.StatusOK, &v) + if v["can_install"] != true || v["install_by_cli"] != false { + t.Fatalf("GET /api/update: %v", v) + } + j := h.apply() + done := h.waitJob(j.ID, func(v jobView) bool { return v.State != stateRunning }) + if done.State != stateDone { + t.Fatalf("job %+v", done) + } + r, _ := done.Result.(map[string]any) + if r["handoff"] != true || r["version"] != "v0.40.0" || !strings.Contains(r["message"].(string), "0.40.0") { + t.Fatalf("result %v", r) + } + if st, _ := r["stopped"].([]any); len(st) != 1 || st[0] != float64(3) { + t.Errorf("stopped %v", r["stopped"]) + } + h.f.mu.Lock() + stops := slices.Clone(h.f.stops) + h.f.mu.Unlock() + if len(stops) != 1 || stops[0].CancelCreate || stops[0].Force { + t.Errorf("stops %+v", stops) + } + sp := a.spawned() + if len(sp) != 1 { + t.Fatalf("spawns %v", sp) + } + 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, + "--reopen-arg", "--root", "--reopen-arg", root, "--reopen-arg", "--no-window", + filepath.Join(root, "logs", updateLogName)} + if !slices.Equal(sp[0], want) { + t.Fatalf("spawn\n got %q\n want %q", sp[0], want) + } + if !h.exited(5 * time.Second) { + t.Fatal("the manager did not exit after the hand-off") + } + // the check the download did is the panel's now + expect(t, h.do("GET", "/api/update", nil), http.StatusOK, &v) + if v["latest_version"] != "v0.40.0" || v["checked_at"] == "" { + t.Errorf("after: %v", v) + } +} + +func TestUpdateApplyUpToDate(t *testing.T) { + a := &applyFakes{prepared: &update.Status{Updatable: true, CurrentVersion: "v0.40.0", Checked: true, + LatestVersion: "v0.40.0", Message: "已经是最新版本(0.40.0)", Notices: []string{}}} + h := applyHarness(t, a) + h.f.put(3, emu.StatusOnline) + j := h.apply() + done := h.waitJob(j.ID, func(v jobView) bool { return v.State != stateRunning }) + r, _ := done.Result.(map[string]any) + if done.State != stateDone || r["handoff"] != false || r["message"] != "已经是最新版本(0.40.0)" { + t.Fatalf("job %+v", done) + } + h.f.mu.Lock() + n := len(h.f.stops) + h.f.mu.Unlock() + if n != 0 || len(a.spawned()) != 0 || h.exited(2*handoffExitDelay) { + t.Fatalf("stops %d, spawns %v, or exited", n, a.spawned()) + } +} + +// A step that fails ends the job there: nothing after it runs, the manager stays. +func TestUpdateApplyFailures(t *testing.T) { + for _, c := range []struct { + name string + set func(a *applyFakes, f *fake) + code string + stops int + spawns int + contains string + }{ + {"download", func(a *applyFakes, f *fake) { + a.prepared, a.prepErr = &update.Status{Notices: []string{}}, errs.New(errs.UpdateUnavailable, "查不了最新版本") + }, errs.UpdateUnavailable, 0, 0, "查不了"}, + {"stop", func(a *applyFakes, f *fake) { + f.stopErr = errs.New(errs.Busy, "实例正在新建") + }, errs.Busy, 1, 0, "实例 3:实例正在新建"}, + {"spawn", func(a *applyFakes, f *fake) { + a.spawnErr = errors.New("拒绝访问") + }, errs.LaunchFailed, 1, 1, "拒绝访问"}, + } { + t.Run(c.name, func(t *testing.T) { + a := &applyFakes{prepared: preparedStatus()} + h := applyHarness(t, a) + h.f.put(3, emu.StatusOnline) + c.set(a, h.f) + j := h.apply() + done := h.waitJob(j.ID, func(v jobView) bool { return v.State != stateRunning }) + if done.State != stateFailed || done.Error == nil || done.Error.Code != c.code || !strings.Contains(done.Error.Message, c.contains) { + t.Fatalf("job %+v %+v", done, done.Error) + } + if c.code != errs.UpdateUnavailable && !strings.Contains(done.Error.Advice, "不用重新下载") { + t.Errorf("advice %q", done.Error.Advice) + } + h.f.mu.Lock() + n := len(h.f.stops) + h.f.mu.Unlock() + if n != c.stops || len(a.spawned()) != c.spawns || h.exited(2*handoffExitDelay) { + t.Fatalf("stops %d, spawns %d, or exited", n, len(a.spawned())) + } + }) + } +} + +// Refused before any job: no tools\modavd.exe, or one of another version (it may not know --after-pid / --reopen). +func TestUpdateApplyRefusedUpFront(t *testing.T) { + a := &applyFakes{prepared: preparedStatus(), version: "0.0.1"} + h := applyHarness(t, a) + e := expectError(t, h.do("POST", "/api/update/apply", `{}`), 422, errs.NotReady) + if !strings.Contains(e.Message, "0.0.1") || !strings.Contains(e.Advice, "update install") { + t.Errorf("%+v", e) + } + a.mu.Lock() + a.version, a.verErr = "", errors.New("exit status 1") + a.mu.Unlock() + expectError(t, h.do("POST", "/api/update/apply", `{}`), 422, errs.NotReady) + if err := os.Remove(filepath.Join(h.s.root.Dir, "tools", "modavd.exe")); err != nil { + t.Fatal(err) + } + expectError(t, h.do("POST", "/api/update/apply", `{}`), 422, errs.NotReady) + if a.prepares != 0 || len(h.s.listJobs()) != 0 { + t.Fatalf("prepares %d, jobs %v", a.prepares, h.s.listJobs()) + } +} + +// The update runs alone: not while another job runs, and no other job while it runs. +func TestUpdateApplyRunsAlone(t *testing.T) { + a := &applyFakes{prepared: preparedStatus(), prepGate: make(chan struct{})} + h := applyHarness(t, a) + j := h.apply() + expectError(t, h.do("POST", "/api/instances/3/start", `{}`), 409, errs.Busy) + expectError(t, h.do("POST", "/api/update/apply", `{}`), 409, errs.Busy) + expect(t, h.do("POST", "/api/jobs/"+j.ID+"/cancel", nil), http.StatusOK, nil) + h.waitJob(j.ID, func(v jobView) bool { return v.State == stateCanceled }) + if len(a.spawned()) != 0 || h.exited(2*handoffExitDelay) { + t.Fatal("a cancelled update handed off or exited") + } + + h.f.mu.Lock() + h.f.startGate = make(chan struct{}) + h.f.mu.Unlock() + var sj jobView + expect(t, h.do("POST", "/api/instances/3/start", `{}`), http.StatusAccepted, &sj) + e := expectError(t, h.do("POST", "/api/update/apply", `{}`), 409, errs.Busy) + if !strings.Contains(e.Message, "开机") { + t.Errorf("%+v", e) + } + close(h.f.startGate) + h.waitJob(sj.ID, func(v jobView) bool { return v.State != stateRunning }) +} + +// What the CLI left after the swap is shown by the manager it started, once. +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()) + if err := update.SaveHandoff(o.Root, res); err != nil { + t.Fatal(err) + } + }) + var v map[string]any + expect(t, h.do("GET", "/api/update", nil), http.StatusOK, &v) + hv, _ := v["handoff"].(map[string]any) + 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 { + t.Error("the file was left for the next start") + } +} diff --git a/internal/ui/web/js/actions.js b/internal/ui/web/js/actions.js index 2e57649..c4770de 100644 --- a/internal/ui/web/js/actions.js +++ b/internal/ui/web/js/actions.js @@ -8,7 +8,8 @@ import { openActivity, runCheck } from './panels.js'; const VERB = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', 'disk-info': '查看磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘', components: '补全组件', 'collect-info': '收集问题信息', 'playstore-install': '补全 Google Play 商店', 'playstore-uninstall': '移除 Google Play 商店', - 'playstore-import': '导入 Google Play 商店的文件', 'gles32-on': '开启报告 GLES 3.2', 'gles32-off': '关闭报告 GLES 3.2' }; + 'playstore-import': '导入 Google Play 商店的文件', 'gles32-on': '开启报告 GLES 3.2', 'gles32-off': '关闭报告 GLES 3.2', + update: '更新 ModAVD' }; // what the store needs once it is in an instance: registering its GSF ID (playstore.js openGSFID, the page is // playstore.js UNCERTIFIED; not imported from there, as playstore.js imports this module) const GSF = '登录时提示设备未经 Play 保护机制认证的,在实例的「更多操作」里点「复制 GSF ID」,到 https://www.google.com/android/uncertified/ 登记。'; @@ -83,6 +84,13 @@ function notify(job) { text: gles32Text(job.kind, r), action: done ? null : activity, timeout: (r.notes || []).length ? 14000 : 7000 }); } else if (job.kind === 'playstore-import') { toast({ title: 'Google Play 商店的文件已就绪', text: `${playStoreText(job.kind, r)}要用的实例在「更多操作」里补全商店。`, timeout: 9000 }); + } else if (job.kind === 'update') { + if (!r.handoff) toast({ tone: 'info', title: r.message || '已经是最新版本' }); + else { + // the manager exits now and tools\modavd.exe starts it again after the swap, in a new window + toast({ title: '正在换上新版', text: `${r.message || ''}。这个窗口会自己关掉。`, timeout: 15000 }); + setTimeout(() => window.close(), 1200); + } } } diff --git a/internal/ui/web/js/update.js b/internal/ui/web/js/update.js index 9c83936..61554c2 100644 --- a/internal/ui/web/js/update.js +++ b/internal/ui/web/js/update.js @@ -1,17 +1,22 @@ // 设置 → ModAVD 更新 (API.md "更新"): version and update state of this root, the update source and the Mirror 酱 CDK, -// a check on request. Installing is not offered here yet: AUTO-MAS still updates with its own updater, source and -// CDK, and the two updaters share no lock; the button is shown and disabled. These settings are used by the check -// and by `modavd update`. +// a check on request, and 更新: the server's update job downloads and verifies, stops the instances and hands the swap +// to tools\modavd.exe; the manager then closes and is started again by it (update_apply.go), and that manager shows +// what happened (v.handoff). These settings are also `modavd update`'s; AUTO-MAS's own updater uses its own. import { h, icon, btn, attr, setText, showError } from './dom.js'; import { S, update, subscribe } from './store.js'; import { api } from './api.js'; import { openDialog, toast, toastError } from './ui.js'; -import { openDiagnostics } from './diagnostics.js'; +import { openDiagnostics, activeJob, progressText } from './diagnostics.js'; +import { onJob, notifyJob } from './actions.js'; const clock = (t) => new Date(t).toLocaleString('zh-CN', { hour12: false }); const ver = (v) => (v ? v.replace(/^v/, '') : ''); const PHASE = { idle: '', preparing: '正在准备新版', staged: '新版已下载并核对好,待安装', swapping: '正在换上新版' }; +const NOTE = '点「更新」会先下载并核对新版(这时实例照常用),再正常关掉所有实例,管理器关闭、换上新版,换好后自动重新打开。' + + '用 AUTO-MAS 的也可以在 AUTO-MAS 里更新,它用 AUTO-MAS 设置里的更新源和 CDK;这里的更新源和 CDK 用于这里和命令行 modavd update。'; +const NOTE_CLI = '这个管理器不能自己更新:关掉所有实例和管理器后运行 tools\\modavd.exe update install。' + + '用 AUTO-MAS 的也可以在 AUTO-MAS 里更新,它用 AUTO-MAS 设置里的更新源和 CDK。'; export function loadUpdate() { return api.get('/api/update').then(onUpdate).catch(() => {}); @@ -20,9 +25,59 @@ export function loadUpdate() { // GET /api/update, the "update" event and every answer of the settings API. export function onUpdate(v) { S.update = v; + showHandoff(v.handoff); 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 }); +} + +// 更新 may be clicked: this manager can come back after the swap, the root can be updated, a check found a newer +// version (or one is already downloaded), and no update is running. +const installable = (v) => !!(v && v.can_install && v.updatable && (v.update_available || v.prepared_version) + && !v.checking && !activeJob('update')); + +function installTitle(v) { + if (!v) return ''; + if (activeJob('update')) return '正在更新'; + if (!v.can_install) return '这个管理器不能自己更新'; + if (!v.updatable) return v.not_updatable_reason; + if (!v.update_available && !v.prepared_version) return v.checked ? '已经是最新版本' : '先点「检查更新」'; + return ''; +} + +// The confirmation, then the update job (its end: actions.js notify, which closes this window on a hand-off). +function confirmInstall(v) { + const target = ver(v.prepared_version || v.latest_version); + const running = ((S.list && S.list.instances) || []).filter((x) => x.running).length; + const stops = running ? `正常关掉正在运行的 ${running} 台实例(安卓正常关机)` : '(现在没有实例在运行)'; + const go = btn('btn btn-primary', [icon('download'), '更新'], async () => { + dlg.close(); + try { + const j = await api.post('/api/update/apply', {}); + const seen = S.jobs.get(j.id); + if (seen && seen.state !== 'running') notifyJob(seen); + else { + S.watch.add(j.id); + onJob(j); + } + } catch (e) { toastError(e); } + }); + const dlg = openDialog({ + title: `更新到 ${target}`, sub: 'ModAVD 整包:模拟器、组件、脚本和管理器', light: true, + body: h('div', { class: 'dlg-body' }, + h('p', { text: `先下载并核对新版,这时实例照常用;然后${stops},管理器关闭、换上新版,换好后自动重新打开管理器。` }), + h('p', { class: 'f-hint', text: '实例的数据(avd 文件夹)和设置都不动。正在新建的实例要先等它建完。' })), + actions: { ok: go }, + }); +} + // The chip: 设置, with a dot when a check found a newer ModAVD. export function renderUpdateChip(chip, badge) { const v = S.update; @@ -88,12 +143,11 @@ export function openUpdate() { h('div', { class: 'f-ctl' }, h('div', { class: 'upd-cdk' }, cdk, save, clear), cdkState), h('label', { class: 'f-label', for: 'upd-comp', text: '组件下载源' }), h('div', { class: 'f-ctl' }, componentSource, componentURLsRow, h('p', { class: 'f-hint', text: '「补全组件」和 modavd components fetch 都从这里选的源下载;选了就保存。' }))); - const note = h('p', { class: 'f-hint', text: '这里暂时只能检查新版本。用 AUTO-MAS 的,在 AUTO-MAS 里更新(它用 AUTO-MAS 设置里的更新源和 CDK);不用 AUTO-MAS 的,关掉所有实例和管理器,把新版整包覆盖解压到这个文件夹,avd 文件夹不会被动到。这里的更新源和 CDK 只用于「检查更新」和命令行 modavd update。' }); + const note = h('p', { class: 'f-hint' }); const diagBtn = btn('btn btn-secondary', [icon('shield'), '组件与问题反馈'], () => { ctl.close(); openDiagnostics(); }); const when = h('span', { class: 'foot-note' }); const checkBtn = btn('btn btn-secondary', null, () => run(api.post('/api/update/check'))); - const installBtn = btn('btn btn-primary', [icon('download'), '更新'], null, - { disabled: true, 'aria-disabled': 'true', title: '这里暂时不能直接更新' }); + const installBtn = btn('btn btn-primary', null, () => installable(S.update) && confirmInstall(S.update)); for (const r of radios) r.addEventListener('change', () => r.checked && post({ source: r.value }, r.value === 'github' ? '改用 GitHub 更新' : '改用 Mirror 酱更新')); ctl = openDialog({ @@ -122,10 +176,14 @@ export function openUpdate() { const phase = PHASE[v.phase] || ''; if (phase) rows.push(['状态', v.prepared_version ? `${phase}:${ver(v.prepared_version)}` : phase]); const lr = v.last_result; - if (lr && lr.action) rows.push(['上次', lr.ok ? `已更新到 ${ver(lr.version)}` : `没成功:${lr.error || '原因未知'}`]); + if (v.handoff && !v.handoff.ok) rows.push(['上次', `没换上:${v.handoff.message}`]); + else if (lr && lr.action) rows.push(['上次', lr.ok ? `已更新到 ${ver(lr.version)}` : `没成功:${lr.error || '原因未知'}`]); kv.replaceChildren(...rows.flatMap(([k, t]) => [h('dt', { text: k }), h('dd', null, h('span', { class: 'v', text: t }))])); - setText(msg, v.message || ''); - msg.hidden = !v.message; + const job = activeJob('update'); + const line = job ? progressText(job) : (v.message || ''); + setText(msg, line); + msg.hidden = !line; + setText(note, v.can_install ? NOTE : NOTE_CLI); if (v.error && !v.checking) { showError(box, v.error); box.hidden = false; } for (const r of radios) r.checked = r.value === (v.source || 'github'); setText(cdkState, v.cdk_set ? '已填(加密保存在本机,只有这个 Windows 用户能读)' : v.source === 'mirrorchyan' ? '没填:没有 CDK 时只能从 GitHub 下载' : '选 Mirror 酱时才用到'); @@ -134,8 +192,14 @@ export function openUpdate() { componentURLsRow.hidden = componentSource.value !== 'custom'; setText(when, v.checked_at ? `检查于 ${clock(v.checked_at)}` : ''); checkBtn.replaceChildren(v.checking ? h('span', { class: 'spin' }) : icon('refresh'), v.checking ? '正在检查…' : '检查更新'); - checkBtn.disabled = !!v.checking; + checkBtn.disabled = !!v.checking || !!job; attr(checkBtn, 'aria-busy', v.checking || null); + installBtn.replaceChildren(job ? h('span', { class: 'spin' }) : icon('download'), job ? '正在更新…' : '更新'); + const can = installable(v); + installBtn.disabled = !can; + attr(installBtn, 'aria-disabled', can ? null : 'true'); + attr(installBtn, 'aria-busy', job ? 'true' : null); + attr(installBtn, 'title', installTitle(v) || null); }; const un = subscribe(render); ctl.onClose.push(() => { un(); ctl = null; }); diff --git a/internal/update/handoff.go b/internal/update/handoff.go new file mode 100644 index 0000000..86d1538 --- /dev/null +++ b/internal/update/handoff.go @@ -0,0 +1,97 @@ +package update + +// The manager's update (ModAVD.exe, 设置 → 更新). The manager cannot replace itself while it runs, so it hands the swap +// to the CLI: it downloads and verifies (Prepare), stops the instances, then starts +// +// 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. + +import ( + "encoding/json" + "os" + "path/filepath" + "time" + + "modavd/internal/errs" + "modavd/internal/root" +) + +const ( + handoffName = "manager-result.json" + // 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 +) + +// HandoffResult is what the CLI tells the manager it starts again. +type HandoffResult struct { + At string `json:"at"` // RFC 3339 + OK bool `json:"ok"` + Updated bool `json:"updated"` // a new version was swapped in + Version string `json:"version"` // the root's version now, vX.Y.Z ("" when unknown) + Message string `json:"message"` + Error *HandoffError `json:"error"` // nil when OK +} + +// HandoffError is the failure, as the CLI's --json error has it. +type HandoffError struct { + Code string `json:"code"` + Message string `json:"message"` + Advice string `json:"advice"` +} + +func handoffPath(r *root.Root) string { return filepath.Join(layout{root: r.Dir}.dir(), handoffName) } + +// 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} + if st != nil { + res.Updated, res.Version, res.Message = st.Updated, st.CurrentVersion, st.Message + } + if err != nil { + e := errs.As(err) + res.Error = &HandoffError{Code: e.Code, Message: e.Message, Advice: e.Advice} + res.Message = e.Message + } + return res +} + +// SaveHandoff writes res for the manager the CLI starts next (atomically, replacing an older one). +func SaveHandoff(r *root.Root, res *HandoffResult) error { + data, err := json.MarshalIndent(res, "", " ") + if err != nil { + return err + } + path := handoffPath(r) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := writeFileSync(tmp, data); err != nil { + return err + } + return root.ReplaceWithRetry(tmp, path) +} + +// 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) + if err != nil { + return nil + } + _ = os.Remove(path) + var res HandoffResult + if json.Unmarshal(data, &res) != nil { + return nil + } + at, err := time.Parse(time.RFC3339, res.At) + if err != nil || now.Sub(at) > HandoffFresh || at.Sub(now) > time.Minute { + return nil + } + return &res +} diff --git a/internal/update/handoff_test.go b/internal/update/handoff_test.go new file mode 100644 index 0000000..bd05113 --- /dev/null +++ b/internal/update/handoff_test.go @@ -0,0 +1,71 @@ +package update + +import ( + "os" + "testing" + "time" + + "modavd/internal/errs" + "modavd/internal/root" +) + +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 { + 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 { + t.Fatal(err) + } + got := TakeHandoff(r, 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) { + 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 { + t.Fatal(err) + } + got = TakeHandoff(r, 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) + } +} + +// Too old (a manager started long after), from the future or unreadable: not shown, and removed all the same. +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 { + t.Fatal(err) + } + if got := TakeHandoff(r, now); got != nil { + t.Errorf("at %v: %+v", at, got) + } + if _, err := os.Stat(handoffPath(r)); !os.IsNotExist(err) { + t.Errorf("at %v: not removed", at) + } + } + if err := SaveHandoff(r, NewHandoffResult(&Status{}, nil, now.Add(-HandoffFresh+time.Second))); err != nil { + t.Fatal(err) + } + if TakeHandoff(r, now) == nil { + t.Error("a result just inside HandoffFresh was dropped") + } + if err := os.WriteFile(handoffPath(r), []byte("{"), 0o644); err != nil { + t.Fatal(err) + } + if TakeHandoff(r, now) != nil { + t.Error("a broken file gave a result") + } + if _, err := os.Stat(handoffPath(r)); !os.IsNotExist(err) { + t.Error("a broken file was not removed") + } +} From 7a9e9c9d4b2618faf2ae10707c7368e960b3bacc Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Mon, 9 Nov 2026 19:07:00 +0100 Subject: [PATCH 5/6] =?UTF-8?q?=E6=95=B4=E5=8C=85=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=9A=84=E5=90=8D=E5=AD=97=E6=94=B9=E6=88=90?= =?UTF-8?q?=E3=80=8C=E6=95=B4=E5=8C=85=E6=9B=B4=E6=96=B0=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E5=92=8C=E6=8F=90=E7=A4=BA=E9=87=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=92=8C=E4=B8=AD=E6=96=87=E6=8C=A4=E5=9C=A8=E4=B8=80?= =?UTF-8?q?=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ui/jobs.go | 4 ++-- internal/ui/web/js/actions.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index 9830614..e6577c4 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -78,7 +78,7 @@ func kindLabel(kind string) string { case kindGLES32Off: return "关闭报告 GLES 3.2" case kindUpdate: - return "更新 ModAVD" + return "整包更新" } return kind } @@ -239,7 +239,7 @@ func (s *server) startJob(kind string, i int, fn jobFunc) (jobView, error) { if kind == kindUpdate { return jobView{}, errs.Newf(errs.Busy, "%s%s还没做完(任务 %s),等它做完再更新", jobTarget(o.view.Index), kindLabel(o.view.Kind), o.view.ID) } - return jobView{}, errs.Newf(errs.Busy, "正在更新 ModAVD(任务 %s),等它做完再试", o.view.ID) + return jobView{}, errs.Newf(errs.Busy, "正在整包更新(任务 %s),等它做完再试", o.view.ID) } if (sdkJob(kind) && o.view.Index != rootJobIndex && bootJob(o.view.Kind)) || (i != rootJobIndex && sdkJob(o.view.Kind) && bootJob(kind)) { diff --git a/internal/ui/web/js/actions.js b/internal/ui/web/js/actions.js index c4770de..1d25829 100644 --- a/internal/ui/web/js/actions.js +++ b/internal/ui/web/js/actions.js @@ -9,7 +9,7 @@ import { openActivity, runCheck } from './panels.js'; const VERB = { start: '开机', stop: '关机', install: '安装 APK', create: '新建', 'disk-info': '查看磁盘占用', 'disk-trim': '回收磁盘空间', 'disk-compact': '整理磁盘', components: '补全组件', 'collect-info': '收集问题信息', 'playstore-install': '补全 Google Play 商店', 'playstore-uninstall': '移除 Google Play 商店', 'playstore-import': '导入 Google Play 商店的文件', 'gles32-on': '开启报告 GLES 3.2', 'gles32-off': '关闭报告 GLES 3.2', - update: '更新 ModAVD' }; + update: '整包更新' }; // what the store needs once it is in an instance: registering its GSF ID (playstore.js openGSFID, the page is // playstore.js UNCERTIFIED; not imported from there, as playstore.js imports this module) const GSF = '登录时提示设备未经 Play 保护机制认证的,在实例的「更多操作」里点「复制 GSF ID」,到 https://www.google.com/android/uncertified/ 登记。'; From ce0739ddafbda767e2eea1978e5e25389d343664 Mon Sep 17 00:00:00 2001 From: qiyinxi Date: Mon, 9 Nov 2026 19:34:27 +0100 Subject: [PATCH 6/6] =?UTF-8?q?=E6=8C=89=E5=AE=A1=E6=9F=A5=E6=84=8F?= =?UTF-8?q?=E8=A7=81=E6=94=B6=E7=B4=A7=EF=BC=9A=E5=85=B3=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E5=89=8D=E5=85=88=E9=A2=84=E6=A3=80=EF=BC=8C=E4=BA=A4=E6=8E=A5?= =?UTF-8?q?=E5=90=8E=E4=B8=8D=E5=86=8D=E6=8E=A5=E4=BB=BB=E5=8A=A1=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=91=BD=E4=BB=A4=E8=A1=8C=E8=BF=98=E5=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 关实例前先查:别的进程在跑这个根目录的 modavd(如 modavd mcp)、有实例正在 新建、暂存的版本不比装着的新,就一台都不关(审查 P2-2、P3-1)。 - 交给命令行后不再开新任务;退出前还有任务就取消并等它收尾(P2-1)。 - 启动命令行半秒后确认它还在,不在就不退出、报错(P3-3)。 - 新版要求的 AUTO-MAS 版本显示在设置和确认框里(P2-3)。 - 只在 Windows 上允许点「更新」(P3-5)。 --- README.en.md | 2 +- README.md | 2 +- internal/ui/API.md | 7 +-- internal/ui/jobs.go | 4 ++ internal/ui/ui.go | 24 ++++++++-- internal/ui/update.go | 9 +++- internal/ui/update_apply.go | 78 ++++++++++++++++++++++++++++---- internal/ui/update_apply_test.go | 54 ++++++++++++++++++++-- internal/ui/web/js/update.js | 6 ++- internal/update/handoff.go | 18 ++++++++ internal/update/handoff_test.go | 21 +++++++++ 11 files changed, 200 insertions(+), 25 deletions(-) diff --git a/README.en.md b/README.en.md index 79f4022..8c52926 100644 --- a/README.en.md +++ b/README.en.md @@ -267,7 +267,7 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis - `install` runs `prepare` first if needed, then swaps in `sdk\emulator` and the other files in the package. All instances, manager windows and MCP clients must be closed first; if any program is using the files, it changes nothing (exit code 5), and the staged new version is kept so the next run can swap it in directly; - `recover` finishes or rolls back a swap that was left unfinished last time (it also runs first at the start of every step). - The update source is chosen in the manager's "Settings" (`设置`): GitHub (the latest Release of `ModAVD/ModAVD`, the full package) or MirrorChyan (downloads need a CDK, and it can provide delta packages; on errors it downloads from GitHub instead and says so in the result's `notices`). The CDK is encrypted with Windows DPAPI and stored in `%LOCALAPPDATA%\ModAVD\update-settings.json`; only this Windows user can decrypt it, and it is not written to logs and does not appear on the command line or in API responses. AUTO-MAS's own updater uses AUTO-MAS's settings. -- "Update" (`更新`) in the manager's "Settings": the manager downloads, verifies and stages the new version (instances keep running meanwhile), shuts down every running instance normally, then starts `tools\modavd.exe update install --after-pid --reopen [--reopen-arg ARG]...` and exits. The CLI waits for the manager to exit, swaps the new version in, writes the result to `.update\manager-result.json` and starts the manager again with its original arguments; the new manager shows the result when it opens. If the swap cannot be done, the manager is started again all the same, and the downloaded version is kept so it does not have to be downloaded again. The CLI's output goes to `logs\modavd-update.log`. `--after-pid`, `--reopen` and `--reopen-arg` only go with `install` and are meant for the manager. +- "Update" (`更新`) in the manager's "Settings": the manager downloads, verifies and stages the new version (instances keep running meanwhile); if another program runs this root's modavd (for example `modavd mcp`) or an instance is being created, it stops nothing and asks you to deal with that first; it then shuts down every running instance normally, then starts `tools\modavd.exe update install --after-pid --reopen [--reopen-arg ARG]...` and exits. The CLI waits for the manager to exit, swaps the new version in, writes the result to `.update\manager-result.json` and starts the manager again with its original arguments; the new manager shows the result when it opens. If the swap cannot be done, the manager is started again all the same, and the downloaded version is kept so it does not have to be downloaded again. The CLI's output goes to `logs\modavd-update.log`. `--after-pid`, `--reopen` and `--reopen-arg` only go with `install` and are meant for the manager. - Only files from the package are swapped: `avd\`, `mas-avd.json` (only `components.emulator` is changed and an `updateHistory` entry is recorded), `sdk\platform-tools`, `sdk\system-images`, `logs\` and so on are never touched; `sdk\emulator` is replaced as a whole. A root folder that sets `sdkRoot` in `mas-avd.json` is not updated. - `--automas-version` is the version of the calling AUTO-MAS: if the first line of the new version's announcement requires a higher AUTO-MAS, it does not install. - Exit codes: being unable to check (offline, rate-limited, a GitHub error) is 9 (`update_unavailable`), a downloaded package that fails verification is also 9 (`update_rejected`), and a program in use or an update already in progress is 5. diff --git a/README.md b/README.md index 6eb1f09..5ddc510 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ claude mcp add --transport stdio --scope user modavd '--' D:\ModAVD\tools\modavd - `install` 需要时先 `prepare`,再换上 `sdk\emulator` 和包里的其他文件。要先关掉所有实例、管理器窗口和 MCP 客户端,有程序在用就什么都不动(退出码 5),暂存的新版留着,下次直接换; - `recover` 补完或退回上次没做完的换上(每一步开始前也会先做)。 - 更新源在管理器的「设置」里选:GitHub(`ModAVD/ModAVD` 的最新 Release,整包)或 Mirror 酱(填了 CDK 才能下载,能拿到增量包;出错时改从 GitHub 下,并在结果的 `notices` 里写明)。CDK 用 Windows DPAPI 加密存在 `%LOCALAPPDATA%\ModAVD\update-settings.json`,只有这个 Windows 用户能解开,不写日志、不出现在命令行和接口返回里。AUTO-MAS 自己的更新器用 AUTO-MAS 的设置。 -- 管理器「设置」里点「更新」:管理器先下载、核对、暂存好(这时实例照常用),再正常关掉所有在运行的实例,然后启动 `tools\modavd.exe update install --after-pid <管理器 pid> --reopen <管理器程序> [--reopen-arg 参数]...` 并退出。命令行等管理器退出、换上新版,把结果写进 `.update\manager-result.json`,再按原来的参数重新打开管理器,新管理器打开时提示更新结果。换不上也照样重新打开,下载好的新版留着,下次不用重下。命令行的输出在 `logs\modavd-update.log`。`--after-pid`、`--reopen`、`--reopen-arg` 只和 `install` 一起用,是给管理器用的。 +- 管理器「设置」里点「更新」:管理器先下载、核对、暂存好(这时实例照常用);有别的程序在跑这个根目录的 modavd(比如 `modavd mcp`)或有实例正在新建,就一台都不关、提示先处理;再正常关掉所有在运行的实例,然后启动 `tools\modavd.exe update install --after-pid <管理器 pid> --reopen <管理器程序> [--reopen-arg 参数]...` 并退出。命令行等管理器退出、换上新版,把结果写进 `.update\manager-result.json`,再按原来的参数重新打开管理器,新管理器打开时提示更新结果。换不上也照样重新打开,下载好的新版留着,下次不用重下。命令行的输出在 `logs\modavd-update.log`。`--after-pid`、`--reopen`、`--reopen-arg` 只和 `install` 一起用,是给管理器用的。 - 只换包里的文件:`avd\`、`mas-avd.json`(只改 `components.emulator` 并记一条 `updateHistory`)、`sdk\platform-tools`、`sdk\system-images`、`logs\` 等一律不碰;`sdk\emulator` 整个换掉。根目录在 `mas-avd.json` 里设了 `sdkRoot` 的不更新。 - `--automas-version` 是调用方 AUTO-MAS 的版本:新版的公告第一行要求更高的 AUTO-MAS 时不装。 - 退出码:查不了(断网、限流、GitHub 出错)是 9(`update_unavailable`),下载的包没通过核对也是 9(`update_rejected`),有程序在用、正在更新是 5。 diff --git a/internal/ui/API.md b/internal/ui/API.md index e449098..5d89147 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -174,9 +174,10 @@ Apple Silicon macOS 的组件下载/导入使用 API35 Google Play ARM64 r9, - 更新源和 CDK 是本机这个 Windows 用户的设置(`%LOCALAPPDATA%\ModAVD\update-settings.json`),管理器和命令行更新时都按它来;AUTO-MAS 自己的更新器用 AUTO-MAS 的设置。 - 「更新」(`POST /api/update/apply`):管理器不能在自己运行时换掉自己,所以分三步。 1. 任务(`kind` 是 `update`,根任务)先下载、按清单核对、暂存好新版(同 `modavd update prepare`,实例照常开着);已经是最新就到此为止,结果 `handoff` 为 `false`。 - 2. 正常关掉所有在运行的实例(同 `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}`;约 1.5 秒后管理器退出。命令行等管理器退出,换上新版,把结果写进 `.update\manager-result.json`,再按原来的参数(`--root`,以及 `--port`、`--no-window`、`--dev`)重新打开管理器;新管理器启动时读走这份结果,放进 `handoff`。换不上(有程序占着等)也照样重新打开,暂存的新版留着,下次不用重下。 -- 开始前就拒绝、不开任务(422):管理器不知道怎么重新打开自己(`unsupported`);根目录没有 `tools\modavd.exe`,或者它的版本和管理器不一样(`not_ready`,旧版命令行不认这些选项)。有别的任务在跑时回 409 `busy`;更新任务在跑时,别的任务也回 409。 + 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`。换不上(有程序占着等)也照样重新打开,暂存的新版留着,下次不用重下。 +- 管理器装的时候不带 AUTO-MAS 的版本检查(和人用命令行装一样),页面在 `required_automas` 不为空时提示用 AUTO-MAS 的先更新 AUTO-MAS。 +- 开始前就拒绝、不开任务(422):不是 Windows,或者管理器不知道怎么重新打开自己(`unsupported`);根目录没有 `tools\modavd.exe`,或者它的版本和管理器不一样(`not_ready`,旧版命令行不认这些选项)。有别的任务在跑时回 409 `busy`;更新任务在跑时,别的任务也回 409。 ## 生命周期 diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index e6577c4..0584178 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -229,6 +229,10 @@ type jobFunc func(ctx context.Context, j *job, b backend) (any, error) // job (file log with the job id, log events, warnings). errs.Busy when the instance has a conflicting job. func (s *server) startJob(kind string, i int, fn jobFunc) (jobView, error) { s.jobs.mu.Lock() + if s.handingOff.Load() { + s.jobs.mu.Unlock() + return jobView{}, errs.New(errs.Busy, "正在交给命令行换上新版,管理器马上关闭,换好后会自动重新打开") + } for _, o := range s.jobs.list { if o.view.State != stateRunning { continue diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 70d7bc7..8e69fb9 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -24,6 +24,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -58,7 +59,8 @@ type Options struct { // Test hooks. prepareUpdate func(ctx context.Context, lg *logx.Logger, progress func(update.Progress)) (*update.Status, error) cliVersion func(exe string) (string, error) - spawn func(exe string, args []string, logPath string) error + programsInUse func() ([]string, error) + spawn func(exe string, args []string, logPath string) (alive func() bool, err error) newBackend func(log *logx.Logger) backend newDiagBackend func(log *logx.Logger) diagBackend token string @@ -125,9 +127,12 @@ type server struct { reopen *Reopen prepareUpdate func(ctx context.Context, lg *logx.Logger, progress func(update.Progress)) (*update.Status, error) cliVersion func(exe string) (string, error) - spawn func(exe string, args []string, logPath string) error + programsInUse func() ([]string, error) + spawn func(exe string, args []string, logPath string) (alive func() bool, err error) exitReq chan struct{} exitOnce sync.Once + // handingOff: the CLI has taken the swap; no job may start any more (startJob), the manager is about to exit + handingOff atomic.Bool } func newServer(opt Options) (*server, error) { @@ -211,7 +216,12 @@ func newServer(opt Options) (*server, error) { if h := s.upd.handoff; h != nil { lg.Infof("上次在设置里点的更新:%s", h.Message) } - s.reopen, s.prepareUpdate, s.cliVersion, s.spawn = opt.Reopen, opt.prepareUpdate, opt.cliVersion, opt.spawn + s.reopen, s.prepareUpdate, s.cliVersion, s.programsInUse, s.spawn = + opt.Reopen, opt.prepareUpdate, opt.cliVersion, opt.programsInUse, opt.spawn + if s.programsInUse == nil { + r := opt.Root + s.programsInUse = func() ([]string, error) { return update.New(r, nil, update.Options{}).ProgramsInUse() } + } if s.prepareUpdate == nil { r := opt.Root s.prepareUpdate = func(ctx context.Context, l *logx.Logger, progress func(update.Progress)) (*update.Status, error) { @@ -328,7 +338,13 @@ func (s *server) waitForExit(ctx context.Context, w *Window) { } return case <-s.exitReq: - // 设置 → 更新 handed the swap to the CLI, which waits for us to exit (its job has ended) + // 设置 → 更新 handed the swap to the CLI, which waits for us to exit. No job may start since (handingOff); + // one that slipped in before is cancelled and waited for, as when we are told to exit. + if n := s.jobs.running(); n > 0 { + s.log.Warnf("交给命令行更新后还有 %d 个任务在跑,取消", n) + s.jobCancel() + s.jobs.waitIdle(jobDrainOnCancel) + } return case <-edgeDone: edgeDone = nil diff --git a/internal/ui/update.go b/internal/ui/update.go index a160f96..3a63159 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -12,6 +12,7 @@ import ( "time" "modavd/internal/errs" + "modavd/internal/root" "modavd/internal/update" ) @@ -64,14 +65,18 @@ func (s *server) updateView() updateView { } st.Message = c.Message } - v := updateView{Status: st, Checking: p.checking, Error: p.err, InstallByCLI: s.reopen == nil, CanInstall: s.reopen != nil, - Handoff: p.handoff} + can := s.canInstall() + v := updateView{Status: st, Checking: p.checking, Error: p.err, InstallByCLI: !can, CanInstall: can, Handoff: p.handoff} if !p.at.IsZero() { v.CheckedAt = p.at.Format(time.RFC3339) } return v } +// canInstall: 更新 may be tried here — the manager knows how to come back, and updating is supported on this platform +// (Windows only for now). +func (s *server) canInstall() bool { return s.reopen != nil && root.CurrentPlatform().Windows() } + func (s *server) handleUpdateGet(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.updateView()) } diff --git a/internal/ui/update_apply.go b/internal/ui/update_apply.go index b93affc..0daf882 100644 --- a/internal/ui/update_apply.go +++ b/internal/ui/update_apply.go @@ -36,6 +36,8 @@ const ( // closes its window first. handoffExitDelay = 1500 * time.Millisecond cliVersionTimeout = 15 * time.Second + // handoffAliveCheck: how long after starting the CLI we look whether it still runs. + handoffAliveCheck = 500 * time.Millisecond // updateLogName: the CLI's output during the hand-off, under logs\ (appended). updateLogName = "modavd-update.log" ) @@ -61,7 +63,7 @@ func (s *server) updateCLI() string { return filepath.Join(s.root.Dir, "tools", // applyReady is what the 更新 refuses at once, before a job: no way to come back, no CLI of our version to hand to. func (s *server) applyReady() error { cliAdvice := `关掉管理器后运行 tools\modavd.exe update install` - if s.reopen == nil { + if !s.canInstall() { return errs.New(errs.Unsupported, "这个管理器不能自己更新").WithAdvice(cliAdvice) } cli := s.updateCLI() @@ -104,28 +106,81 @@ func (s *server) updateJob(ctx context.Context, j *job, b backend) (any, error) if err != nil { return nil, err } - if st.PreparedVersion == "" { + if !stagedNewer(st) { msg := st.Message - if msg == "" { + if msg == "" || st.PreparedVersion != "" { msg = "已经是最新版本" } return &updateResult{Stopped: []int{}, Message: msg}, nil } shown := strings.TrimPrefix(st.PreparedVersion, "v") + if err := s.beforeStopping(ctx, b, shown); err != nil { + return nil, err + } stopped, err := s.stopForUpdate(ctx, j, b, shown) if err != nil { return nil, err } s.setProgress(j, jobProgress{Step: "handoff", Message: "正在交给命令行换上 " + shown}) - if err := s.handOff(lg); err != nil { + cliAdvice := shown + ` 已下载并核对好;关掉管理器后运行 tools\modavd.exe update install,不用重新下载` + alive, err := s.handOff(lg) + if err != nil { return nil, errs.Wrap(errs.LaunchFailed, err, `启动不了 tools\modavd.exe 来换上新版:`+errs.As(err).Message). - WithAdvice(shown + ` 已下载并核对好;关掉管理器后运行 tools\modavd.exe update install,不用重新下载`) + WithAdvice(cliAdvice) + } + // It waits for us from its first step: gone already means it failed (a crash, an antivirus), and nobody would + // start the manager again. + select { + case <-time.After(handoffAliveCheck): + case <-ctx.Done(): + // we stay: the CLI gives up after update.AfterPIDWait, swaps nothing and only brings our window back + return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } + if !alive() { + return nil, errs.New(errs.LaunchFailed, `tools\modavd.exe 启动后马上退出了,新版没有换上`). + WithAdvice(cliAdvice + `;原因看 logs\` + updateLogName) + } + s.handingOff.Store(true) go s.handoffExit(j) return &updateResult{Handoff: true, Version: st.PreparedVersion, Stopped: stopped, Message: "管理器会关闭,换上 " + shown + " 后自动重新打开"}, nil } +// stagedNewer: a version is staged and it is newer than the one installed (a staged one may be older, when the +// package was copied over by hand since). +func stagedNewer(st *update.Status) bool { + staged, ok := update.ParseVersion(st.PreparedVersion) + if !ok { + return false + } + cur, ok := update.ParseVersion(st.CurrentVersion) + return !ok || cur.Less(staged) +} + +// beforeStopping refuses what would make the swap fail after the instances were stopped for nothing: another +// program of the root running (an MCP server, another modavd), an instance being created (it must not be cancelled). +func (s *server) beforeStopping(ctx context.Context, b backend, version string) error { + advice := version + " 已下载并核对好;处理好后再点「更新」,不用重新下载" + progs, err := s.programsInUse() + if err != nil { + return errs.Wrap(errs.Internal, err, "查不了有没有别的程序在用这个根目录,一台实例都没关:"+err.Error()) + } + if len(progs) > 0 { + return errs.Newf(errs.Busy, "还有程序在用这个根目录的 modavd:%s。一台实例都没关", strings.Join(progs, "、")). + WithAdvice("先关掉它们(比如用 modavd mcp 的 AI 助手、别的命令行窗口);" + advice) + } + list, err := b.List(ctx) + if err != nil { + return err + } + for _, in := range list.Instances { + if in.Status == emu.StatusCreating { + return errs.Newf(errs.Busy, "实例 %d 正在新建,一台实例都没关", in.Index).WithAdvice("等它建完;" + advice) + } + } + return nil +} + // updateProgress is an update step as the job's progress line (a download's line has the percentage and sizes). func updateProgress(p update.Progress) DiagProgress { msg := p.Message @@ -177,8 +232,8 @@ 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. -func (s *server) handOff(lg *logx.Logger) error { +// 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) { args := []string{"--root", s.root.Dir, "--json", "update", "install", "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", s.reopen.Exe} for _, a := range s.reopen.Args { @@ -226,7 +281,10 @@ func realCLIVersion(exe string) (string, error) { return env.Data.Version, nil } -func realSpawn(exe string, args []string, logPath string) error { - _, err := host.StartOrphan(exe, args, host.StartOptions{Dir: filepath.Dir(exe), OutputPath: logPath}) - return err +func realSpawn(exe string, args []string, logPath string) (func() bool, error) { + p, err := host.StartOrphan(exe, args, host.StartOptions{Dir: filepath.Dir(exe), OutputPath: logPath}) + if err != nil { + return nil, err + } + return p.Alive, nil } diff --git a/internal/ui/update_apply_test.go b/internal/ui/update_apply_test.go index 6430e8d..30e5988 100644 --- a/internal/ui/update_apply_test.go +++ b/internal/ui/update_apply_test.go @@ -32,7 +32,10 @@ type applyFakes struct { prepares int version string // what tools\modavd.exe says it is verErr error + inUse []string // other programs of the root running + inUseErr error spawnErr error + dead bool // the started CLI is gone at once spawns [][]string // exe, then the args, then the log path } @@ -71,11 +74,17 @@ func applyHarness(t *testing.T, a *applyFakes) *harness { return a.prepared, a.prepErr } o.cliVersion = func(exe string) (string, error) { return a.version, a.verErr } - o.spawn = func(exe string, args []string, logPath string) error { + o.programsInUse = func() ([]string, error) { + a.mu.Lock() + defer a.mu.Unlock() + return a.inUse, a.inUseErr + } + o.spawn = func(exe string, args []string, logPath string) (func() bool, error) { a.mu.Lock() defer a.mu.Unlock() a.spawns = append(a.spawns, append(append([]string{exe}, args...), logPath)) - return a.spawnErr + dead := a.dead + return func() bool { return !dead }, a.spawnErr } if err := os.MkdirAll(filepath.Join(o.Root.Dir, "tools"), 0o755); err != nil { t.Fatal(err) @@ -153,6 +162,11 @@ func TestUpdateApplyHandsOff(t *testing.T) { if !slices.Equal(sp[0], want) { t.Fatalf("spawn\n got %q\n want %q", sp[0], want) } + // from the hand-off on, nothing may start: the manager is about to exit + e := expectError(t, h.do("POST", "/api/instances/4/start", `{}`), 409, errs.Busy) + if !strings.Contains(e.Message, "交给命令行") { + t.Errorf("%+v", e) + } if !h.exited(5 * time.Second) { t.Fatal("the manager did not exit after the hand-off") } @@ -182,6 +196,27 @@ func TestUpdateApplyUpToDate(t *testing.T) { } } +// A staged version that is not newer than the one installed (the package was copied over by hand since): up to date, +// nothing stopped. +func TestUpdateApplyStagedNotNewer(t *testing.T) { + st := preparedStatus() + st.CurrentVersion = "v0.40.0" + a := &applyFakes{prepared: st} + h := applyHarness(t, a) + h.f.put(3, emu.StatusOnline) + done := h.waitJob(h.apply().ID, func(v jobView) bool { return v.State != stateRunning }) + r, _ := done.Result.(map[string]any) + if done.State != stateDone || r["handoff"] != false || r["message"] != "已经是最新版本" { + t.Fatalf("job %+v", done) + } + h.f.mu.Lock() + n := len(h.f.stops) + h.f.mu.Unlock() + if n != 0 || len(a.spawned()) != 0 { + t.Fatalf("stops %d, spawns %v", n, a.spawned()) + } +} + // A step that fails ends the job there: nothing after it runs, the manager stays. func TestUpdateApplyFailures(t *testing.T) { for _, c := range []struct { @@ -201,6 +236,19 @@ func TestUpdateApplyFailures(t *testing.T) { {"spawn", func(a *applyFakes, f *fake) { a.spawnErr = errors.New("拒绝访问") }, errs.LaunchFailed, 1, 1, "拒绝访问"}, + {"cli gone at once", func(a *applyFakes, f *fake) { + a.dead = true + }, errs.LaunchFailed, 1, 1, "马上退出"}, + // checked before any instance is stopped + {"another program of the root", func(a *applyFakes, f *fake) { + a.inUse = []string{"modavd.exe(pid 9)"} + }, errs.Busy, 0, 0, "modavd.exe(pid 9)"}, + {"process scan", func(a *applyFakes, f *fake) { + a.inUseErr = errors.New("快照失败") + }, errs.Internal, 0, 0, "快照失败"}, + {"an instance being created", func(a *applyFakes, f *fake) { + f.put(4, emu.StatusCreating) + }, errs.Busy, 0, 0, "实例 4 正在新建"}, } { t.Run(c.name, func(t *testing.T) { a := &applyFakes{prepared: preparedStatus()} @@ -212,7 +260,7 @@ func TestUpdateApplyFailures(t *testing.T) { if done.State != stateFailed || done.Error == nil || done.Error.Code != c.code || !strings.Contains(done.Error.Message, c.contains) { t.Fatalf("job %+v %+v", done, done.Error) } - if c.code != errs.UpdateUnavailable && !strings.Contains(done.Error.Advice, "不用重新下载") { + if c.code != errs.UpdateUnavailable && c.code != errs.Internal && !strings.Contains(done.Error.Advice, "不用重新下载") { t.Errorf("advice %q", done.Error.Advice) } h.f.mu.Lock() diff --git a/internal/ui/web/js/update.js b/internal/ui/web/js/update.js index 61554c2..6252043 100644 --- a/internal/ui/web/js/update.js +++ b/internal/ui/web/js/update.js @@ -73,7 +73,10 @@ function confirmInstall(v) { title: `更新到 ${target}`, sub: 'ModAVD 整包:模拟器、组件、脚本和管理器', light: true, body: h('div', { class: 'dlg-body' }, h('p', { text: `先下载并核对新版,这时实例照常用;然后${stops},管理器关闭、换上新版,换好后自动重新打开管理器。` }), - h('p', { class: 'f-hint', text: '实例的数据(avd 文件夹)和设置都不动。正在新建的实例要先等它建完。' })), + // the manager installs as a person does, without the AUTO-MAS check the CLI does for AUTO-MAS + v.required_automas && h('div', { class: 'note-box tone-warn', role: 'status' }, icon('alert'), + h('span', { text: `这一版要求 AUTO-MAS ${v.required_automas} 或更新。用 AUTO-MAS 的,先把 AUTO-MAS 更新到这个版本,再更新这里。` })), + h('p', { class: 'f-hint', text: '实例的数据(avd 文件夹)和设置都不动。正在新建的实例要先等它建完;用 modavd mcp 的 AI 助手等程序也要先关掉。' })), actions: { ok: go }, }); } @@ -173,6 +176,7 @@ export function openUpdate() { const rows = [['当前版本', ver(v.current_version) ? `${ver(v.current_version)}${v.current_build ? `(${v.current_build})` : ''}` : '未知']]; if (!v.updatable) rows.push(['不能更新', v.not_updatable_reason]); if (v.checked) rows.push(['最新版本', `${ver(v.latest_version)}(${v.latest_source === 'mirrorchyan' ? 'Mirror 酱' : 'GitHub'})`]); + if (v.update_available && v.required_automas) rows.push(['要求 AUTO-MAS', `${v.required_automas} 或更新(用 AUTO-MAS 的才要)`]); const phase = PHASE[v.phase] || ''; if (phase) rows.push(['状态', v.prepared_version ? `${phase}:${ver(v.prepared_version)}` : phase]); const lr = v.last_result; diff --git a/internal/update/handoff.go b/internal/update/handoff.go index 86d1538..5b3f839 100644 --- a/internal/update/handoff.go +++ b/internal/update/handoff.go @@ -10,6 +10,7 @@ package update import ( "encoding/json" + "fmt" "os" "path/filepath" "time" @@ -76,6 +77,23 @@ func SaveHandoff(r *root.Root, res *HandoffResult) error { return root.ReplaceWithRetry(tmp, path) } +// ProgramsInUse lists the other processes running this root's ModAVD.exe or tools\modavd.exe (an MCP server, another +// CLI ...), this process and the short-lived shutdown helpers aside. An install refuses them, so the manager asks +// before it stops any instance (the emulators themselves are what it stops, so they are not listed). +func (u *Updater) ProgramsInUse() ([]string, error) { + uses, err := u.processesUsing(true) + if err != nil { + return nil, err + } + var out []string + for _, p := range uses { + if p.RootProgram && !p.ShutdownHelper { + out = append(out, fmt.Sprintf("%s(pid %d)", p.Name, p.PID)) + } + } + 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 { diff --git a/internal/update/handoff_test.go b/internal/update/handoff_test.go index bd05113..e38e6d9 100644 --- a/internal/update/handoff_test.go +++ b/internal/update/handoff_test.go @@ -38,6 +38,27 @@ func TestHandoffRoundTrip(t *testing.T) { } } +// 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{}) + u.scan = func(programs bool) ([]processUse, error) { + if !programs { + t.Error("scanned without the programs") + } + return []processUse{ + {PID: 10, Name: "qemu-system-x86_64.exe"}, + {PID: 11, Name: "emulator.exe", ShutdownHelper: true}, + {PID: 12, Name: "modavd.exe", RootProgram: true, ShutdownHelper: true}, + {PID: 13, Name: "modavd.exe", RootProgram: true}, + {PID: 14, Name: "netsimd.exe", Unknown: true}, + }, nil + } + got, err := u.ProgramsInUse() + if err != nil || len(got) != 1 || got[0] != "modavd.exe(pid 13)" { + t.Fatalf("%q %v", got, err) + } +} + // Too old (a manager started long after), from the future or unreadable: not shown, and removed all the same. func TestHandoffStaleOrBroken(t *testing.T) { r := &root.Root{Dir: t.TempDir()}