diff --git a/README.en.md b/README.en.md index b1c80c7..296a2e4 100644 --- a/README.en.md +++ b/README.en.md @@ -268,7 +268,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); 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. - On macOS, update settings and component download sources use `~/Library/Application Support/ModAVD/update-settings.json` and `components-settings.json`. The CDK is encrypted with AES-GCM using a random wrapping key kept in the current user's Keychain. A locked or unauthorized Keychain makes saving fail without plaintext storage; copying the settings to another user or device does not transfer that key. - 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. diff --git a/README.md b/README.md index 1c91985..ac52a7f 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,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 的设置。 +- 管理器「设置」里点「更新」:管理器先下载、核对、暂存好(这时实例照常用);有别的程序在跑这个根目录的 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` 一起用,是给管理器用的。 - macOS 的更新设置和组件下载源分别保存在 `~/Library/Application Support/ModAVD/update-settings.json` 和 `components-settings.json`。CDK 使用 AES-GCM 加密,随机包装密钥留在当前用户的 Keychain;钥匙串锁定或未授权时保存失败,不会降级为明文存储。复制设置文件到其他用户或设备不会同时转移密钥。 - 只换包里的文件:`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 时不装。 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 2a75eea..92fd0b1 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,16 @@ 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` 和 DPAPI;macOS 使用 `~/Library/Application Support/ModAVD/update-settings.json`,CDK 的随机 AES-GCM 包装密钥保存在当前用户的 Keychain。钥匙串锁定或未授权时保存失败,不弹授权框,不保存明文密钥。 +- 只有点「检查更新」或「更新」才联网;没有自动检查。 +- 更新源和 CDK 是当前系统用户的设置,管理器和命令行更新时都按它来;AUTO-MAS 自己的更新器用 AUTO-MAS 的设置。Windows 使用 `%LOCALAPPDATA%\ModAVD\update-settings.json` 和 DPAPI;macOS 使用 `~/Library/Application Support/ModAVD/update-settings.json`,CDK 的随机 AES-GCM 包装密钥保存在当前用户的 Keychain。钥匙串锁定或未授权时保存失败,不弹授权框,不保存明文密钥。 +- 「更新」(`POST /api/update/apply`):管理器不能在自己运行时换掉自己,所以分三步。 + 1. 任务(`kind` 是 `update`,根任务)先下载、按清单核对、暂存好新版(同 `modavd update prepare`,实例照常开着);已经是最新就到此为止,结果 `handoff` 为 `false`。 + 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/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..0584178 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 "整包更新" } return kind } @@ -227,10 +229,22 @@ 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 } + // 设置 → 更新 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, "正在整包更新(任务 %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..8e69fb9 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -24,6 +24,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -31,6 +32,7 @@ import ( "modavd/internal/errs" "modavd/internal/logx" "modavd/internal/root" + "modavd/internal/update" ) // Options of Run. @@ -50,8 +52,15 @@ 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) + 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 @@ -113,6 +122,17 @@ 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) + 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) { @@ -192,7 +212,29 @@ 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.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) { + 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 +337,15 @@ 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. 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 if time.Since(w.Started) < edgeHandoffWindow { diff --git a/internal/ui/update.go b/internal/ui/update.go index fb6ffa9..3a63159 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" @@ -12,6 +12,7 @@ import ( "time" "modavd/internal/errs" + "modavd/internal/root" "modavd/internal/update" ) @@ -21,9 +22,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 +37,7 @@ type updatePanel struct { checked *update.Status // the last successful check at time.Time err *errorBody + handoff *update.HandoffResult // taken from .update when the manager started } func (p *updatePanel) busy() bool { @@ -59,13 +65,18 @@ func (s *server) updateView() updateView { } st.Message = c.Message } - v := updateView{Status: st, Checking: p.checking, Error: p.err, InstallByCLI: true} + 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 new file mode 100644 index 0000000..0daf882 --- /dev/null +++ b/internal/ui/update_apply.go @@ -0,0 +1,290 @@ +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 + // 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" +) + +// 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.canInstall() { + 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 !stagedNewer(st) { + msg := st.Message + 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}) + 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(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 + 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. 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 { + 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) (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 new file mode 100644 index 0000000..30e5988 --- /dev/null +++ b/internal/ui/update_apply_test.go @@ -0,0 +1,341 @@ +//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 + 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 +} + +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.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)) + 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) + } + 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) + } + // 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") + } + // 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 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 { + 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, "拒绝访问"}, + {"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()} + 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 && c.code != errs.Internal && !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..1d25829 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: '整包更新' }; // 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..6252043 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,62 @@ 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},管理器关闭、换上新版,换好后自动重新打开管理器。` }), + // 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 }, + }); +} + // The chip: 设置, with a dot when a check found a newer ModAVD. export function renderUpdateChip(chip, badge) { const v = S.update; @@ -88,12 +146,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({ @@ -119,13 +176,18 @@ 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; - 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 +196,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..5b3f839 --- /dev/null +++ b/internal/update/handoff.go @@ -0,0 +1,115 @@ +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" + "fmt" + "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) +} + +// 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 { + 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..e38e6d9 --- /dev/null +++ b/internal/update/handoff_test.go @@ -0,0 +1,92 @@ +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) + } +} + +// 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()} + 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") + } +}