Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions cmd/modavd-gui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
"modavd/internal/logx"
"modavd/internal/root"
"modavd/internal/ui"
"modavd/internal/update"
)

func main() {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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()

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
55 changes: 55 additions & 0 deletions cmd/modavd-gui/update_handoff.go
Original file line number Diff line number Diff line change
@@ -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
}
73 changes: 73 additions & 0 deletions cmd/modavd-gui/update_handoff_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
36 changes: 27 additions & 9 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;" +
Expand Down Expand Up @@ -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 是这次生效的。" +
Expand All @@ -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;" +
Expand All @@ -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),
},
{
Expand Down Expand Up @@ -1498,17 +1499,32 @@ 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)
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)
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")}
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 {
Expand All @@ -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("已重新打开管理器")
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/update_caller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading