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
3 changes: 2 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <manager pid> --reopen <manager executable> [--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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 时不装。
Expand Down
23 changes: 23 additions & 0 deletions cmd/modavd-gui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"os/signal"
"runtime"
"runtime/debug"
"strconv"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down Expand Up @@ -93,6 +94,7 @@ import (
"io"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"

Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
},
}
}

Expand Down
57 changes: 54 additions & 3 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "",
Expand Down Expand Up @@ -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),
},
{
Expand Down Expand Up @@ -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
Expand All @@ -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 != "" {
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"modavd/internal/logx"
"modavd/internal/mcp"
"modavd/internal/root"
"modavd/internal/update"
"modavd/internal/win"
)

Expand Down Expand Up @@ -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 {
Expand All @@ -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, "没有根目录") }
Expand Down
Loading
Loading