diff --git a/cmd/modavd-gui/main.go b/cmd/modavd-gui/main.go index 6b3512c..5c1dfbd 100644 --- a/cmd/modavd-gui/main.go +++ b/cmd/modavd-gui/main.go @@ -42,6 +42,7 @@ import ( "modavd/internal/logx" "modavd/internal/root" "modavd/internal/ui" + "modavd/internal/update" ) func main() { @@ -82,6 +83,9 @@ func helpText(fs *flag.FlagSet) string { b.WriteString(usage + "\n") fs.VisitAll(func(f *flag.Flag) { name := "--" + f.Name + if name == update.HandoffArg { + return + } if v := flagValueNames[f.Name]; v != "" { name += " " + v } @@ -128,6 +132,7 @@ func run(args []string) (code int) { fs.BoolVar(&chromeWindow, "chrome-window", false, "用独立 Chrome 应用窗口打开(默认使用系统浏览器)") } dev := fs.Bool("dev", false, "开发模式:提供 /dev/ 的模拟后端页面,没有窗口也不自动退出") + handoffArg := fs.String(strings.TrimPrefix(update.HandoffArg, "--"), "", "更新交接 ID") if err := fs.Parse(args); err != nil { // --help / -h / -help ask for the usage: an information box and exit code 0, not an error (ModAVD/ModAVD#115). if errors.Is(err, flag.ErrHelp) { @@ -145,6 +150,15 @@ func run(args []string) (code int) { fatal(fmt.Sprintf("端口 %d 不对(0–65535,0 = 随机)", *port)) return 2 } + var handoffID update.HandoffID + if *handoffArg != "" { + var err error + handoffID, err = update.ParseHandoffID(*handoffArg) + if err != nil { + fatal(err.Error()) + return 2 + } + } r, err := root.Resolve(*rootFlag) if err != nil { fatalErr("找不到 ModAVD 根目录", err) @@ -166,7 +180,7 @@ func run(args []string) (code int) { return 1 } if inst == nil { - return reopen(r, *noWindow, chromeWindow, log) + return reopen(r, *noWindow, chromeWindow, log, handoffID) } defer inst.release() @@ -179,6 +193,7 @@ func run(args []string) (code int) { OpenWindow: !*noWindow, ChromeWindow: chromeWindow, Dev: *dev, + HandoffID: handoffID, // the tray icon and the boss keys (Windows): closing the window leaves the manager in the tray Tray: runtime.GOOS == "windows", Ready: func(baseURL, token string) { @@ -224,7 +239,11 @@ func reopenArgs(r *root.Root, port int, noWindow, dev bool, log *logx.Logger) *u } // reopen is the second launch on a root: the running manager gets a new window. -func reopen(r *root.Root, noWindow, chromeWindow bool, log *logx.Logger) int { +func reopen(r *root.Root, noWindow, chromeWindow bool, log *logx.Logger, id update.HandoffID) int { + if id != "" { + // A failed delivery must not leave this result for a later launch. + defer update.TakeHandoff(r, id, time.Now()) + } st, err := readStateWait(r, 5*time.Second) if err != nil { log.Errorf("已有管理器在运行,但读不到它的地址: %v", err) @@ -235,6 +254,11 @@ func reopen(r *root.Root, noWindow, chromeWindow bool, log *logx.Logger) int { fatal("这个根目录的 ModAVD 管理器已经在运行,但找不到它的地址。\n\n" + advice + "\n\n根目录:" + r.Dir) return 1 } + if id != "" { + if err := forwardHandoff(st, id); err != nil { + log.Warnf("把本次更新结果交给已运行的管理器失败: %v", err) + } + } if noWindow { return 0 } diff --git a/cmd/modavd-gui/update_handoff.go b/cmd/modavd-gui/update_handoff.go new file mode 100644 index 0000000..4481923 --- /dev/null +++ b/cmd/modavd-gui/update_handoff.go @@ -0,0 +1,55 @@ +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "modavd/internal/update" +) + +// forwardHandoff delivers the restart's ID to the existing manager, even with --no-window. It never sends the +// local login cookie through an environment proxy or a redirect. +func forwardHandoff(st state, id update.HandoffID) error { + base, err := url.Parse(st.URL) + if err != nil || base.Scheme != "http" || base.Host != "127.0.0.1:"+strconv.Itoa(st.Port) || + base.User != nil || base.Path != "/" || base.RawQuery != "" || base.Fragment != "" || st.Port <= 0 || st.Port > 65535 { + return fmt.Errorf("管理器地址不对") + } + body, err := json.Marshal(struct { + ID update.HandoffID `json:"id"` + }{id}) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, st.URL+"api/update/handoff/receive", strings.NewReader(string(body))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", strings.TrimSuffix(st.URL, "/")) + req.AddCookie(&http.Cookie{Name: "modavd_ui_" + strconv.Itoa(st.Port), Value: st.Token}) + client := &http.Client{Transport: &http.Transport{}, CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + defer client.CloseIdleConnections() + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("管理器接收更新结果失败(HTTP %d)", resp.StatusCode) + } + return nil +} diff --git a/cmd/modavd-gui/update_handoff_test.go b/cmd/modavd-gui/update_handoff_test.go new file mode 100644 index 0000000..8e3a8f5 --- /dev/null +++ b/cmd/modavd-gui/update_handoff_test.go @@ -0,0 +1,73 @@ +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync/atomic" + "testing" + + "modavd/internal/update" +) + +func TestForwardHandoffUsesOnlyLocalAuthenticatedEndpoint(t *testing.T) { + id := update.HandoffID(strings.Repeat("a", 32)) + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodPost || r.URL.Path != "/api/update/handoff/receive" || r.Header.Get("Origin") != "http://"+r.Host { + t.Errorf("request: %s %s, origin %s", r.Method, r.URL, r.Header.Get("Origin")) + } + _, port, _ := strings.Cut(r.Host, ":") + cookie, err := r.Cookie("modavd_ui_" + port) + if err != nil || cookie.Value != "local-token" { + t.Errorf("authentication: %v", err) + } + var body struct{ ID update.HandoffID } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID != id { + t.Errorf("body: %+v, %v", body, err) + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + base, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(base.Port()) + st := state{URL: srv.URL + "/", Port: port, Token: "local-token"} + if err := forwardHandoff(st, id); err != nil || calls.Load() != 1 { + t.Fatalf("forward: %v, calls %d", err, calls.Load()) + } + for _, address := range []string{"http://elsewhere.invalid/", srv.URL + "/other/", srv.URL + "/?x=1", "http://user@" + base.Host + "/"} { + st.URL = address + if err := forwardHandoff(st, id); err == nil || calls.Load() != 1 { + t.Fatalf("accepted address %s: %v", address, err) + } + } +} + +func TestForwardHandoffDoesNotFollowRedirect(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect) + })) + defer srv.Close() + base, _ := url.Parse(srv.URL) + port, _ := strconv.Atoi(base.Port()) + err := forwardHandoff(state{URL: srv.URL + "/", Port: port}, update.HandoffID(strings.Repeat("a", 32))) + if err == nil || calls.Load() != 1 { + t.Fatalf("redirect: %v, calls %d", err, calls.Load()) + } +} + +func TestBadHandoffIDIsRejectedBeforeRootLookup(t *testing.T) { + errors, _ := showBoxes(t) + if code := run([]string{update.HandoffArg, "../bad"}); code != 2 || len(*errors) != 1 { + t.Fatalf("code %d, errors %q", code, *errors) + } +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 2dc2ec4..b3030ec 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -105,8 +105,9 @@ var ( optNoBridge = flagSpec{"no-bridge", kindBool, "", "不装 arm64 执行桥,以后开机也不装(默认 modavd / 管理器开机时自动装上)"} optAutoMAS = flagSpec{"automas-version", kindString, "V", "调用方 AUTO-MAS 的版本(新版要求更高的 AUTO-MAS 时不装)"} optAfterPID = flagSpec{"after-pid", kindInt, "PID", "管理器用:先等这个进程退出(最多 60 秒)再装,只和 install 一起用"} - optReopen = flagSpec{"reopen", kindString, "程序", "管理器用:装完(成不成都)把结果写进 .update\\manager-result.json,再启动这个程序(完整路径),只和 install 一起用"} + optReopen = flagSpec{"reopen", kindString, "程序", "管理器用:装完(成不成都)保存本次交接结果,再启动这个程序(完整路径),只和 install 一起用"} optReopenArg = flagSpec{"reopen-arg", kindStrings, "参数", "管理器用:启动 --reopen 的程序时带的参数,按顺序,可以给多次"} + optHandoffID = flagSpec{"handoff-id", kindString, "ID", "管理器用:本次交接的关联 ID,只和 --reopen 一起用;不给就生成"} optUpdateSource = flagSpec{"source", kindString, "github|mirrorchyan", "这次用的更新源,盖过「设置」里的,不保存;recover 不用"} optCDKDPAPI = flagSpec{"cdk-dpapi", kindString, "BASE64", "这次用的 Mirror 酱 CDK,不保存:调用方用 Windows DPAPI(CryptProtectData,不带 entropy)加密 UTF-8 的 CDK 后的 base64;" + @@ -383,7 +384,7 @@ func init() { run: runVersion, human: humanOf(formatVersion), }, { - name: "update", args: "[status|check|prepare|install|recover] [--source github|mirrorchyan] [--cdk-dpapi BASE64] [--automas-version V] [--package ZIP --sha256 HEX] [--after-pid PID --reopen 程序 [--reopen-arg 参数]...]", + name: "update", args: "[status|check|prepare|install|recover] [--source github|mirrorchyan] [--cdk-dpapi BASE64] [--automas-version V] [--package ZIP --sha256 HEX] [--after-pid PID --reopen 程序 [--reopen-arg 参数]... [--handoff-id ID]]", summary: "更新这个根目录的 ModAVD 包(模拟器、组件、脚本和管理器)", details: "按管理器「设置」里选的更新源(GitHub 或 Mirror 酱,Mirror 酱下载要填 CDK);" + "调用方有自己的设置时用 --source 和 --cdk-dpapi 传这一次的,只盖过「设置」、不保存,status 里的 source、cdk_set 是这次生效的。" + @@ -393,7 +394,7 @@ func init() { "install 需要时先 prepare,再换上 sdk\\emulator 和包里的其他文件,要先关掉所有实例、管理器(包括托盘里的)和 MCP 客户端;" + "recover 补完或退回上次没做完的换上。和 AUTO-MAS 用同一个 .update 目录,能接着对方没做完的做。" + "--automas-version 是调用方 AUTO-MAS 的版本,新版要求更高的 AUTO-MAS 时不装。" + - "--after-pid、--reopen、--reopen-arg 是管理器「设置」里点「更新」时用的:管理器下载核对好、关掉实例后启动这条命令并退出," + + "--after-pid、--reopen、--reopen-arg、--handoff-id 是管理器「设置」里点「更新」时用的:管理器下载核对好、关掉实例后启动这条命令并退出," + "命令等它退出再换上新版,然后重新打开管理器。" + "macOS 可用 --package ZIP --sha256 HEX 准备或安装本地完整签名包;摘要需从可信来源独立取得。" + "--json 时每个进度在 stderr 的日志行里多一个 progress 对象(checking、extracting、verifying、installing 开始时各一行,percent 为 -1;" + @@ -404,7 +405,7 @@ func init() { flags: []flagSpec{optUpdateSource, optCDKDPAPI, optAutoMAS, {name: "package", kind: kindString, arg: "ZIP", help: "macOS 本地完整更新包"}, {name: "sha256", kind: kindString, arg: "HEX", help: "本地更新包的独立可信 SHA-256"}, - optAfterPID, optReopen, optReopenArg}, final: true, + optAfterPID, optReopen, optReopenArg, optHandoffID}, final: true, run: runUpdate, human: humanOf(formatUpdateStatus), }, { @@ -1498,10 +1499,11 @@ func runUpdate(c *cmdCtx) (any, error) { return nil, errs.New(errs.Usage, "--package 和 --sha256 只用于 update prepare 或 install") } afterPID, reopen, reopenArgs := c.inv.intOpt("after-pid"), c.inv.strOpt("reopen"), c.inv.strsOpt("reopen-arg") - handoff := afterPID != 0 || reopen != "" || len(reopenArgs) > 0 + idArg := c.inv.strOpt("handoff-id") + handoff := afterPID != 0 || reopen != "" || len(reopenArgs) > 0 || idArg != "" switch { case handoff && action != "install": - return nil, errs.New(errs.Usage, "--after-pid、--reopen、--reopen-arg 只能和 install 一起用"). + return nil, errs.New(errs.Usage, "--after-pid、--reopen、--reopen-arg、--handoff-id 只能和 install 一起用"). WithAdvice(findCommand("update").usageAdvice()) case afterPID < 0: return nil, errs.Newf(errs.Usage, "--after-pid 要给进程号,收到 %d", afterPID) @@ -1509,6 +1511,20 @@ func runUpdate(c *cmdCtx) (any, error) { return nil, errs.New(errs.Usage, "--reopen-arg 要和 --reopen 一起用") case reopen != "" && !filepath.IsAbs(reopen): return nil, errs.Newf(errs.Usage, "--reopen 要给程序的完整路径,收到「%s」", reopen) + case idArg != "" && reopen == "": + return nil, errs.New(errs.Usage, "--handoff-id 要和 --reopen 一起用") + } + var id update.HandoffID + if reopen != "" { + var err error + if idArg != "" { + id, err = update.ParseHandoffID(idArg) + } else { + id, err = update.NewHandoffID() + } + if err != nil { + return nil, err + } } opt := update.Options{AutoMASVersion: c.inv.strOpt("automas-version"), PackagePath: c.inv.strOpt("package"), PackageSHA256: c.inv.strOpt("sha256")} @@ -1541,7 +1557,7 @@ func runUpdate(c *cmdCtx) (any, error) { return u.Prepare(c.ctx) case "install": if handoff { - return installHandoff(c, r, u, afterPID, reopen, reopenArgs) + return installHandoff(c, r, u, afterPID, reopen, reopenArgs, id) } return c.deps.updateInstall(c.ctx, u) case "recover": @@ -1577,7 +1593,7 @@ func updateCallerSettings(inv *invocation, action string, opt *update.Options) e // installHandoff is `update install` for the manager's 更新 (update/handoff.go): wait for the manager (afterPID) to exit, // install, then, with reopen, leave the result for the manager and start it again whatever happened, so the user is // never left without a manager. -func installHandoff(c *cmdCtx, r *root.Root, u *update.Updater, afterPID int, reopen string, args []string) (*update.Status, error) { +func installHandoff(c *cmdCtx, r *root.Root, u *update.Updater, afterPID int, reopen string, args []string, id update.HandoffID) (*update.Status, error) { var st *update.Status var err error if afterPID > 0 { @@ -1592,10 +1608,12 @@ func installHandoff(c *cmdCtx, r *root.Root, u *update.Updater, afterPID int, re st, err = c.deps.updateInstall(c.ctx, u) } if reopen != "" { - if serr := update.SaveHandoff(r, update.NewHandoffResult(st, err, time.Now())); serr != nil { + if serr := update.SaveHandoff(r, update.NewHandoffResult(id, st, err, time.Now())); serr != nil { c.log.Warnf("写不了给管理器的更新结果: %v", serr) } + args = append(args, update.HandoffArg, string(id)) if rerr := c.deps.reopen(reopen, args); rerr != nil { + update.TakeHandoff(r, id, time.Now()) c.log.Warnf("重新打开管理器(%s)失败: %v", reopen, rerr) } else { c.log.Infof("已重新打开管理器") diff --git a/internal/cli/update_caller_test.go b/internal/cli/update_caller_test.go index 7bb2d06..0c9dbe8 100644 --- a/internal/cli/update_caller_test.go +++ b/internal/cli/update_caller_test.go @@ -138,9 +138,12 @@ func TestUpdateCallerSettingsPassed(t *testing.T) { h.reopen = func(exe string, args []string) error { reopened = args; return nil } if res := h.run(t, nil, "update", "install", "--reopen", `C:\M\ModAVD.exe`, "--reopen-arg", "--root", "--reopen-arg", `C:\M`, "--reopen-arg", "--source", "--json"); res.code != 0 || - strings.Join(reopened, " ") != `--root C:\M --source` { + len(reopened) != 5 || strings.Join(reopened[:4], " ") != `--root C:\M --source --update-handoff` { t.Fatalf("--reopen-arg --root: exit %d: %s %v", res.code, res.stdout, reopened) } + if _, err := update.ParseHandoffID(reopened[4]); err != nil { + t.Fatal(err) + } h, opts, _ = updateHarness(t) if res := h.run(t, nil, "update", "install", "--source", "github", "--json"); res.code != 0 { t.Fatalf("exit %d: %s", res.code, res.stdout) diff --git a/internal/cli/update_handoff_test.go b/internal/cli/update_handoff_test.go index f9f81c8..c0c9c5c 100644 --- a/internal/cli/update_handoff_test.go +++ b/internal/cli/update_handoff_test.go @@ -4,6 +4,7 @@ package cli import ( "context" + "errors" "slices" "strings" "testing" @@ -14,6 +15,8 @@ import ( "modavd/internal/update" ) +const cliHandoffID update.HandoffID = "0123456789abcdef0123456789abcdef" + // handoffHarness: a root in a temp dir, and recording fakes for the three hand-off steps. func handoffHarness(t *testing.T) (*harness, *root.Root, *[]string) { t.Helper() @@ -36,13 +39,77 @@ func handoffHarness(t *testing.T) (*harness, *root.Root, *[]string) { return h, r, &calls } +func TestUpdateHandoffRejectsInvalidIDBeforeInstall(t *testing.T) { + for _, args := range [][]string{ + {"update", "install", "--handoff-id", string(cliHandoffID)}, + {"update", "check", "--handoff-id", string(cliHandoffID)}, + {"update", "install", "--reopen", `C:\ModAVD\ModAVD.exe`, "--handoff-id", "../bad"}, + } { + h, _, calls := handoffHarness(t) + if res := h.run(t, nil, args...); res.code != 2 || len(*calls) != 0 { + t.Fatalf("args %v: code %d, calls %v", args, res.code, *calls) + } + } +} + +func TestUpdateHandoffReopenFailureDiscardsOnlyOwnResult(t *testing.T) { + h, r, _ := handoffHarness(t) + other, err := update.NewHandoffID() + if err != nil { + t.Fatal(err) + } + if err := update.SaveHandoff(r, update.NewHandoffResult(other, nil, nil, time.Now())); err != nil { + t.Fatal(err) + } + h.reopen = func(string, []string) error { return errors.New("cannot start") } + res := h.run(t, nil, "update", "install", "--reopen", `C:\ModAVD\ModAVD.exe`, "--handoff-id", string(cliHandoffID)) + if res.code != 0 || !strings.Contains(res.stderr, "cannot start") { + t.Fatalf("install result changed: %+v", res) + } + if update.TakeHandoff(r, cliHandoffID, time.Now()) != nil { + t.Fatal("failed restart left a result") + } + if update.TakeHandoff(r, other, time.Now()) == nil { + t.Fatal("failed restart discarded another result") + } +} + +func TestUpdateHandoffGeneratesDistinctIDsForRetries(t *testing.T) { + h, r, _ := handoffHarness(t) + var ids []update.HandoffID + h.reopen = func(_ string, args []string) error { + if len(args) != 3 || args[0] != "--no-window" || args[1] != update.HandoffArg { + t.Fatalf("restart args: %q", args) + } + id, err := update.ParseHandoffID(args[2]) + if err != nil { + t.Fatal(err) + } + ids = append(ids, id) + return nil + } + for range 2 { + if res := h.run(t, nil, "update", "install", "--reopen", `C:\ModAVD\ModAVD.exe`, "--reopen-arg", "--no-window"); res.code != 0 { + t.Fatalf("exit %d", res.code) + } + } + if ids[0] == ids[1] { + t.Fatal("retry reused the previous handoff ID") + } + for _, id := range ids { + if result := update.TakeHandoff(r, id, time.Now()); result == nil || result.ID != id { + t.Fatalf("missing matching result for %s: %+v", id, result) + } + } +} + func TestUpdateHandoffOptionsOnlyWithInstall(t *testing.T) { for _, args := range [][]string{ {"update", "check", "--after-pid", "5"}, - {"update", "--reopen", `C:\x\ModAVD.exe`}, + {"update", "--handoff-id", string(cliHandoffID), "--reopen", `C:\x\ModAVD.exe`}, {"update", "prepare", "--reopen-arg", "--no-window"}, {"update", "install", "--reopen-arg", "--no-window"}, - {"update", "install", "--reopen", `ModAVD.exe`}, + {"update", "install", "--handoff-id", string(cliHandoffID), "--reopen", `ModAVD.exe`}, {"update", "install", "--after-pid", "-1"}, } { h, _, calls := handoffHarness(t) @@ -56,23 +123,23 @@ func TestUpdateHandoffOptionsOnlyWithInstall(t *testing.T) { func TestUpdateHandoffWaitsInstallsThenReopens(t *testing.T) { h, r, calls := handoffHarness(t) exe := `C:\ModAVD\ModAVD.exe` - res := h.run(t, nil, "update", "install", "--after-pid", "4242", "--reopen", exe, + res := h.run(t, nil, "update", "install", "--after-pid", "4242", "--handoff-id", string(cliHandoffID), "--reopen", exe, "--reopen-arg", "--root", "--reopen-arg", `C:\ModAVD`, "--reopen-arg", "--no-window", "--json") if res.code != 0 { t.Fatalf("exit %d: %s %s", res.code, res.stdout, res.stderr) } - want := []string{"wait 4242 " + update.AfterPIDWait.String(), "install", "reopen " + exe + ` --root C:\ModAVD --no-window`} + want := []string{"wait 4242 " + update.AfterPIDWait.String(), "install", "reopen " + exe + ` --root C:\ModAVD --no-window --update-handoff ` + string(cliHandoffID)} if !slices.Equal(*calls, want) { t.Fatalf("calls %v, want %v", *calls, want) } if d := envelope(t, res.stdout)["data"].(map[string]any); d["updated"] != true || d["current_version"] != "v0.40.0" { t.Errorf("data %v", d) } - got := update.TakeHandoff(r, time.Now()) + got := update.TakeHandoff(r, cliHandoffID, time.Now()) if got == nil || !got.OK || !got.Updated || got.Version != "v0.40.0" || got.Message != "已更新到 0.40.0" || got.Error != nil { t.Fatalf("handoff %+v", got) } - if update.TakeHandoff(r, time.Now()) != nil { + if update.TakeHandoff(r, cliHandoffID, time.Now()) != nil { t.Error("the result was not removed when taken") } } @@ -85,14 +152,14 @@ func TestUpdateHandoffManagerStillRunning(t *testing.T) { *calls = append(*calls, "wait") return false } - res := h.run(t, nil, "update", "install", "--after-pid", "4242", "--reopen", `C:\ModAVD\ModAVD.exe`, "--json") + res := h.run(t, nil, "update", "install", "--after-pid", "4242", "--handoff-id", string(cliHandoffID), "--reopen", `C:\ModAVD\ModAVD.exe`, "--json") if res.code != errs.ExitCode(errs.New(errs.Busy, "")) { t.Fatalf("exit %d: %s", res.code, res.stdout) } - if !slices.Equal(*calls, []string{"wait", `reopen C:\ModAVD\ModAVD.exe `}) { + if !slices.Equal(*calls, []string{"wait", `reopen C:\ModAVD\ModAVD.exe --update-handoff ` + string(cliHandoffID)}) { t.Fatalf("calls %v", *calls) } - got := update.TakeHandoff(r, time.Now()) + got := update.TakeHandoff(r, cliHandoffID, time.Now()) if got == nil || got.OK || got.Error == nil || got.Error.Code != errs.Busy || !strings.Contains(got.Message, "4242") { t.Fatalf("handoff %+v", got) } @@ -105,14 +172,14 @@ func TestUpdateHandoffInstallFailsStillReopens(t *testing.T) { return &update.Status{CurrentVersion: "v0.39.0", Notices: []string{}}, errs.New(errs.Busy, "0.40.0 已下载并核对好,但现在不能换上").WithAdvice("关掉实例再装") } - res := h.run(t, nil, "update", "install", "--after-pid", "7", "--reopen", `C:\ModAVD\ModAVD.exe`, "--json") + res := h.run(t, nil, "update", "install", "--after-pid", "7", "--handoff-id", string(cliHandoffID), "--reopen", `C:\ModAVD\ModAVD.exe`, "--json") if res.code != 5 { t.Fatalf("exit %d", res.code) } - if len(*calls) != 3 || (*calls)[2] != `reopen C:\ModAVD\ModAVD.exe ` { + if len(*calls) != 3 || (*calls)[2] != `reopen C:\ModAVD\ModAVD.exe --update-handoff `+string(cliHandoffID) { t.Fatalf("calls %v", *calls) } - got := update.TakeHandoff(r, time.Now()) + got := update.TakeHandoff(r, cliHandoffID, time.Now()) if got == nil || got.OK || got.Updated || got.Version != "v0.39.0" || got.Error.Advice != "关掉实例再装" || got.Message != "0.40.0 已下载并核对好,但现在不能换上" { t.Fatalf("handoff %+v", got) @@ -135,7 +202,7 @@ func TestUpdateInstallWithoutReopen(t *testing.T) { if !slices.Equal(*calls, []string{"install"}) { t.Fatalf("calls %v", *calls) } - if update.TakeHandoff(r, time.Now()) != nil { + if update.TakeHandoff(r, cliHandoffID, time.Now()) != nil { t.Error("a result was left without --reopen") } } diff --git a/internal/ui/API.en.md b/internal/ui/API.en.md index 3bf164c..38798be 100644 --- a/internal/ui/API.en.md +++ b/internal/ui/API.en.md @@ -82,6 +82,8 @@ HTTP 4xx / 5xx with the body `{"error":{"code":"…","message":"…","advice":" | GET | `/api/update` | | `UpdateView`, see below (does not use the network) | | POST | `/api/update/check` | `{}` | `UpdateView`: looks up the latest version from the configured update source (without a CDK); 409 `busy` while a check is in progress | | POST | `/api/update/settings` | `{source?: "github"\|"mirrorchyan", cdk?: string}` | `UpdateView`; a `cdk` of `""` clears it; the CDK is stored encrypted and never returned | +| POST | `/api/update/handoff/take` | `{id}` | 200 this handoff's `HandoffResult`, claimed once across all windows; `null` if already claimed or the ID does not match | +| POST | `/api/update/handoff/receive` | `{id}` | 200 `{received}`, used by a restarted launcher to deliver this handoff to an already running manager | | POST | `/api/update/apply` | `{}` | 202 `Job` (`kind` is `update`, a root job, `index` is `-1`): updates this root folder, see "Update" below; the result is `{handoff, version, stopped, message}` | `{i}` is the instance number (0–9, except 5). @@ -184,14 +186,14 @@ Some games require GLES 3.2 in their manifest, and the Play store judges ModAVD ### Update `UpdateView` -All the fields of `update.Status` (the same as the `data` of `modavd update --json`: `root`, `updatable`, `not_updatable_reason`, `current_version`, `current_build`, `source`, `cdk_set`, `checked`, `check_error`, `latest_version`, `latest_source`, `update_available`, `required_automas`, `release_url`, `phase`, `prepared_version`, `last_result`, `recovered`, `updated`, `message`, `notices`), plus `checking`, `checked_at` (RFC 3339, `""` if no check has been made since this start), `error` (the error object of the last check, `null` if none), `can_install` (`POST /api/update/apply` can be tried: the manager knows how to reopen itself), `install_by_cli` (the opposite of `can_install`: it can only be installed with the command line), and `handoff` (the result that the command line left after the last click on "Update" handed over to it, `{at, ok, updated, version, message, error: {code, message, advice} | null}`; `null` if this manager was not the one reopened by that run, or if the result is older than 30 minutes). +All the fields of `update.Status` (the same as the `data` of `modavd update --json`: `root`, `updatable`, `not_updatable_reason`, `current_version`, `current_build`, `source`, `cdk_set`, `checked`, `check_error`, `latest_version`, `latest_source`, `update_available`, `required_automas`, `release_url`, `phase`, `prepared_version`, `last_result`, `recovered`, `updated`, `message`, `notices`), plus `checking`, `checked_at` (RFC 3339, `""` if no check has been made since this start), `error` (the error object of the last check, `null` if none), `can_install` (`POST /api/update/apply` can be tried: the manager knows how to reopen itself), `install_by_cli` (the opposite of `can_install`: it can only be installed with the command line), and `handoff` (the result that the command line left after the last click on "Update" handed over to it, `{id, at, ok, updated, version, message, error: {code, message, advice} | null}`; `null` if no handoff ID was delivered, a window has already claimed the result, or the result is older than 30 minutes). - Only clicking "Check for updates" or "Update" uses the network; there is no automatic check. - The update source and the CDK are settings of the current system user, used by both the manager and the command line when updating; AUTO-MAS's own updater uses AUTO-MAS's settings. Windows uses `%LOCALAPPDATA%\ModAVD\update-settings.json` and DPAPI; macOS uses `~/Library/Application Support/ModAVD/update-settings.json`, with the random AES-GCM wrapping key of the CDK kept in the current user's Keychain. When the keychain is locked or access is not authorized, saving fails, no authorization prompt is shown, and no plaintext key is saved. - "Update" (`POST /api/update/apply`): the manager cannot replace itself while running, so it takes three steps. - 1. The job (`kind` is `update`, a root job) first downloads, checks against the manifest and stages the new version (same as `modavd update prepare`, with instances still running as usual); if it is already the latest, it stops there and the result has `handoff` `false`. + 1. The job (`kind` is `update`, a root job) first downloads, checks against the manifest and stages the new version (same as `modavd update prepare`, with instances still running as usual); if it is already the latest, it stops there and the result has `handoff` `false`. When Prepare returns `update_unavailable`, it continues offline only if the stage fingerprint and version records still match and the staged version is newer than the known installed version. Cancellation, a damaged stage, and other errors still end the job. 2. It first checks for situations that would make the package swap fail, and if there is one, no instance is stopped and the job fails: another process is running this root folder's `ModAVD.exe` or `tools\modavd.exe` (for example `modavd mcp`); an instance is being created; the staged version is not newer than the installed one (which counts as already the latest). Then it shuts down all running instances normally (same as `stop --all`, but without canceling an instance being created). - 3. It uses `host.StartOrphan` to start the root folder's `tools\modavd.exe --root --json update install --after-pid --reopen --reopen-arg …` (output is appended to `logs\modavd-update.log`). The job counts as successful only if it is still running half a second later, with the result `{handoff: true, version, stopped, message}`; from then on every other job gets 409, and the manager exits after about 1.5 seconds. The command line waits for the manager to exit, swaps in the new version, writes the result to `.update\manager-result.json`, and reopens the manager with the original arguments (`--root`, plus `--port`, `--no-window` and `--dev`); the new manager reads that result at startup and puts it into `handoff`. If the swap cannot be done (for example a program is holding a file), the manager is reopened all the same, and the staged new version is kept so it need not be downloaded again next time. + 3. It uses `host.StartOrphan` to start the root folder's `tools\modavd.exe --root --json update install --after-pid --reopen --reopen-arg …` (output is appended to `logs\modavd-update.log`). The job counts as successful only if it is still running half a second later, with the result `{handoff: true, version, stopped, message}`; from then on every other job gets 409, and the manager exits after about 1.5 seconds. The command line waits for the manager to exit, swaps in the new version, writes the result and this run's random ID to `.update\manager-results\.json`, and reopens the manager with the original arguments (`--root`, plus `--port`, `--no-window` and `--dev`); the command line passes the ID with `--update-handoff `, and the new manager reads only the matching result into `handoff`. If a manager is already running, the restart delivers this ID through `POST /api/update/handoff/receive` before opening the existing window. A page first claims the result with `POST /api/update/handoff/take`. Only the page that receives it shows a notification, and the server clears the claimed result. Ordinary launches ignore legacy result files without an ID. If the swap cannot be done (for example a program is holding a file), the manager is reopened all the same, and the staged new version is kept so it need not be downloaded again next time. - When the manager installs, it does not do the AUTO-MAS version check (the same as installing by hand with the command line); when `required_automas` is not empty, the page prompts the user to update AUTO-MAS first with AUTO-MAS. - Refused before starting, with no job (422): not Windows, or the manager does not know how to reopen itself (`unsupported`); the root folder has no `tools\modavd.exe`, or its version differs from the manager's (`not_ready`; an older command line does not recognize these options). When another job is running, 409 `busy`; while an update job is running, other jobs also get 409. - Native macOS update is done with the CLI's `update prepare/install --package ZIP --sha256 HEX`, selecting a local signed package; after staging, `install/recover` can run offline. Without a compatible `macos-arm64` remote asset, the check result is `update_unavailable`. The UI offers no install entry; for the package protocol see `internal/update/MACOS.md`. diff --git a/internal/ui/API.md b/internal/ui/API.md index d37f6d5..7054b07 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -82,6 +82,8 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | GET | `/api/update` | | `UpdateView`,见下(不联网) | | POST | `/api/update/check` | `{}` | `UpdateView`:按设置的更新源查最新版本(不带 CDK);检查进行中回 409 `busy` | | POST | `/api/update/settings` | `{source?: "github"\|"mirrorchyan", cdk?: string}` | `UpdateView`;`cdk` 为 `""` 时清除,CDK 加密保存,从不返回 | +| POST | `/api/update/handoff/take` | `{id}` | 200 本次交接的 `HandoffResult`,全管理器只领取一次;已领走或 ID 不匹配是 `null` | +| POST | `/api/update/handoff/receive` | `{id}` | 200 `{received}`,供重开启动把本次交接结果转交给已运行的管理器 | | POST | `/api/update/apply` | `{}` | 202 `Job`(`kind` 是 `update`,根任务,`index` 为 `-1`):更新这个根目录,见下「更新」;结果是 `{handoff, version, stopped, message}` | `{i}` 是实例编号(0–9,5 除外)。 @@ -184,14 +186,14 @@ Apple Silicon macOS 的组件下载/导入使用 API35 Google Play ARM64 r9, ### 更新 `UpdateView` -`update.Status` 的全部字段(和 `modavd update --json` 的 `data` 相同:`root`、`updatable`、`not_updatable_reason`、`current_version`、`current_build`、`source`、`cdk_set`、`checked`、`check_error`、`latest_version`、`latest_source`、`update_available`、`required_automas`、`release_url`、`phase`、`prepared_version`、`last_result`、`recovered`、`updated`、`message`、`notices`),加上 `checking`、`checked_at`(RFC 3339,本次启动后没查过是 `""`)、`error`(上次检查的错误对象,没有是 `null`)、`can_install`(可以试 `POST /api/update/apply`:管理器知道怎么重新打开自己)、`install_by_cli`(`can_install` 的反面:只能用命令行装)、`handoff`(上次点「更新」交给命令行以后命令行留下的结果 `{at, ok, updated, version, message, error: {code, message, advice} | null}`;这个管理器不是那次重新打开的、或者结果超过 30 分钟,是 `null`)。 +`update.Status` 的全部字段(和 `modavd update --json` 的 `data` 相同:`root`、`updatable`、`not_updatable_reason`、`current_version`、`current_build`、`source`、`cdk_set`、`checked`、`check_error`、`latest_version`、`latest_source`、`update_available`、`required_automas`、`release_url`、`phase`、`prepared_version`、`last_result`、`recovered`、`updated`、`message`、`notices`),加上 `checking`、`checked_at`(RFC 3339,本次启动后没查过是 `""`)、`error`(上次检查的错误对象,没有是 `null`)、`can_install`(可以试 `POST /api/update/apply`:管理器知道怎么重新打开自己)、`install_by_cli`(`can_install` 的反面:只能用命令行装)、`handoff`(上次点「更新」交给命令行以后命令行留下的结果 `{id, at, ok, updated, version, message, error: {code, message, advice} | null}`;没有收到本次交接 ID、结果已被任一窗口领取、或者结果超过 30 分钟,是 `null`)。 - 只有点「检查更新」或「更新」才联网;没有自动检查。 - 更新源和 CDK 是当前系统用户的设置,管理器和命令行更新时都按它来;AUTO-MAS 自己的更新器用 AUTO-MAS 的设置。Windows 使用 `%LOCALAPPDATA%\ModAVD\update-settings.json` 和 DPAPI;macOS 使用 `~/Library/Application Support/ModAVD/update-settings.json`,CDK 的随机 AES-GCM 包装密钥保存在当前用户的 Keychain。钥匙串锁定或未授权时保存失败,不弹授权框,不保存明文密钥。 - 「更新」(`POST /api/update/apply`):管理器不能在自己运行时换掉自己,所以分三步。 - 1. 任务(`kind` 是 `update`,根任务)先下载、按清单核对、暂存好新版(同 `modavd update prepare`,实例照常开着);已经是最新就到此为止,结果 `handoff` 为 `false`。 + 1. 任务(`kind` 是 `update`,根任务)先下载、按清单核对、暂存好新版(同 `modavd update prepare`,实例照常开着);已经是最新就到此为止,结果 `handoff` 为 `false`。Prepare 回 `update_unavailable` 时,只有暂存指纹和版本记录仍相符、且暂存版本比已安装的已知版本新,才离线继续;取消、损坏暂存和其他错误仍终止任务。 2. 先查会让换包失败的情况,有就一台实例都不关、任务失败:别的进程在跑这个根目录的 `ModAVD.exe` 或 `tools\modavd.exe`(比如 `modavd mcp`);有实例正在新建;暂存的版本不比装着的新(这时算已经是最新)。然后正常关掉所有在运行的实例(同 `stop --all`,但不取消正在新建的)。 - 3. 用 `host.StartOrphan` 启动根目录的 `tools\modavd.exe --root <根> --json update install --after-pid <管理器 pid> --reopen <管理器程序> --reopen-arg …`(输出追加到 `logs\modavd-update.log`)。半秒后它还在跑,任务才算成功,结果 `{handoff: true, version, stopped, message}`;从这时起别的任务一律回 409,约 1.5 秒后管理器退出。命令行等管理器退出,换上新版,把结果写进 `.update\manager-result.json`,再按原来的参数(`--root`,以及 `--port`、`--no-window`、`--dev`)重新打开管理器;新管理器启动时读走这份结果,放进 `handoff`。换不上(有程序占着等)也照样重新打开,暂存的新版留着,下次不用重下。 + 3. 用 `host.StartOrphan` 启动根目录的 `tools\modavd.exe --root <根> --json update install --after-pid <管理器 pid> --reopen <管理器程序> --reopen-arg …`(输出追加到 `logs\modavd-update.log`)。半秒后它还在跑,任务才算成功,结果 `{handoff: true, version, stopped, message}`;从这时起别的任务一律回 409,约 1.5 秒后管理器退出。命令行等管理器退出,换上新版,把结果和本次随机 ID 写进 `.update\manager-results\.json`,再按原来的参数(`--root`,以及 `--port`、`--no-window`、`--dev`)重新打开管理器;命令行通过 `--update-handoff ` 传递本次 ID,新管理器只读走对应的结果,放进 `handoff`。如果已有管理器在运行,这次启动通过 `POST /api/update/handoff/receive` 转交本次 ID,再打开已有窗口。页面先用 `POST /api/update/handoff/take` 领取结果,只有领到的窗口提示;已领取的结果从服务端清掉。普通启动不读取无 ID 的旧版结果文件。换不上(有程序占着等)也照样重新打开,暂存的新版留着,下次不用重下。 - 管理器装的时候不带 AUTO-MAS 的版本检查(和人用命令行装一样),页面在 `required_automas` 不为空时提示用 AUTO-MAS 的先更新 AUTO-MAS。 - 开始前就拒绝、不开任务(422):不是 Windows,或者管理器不知道怎么重新打开自己(`unsupported`);根目录没有 `tools\modavd.exe`,或者它的版本和管理器不一样(`not_ready`,旧版命令行不认这些选项)。有别的任务在跑时回 409 `busy`;更新任务在跑时,别的任务也回 409。 - macOS 原生更新由 CLI 的 `update prepare/install --package ZIP --sha256 HEX` 选择本地签名包;暂存后可离线 `install/recover`。没有兼容的 `macos-arm64` 远端资产时,检查结果为 `update_unavailable`。界面不提供安装入口;包协议见 `internal/update/MACOS.md`。 diff --git a/internal/ui/http.go b/internal/ui/http.go index 16dc53d..fa415e1 100644 --- a/internal/ui/http.go +++ b/internal/ui/http.go @@ -70,6 +70,8 @@ func (s *server) routes() { m.HandleFunc("POST /api/update/check", s.handleUpdateCheck) m.HandleFunc("POST /api/update/settings", s.handleUpdateSettings) m.HandleFunc("POST /api/update/apply", s.handleUpdateApply) + m.HandleFunc("POST /api/update/handoff/take", s.handleUpdateHandoffTake) + m.HandleFunc("POST /api/update/handoff/receive", s.handleUpdateHandoffReceive) s.mux = m } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 396ccf2..b12ecee 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -59,6 +59,8 @@ type Options struct { // Reopen is how the CLI starts this manager again after 设置 → 更新 swapped the new version in (update_apply.go); // nil: the manager cannot update itself (更新 is refused, checking still works). Reopen *Reopen + // HandoffID is set only by the CLI's restart after one update. + HandoffID update.HandoffID // Tray puts the manager's icon in the notification area and registers the instances' boss keys (bosskey.go; // Windows only, ignored elsewhere). The manager then stays after its window closed, in the tray, until 退出 in the // icon's menu (or the process is told to end). @@ -238,7 +240,7 @@ func newServer(opt Options) (*server, error) { if opt.proxyEvery > 0 { s.proxyEvery = opt.proxyEvery } - s.upd = &updatePanel{handoff: update.TakeHandoff(opt.Root, time.Now())} + s.upd = &updatePanel{handoff: update.TakeHandoff(opt.Root, opt.HandoffID, time.Now())} if h := s.upd.handoff; h != nil { lg.Infof("上次在设置里点的更新:%s", h.Message) } @@ -251,7 +253,7 @@ func newServer(opt Options) (*server, error) { if s.prepareUpdate == nil { r := opt.Root s.prepareUpdate = func(ctx context.Context, l *logx.Logger, progress func(update.Progress)) (*update.Status, error) { - return update.New(r, l, update.Options{Progress: progress}).Prepare(ctx) + return update.New(r, l, update.Options{Progress: progress}).PrepareHandoff(ctx) } } if s.cliVersion == nil { diff --git a/internal/ui/update.go b/internal/ui/update.go index 3a63159..ad9c499 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -27,7 +27,7 @@ type updateView struct { InstallByCLI bool `json:"install_by_cli"` // 更新 is not offered here (CanInstall false): only the CLI installs CanInstall bool `json:"can_install"` // POST /api/update/apply may be tried // Handoff is what the CLI reported after the last 更新 handed to it (the swap and the manager's restart); null when - // this manager was not started by such a hand-off, or the report is older than update.HandoffFresh. + // no matching hand-off was delivered, or a window has already claimed the notification. Handoff *update.HandoffResult `json:"handoff"` } @@ -37,7 +37,7 @@ type updatePanel struct { checked *update.Status // the last successful check at time.Time err *errorBody - handoff *update.HandoffResult // taken from .update when the manager started + handoff *update.HandoffResult // matching result received at startup or from a later CLI restart } func (p *updatePanel) busy() bool { @@ -81,6 +81,64 @@ func (s *server) handleUpdateGet(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.updateView()) } +// The page claims a notification before showing it. Every window shares this claim, so opening another window +// or reconnecting an event stream cannot show the same result again. +func (s *server) handleUpdateHandoffTake(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHandoffID(w, r) + if !ok { + return + } + p := s.upd + p.mu.Lock() + var res *update.HandoffResult + if p.handoff != nil && p.handoff.ID == id { + if p.handoff.Fresh(time.Now()) { + res = p.handoff + } + p.handoff = nil + } + p.mu.Unlock() + s.hub.broadcast("update", s.updateView()) + writeJSON(w, http.StatusOK, res) +} + +// A CLI restart can encounter an already running manager. That launch forwards only its own ID here, before +// opening the existing window. The ordinary launcher never asks this endpoint to read a file. +func (s *server) handleUpdateHandoffReceive(w http.ResponseWriter, r *http.Request) { + id, ok := decodeHandoffID(w, r) + if !ok { + return + } + p := s.upd + p.mu.Lock() + res := update.TakeHandoff(s.root, id, time.Now()) + if res != nil { + p.handoff = res + } + p.mu.Unlock() + if res != nil { + s.hub.broadcast("update", s.updateView()) + } + writeJSON(w, http.StatusOK, struct { + Received bool `json:"received"` + }{res != nil}) +} + +func decodeHandoffID(w http.ResponseWriter, r *http.Request) (update.HandoffID, bool) { + var body struct { + ID string `json:"id"` + } + if !decode(w, r, &body) { + return "", false + } + id, err := update.ParseHandoffID(body.ID) + if err != nil { + writeError(w, err) + return "", false + } + return id, true +} + func (s *server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) { if !decode(w, r, &struct{}{}) { return diff --git a/internal/ui/update_apply.go b/internal/ui/update_apply.go index 0daf882..a137144 100644 --- a/internal/ui/update_apply.go +++ b/internal/ui/update_apply.go @@ -234,8 +234,12 @@ func (s *server) stopForUpdate(ctx context.Context, j *job, b backend, version s // handOff starts the CLI that swaps once we are gone and starts us again; it is not our descendant (host.StartOrphan), // so nothing that ends this process can end it. alive tells whether it still runs. func (s *server) handOff(lg *logx.Logger) (alive func() bool, err error) { + id, err := update.NewHandoffID() + if err != nil { + return nil, err + } args := []string{"--root", s.root.Dir, "--json", "update", "install", - "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", s.reopen.Exe} + "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", s.reopen.Exe, "--handoff-id", string(id)} for _, a := range s.reopen.Args { args = append(args, "--reopen-arg", a) } diff --git a/internal/ui/update_apply_test.go b/internal/ui/update_apply_test.go index 30e5988..ed1d307 100644 --- a/internal/ui/update_apply_test.go +++ b/internal/ui/update_apply_test.go @@ -156,7 +156,7 @@ func TestUpdateApplyHandsOff(t *testing.T) { } root := h.s.root.Dir want := []string{filepath.Join(root, "tools", "modavd.exe"), "--root", root, "--json", "update", "install", - "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", reopenExe, + "--after-pid", strconv.Itoa(os.Getpid()), "--reopen", reopenExe, "--handoff-id", sp[0][11], "--reopen-arg", "--root", "--reopen-arg", root, "--reopen-arg", "--no-window", filepath.Join(root, "logs", updateLogName)} if !slices.Equal(sp[0], want) { @@ -324,7 +324,8 @@ func TestUpdateApplyRunsAlone(t *testing.T) { func TestUpdateHandoffShownAfterRestart(t *testing.T) { t.Setenv("LOCALAPPDATA", testutil.TempDir(t)) h := newHarness(t, func(o *Options) { - res := update.NewHandoffResult(&update.Status{Updated: true, CurrentVersion: "v0.40.0", Message: "已更新到 0.40.0"}, nil, time.Now()) + o.HandoffID = uiHandoffID + res := update.NewHandoffResult(uiHandoffID, &update.Status{Updated: true, CurrentVersion: "v0.40.0", Message: "已更新到 0.40.0"}, nil, time.Now()) if err := update.SaveHandoff(o.Root, res); err != nil { t.Fatal(err) } @@ -335,7 +336,7 @@ func TestUpdateHandoffShownAfterRestart(t *testing.T) { if hv == nil || hv["ok"] != true || hv["message"] != "已更新到 0.40.0" || hv["version"] != "v0.40.0" { t.Fatalf("handoff %v", v["handoff"]) } - if update.TakeHandoff(h.s.root, time.Now()) != nil { + if update.TakeHandoff(h.s.root, uiHandoffID, time.Now()) != nil { t.Error("the file was left for the next start") } } diff --git a/internal/ui/update_handoff_test.go b/internal/ui/update_handoff_test.go new file mode 100644 index 0000000..1f83ce2 --- /dev/null +++ b/internal/ui/update_handoff_test.go @@ -0,0 +1,144 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "modavd/internal/errs" + "modavd/internal/update" +) + +const uiHandoffID update.HandoffID = "0123456789abcdef0123456789abcdef" + +func TestUnrelatedManagerDoesNotTakeHandoff(t *testing.T) { + h := newHarness(t, func(o *Options) { + if err := update.SaveHandoff(o.Root, update.NewHandoffResult(uiHandoffID, nil, nil, time.Now())); err != nil { + t.Fatal(err) + } + }) + var v map[string]any + expect(t, h.do("GET", "/api/update", nil), http.StatusOK, &v) + if v["handoff"] != nil { + t.Fatalf("unrelated startup exposed an old handoff: %v", v["handoff"]) + } + if got := update.TakeHandoff(h.s.root, uiHandoffID, time.Now()); got == nil { + t.Fatal("unrelated startup consumed the owned result") + } +} + +func handoffHarness(t *testing.T) *harness { + t.Helper() + return newHarness(t, func(o *Options) { + o.HandoffID = uiHandoffID + if err := update.SaveHandoff(o.Root, update.NewHandoffResult(uiHandoffID, + &update.Status{Updated: true, CurrentVersion: "v0.40.0"}, nil, time.Now())); err != nil { + t.Fatal(err) + } + }) +} + +func TestUpdateHandoffClaimedOnceAcrossWindows(t *testing.T) { + h := handoffHarness(t) + // Wrong IDs cannot claim or clear the pending result. + other := strings.Repeat("a", 32) + expect(t, h.do("POST", "/api/update/handoff/take", `{"id":"`+other+`"}`), http.StatusOK, nil) + var count atomic.Int32 + var wg sync.WaitGroup + for range 12 { + wg.Go(func() { + resp := h.do("POST", "/api/update/handoff/take", `{"id":"`+string(uiHandoffID)+`"}`) + defer resp.Body.Close() + var result *update.HandoffResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || resp.StatusCode != http.StatusOK { + t.Errorf("claim: HTTP %d, %v", resp.StatusCode, err) + } else if result != nil { + if result.ID != uiHandoffID || !result.OK { + t.Errorf("wrong result: %+v", result) + } + count.Add(1) + } + }) + } + wg.Wait() + if count.Load() != 1 { + t.Fatalf("%d windows received the notification", count.Load()) + } + var v map[string]any + expect(t, h.do("GET", "/api/update", nil), http.StatusOK, &v) + if v["handoff"] != nil { + t.Fatal("reopened window can still see the notification") + } +} + +func TestUpdateHandoffDeliveredToRunningManager(t *testing.T) { + h := newHarness(t, nil) + other := update.HandoffID(strings.Repeat("a", 32)) + for _, id := range []update.HandoffID{uiHandoffID, other} { + if err := update.SaveHandoff(h.s.root, update.NewHandoffResult(id, nil, + errs.New(errs.Busy, "管理器没有退出"), time.Now())); err != nil { + t.Fatal(err) + } + } + var received map[string]any + expect(t, h.do("POST", "/api/update/handoff/receive", `{"id":"`+string(uiHandoffID)+`"}`), http.StatusOK, &received) + if received["received"] != true { + t.Fatalf("not received: %v", received) + } + // Retrying delivery cannot publish it again after a page claimed it. + var result *update.HandoffResult + expect(t, h.do("POST", "/api/update/handoff/take", `{"id":"`+string(uiHandoffID)+`"}`), http.StatusOK, &result) + if result == nil || result.Error == nil || result.Error.Code != errs.Busy { + t.Fatalf("wrong result: %+v", result) + } + expect(t, h.do("POST", "/api/update/handoff/receive", `{"id":"`+string(uiHandoffID)+`"}`), http.StatusOK, &received) + if received["received"] != false { + t.Fatalf("redelivered: %v", received) + } + if update.TakeHandoff(h.s.root, other, time.Now()) == nil { + t.Fatal("delivery consumed another handoff") + } +} + +func TestUpdateHandoffClaimAuthAndExpiry(t *testing.T) { + h := handoffHarness(t) + path := "/api/update/handoff/take" + body := `{"id":"` + string(uiHandoffID) + `"}` + expectError(t, h.do("POST", path, body, noCookie), http.StatusUnauthorized, "unauthorized") + expectError(t, h.do("POST", path, body, withHeader("Origin", "http://elsewhere.invalid")), http.StatusForbidden, "forbidden") + expectError(t, h.do("POST", path, `{"id":"../bad"}`), http.StatusBadRequest, errs.Usage) + h.s.upd.mu.Lock() + h.s.upd.handoff.At = time.Now().Add(-update.HandoffFresh - time.Second).Format(time.RFC3339) + h.s.upd.mu.Unlock() + var result *update.HandoffResult + expect(t, h.do("POST", path, body), http.StatusOK, &result) + if result != nil { + t.Fatalf("expired result: %+v", result) + } +} + +func TestLegacyHandoffIsIgnored(t *testing.T) { + h := newHarness(t, func(o *Options) { + if err := os.MkdirAll(filepath.Join(o.Root.Dir, ".update"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(o.Root.Dir, ".update", "manager-result.json"), + []byte(`{"at":"`+time.Now().Format(time.RFC3339)+`","ok":true}`), 0o644); err != nil { + t.Fatal(err) + } + }) + if h.s.upd.handoff != nil { + t.Fatal("legacy unowned result became a notification") + } +} diff --git a/internal/ui/web/js/update.js b/internal/ui/web/js/update.js index 6252043..e4885f5 100644 --- a/internal/ui/web/js/update.js +++ b/internal/ui/web/js/update.js @@ -29,13 +29,19 @@ export function onUpdate(v) { update(); } -// What the CLI did after the last 更新 (this manager is the one it started again): told once per page. -let handoffShown = ''; -function showHandoff(x) { - if (!x || handoffShown === x.at) return; - handoffShown = x.at; - if (x.ok) toast({ title: x.updated ? `已更新到 ${ver(x.version)}` : (x.message || '已经是最新版本'), timeout: 9000 }); - else toast({ tone: 'err', title: '新版没有换上', text: (x.error && x.error.message) || x.message, advice: x.error && x.error.advice, timeout: 15000 }); +// The server grants the notification to one page across all manager windows. +const handoffClaims = new Set(); +async function showHandoff(x) { + if (!x || !x.id || handoffClaims.has(x.id)) return; + handoffClaims.add(x.id); + try { + const claimed = await api.post('/api/update/handoff/take', { id: x.id }); + if (!claimed) return; + if (claimed.ok) toast({ title: claimed.updated ? `已更新到 ${ver(claimed.version)}` : (claimed.message || '已经是最新版本'), timeout: 9000 }); + else toast({ tone: 'err', title: '新版没有换上', text: (claimed.error && claimed.error.message) || claimed.message, advice: claimed.error && claimed.error.advice, timeout: 15000 }); + } catch { + handoffClaims.delete(x.id); + } } // 更新 may be clicked: this manager can come back after the swap, the root can be updated, a check found a newer diff --git a/internal/update/handoff.go b/internal/update/handoff.go index 5b3f839..368ec04 100644 --- a/internal/update/handoff.go +++ b/internal/update/handoff.go @@ -5,10 +5,13 @@ package update // // tools\modavd.exe update install --after-pid --reopen [--reopen-arg A ...] // -// and exits. The CLI waits for it to be gone, installs, writes what happened to .update\manager-result.json and -// starts the manager again; the manager takes the file on start and shows the result. +// and exits. Each hand-off has its own ID and result under .update\manager-results\. The CLI passes that ID to +// the manager it starts again; only that launch takes the result. import ( + "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "os" @@ -20,15 +23,80 @@ import ( ) const ( - handoffName = "manager-result.json" + handoffDir = "manager-results" + // HandoffArg is the manager's launch option for the result of one update. + HandoffArg = "--update-handoff" // HandoffFresh: a result older than this is not shown (a manager started long after a failed hand-off). HandoffFresh = 30 * time.Minute // AfterPIDWait is how long the CLI waits for the manager it is to replace to exit. AfterPIDWait = time.Minute ) +// HandoffID identifies one update hand-off, independently of its version or timestamp. +type HandoffID string + +func NewHandoffID() (HandoffID, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return HandoffID(hex.EncodeToString(b)), nil +} + +// ParseHandoffID accepts only the generated form, which is also safe as a filename. +func ParseHandoffID(s string) (HandoffID, error) { + if len(s) == 32 { + valid := true + for _, c := range s { + valid = valid && (c >= '0' && c <= '9' || c >= 'a' && c <= 'f') + } + if valid { + return HandoffID(s), nil + } + } + return "", errs.New(errs.Usage, "更新交接 ID 必须是 32 位小写十六进制字符") +} + +// PrepareHandoff prepares a manager update. If the source is unavailable, it can reuse an earlier stage, but +// only after checking its fingerprint under the update lock and comparing it to the installed version. +func (u *Updater) PrepareHandoff(ctx context.Context) (*Status, error) { + st, err := u.Prepare(ctx) + if errs.CodeOf(err) != errs.UpdateUnavailable { + return st, err + } + if ctx.Err() != nil { + return st, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + } + lock, lerr := u.begin(st) + if lerr != nil { + return st, lerr + } + defer lock.Unlock() + out := u.finish(st) + if !out.Updatable || out.Phase != phaseStaged { + return out, err + } + if verr := u.l.checkStaged(u.l.readState()); verr != nil { + return u.finish(st), verr + } + staged, haveStage := ParseVersion(out.PreparedVersion) + local, haveLocal := ParseVersion(out.CurrentVersion) + if !haveStage || !haveLocal || !local.Less(staged) { + return out, err + } + if ctx.Err() != nil { + return out, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + } + note := "查不了最新版本,使用已下载并核对好的 " + staged.Display() + out.CheckError, out.Message = errs.As(err).Message, note + out.Notices = append(out.Notices, note) + u.Log.Warnf("%s", note) + return out, nil +} + // HandoffResult is what the CLI tells the manager it starts again. type HandoffResult struct { + ID HandoffID `json:"id"` At string `json:"at"` // RFC 3339 OK bool `json:"ok"` Updated bool `json:"updated"` // a new version was swapped in @@ -44,11 +112,13 @@ type HandoffError struct { Advice string `json:"advice"` } -func handoffPath(r *root.Root) string { return filepath.Join(layout{root: r.Dir}.dir(), handoffName) } +func handoffPath(r *root.Root, id HandoffID) string { + return filepath.Join(layout{root: r.Dir}.dir(), handoffDir, string(id)+".json") +} // NewHandoffResult is the result of an install: st as Install returned it, err its error. -func NewHandoffResult(st *Status, err error, now time.Time) *HandoffResult { - res := &HandoffResult{At: now.Format(time.RFC3339), OK: err == nil} +func NewHandoffResult(id HandoffID, st *Status, err error, now time.Time) *HandoffResult { + res := &HandoffResult{ID: id, At: now.Format(time.RFC3339), OK: err == nil} if st != nil { res.Updated, res.Version, res.Message = st.Updated, st.CurrentVersion, st.Message } @@ -60,17 +130,24 @@ func NewHandoffResult(st *Status, err error, now time.Time) *HandoffResult { return res } -// SaveHandoff writes res for the manager the CLI starts next (atomically, replacing an older one). +// SaveHandoff atomically writes this hand-off's result without replacing another hand-off's file. func SaveHandoff(r *root.Root, res *HandoffResult) error { + if res == nil { + return errs.New(errs.Usage, "没有更新交接结果") + } + if _, err := ParseHandoffID(string(res.ID)); err != nil { + return err + } data, err := json.MarshalIndent(res, "", " ") if err != nil { return err } - path := handoffPath(r) + path := handoffPath(r, res.ID) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".tmp" + defer os.Remove(tmp) if err := writeFileSync(tmp, data); err != nil { return err } @@ -94,22 +171,38 @@ func (u *Updater) ProgramsInUse() ([]string, error) { return out, nil } -// TakeHandoff reads and removes the CLI's result; nil when there is none, it cannot be read, or it is older than -// HandoffFresh (then it is removed too). -func TakeHandoff(r *root.Root, now time.Time) *HandoffResult { - path := handoffPath(r) - data, err := os.ReadFile(path) +// TakeHandoff claims only id's result, once. Missing IDs never read a result. A stale, broken or mismatched owned +// result is discarded. An exclusive claim file prevents simultaneous consumers from taking the same result. +func TakeHandoff(r *root.Root, id HandoffID, now time.Time) *HandoffResult { + if _, err := ParseHandoffID(string(id)); err != nil { + return nil + } + path := handoffPath(r, id) + claimPath := path + ".claim" + claim, err := os.OpenFile(claimPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) if err != nil { return nil } - _ = os.Remove(path) + claim.Close() + defer os.Remove(claimPath) + data, err := root.ReadFileShared(path) + if err != nil { + return nil + } + if err := os.Remove(path); err != nil { + return nil + } var res HandoffResult - if json.Unmarshal(data, &res) != nil { + if json.Unmarshal(data, &res) != nil || res.ID != id { return nil } - at, err := time.Parse(time.RFC3339, res.At) - if err != nil || now.Sub(at) > HandoffFresh || at.Sub(now) > time.Minute { + if !res.Fresh(now) { return nil } return &res } + +func (res *HandoffResult) Fresh(now time.Time) bool { + at, err := time.Parse(time.RFC3339, res.At) + return err == nil && now.Sub(at) <= HandoffFresh && at.Sub(now) <= time.Minute +} diff --git a/internal/update/handoff_offline_test.go b/internal/update/handoff_offline_test.go new file mode 100644 index 0000000..a8ea8b9 --- /dev/null +++ b/internal/update/handoff_offline_test.go @@ -0,0 +1,127 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "modavd/internal/errs" +) + +func TestPrepareHandoffOffline(t *testing.T) { + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, nil) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, nil), false)) + u := f.updater(r) + if _, err := u.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + f.srv.Close() + st, err := u.PrepareHandoff(context.Background()) + if err != nil || st.PreparedVersion != "v0.37.0" { + t.Fatalf("valid newer stage must remain usable offline: %+v, %v", st, err) + } + if st.CurrentVersion != "v0.36.0" || st.CheckError == "" || len(st.Notices) != 1 || st.Updated { + t.Fatalf("offline preparation lost its status: %+v", st) + } + // Retry uses the same verified stage; install still rechecks it and actually swaps the package offline. + if _, err := u.PrepareHandoff(context.Background()); err != nil { + t.Fatal(err) + } + st, err = u.Install(context.Background()) + if err != nil || !st.Updated || st.CurrentVersion != "v0.37.0" || st.Phase != phaseIdle { + t.Fatalf("offline install: %+v, %v", st, err) + } +} + +func TestPrepareHandoffOfflineRefusals(t *testing.T) { + for _, tc := range []struct { + name string + edit func(t *testing.T, u *Updater) + code string + }{ + {"no stage", func(t *testing.T, u *Updater) { + if err := u.l.abortStaging("test"); err != nil { + t.Fatal(err) + } + }, errs.UpdateUnavailable}, + {"changed file", func(t *testing.T, u *Updater) { + if err := os.WriteFile(filepath.Join(u.l.stageEmulator(), "emulator.exe"), []byte("changed"), 0o644); err != nil { + t.Fatal(err) + } + }, errs.UpdateRejected}, + {"missing marker", func(t *testing.T, u *Updater) { + if err := os.Remove(u.l.stagedMarker()); err != nil { + t.Fatal(err) + } + }, errs.UpdateRejected}, + {"equal version", func(t *testing.T, u *Updater) { setInstalledHandoffVersion(t, u, "v0.37.0") }, errs.UpdateUnavailable}, + {"older stage", func(t *testing.T, u *Updater) { setInstalledHandoffVersion(t, u, "v0.38.0") }, errs.UpdateUnavailable}, + {"unknown installed version", func(t *testing.T, u *Updater) { + if err := os.Remove(filepath.Join(u.Root.Dir, manifestName)); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(u.l.emulator(), "source.properties"), []byte("Pkg.BuildId=mas-999\n"), 0o644); err != nil { + t.Fatal(err) + } + }, errs.UpdateUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + u := newSources(t, 37, zipOf(t, 37, pkgFiles(37, nil), false)).updater(writeRoot(t, 36, nil)) + if _, err := u.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + tc.edit(t, u) + u.githubAPI += "/offline" // local fixture answers 404; no real source is contacted + st, err := u.PrepareHandoff(context.Background()) + if errs.CodeOf(err) != tc.code || st.Updated { + t.Fatalf("status %+v, error %v, want %s", st, err, tc.code) + } + if tc.code == errs.UpdateRejected && st.Phase != phaseIdle { + t.Fatalf("bad stage was retained: %+v", st) + } + }) + } +} + +func setInstalledHandoffVersion(t *testing.T, u *Updater, version string) { + t.Helper() + data := strings.ReplaceAll(string(manifestFor(36, pkgFiles(36, nil))), "v0.36.0", version) + if err := os.WriteFile(filepath.Join(u.Root.Dir, manifestName), []byte(data), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestPrepareHandoffKeepsCancellationAndOtherErrors(t *testing.T) { + withSettings(t, SourceGitHub, "") + u := newSources(t, 37, zipOf(t, 37, pkgFiles(37, nil), false)).updater(writeRoot(t, 36, nil)) + if _, err := u.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := u.PrepareHandoff(ctx); errs.CodeOf(err) != errs.Canceled { + t.Fatalf("canceled: %v", err) + } + u.Opt.AutoMASVersion = "1.0.0" + if _, err := u.PrepareHandoff(context.Background()); errs.CodeOf(err) != errs.NotReady { + t.Fatalf("version requirement: %v", err) + } + u.Opt.AutoMASVersion = "" + lock, err := u.lock() + if err != nil { + t.Fatal(err) + } + defer lock.Unlock() + if _, err := u.PrepareHandoff(context.Background()); errs.CodeOf(err) != errs.Busy { + t.Fatalf("update lock: %v", err) + } +} diff --git a/internal/update/handoff_test.go b/internal/update/handoff_test.go index e38e6d9..2d304b2 100644 --- a/internal/update/handoff_test.go +++ b/internal/update/handoff_test.go @@ -1,7 +1,11 @@ package update import ( + "encoding/json" "os" + "path/filepath" + "sync" + "sync/atomic" "testing" "time" @@ -9,35 +13,96 @@ import ( "modavd/internal/root" ) +const testHandoffID HandoffID = "0123456789abcdef0123456789abcdef" + func TestHandoffRoundTrip(t *testing.T) { r := &root.Root{Dir: t.TempDir()} now := time.Date(2026, 11, 9, 20, 0, 0, 0, time.Local) - if TakeHandoff(r, now) != nil { + if TakeHandoff(r, testHandoffID, now) != nil { t.Fatal("a result without a file") } st := &Status{Updated: true, CurrentVersion: "v0.40.0", Message: "已更新到 0.40.0"} - if err := SaveHandoff(r, NewHandoffResult(st, nil, now)); err != nil { + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, st, nil, now)); err != nil { t.Fatal(err) } - got := TakeHandoff(r, now.Add(time.Minute)) + got := TakeHandoff(r, testHandoffID, now.Add(time.Minute)) if got == nil || !got.OK || !got.Updated || got.Version != "v0.40.0" || got.Message != "已更新到 0.40.0" || got.Error != nil { t.Fatalf("%+v", got) } - if _, err := os.Stat(handoffPath(r)); !os.IsNotExist(err) { + if _, err := os.Stat(handoffPath(r, testHandoffID)); !os.IsNotExist(err) { t.Errorf("the file is still there: %v", err) } err := errs.New(errs.Busy, "现在不能换上").WithAdvice("先关实例") - if err := SaveHandoff(r, NewHandoffResult(nil, err, now)); err != nil { + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, nil, err, now)); err != nil { t.Fatal(err) } - got = TakeHandoff(r, now) + got = TakeHandoff(r, testHandoffID, now) if got == nil || got.OK || got.Updated || got.Message != "现在不能换上" || got.Error == nil || got.Error.Code != errs.Busy || got.Error.Advice != "先关实例" { t.Fatalf("%+v", got) } } +func TestHandoffOwnershipAndConcurrentTake(t *testing.T) { + r := &root.Root{Dir: t.TempDir()} + now := time.Now() + other, err := NewHandoffID() + if err != nil || other == testHandoffID { + t.Fatalf("new ID %q: %v", other, err) + } + for _, id := range []HandoffID{testHandoffID, other} { + if err := SaveHandoff(r, NewHandoffResult(id, nil, nil, now)); err != nil { + t.Fatal(err) + } + } + if TakeHandoff(r, "", now) != nil || TakeHandoff(r, HandoffID("../bad"), now) != nil { + t.Fatal("missing or invalid ID claimed a result") + } + var count atomic.Int32 + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + if res := TakeHandoff(r, testHandoffID, now); res != nil { + count.Add(1) + } + }) + } + wg.Wait() + if count.Load() != 1 { + t.Fatalf("concurrent consumers received %d results", count.Load()) + } + if got := TakeHandoff(r, other, now); got == nil || got.ID != other { + t.Fatalf("another handoff was consumed: %+v", got) + } + files, err := os.ReadDir(filepath.Dir(handoffPath(r, testHandoffID))) + if err != nil || len(files) != 0 { + t.Fatalf("result leftovers: %v, %v", files, err) + } +} + +func TestHandoffRejectsWrongPayloadAndInvalidIDs(t *testing.T) { + r := &root.Root{Dir: t.TempDir()} + for _, id := range []HandoffID{"", "../bad", "0123456789ABCDEF0123456789ABCDEF"} { + if err := SaveHandoff(r, NewHandoffResult(id, nil, nil, time.Now())); errs.CodeOf(err) != errs.Usage { + t.Fatalf("saved invalid ID %q: %v", id, err) + } + } + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, nil, nil, time.Now())); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(NewHandoffResult("", nil, nil, time.Now())) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(handoffPath(r, testHandoffID), data, 0o644); err != nil { + t.Fatal(err) + } + if TakeHandoff(r, testHandoffID, time.Now()) != nil { + t.Fatal("accepted a payload without the matching ID") + } +} + // Only the root's programs count (not the emulators the manager is about to stop), and never a shutdown helper. func TestProgramsInUse(t *testing.T) { u := New(&root.Root{Dir: t.TempDir()}, nil, Options{}) @@ -64,29 +129,29 @@ func TestHandoffStaleOrBroken(t *testing.T) { r := &root.Root{Dir: t.TempDir()} now := time.Date(2026, 11, 9, 20, 0, 0, 0, time.Local) for _, at := range []time.Time{now.Add(-HandoffFresh - time.Second), now.Add(2 * time.Minute)} { - if err := SaveHandoff(r, NewHandoffResult(&Status{}, nil, at)); err != nil { + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, &Status{}, nil, at)); err != nil { t.Fatal(err) } - if got := TakeHandoff(r, now); got != nil { + if got := TakeHandoff(r, testHandoffID, now); got != nil { t.Errorf("at %v: %+v", at, got) } - if _, err := os.Stat(handoffPath(r)); !os.IsNotExist(err) { + if _, err := os.Stat(handoffPath(r, testHandoffID)); !os.IsNotExist(err) { t.Errorf("at %v: not removed", at) } } - if err := SaveHandoff(r, NewHandoffResult(&Status{}, nil, now.Add(-HandoffFresh+time.Second))); err != nil { + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, &Status{}, nil, now.Add(-HandoffFresh+time.Second))); err != nil { t.Fatal(err) } - if TakeHandoff(r, now) == nil { + if TakeHandoff(r, testHandoffID, now) == nil { t.Error("a result just inside HandoffFresh was dropped") } - if err := os.WriteFile(handoffPath(r), []byte("{"), 0o644); err != nil { + if err := os.WriteFile(handoffPath(r, testHandoffID), []byte("{"), 0o644); err != nil { t.Fatal(err) } - if TakeHandoff(r, now) != nil { + if TakeHandoff(r, testHandoffID, now) != nil { t.Error("a broken file gave a result") } - if _, err := os.Stat(handoffPath(r)); !os.IsNotExist(err) { + if _, err := os.Stat(handoffPath(r, testHandoffID)); !os.IsNotExist(err) { t.Error("a broken file was not removed") } } diff --git a/internal/update/handoff_windows_test.go b/internal/update/handoff_windows_test.go new file mode 100644 index 0000000..d8bc5b0 --- /dev/null +++ b/internal/update/handoff_windows_test.go @@ -0,0 +1,36 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "os" + "testing" + "time" + + "modavd/internal/root" +) + +func TestHandoffLockedResultCanBeRetried(t *testing.T) { + r := &root.Root{Dir: t.TempDir()} + if err := SaveHandoff(r, NewHandoffResult(testHandoffID, nil, nil, time.Now())); err != nil { + t.Fatal(err) + } + // os.Open does not share DELETE on Windows. A result that cannot be removed must not be reported yet. + f, err := os.Open(handoffPath(r, testHandoffID)) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if res := TakeHandoff(r, testHandoffID, time.Now()); res != nil { + t.Fatalf("reported an unremoved result: %+v", res) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if res := TakeHandoff(r, testHandoffID, time.Now()); res == nil { + t.Fatal("could not retry the result after unlocking it") + } +} diff --git a/scripts/test_update_handoff.mjs b/scripts/test_update_handoff.mjs new file mode 100644 index 0000000..31097de --- /dev/null +++ b/scripts/test_update_handoff.mjs @@ -0,0 +1,92 @@ +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later +// Run: node --experimental-vm-modules --test scripts/test_update_handoff.mjs +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import vm from 'node:vm'; + +const source = await readFile(new URL('../internal/ui/web/js/update.js', import.meta.url), 'utf8'); +const settle = () => new Promise(resolve => setImmediate(resolve)); + +async function page(post) { + const notices = []; + const noop = () => {}; + const stubs = { + './dom.js': Object.fromEntries(['h', 'icon', 'btn', 'attr', 'setText', 'showError'].map(k => [k, noop])), + './store.js': { S: {}, update: noop, subscribe: noop }, + './api.js': { api: { post } }, + './ui.js': { openDialog: noop, toast: value => notices.push(value), toastError: noop }, + './diagnostics.js': { openDiagnostics: noop, activeJob: noop, progressText: noop }, + './actions.js': { onJob: noop, notifyJob: noop }, + }; + const context = vm.createContext({}); + const module = new vm.SourceTextModule(source, { context }); + await module.link(specifier => { + const values = stubs[specifier]; + return new vm.SyntheticModule(Object.keys(values), function() { + for (const [key, value] of Object.entries(values)) this.setExport(key, value); + }, { context }); + }); + await module.evaluate(); + return { update: value => module.namespace.onUpdate({ handoff: value }), notices }; +} + +test('two windows claim once, then repeated events and reloads stay silent', async () => { + const result = { id: 'a'.repeat(32), at: 'same-second', ok: true, updated: true, version: 'v0.40.0' }; + let pending = result; + let calls = 0; + const post = async (path, body) => { + assert.equal(path, '/api/update/handoff/take'); + assert.equal(body.id, result.id); + calls++; + const claimed = pending; + pending = null; + return claimed; + }; + const first = await page(post); + const second = await page(post); + first.update(result); + first.update(result); + second.update(result); + await settle(); + assert.equal(first.notices.length + second.notices.length, 1); + assert.equal(first.notices[0].title, '已更新到 0.40.0'); + assert.equal(calls, 2); + first.update(result); + const reloaded = await page(post); + reloaded.update(result); + await settle(); + assert.equal(reloaded.notices.length, 0); +}); + +test('different IDs in the same second show their own success and error', async () => { + const results = [ + { id: 'a'.repeat(32), at: 'same-second', ok: true, message: 'already current' }, + { id: 'b'.repeat(32), at: 'same-second', ok: false, error: { message: 'busy', advice: 'close manager' } }, + ]; + const p = await page(async (_path, body) => results.find(x => x.id === body.id)); + for (const result of results) p.update(result); + await settle(); + assert.equal(p.notices.length, 2); + assert.equal(p.notices[0].title, 'already current'); + assert.equal(p.notices[1].text, 'busy'); + assert.equal(p.notices[1].advice, 'close manager'); +}); + +test('a failed request retries on the next update without showing unclaimed data', async () => { + const result = { id: 'a'.repeat(32), ok: true, message: 'done' }; + let calls = 0; + const p = await page(async () => { + if (++calls === 1) throw new Error('disconnected'); + return result; + }); + p.update({ ok: true }); // legacy payload has no owner + p.update(result); + await settle(); + assert.equal(p.notices.length, 0); + p.update(result); + await settle(); + assert.equal(calls, 2); + assert.equal(p.notices.length, 1); +});