diff --git a/README.en.md b/README.en.md index c673656..8656feb 100644 --- a/README.en.md +++ b/README.en.md @@ -66,7 +66,7 @@ modavd [--root ] [--json] [--quiet] [--verbose] [args] | `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb auto\|N] [--diagnostics on\|off] [--boss-key combination\|none]` | Change settings. Diagnostics and the boss key apply immediately; other settings take effect at the next start. `--data-gb auto` makes the data disk automatic, `--data-gb N` gives it a fixed size (see below); the size can only be increased, and game data is untouched: the emulator (from 0.36) grows the disk image at start and grows the file system online after boot. A disk created before 0.36 at 16 GB or smaller can only be grown to just below the next backup group (a 16 GB disk to about 30 GB). `--boss-key` is this instance's boss key (see the ModAVD.exe manager section), for example `Ctrl+Alt+Shift+1`; `none` means no boss key; Windows only. | | `proxy [on\|off\|test] [--server URL\|auto] [--add PKG] [--remove PKG] [--apps PKG]` | Per-app proxy: the listed apps go through a proxy, the others direct (see below) | | `delete --yes` | Delete a stopped instance, together with its game data | -| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | Start (headless by default, with the window for `--window`), wait until boot finishes and the post-boot setup is done, and before returning confirm that files can be written to `/sdcard` (normally it is ready long before this; it waits up to 60 more seconds and only warns at the deadline). If the instance is shutting down (for example, right after a click on the emulator window's ×), it first waits for the shutdown to finish (up to 30 seconds) and then starts it. If the instance has no execution bridge yet, install it afterward; `--no-bridge` skips that this time. No switch while it runs: if the instance is already running and `--window` / `--headless` asks for the other way, it fails (exit code 5) without taking it over or restarting it; to switch, `stop` and then `start`. With neither, it is taken over as it runs | +| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | Start (headless by default, with the window for `--window`), wait until boot finishes and the post-boot setup is done, and before returning confirm that files can be written to `/sdcard` (normally it is ready long before this; it waits up to 60 more seconds and only warns at the deadline). If the instance is shutting down (for example, right after a click on the emulator window's ×), it first waits for the shutdown to finish (up to 30 seconds) and then starts it. If the instance has no execution bridge yet, install it afterward; `--no-bridge` skips that this time. The install takes a cold boot or two: with `--window`, the boots that only prepare it are headless, and after a normal shutdown the last boot has the window (when the instance has no boot marker yet, so the bridge is surely to be installed, the first boot is headless too; with the marker there, whether the bridge is installed is only known once it runs, so the first boot has the window). No switch while it runs: if the instance is already running and `--window` / `--headless` asks for the other way, it fails (exit code 5) without taking it over or restarting it; to switch, `stop` and then `start`. With neither, it is taken over as it runs | | `stop [--force] [--cancel-create]`, `stop --all [--force]` | Shut down: an app in the foreground is first sent home and given 2 s to save; then sync and ask Android for a clean shutdown (the foreground app is paused and stopped first), and if that fails, sync + kill; `--force` is sync + force-kill. Creating instances are refused unless `--cancel-create` is given. `--all` also stops creating instances and marks their results with `interrupted_create: true` | | `bridge install\|uninstall\|verify` | Install, uninstall or check the arm64 execution bridge; it is installed automatically at start, so you normally do not run it by hand (see below) | | `playstore install\|uninstall\|status` | Add the Google Play Store to an instance, remove it, or check it (see below) | @@ -100,7 +100,7 @@ Apple Silicon macOS uses the SDK's native `qemu-img`. Host usage counts allocate - Every time modavd (the CLI, MCP) and the manager start an instance, they check: if the instance has no bridge yet, they install it after the post-boot setup is done, with one or two shutdowns and cold boots in between; if it already has the bridge but the bridge's files differ from the ones in this package (the ModAVD package was updated), they replace them the same way after the setup, followed by one cold boot. This happens only when they started the emulator this time; when taking over an instance that is already running, nothing is touched. Because the update takes a cold boot, it is put off to a later start when, once the post-boot setup is done, an app is already in the foreground or an APK is being installed. If installing or updating fails it is only a warning, the instance keeps running, and it is tried again at the next start. Instances that AUTO-MAS starts by itself do not go through this step: to get the new files, shut the instance down, then start it once with ModAVD.exe or `modavd start`, or run `bridge install`. - Before moving a root, cold-start each existing bridge instance once at the original location with the updated manager or `modavd start`, then shut it down. Move the whole root, including each instance’s `modavd-bridge-backings.json`. If the old path is missing at the next boot, the manager verifies the size and SHA-256 of the new SDK’s system and vendor images before reconnecting them. If you already moved an older instance without this record, keep its overlays, restore the root and original images at their old location, then repeat the move. - To keep an instance without the bridge, pass `--no-bridge` on create, or run `bridge uninstall` later. Both record `instances..bridge` as `false` in `mas-avd.json`, so later starts no longer install it automatically; `bridge install` again brings it back. The `--no-bridge` of `start` applies to that one run only. -- `bridge` calls `tools\modavd-bridge.ps1` in the ModAVD package. `install`: shuts down and starts again if needed, and installs; if it is already installed, it updates only when the payload has changed. `uninstall`: removes the bridge, the boot marker, and the GL transport mode (asg) that was set when the bridge was installed; uninstall before going back to an earlier version. If the instance was off before, it is shut down again afterward; if it was running, its cold boots bring it back the way it ran, with its window or headless. `verify`: a read-only check; the instance must be running. +- `bridge` calls `tools\modavd-bridge.ps1` in the ModAVD package. `install`: shuts down and starts again if needed, and installs; if it is already installed, it updates only when the payload has changed. `uninstall`: removes the bridge, the boot marker, and the GL transport mode (asg) that was set when the bridge was installed; uninstall before going back to an earlier version. If the instance was off before, it is shut down again afterward; if it was running, its last cold boot brings it back the way it ran, with its window or headless; the boots in between that only prepare (those before the bridge is installed, and the uninstall's first cold boot when another follows) are headless. `verify`: a read-only check; the instance must be running. - The `bridge` field of `info` / `list` / `status` is `off` (marked as not wanting the bridge), `pending` (will be installed at the next start that modavd launches), `installed` (the bridge is present in the running guest) or `unknown`. It only queries a guest that has finished booting. For an instance that is off or still booting, the boot marker stays once the bridge is installed and is also there when installation failed, so when first-boot initialization has been done and the marker is present, the value is `unknown`; to know the exact result, check after the instance has started. - While the bridge is being installed (including the shutdowns and cold boots in it), `busy` is `bridging`; during this time `delete` and another `bridge` return instance busy (exit code 5). Callers should treat any `busy` value they do not recognize as busy. - An instance that was not started through modavd or the manager (another program launches the emulator directly) does not get the bridge automatically; to install it, run `bridge install` once. @@ -170,7 +170,9 @@ On Apple Silicon macOS, downloads and imports use Android 15 Google Play `arm64- **Conventions** (the same as AUTO-MAS): -- adb only uses the root folder's private server (`adbServerPort` in `mas-avd.json`, default 20050) and never touches 5037. +- modavd's own adb commands only use the root folder's private server (`adbServerPort` in `mas-avd.json`, default 20050). + - While an instance runs, its boot guardian looks at the default adb server (5037) every 30 seconds. When something runs that server and it does not know the instance, the guardian registers the instance there, where it shows as `emulator-`. Tools that find devices with `adb devices` (MAA's auto-detection, for example) then see ModAVD's instances too (ModAVD/ModAVD#196). + - Nothing happens while no server runs on 5037: the guardian never starts one. To turn the registration off, write `"shareDefaultAdbServer": false` in `mas-avd.json`. - Ports: the console port of instance i is 20000 + 10 × i, adb is 1 more and gRPC 2 more; the index is 0–9 without 5. - A root folder for development or testing can move to its own port range with `"portBase": 21000` in `mas-avd.json` (a key of modavd's own; AUTO-MAS does not know it). Its instances then do not fight over ports with the instances of the same index in the root folder in daily use on the same computer (for example the one AUTO-MAS manages), and are not taken for them. - `portBase` must be a multiple of 100 between 1100 and 49000, excluding 5500. Other adb servers scan the 5500 range automatically; ranges at 49100 and above overlap Windows' default ephemeral ports. Existing configurations in those ranges still support status and shutdown, but block new starts. Shut down all instances before changing the range. The default remains 20000. With another range, both `adbServerPort` and `scriptAdbServerPort` must be set, and neither can be 20049 or 20050 or fall on an instance port of the 20000 range. @@ -279,7 +281,7 @@ Double-click `ModAVD.exe` in the package root: it starts a local server that lis - Several instances on one key is not a conflict: a press hides them all when one of them shows its window, and shows them all when all are hidden. It looks at whether the windows show now, so windows hidden by AUTO-MAS or `modavd window` come back too. - Only instances running with their window are affected; headless ones have no window. Hidden windows keep rendering and the screenshot channel keeps working, they have no taskbar button, and they come back at the same position and size. - The manager registers the keys, so they work while the manager runs (left in the tray with its window closed counts). A key is registered only while an instance using it runs with its window, and released when none does. When another program holds a key, the settings page shows a warning icon next to it, saying on hover that another program holds the key and to pick another one; no dialog pops up. -- Tray (Windows only): the manager puts an icon in the notification area, and closing the window leaves the manager there. A left click on the icon brings every hidden window back; the right-click menu has "打开管理器" (open the manager) and "退出" (exit). Exiting shows the hidden windows first and waits for running tasks to finish. +- Tray (Windows only): closing the window leaves the manager in the tray. A single left click restores hidden instance windows after the system double-click interval. A double click hides or shows all windows on the manager's Edge profile, preserving page state, or opens one if none exists. The right-click menu's "打开管理器" (open the manager) restores existing windows first. "退出" (exit) waits for running tasks to finish. Exiting also restores hidden manager and instance windows. With the default browser, a double click only opens the manager. - Security: each launch uses a random token, which is exchanged for an HttpOnly cookie (its name includes the port, so managers for two root folders running at the same time do not overwrite each other); it validates Host (against DNS rebinding) and Origin; strict CSP. - One root folder gets one server; double-clicking again just reopens the window. The state file is in `%LOCALAPPDATA%\ModAVD\`, and the log is at `\logs\modavd-ui.log`. - After the window is closed: on Windows it stays in the tray (see above); on macOS it waits for the running tasks to finish and exits. The latter also applies when the tray icon cannot be put up. diff --git a/README.md b/README.md index b8c248b..cf85785 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ modavd [--root <目录>] [--json] [--quiet] [--verbose] <命令> [参数] | `set [--title T] [--memory M] [--cpu N] [--fps F] [--resolution 1080\|720] [--data-gb auto\|N] [--diagnostics on\|off] [--boss-key 组合\|none]` | 修改;诊断模式和老板键立即生效,其余设置下次开机生效。`--data-gb auto` 改成自动扩盘,`--data-gb N` 改成指定大小(见下);大小只能改大,游戏数据不动:模拟器(0.36 起)开机时扩磁盘镜像,开机后在线扩文件系统。0.36 之前按 16 GB 及以下建的盘只能扩到下一个备份组之前(16 GB 约到 30 GB)。`--boss-key` 是这台的老板键(见「管理器 ModAVD.exe」一节),比如 `Ctrl+Alt+Shift+1`,`none` 是不要,只有 Windows。 | | `proxy [on\|off\|test] [--server URL\|auto] [--add 包名] [--remove 包名] [--apps 包名]` | 按应用走代理:名单里的应用走代理,其余直连(见下) | | `delete --yes` | 删除已关机的实例,连同游戏数据 | -| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 | +| `start [--window \| --headless] [--timeout 300] [--no-bridge]` | 开机(默认无头,`--window` 带窗口),等开完并做完开机后的设置,返回前确认 `/sdcard` 能写进文件(一般这时早已就绪;最多再等 60 秒,到点只警告);实例正在关机时(比如刚点了模拟器窗口的 ×)先等它关完(最多 30 秒)再开机;实例还没有执行桥时接着装上,`--no-bridge` 这次不装;装桥要冷启动一两次,带 `--window` 时这几次准备用的开机都无头,装好后正常关机,最后一次才带窗口开机(实例还没有开机标记、一定要装桥时,第一次开机就无头;有标记的要开起来才知道装没装,第一次照样带窗口)。运行中不换方式:实例已经在运行时,给了 `--window` / `--headless` 而它不是这种方式,就报错(退出码 5),不接管也不重启,要换先 `stop` 再 `start`;两个都不给就照原样接管 | | `stop [--force] [--cancel-create]`、`stop --all [--force]` | 关机:前台开着应用时先按 HOME,等 2 秒让它保存;再 sync,让安卓正常关机(前台应用先收到暂停、停止),不成再 sync + kill;`--force` 是 sync + 强杀。新建中默认拒绝,`--cancel-create` 显式取消;`--all` 也关闭新建中的实例,结果标记 `interrupted_create: true` | | `bridge install\|uninstall\|verify` | 装、卸、检查 arm64 执行桥;开机时会自动装,一般不用手动跑(见下) | | `playstore install\|uninstall\|status` | 给实例补全、移除、查看 Google Play 商店(见下) | @@ -106,7 +106,7 @@ Apple Silicon macOS 使用 SDK 中原生的 `qemu-img`。磁盘占用按宿主 - modavd(命令行、MCP)和管理器每次开机都会看一眼:实例还没有桥,就在开机后的设置做完后装上,中间关机再冷启动一两次;已经有桥、但桥的文件和这个包里的不一样(更新过 ModAVD 包),就同样在设置做完后换上新的,再冷启动一次。只在这次是它拉起的模拟器时装或更新;接管已经在运行的实例时不动。更新要冷启动,所以开机后的设置做完时已经有应用在前台、或者正在装 APK,这次就先不更新。装不上、更新不上都只是警告,实例照常运行,下次开机再试。AUTO-MAS 自己开机的实例不经过这一步:要换新文件,先关掉实例,再用 ModAVD.exe 或 `modavd start` 开一次机,或者跑 `bridge install`。 - 搬根目录前,先在原位置用新版管理器或 `modavd start` 给每台已装桥的实例冷启动一次,再关机。把整个根目录一起搬走,包括实例目录里的 `modavd-bridge-backings.json`。下次开机发现旧路径失效时,管理器会用记录的大小和 SHA-256 核对新 SDK 里的 system / vendor 镜像。老实例已经搬走且没有记录时,请保留覆盖层,将原镜像和根目录恢复到原位置,再按上述步骤搬迁。 - 不要桥的实例:新建时带 `--no-bridge`,或者之后跑 `bridge uninstall`。两种都会在 `mas-avd.json` 里记下 `instances..bridge` 为 `false`,以后开机不再自动装;再 `bridge install` 就恢复。`start` 的 `--no-bridge` 只管这一次。 -- `bridge` 调用 ModAVD 包里的 `tools\modavd-bridge.ps1`。`install`:需要时关机再开机,装上;已经装了的只在载荷有变时更新。`uninstall`:卸掉桥、开机标记和装桥时改成的 GL 传输方式(asg);退回更早的版本之前要先卸。实例原来关着的,做完再关掉;开着的,中间冷启动后照原来的方式开回来,带窗口的还带窗口,无头的还无头。`verify`:只读核对,实例要开着。 +- `bridge` 调用 ModAVD 包里的 `tools\modavd-bridge.ps1`。`install`:需要时关机再开机,装上;已经装了的只在载荷有变时更新。`uninstall`:卸掉桥、开机标记和装桥时改成的 GL 传输方式(asg);退回更早的版本之前要先卸。实例原来关着的,做完再关掉;开着的,最后照原来的方式开回来,带窗口的还带窗口,无头的还无头;中间只做准备的开机(装桥时装好之前的几次,卸桥时后面还要再冷启动的那次)都无头。`verify`:只读核对,实例要开着。 - `info` / `list` / `status` 的 `bridge`:`off`(记了不要桥)、`pending`(下次由 modavd 拉起的开机会装)、`installed`(开着的客体里桥在)、`unknown`。只问已经开完机的客体;关着或还在开机的实例,开机标记在桥装好后一直留着,装失败时也在,所以首次开机做过、标记也在时是 `unknown`,要知道确切结果就开机后再查。 - 装桥期间(含其中的关机、冷启动)`busy` 为 `bridging`;这时 `delete`、再次 `bridge` 返回实例忙(退出码 5)。调用方不认识的 `busy` 取值一律当忙处理。 - 不经 modavd 或管理器开机的实例(别的程序直接拉起模拟器)不会自动装,要装就跑一次 `bridge install`。 @@ -176,7 +176,9 @@ Apple Silicon macOS 的下载与导入使用 Android 15 Google Play `arm64-v8a` **约定**(和 AUTO-MAS 一致): -- adb 只走根目录的私有 server(`mas-avd.json` 的 `adbServerPort`,默认 20050),不碰 5037。 +- modavd 自己的 adb 命令只走根目录的私有 server(`mas-avd.json` 的 `adbServerPort`,默认 20050)。 + - 实例开着时,开机守护每 30 秒看一下默认的 adb server(5037)。有人开着它、它又不认识这台实例,就替实例登记过去,那边显示成 `emulator-<控制台端口>`。这样用 `adb devices` 找设备的工具(比如 MAA 的自动检测)也能看到 ModAVD 的实例(ModAVD/ModAVD#196)。 + - 5037 上没有 server 时什么都不做,不替别人起 server。不想登记就在 `mas-avd.json` 里写 `"shareDefaultAdbServer": false`。 - 端口:实例 i 的控制台端口是 20000 + 10 × i,adb 加 1、gRPC 加 2,编号 0–9、不含 5。 - 开发、测试用的根目录可以在 `mas-avd.json` 里写 `"portBase": 21000`,换一段端口(这是 modavd 自己的键,AUTO-MAS 不认)。这样它的实例不会和同一台电脑上日常用的根目录(比如 AUTO-MAS 管的那个)里同编号的实例抢端口,也不会被认成那一台。 - `portBase` 只能是 1100–49000 之间 100 的整数倍,且不能是 5500。5500 段会被别的 adb server 自动扫描;49100 及以上会与 Windows 默认临时端口重叠。已有这些配置仍可查状态和关机,但不能再开机,先关掉全部实例再换段。默认值仍是 20000。设了别的端口段,`adbServerPort`、`scriptAdbServerPort` 都要配上,而且不能是 20049、20050,也不能落在 20000 段的实例端口上。 @@ -281,7 +283,7 @@ Apple Silicon macOS 可运行 `ModAVD` 管理器;`ModAVD.app` 放在包根目 - 几台设成同一个键不算冲突:按下时,这几台里有一台的窗口显示着就全部藏起来,全都藏着就全部显示。看的是窗口现在显不显示,所以 AUTO-MAS、`modavd window` 藏起来的也能叫回来。 - 只管带窗口运行的实例,无头的没有窗口,不受影响。藏着的时候照旧渲染、照旧能截图,任务栏上没有它的按钮;叫回来以后位置、大小和藏之前一样。 - 由管理器注册,所以管理器开着(窗口关了、留在托盘也算)才管用。只在用这个键的实例有带窗口运行着的时候才注册,都关了就注销。被别的程序占了注册不上时,设置页那一项旁边标一个警告图标,悬停说明「这个键被别的程序占了,换一个」,不弹窗。 -- 托盘(只有 Windows):管理器在托盘里放一个图标,关掉窗口后管理器留在托盘。左键点图标把藏着的窗口都叫回来;右键菜单有「打开管理器」「退出」。退出前先把藏着的窗口都显示出来,等正在跑的任务做完再退出。 +- 托盘(只有 Windows):关掉窗口后管理器留在托盘。左键单击图标,等系统双击间隔过去后,把藏着的实例窗口都叫回来。双击切换管理器窗口的隐藏和显示,保留页面状态;同一管理器 Edge 配置的多个窗口一起切换,没有窗口时新开一个。右键「打开管理器」优先显示已有窗口,「退出」等正在跑的任务做完再退出。退出前也会恢复藏着的管理器和实例窗口。用默认浏览器打开时,双击只打开管理器。 - 安全:每次启动随机令牌,换成 HttpOnly cookie(名字带端口,两个根目录的管理器同时开不会互相顶掉);校验 Host(防 DNS 重绑定)和 Origin;严格的 CSP。 - 一个根目录只开一个服务,再双击只是把窗口重新打开;状态文件在 `%LOCALAPPDATA%\ModAVD\`,日志在 `<根>\logs\modavd-ui.log`。 - 关掉窗口后:Windows 上留在托盘(见上);macOS 上等正在跑的任务做完再退出。托盘图标放不成时也按后者。 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/rsrc_windows_amd64.syso b/cmd/modavd-gui/rsrc_windows_amd64.syso index 6c33eef..1f61231 100644 Binary files a/cmd/modavd-gui/rsrc_windows_amd64.syso and b/cmd/modavd-gui/rsrc_windows_amd64.syso differ 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/adb/default_server.go b/internal/adb/default_server.go new file mode 100644 index 0000000..09ff510 --- /dev/null +++ b/internal/adb/default_server.go @@ -0,0 +1,108 @@ +package adb + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "time" +) + +// DefaultServerPort is adb's own default server port, the one other tools use when they run `adb devices` +// (ModAVD/ModAVD#196). Our instances register with the root's private server only; ShareWithDefaultServer also +// registers them here, so that tools which find devices through the default server see them. A variable so that tests +// use a fake server instead: no test may reach the real one. +var DefaultServerPort = 5037 + +// defaultServerTimeout bounds one look at the default server (connect, request, reply). +const defaultServerTimeout = 3 * time.Second + +// ShareWithDefaultServer registers instance console (its adb port is console + 1) with the adb server on +// DefaultServerPort when that server runs and does not list emulator- yet. It never starts that server: the +// tool that starts it gets our instance at the next call (the boot watch calls this every bootWatchShareInterval). +// Returns whether it registered the instance now; nothing listening is not an error. +func ShareWithDefaultServer(ctx context.Context, console int) (bool, error) { + serials, err := serverDevices(ctx, DefaultServerPort, defaultServerTimeout) + if err != nil { + if errors.Is(err, errNoServer) { + return false, nil + } + return false, err + } + serial := "emulator-" + strconv.Itoa(console) + for _, s := range serials { + if s == serial { + return false, nil + } + } + if err := Register(ctx, DefaultServerPort, console+1, defaultServerTimeout); err != nil { + return false, err + } + return true, nil +} + +// errNoServer: nothing accepts connections on the port. +var errNoServer = errors.New("no adb server") + +// serverDevices sends host:devices to the adb server on port and returns the serials it lists (any state). +func serverDevices(ctx context.Context, port int, timeout time.Duration) ([]string, error) { + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + dctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var d net.Dialer + conn, err := d.DialContext(dctx, "tcp", addr) + if err != nil { + if ctx.Err() != nil { + return nil, canceled(ctx) + } + var op *net.OpError + if errors.As(err, &op) && op.Op == "dial" && dctx.Err() == nil { + return nil, errNoServer // refused: nothing listens + } + return nil, fmt.Errorf("连不上 %s: %w", addr, err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(timeout)) + const msg = "host:devices" + if _, err := fmt.Fprintf(conn, "%04x%s", len(msg), msg); err != nil { + return nil, fmt.Errorf("向 %s 发送 %s 失败: %w", addr, msg, err) + } + status := make([]byte, 4) + if _, err := io.ReadFull(conn, status); err != nil { + return nil, fmt.Errorf("%s 没有回 %s: %w", addr, msg, err) + } + body, err := readHexPrefixed(conn) + if err != nil { + return nil, fmt.Errorf("%s 回的 %s 读不全: %w", addr, msg, err) + } + if string(status) != "OKAY" { + return nil, fmt.Errorf("%s 拒绝了 %s: %s %s", addr, msg, status, body) + } + var serials []string + for _, line := range strings.Split(body, "\n") { + if serial, _, ok := strings.Cut(strings.TrimSpace(line), "\t"); ok && serial != "" { + serials = append(serials, serial) + } + } + return serials, nil +} + +// readHexPrefixed reads one adb host-protocol string: 4 hex digits of length, then that many bytes. +func readHexPrefixed(r io.Reader) (string, error) { + head := make([]byte, 4) + if _, err := io.ReadFull(r, head); err != nil { + return "", err + } + n, err := strconv.ParseUint(string(head), 16, 16) + if err != nil { + return "", fmt.Errorf("长度 %q 不是十六进制", head) + } + body := make([]byte, n) + if _, err := io.ReadFull(r, body); err != nil { + return "", err + } + return string(body), nil +} diff --git a/internal/adb/default_server_test.go b/internal/adb/default_server_test.go new file mode 100644 index 0000000..da3bfeb --- /dev/null +++ b/internal/adb/default_server_test.go @@ -0,0 +1,121 @@ +package adb + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "testing" + "time" +) + +// fakeDefaultServer is a stand-in for the default adb server on a free port of 127.0.0.1: it answers host:devices with +// devices and records every other request. DefaultServerPort points at it for the test. +func fakeDefaultServer(t *testing.T, devices string) <-chan string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + old := DefaultServerPort + DefaultServerPort = ln.Addr().(*net.TCPAddr).Port + t.Cleanup(func() { DefaultServerPort = old }) + if DefaultServerPort == 5037 { + t.Fatal("测试端口不能是 5037") + } + got := make(chan string, 8) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + c.SetDeadline(time.Now().Add(5 * time.Second)) + msg, err := readHexPrefixed(bufio.NewReader(c)) + if err != nil { + return + } + if msg == "host:devices" { + fmt.Fprintf(c, "OKAY%04x%s", len(devices), devices) + return + } + got <- msg + io.Copy(io.Discard, c) + }(conn) + } + }() + return got +} + +func TestShareWithDefaultServerRegistersMissingInstance(t *testing.T) { + got := fakeDefaultServer(t, "emulator-5554\tdevice\n127.0.0.1:16384\toffline\n") + registered, err := ShareWithDefaultServer(context.Background(), 31020) + if err != nil || !registered { + t.Fatalf("ShareWithDefaultServer = %v, %v,应登记", registered, err) + } + select { + case msg := <-got: + if msg != "host:emulator:31021" { + t.Fatalf("登记发的是 %q,应是 host:emulator:31021(adb 端口 = 控制台 + 1)", msg) + } + case <-time.After(3 * time.Second): + t.Fatal("假 server 没收到登记") + } +} + +func TestShareWithDefaultServerSkipsKnownInstance(t *testing.T) { + for _, state := range []string{"device", "offline", "unauthorized"} { + got := fakeDefaultServer(t, "emulator-31020\t"+state+"\n") + registered, err := ShareWithDefaultServer(context.Background(), 31020) + if err != nil || registered { + t.Fatalf("%s: ShareWithDefaultServer = %v, %v,已经列着的不该再登记", state, registered, err) + } + select { + case msg := <-got: + t.Fatalf("%s: 不该再发 %q", state, msg) + case <-time.After(300 * time.Millisecond): + } + } +} + +func TestShareWithDefaultServerWithoutServer(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() // nothing listens there now + old := DefaultServerPort + DefaultServerPort = port + t.Cleanup(func() { DefaultServerPort = old }) + registered, err := ShareWithDefaultServer(context.Background(), 31020) + if err != nil || registered { + t.Fatalf("没有 server 时 ShareWithDefaultServer = %v, %v,应什么都不做、不报错", registered, err) + } +} + +func TestServerDevicesRefusal(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + readHexPrefixed(bufio.NewReader(c)) + const why = "unknown host service" + fmt.Fprintf(c, "FAIL%04x%s", len(why), why) + }() + port := ln.Addr().(*net.TCPAddr).Port + if _, err := serverDevices(context.Background(), port, 2*time.Second); err == nil { + t.Fatal("FAIL 回复应报错") + } +} diff --git a/internal/adb/real_adb_test.go b/internal/adb/real_adb_test.go index d8d4b97..a349e90 100644 --- a/internal/adb/real_adb_test.go +++ b/internal/adb/real_adb_test.go @@ -38,15 +38,15 @@ const ( testScriptPort = 20076 ) -// testSDK is an SDK whose adb.exe the test may run (only run, never modified): $MODAVD_TEST_SDK, else the package's. +// testSDK is an SDK whose adb.exe the test may run (only run, never modified): $MODAVD_TEST_SDK only. There is no +// fallback to an installed root, so a plain `go test ./...` never reaches a real (production) root. func testSDK() string { - for _, sdk := range []string{os.Getenv("MODAVD_TEST_SDK"), `D:\ModAVD\sdk`} { - if sdk == "" { - continue - } - if st, err := os.Stat(filepath.Join(sdk, "platform-tools", "adb.exe")); err == nil && !st.IsDir() { - return sdk - } + sdk := os.Getenv("MODAVD_TEST_SDK") + if sdk == "" { + return "" + } + if st, err := os.Stat(filepath.Join(sdk, "platform-tools", "adb.exe")); err == nil && !st.IsDir() { + return sdk } return "" } @@ -57,7 +57,7 @@ func realRoot(t *testing.T) (*root.Root, string) { t.Helper() sdk := testSDK() if sdk == "" { - t.Skip("没有可用的 adb.exe(MODAVD_TEST_SDK / D:\\ModAVD\\sdk)") + t.Skip("没有可用的 adb.exe(设 MODAVD_TEST_SDK 指向一个 SDK 才跑)") } dir := tempDir(t) meta, _ := json.Marshal(map[string]any{ diff --git a/internal/avd/avd_test.go b/internal/avd/avd_test.go index 5083a52..e3727c7 100644 --- a/internal/avd/avd_test.go +++ b/internal/avd/avd_test.go @@ -480,17 +480,48 @@ func TestPropertiesMatchPython(t *testing.T) { compareWithPython(t, setup, []refOp{{"op": "list"}}) } -// TestMatchesProductionInstance: an instance created here with the options of D:\ModAVD's mas_1 (made by AUTO-MAS -// on 10-16; read only) is the same bytes. The template has changed once since: hw.mainKeys no -> yes (ModAVD #1). -// New instances default to 1080p since ModAVD #100; mas_1 is 720p, so its resolution is passed like the others. +// prodMas1Config is config.ini of a production mas_1 as AUTO-MAS created it on 10-16 (CRLF, no BOM). It is a +// snapshot: tests never read a real root. +var prodMas1Config = strings.Join([]string{ + "PlayStore.enabled=no", "abi.type=x86_64", "avd.ini.encoding=UTF-8", "disk.cachePartition=yes", + "disk.cachePartition.size=66MB", "fastboot.forceColdBoot=yes", "fastboot.forceFastBoot=no", + "hw.accelerometer=yes", "hw.arc=false", "hw.audioInput=no", "hw.audioOutput=no", "hw.battery=yes", + "hw.camera.back=none", "hw.camera.front=none", "hw.cpu.arch=x86_64", "hw.dPad=no", "hw.gps=no", + "hw.gpu.enabled=yes", "hw.gpu.mode=host", "hw.gsmModem=yes", "hw.gyroscope=yes", + "hw.initialOrientation=landscape", "hw.keyboard=yes", "hw.keyboard.lid=yes", "hw.lcd.depth=32", + "hw.lcd.vsync=30", "hw.mainKeys=no", "hw.sdCard=no", "hw.sensors.orientation=yes", "hw.sensors.proximity=no", + "hw.trackBall=no", "hw.useext4=yes", "showDeviceFrame=no", "tag.display=Google APIs", "tag.id=google_apis", + "target=android-34", "vm.heapSize=192M", "avd.id=mas_1", "avd.name=mas_1", "avd.ini.displayname=二号", + "disk.dataPartition.size=256G", "hw.cpu.ncore=4", "hw.ramSize=4096M", "hw.lcd.width=1280", + "hw.lcd.height=720", "hw.lcd.density=240", `image.sysdir.1=system-images\android-34\google_apis\x86_64\`, "", +}, "\r\n") + +// prodMas1Ini is the matching avd\mas_1.ini; path= is filled in with the snapshot root's instance directory. +const prodMas1Ini = "avd.ini.encoding=UTF-8\r\npath=%s\r\npath.rel=avd\\mas_1.avd\r\ntarget=android-34\r\n" + +// TestMatchesProductionInstance: an instance created here with the options of a production mas_1 (made by AUTO-MAS +// on 10-16, kept above as a snapshot in a temp root) is the same bytes. The template has changed once since: +// hw.mainKeys no -> yes (ModAVD #1). New instances default to 1080p since ModAVD #100; mas_1 is 720p, so its +// resolution is passed like the others. func TestMatchesProductionInstance(t *testing.T) { - const prod = `D:\ModAVD` - prodIni, err1 := os.ReadFile(prod + `\avd\mas_1.ini`) - prodCfg, err2 := os.ReadFile(prod + `\avd\mas_1.avd\config.ini`) - if err1 != nil || err2 != nil { - t.Skip("no production instance mas_1") + pr := newRoot(t) + prodCfg := []byte(prodMas1Config) + prodIni := []byte(fmt.Sprintf(prodMas1Ini, Dir(pr, 1))) + for path, data := range map[string][]byte{ + ConfigPath(pr, 1): prodCfg, + IniPath(pr, 1): prodIni, + // Production's instances.1 as created (later keys trimmed). + filepath.Join(pr.Dir, root.MetadataFile): []byte(`{"instances": {"1": {"memoryMb": 4096, "initialized": true, ` + + `"createdAt": "2026-10-16T07:41:39", "initializedAt": "2026-10-16T07:42:57"}}}`), + } { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } } - pr, err := root.New(prod) + pr, err := root.New(pr.Dir) if err != nil { t.Fatal(err) } @@ -518,7 +549,7 @@ func TestMatchesProductionInstance(t *testing.T) { if got := readFile(t, ConfigPath(r, 1)); !bytes.Equal(got, wantCfg) { t.Errorf("config.ini differs\n got %q\nwant %q", got, wantCfg) } - wantIni := bytes.Replace(prodIni, []byte(`path=D:\ModAVD\avd\mas_1.avd`), []byte(`path=`+Dir(r, 1)), 1) + wantIni := []byte(fmt.Sprintf(prodMas1Ini, Dir(r, 1))) if got := readFile(t, IniPath(r, 1)); !bytes.Equal(got, wantIni) { t.Errorf("mas_1.ini differs\n got %q\nwant %q", got, wantIni) } diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index 1ae633d..2a374db 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -4,7 +4,7 @@ package buildinfo // Version of modavd itself, kept in the source rather than set with -ldflags -X: release builds must come out byte // for byte the same for the same source (Mirror 酱 ships per-file deltas, and a version stamped per package would // change the exe in every release). The ModAVD package version comes from the root at run time. -var Version = "0.9.0" +var Version = "0.10.0" // Protocol is the version of the machine-readable contract: the commands, the JSON written by `--json`, the exit codes // and the MCP tool schemas. Callers such as AUTO-MAS check it (`modavd version --json`) before relying on a field. diff --git a/internal/cli/commands.go b/internal/cli/commands.go index cd1fbbf..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;" + @@ -187,7 +188,7 @@ func init() { name: "status", args: "", summary: "一台实例的状态", details: "status:offline 已关机 / starting 开机中 / online 运行中 / creating 新建中 / create_failed 新建失败;" + "running 是 qemu 进程在不在,booted 是 Android 开完没有(新建中这两个可能是 true,以 status 为准);" + - "busy 是另一个 modavd 正在开机(starting)或关机(stopping)它;新建中 creation_progress 是做完的百分比(0–100,按做完的步骤算)。", + "busy 是另一个 modavd 正在开机(starting)或关机(stopping)它,它自己在关(关了窗口、安卓里关机)也报 stopping;新建中 creation_progress 是做完的百分比(0–100,按做完的步骤算)。", posNames: []string{"实例编号"}, minPos: 1, maxPos: 1, run: runStatus, human: humanOf(formatStatus), }, @@ -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/download/download.go b/internal/download/download.go index 47c4daf..cb6e58a 100644 --- a/internal/download/download.go +++ b/internal/download/download.go @@ -354,50 +354,79 @@ func (c *Client) File(ctx context.Context, o FileOptions) error { return nil } -// Range reads bytes [offset, offset+length) of a file of total bytes with one range request. The answer must be 206 -// with exactly that Content-Range; anything else (a server that ignores Range answers 200 with the whole file) is an -// Error of Kind NoRange, and its body is not read. +// Range reads a small range into memory. Use RangeTo for entries that can be large. func (c *Client) Range(ctx context.Context, rawURL string, offset, length, total int64, what string) ([]byte, error) { - if offset < 0 || length <= 0 || offset+length > total { - return nil, &Error{Message: fmt.Sprintf("要的区间不对(%d+%d / %d)", offset, length, total)} + var b bytes.Buffer + if err := c.RangeTo(ctx, rawURL, offset, length, total, what, &b); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// RangeTo writes bytes [offset, offset+length) to dst using a fixed-size buffer. The answer must be 206 with exactly +// that Content-Range and byte count. Rejected headers leave dst untouched. A failed transfer may leave a prefix; +// the caller must reset dst before retrying. Writer errors are returned unchanged and must not be retried as network errors. +func (c *Client) RangeTo(ctx context.Context, rawURL string, offset, length, total int64, what string, dst io.Writer) error { + if offset < 0 || length <= 0 || offset > total || length > total-offset { + return &Error{Message: fmt.Sprintf("要的区间不对(%d+%d / %d)", offset, length, total)} } end := offset + length - 1 resp, err := c.get(ctx, rawURL, fmt.Sprintf("bytes=%d-%d", offset, end), what) if err != nil { - return nil, err + return err } defer resp.close() want := fmt.Sprintf("bytes %d-%d/%d", offset, end, total) if got := resp.Header.Get("Content-Range"); resp.StatusCode != http.StatusPartialContent || got != want { - return nil, &Error{Message: fmt.Sprintf("服务器没按区间回(HTTP %d,Content-Range: %q,要的是 %s)", resp.StatusCode, got, want), + return &Error{Message: fmt.Sprintf("服务器没按区间回(HTTP %d,Content-Range: %q,要的是 %s)", resp.StatusCode, got, want), Kind: NoRange, Status: resp.StatusCode} } if resp.ContentLength >= 0 && resp.ContentLength != length { - return nil, sizeError("区间大小和预期不符(服务器给 %d 字节,应为 %d 字节)", resp.ContentLength, length) + return sizeError("区间大小和预期不符(服务器给 %d 字节,应为 %d 字节)", resp.ContentLength, length) } - buf := make([]byte, length+1) // one more to notice a longer body - done := 0 + buf := make([]byte, 64<<10) + var done int64 for { - n, rerr := resp.read(buf[done:], c.stallTimeout()) - done += n - if done > int(length) { - return nil, sizeError("区间大小和预期不符(收到的比 %d 字节多)", length) + if ctx.Err() != nil { + return canceled(ctx) + } + // One extra byte detects a chunked response that runs past the requested range, without writing it. + next := int64(len(buf)) + if length-done < next { + next = length - done + 1 + } + n, rerr := resp.read(buf[:next], c.stallTimeout()) + if int64(n) > length-done { + return sizeError("区间大小和预期不符(收到的比 %d 字节多)", length) + } + if n > 0 { + written, werr := dst.Write(buf[:n]) + if werr != nil { + return werr + } + if written != n { + return io.ErrShortWrite + } + done += int64(n) } if rerr == io.EOF { break } if rerr != nil { if ctx.Err() != nil { - return nil, canceled(ctx) + return canceled(ctx) } if resp.fired.Load() { - return nil, timeoutError("超时:%s(收数据,已收 %d 字节),%s 秒内没有进展", what, done, seconds(c.stallTimeout())) + return timeoutError("超时:%s(收数据,已收 %d 字节),%s 秒内没有进展", what, done, seconds(c.stallTimeout())) } - return nil, &Error{Message: "收数据出错:" + NetError(rerr, "")} + return &Error{Message: "收数据出错:" + NetError(rerr, "")} } } - if done != int(length) { - return nil, &Error{Message: fmt.Sprintf("区间没收全:应为 %d 字节,实际 %d 字节", length, done)} + if ctx.Err() != nil { + return canceled(ctx) } - return buf[:length], nil + if done != length { + return &Error{Message: fmt.Sprintf("区间没收全:应为 %d 字节,实际 %d 字节", length, done)} + } + return nil } diff --git a/internal/download/range_stream_test.go b/internal/download/range_stream_test.go new file mode 100644 index 0000000..ab3e286 --- /dev/null +++ b/internal/download/range_stream_test.go @@ -0,0 +1,166 @@ +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package download + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" + "time" + + "modavd/internal/errs" +) + +type rangeWriter struct { + count, max int + err error + cancel context.CancelFunc +} + +func (w *rangeWriter) Write(p []byte) (int, error) { + w.count += len(p) + w.max = max(w.max, len(p)) + if w.cancel != nil { + w.cancel() + } + if w.err != nil { + return 0, w.err + } + return len(p), nil +} + +func TestRangeToStreamsWithBoundedMemory(t *testing.T) { + const size = 12 << 20 + // A generated body has no size-dependent allocation to hide the client's allocation cost. + body := &watchedBody{Reader: io.LimitReader(zeroReader{}, size)} + d := Client{HTTP: &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 206, Header: http.Header{"Content-Range": {fmt.Sprintf("bytes 0-%d/%d", size-1, size)}}, ContentLength: size, Body: body}, nil + })}} + w := &rangeWriter{} + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + err := d.RangeTo(context.Background(), "https://example.invalid/pkg", 0, size, size, "test", w) + runtime.ReadMemStats(&after) + allocated := after.TotalAlloc - before.TotalAlloc + if err != nil || w.count != size || w.max > 64<<10 || !body.closed || allocated > 2<<20 { + t.Fatalf("stream: bytes %d, max chunk %d, allocated %d, closed %v, error %v", w.count, w.max, allocated, body.closed, err) + } + t.Logf("%d bytes transferred, max write %d, allocated %d", size, w.max, allocated) +} + +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { clear(p); return len(p), nil } + +func TestRangeToProtocol(t *testing.T) { + for _, tc := range []struct { + name, cr, body, encoding string + status int + length int64 + kind Kind + want string + }{ + {"valid", "bytes 2-4/10", "cde", "", 206, 3, Network, "cde"}, + {"ignored", "", "abcdefghij", "", 200, 10, NoRange, ""}, + {"other range", "bytes 2-5/10", "cdef", "", 206, 4, NoRange, ""}, + {"wrong total", "bytes 2-4/11", "cde", "", 206, 3, NoRange, ""}, + {"wrong length", "bytes 2-4/10", "cd", "", 206, 2, Size, ""}, + {"encoding", "bytes 2-4/10", "cde", "gzip", 206, 3, Encoding, ""}, + {"short", "bytes 2-4/10", "cd", "", 206, -1, Network, "cd"}, + {"long", "bytes 2-4/10", "cdef", "", 206, -1, Size, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + body := &watchedBody{Reader: strings.NewReader(tc.body)} + d := Client{HTTP: &http.Client{Transport: roundTrip(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Range") != "bytes=2-4" || req.Header.Get("Accept-Encoding") != "identity" { + t.Errorf("headers: %v", req.Header) + } + return &http.Response{StatusCode: tc.status, Header: http.Header{"Content-Range": {tc.cr}, "Content-Encoding": {tc.encoding}}, ContentLength: tc.length, Body: body}, nil + })}} + var dst bytes.Buffer + err := d.RangeTo(context.Background(), "https://example.invalid/pkg", 2, 3, 10, "test", &dst) + var de *Error + if tc.name == "valid" { + if err != nil { + t.Fatal(err) + } + } else if !errors.As(err, &de) || de.Kind != tc.kind { + t.Fatalf("want kind %d, got %v", tc.kind, err) + } + if dst.String() != tc.want || !body.closed { + t.Fatalf("output %q, closed %v", dst.String(), body.closed) + } + }) + } +} + +func TestRangeToLocalErrorAndCancel(t *testing.T) { + local := errors.New("disk full") + for _, cancelWrite := range []bool{false, true} { + ctx, cancel := context.WithCancel(context.Background()) + body := &watchedBody{Reader: strings.NewReader("abc")} + d := Client{HTTP: &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 206, Header: http.Header{"Content-Range": {"bytes 0-2/3"}}, ContentLength: 3, Body: body}, nil + })}} + w := &rangeWriter{err: local} + if cancelWrite { + w.err, w.cancel = nil, cancel + } + err := d.RangeTo(ctx, "https://example.invalid/pkg", 0, 3, 3, "test", w) + cancel() + if (!cancelWrite && !errors.Is(err, local)) || (cancelWrite && errs.CodeOf(err) != errs.Canceled) || !body.closed { + t.Fatalf("cancel %v: closed %v, error %v", cancelWrite, body.closed, err) + } + } +} + +func TestRangeToStallClosesResponse(t *testing.T) { + closed := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Range", "bytes 0-5/6") + w.Header().Set("Content-Length", "6") + w.WriteHeader(206) + io.WriteString(w, "abc") + w.(http.Flusher).Flush() + <-r.Context().Done() + close(closed) + })) + defer srv.Close() + d := Client{HTTP: NewHTTPClient(0), StallTimeout: 30 * time.Millisecond} + err := d.RangeTo(context.Background(), srv.URL, 0, 6, 6, "test", io.Discard) + var de *Error + if !errors.As(err, &de) || de.Kind != Timeout { + t.Fatalf("want timeout, got %v", err) + } + select { + case <-closed: + case <-time.After(time.Second): + t.Fatal("response not canceled") + } +} + +func TestRangeToOverflow(t *testing.T) { + requests := 0 + d := Client{HTTP: &http.Client{Transport: roundTrip(func(*http.Request) (*http.Response, error) { + requests++ + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(""))}, nil + })}} + for _, bounds := range [][3]int64{{math.MaxInt64 - 1, 5, math.MaxInt64}, {-1, 1, 10}, {0, 0, 10}} { + if err := d.RangeTo(context.Background(), "https://example.invalid/pkg", bounds[0], bounds[1], bounds[2], "test", io.Discard); err == nil { + t.Fatal("invalid range accepted") + } + } + if requests != 0 { + t.Fatal("invalid range sent a request") + } +} diff --git a/internal/emu/bootwatch.go b/internal/emu/bootwatch.go index b7dd6bc..bfaeab5 100644 --- a/internal/emu/bootwatch.go +++ b/internal/emu/bootwatch.go @@ -20,7 +20,7 @@ package emu // JSON object per line, as `--json` on stderr). // // Being the one process that lives as long as the emulator, the watch also writes the emulator's memory to that log -// every bootWatchMemoryInterval (logMemory). +// at start, on a large change and at least every ten minutes (watchMemory). import ( "context" @@ -31,6 +31,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "strconv" "strings" "time" @@ -265,11 +266,33 @@ func RunBootWatch(args []string, w io.Writer) int { } cancel() }() - watch.logMemory(time.Now()) + memoryDone := make(chan struct{}) + go func() { + defer close(memoryDone) + if runtime.GOOS != "windows" { + return // these counters are not implemented on other hosts + } + if ctx.Err() == nil { + watch.logMemory(time.Now()) + } + // Start the timer after the first read, so its tenth tick is not just before that sample's ten-minute mark. + ticker := time.NewTicker(bootWatchMemorySampleInterval) + defer ticker.Stop() + watch.watchMemory(ctx, ticker.C) + }() + shareDone := make(chan struct{}) + go func() { + defer close(shareDone) + ticker := time.NewTicker(bootWatchShareInterval) + defer ticker.Stop() + watch.shareDefaultAdb(ctx, ticker.C) + }() for !q.Wait(bootWatchInterval) { watch.tick(ctx, time.Now()) - watch.logMemory(time.Now()) } + cancel() + <-shareDone + <-memoryDone // write the summary before releasing the watch lock or ending the log m.Wait(10 * time.Second) log.Infof("实例 %d 的模拟器(qemu pid %d)已退出,开机守护结束", i, q.PID) return 0 @@ -283,12 +306,29 @@ type bootWatch struct { // missingSince: since when the private server has not known the instance (zero: it did at the last look), or // since the watch last registered it again (bootWatchReregisterAfter). missingSince time.Time - // memoryAt: when logMemory wrote its last line (zero: not yet). - memoryAt time.Time + // Owned only by watchMemory, independently of guest reboot setup in tick. + memory bootWatchMemory + // shareFailing: the last registration with the default adb server failed (warned once). Owned by shareDefaultAdb. + shareFailing bool } -// bootWatchMemoryInterval: how often the watch writes the emulator's memory to its log. -const bootWatchMemoryInterval = 10 * time.Minute +const ( + bootWatchMemorySampleInterval = time.Minute + bootWatchMemoryInterval = 10 * time.Minute + bootWatchMemoryChange = 256 << 20 +) + +type bootWatchMemorySample struct { + at time.Time + pm host.ProcessMemory +} + +type bootWatchMemory struct { + last, logged *bootWatchMemorySample + samples, failures uint64 + maxCommit, maxWorkingSet uint64 + readErr error +} // The memory counters behind logMemory; tests replace them. var ( @@ -296,29 +336,130 @@ var ( systemMemory = host.GetSystemMemory ) -// logMemory writes one line with the memory of qemu and of the computer, at the watch's start and then every -// bootWatchMemoryInterval: a report that the computer got slow after hours of running can then be told apart into -// the emulator's commit growing (a leak on the host side) and the computer simply being short of memory. Nothing -// where the counters cannot be read. -func (w *bootWatch) logMemory(now time.Time) { - if !w.memoryAt.IsZero() && now.Sub(w.memoryAt) < bootWatchMemoryInterval { - return +// watchMemory continues sampling after the initial read. Guest reboot setup may block tick for minutes, so it must +// not own this timer. Cancellation means qemu has exited; do not reopen a PID to try to recover final counters then. +func (w *bootWatch) watchMemory(ctx context.Context, ticks <-chan time.Time) { + for { + select { + case <-ctx.Done(): + w.logMemorySummary() + return + case <-ticks: + if ctx.Err() == nil { + w.logMemory(time.Now()) // the read time, not a queued tick's old timestamp after suspend or slow I/O + } + } } +} + +// logMemory samples once. Only a first/recovered sample, an absolute change >256 MiB from the last written commit, +// or ten minutes since that line writes process and system counters. Failed reads do not move either baseline. +func (w *bootWatch) logMemory(now time.Time) { + m := &w.memory pm, err := processMemory(w.q) if err != nil { + m.failures++ + if m.readErr == nil { + w.m.Log.Warnf("实例 %d 模拟器内存采样失败(qemu pid %d):%v;一分钟后重试", w.i, w.q.PID, err) + } + m.readErr = err + return + } + recovered := m.readErr != nil + m.readErr = nil + m.last = &bootWatchMemorySample{at: now, pm: pm} + m.samples++ + m.maxCommit = max(m.maxCommit, pm.Commit) + m.maxWorkingSet = max(m.maxWorkingSet, pm.WorkingSet) + if m.logged != nil && !recovered && now.Sub(m.logged.at) < bootWatchMemoryInterval && + max(pm.Commit, m.logged.pm.Commit)-min(pm.Commit, m.logged.pm.Commit) <= bootWatchMemoryChange { return } - w.memoryAt = now + m.logged = m.last const mb = 1 << 20 - msg := fmt.Sprintf("实例 %d 模拟器内存(qemu pid %d):提交 %d MB(最高 %d MB),工作集 %d MB", w.i, w.q.PID, + msg := fmt.Sprintf("实例 %d 模拟器内存(qemu pid %d):提交 %d MB(OS 提交峰值 %d MB),工作集 %d MB", w.i, w.q.PID, pm.Commit/mb, pm.PeakCommit/mb, pm.WorkingSet/mb) if sm, err := systemMemory(); err == nil { msg += fmt.Sprintf(";电脑:物理内存可用 %d / %d MB,提交 %d / %d MB", sm.AvailPhys/mb, sm.TotalPhys/mb, sm.Committed/mb, sm.CommitLimit/mb) } + if recovered { + msg += ";内存采样已恢复" + } w.m.Log.Infof("%s", msg) } +// logMemorySummary reports observations, not an exact lifetime peak. GetProcessMemoryInfo is not guaranteed to +// yield counters after exit, even with a retained process handle. The OS peak below is exact only up to last.at; +// sampled maxima can miss changes between samples. Neither includes an unobserved interval before exit. +func (w *bootWatch) logMemorySummary() { + m := &w.memory + msg := fmt.Sprintf("实例 %d 模拟器内存退出汇总(qemu pid %d):成功采样 %d 次,读取失败 %d 次", w.i, w.q.PID, + m.samples, m.failures) + if m.last == nil { + msg += ";没有成功样本" + } else { + const mb = 1 << 20 + pm := m.last.pm + msg += fmt.Sprintf(";最后成功采样 %s:提交 %d MB,OS 提交峰值 %d MB,工作集 %d MB;采样最大提交 %d MB,工作集 %d MB", + m.last.at.Format(time.RFC3339), pm.Commit/mb, pm.PeakCommit/mb, pm.WorkingSet/mb, + m.maxCommit/mb, m.maxWorkingSet/mb) + } + if m.readErr != nil { + msg += fmt.Sprintf(";最近读取失败:%v", m.readErr) + } + w.m.Log.Infof("%s;精确退出峰值未知", msg) +} + +// bootWatchShareInterval: how often the watch makes sure the default adb server (5037) knows the instance. A tool that +// starts that server finds the instance at most this long after. +var bootWatchShareInterval = 30 * time.Second + +// shareDefaultAdbServer is adb.ShareWithDefaultServer; tests replace it. +var shareDefaultAdbServer = adb.ShareWithDefaultServer + +// shareDefaultAdb registers the instance with the default adb server now and at every tick, until ctx ends (qemu +// exited). Its own goroutine: tick may block for minutes in a guest reboot setup. Skipped while a stop runs, before a +// start has finished on q, and when mas-avd.json turns it off; a failure is logged once until it works again. +func (w *bootWatch) shareDefaultAdb(ctx context.Context, ticks <-chan time.Time) { + for { + if ctx.Err() != nil { + return + } + w.shareDefaultAdbOnce(ctx) + select { + case <-ctx.Done(): + return + case <-ticks: + } + } +} + +// shareDefaultAdbOnce is one look of shareDefaultAdb; it reports whether it asked the default server. +func (w *bootWatch) shareDefaultAdbOnce(ctx context.Context) bool { + m, i := w.m, w.i + rec, ok := m.readBootRecord(i) + if !ok || !rec.names(w.q) || m.stopping(i) || !m.Root.ShareDefaultAdbServer() { + return false + } + console := m.Root.Ports().Console(i) + registered, err := shareDefaultAdbServer(ctx, console) + switch { + case err != nil: + if !w.shareFailing && ctx.Err() == nil { + m.Log.Warnf("实例 %d 没能登记到默认的 adb server(端口 %d),别的工具里可能看不到它: %v", i, + adb.DefaultServerPort, err) + } + w.shareFailing = true + case registered: + m.Log.Infof("实例 %d 已登记到默认的 adb server(端口 %d),那边是 emulator-%d", i, adb.DefaultServerPort, console) + w.shareFailing = false + default: + w.shareFailing = false + } + return true +} + // tick is one look of the watch at now: nothing while a start or a stop of the instance runs, or while the record does // not name q (no start has finished on it); otherwise, once Android has booted with another boot_id than the // record's, the start that sets the boot up again. diff --git a/internal/emu/bootwatch_memory_policy_test.go b/internal/emu/bootwatch_memory_policy_test.go new file mode 100644 index 0000000..0440b56 --- /dev/null +++ b/internal/emu/bootwatch_memory_policy_test.go @@ -0,0 +1,223 @@ +//go:build windows + +// Copyright (C) 2026 jinghero + +package emu + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "modavd/internal/host" +) + +// A minute's growth must be visible before the ten-minute heartbeat. +func TestBootWatchMemoryDeltaRegression(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + const mb = 1 << 20 + commit := uint64(1000 * mb) + processMemory = func(host.Process) (host.ProcessMemory, error) { + return host.ProcessMemory{Commit: commit, PeakCommit: commit, WorkingSet: 500 * mb}, nil + } + systemMemory = func() (host.SystemMemory, error) { return host.SystemMemory{}, nil } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + w.logMemory(t0) + commit += 257 * mb + w.logMemory(t0.Add(time.Minute)) + if len(*lines) != 2 { + t.Fatalf("257 MiB growth after a minute: %v, want two lines", *lines) + } +} + +func TestBootWatchMemoryPolicy(t *testing.T) { + const mb = 1 << 20 + type sample struct { + minute int + commit uint64 + lines int + } + for _, tc := range []struct { + name string + samples []sample + }{ + {"stable heartbeat", []sample{{0, 1000 * mb, 1}, {1, 1000 * mb, 1}, {9, 1000 * mb, 1}, {10, 1000 * mb, 2}}}, + {"cumulative strict threshold", []sample{{0, 1000 * mb, 1}, {1, 1128 * mb, 1}, {2, 1256 * mb, 1}, {3, 1256*mb + 1, 2}}}, + {"falling strict threshold", []sample{{0, 1000 * mb, 1}, {1, 744 * mb, 1}, {2, 744*mb - 1, 2}}}, + {"heartbeat resets on write", []sample{{0, 1000 * mb, 1}, {3, 1300 * mb, 2}, {10, 1300 * mb, 2}, {12, 1300 * mb, 2}, {13, 1300 * mb, 3}}}, + {"unsigned large difference", []sample{{0, 1, 1}, {1, 1 << 63, 2}, {2, 1, 3}}}, + } { + t.Run(tc.name, func(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + var commit uint64 + reads, systemReads := 0, 0 + processMemory = func(p host.Process) (host.ProcessMemory, error) { + if p.PID != 4242 || p.Created != 17 { + t.Fatalf("lost process identity: %+v", p) + } + reads++ + return host.ProcessMemory{Commit: commit, PeakCommit: commit, WorkingSet: commit}, nil + } + systemMemory = func() (host.SystemMemory, error) { + systemReads++ + return host.SystemMemory{}, nil + } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 17}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + for i, s := range tc.samples { + commit = s.commit + w.logMemory(t0.Add(time.Duration(s.minute) * time.Minute)) + if len(*lines) != s.lines || systemReads != s.lines || reads != i+1 { + t.Fatalf("minute %d: logs %v, process reads %d, system reads %d", s.minute, *lines, reads, systemReads) + } + } + }) + } +} + +func TestBootWatchMemorySummary(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + const mb = 1 << 20 + reads, systemReads := 0, 0 + var pm host.ProcessMemory + var readErr error + processMemory = func(host.Process) (host.ProcessMemory, error) { reads++; return pm, readErr } + systemMemory = func() (host.SystemMemory, error) { systemReads++; return host.SystemMemory{}, nil } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + pm = host.ProcessMemory{Commit: 1000 * mb, PeakCommit: 2000 * mb, WorkingSet: 200 * mb} + w.logMemory(t0) + pm = host.ProcessMemory{Commit: 1100 * mb, PeakCommit: 2200 * mb, WorkingSet: 350 * mb} + w.logMemory(t0.Add(time.Minute)) // sampled but not written + pm = host.ProcessMemory{Commit: 900 * mb, PeakCommit: 2300 * mb, WorkingSet: 300 * mb} + w.logMemory(t0.Add(2 * time.Minute)) + readErr = errors.New("denied") + pm = host.ProcessMemory{Commit: 99999 * mb} // partial result with error must never become history + w.logMemory(t0.Add(3 * time.Minute)) + w.logMemory(t0.Add(4 * time.Minute)) + w.logMemorySummary() + want := "实例 3 模拟器内存退出汇总(qemu pid 4242):成功采样 3 次,读取失败 2 次;最后成功采样 2026-11-12T20:02:00Z:提交 900 MB,OS 提交峰值 2300 MB,工作集 300 MB;采样最大提交 1100 MB,工作集 350 MB;最近读取失败:denied;精确退出峰值未知" + if len(*lines) != 3 || (*lines)[2].msg != want || reads != 5 || systemReads != 1 { + t.Fatalf("summary = %v, process reads %d, system reads %d", *lines, reads, systemReads) + } +} + +func TestBootWatchMemoryNoSuccessfulSample(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + processMemory = func(host.Process) (host.ProcessMemory, error) { + return host.ProcessMemory{}, errors.New("unreadable") + } + systemMemory = func() (host.SystemMemory, error) { + t.Fatal("queried system after failed sample") + return host.SystemMemory{}, nil + } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + w.logMemory(time.Now()) + w.logMemory(time.Now().Add(time.Minute)) + w.logMemorySummary() + if len(*lines) != 2 || !strings.Contains((*lines)[1].msg, "成功采样 0 次,读取失败 2 次;没有成功样本") || + !strings.HasSuffix((*lines)[1].msg, "精确退出峰值未知") || strings.Contains((*lines)[1].msg, "0 MB") { + t.Fatalf("unknown counters presented as numbers: %v", *lines) + } +} + +func TestBootWatchMemoryFailureRecovery(t *testing.T) { + savedP, savedS := processMemory, systemMemory + t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) + var readErr error + processMemory = func(host.Process) (host.ProcessMemory, error) { + return host.ProcessMemory{Commit: 1 << 30}, readErr + } + systemMemory = func() (host.SystemMemory, error) { return host.SystemMemory{}, nil } + m, lines := recordingManager() + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + t0 := time.Date(2026, 11, 12, 20, 0, 0, 0, time.UTC) + w.logMemory(t0) + readErr = errors.New("denied") + w.logMemory(t0.Add(10 * time.Minute)) + w.logMemory(t0.Add(11 * time.Minute)) + if w.memory.logged.at != t0 || w.memory.last.at != t0 { + t.Fatal("failure moved the successful/logged baseline") + } + readErr = nil + w.logMemory(t0.Add(12 * time.Minute)) + w.logMemory(t0.Add(21 * time.Minute)) + w.logMemory(t0.Add(22 * time.Minute)) + w.logMemorySummary() + if len(*lines) != 5 || !strings.HasSuffix((*lines)[2].msg, "内存采样已恢复") || + strings.Contains((*lines)[4].msg, "最近读取失败") || !strings.Contains((*lines)[4].msg, "读取失败 2 次") { + t.Fatalf("recovery/heartbeat = %v", *lines) + } +} + +// A guest read can block the reboot/stop path, but cannot block memory sampling or its exit summary. +func TestBootWatchMemoryIndependentOfGuest(t *testing.T) { + savedP, savedS, savedGuest := processMemory, systemMemory, guestBootRead + t.Cleanup(func() { processMemory, systemMemory, guestBootRead = savedP, savedS, savedGuest }) + rootManager, _ := opsTestManager(t) + i := 3 + if _, err := rootManager.Create(context.Background(), CreateRequest{filesOnly: true, Index: &i}); err != nil { + t.Fatal(err) + } + m, lines := recordingManager() + m.Root = rootManager.Root + w := &bootWatch{m: m, i: 3, q: host.Process{PID: 4242, Created: 1}} + m.writeBootRecord(3, bootRecord{QemuPID: w.q.PID, QemuCreated: w.q.Created, BootID: testBootA}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + entered := make(chan struct{}) + guestBootRead = func(int, bool) (bool, string, bool) { + close(entered) + <-ctx.Done() + return false, "", false + } + guestDone := make(chan struct{}) + go func() { defer close(guestDone); w.tick(ctx, time.Now()) }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("guest read never started") + } + read := make(chan struct{}, 2) + processMemory = func(host.Process) (host.ProcessMemory, error) { + read <- struct{}{} + return host.ProcessMemory{Commit: 1 << 30}, nil + } + systemMemory = func() (host.SystemMemory, error) { return host.SystemMemory{}, nil } + ticks := make(chan time.Time) + done := make(chan struct{}) + w.logMemory(time.Now()) + go func() { defer close(done); w.watchMemory(ctx, ticks) }() + awaitRead := func() { + t.Helper() + select { + case <-read: + case <-time.After(5 * time.Second): + t.Fatal("memory sample blocked by guest read") + } + } + awaitRead() + ticks <- time.Now().Add(time.Minute) + awaitRead() + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("memory worker did not stop") + } + <-guestDone + if w.memory.samples != 2 || len(read) != 0 || len(*lines) != 2 || !strings.Contains((*lines)[1].msg, "内存退出汇总") { + t.Fatalf("sampling/exit read count = %d, extra reads %d, logs %v", w.memory.samples, len(read), *lines) + } +} diff --git a/internal/emu/bootwatch_memory_test.go b/internal/emu/bootwatch_memory_test.go index cbc9cac..ef65c1f 100644 --- a/internal/emu/bootwatch_memory_test.go +++ b/internal/emu/bootwatch_memory_test.go @@ -5,7 +5,9 @@ package emu import ( "errors" "os" + "os/exec" "strings" + "syscall" "testing" "time" @@ -13,13 +15,13 @@ import ( "modavd/internal/logx" ) -// logMemory writes a line at the first look and then once per bootWatchMemoryInterval, with qemu's and the computer's -// numbers in MB; nothing while qemu's counters cannot be read, and only qemu's part without the computer's. +// Successful reads resume after failure; only emitted samples query the system counters. func TestBootWatchLogMemory(t *testing.T) { savedP, savedS := processMemory, systemMemory t.Cleanup(func() { processMemory, systemMemory = savedP, savedS }) const mb = 1 << 20 var perr, serr error + systemCalls := 0 processMemory = func(p host.Process) (host.ProcessMemory, error) { if p.PID != 4242 { t.Errorf("asked for pid %d", p.PID) @@ -27,6 +29,7 @@ func TestBootWatchLogMemory(t *testing.T) { return host.ProcessMemory{Commit: 6100 * mb, PeakCommit: 6200 * mb, WorkingSet: 4300 * mb}, perr } systemMemory = func() (host.SystemMemory, error) { + systemCalls++ return host.SystemMemory{TotalPhys: 15728 * mb, AvailPhys: 4400 * mb, CommitLimit: 41574 * mb, Committed: 38600 * mb}, serr } m, lines := recordingManager() @@ -35,26 +38,60 @@ func TestBootWatchLogMemory(t *testing.T) { perr = errors.New("gone") w.logMemory(t0) - if len(*lines) != 0 { - t.Fatalf("logged without counters: %+v", *lines) + if len(*lines) != 1 || (*lines)[0].level != logx.Warn || systemCalls != 0 { + t.Fatalf("failed read: %+v, system calls %d", *lines, systemCalls) } perr = nil - w.logMemory(t0) - w.logMemory(t0.Add(bootWatchMemoryInterval - time.Second)) - if len(*lines) != 1 { - t.Fatalf("lines = %+v, want one", *lines) + w.logMemory(t0.Add(time.Minute)) + w.logMemory(t0.Add(bootWatchMemoryInterval)) + if len(*lines) != 2 || systemCalls != 1 { + t.Fatalf("lines = %+v, system calls %d, want recovery only", *lines, systemCalls) } - want := "实例 3 模拟器内存(qemu pid 4242):提交 6100 MB(最高 6200 MB),工作集 4300 MB;电脑:物理内存可用 4400 / 15728 MB,提交 38600 / 41574 MB" - if got := (*lines)[0]; got.level != logx.Info || got.msg != want { + want := "实例 3 模拟器内存(qemu pid 4242):提交 6100 MB(OS 提交峰值 6200 MB),工作集 4300 MB;电脑:物理内存可用 4400 / 15728 MB,提交 38600 / 41574 MB;内存采样已恢复" + if got := (*lines)[1]; got.level != logx.Info || got.msg != want { t.Errorf("line = %v %q\nwant %q", got.level, got.msg, want) } serr = errors.New("no") - w.logMemory(t0.Add(bootWatchMemoryInterval)) - if len(*lines) != 2 || !strings.HasSuffix((*lines)[1].msg, "工作集 4300 MB") { + w.logMemory(t0.Add(time.Minute + bootWatchMemoryInterval)) + if len(*lines) != 3 || !strings.HasSuffix((*lines)[2].msg, "工作集 4300 MB") { t.Errorf("without the computer's numbers: %+v", *lines) } } +// Retaining the process object does not make post-exit memory a valid live snapshot. Windows can return success +// with most allocations already torn down, so the memory boundary must check exit as well as creation identity. +func TestMemoryRejectsRetainedExitedProcess(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(exe, "-test.run=^TestBootWatchHelperSleeper$") + cmd.Env = append(os.Environ(), "MODAVD_TEST_BOOTWATCH_SLEEPER=1") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) + p, ok := host.FindProcess(uint32(cmd.Process.Pid)) + if !ok { + t.Fatal("child not found") + } + h, err := syscall.OpenProcess(0x1000, false, p.PID) // PROCESS_QUERY_LIMITED_INFORMATION, retained through Wait + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(h) + if _, err := host.GetProcessMemory(p); err != nil { + t.Fatal(err) + } + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + cmd.Wait() + if pm, err := host.GetProcessMemory(p); err == nil { + t.Fatalf("accepted torn-down counters of exited process: %+v", pm) + } +} + // The real counters read this test process and the computer. func TestMemoryCounters(t *testing.T) { p, ok := host.FindProcess(uint32(os.Getpid())) diff --git a/internal/emu/bootwatch_share_test.go b/internal/emu/bootwatch_share_test.go new file mode 100644 index 0000000..da95b6d --- /dev/null +++ b/internal/emu/bootwatch_share_test.go @@ -0,0 +1,128 @@ +//go:build windows + +package emu + +import ( + "context" + "errors" + "strings" + "sync/atomic" + "testing" + "time" + + "modavd/internal/host" + "modavd/internal/jsonord" +) + +func stubShareDefaultAdb(t *testing.T, fn func(console int) (bool, error)) { + t.Helper() + old := shareDefaultAdbServer + shareDefaultAdbServer = func(_ context.Context, console int) (bool, error) { return fn(console) } + t.Cleanup(func() { shareDefaultAdbServer = old }) +} + +// The boot watch registers the instance with the default adb server only once a start has finished on its qemu, not +// while a stop runs, and not when mas-avd.json turns it off (ModAVD/ModAVD#196). A failure is warned once until a +// look works again. +func TestBootWatchShareDefaultAdbOnce(t *testing.T) { + m, logs := opsTestManager(t) + created, err := m.Create(context.Background(), CreateRequest{filesOnly: true}) + if err != nil { + t.Fatal(err) + } + i := created.Index + q := host.Process{PID: 4242, Created: 1_000_500} + console := m.Root.Ports().Console(i) + var calls atomic.Int32 + var fail atomic.Bool + var got atomic.Int32 + stubShareDefaultAdb(t, func(c int) (bool, error) { + calls.Add(1) + got.Store(int32(c)) + if fail.Load() { + return false, errors.New("boom") + } + return true, nil + }) + ctx := context.Background() + w := &bootWatch{m: m, i: i, q: q} + + if w.shareDefaultAdbOnce(ctx) { + t.Fatal("asked without a boot record") + } + m.writeBootRecord(i, bootRecord{QemuPID: 4100, QemuCreated: q.Created, BootID: testBootA}) + if w.shareDefaultAdbOnce(ctx) { + t.Fatal("asked with the record of another qemu") + } + m.writeBootRecord(i, bootRecord{QemuPID: q.PID, QemuCreated: q.Created, BootID: testBootA}) + if !w.shareDefaultAdbOnce(ctx) || calls.Load() != 1 || int(got.Load()) != console { + t.Fatalf("asked=%d console=%d, want 1 call for console %d", calls.Load(), got.Load(), console) + } + + l, ok, err := host.TryLock(m.lockPath(i, "stop")) + if err != nil || !ok { + t.Fatalf("stop lock: %v %v", ok, err) + } + if w.shareDefaultAdbOnce(ctx) { + t.Error("asked while a stop runs") + } + l.Unlock() + + fail.Store(true) + before := len(logs.Messages()) + for k := 0; k < 3; k++ { + if !w.shareDefaultAdbOnce(ctx) { + t.Fatal("a failing look should still ask") + } + } + if warned := logs.Messages()[before:]; len(warned) != 1 || !strings.Contains(warned[0], "没能登记到默认的 adb server") { + t.Fatalf("warnings after three failures: %q, want one", warned) + } + fail.Store(false) + w.shareDefaultAdbOnce(ctx) + fail.Store(true) + w.shareDefaultAdbOnce(ctx) + if n := len(logs.Messages()) - before; n != 2 { + t.Fatalf("a failure after a success should warn again: %d warnings", n) + } + + if err := m.Root.UpdateMetadata(func(o *jsonord.Obj) error { + o.Set("shareDefaultAdbServer", jsonord.NewBool(false)) + return nil + }); err != nil { + t.Fatal(err) + } + if w.shareDefaultAdbOnce(ctx) { + t.Error("asked although mas-avd.json turned it off") + } +} + +// The loop looks right away, again at every tick, and returns once ctx ends. +func TestBootWatchShareDefaultAdbLoop(t *testing.T) { + m := markerTestManager(t) + const i = 2 + q := host.Process{PID: 4242, Created: 1_000_500} + m.writeBootRecord(i, bootRecord{QemuPID: q.PID, QemuCreated: q.Created, BootID: testBootA}) + looks := make(chan struct{}, 8) + stubShareDefaultAdb(t, func(int) (bool, error) { looks <- struct{}{}; return false, nil }) + ctx, cancel := context.WithCancel(context.Background()) + ticks := make(chan time.Time) + done := make(chan struct{}) + go func() { defer close(done); (&bootWatch{m: m, i: i, q: q}).shareDefaultAdb(ctx, ticks) }() + wait := func(what string) { + select { + case <-looks: + case <-time.After(3 * time.Second): + t.Fatalf("no look %s", what) + } + } + wait("right away") + ticks <- time.Now() + wait("after a tick") + cancel() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("the loop did not return after cancel") + } +} diff --git a/internal/emu/bridge.go b/internal/emu/bridge.go index 30dc23b..9cfac8d 100644 --- a/internal/emu/bridge.go +++ b/internal/emu/bridge.go @@ -148,7 +148,7 @@ func (m *Manager) bridgePending(ctx context.Context, i int) bool { if !regularFile(bridgeMarker(m.Root, i)) { return false } - b := m.guestBridge(ctx, i) + b := bridgeGuest(m, ctx, i) return b != "" && b != bridgeProp } @@ -331,8 +331,10 @@ func (m *Manager) runBridgeScript(ctx context.Context, i int, action string, reb } // finishBridge is the last step of a start that launched the instance, when the marker is there and the bridge is not: -// the script's install, and a cold boot (same window and timeout, without this step) whenever the bridge has not taken -// effect yet. A script that fails is only a warning: the instance keeps running, arm64 apps on the stock translator, +// the script's install, and a cold boot (same timeout, without this step) whenever the bridge has not taken effect +// yet. The cold boots that only prepare (the system not writable yet) are headless; the one after the install, which +// should load the bridge, has the window when the start asked for one (and Start's afterStartBridge boots once more +// with it when the instance is left headless). A script that fails is only a warning: the instance keeps running, arm64 apps on the stock translator, // and the next start tries again. A cold boot that fails is the start's error (the instance is off or half up then). // Returns the result of the last start. Start calls it only when bridgeWanted, which already leaves arm64 guests out. func (m *Manager) finishBridge(ctx context.Context, i int, opt StartOptions, res *StartResult) (*StartResult, error) { @@ -366,7 +368,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re opt.Progress(Progress{Step: "bridge", Message: fmt.Sprintf("实例 %d 装 arm64 执行桥(第 %d 轮)", i, round), Done: 0, Total: progressTotal}) } - run, err := mm.runBridgeScript(ctx, i, "install", false) + run, err := bridgeScript(mm, ctx, i, "install", false) if err != nil && errs.As(err).Code == errs.Canceled { res.Warnings = append(res.Warnings, col.Messages()...) return nil, err @@ -380,7 +382,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re mm.Log.Warnf("实例 %d 没装上执行桥(退出码 %d:%s),下次开机再试;日志 %s", i, run.Code, run.Last, run.LogFile) break } - if mm.guestBridge(ctx, i) == bridgeProp { + if bridgeGuest(mm, ctx, i) == bridgeProp { break } // a second "not writable" in a row: another cold boot will not change that (an emulator without the marker @@ -393,7 +395,13 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re } else { notWritable = 0 } - r2, err := m.bridgeRestart(ctx, i, opt) + // The script installed (0): this cold boot loads the bridge and should be the last one, so it comes back the + // way the start was asked for. Not writable yet (3): another round follows, and this boot only prepares, + // headless. Should the bridge still be missing after a last boot with the window, the next round's boot is + // headless again, and Start gives the window back at the end (afterStartBridge). + bopt := opt + bopt.Window = opt.Window && run.Code == 0 + r2, err := m.bridgeRestart(ctx, i, bopt) if err != nil { res.Warnings = append(res.Warnings, col.Messages()...) return nil, errs.Wrap(errs.As(err).Code, err, fmt.Sprintf("实例 %d 装执行桥时关机再开机失败: %v", i, err)) @@ -401,7 +409,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re r2.Warnings = append(res.Warnings, r2.Warnings...) res = r2 } - if mm.guestBridge(ctx, i) == bridgeProp { + if bridgeGuest(mm, ctx, i) == bridgeProp { mm.Log.Infof("实例 %d 已装好执行桥", i) } else { mm.Log.Warnf("实例 %d 没装上执行桥,下次开机再试;日志见 logs 里的 modavd-bridge-%d-*.log", i, i) @@ -414,7 +422,7 @@ func (m *Manager) installBridge(ctx context.Context, i int, opt StartOptions, re // step had nothing to do). When the guest's files are not the package's payload (bridgeOutdated: the package was // updated since), the script's install replaces the files that differ (each is pushed under a temporary name, checked // against the payload's hash and renamed into place), and one cold boot (same window and timeout, without the bridge -// step) loads them. Decided under the bridge lock: another modavd holding it is installing or updating already. +// step: it is the start's last boot) loads them. Decided under the bridge lock: another modavd holding it is installing or updating already. // // The files the guest has keep working, so the update waits for a later start while an install holds the guest lock // or an app is in the foreground once the setup is done (someone began to use the instance meanwhile; the update @@ -564,7 +572,9 @@ func init() { bridgeStart = (*Manager).Start } // Bridge installs, uninstalls or verifies the execution bridge on instance i. Restarts are cold boots: a running // instance comes back the way it ran, with its window or headless (a start never switches, StartOptions.Window; -// headless when that cannot be told), and one that was off boots headless. +// headless when that cannot be told), and one that was off boots headless. The boots in between that only prepare +// (the install's start and its cold boots up to the one that loads the bridge, the uninstall's first cold boot when a +// second follows) are headless; only the last one has the window. // - install: places the marker; a running instance without the bridge is restarted, an off one started, and the // start installs (finishBridge). An instance that already has the bridge gets the script's install anyway, which // only updates a payload that differs from this package's; then it is restarted so the new one is loaded. @@ -641,8 +651,9 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu res.Restarted = true } if !wasRunning || res.Restarted { - // this start installs (installBridge, under the lock held above; its cold boots keep the window) - sr, err := bridgeStart(m, ctx, i, StartOptions{Window: windowed, bridgeLocked: true}) + // this start installs (installBridge, under the lock held above): it boots headless while it prepares, and + // its last boot comes back the way the instance ran (bridgePrep: it was running without the bridge) + sr, err := bridgeStart(m, ctx, i, StartOptions{Window: windowed, bridgeLocked: true, bridgePrep: res.Restarted}) if err != nil { return nil, err } @@ -704,9 +715,13 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu if err != nil { return res, err } + // headless: a boot that only prepares, with another cold boot after it (an installed bridge is taken off by one); + // should that one not come, the instance gets its window back below + headless := false if run.Code == 3 { // the first remount of this instance only turned verity off: boot cold and run it again - sr, err := m.bridgeRestart(ctx, i, StartOptions{Window: windowed}) + headless = windowed && installed + sr, err := m.bridgeRestart(ctx, i, StartOptions{Window: windowed && !headless}) if err != nil { return res, err } @@ -726,6 +741,13 @@ func (m *Manager) Bridge(ctx context.Context, i int, action string) (*BridgeResu } res.Warnings = append(res.Warnings, sr.Warnings...) res.Restarted = true + } else if headless { + // the second cold boot did not come (the script did not take it off): back the way it ran + sr, err := m.bridgeRestart(ctx, i, StartOptions{Window: true}) + if err != nil { + return res, err + } + res.Warnings = append(res.Warnings, sr.Warnings...) } res.Installed = bridgeGuest(m, ctx, i) == bridgeProp // An instance whose bridge was never installed (created by modavd, never booted by it) keeps the marker when diff --git a/internal/emu/bridge_prep_test.go b/internal/emu/bridge_prep_test.go new file mode 100644 index 0000000..8e2edb5 --- /dev/null +++ b/internal/emu/bridge_prep_test.go @@ -0,0 +1,162 @@ +//go:build windows + +package emu + +import ( + "context" + "testing" + + "modavd/internal/win" +) + +// TestBridgePrepLaunch: a start asked for a window launches headless only when the bridge is surely installed after +// the launch (its marker not there yet, or the caller says so); with the marker there it launches as asked. +func TestBridgePrepLaunch(t *testing.T) { + for _, c := range []struct { + name string + window, prep, bridge, marker bool + want bool + }{ + {"window, bridge wanted, no marker yet", true, false, true, false, true}, + {"window, marker there", true, false, true, true, false}, + {"window, marker there, caller knows", true, true, true, true, true}, + {"headless start", false, false, true, false, false}, + {"headless start, caller knows", false, true, true, true, false}, + {"no bridge", true, false, false, false, false}, + {"no bridge, caller knows", true, true, false, true, false}, + } { + opt := StartOptions{Window: c.window, bridgePrep: c.prep} + if got := bridgePrepLaunch(opt, c.bridge, c.marker); got != c.want { + t.Errorf("%s: %v, want %v", c.name, got, c.want) + } + } +} + +// TestStartBridgePrepHeadless: the end of a start that launched an instance whose bridge is still to be installed. +// With a window asked for, every boot that only prepares (the cold boot after a script run that found the system not +// writable yet) is headless, and the last boot, a cold boot after a normal stop, has the window: either the one after +// the install (which loads the bridge), or one more when the instance is left headless. A headless start stays +// headless throughout. The starts here are fakes (bridgeStart): no emulator, a temporary root. +func TestStartBridgePrepHeadless(t *testing.T) { + m, i := opsTestInstance(t) + if err := writeBridgeMarker(m.Root, i); err != nil { + t.Fatal(err) + } + start, guest, script := bridgeStart, bridgeGuest, bridgeScript + t.Cleanup(func() { bridgeStart, bridgeGuest, bridgeScript = start, guest, script }) + ctx := context.Background() + const stock = "libndk_translation.so" + for _, c := range []struct { + name string + window bool // the start asked for the window + launchWindow bool // how the start launched the first boot + locked bool // bridgeLocked (no update step: the guest is never asked for hashes) + codes []int // the script's exit codes, in order (0 after them) + loadedAfter int // the guest runs the bridge after this many cold boots (-1: never) + want []bool + }{ + // the production case: marker placed by this start, so the first boot is headless; verity off, install, load + {"window, prep launch, three rounds", true, false, false, []int{3, 0, 0}, 2, []bool{false, true}}, + // the marker was there: the first boot had the window (not known before), the prep cold boot does not + {"window, windowed launch, three rounds", true, true, false, []int{3, 0, 0}, 2, []bool{false, true}}, + {"window, prep launch, installed in one round", true, false, false, []int{0, 0}, 1, []bool{true}}, + // never writable: the prep boot is headless, then one more cold boot with the window + {"window, prep launch, not writable twice", true, false, false, []int{3, 3}, -1, []bool{false, true}}, + // the install's boot had the window, the bridge was still missing, the next round's boot only prepared + {"window, loaded late", true, false, false, []int{0, 3, 0}, 3, []bool{true, false, true}}, + // prep launch, and the guest has the bridge after all: one cold boot with the window + {"window, prep launch, nothing to install", true, false, true, nil, 0, []bool{true}}, + {"headless, three rounds", false, false, false, []int{3, 0, 0}, 2, []bool{false, false}}, + {"headless, not writable twice", false, false, false, []int{3, 3}, -1, []bool{false}}, + } { + var starts []StartOptions + bridgeStart = func(_ *Manager, _ context.Context, _ int, opt StartOptions) (*StartResult, error) { + starts = append(starts, opt) + // what Start reports for a launch (the instance was stopped before each of these) + return &StartResult{Launched: true, launchWindow: opt.Window && !opt.launchHeadless}, nil + } + bridgeGuest = func(*Manager, context.Context, int) string { + if c.loadedAfter >= 0 && len(starts) >= c.loadedAfter { + return bridgeProp + } + return stock + } + n := 0 + bridgeScript = func(*Manager, context.Context, int, string, bool) (bridgeRun, error) { + code := 0 + if n < len(c.codes) { + code = c.codes[n] + } + n++ + return bridgeRun{Code: code, Last: "done"}, nil + } + opt := StartOptions{Window: c.window, bridgeLocked: c.locked} + res, err := m.afterStartBridge(ctx, i, opt, true, &StartResult{Launched: true, launchWindow: c.launchWindow}) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + got := make([]bool, len(starts)) + for k, o := range starts { + got[k] = o.Window + if !o.NoBridge || o.Headless || o.bridgePrep || o.launchHeadless { + t.Errorf("%s: cold boot %d: NoBridge %v Headless %v bridgePrep %v launchHeadless %v", c.name, k+1, + o.NoBridge, o.Headless, o.bridgePrep, o.launchHeadless) + } + } + if !equalBools(got, c.want) { + t.Errorf("%s: cold boots with Window %v, want %v", c.name, got, c.want) + } + if res.launchWindow != c.window { + t.Errorf("%s: the instance ends with window %v, want %v", c.name, res.launchWindow, c.window) + } + } +} + +// TestBridgeUninstallRestoresWindow: the uninstall's first cold boot on a windowed instance with the bridge is +// headless because a second one should follow; when the script then does not take the bridge off (no second cold +// boot), the instance still gets its window back with one more. +func TestBridgeUninstallRestoresWindow(t *testing.T) { + m, i := opsTestInstance(t) + windowed, _ := startSleeper(t) // a readable command line without -no-window + findQemu, start, guest, script := bridgeFindQemu, bridgeStart, bridgeGuest, bridgeScript + t.Cleanup(func() { bridgeFindQemu, bridgeStart, bridgeGuest, bridgeScript = findQemu, start, guest, script }) + var starts []StartOptions + bridgeFindQemu = func(*Manager, int) (win.Process, bool, error) { return windowed, true, nil } + bridgeStart = func(_ *Manager, _ context.Context, _ int, opt StartOptions) (*StartResult, error) { + starts = append(starts, opt) + return &StartResult{Launched: true, launchWindow: opt.Window}, nil + } + bridgeGuest = func(*Manager, context.Context, int) string { return bridgeProp } + codes := []int{3, 1} + n := 0 + bridgeScript = func(*Manager, context.Context, int, string, bool) (bridgeRun, error) { + code := 0 + if n < len(codes) { + code = codes[n] + } + n++ + return bridgeRun{Code: code, Last: "done"}, nil + } + if _, err := m.Bridge(context.Background(), i, "uninstall"); err != nil { + t.Fatal(err) + } + got := make([]bool, len(starts)) + for k, o := range starts { + got[k] = o.Window + } + if want := []bool{false, true}; !equalBools(got, want) { + t.Fatalf("cold boots with Window %v, want %v", got, want) + } +} + +func equalBools(a, b []bool) bool { + if len(a) != len(b) { + return false + } + for k := range a { + if a[k] != b[k] { + return false + } + } + return true +} diff --git a/internal/emu/bridge_test.go b/internal/emu/bridge_test.go index 7cec406..a31b51f 100644 --- a/internal/emu/bridge_test.go +++ b/internal/emu/bridge_test.go @@ -170,9 +170,10 @@ func TestBridgeLockExcludes(t *testing.T) { } } -// TestBridgeKeepsWindowMode: every cold boot of modavd bridge (the install's on an instance without the bridge, the one -// after an update, both of an uninstall) brings a running instance back the way it ran, with its window or headless; -// an instance that was off is started headless. +// TestBridgeKeepsWindowMode: modavd bridge brings a running instance back the way it ran, with its window or headless +// (the install's start on an instance without the bridge, the cold boot after an update, the uninstall's last one); +// the boots that only prepare are headless (the uninstall's first cold boot when a second follows; the install's start +// is marked bridgePrep, Start then launches headless); an instance that was off is started headless. func TestBridgeKeepsWindowMode(t *testing.T) { m, i := opsTestInstance(t) windowed, _ := startSleeper(t) // a readable command line without -no-window @@ -235,9 +236,19 @@ func TestBridgeKeepsWindowMode(t *testing.T) { t.Errorf("%s, %s: %d starts, want %d", c.name, run.mode, len(starts), want) } for k, opt := range starts { - if opt.Window != run.window || opt.Headless { + want := run.window + if c.action == "uninstall" && run.qemu != nil && k == 0 { + // the cold boot between the uninstall's two script runs only prepares: headless + want = false + } + if opt.Window != want || opt.Headless { t.Errorf("%s, %s: start %d has Window %v Headless %v, want Window %v", c.name, run.mode, k+1, - opt.Window, opt.Headless, run.window) + opt.Window, opt.Headless, want) + } + // the install's start on an instance without the bridge prepares it (Start boots it headless and + // gives the window back on its last boot) + if prep := c.action == "install" && !c.installed && run.qemu != nil; opt.bridgePrep != prep { + t.Errorf("%s, %s: start %d has bridgePrep %v, want %v", c.name, run.mode, k+1, opt.bridgePrep, prep) } } } diff --git a/internal/emu/closing_test.go b/internal/emu/closing_test.go new file mode 100644 index 0000000..d0e1fcf --- /dev/null +++ b/internal/emu/closing_test.go @@ -0,0 +1,53 @@ +//go:build windows + +package emu + +import ( + "testing" + "time" + + "modavd/internal/win" +) + +// TestClosingItself: a running, not booted instance reads as going down by itself only when adb no longer reaches +// the guest and no unfinished modavd boot of this qemu is recorded (setup marker). +func TestClosingItself(t *testing.T) { + m := markerTestManager(t) + now := time.Now() + qemuAt := func(age time.Duration) win.Process { + return win.Process{PID: 4000, PPID: 3000, Name: "qemu-system-x86_64.exe", Created: filetimeOf(now.Add(-age))} + } + old, young := qemuAt(10*time.Minute), qemuAt(20*time.Second) + + if !m.closingItself(3, old, false, now) { + t.Error("old qemu, adb gone, no marker: want closing") + } + if m.closingItself(3, old, true, now) { + t.Error("adb still answers (guest reboot, booting): want not closing") + } + if !m.closingItself(3, young, false, now) { + t.Error("window closed right after the boot: want closing") + } + + // an interrupted modavd boot of this qemu (marker before its deadline): still booting + mk := setupMarker{QemuPID: old.PID, QemuCreated: old.Created, ValidUntil: now.Add(time.Hour).Unix()} + m.writeSetupMarker(3, mk) + if got, ok := m.readSetupMarker(3); !ok || got != mk { + t.Fatalf("marker not written: %+v %v", got, ok) + } + if m.closingItself(3, old, false, now) { + t.Error("unfinished modavd boot of this qemu: want not closing") + } + // the marker of another qemu (an earlier boot) does not count + mk.QemuPID = 4001 + m.writeSetupMarker(3, mk) + if !m.closingItself(3, old, false, now) { + t.Error("marker of another qemu: want closing") + } + // a marker past its deadline: that boot has long been in use + mk.QemuPID, mk.ValidUntil = old.PID, now.Add(-time.Minute).Unix() + m.writeSetupMarker(3, mk) + if !m.closingItself(3, old, false, now) { + t.Error("stale marker: want closing") + } +} diff --git a/internal/emu/createinit_test.go b/internal/emu/createinit_test.go index af7ce77..1f10e8c 100644 --- a/internal/emu/createinit_test.go +++ b/internal/emu/createinit_test.go @@ -257,7 +257,7 @@ func TestStatusCreatingThroughGetprop(t *testing.T) { savedScan, savedBooted := qemuScan, statusBooted t.Cleanup(func() { qemuScan, statusBooted = savedScan, savedBooted }) qemuScan = func([]int) map[int]win.Process { return map[int]win.Process{6: {PID: 4242}} } - statusBooted = func(m *Manager, _ context.Context, i int) bool { + statusBooted = func(m *Manager, _ context.Context, i int) (bool, bool) { // the setup is over while the guest shuts down m.removeCreateProgress(i) if err := m.setCreation(i, "", "", ""); err != nil { @@ -265,7 +265,7 @@ func TestStatusCreatingThroughGetprop(t *testing.T) { } l.Unlock() unlocked = true - return false + return false, false } info, err := m.Info(ctx, 6) if err != nil || !info.Running || info.Status != StatusCreating || info.Busy != "creating" || info.CreationProgress != 88 { diff --git a/internal/emu/manager.go b/internal/emu/manager.go index 80f550d..590fe9d 100644 --- a/internal/emu/manager.go +++ b/internal/emu/manager.go @@ -236,9 +236,16 @@ func (m *Manager) findQemu(i int) (host.Process, bool, error) { // bootCompleted: `getprop sys.boot_completed` says 1. reregister=false while the instance is booting (it has not // registered with the private server yet, which is normal then). func (m *Manager) bootCompleted(ctx context.Context, i int, timeout time.Duration, reregister bool) bool { + booted, _ := m.bootProbe(ctx, i, timeout, reregister) + return booted +} + +// bootProbe is bootCompleted plus answered: the getprop ran in the guest at all (adb exit 0), so the device was +// reachable even when it printed nothing. +func (m *Manager) bootProbe(ctx context.Context, i int, timeout time.Duration, reregister bool) (booted, answered bool) { res := m.ADB.Run(ctx, adb.RunOptions{Serial: m.Root.Ports().Serial(i), Timeout: timeout, Reregister: reregister}, "shell", "getprop", "sys.boot_completed") - return res.Code == 0 && strings.TrimSpace(res.Out) == "1" + return res.Code == 0 && strings.TrimSpace(res.Out) == "1", res.Code == 0 } // ---- guest commands ---------------------------------------------------------------------------------------------- diff --git a/internal/emu/manager_test.go b/internal/emu/manager_test.go index 75ce801..0bd2bec 100644 --- a/internal/emu/manager_test.go +++ b/internal/emu/manager_test.go @@ -122,9 +122,9 @@ func TestStopTargetsIncludesOnlyActiveCreationsAndQemu(t *testing.T) { } return map[int]win.Process{2: {PID: 4242}, 3: {PID: 4243}, 9: {PID: 4249}} } - statusBooted = func(*Manager, context.Context, int) bool { + statusBooted = func(*Manager, context.Context, int) (bool, bool) { t.Fatal("stop target discovery called adb") - return false + return false, false } targets, err := m.StopTargets(context.Background()) if err != nil || !slices.Equal(targets, []int{1, 2, 3}) || scans != 1 { diff --git a/internal/emu/portplan_test.go b/internal/emu/portplan_test.go index 6f43731..ad3d3ac 100644 --- a/internal/emu/portplan_test.go +++ b/internal/emu/portplan_test.go @@ -79,6 +79,8 @@ func TestMain(m *testing.M) { case "client": os.Exit(fakeAdbClientMain(os.Args[1:])) } + // No test may reach the machine's default adb server (5037): boot watches registering there are faked. + shareDefaultAdbServer = func(context.Context, int) (bool, error) { return false, nil } os.Exit(m.Run()) } diff --git a/internal/emu/start.go b/internal/emu/start.go index c0a88f5..cddd4d3 100644 --- a/internal/emu/start.go +++ b/internal/emu/start.go @@ -112,24 +112,61 @@ func (m *Manager) Start(ctx context.Context, i int, opt StartOptions) (*StartRes // The execution bridge (bridge.go): an instance this start launches gets its boot marker first (bridgeMarker). bridge := opt.watchQemu == nil && !opt.NoBridge && m.bridgeWanted(i, inst.Config) opt.bridgeMarker = bridge + // A launch that is known to be followed by the bridge's install and cold boots only prepares the instance: it is + // headless, and the window comes with the last boot (afterStartBridge). + opt.launchHeadless = bridgePrepLaunch(opt, bridge, regularFile(bridgeMarker(m.Root, i))) res, err := mm.start(ctx, inst, opt, t0) if err != nil { return nil, err } res.Warnings = col.Messages() + return m.afterStartBridge(ctx, i, opt, bridge, res) +} + +// bridgePrepLaunch: a launch of this start (opt; bridge: Start wants the execution bridge; marker: its boot marker is +// there already) is headless although the caller asked for a window, because the bridge is surely installed after it, +// with a cold boot or two: the marker is not there yet (the instance never had the bridge: this launch places it), or +// the caller knows (bridgePrep). With the marker there, whether the guest has the bridge is only known once it runs, +// so that launch has the window as asked. +func bridgePrepLaunch(opt StartOptions, bridge, marker bool) bool { + return bridge && opt.Window && (opt.bridgePrep || !marker) +} + +// afterStartBridge is the end of Start (res: its boot, set up): the execution bridge's install or update, and the +// last boot with the window asked for when the instance runs headless after them. +func (m *Manager) afterStartBridge(ctx context.Context, i int, opt StartOptions, bridge bool, + res *StartResult) (*StartResult, error) { + var err error + switch { // The execution bridge (bridge.go): its marker is there and the guest still runs the stock translator. After the // whole setup, because installing takes a cold boot or two; and only on a boot this start launched, never on a // running instance it adopted (someone may be playing on it: the install restarts adbd and boots it cold). - if bridge && res.Launched && m.bridgePending(ctx, i) { + case bridge && res.Launched && m.bridgePending(ctx, i): if opt.bridgeLocked { - return m.installBridge(ctx, i, opt, res) + res, err = m.installBridge(ctx, i, opt, res) + } else { + res, err = m.finishBridge(ctx, i, opt, res) } - return m.finishBridge(ctx, i, opt, res) - } // The bridge is installed: its files are updated when they are not this package's payload (the package was updated // since), also only on a boot this start launched. `modavd bridge install` (bridgeLocked) updates by itself. - if bridge && res.Launched && !opt.bridgeLocked { - return m.updateBridge(ctx, i, opt, res) + case bridge && res.Launched && !opt.bridgeLocked: + res, err = m.updateBridge(ctx, i, opt, res) + } + if err != nil || res == nil { + return res, err + } + // Asked for the window, and the instance runs headless after the prep boots (or after a prep launch with nothing + // to install after all): one more cold boot, after a normal shutdown, gives it the window. A running instance + // does not switch (StartOptions.Window), so this is never an adopt. + if opt.Window && res.Launched && !res.launchWindow { + m.Log.Infof("ModAVD 实例 %d 准备阶段无头开机已做完,关机后按要求带窗口开机", i) + r, err := m.bridgeRestart(ctx, i, StartOptions{Window: true, Timeout: opt.Timeout, Progress: opt.Progress, + creating: opt.creating}) + if err != nil { + return nil, errs.Wrap(errs.As(err).Code, err, fmt.Sprintf("实例 %d 准备完以后带窗口开机失败: %v", i, err)) + } + r.Warnings = append(append([]string{}, res.Warnings...), r.Warnings...) + res = r } return res, nil } @@ -280,7 +317,11 @@ func (m *Manager) start(ctx context.Context, inst *avd.Instance, opt StartOption m.Log.Warnf("实例 %d 没放上执行桥的开机标记: %v", i, err) } } - plan, err := m.prepareLaunch(ctx, inst, opt.Window) + launchWindow := opt.Window && !opt.launchHeadless + if opt.launchHeadless { + m.Log.Infof("ModAVD 实例 %d 开机后要装 arm64 执行桥(要冷启动一到两次):准备阶段无头开机,装好后再带窗口开机", i) + } + plan, err := m.prepareLaunch(ctx, inst, launchWindow) if err != nil { return nil, err } @@ -301,6 +342,7 @@ func (m *Manager) start(ctx context.Context, inst *avd.Instance, opt StartOption launchedAt = time.Now() emulator = &p res.Launched = true + res.launchWindow = launchWindow mode = setupLaunched res.LogFile = plan.logPath // written before qemu shows up (a cancel may come first): until then it names qemu by its parent diff --git a/internal/emu/status.go b/internal/emu/status.go index 239bc41..ec9ed32 100644 --- a/internal/emu/status.go +++ b/internal/emu/status.go @@ -23,9 +23,25 @@ import ( // depend on what runs on the machine either. var qemuScan func(indexes []int) map[int]host.Process -// statusBooted is the getprop of instanceInfo; a test replaces it. -var statusBooted = func(m *Manager, ctx context.Context, i int) bool { - return m.bootCompleted(ctx, i, statusAdbTimeout, true) +// statusBooted is the getprop of instanceInfo; a test replaces it. answered: adb reached the guest at all (the +// getprop ran, whatever it printed); false when the device is offline or unknown to the server. +var statusBooted = func(m *Manager, ctx context.Context, i int) (booted, answered bool) { + return m.bootProbe(ctx, i, statusAdbTimeout, true) +} + +// closingItself: a running, not booted instance that no modavd is starting, stopping or bridging is going down by +// itself (its window was closed, Android was powered off inside): no unfinished modavd boot of this qemu (setup +// marker; a start in progress holds the start lock anyway) and adb no longer reaches the guest. Measured 11-13 on a +// windowed instance: after WM_CLOSE the guest says sys.powerctl=shutdown, adb loses it at ~3 s and qemu exits at +// ~7.5 s; that gap read as "starting" (开机中). A guest reboot reads the same until its adbd is back (seconds), then +// "starting" again; so does a qemu launched outside modavd until its adbd is up. +func (m *Manager) closingItself(i int, p host.Process, answered bool, now time.Time) bool { + if answered { + return false + } + mk, ok := m.readSetupMarker(i) + use, _ := setupMarkerUse(mk, ok, p, now) + return !use } // qemus is the process scan of the queries: qemuScan when a test set it, else m.scanQemu. @@ -161,6 +177,7 @@ func (m *Manager) instanceInfo(ctx context.Context, in *avd.Instance, p *host.Pr if info.Creation == creationCreating { info.CreationProgress = m.createPercent(i) } + closing := false if p != nil { info.Running = true info.Window, _ = qemuWindowed(*p) @@ -169,17 +186,22 @@ func (m *Manager) instanceInfo(ctx context.Context, in *avd.Instance, p *host.Pr info.StartedAt = t.Format(time.RFC3339) info.StartedAtMS = t.UnixMilli() } + answered := true if booted != nil { info.Booted = *booted } else { - info.Booted = statusBooted(m, ctx, i) + info.Booted, answered = statusBooted(m, ctx, i) } + closing = !info.Booted && m.closingItself(i, *p, answered, time.Now()) // Only for a running instance: the name is per console port, not per root, so an offline instance would // report another root's mapping on the same port. info.Screencap.ShmReady = host.SharedMemoryExists(info.Screencap.Shm) } info.Status = statusOf(info.Running, info.Booted) info.Busy = busyOf(m.stopping(i), m.lockHeld(i, "start"), m.lockHeld(i, "bridge")) + if info.Busy == "" && closing { + info.Busy = "stopping" + } // "creating" overrides what the boots of that setup look like from outside switch info.Creation { case creationCreating: diff --git a/internal/emu/status_test.go b/internal/emu/status_test.go index f698358..3b9b17b 100644 --- a/internal/emu/status_test.go +++ b/internal/emu/status_test.go @@ -85,8 +85,12 @@ func TestPollTakesGuestArchFromListedConfig(t *testing.T) { if err != nil || len(list.Instances) != 1 { t.Fatalf("%s: list: %+v %v", label, list, err) } - a, _ := json.Marshal(info) - b, _ := json.Marshal(list.Instances[0]) + // host_free_bytes is the host disk's free space at that moment: anything writing to the disk between two + // reads changes it (CI failed on it), and it has nothing to do with what this test compares + in, li := *info, list.Instances[0] + in.DataSpace.HostFreeBytes, li.DataSpace.HostFreeBytes = 0, 0 + a, _ := json.Marshal(in) + b, _ := json.Marshal(li) if string(a) != string(b) { t.Fatalf("%s: info and list differ:\n%s\n%s", label, a, b) } diff --git a/internal/emu/types.go b/internal/emu/types.go index ee439c1..edd1a8c 100644 --- a/internal/emu/types.go +++ b/internal/emu/types.go @@ -57,7 +57,8 @@ type InstanceInfo struct { Window bool `json:"window"` // Busy: "starting" / "stopping" / "bridging" while some modavd process holds that lock of the instance (AUTO-MAS's // updater must not swap sdk\emulator then); "" otherwise. "bridging" covers the whole bridge install, including the - // stops and cold boots in it; callers that do not know a value treat it as busy. + // stops and cold boots in it; callers that do not know a value treat it as busy. "stopping" also when no lock is + // held but the instance is going down by itself (window closed, Android powered off; see closingItself). Busy string `json:"busy"` // Bridge (the arm64 execution bridge): "off" (instances..bridge is false), "pending" (the next start modavd // launches installs it), "installed" (the booted guest runs it) or "unknown". Only a booted guest is asked; an @@ -197,6 +198,9 @@ type StartOptions struct { // Window: boot with the emulator window (default headless, like AUTO-MAS silent mode). Headless: the caller wants // it headless. An instance already running the other way is not adopted: errs.Busy, shut it down first (switching // would take a restart). With neither, a running instance is adopted as it runs and a launch is headless. + // Window is how the instance comes back at the end: the boots that only prepare it (the execution bridge's install + // and its cold boots, bridge.go) are headless, and the last one, a cold boot after a normal shutdown, has the + // window. Window bool Headless bool // Timeout for Android to finish booting; 0 = DefaultBootTimeout. @@ -217,6 +221,11 @@ type StartOptions struct { creating bool // bridgeMarker: Start wants the execution bridge on this instance, so a launch places its boot marker first. bridgeMarker bool + // bridgePrep: the caller (Bridge) knows this launch installs the execution bridge, so it is headless even with + // Window (the install's last cold boot gets the window). Start also decides it by itself (bridgePrepLaunch). + bridgePrep bool + // launchHeadless: set by Start for start: a launch is headless whatever Window says (a bridge prep boot). + launchHeadless bool } type Progress struct { @@ -252,6 +261,11 @@ type StartResult struct { SetupPending bool `json:"setup_pending"` Warnings []string `json:"warnings"` // never null; setup steps that failed (none of them stop the boot) HostIME bool `json:"host_ime"` + + // launchWindow: the qemu this call launched has the emulator window (false for a headless launch, and when it + // launched nothing). A start asked for a window launches headless while the execution bridge is still to be + // installed (bridgePrepLaunch); its last boot gets the window. + launchWindow bool } type StopOptions struct { diff --git a/internal/host/memory.go b/internal/host/memory.go index 1d754af..e2fc452 100644 --- a/internal/host/memory.go +++ b/internal/host/memory.go @@ -3,7 +3,7 @@ package host // ProcessMemory is the memory of one process, in bytes. type ProcessMemory struct { Commit uint64 // private commit charge (Task Manager's "Commit size") - PeakCommit uint64 // the highest Commit has been + PeakCommit uint64 // OS PeakPagefileUsage at this read, not a sampled maximum or a final exit counter WorkingSet uint64 // in physical memory now } diff --git a/internal/report/collect.go b/internal/report/collect.go index 26d270e..9aa10af 100644 --- a/internal/report/collect.go +++ b/internal/report/collect.go @@ -808,7 +808,7 @@ func (c *collector) runningAndAdb() error { } c.L(fmt.Sprintf("pid %d 启动于 %s", q.PID, ct)) if q.CommitBytes != 0 { - c.L(fmt.Sprintf(" 内存:提交 %s,工作集 %s(隔 10 分钟的记录在 modavd-bootwatch.log)", + c.L(fmt.Sprintf(" 内存:提交 %s,工作集 %s(每分钟采样,变化超过 256 MiB 或满 10 分钟时记录,退出汇总见 modavd-bootwatch.log)", formatSize(float64(q.CommitBytes)), formatSize(float64(q.WorkingSetBytes)))) } cmd := q.CommandLine diff --git a/internal/root/root.go b/internal/root/root.go index 39d53c1..738f51c 100644 --- a/internal/root/root.go +++ b/internal/root/root.go @@ -703,6 +703,18 @@ func (r *Root) ScriptAdbServerPort() int { return scriptAdbServerPort(r.runtimeMetadata()) } +// ShareDefaultAdbServer: also register running instances with the default adb server (port 5037) when one runs, so +// tools that look there (`adb devices`) find them (ModAVD/ModAVD#196). On unless mas-avd.json has +// "shareDefaultAdbServer": false. +func (r *Root) ShareDefaultAdbServer() bool { + v := r.runtimeMetadata().Get("shareDefaultAdbServer") + if v.Kind() != jsonord.Bool { + return true + } + b, _ := v.BoolVal() + return b +} + func adbServerPort(o *jsonord.Obj) int { if port, ok := metadataPort(o, "adbServerPort"); ok { return port diff --git a/internal/ui/API.en.md b/internal/ui/API.en.md index f938e0b..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`. @@ -220,7 +222,7 @@ ModAVD/ModAVD#101, Windows only. - Each instance has one boss key: `InstanceInfo.boss_key` (`"Ctrl+Alt+Shift+1"`, `""` for none), stored as `instances..bossKey` in `mas-avd.json`. Never set, instances 0–9 use Ctrl+Alt+Shift+number (the digit row of the main keyboard) and instances above 9 have none. It needs at least one of Ctrl, Alt and Shift and never takes Win; the key can be a digit, a letter, F1–F24, Num0–Num9, Space, Insert, Delete, Home, End, PageUp, PageDown or an arrow key. - The manager registers them all with `RegisterHotKey`: each key once, and only while an instance using it runs with its window; when they are all off or headless, it is unregistered. It checks every 3 seconds from the processes and instance files (no adb), and at once after a setting changed. A key that cannot be registered goes into `taken` and is tried again each round, so it is registered once the other program lets go. - A press looks at the instances using that key that run with a window: if one of them shows its window, all are hidden; if all are hidden, all are shown (as `modavd window show|hide`, by whether the window shows now, whoever hid it). Hidden windows keep rendering and the screenshot channel keeps working; they have no taskbar button and keep their position and size. -- The tray icon: a left click shows every hidden window; the right-click menu has "打开管理器" (open the manager) and "退出" (exit). Before exiting (any way it exits), the hidden windows are shown first, then all boss keys are unregistered and the icon removed. +- The tray icon: a single left click restores hidden instance windows after the system double-click interval. A double click hides or shows manager windows, preserving page state, without restoring instance windows. All manager windows on the dedicated Edge profile toggle together. If none exists, a double click opens one. The right-click menu's "打开管理器" (open the manager) restores existing windows first. With the default browser fallback, a double click only opens the manager. Every orderly exit path, including the tray's "退出" (exit), cancellation, and CLI update handoff, restores hidden manager and instance windows before unregistering boss keys and removing the icon. - The boss key field of the settings page: click the field and press the combination. A change is tried at once with `POST /api/boss-keys/check`, and once more when `PATCH` saves it; opening the settings marks a key in `taken` from `GET /api/boss-keys` with a warning icon. The field is hidden when `supported` is `false`. `resolution` accepts only 1080 and 720. A new instance defaults to 1080; they correspond to 1920×1080 / DPI 280 and 1280×720 / DPI 240 respectively; a change keeps the orientation and takes effect at the next boot. `InstanceInfo.config.resolution` is the tier recognized from the current configured width and height; after a change while running, it means the setting for the next boot. diff --git a/internal/ui/API.md b/internal/ui/API.md index 680acd1..7054b07 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -43,7 +43,7 @@ HTTP 4xx / 5xx,体为 `{"error":{"code":"…","message":"…","advice":"…"," | PATCH | `/api/instances/{i}` | `{title?, memory_mb?, cpu?, fps?, resolution?, data_partition_gb?, data_disk_auto?: bool, diagnostics?: bool, boss_key?: string}` | `UpdateResult`;诊断和老板键立即生效,普通配置下次开机生效。`data_partition_gb` 把数据盘改成指定大小(只能改大),`data_disk_auto: true` 改成自动扩盘,两个不能同时给。给了 `boss_key` 时多一个 `boss_key_taken`(bool):新键当场试注册过,被别的程序占着是 `true`(见下「老板键和托盘」) | | GET | `/api/instances/{i}/diagnostics/preview` | | `{"index","files","bytes"}`:关闭诊断时预计清理的份数、字节,不改配置、不删文件 | | DELETE | `/api/instances/{i}?confirm=1` | 不带 `confirm=1` 回 400 `usage` | `DeleteResult` | -| POST | `/api/instances/{i}/start` | `{}` | 202 `Job`:一律带窗口开机(管理器不开无头的,无头开机只走命令行);实例已经以无头方式在运行时,任务失败,code 是 `busy`(运行中不切换) | +| POST | `/api/instances/{i}/start` | `{}` | 202 `Job`:一律带窗口开机(管理器不开无头的,无头开机只走命令行);实例要先装执行桥时,装桥这几次准备用的开机都无头,装好后正常关机,最后一次带窗口开机(同 `modavd start --window`);实例已经以无头方式在运行时,任务失败,code 是 `busy`(运行中不切换) | | POST | `/api/instances/{i}/stop` | `{force?: bool, cancel_create?: bool}` | 202 `Job`;新建中默认 busy,显式 `cancel_create: true` 才取消;`force` 只管是否跳过 Android 正常关机。被取消的新建在结果里标记 `interrupted_create: true` | | POST | `/api/instances/{i}/install` | `multipart/form-data`,文件字段 `apk` | 202 `Job`(服务端存到临时文件,任务结束删掉) | | POST | `/api/instances/{i}/disk-info` | `{}` | 202 `Job`(`kind` 是 `disk-info`):数据盘的占用,随时可以查(开机时 `allocated_bytes`、`reclaimable_bytes` 不返回,`needs_shutdown` 为 `true`);结果是 emu 的 `DiskInfoResult`。整理中或锁不可用时返回可读取的文件信息及 `warnings`,不运行 qemu-img;读不到宿主占用时省略 `on_disk_bytes` | @@ -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`。 @@ -220,7 +222,7 @@ ModAVD/ModAVD#101,只有 Windows。 - 每台实例一个老板键:`InstanceInfo.boss_key`(`"Ctrl+Alt+Shift+1"`,`""` 是不要),存在 `mas-avd.json` 的 `instances..bossKey`;没写过时编号 0–9 是 Ctrl+Alt+Shift+编号(主键盘那排数字),超过 9 没有。至少带 Ctrl、Alt、Shift 中的一个,不收 Win;按键可以是数字、字母、F1–F24、Num0–Num9、Space、Insert、Delete、Home、End、PageUp、PageDown、方向键。 - 由管理器统一 `RegisterHotKey`:同一个键只注册一次,只在用它的实例里有带窗口运行着的时候注册,都关了或都是无头的就注销。每 3 秒按进程和实例文件对一遍(不走 adb),改了设置马上对一遍。注册不上的键进 `taken`,之后每轮再试,别的程序放了就注册上。 - 按下时,这个键管的实例里带窗口运行着的:有一台的窗口显示着就全部藏起来,全都藏着就全部显示(同 `modavd window show|hide`,按窗口现在显不显示,不管是谁藏的)。藏着照旧渲染、照旧能截图,任务栏上没有按钮,位置大小不变。 -- 托盘图标:左键点一下把藏着的窗口都显示出来;右键菜单「打开管理器」「退出」。退出前(任何方式退出都一样)先把藏着的窗口都显示出来,再注销全部老板键、去掉图标。 +- 托盘图标:左键单击等系统双击间隔过去后恢复藏着的实例窗口。双击切换管理器窗口的隐藏和显示,保留页面状态;双击不会恢复实例窗口。同一独立 Edge 配置的所有管理器窗口一起切换,没有窗口时打开一个。右键「打开管理器」优先显示已有窗口,默认浏览器回退时双击只打开管理器。任何正常退出路径(托盘「退出」、取消运行、交给命令行更新)都先恢复藏着的管理器和实例窗口,再注销老板键、去掉图标。 - 设置页的老板键一项:点一下输入框再按组合;改了先 `POST /api/boss-keys/check` 当场试一次,保存时 `PATCH` 再试一次;打开设置时按 `GET /api/boss-keys` 的 `taken` 在被占的键旁边标警告图标。`supported` 为 `false` 时不显示这一项。 `resolution` 只接受 1080 和 720。新建默认 1080,分别联动 1920×1080 / DPI 280 与 1280×720 / DPI 240;修改保留方向,下次开机生效。`InstanceInfo.config.resolution` 是按当前配置宽高识别的档位,运行中修改后表示下次开机的设置。 diff --git a/internal/ui/bosskey.go b/internal/ui/bosskey.go index 3bdd068..c22b387 100644 --- a/internal/ui/bosskey.go +++ b/internal/ui/bosskey.go @@ -29,10 +29,10 @@ type trayHost interface { Close() } -// trayEvents are the tray's callbacks, each on a goroutine of its own. +// trayEvents are delivered in order off the native window thread. type trayEvents struct { - hotKey func(id int) - click, open, exit func() + hotKey func(id int) + click, doubleClick, open, exit func() } type newTrayFunc func(tip string, ev trayEvents) (trayHost, error) @@ -164,7 +164,7 @@ func (b *bossKeys) close() { // manager then runs as before (it exits with its window) and has no boss keys. func (s *server) startTray(newTray newTrayFunc) { tip := "ModAVD 管理器 · " + s.root.Dir - host, err := newTray(tip, trayEvents{hotKey: s.onHotKey, click: s.onTrayClick, open: s.onTrayOpen, exit: s.onTrayExit}) + host, err := newTray(tip, trayEvents{hotKey: s.onHotKey, click: s.onTrayClick, doubleClick: s.onTrayDoubleClick, open: s.onTrayOpen, exit: s.onTrayExit}) if err != nil { s.log.Warnf("放不了托盘图标,老板键也用不了;关掉窗口管理器就退出: %v", err) return @@ -226,6 +226,9 @@ func (s *server) onHotKey(id int) { } s.toggleMu.Lock() defer s.toggleMu.Unlock() + if s.trayStopped { + return + } ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second) defer cancel() if _, err := s.mgr.ToggleBossKey(ctx, key); err != nil && s.ctx.Err() == nil { @@ -237,6 +240,9 @@ func (s *server) onHotKey(id int) { func (s *server) onTrayClick() { s.toggleMu.Lock() defer s.toggleMu.Unlock() + if s.trayStopped { + return + } ctx, cancel := context.WithTimeout(s.ctx, 30*time.Second) defer cancel() if _, err := s.mgr.ShowHiddenWindows(ctx); err != nil && s.ctx.Err() == nil { @@ -244,9 +250,22 @@ func (s *server) onTrayClick() { } } -// onTrayOpen opens a manager window, like a second launch does. -func (s *server) onTrayOpen() { - if s.base == "" { +// onTrayDoubleClick toggles every manager Edge window, or opens one when none exists. +func (s *server) onTrayDoubleClick() { s.trayManagerWindow(true) } + +// onTrayOpen restores existing manager windows before opening a new one. +func (s *server) onTrayOpen() { s.trayManagerWindow(false) } + +func (s *server) trayManagerWindow(toggle bool) { + s.toggleMu.Lock() + defer s.toggleMu.Unlock() + if s.trayStopped || s.base == "" { + return + } + if found, err := s.managerWindows(toggle); err != nil { + s.log.Warnf("没能切换管理器窗口: %v", err) + return + } else if found { return } if _, err := s.launch(s.base + "?t=" + s.token); err != nil { @@ -269,6 +288,15 @@ func (s *server) stopTray() { return } s.toggleMu.Lock() + if s.trayStopped { + s.toggleMu.Unlock() + return + } + // Already-dispatched callbacks may arrive after Close. None may hide windows again after cleanup. + s.trayStopped = true + if _, err := s.managerWindows(false); err != nil { + s.log.Warnf("退出前没能显示管理器窗口: %v", err) + } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) if shown, err := s.mgr.ShowHiddenWindows(ctx); err != nil { s.log.Warnf("退出前没能显示藏起来的窗口: %s", errs.As(err).Message) 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/tray_foreground_windows_test.go b/internal/ui/tray_foreground_windows_test.go new file mode 100644 index 0000000..1b31652 --- /dev/null +++ b/internal/ui/tray_foreground_windows_test.go @@ -0,0 +1,365 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "syscall" + "testing" + "time" + "unsafe" + + "modavd/internal/testutil" + "modavd/internal/win" +) + +type foregroundProbeState struct { + Command string `json:"command"` + Success bool `json:"success"` + Window uintptr `json:"window"` + Foreground uintptr `json:"foreground"` + ForegroundPID uint32 `json:"foreground_pid"` + LastInput uint32 `json:"last_input"` + InputAgeMS uint32 `json:"input_age_ms"` + LockTimeoutMS uint32 `json:"lock_timeout_ms"` +} + +func foregroundProbeSnapshot(command string, success bool, window uintptr) foregroundProbeState { + u := syscall.NewLazyDLL("user32.dll") + h, _, _ := u.NewProc("GetForegroundWindow").Call() + var pid uint32 + u.NewProc("GetWindowThreadProcessId").Call(h, uintptr(unsafe.Pointer(&pid))) + input := struct{ Size, Time uint32 }{Size: 8} + u.NewProc("GetLastInputInfo").Call(uintptr(unsafe.Pointer(&input))) + tick, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("GetTickCount64").Call() + var timeout uint32 + u.NewProc("SystemParametersInfoW").Call(0x2000, 0, uintptr(unsafe.Pointer(&timeout)), 0) + return foregroundProbeState{command, success, window, h, pid, input.Time, uint32(tick) - input.Time, timeout} +} + +// The broker is a separate, test-owned native window. Only it can grant the test process foreground permission +// while it is foreground. No input is synthesized and no global policy or third-party window is modified. +func TestTrayForegroundBrokerHelper(t *testing.T) { + if os.Getenv("MODAVD_FG_BROKER") != "1" { + t.Skip("foreground probe child only") + } + parent, err := strconv.ParseUint(os.Getenv("MODAVD_FG_PARENT"), 10, 32) + if err != nil { + t.Fatal(err) + } + tray, err := win.StartTray(win.TrayOptions{NoIcon: true}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + u := syscall.NewLazyDLL("user32.dll") + // A temporary native test window. One actual click gives this process foreground eligibility. + styleIndex := -16 + u.NewProc("SetWindowLongPtrW").Call(tray.HWND(), uintptr(styleIndex), 0x00cf0000) + title, _ := syscall.UTF16PtrFromString("ModAVD foreground test") + u.NewProc("SetWindowTextW").Call(tray.HWND(), uintptr(unsafe.Pointer(title))) + u.NewProc("SetWindowPos").Call(tray.HWND(), 0, 40, 40, 420, 180, 0x0004|0x0020) + send := func(command string, ok bool) { + _ = json.NewEncoder(os.Stdout).Encode(foregroundProbeSnapshot(command, ok, tray.HWND())) + } + take := func() bool { r, _, _ := u.NewProc("SetForegroundWindow").Call(tray.HWND()); return r != 0 } + send("ready", true) + in := bufio.NewScanner(os.Stdin) + for in.Scan() { + command := in.Text() + var r uintptr + switch command { + case "prompt": + topmost := -1 + r, _, _ = u.NewProc("SetWindowPos").Call(tray.HWND(), uintptr(topmost), 0, 0, 0, 0, 0x0040|0x0010|0x0002|0x0001) + case "plain": + normal := -2 + r, _, _ = u.NewProc("SetWindowPos").Call(tray.HWND(), uintptr(normal), 0, 0, 0, 0, 0x0010|0x0002|0x0001) + case "take": + send(command, take()) + continue + case "grant": + r, _, _ = u.NewProc("AllowSetForegroundWindow").Call(uintptr(parent)) + case "revoke": + r, _, _ = u.NewProc("AllowSetForegroundWindow").Call(uintptr(os.Getpid())) + case "quit": + send(command, true) + return + default: + t.Fatal("unknown probe command") + } + send(command, r != 0) + } +} + +func startForegroundBroker(t *testing.T) (int, uintptr, func(string) foregroundProbeState) { + t.Helper() + child := exec.Command(os.Args[0], "-test.run=^TestTrayForegroundBrokerHelper$") + child.SysProcAttr = &syscall.SysProcAttr{CreationFlags: win.HideConsole} + child.Env = append(os.Environ(), "MODAVD_FG_BROKER=1", "MODAVD_FG_PARENT="+strconv.Itoa(os.Getpid())) + in, err := child.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := child.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := child.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + fmt.Fprintln(in, "quit") + in.Close() + done := make(chan error, 1) + go func() { done <- child.Wait() }() + select { + case <-done: + case <-time.After(3 * time.Second): + _ = child.Process.Kill() + <-done + } + }) + scan := bufio.NewScanner(out) + if !scan.Scan() { + t.Fatal("no broker response") + } + var state foregroundProbeState + if err := json.Unmarshal(scan.Bytes(), &state); err != nil { + t.Fatalf("broker response %q: %v", scan.Text(), err) + } + t.Logf("broker ready %+v", state) + window := state.Window + command := func(command string) foregroundProbeState { + t.Helper() + if _, err := fmt.Fprintln(in, command); err != nil { + t.Fatal(err) + } + if !scan.Scan() { + t.Fatal("broker stopped") + } + var reply foregroundProbeState + if err := json.Unmarshal(scan.Bytes(), &reply); err != nil { + t.Fatal(err) + } + if reply.Command != command || !reply.Success { + t.Fatalf("broker %q: %+v", command, reply) + } + return reply + } + return child.Process.Pid, window, command +} + +func TestTrayManagerForegroundPermissions(t *testing.T) { + if os.Getenv("MODAVD_FOREGROUND_PROBE") != "1" { + t.Skip("MODAVD_FOREGROUND_PROBE=1 opens test windows") + } + name, err := syscall.UTF16PtrFromString(os.Getenv("MODAVD_INTERACTIVE_LOCK")) + if err != nil || os.Getenv("MODAVD_INTERACTIVE_LOCK") == "" { + t.Fatal("MODAVD_INTERACTIVE_LOCK is required") + } + lock, err := syscall.CreateFile(name, syscall.GENERIC_READ|syscall.GENERIC_WRITE, 0, nil, syscall.OPEN_ALWAYS, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { syscall.CloseHandle(lock) }) + if FindEdge() == "" { + t.Fatal("Edge is required") + } + t.Setenv("LOCALAPPDATA", testutil.TempDir(t)) + u := syscall.NewLazyDLL("user32.dll") + post := u.NewProc("PostMessageW") + allow := u.NewProc("AllowSetForegroundWindow") + send := u.NewProc("SendMessageTimeoutW") + wait := func(what string, cond func() bool) { + t.Helper() + until := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(until) { + t.Fatalf("timeout %s: %+v", what, foregroundProbeSnapshot(what, false, 0)) + } + time.Sleep(10 * time.Millisecond) + } + } + brokerPID, brokerWindow, command := startForegroundBroker(t) + take := func() { + t.Helper() + command("take") + wait("broker foreground", func() bool { return foregroundProbeSnapshot("", false, 0).Foreground == brokerWindow }) + } + window, err := LaunchWindow("about:blank#modavd-foreground-probe") + if err != nil || !window.Edge { + t.Fatalf("Edge launch: %v", err) + } + // Register cleanup before searching: a browser that starts slowly or has unexpected windows still belongs to + // this isolated profile and must be closed even when target discovery fails. + defer func() { + until := time.Now().Add(10 * time.Second) + for { + ws, e := win.EdgeProfileWindows(EdgeProfileDir()) + if e != nil { + t.Error(e) + } else { + for _, w := range ws { + _ = w.SetVisible(true) + post.Call(w.HWND(), 0x0010, 0, 0) + } + } + exited := false + select { + case <-window.Done: + exited = true + default: + } + if e == nil && len(ws) == 0 && exited { + break + } + if time.Now().After(until) { + t.Error("Edge did not close its isolated probe profile") + break + } + time.Sleep(50 * time.Millisecond) + } + removeProfile(t, EdgeProfileDir()) // wait for this test profile's crash-reporting file handles too + }() + var windows []win.EdgeWindow + wait("one Edge app", func() bool { + windows, err = win.EdgeProfileWindows(EdgeProfileDir()) + if err != nil { + t.Fatal(err) + } + return len(windows) == 1 + }) + target := windows[0] + // Showing the prompt does not activate it. Require a new actual input event while this window is foreground. + inputBefore := foregroundProbeSnapshot("before human click", false, brokerWindow).LastInput + command("prompt") + clickedUntil := time.Now().Add(90 * time.Second) + for { + state := foregroundProbeSnapshot("waiting for human click", false, brokerWindow) + if state.Foreground == brokerWindow && state.LastInput != inputBefore { + t.Logf("actual input observed on test broker: %+v", state) + break + } + if time.Now().After(clickedUntil) { + t.Fatal("test window was not clicked") + } + time.Sleep(50 * time.Millisecond) + } + command("plain") + take() + measure := func(phase string) foregroundProbeState { + t.Helper() + before := foregroundProbeSnapshot(phase, false, target.HWND()) + if !target.Visible() { + t.Fatal("foreground target is no longer a visible window of the identified Edge process") + } + accepted := target.Foreground() + var ignored uintptr + ack, _, _ := send.Call(target.HWND(), 0, 0, 0, 0x0002, 1000, uintptr(unsafe.Pointer(&ignored))) + after := foregroundProbeSnapshot(phase, accepted, target.HWND()) + b, _ := json.Marshal(before) + a, _ := json.Marshal(after) + t.Logf("native before=%s after=%s WM_NULL_ack=%v", b, a, ack != 0) + if ack == 0 { + t.Fatal("Edge activation queue did not acknowledge") + } + if before.LastInput != after.LastInput { + t.Fatal("external input changed probe conditions") + } + return after + } + command("revoke") + negative := measure("no explicit grant") + if negative.Success { + t.Log("no-grant denial was not reproduced; this run can verify granted activation but cannot prove a permission contrast") + allow.Call(uintptr(brokerPID)) + take() + } + h, fake := trayHarness(t) + h.s.base = "http://127.0.0.1:33431/" + h.s.managerWindows = changeManagerWindows + events := fake.ev + barrier := make(chan struct{}, 8) + tray, err := win.StartTray(win.TrayOptions{NoIcon: true, Events: win.TrayEvents{ + DoubleClick: events.doubleClick, HotKey: func(id int) { + if id == 1 { + events.open() + } else { + barrier <- struct{}{} + } + }, + }}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + flush := func() { + t.Helper() + post.Call(tray.HWND(), 0x0312, 2, 0) + select { + case <-barrier: + case <-time.After(3 * time.Second): + t.Fatal("tray callback did not finish") + } + } + double := func() { + for _, m := range []uintptr{0x0202, 0x0203, 0x0202} { + post.Call(tray.HWND(), 0x8001, 1, m) + } + flush() + } + lendBack := func() { + t.Helper() + r, _, _ := allow.Call(uintptr(brokerPID)) + if r == 0 { + t.Fatal("return foreground grant to broker failed") + } + take() + } + for round := 1; round <= 3; round++ { + command("grant") + granted := measure(fmt.Sprintf("round %d granted", round)) + if !granted.Success { + t.Fatal("authorized native request failed") + } + if round == 1 && !negative.Success { + if negative.LastInput != granted.LastInput { + t.Fatal("input changed between denied and granted requests; causal contrast is invalid") + } + t.Logf("permission contrast confirmed: same HWND %d, same last input %d, denied without grant and accepted with grant", target.HWND(), granted.LastInput) + } + wait("native foreground", func() bool { return foregroundProbeSnapshot("", false, 0).Foreground == target.HWND() }) + lendBack() + command("grant") + before := foregroundProbeSnapshot("posted double", false, target.HWND()) + double() + if target.Visible() { + t.Fatal("double did not hide window") + } + double() + if !target.Visible() { + t.Fatal("double did not show window") + } + wait("posted double foreground", func() bool { return foregroundProbeSnapshot("", false, 0).Foreground == target.HWND() }) + after := foregroundProbeSnapshot("posted double", true, target.HWND()) + if before.LastInput != after.LastInput { + t.Fatal("posted messages changed last input") + } + lendBack() + command("grant") + post.Call(tray.HWND(), 0x0312, 1, 0) + flush() + wait("open foreground", func() bool { return foregroundProbeSnapshot("", false, 0).Foreground == target.HWND() }) + t.Logf("round %d authorized native / posted double / open activated Edge HWND %d, last input %d", round, target.HWND(), after.LastInput) + lendBack() + } +} diff --git a/internal/ui/tray_manager_edge_windows_test.go b/internal/ui/tray_manager_edge_windows_test.go new file mode 100644 index 0000000..a6bb9fa --- /dev/null +++ b/internal/ui/tray_manager_edge_windows_test.go @@ -0,0 +1,304 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "fmt" + "net" + "net/http" + "os" + "os/exec" + "slices" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "modavd/internal/logx" + "modavd/internal/root" + "modavd/internal/testutil" + "modavd/internal/win" +) + +// TestTrayManagerEdgeProbe is opt-in because it opens real Edge windows. Use an isolated LOCALAPPDATA and set +// MODAVD_INTERACTIVE_LOCK to the shared desktop-test lock path. No emulator or existing browser profile is used. +func TestTrayManagerEdgeProbe(t *testing.T) { + if os.Getenv("MODAVD_TRAY_EDGE_PROBE") != "1" { + t.Skip("MODAVD_TRAY_EDGE_PROBE=1 opens real Edge windows") + } + lockPath := os.Getenv("MODAVD_INTERACTIVE_LOCK") + if lockPath == "" { + t.Fatal("MODAVD_INTERACTIVE_LOCK is required") + } + lockName, err := syscall.UTF16PtrFromString(lockPath) + if err != nil { + t.Fatal(err) + } + var lock syscall.Handle + deadline := time.Now().Add(3 * time.Minute) + for { + lock, err = syscall.CreateFile(lockName, syscall.GENERIC_READ|syscall.GENERIC_WRITE, 0, nil, syscall.OPEN_ALWAYS, 0, 0) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("interactive lock: %v", err) + } + time.Sleep(200 * time.Millisecond) + } + defer syscall.CloseHandle(lock) + t.Log("exclusive interactive lock acquired") + if FindEdge() == "" { + t.Fatal("Edge is required for this probe") + } + t.Setenv("LOCALAPPDATA", testutil.TempDir(t)) + profile, foreign := EdgeProfileDir(), EdgeProfileDir()+"-other" + user32 := syscall.NewLazyDLL("user32.dll") + post := user32.NewProc("PostMessageW") + foreground := user32.NewProc("GetForegroundWindow") + getWindows := func(profile string) []win.EdgeWindow { + t.Helper() + w, e := win.EdgeProfileWindows(profile) + if e != nil { + t.Fatal(e) + } + return w + } + wait := func(what string, condition func() bool) { + t.Helper() + end := time.Now().Add(15 * time.Second) + for !condition() { + if time.Now().After(end) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(50 * time.Millisecond) + } + } + // Restore before closing, and only ever touch the two profiles created by this test. + defer func() { + for _, p := range []string{profile, foreign} { + ws, e := win.EdgeProfileWindows(p) + if e != nil { + t.Error(e) + continue + } + for _, w := range ws { + _ = w.SetVisible(true) + post.Call(w.HWND(), 0x0010, 0, 0) + } // WM_CLOSE + // Keep the interactive lock until the test windows have actually gone, not just until WM_CLOSE was posted. + until := time.Now().Add(5 * time.Second) + for { + left, e := win.EdgeProfileWindows(p) + if e != nil { + t.Error(e) + break + } + if len(left) == 0 { + break + } + if time.Now().After(until) { + t.Errorf("test profile still has %d windows during cleanup", len(left)) + break + } + time.Sleep(50 * time.Millisecond) + } + } + }() + ln, err := net.Listen("tcp", "127.0.0.1:33430") + if err != nil { + t.Fatal(err) + } + var pages atomic.Int32 + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + pages.Add(1) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, "ModAVD tray probe") + })} + go srv.Serve(ln) + defer srv.Close() + base := "http://" + ln.Addr().String() + "/" + r, err := root.New(testutil.TempDir(t)) + if err != nil { + t.Fatal(err) + } + f := newFake() + s, err := newServer(Options{Root: r, newBackend: func(l *logx.Logger) backend { return fakeBackend{f, l} }}) + if err != nil { + t.Fatal(err) + } + defer s.jobCancel() + defer s.cancel() + s.base = base + var events trayEvents + s.startTray(func(tip string, ev trayEvents) (trayHost, error) { events = ev; return realNewTray(tip, ev) }) + if s.tray == nil { + t.Fatal("real tray did not start") + } + defer s.stopTray() + tray := s.tray.(winTray) + var launches atomic.Int32 + s.launch = func(url string) (*Window, error) { launches.Add(1); return LaunchWindow(url) } + first, err := LaunchWindow(base + "?one") + if err != nil || !first.Edge { + t.Fatalf("first launch: %v", err) + } + wait("first Edge window", func() bool { return len(getWindows(profile)) == 1 }) + second, err := LaunchWindow(base + "?two") + if err != nil || !second.Edge { + t.Fatalf("second launch: %v", err) + } + wait("two Edge windows", func() bool { return len(getWindows(profile)) == 2 }) + select { + case <-second.Done: + t.Log("second launch exited after handing window to existing profile process") + case <-time.After(5 * time.Second): + t.Fatal("second launch did not exercise process handoff") + } + other := exec.Command(FindEdge(), edgeArgs(base+"?foreign", foreign)...) + if err := other.Start(); err != nil { + t.Fatal(err) + } + go other.Wait() + wait("foreign Edge window", func() bool { return len(getWindows(foreign)) == 1 }) + handles := func(ws []win.EdgeWindow) []uintptr { + out := []uintptr{} + for _, w := range ws { + out = append(out, w.HWND()) + } + slices.Sort(out) + return out + } + owned := handles(getWindows(profile)) + // Posted messages are not real input. Windows may leave focus with another app even when visibility changed. + // Set MODAVD_TRAY_REQUIRE_FOREGROUND=1 for a controlled interactive acceptance run. + checkForeground := func() { + t.Helper() + until := time.Now().Add(time.Second) + var h uintptr + for { + h, _, _ = foreground.Call() + if slices.Contains(owned, h) { + t.Logf("foreground observed on owned HWND %d", h) + return + } + if time.Now().After(until) { + break + } + time.Sleep(10 * time.Millisecond) + } + if os.Getenv("MODAVD_TRAY_REQUIRE_FOREGROUND") == "1" { + t.Fatalf("foreground not observed; current HWND %d", h) + } + t.Logf("foreground not observed after posted messages; current HWND %d; visibility assertions remain required", h) + } + unrelated := handles(getWindows(foreign)) + t.Logf("owned HWNDs %v; foreign HWNDs %v", owned, unrelated) + wait("all pages loaded", func() bool { return pages.Load() >= 3 }) + loads := pages.Load() + double := func() { + for _, msg := range []uintptr{0x0202, 0x0203, 0x0202} { + post.Call(tray.HWND(), 0x8001, 1, msg) + } + } + check := func(visible bool) bool { + ws := getWindows(profile) + if len(ws) != 2 { + return false + } + for _, w := range ws { + if w.Visible() != visible { + return false + } + } + return true + } + for round := 1; round <= 3; round++ { + double() + wait("both manager windows hidden", func() bool { return check(false) }) + if round == 2 { + events.open() + } else { + double() + } + wait("both manager windows restored", func() bool { return check(true) }) + if !slices.Equal(handles(getWindows(profile)), owned) { + t.Fatal("toggle replaced manager HWNDs") + } + if !slices.Equal(handles(getWindows(foreign)), unrelated) || !getWindows(foreign)[0].Visible() { + t.Fatal("foreign profile affected") + } + checkForeground() + t.Logf("round %d: hidden/restored both windows; foreign unchanged", round) + } + // IsWindowVisible is true even while minimized. Wait for SW_RESTORE itself before foreground activation. + minimized := getWindows(profile)[0] + user32.NewProc("ShowWindowAsync").Call(minimized.HWND(), 6) // SW_MINIMIZE + wait("minimized manager window", minimized.Minimized) + events.open() + wait("minimized window restored", func() bool { return minimized.Visible() && !minimized.Minimized() }) + checkForeground() + t.Log("right-click open restored minimized window") + user32.NewProc("ShowWindowAsync").Call(minimized.HWND(), 3) // SW_MAXIMIZE + zoomed := func() bool { r, _, _ := user32.NewProc("IsZoomed").Call(minimized.HWND()); return r != 0 } + wait("maximized manager window", zoomed) + double() + wait("maximized window hidden", func() bool { return check(false) }) + double() + wait("maximized window restored", func() bool { return check(true) && zoomed() }) + t.Log("hide/show retained maximized placement") + if launches.Load() != 0 { + t.Fatal("restore opened an extra manager") + } + if pages.Load() != loads { + t.Fatalf("toggle reloaded page: %d -> %d", loads, pages.Load()) + } + f.mu.Lock() + single := f.showHidden + f.mu.Unlock() + if single != 0 { + t.Fatal("double click leaked instance restore") + } + // A single click really traverses the native timer to the server backend. + post.Call(tray.HWND(), 0x8001, 1, 0x0202) + wait("single-click instance restore", func() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.showHidden == 1 }) + for _, w := range getWindows(profile) { + post.Call(w.HWND(), 0x0010, 0, 0) + } + wait("manager windows closed", func() bool { return len(getWindows(profile)) == 0 }) + double() + wait("no-window double click opened manager", func() bool { return launches.Load() == 1 && len(getWindows(profile)) == 1 }) + t.Log("no-window double click opened one manager window") + double() + wait("new manager window hidden", func() bool { ws := getWindows(profile); return len(ws) == 1 && !ws[0].Visible() }) + s.stopTray() + wait("exit restored manager window", func() bool { ws := getWindows(profile); return len(ws) == 1 && ws[0].Visible() }) + events.doubleClick() + events.open() + if launches.Load() != 1 || !getWindows(profile)[0].Visible() { + t.Fatal("late callback changed window after exit") + } + t.Logf("exit restored window; page requests before close %d; isolated single click delivered once", loads) + // Record only process identities belonging to our profiles; no unrelated command line is logged. + ps, _ := win.ListProcesses() + for _, p := range ps { + if strings.EqualFold(p.Name, "msedge.exe") { + args, e := p.CommandLine() + if e == nil { + for _, a := range args { + if a == "--user-data-dir="+profile { + t.Logf("profile process PID %d created %d", p.PID, p.Created) + } + } + } + } + } +} diff --git a/internal/ui/tray_manager_windows_test.go b/internal/ui/tray_manager_windows_test.go new file mode 100644 index 0000000..4c0d973 --- /dev/null +++ b/internal/ui/tray_manager_windows_test.go @@ -0,0 +1,154 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" +) + +func TestTrayManagerWindowActions(t *testing.T) { + for _, tc := range []struct { + name string + found bool + failure bool + }{ + {"existing windows", true, false}, {"closed or default browser", false, false}, {"lookup failure", false, true}, + } { + t.Run(tc.name, func(t *testing.T) { + h, tray := trayHarness(t) + h.s.base = "http://127.0.0.1:33400/" + var modes []bool + h.s.managerWindows = func(toggle bool) (bool, error) { + modes = append(modes, toggle) + if tc.failure { + return false, errors.New("enumeration failed") + } + return tc.found, nil + } + launches := 0 + h.s.launch = func(url string) (*Window, error) { + launches++ + if url != h.s.base+"?t="+testToken { + t.Error("wrong login URL") + } + return &Window{}, nil + } + if tray.ev.doubleClick == nil { + t.Fatal("double click not wired") + } + tray.ev.doubleClick() + tray.ev.open() + if len(modes) != 2 || !modes[0] || modes[1] { + t.Fatalf("actions %v", modes) + } + want := 0 + if !tc.found && !tc.failure { + want = 2 + } + if launches != want { + t.Fatalf("launches %d, want %d", launches, want) + } + h.f.mu.Lock() + shown := h.f.showHidden + h.f.mu.Unlock() + if shown != 0 { + t.Fatal("manager action restored instance windows") + } + }) + } +} + +func TestTrayManagerRestoreOnEveryExit(t *testing.T) { + for _, reason := range []string{"context", "update handoff", "tray menu"} { + for _, hidden := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/hidden=%v", reason, hidden), func(t *testing.T) { + h, tray := trayHarness(t) + s := h.s + s.base = "http://127.0.0.1:33401/" + s.exitEvery = time.Millisecond + visible := true + s.managerWindows = func(toggle bool) (bool, error) { + if toggle { + visible = !visible + } else { + visible = true + } + return true, nil + } + if hidden { + tray.ev.doubleClick() + if visible { + t.Fatal("not hidden") + } + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + switch reason { + case "context": + cancel() + case "update handoff": + close(s.exitReq) + case "tray menu": + tray.ev.exit() + } + s.waitForExit(ctx, nil) + s.stopTray() + if !visible { + t.Fatal("exit left manager hidden") + } + tray.ev.doubleClick() + tray.ev.open() + tray.ev.click() + s.stopTray() + if !visible { + t.Fatal("late callback hid manager") + } + h.f.mu.Lock() + shown := h.f.showHidden + h.f.mu.Unlock() + if shown != 1 { + t.Fatalf("restore calls %d, want one exit cleanup", shown) + } + }) + } + } +} + +func TestTrayManagerConcurrentExit(t *testing.T) { + h, tray := trayHarness(t) + s := h.s + s.base = "http://127.0.0.1:33402/" + entered, release := make(chan struct{}), make(chan struct{}) + visible := true + s.managerWindows = func(toggle bool) (bool, error) { + if toggle { + close(entered) + <-release + visible = false + } else { + visible = true + } + return true, nil + } + var wg sync.WaitGroup + wg.Go(tray.ev.doubleClick) + <-entered + wg.Go(s.stopTray) + close(release) + wg.Wait() + if !visible { + t.Fatal("concurrent exit left manager hidden") + } + tray.ev.doubleClick() + if !visible { + t.Fatal("callback after cleanup hid manager") + } +} diff --git a/internal/ui/tray_other.go b/internal/ui/tray_other.go index 216e7b9..d5d469d 100644 --- a/internal/ui/tray_other.go +++ b/internal/ui/tray_other.go @@ -8,3 +8,5 @@ import "modavd/internal/errs" func realNewTray(string, trayEvents) (trayHost, error) { return nil, errs.New(errs.Unsupported, "当前平台没有托盘图标和老板键") } + +func changeManagerWindows(bool) (bool, error) { return false, nil } diff --git a/internal/ui/tray_windows.go b/internal/ui/tray_windows.go index 4bff7f6..ad226c6 100644 --- a/internal/ui/tray_windows.go +++ b/internal/ui/tray_windows.go @@ -8,7 +8,7 @@ import ( // realNewTray is the manager's win.Tray. func realNewTray(tip string, ev trayEvents) (trayHost, error) { t, err := win.StartTray(win.TrayOptions{Tip: tip, Events: win.TrayEvents{HotKey: ev.hotKey, Click: ev.click, - Open: ev.open, Exit: ev.exit}}) + DoubleClick: ev.doubleClick, Open: ev.open, Exit: ev.exit}}) if err != nil { return nil, err } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 02c1951..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). @@ -149,8 +151,11 @@ type server struct { bossKick chan struct{} trayExit chan struct{} // closed by 退出 in the icon's menu trayExitOnce sync.Once - toggleMu sync.Mutex // one boss key press or tray click at a time + toggleMu sync.Mutex // one tray action or exit cleanup at a time base string // "http://127.0.0.1:/", set once before serving + + trayStopped bool // protected by toggleMu + managerWindows func(toggle bool) (found bool, err error) // queries the Edge profile, never a launch PID } func newServer(opt Options) (*server, error) { @@ -217,6 +222,8 @@ func newServer(opt Options) (*server, error) { proxyEvery: proxyEvery, hub: newHub(), marks: newProxyMarks(), + + managerWindows: changeManagerWindows, } if s.openPath == nil { s.openPath = shellOpen @@ -233,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) } @@ -246,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/cards.js b/internal/ui/web/js/cards.js index ce41bc0..54c11ef 100644 --- a/internal/ui/web/js/cards.js +++ b/internal/ui/web/js/cards.js @@ -3,7 +3,7 @@ import { h, icon, btn, ibtn, keyed, setText, attr } from './dom.js'; import { S, lastJob, rootBlocked } from './store.js'; -import { label, ownTitle, uptime, stripInst, pct, cfgText } from './format.js'; +import { label, ownTitle, stripInst, pct, cfgText } from './format.js'; import * as A from './actions.js'; import { openMenu } from './ui.js'; import { openSettings, openDelete } from './forms.js'; @@ -187,7 +187,6 @@ function updateCard(c, inst) { setText(c.title, (c.title.title = name)); const sub = []; if (ownTitle(inst)) sub.push(inst.name); - if (live && inst.started_at) sub.push(uptime(inst.started_at)); const subtitle = sub.join(' · ') || `编号 ${inst.index}`; const headless = inst.running && inst.window === false && v.key !== 'creating'; setText(c.sub, subtitle + (headless ? ' · 无头' : '') + (inst.diagnostics ? ' · 诊断模式' : '')); diff --git a/internal/ui/web/js/forms.js b/internal/ui/web/js/forms.js index 592a6eb..dc7e34f 100644 --- a/internal/ui/web/js/forms.js +++ b/internal/ui/web/js/forms.js @@ -158,21 +158,10 @@ export function openCreate() { toast({ tone: 'info', title: '已经有 9 台实例了', text: '最多 9 台(编号 0–9,5 除外)。删掉不用的实例再新建。' }); return; } - const title = h('input', { class: 'input', id: 'c-title', maxlength: '40', autocomplete: 'off', placeholder: '比如:明日方舟 · 主号' }); - const titleHint = hint(''); + const title = h('input', { class: 'input', id: 'c-title', maxlength: '40', autocomplete: 'off', placeholder: '明日方舟 · 主号' }); + // the number is always picked by the server (the first free one); the dialog no longer offers a choice + const titleHint = hint(`不填就显示「${defaultName(free[0])}」,以后可以随时改`); const disk = diskField('c', 'auto', 64, 16); - const idx = h('select', { class: 'select', id: 'c-index' }, h('option', { value: '', text: `自动(编号 ${free[0]})` }), - free.map((i) => h('option', { value: String(i), text: `编号 ${i}` }))); - const idxHint = hint(''); - // the root's port plan (mas-avd.json portBase); a manager without port_base only knows 20000 - const base = S.list.port_base || 20000; - const sync = () => { - const i = idx.value === '' ? free[0] : Number(idx.value); - titleHint.textContent = `不填就显示「${defaultName(i)}」,以后可以随时改`; - idxHint.replaceChildren('编号决定端口,脚本连 ', h('b', { text: `127.0.0.1:${base + 1 + 10 * i}` })); - }; - idx.addEventListener('change', sync); - sync(); const fps = fpsField('c', 60); const box = errorBox(); const ok = h('button', { class: 'btn btn-primary', type: 'submit' }, icon('plus'), h('span', { text: '新建' })); @@ -184,8 +173,7 @@ export function openCreate() { segRow('CPU', 'cpu', CPU, 6), fps.rows, segRow('分辨率', 'resolution', RESOLUTION, 1080, hint('720p 更省资源')), - disk.rows, - inputRow('编号', idx, idxHint)), box), + disk.rows), box), actions: { ok }, onSubmit() { const f = ctl.form.elements; @@ -198,7 +186,7 @@ export function openCreate() { const job = await api.post('/api/instances', { title: title.value.trim() || undefined, memory_mb: Number(f.memory.value), cpu: Number(f.cpu.value), // no size: 自动扩盘 - fps: fpsValue, resolution: Number(f.resolution.value), data_partition_gb: d.auto ? undefined : d.gb, index: idx.value === '' ? undefined : Number(idx.value), + fps: fpsValue, resolution: Number(f.resolution.value), data_partition_gb: d.auto ? undefined : d.gb, }); A.onJob(job); S.watch.add(job.id); 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/ui/window_visibility_windows.go b/internal/ui/window_visibility_windows.go new file mode 100644 index 0000000..09dc880 --- /dev/null +++ b/internal/ui/window_visibility_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "errors" + "fmt" + "time" + + "modavd/internal/win" +) + +func changeManagerWindows(toggle bool) (bool, error) { + return changeEdgeWindows(EdgeProfileDir(), toggle) +} + +// changeEdgeWindows runs under server.toggleMu. Each action re-reads the actual profile windows: a launch PID or a +// cached HWND is not their owner. Wait briefly for the asynchronous changes so the next action sees the new state. +func changeEdgeWindows(profile string, toggle bool) (bool, error) { + windows, err := win.EdgeProfileWindows(profile) + if err != nil || len(windows) == 0 { + return false, err + } + visible := true + if toggle { + for _, w := range windows { + if w.Visible() { + visible = false + break + } + } + } + var failures []error + for _, w := range windows { + if err := w.SetVisible(visible); err != nil { + failures = append(failures, err) + } + } + deadline := time.Now().Add(time.Second) + for { + settled := true + for _, w := range windows { + if w.Visible() != visible || (visible && w.Minimized()) { + settled = false + break + } + } + if settled { + break + } + if time.Now().After(deadline) { + failures = append(failures, fmt.Errorf("Edge 窗口未在一秒内响应显示切换")) + break + } + time.Sleep(10 * time.Millisecond) + } + if visible { + windows[0].Foreground() + } + return true, errors.Join(failures...) +} diff --git a/internal/update/caller_settings_test.go b/internal/update/caller_settings_test.go index 72ec9d3..b994ad8 100644 --- a/internal/update/caller_settings_test.go +++ b/internal/update/caller_settings_test.go @@ -116,8 +116,8 @@ func TestCallerSettingsOverride(t *testing.T) { t.Fatalf("status: %+v", st) } st, err := u.Install(context.Background()) - // every file changed between the two builds: GitHub's range path falls back to the whole package (second notice) - if err != nil || !st.Updated || len(st.Notices) != 2 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") || !strings.Contains(st.Notices[1], "变了的文件太多") { + // The normal GitHub range threshold adds no notice; the bad CDK still does. + if err != nil || !st.Updated || len(st.Notices) != 1 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") { t.Fatalf("install: %+v %v", st, err) } if len(seen) == 0 || seen[len(seen)-1]["cdk"] != cdk { 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/internal/update/images_windows.go b/internal/update/images_windows.go index 78bb9e4..77e940f 100644 --- a/internal/update/images_windows.go +++ b/internal/update/images_windows.go @@ -68,7 +68,7 @@ func (u *Updater) lockSDKImages() (release func(), err error) { } else if err != nil { return nil, swapBlocked("无法检查模拟器目录 %s(%v),什么都没动", dir, err) } - resolved, err := filepath.EvalSymlinks(dir) + resolved, err := resolvePath(dir) if err != nil { return nil, swapBlocked("无法确认模拟器目录 %s(%v),什么都没动", dir, err) } diff --git a/internal/update/package_compat_windows_test.go b/internal/update/package_compat_windows_test.go new file mode 100644 index 0000000..8e01020 --- /dev/null +++ b/internal/update/package_compat_windows_test.go @@ -0,0 +1,223 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "testing" + + "modavd/internal/errs" +) + +func compatArchive(t *testing.T, files map[string]string, incremental, manifest bool, deleted []string) []byte { + t.Helper() + payload := merged(files) + if manifest { + payload[manifestName] = string(manifestFor(37, files)) + } + if incremental { + modified := make([]string, 0, len(payload)) + for name := range payload { + modified = append(modified, name) + } + sort.Strings(modified) + changes, err := json.Marshal(map[string][]string{"modified": modified, "deleted": deleted}) + if err != nil { + t.Fatal(err) + } + payload["changes.json"] = string(changes) + } + return zipOf(t, 37, payload, true) +} + +func stageCachedPackage(t *testing.T, data []byte, incremental bool, tag, lockTarget string, baseFiles map[string]string) (*Updater, string, error) { + t.Helper() + r := writeRoot(t, 36, baseFiles) + u := New(r, nil, Options{}) + archive := u.l.downloadAt("cached.zip") + if err := os.MkdirAll(filepath.Dir(archive), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(archive, data, 0o644); err != nil { + t.Fatal(err) + } + rel := &release{source: SourceMirrorChyan, tag: tag, version: Version{0, 37, 0}, assetName: "cached.zip", url: "https://unused.invalid/cached.zip", size: int64(len(data)), sha256: hashBytes(data), incremental: incremental, base: Version{0, 36, 0}} + var held syscall.Handle + defer func() { + if held != 0 { + syscall.CloseHandle(held) + } + }() + if lockTarget != "" { + u.Opt.Progress = func(p Progress) { + if p.Stage != "verifying" || held != 0 { + return + } + name, err := syscall.UTF16PtrFromString(u.l.pkgAt(lockTarget)) + if err != nil { + t.Fatal(err) + } + held, err = syscall.CreateFile(name, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + } + } + err := u.stage(context.Background(), rel, &Status{}) + return u, archive, err +} + +func TestRequiredPackageDirectoriesRejectAndDeleteCache(t *testing.T) { + for _, mode := range []string{"full", "incremental", "legacy"} { + for _, target := range []string{manifestName, "sdk/emulator/source.properties", "sdk/emulator/qemu/windows-x86_64/qemu-system-x86_64-headless.exe"} { + t.Run(mode+"/"+target, func(t *testing.T) { + files := pkgFiles(37, nil) + delete(files, target) + files[target+"/child"] = "directory supplied by the package" + incremental := mode == "incremental" + manifest := mode != "legacy" && target != manifestName + tag := "v0.37.0" + if mode == "legacy" { + tag = "v37.2.10-mas37" + } + u, archive, err := stageCachedPackage(t, compatArchive(t, files, incremental, manifest, []string{target}), incremental, tag, "", nil) + if mode == "legacy" && target == manifestName { + if err != nil || stagedMode(u) != "full" || !lexists(archive) { + t.Fatalf("optional legacy manifest directory changed behavior: %v", err) + } + } else if errs.CodeOf(err) != errs.UpdateRejected || lexists(archive) || phaseOf(u.l.readState()) != phaseIdle { + t.Fatalf("package directory: error %v, code %s, cached %v, phase %s", err, errs.CodeOf(err), lexists(archive), phaseOf(u.l.readState())) + } + }) + } + } +} + +func TestPackageReadErrorsRetainFullAndIncrementalCache(t *testing.T) { + for _, incremental := range []bool{false, true} { + for _, target := range []string{manifestName, "sdk/emulator/emulator.exe"} { + name := "full/" + target + if incremental { + name = "incremental/" + target + } + t.Run(name, func(t *testing.T) { + data := compatArchive(t, pkgFiles(37, nil), incremental, true, nil) + u, archive, err := stageCachedPackage(t, data, incremental, "v0.37.0", target, nil) + var pe *os.PathError + if !errors.As(err, &pe) || errs.CodeOf(err) == errs.UpdateRejected || !lexists(archive) || lexists(u.l.stagedMarker()) { + t.Fatalf("local read: %v, cached %v", err, lexists(archive)) + } + // The read-denying handle is released before cleanup retries the parent-directory rename. + if err := u.l.abortStaging("test read failure"); err != nil { + t.Fatal(err) + } + }) + } + } +} + +func TestWholeAndLegacyPackageSuccess(t *testing.T) { + for _, legacy := range []bool{false, true} { + name, tag := "full", "v0.37.0" + if legacy { + name, tag = "legacy", "v37.2.10-mas37" + } + t.Run(name, func(t *testing.T) { + files := pkgFiles(37, nil) + u, _, err := stageCachedPackage(t, compatArchive(t, files, false, !legacy, nil), false, tag, "", nil) + if err != nil || stagedMode(u) != "full" { + t.Fatalf("stage: %v", err) + } + if err := u.l.checkStaged(u.l.readState()); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestIncrementalPackageReusesAddsAndDeletes(t *testing.T) { + files := pkgFiles(37, map[string]string{"tools/keep.ps1": "kept", "tools/added.ps1": "added"}) + payload := merged(files) + delete(payload, "tools/keep.ps1") + payload[manifestName] = string(manifestFor(37, files)) + modified := []string{manifestName} + for name := range payload { + if name != manifestName && name != "tools/added.ps1" { + modified = append(modified, name) + } + } + sort.Strings(modified) + changes, err := json.Marshal(map[string][]string{"added": {"tools/added.ps1"}, "modified": modified, "deleted": {"tools/deleted.ps1"}}) + if err != nil { + t.Fatal(err) + } + payload["changes.json"] = string(changes) + u, _, err := stageCachedPackage(t, zipOf(t, 37, payload, true), true, "v0.37.0", "", map[string]string{"tools/keep.ps1": "kept", "tools/deleted.ps1": "old"}) + if err != nil || stagedMode(u) != "incremental" { + t.Fatalf("stage: %v", err) + } + for name, want := range files { + path := u.l.pkgAt(name) + if suffix, ok := strings.CutPrefix(name, "sdk/emulator/"); ok { + path = filepath.Join(u.l.stageEmulator(), filepath.FromSlash(suffix)) + } + body, err := os.ReadFile(path) + if err != nil || string(body) != want { + t.Fatalf("%s: %q %v", name, body, err) + } + } + if lexists(u.l.pkgAt("tools/deleted.ps1")) || !lexists(u.l.at("tools/deleted.ps1")) { + t.Fatal("incremental deletion or installed-root isolation failed") + } + if err := u.l.checkStaged(u.l.readState()); err != nil { + t.Fatal(err) + } +} + +func TestMirrorPackageDirectoryFallsBackToGitHub(t *testing.T) { + for _, incremental := range []bool{false, true} { + name, updateType := "full", "full" + if incremental { + name, updateType = "incremental", "incremental" + } + t.Run(name, func(t *testing.T) { + withSettings(t, SourceMirrorChyan, "test-cdk") + target := "sdk/emulator/source.properties" + badFiles := pkgFiles(37, nil) + delete(badFiles, target) + badFiles[target+"/child"] = "not a properties file" + bad := compatArchive(t, badFiles, incremental, true, []string{target}) + good := zipOf(t, 37, pkgFiles(37, nil), false) + f := newSources(t, 37, good) + f.mirror = func(map[string]string) (int, any) { + return 0, map[string]any{"version_name": "v0.37.0", "url": f.srv.URL + "/dl/bad.zip", "filesize": len(bad), "sha256": hashBytes(bad), "update_type": updateType} + } + log := &dlLog{} + serve := log.serve(good, nil) + f.dl = func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/bad.zip") { + http.ServeContent(w, r, "bad.zip", zipTime, bytes.NewReader(bad)) + return + } + serve(w, r) + } + u := f.updater(writeRoot(t, 36, nil)) + st, err := u.Prepare(context.Background()) + if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" || log.fullGets() != 1 || len(st.Notices) != 1 || !strings.Contains(st.Notices[0], "Mirror 酱") { + t.Fatalf("fallback: %+v, error %v, full GETs %d", st, err, log.fullGets()) + } + }) + } +} diff --git a/internal/update/path_identity_windows_test.go b/internal/update/path_identity_windows_test.go new file mode 100644 index 0000000..f82a822 --- /dev/null +++ b/internal/update/path_identity_windows_test.go @@ -0,0 +1,323 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "modavd/internal/jsonord" + "modavd/internal/root" + "modavd/internal/testutil" + "modavd/internal/win" +) + +func updatePathAlias(t *testing.T, target, kind string) string { + t.Helper() + if kind == "short name" { + ptr, err := syscall.UTF16PtrFromString(target) + if err != nil { + t.Fatal(err) + } + var buf [32768]uint16 + n, err := syscall.GetShortPathName(ptr, &buf[0], uint32(len(buf))) + if err != nil || n == 0 || n >= uint32(len(buf)) { + t.Fatalf("GetShortPathName: %d, %v", n, err) + } + alias := syscall.UTF16ToString(buf[:n]) + if strings.EqualFold(alias, target) { + t.Skip("test volume does not create 8.3 names") + } + return alias + } + alias := filepath.Join(testutil.TempDir(t), "alias") + if kind == "symlink" { + if err := os.Symlink(target, alias); err != nil { + t.Skipf("directory symlink unavailable: %v", err) + } + } else { + cmd := exec.Command("cmd.exe", "/d", "/c", "mklink", "/J", alias, target) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("mklink /J: %v\n%s", err, out) + } + } + st, err := os.Stat(alias) + if err != nil || !st.IsDir() { + t.Fatalf("invalid directory alias: %v", err) + } + return alias +} + +func TestSelfUpdatePathAliases(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"junction", "symlink", "short name"} { + for _, reverse := range []bool{false, true} { + for _, action := range []string{"check", "apply", "restore", "recover finish", "recover rollback"} { + name := kind + "/alias root/" + action + if reverse { + name = kind + "/alias image/" + action + } + t.Run(name, func(t *testing.T) { + real := filepath.Join(testutil.TempDir(t), "root with long name") + if err := copyFile(self, filepath.Join(real, "tools", "modavd.exe")); err != nil { + t.Fatal(err) + } + alias := updatePathAlias(t, real, kind) + rootPath, imageRoot := alias, real + if reverse { + rootPath, imageRoot = real, alias + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, filepath.Join(imageRoot, "tools", "modavd.exe"), "-test.run=^TestSelfUpdatePathAliasChild$", "-test.v") + cmd.Env = append(os.Environ(), "MODAVD_PATH_ALIAS_ROOT="+rootPath, "MODAVD_PATH_ALIAS_ACTION="+action) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("self update: %v\n%s", err, out) + } + }) + } + } + } +} + +func TestSelfUpdatePathAliasChild(t *testing.T) { + dir := os.Getenv("MODAVD_PATH_ALIAS_ROOT") + if dir == "" { + t.Skip("self-update fixture only") + } + l := layout{root: dir} + const rel = "tools/modavd.exe" + plan := rootPlan{replace: []string{rel}} + action := os.Getenv("MODAVD_PATH_ALIAS_ACTION") + if action == "check" { + if busy := l.rootFileInUse(plan); busy != "" { + t.Fatalf("self treated as occupant: %s", busy) + } + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, l.at("ModAVD.exe")); err != nil { + t.Fatal(err) + } + occupancyChild(t, l.at("ModAVD.exe")) + plan.replace = append(plan.replace, "ModAVD.exe") + if busy := l.rootFileInUse(plan); busy != "ModAVD.exe" { + t.Fatalf("different running image was exempted: %q", busy) + } + return + } + source := l.pkgAt(rel) + if action == "restore" || action == "recover rollback" { + source = l.prevAt(rel) + } + write := func(path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + write(source, action) + var err error + switch action { + case "apply": + err = l.applyRootFiles(plan) + case "restore": + err = l.restoreRootFiles(plan) + case "recover finish", "recover rollback": + write(l.at("mas-avd.json"), "{}") + if err := os.MkdirAll(l.at("sdk"), 0o755); err != nil { + t.Fatal(err) + } + st := newState(phaseSwapping, "plan", plan.value(), "swap", stepVal(stepOldMoved)) + target := jsonord.NewObject() + target.Obj().Set("version", jsonord.NewString("v0.37.0")) + st.Set("target", target) + if action == "recover finish" { + write(filepath.Join(l.stageEmulator(), "marker"), "new SDK") + staged := jsonord.NewObject() + staged.Obj().Set("verified", jsonord.NewBool(true)) + st.Set("staged", staged) + } else { + write(filepath.Join(l.previousEmulator(), "marker"), "old SDK") + } + if err := l.writeState(st); err != nil { + t.Fatal(err) + } + r, err := root.New(dir) + if err != nil { + t.Fatal(err) + } + u := New(r, nil, Options{}) + u.scan = ownScan(u) + if _, err := u.recover(); err != nil { + t.Fatal(err) + } + if phaseOf(l.readState()) != phaseIdle || !isDir(l.emulator()) { + t.Fatal("recovery did not commit its result") + } + wantSDK := "old SDK" + if action == "recover finish" { + wantSDK = "new SDK" + } + if got, err := os.ReadFile(filepath.Join(l.emulator(), "marker")); err != nil || string(got) != wantSDK { + t.Fatalf("wrong recovered SDK: %q, %v", got, err) + } + ok, _ := l.readState().Get("lastResult").Obj().Get("ok").BoolVal() + if ok != (action == "recover finish") { + t.Fatalf("wrong recovery result: ok=%v", ok) + } + default: + t.Fatalf("unknown action: %s", action) + } + if err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(l.at(rel)); err != nil || string(got) != action { + t.Fatalf("wrong replacement: %q, %v", got, err) + } +} + +func TestPathAliasProcessOwnership(t *testing.T) { + for _, kind := range []string{"junction", "symlink", "short name"} { + for _, reverse := range []bool{false, true} { + for _, rel := range []string{"tools/modavd.exe", "ModAVD.exe", "sdk/emulator/emulator.exe", ".update/previous/emulator/emulator.exe"} { + name := kind + "/alias root/" + rel + if reverse { + name = kind + "/alias image/" + rel + } + t.Run(name, func(t *testing.T) { + r := writeRoot(t, 36, nil) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, filepath.Join(r.Dir, filepath.FromSlash(rel))); err != nil { + t.Fatal(err) + } + alias := updatePathAlias(t, r.Dir, kind) + rootPath, imageRoot := alias, r.Dir + if reverse { + rootPath, imageRoot = r.Dir, alias + } + u := New(&root.Root{Dir: rootPath}, nil, Options{}) + if err := u.l.writeState(newState(phaseSwapping)); err != nil { + t.Fatal(err) + } + child := occupancyChild(t, filepath.Join(imageRoot, filepath.FromSlash(rel))) + program := rel == "tools/modavd.exe" || rel == "ModAVD.exe" + for _, programs := range []bool{false, true} { + uses, err := u.scanProcesses(programs, ownProcesses, win.Process.ImagePath) + if err != nil { + t.Fatal(err) + } + want := !program || programs + if !want { + if len(uses) != 0 { + t.Fatalf("program scan disabled: %+v", uses) + } + continue + } + if len(uses) != 1 || uses[0].PID != uint32(child.Process.Pid) || uses[0].RootProgram != program || uses[0].ShutdownHelper { + t.Fatalf("wrong process ownership: %+v", uses) + } + } + if program { + image := filepath.Join(imageRoot, filepath.FromSlash(rel)) + if !u.shutdownHelper(image, []string{image, "__bootwatch", imageRoot, "0", "123", "456"}) { + t.Error("same root's bootwatch was not recognized") + } + if u.shutdownHelper(image, []string{image, "__bootwatch", t.TempDir(), "0", "123", "456"}) { + t.Error("another root's bootwatch was recognized") + } + } + }) + } + } + } +} + +func TestPathAliasSDKInstall(t *testing.T) { + u, _ := imageUpdate(t) + alias := updatePathAlias(t, u.Root.Dir, "junction") + u.Root = &root.Root{Dir: alias} + u.l.root = alias + u.scan = ownScan(u) + if st, err := u.Install(context.Background()); err != nil || !st.Updated || st.Phase != phaseIdle { + t.Fatalf("install through junction: %+v, %v", st, err) + } +} + +func TestPathAliasReadOnlyLoadedImage(t *testing.T) { + dir := testutil.TempDir(t) + alias := updatePathAlias(t, dir, "junction") + path := filepath.Join(dir, "loaded.dll") + free := loadDLL(t, filepath.Join(alias, "loaded.dll")) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + for _, probe := range []string{path, filepath.Join(alias, "loaded.dll")} { + if !loadedImageInUse(probe) { + t.Error("loaded image query missed through junction") + } + if !fileInUse(probe) { + t.Error("read-only loaded image missed through junction") + } + } + free() + if fileInUse(path) { + t.Error("unloaded read-only image is still busy") + } +} + +func TestPathAliasDifferentRoot(t *testing.T) { + r := writeRoot(t, 36, nil) + alias := updatePathAlias(t, r.Dir, "junction") + u := New(&root.Root{Dir: alias}, nil, Options{}) + other := filepath.Join(testutil.TempDir(t), "tools", "modavd.exe") + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if err := copyFile(self, other); err != nil { + t.Fatal(err) + } + occupancyChild(t, other) + if uses, err := u.scanProcesses(true, ownProcesses, win.Process.ImagePath); err != nil || len(uses) != 0 { + t.Fatalf("another root's program matched: %+v, %v", uses, err) + } + if u.shutdownHelper(other, []string{other, "__bootwatch", alias, "0", "123", "456"}) { + t.Fatal("another root's program was treated as a shutdown helper") + } +} + +func TestUpdatePathResolutionFailure(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + if _, err := resolvePath(missing); err == nil { + t.Fatal("missing path resolved successfully") + } + if got := realOrSame(missing); got != missing { + t.Fatalf("missing path fallback changed: %q", got) + } + if _, err := resolvePath("invalid\x00path"); err == nil { + t.Fatal("invalid path resolved successfully") + } +} diff --git a/internal/update/pkg.go b/internal/update/pkg.go index 0ca12e6..0e029dc 100644 --- a/internal/update/pkg.go +++ b/internal/update/pkg.go @@ -10,6 +10,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "os" @@ -167,6 +168,9 @@ func extractEntry(e zipEntry, dst string) error { } rc, err := e.f.Open() if err != nil { + if isDiskError(err) { + return err + } return packageError("包里的 %s 打不开: %v", e.rel, err) } defer rc.Close() @@ -174,10 +178,7 @@ func extractEntry(e zipEntry, dst string) error { if err != nil { return err } - _, cerr := io.Copy(out, rc) - if err := out.Close(); cerr == nil { - cerr = err - } + cerr := copyEntryContents(out, rc) if cerr != nil { os.Remove(dst) if os.IsPermission(cerr) || isDiskError(cerr) { @@ -188,9 +189,18 @@ func extractEntry(e zipEntry, dst string) error { return nil } +// copyEntryContents owns the output handle, including when reading the ZIP entry fails. +func copyEntryContents(out io.WriteCloser, source io.Reader) error { + _, err := io.Copy(out, source) + if closeErr := out.Close(); closeErr != nil { + return closeErr + } + return err +} + func isDiskError(err error) bool { - _, ok := err.(*os.PathError) - return ok + var pathErr *os.PathError + return errors.As(err, &pathErr) } // extractPackage unpacks a full package into pkg\; the entry count. @@ -421,16 +431,41 @@ func (l layout) buildPatchedPackage(r *root.Root, archive string, base Version) // ---- the manifest +// regularPackageFile distinguishes an absent or wrong-shaped package entry from a local metadata error. +func regularPackageFile(path string) (bool, error) { + st, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return st.Mode().IsRegular(), nil +} + +// readManifest is a best-effort installed-version probe. Staging callers use loadManifest to retain I/O errors. func readManifest(path string) (*jsonord.Obj, bool) { + m, ok, _ := loadManifest(path) + return m, ok +} + +func loadManifest(path string) (*jsonord.Obj, bool, error) { + regular, err := regularPackageFile(path) + if err != nil || !regular { + return nil, false, err + } b, err := root.ReadFileShared(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } if err != nil { - return nil, false + return nil, false, err } v, err := jsonord.Parse(trimBOM(b)) if err != nil || v.Kind() != jsonord.Object || v.Obj().Len() == 0 { - return nil, false + return nil, false, nil } - return v.Obj(), true + return v.Obj(), true, nil } func manifestString(m *jsonord.Obj, key string) string { @@ -475,9 +510,10 @@ func verifyTree(tree string, files map[string]fileSpec) ([]string, error) { want[strings.ToLower(rel)] = rel } var problems []string + var readErr error seen := map[string]bool{} err := walkFiles(tree, func(rel string, info os.FileInfo) { - if strings.EqualFold(rel, manifestName) { + if readErr != nil || strings.EqualFold(rel, manifestName) { return } key := strings.ToLower(rel) @@ -492,13 +528,21 @@ func verifyTree(tree string, files map[string]fileSpec) ([]string, error) { problems = append(problems, rel+" 大小不对") return } - if sum, err := hashFile(filepath.Join(tree, filepath.FromSlash(rel))); err != nil || sum != spec.sha256 { + sum, err := hashFile(filepath.Join(tree, filepath.FromSlash(rel))) + if err != nil { + readErr = err + return + } + if sum != spec.sha256 { problems = append(problems, rel+" 内容与清单不符") } }) if err != nil { return nil, err } + if readErr != nil { + return nil, readErr + } for key, rel := range want { if !seen[key] { problems = append(problems, "缺 "+rel) @@ -546,10 +590,9 @@ func checkPackageManifest(m *jsonord.Obj, target Version) error { var buildIDRe = regexp.MustCompile(`^mas-([0-9]+)$`) -// packageBuild is the emulator build the package in pkg\ says it carries: its manifest's "build", else (a legacy -// package without one) the target's minor number, as versions through 0.40 were numbered. -func (l layout) packageBuild(target Version) int { - if m, ok := readManifest(l.pkgAt(manifestName)); ok { +// manifestBuild is the emulator build from the already-read manifest, or the minor version for legacy packages. +func manifestBuild(m *jsonord.Obj, target Version) int { + if m != nil { if n, ok := m.Get("build").Int(); ok && n > 0 { return int(n) } @@ -561,12 +604,22 @@ func (l layout) packageBuild(target Version) int { // and its qemu is our own build (it knows the free-page-reporting balloon option). func checkEmulator(emu string, want int, target Version) error { p := root.CurrentPlatform() - qemu := filepath.Join(emu, "qemu", p.QemuDir, p.QEMU) - if !isFile(filepath.Join(emu, p.Emulator)) || !isFile(qemu) { - return packageError(`包里的 sdk\emulator 不完整(缺 emulator.exe 或 qemu)`) + for _, rel := range []string{p.Emulator, filepath.Join("qemu", p.QemuDir, p.QEMU), + "source.properties", filepath.Join("qemu", p.QemuDir, p.QEMUHeadless)} { + regular, err := regularPackageFile(filepath.Join(emu, rel)) + if err != nil { + return err + } + if !regular { + return packageError("包里的 sdk/emulator/%s 不存在或不是普通文件", filepath.ToSlash(rel)) + } } build := "读不到" - if b, err := os.ReadFile(filepath.Join(emu, "source.properties")); err == nil { + b, err := os.ReadFile(filepath.Join(emu, "source.properties")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err == nil { id := "" for _, line := range strings.Split(string(b), "\n") { if k, v, ok := strings.Cut(line, "="); ok && strings.TrimSpace(k) == "Pkg.BuildId" { @@ -580,26 +633,36 @@ func checkEmulator(emu string, want int, target Version) error { if build != fmt.Sprint(want) { return packageError("包里模拟器的构建号(%s)和要装的 %s(构建号 %d)不一致", build, target.Display(), want) } - if !fileContains(filepath.Join(emu, "qemu", p.QemuDir, p.QEMUHeadless), []byte("free-page-reporting")) { + ours, err := fileContains(filepath.Join(emu, "qemu", p.QemuDir, p.QEMUHeadless), []byte("free-page-reporting")) + if err != nil { + return err + } + if !ours { return packageError("包里的模拟器不是 ModAVD 的自编版") } return nil } -// fileContains scans p in 4 MiB chunks (with overlap) for needle; false when unreadable. -func fileContains(p string, needle []byte) bool { +// fileContains scans p in 4 MiB chunks (with overlap) for needle. A missing file has no match; other I/O errors propagate. +func fileContains(p string, needle []byte) (bool, error) { f, err := os.Open(p) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } if err != nil { - return false + return false, err } defer f.Close() buf := make([]byte, 4<<20) carry := []byte{} for { n, err := f.Read(buf) + if err != nil && err != io.EOF { + return false, err + } chunk := append(carry, buf[:n]...) if bytes.Contains(chunk, needle) { - return true + return true, nil } if len(chunk) >= len(needle) { carry = append([]byte{}, chunk[len(chunk)-len(needle)+1:]...) @@ -607,7 +670,7 @@ func fileContains(p string, needle []byte) bool { carry = chunk } if err != nil { - return false + return false, nil } } } @@ -615,7 +678,10 @@ func fileContains(p string, needle []byte) bool { // verifyPackage is AUTO-MAS verify_package: "manifest" when pkg\ matches its manifest, "zip" for a legacy package // without one (only its zip CRC and download hash vouch for it). func (l layout) verifyPackage(target Version, tag string) (string, error) { - m, ok := readManifest(l.pkgAt(manifestName)) + m, ok, err := loadManifest(l.pkgAt(manifestName)) + if err != nil { + return "", err + } verified := "zip" switch { case !ok && requiresManifest(tag): @@ -642,7 +708,7 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { } verified = "manifest" } - if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { + if err := checkEmulator(l.pkgAt("sdk/emulator"), manifestBuild(m, target), target); err != nil { return "", err } return verified, nil @@ -650,7 +716,11 @@ func (l layout) verifyPackage(target Version, tag string) (string, error) { // buildStage moves pkg\sdk\emulator to .update\stage\emulator. func (l layout) buildStage(target Version) error { - if err := checkEmulator(l.pkgAt("sdk/emulator"), l.packageBuild(target), target); err != nil { + m, _, err := loadManifest(l.pkgAt(manifestName)) + if err != nil { + return err + } + if err := checkEmulator(l.pkgAt("sdk/emulator"), manifestBuild(m, target), target); err != nil { return err } if err := removeTree(l.stage()); err != nil { diff --git a/internal/update/range.go b/internal/update/range.go index b63e886..aadc76b 100644 --- a/internal/update/range.go +++ b/internal/update/range.go @@ -6,11 +6,12 @@ package update // unpacking the whole package would leave it. The manifest check that follows is the same as for a full download. // // archive/zip reads the package through remoteZip, an io.ReaderAt that holds only the pieces fetched so far. Anything -// that goes wrong on the way (the server ignores Range, too much changed, a fetched entry fails its CRC, the result -// fails the manifest) is a *rangeUnavailable: the caller drops pkg\ and downloads the whole package instead. +// that makes the range result unusable (unsupported ranges, too much changed, a bad CRC or manifest) returns a +// *rangeUnavailable: the caller drops pkg\ and downloads the whole package instead. Local creation, write and close errors stop the update. import ( "archive/zip" + "bytes" "context" "errors" "fmt" @@ -44,19 +45,22 @@ const ( rangeMaxDirectory = 32 << 20 ) -// rangeUnavailable: this update cannot be done by range; download the whole package. The reason goes into the notices. -type rangeUnavailable struct{ reason string } +// rangeUnavailable: download the whole package. Only failed attempts, not normal threshold decisions, add a notice. +type rangeUnavailable struct { + reason string + normal bool // a threshold decision, not a failed range attempt +} func (e *rangeUnavailable) Error() string { return e.reason } func unavailable(format string, args ...any) *rangeUnavailable { - return &rangeUnavailable{fmt.Sprintf(format, args...)} + return &rangeUnavailable{reason: fmt.Sprintf(format, args...)} } // segment is a piece of the remote package that has been fetched. type segment struct { - start int64 - data []byte + start, length int64 + reader io.ReaderAt } // remoteZip is the remote package as an io.ReaderAt for archive/zip: reads are served from the fetched segments. While @@ -76,7 +80,7 @@ var errNotFetched = errors.New("读到了没取回的位置") func (r *remoteZip) find(pos int64) *segment { for k := range r.segs { s := &r.segs[k] - if s.start <= pos && pos < s.start+int64(len(s.data)) { + if s.start <= pos && pos < s.start+s.length { return s } } @@ -95,7 +99,7 @@ func (r *remoteZip) gapEnd(pos int64) int64 { } func (r *remoteZip) add(start int64, data []byte) { - r.segs = append(r.segs, segment{start, data}) + r.segs = append(r.segs, segment{start, int64(len(data)), bytes.NewReader(data)}) } func (r *remoteZip) drop(start int64) { @@ -133,7 +137,15 @@ func (r *remoteZip) ReadAt(p []byte, off int64) (int, error) { r.add(pos, data) continue } - n += copy(p[n:], s.data[pos-s.start:]) + want := min(int64(len(p)-n), s.length-(pos-s.start)) + got, err := s.reader.ReadAt(p[n:n+int(want)], pos-s.start) + n += got + if err != nil { + return n, err + } + if int64(got) != want { + return n, io.ErrUnexpectedEOF + } } return n, nil } @@ -178,21 +190,34 @@ func (l layout) sameAsLocal(e zipEntry) bool { return h.Sum32() == e.f.CRC32 } -// rangeRead reads one range of r's package, retried like a download (but not when the server cannot serve ranges). +// rangeRead is bounded by rangeMaxDirectory for metadata and rangeMaxGroup for small entry groups. func (u *Updater) rangeRead(ctx context.Context, r *release, off, n int64, what string) ([]byte, error) { d := download.Client{HTTP: u.client(), StallTimeout: u.stallTimeout} + var data []byte + err := u.rangeRetry(ctx, off, n, what, func() (err error) { + data, err = d.Range(ctx, r.url, off, n, r.size, what) + return err + }) + return data, err +} + +// rangeRetry retries only transport failures. Local writer, seek and truncate errors stop immediately. +func (u *Updater) rangeRetry(ctx context.Context, off, n int64, what string, fetch func() error) error { var last error for attempt := 1; attempt <= downloadAttempts; attempt++ { - b, err := d.Range(ctx, r.url, off, n, r.size, what) + err := fetch() if err == nil { - return b, nil + return nil } if ctx.Err() != nil { - return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + return errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } - last = err var de *download.Error - if errors.As(err, &de) && (de.Kind == download.NoRange || de.Kind == download.Size) { + if !errors.As(err, &de) { + return err + } + last = err + if de.Kind == download.NoRange || de.Kind == download.Size { break } u.Log.Warnf("%s 第 %d 次没取到: %v", what, attempt, err) @@ -200,11 +225,11 @@ func (u *Updater) rangeRead(ctx context.Context, r *release, off, n int64, what select { case <-time.After(time.Duration(attempt) * time.Second): case <-ctx.Done(): - return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + return errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } } } - return nil, unavailable("按区间取 %d-%d 没成(%v)", off, off+n-1, last) + return unavailable("按区间取 %d-%d 没成(%v)", off, off+n-1, last) } // planRange reads the package's central directory and compares it with the root: which entries to fetch, which to @@ -239,7 +264,7 @@ func (u *Updater) planRange(ctx context.Context, r *release) (*rangePlan, error) if ctx.Err() != nil { return nil, errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } - if u.l.sameAsLocal(e) { + if !strings.EqualFold(e.rel, manifestName) && u.l.sameAsLocal(e) { plan.copy = append(plan.copy, e) continue } @@ -251,8 +276,8 @@ func (u *Updater) planRange(ctx context.Context, r *release) (*rangePlan, error) plan.fetch = append(plan.fetch, rangeEntry{e, start, length}) } if fetched := plan.fetchBytes(); rangeTooMuch(len(plan.fetch), fetched, r.size) { - return nil, unavailable("变了的文件太多(%d 个、约 %s,超过整包的 %.0f%% 或 %d 个文件)", len(plan.fetch), mib(fetched), - rangeMaxFetchRatio*100, rangeMaxFetchEntries) + return nil, &rangeUnavailable{reason: fmt.Sprintf("变了的文件太多(%d 个、约 %s,超过整包的 %.0f%% 或 %d 个文件)", + len(plan.fetch), mib(fetched), rangeMaxFetchRatio*100, rangeMaxFetchEntries), normal: true} } return plan, nil } @@ -327,19 +352,10 @@ func (u *Updater) stageByRange(ctx context.Context, r *release) (int, string, er var fetched int64 u.progress("downloading", label, 0, 0, total) for _, g := range groups { - data, err := u.rangeRead(ctx, r, g.start, g.length, r.assetName) - if err != nil { + if err := u.stageRangeGroup(ctx, r, g, plan.remote); err != nil { return 0, "", err } - plan.remote.add(g.start, data) - for _, e := range g.members { - if err := extractEntry(e.zipEntry, u.l.pkgAt(e.rel)); err != nil { - plan.remote.drop(g.start) - return 0, "", unavailable("取回的 %s 不对: %v", e.rel, errs.As(err).Message) - } - } - plan.remote.drop(g.start) - fetched += int64(len(data)) + fetched += g.length u.progress("downloading", label, min(float64(fetched)*100/float64(max(total, 1)), 100), min(fetched, total), total) } u.step("extracting", "正在拼新版(没变的文件从本地复制)") @@ -348,7 +364,7 @@ func (u *Updater) stageByRange(ctx context.Context, r *release) (int, string, er return 0, "", errs.Wrap(errs.Canceled, ctx.Err(), "已取消") } if err := copyFile(u.l.at(e.rel), u.l.pkgAt(e.rel)); err != nil { - return 0, "", unavailable("复制本地的 %s 没成: %v", e.rel, err) + return 0, "", err } if k%200 == 0 || k == len(plan.copy)-1 { u.progress("extracting", "正在拼新版(没变的文件从本地复制)", float64(k+1)*100/float64(len(plan.copy)), 0, 0) @@ -366,6 +382,60 @@ func (u *Updater) stageByRange(ctx context.Context, r *release) (int, string, er return len(plan.fetch) + len(plan.copy), verified, nil } +// stageRangeGroup keeps small groups in memory. A large entry uses a private file under pkg so an interrupted +// staging attempt is cleaned by the existing staging recovery. Drop the ReaderAt segment before closing the file. +func (u *Updater) stageRangeGroup(ctx context.Context, r *release, g rangeGroup, remote *remoteZip) (err error) { + if g.length <= rangeMaxGroup { + data, err := u.rangeRead(ctx, r, g.start, g.length, r.assetName) + if err != nil { + return err + } + remote.add(g.start, data) + } else { + f, createErr := os.CreateTemp(u.l.pkg(), ".range-*") + if createErr != nil { + return createErr + } + defer func() { + // A local cleanup failure must also stop the update instead of starting a full download. + cerr := f.Close() + rerr := os.Remove(f.Name()) + if cerr != nil { + err = cerr + } + if rerr != nil { + err = rerr + } + }() + d := download.Client{HTTP: u.client(), StallTimeout: u.stallTimeout} + if err := u.rangeRetry(ctx, g.start, g.length, r.assetName, func() error { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + if err := f.Truncate(0); err != nil { + return err + } + return d.RangeTo(ctx, r.url, g.start, g.length, r.size, r.assetName, f) + }); err != nil { + return err + } + remote.segs = append(remote.segs, segment{g.start, g.length, f}) + } + defer remote.drop(g.start) + for _, e := range g.members { + if ctx.Err() != nil { + return errs.Wrap(errs.Canceled, ctx.Err(), "已取消") + } + if err := extractEntry(e.zipEntry, u.l.pkgAt(e.rel)); err != nil { + if errs.CodeOf(err) != errs.UpdateRejected { + return err + } + return unavailable("取回的 %s 不对: %v", e.rel, errs.As(err).Message) + } + } + return nil +} + // rangeAllowed: GitHub's full package of a version that carries a manifest (the range path cannot check the package's // SHA-256, so the manifest has to vouch for every file), with no full download of it (finished or partial) to use. func (u *Updater) rangeAllowed(r *release) bool { diff --git a/internal/update/range_cleanup_windows_test.go b/internal/update/range_cleanup_windows_test.go new file mode 100644 index 0000000..2631704 --- /dev/null +++ b/internal/update/range_cleanup_windows_test.go @@ -0,0 +1,142 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "syscall" + "testing" +) + +type rangeEOFReader struct { + io.Reader + onEOF func() +} + +func (r *rangeEOFReader) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + if err == io.EOF && r.onEOF != nil { + r.onEOF() + r.onEOF = nil + } + return n, err +} +func (*rangeEOFReader) Close() error { return nil } + +func TestRangeLargeGroupCleanupErrorsStopFallback(t *testing.T) { + // Store the fixture so the range exceeds 8 MiB without spending time compressing test padding. + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + w, err := zw.CreateHeader(&zip.FileHeader{Name: "payload.bin", Method: zip.Store}) + if err != nil { + t.Fatal(err) + } + if _, err := io.CopyN(w, bytes.NewReader(bytes.Repeat([]byte{7}, 9<<20)), 9<<20); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + for _, failure := range []string{"close", "remove"} { + t.Run(failure, func(t *testing.T) { + r := writeRoot(t, 36, nil) + f := newSources(t, 37, archive.Bytes()) + u := f.updater(r) + if err := os.MkdirAll(u.l.pkg(), 0o755); err != nil { + t.Fatal(err) + } + zr, err := zip.NewReader(bytes.NewReader(archive.Bytes()), int64(archive.Len())) + if err != nil { + t.Fatal(err) + } + remote := &remoteZip{size: int64(archive.Len())} + var held syscall.Handle + defer func() { + if held != 0 { + syscall.CloseHandle(held) + } + }() + injected := false + zr.RegisterDecompressor(zip.Store, func(input io.Reader) io.ReadCloser { + return &rangeEOFReader{Reader: input, onEOF: func() { + // Inject the OS failure after extraction has read every byte, so only cleanup can report it. + for _, s := range remote.segs { + file, ok := s.reader.(*os.File) + if !ok { + continue + } + injected = true + if failure == "close" { + if err := file.Close(); err != nil { + t.Fatal(err) + } + } else { + name, err := syscall.UTF16PtrFromString(file.Name()) + if err != nil { + t.Fatal(err) + } + held, err = syscall.CreateFile(name, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, + nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + } + } + }} + }) + g := rangeGroup{length: int64(archive.Len()), members: []rangeEntry{{zipEntry: zipEntry{rel: "payload.bin", f: zr.File[0]}}}} + rel, err := u.githubLatest(context.Background()) + if err != nil { + t.Fatal(err) + } + err = u.stageRangeGroup(context.Background(), rel, g, remote) + var ru *rangeUnavailable + var pathErr *os.PathError + if !injected || err == nil || errors.As(err, &ru) || !errors.As(err, &pathErr) || len(remote.segs) != 0 { + t.Fatalf("cleanup %s: injected %v, error %v, remaining segments %d", failure, injected, err, len(remote.segs)) + } + if failure == "close" && !errors.Is(err, os.ErrClosed) { + t.Fatalf("want close error, got %v", err) + } + if held != 0 { + syscall.CloseHandle(held) + held = 0 + } + temps, err := filepath.Glob(filepath.Join(u.l.pkg(), ".range-*")) + if err != nil { + t.Fatal(err) + } + for _, name := range temps { + if err := os.Remove(name); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestRangeLargeGroupCreateErrorIsLocal(t *testing.T) { + r := writeRoot(t, 36, nil) + u := New(r, nil, Options{}) + if err := os.MkdirAll(u.l.dir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(u.l.pkg(), []byte("blocks creation"), 0o644); err != nil { + t.Fatal(err) + } + err := u.stageRangeGroup(context.Background(), &release{}, rangeGroup{length: rangeMaxGroup + 1}, &remoteZip{}) + var ru *rangeUnavailable + var pathErr *os.PathError + if err == nil || errors.As(err, &ru) || !errors.As(err, &pathErr) { + t.Fatalf("create error: %v", err) + } +} diff --git a/internal/update/range_hardening_test.go b/internal/update/range_hardening_test.go new file mode 100644 index 0000000..30be873 --- /dev/null +++ b/internal/update/range_hardening_test.go @@ -0,0 +1,243 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "fmt" + "hash/crc32" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + "testing" + + "modavd/internal/errs" +) + +func TestRangeManifestAlwaysRemote(t *testing.T) { + c := newRangeCase(t) + manifest := manifestFor(37, c.newFiles) + forged := forgeCRC(bytes.Repeat([]byte("x"), len(manifest)), crc32.ChecksumIEEE(manifest)) + if bytes.Equal(forged, manifest) || crc32.ChecksumIEEE(forged) != crc32.ChecksumIEEE(manifest) { + t.Fatal("invalid collision fixture") + } + r := writeRoot(t, 36, c.big) + f := newSources(t, 37, c.zip) + u := f.updater(r) + if err := os.WriteFile(u.l.at(manifestName), forged, 0o644); err != nil { + t.Fatal(err) + } + rel, err := u.githubLatest(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, _, err = u.stageByRange(context.Background(), rel); err != nil { + t.Fatalf("remote manifest should allow staging despite the local CRC collision: %v", err) + } + got, err := os.ReadFile(u.l.pkgAt(manifestName)) + if err != nil || !bytes.Equal(got, manifest) { + t.Fatalf("manifest was not fetched from the package: %v", err) + } +} + +func TestRangeThresholdDoesNotAddNotice(t *testing.T) { + withSettings(t, SourceGitHub, "") + c := newRangeCase(t) + r := writeRoot(t, 36, nil) + f := newSources(t, 37, c.zip) + log := &dlLog{} + f.dl = log.serve(c.zip, nil) + u := f.updater(r) + st, err := u.Prepare(context.Background()) + if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" || len(st.Notices) != 0 || log.fullGets() != 1 { + t.Fatalf("normal full-package fallback: status %+v, error %v, full GETs %d", st, err, log.fullGets()) + } + st, err = u.Install(context.Background()) + if err != nil || !st.Updated || strings.Contains(st.Message, "改下整包") { + t.Fatalf("normal fallback in final message: %+v %v", st, err) + } +} + +func TestRangeLocalExtractionErrorDoesNotDownloadWhole(t *testing.T) { + for _, operation := range []string{"mkdir", "open", "copy"} { + t.Run(operation, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + c := newRangeCase(t) + r := writeRoot(t, 36, c.big) + f := newSources(t, 37, c.zip) + log := &dlLog{} + f.dl = log.serve(c.zip, nil) + u := f.updater(r) + injected := false + u.Opt.Progress = func(p Progress) { + if injected || p.Stage != "downloading" || p.Total == 0 { + return + } + injected = true + dst := u.l.pkgAt("sdk") + if operation == "mkdir" { + if err := os.WriteFile(dst, []byte("blocks the destination directory"), 0o644); err != nil { + t.Fatal(err) + } + } else { + name := "sdk/emulator/emulator.exe" + if operation == "copy" { + name = "sdk/emulator/lib/big0.dll" + } + if err := os.MkdirAll(u.l.pkgAt(name), 0o755); err != nil { + t.Fatal(err) + } + } + } + st, err := u.Prepare(context.Background()) + var unavailable *rangeUnavailable + if !injected || err == nil || errors.As(err, &unavailable) || log.fullGets() != 0 || len(st.Notices) != 0 || st.Phase == phaseStaged { + t.Fatalf("local %s error triggered a fallback: injected %v, status %+v, error %v, full GETs %d", operation, injected, st, err, log.fullGets()) + } + }) + } +} + +func TestRangeLargeEntryLifecycle(t *testing.T) { + const largePath = "sdk/emulator/lib/new-large.dll" + big := bigFiles(1, 4, 6<<20) + large := bigFiles(2, 1, 9<<20)["sdk/emulator/lib/big0.dll"] + files := pkgFiles(37, merged(big, map[string]string{largePath: large})) + archive := zipOf(t, 37, files, false) + zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + t.Fatal(err) + } + var dataOffset int64 + for _, f := range zr.File { + if f.Name == largePath { + dataOffset, err = f.DataOffset() + } + } + if err != nil || dataOffset == 0 { + t.Fatal("large entry not found", err) + } + for _, mode := range []string{"success", "retry", "cancel", "corrupt", "local-create", "local-open"} { + t.Run(mode, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, big) + f := newSources(t, 37, archive) + u := f.updater(r) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + log := &dlLog{} + var requests, tempSeen atomic.Int32 + rangeBody := archive + if mode == "corrupt" { + rangeBody = append([]byte(nil), archive...) + rangeBody[dataOffset+12345] ^= 0x55 + } + serve := log.serve(archive, rangeBody) + f.dl = func(w http.ResponseWriter, req *http.Request) { + var start, end int64 + _, parseErr := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end) + if parseErr != nil || end-start+1 <= rangeMaxGroup { + serve(w, req) + return + } + attempt := requests.Add(1) + temps, err := filepath.Glob(filepath.Join(u.l.pkg(), ".range-*")) + if err != nil || len(temps) != 1 { + t.Errorf("large transfer has no private file: %v %v", temps, err) + } else { + tempSeen.Add(1) + } + if (mode == "retry" && attempt == 1) || mode == "cancel" { + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(archive))) + w.Header().Set("Content-Length", fmt.Sprint(end-start+1)) + w.WriteHeader(http.StatusPartialContent) + w.Write(bytes.Repeat([]byte{0x55}, 64<<10)) + w.(http.Flusher).Flush() + if mode == "cancel" { + cancel() + <-req.Context().Done() + } + return + } + if mode == "local-open" { + // Deny the next extracted output's open, after the large range was received. + if err := os.MkdirAll(u.l.pkgAt(largePath), 0o755); err != nil { + t.Error(err) + } + } + serve(w, req) + } + if mode == "local-create" { + u.Opt.Progress = func(p Progress) { + if p.Stage != "downloading" || p.Total == 0 || p.Done != 0 { + return + } + if err := os.Remove(u.l.pkg()); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(u.l.pkg(), []byte("cannot create a range file here"), 0o644); err != nil { + t.Fatal(err) + } + } + } + st, err := u.Prepare(ctx) + switch mode { + case "success", "retry", "corrupt": + wantMode, wantNotices, wantFull := "range", 0, 0 + if mode == "corrupt" { + wantMode, wantNotices, wantFull = "full", 1, 1 + } + if err != nil || st.Phase != phaseStaged || stagedMode(u) != wantMode || len(st.Notices) != wantNotices || log.fullGets() != wantFull { + t.Fatalf("prepare: %+v, error %v, full GETs %d", st, err, log.fullGets()) + } + // buildStage has moved sdk/emulator into stage/emulator. + got, err := os.ReadFile(filepath.Join(u.l.stageEmulator(), "lib", "new-large.dll")) + if err != nil || !bytes.Equal(got, []byte(large)) { + t.Fatalf("large staged output differs: %v", err) + } + if mode == "retry" && requests.Load() != 2 { + t.Fatalf("want two large attempts, got %d", requests.Load()) + } + case "cancel": + if errs.CodeOf(err) != errs.Canceled || log.fullGets() != 0 || requests.Load() != 1 { + t.Fatalf("cancel: %v, large attempts %d, full GETs %d", err, requests.Load(), log.fullGets()) + } + default: + if err == nil || log.fullGets() != 0 || st.Phase == phaseStaged { + t.Fatalf("local failure: %+v %v, full GETs %d", st, err, log.fullGets()) + } + } + if mode != "local-create" && tempSeen.Load() == 0 { + t.Fatal("large entry never used a private temporary file") + } + // Also catches leaked Windows handles: the existing abort and temp cleanup must remove the file. + temps, err := filepath.Glob(filepath.Join(u.l.pkg(), ".range-*")) + if err != nil || len(temps) != 0 { + t.Fatalf("range file leaked: %v %v", temps, err) + } + }) + } +} + +func TestRangeRetryDoesNotRetryLocalFailures(t *testing.T) { + r := writeRoot(t, 36, nil) + u := New(r, nil, Options{}) + for _, failure := range []error{io.ErrShortWrite, &os.PathError{Op: "write", Path: "range file", Err: syscall.Errno(112)}} { + attempts := 0 + err := u.rangeRetry(context.Background(), 0, 1, "test", func() error { attempts++; return failure }) + var ru *rangeUnavailable + if !errors.Is(err, failure) || attempts != 1 || errors.As(err, &ru) { + t.Fatalf("attempts %d, error %v", attempts, err) + } + } +} diff --git a/internal/update/range_local_errors_windows_test.go b/internal/update/range_local_errors_windows_test.go new file mode 100644 index 0000000..3d6d9d6 --- /dev/null +++ b/internal/update/range_local_errors_windows_test.go @@ -0,0 +1,177 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "io" + "modavd/internal/errs" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" +) + +func TestRangeVerificationReadErrorDoesNotDownloadWhole(t *testing.T) { + for _, target := range []string{manifestName, "sdk/emulator/emulator.exe"} { + t.Run(target, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + c := newRangeCase(t) + r := writeRoot(t, 36, c.big) + f := newSources(t, 37, c.zip) + log := &dlLog{} + serve := log.serve(c.zip, nil) + u := f.updater(r) + var mu sync.Mutex + var held syscall.Handle + unlock := func() { + mu.Lock() + defer mu.Unlock() + if held != 0 { + syscall.CloseHandle(held) + held = 0 + } + } + defer unlock() + f.dl = func(w http.ResponseWriter, req *http.Request) { + if req.Header.Get("Range") == "" { + unlock() + } + serve(w, req) + } + injected := false + u.Opt.Progress = func(p Progress) { + if injected || p.Stage != "verifying" { + return + } + injected = true + if _, err := os.Stat(u.l.pkgAt(target)); err != nil { + t.Fatal(err) + } + name, err := syscall.UTF16PtrFromString(u.l.pkgAt(target)) + if err != nil { + t.Fatal(err) + } + mu.Lock() + held, err = syscall.CreateFile(name, syscall.GENERIC_READ, + syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, + syscall.FILE_ATTRIBUTE_NORMAL, 0) + mu.Unlock() + if err != nil { + t.Fatal(err) + } + } + st, err := u.Prepare(context.Background()) + t.Logf("injected=%v error=%v code=%s mode=%s fullGets=%d notices=%v", injected, err, errs.CodeOf(err), stagedMode(u), log.fullGets(), st.Notices) + var pathErr *os.PathError + if !injected || !errors.As(err, &pathErr) || errs.CodeOf(err) == errs.UpdateRejected || log.fullGets() != 0 || len(st.Notices) != 0 { + t.Errorf("local verification read failure did not stop before whole download") + } + }) + } +} + +// entryFaultOutput exercises destination errors without changing global file operations. +type entryFaultOutput struct { + data bytes.Buffer + writeErr, closeErr error + closed int +} + +func (o *entryFaultOutput) Write(p []byte) (int, error) { + if o.writeErr != nil { + return 0, o.writeErr + } + return o.data.Write(p) +} +func (o *entryFaultOutput) Close() error { o.closed++; return o.closeErr } + +type entryReadError struct{ err error } + +func (r entryReadError) Read([]byte) (int, error) { return 0, r.err } + +func TestEntryCloseErrorTakesPrecedenceOverBadZIP(t *testing.T) { + disk := &os.PathError{Op: "close", Path: "output", Err: syscall.Errno(112)} + for _, tc := range []struct { + name string + readErr, writeErr, closeErr, want error + }{ + {"bad ZIP and close", zip.ErrChecksum, nil, disk, disk}, + {"bad ZIP alone", zip.ErrChecksum, nil, nil, zip.ErrChecksum}, + {"write error", nil, disk, nil, disk}, + {"close error", nil, nil, disk, disk}, + {"success", nil, nil, nil, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + out := &entryFaultOutput{writeErr: tc.writeErr, closeErr: tc.closeErr} + source := io.Reader(strings.NewReader("payload")) + if tc.readErr != nil { + source = io.MultiReader(source, entryReadError{tc.readErr}) + } + err := copyEntryContents(out, source) + if !errors.Is(err, tc.want) || out.closed != 1 { + t.Fatalf("error %v, want %v, closes %d", err, tc.want, out.closed) + } + }) + } +} + +func TestEmulatorVerificationReadErrorsStayLocal(t *testing.T) { + for _, target := range []string{"source.properties", "qemu/windows-x86_64/qemu-system-x86_64-headless.exe"} { + t.Run(target, func(t *testing.T) { + r := writeRoot(t, 37, nil) + path := filepath.Join(r.Dir, "sdk", "emulator", filepath.FromSlash(target)) + name, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + held, err := syscall.CreateFile(name, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(held) + version, _ := ParseVersion("v0.37.0") + err = checkEmulator(filepath.Join(r.Dir, "sdk", "emulator"), 37, version) + var pathErr *os.PathError + if !errors.As(err, &pathErr) || errs.CodeOf(err) == errs.UpdateRejected { + t.Fatalf("local emulator read: %v", err) + } + }) + } +} + +func TestStageBuildManifestReadErrorIsLocal(t *testing.T) { + r := writeRoot(t, 36, nil) + u := New(r, nil, Options{}) + archive := filepath.Join(r.Dir, "package.zip") + if err := os.WriteFile(archive, zipOf(t, 37, pkgFiles(37, nil), false), 0o644); err != nil { + t.Fatal(err) + } + if _, err := u.l.extractPackage(archive, "", nil); err != nil { + t.Fatal(err) + } + path, err := syscall.UTF16PtrFromString(u.l.pkgAt(manifestName)) + if err != nil { + t.Fatal(err) + } + held, err := syscall.CreateFile(path, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(held) + version, _ := ParseVersion("v0.37.0") + err = u.l.buildStage(version) + var pathErr *os.PathError + if !errors.As(err, &pathErr) || lexists(u.l.stageEmulator()) { + t.Fatalf("unreadable manifest was ignored when building stage: %v", err) + } +} diff --git a/internal/update/range_test.go b/internal/update/range_test.go index d36802e..306a2c6 100644 --- a/internal/update/range_test.go +++ b/internal/update/range_test.go @@ -290,8 +290,15 @@ func TestRangeFallsBack(t *testing.T) { u := f.updater(r) ctx := context.Background() st, err := u.Prepare(ctx) - if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" || len(st.Notices) != 1 || - !strings.Contains(st.Notices[0], tc.notice) || !strings.Contains(st.Notices[0], "改下整包") { + if err != nil || st.Phase != phaseStaged || stagedMode(u) != "full" { + t.Fatalf("prepare: %+v %v (mode %q)", st, err, stagedMode(u)) + } + wantNotices := 1 + if tc.name == "too much changed" { + wantNotices = 0 + } + if len(st.Notices) != wantNotices || (wantNotices > 0 && + (!strings.Contains(st.Notices[0], tc.notice) || !strings.Contains(st.Notices[0], "改下整包"))) { t.Fatalf("prepare: %+v %v (mode %q)", st, err, stagedMode(u)) } if log.fullGets() != 1 { diff --git a/internal/update/root_busy_windows_test.go b/internal/update/root_busy_windows_test.go new file mode 100644 index 0000000..9198684 --- /dev/null +++ b/internal/update/root_busy_windows_test.go @@ -0,0 +1,468 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package update + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + "unsafe" + + "modavd/internal/errs" + "modavd/internal/testutil" +) + +func TestRootBusyReadOnlyLoadedDLL(t *testing.T) { + path := filepath.Join(t.TempDir(), "loaded.dll") + free := loadDLL(t, path) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + for _, access := range []uint32{syscall.GENERIC_WRITE, 0x00010000} { + h, err := syscall.CreateFile(ptr, access, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, 0, 0) + t.Logf("loaded read-only DLL access=%#x: %v", access, err) + if err == nil { + syscall.CloseHandle(h) + } + } + if !fileInUse(path) { + t.Error("loaded read-only DLL was not detected") + } + st, err := os.Stat(path) + if err != nil || st.Mode().Perm()&0o200 != 0 { + t.Fatalf("read-only attribute changed: %v, %v", st, err) + } + free() + if fileInUse(path) { + t.Error("unloaded read-only DLL is busy") + } +} + +func TestRootBusyHandleSharing(t *testing.T) { + for _, tc := range []struct { + name string + share uint32 + busy bool + }{ + {"no sharing", 0, true}, + {"read and write only", syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE, true}, + {"all sharing", syscall.FILE_SHARE_READ | syscall.FILE_SHARE_WRITE | syscall.FILE_SHARE_DELETE, false}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(ptr, syscall.GENERIC_READ, tc.share, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + if got := fileInUse(path); got != tc.busy { + t.Errorf("busy = %v, want %v", got, tc.busy) + } + if err := syscall.CloseHandle(h); err != nil { + t.Fatal(err) + } + if fileInUse(path) { + t.Error("closed handle is still busy") + } + if got, err := os.ReadFile(path); err != nil || string(got) != "unchanged" { + t.Fatalf("probe changed file: %q %v", got, err) + } + }) + } + t.Run("os.Open", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "open.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if !fileInUse(path) { + t.Error("os.Open handle was not detected") + } + }) +} + +func TestRootBusyAccessDeniedIsNotUse(t *testing.T) { + dir := t.TempDir() + if fileInUse(dir) { + t.Error("directory access denial counts as busy") + } + path := filepath.Join(dir, "readonly.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + if f, err := os.OpenFile(path, os.O_WRONLY, 0); !errors.Is(err, syscall.ERROR_ACCESS_DENIED) { + if err == nil { + f.Close() + } + t.Fatalf("read-only fixture did not deny writing: %v", err) + } + if fileInUse(path) { + t.Error("read-only file without a handle counts as busy") + } +} + +// Sharing allows the probes but denies a backup reader. If a busy file is known, +// reaching backupRootFiles would fail here before the updater could report Busy. +func rootBusyBackupGuard(t *testing.T, path string) func() { + t.Helper() + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(ptr, syscall.GENERIC_READ, syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + open := true + close := func() { + if open { + open = false + syscall.CloseHandle(h) + } + } + t.Cleanup(close) + return close +} + +func TestRootBusyInstallChecksBeforeBackup(t *testing.T) { + const target = "extras/occupied.dll" + for _, mode := range []string{"loaded DLL", "read-only loaded DLL", "no delete sharing", "deleted file"} { + t.Run(mode, func(t *testing.T) { + withSettings(t, SourceGitHub, "") + oldFiles := map[string]string{"0-backup.txt": "old backup", target: "old DLL"} + newFiles := map[string]string{"0-backup.txt": "new backup", target: "new DLL"} + if mode == "deleted file" { + delete(newFiles, target) + } + r := writeRoot(t, 36, oldFiles) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, newFiles), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + if _, err := u.Prepare(context.Background()); err != nil { + t.Fatal(err) + } + path := u.l.at(target) + var release func() + if mode == "no delete sharing" || mode == "deleted file" { + opened, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + release = func() { opened.Close() } + t.Cleanup(release) + } else { + release = loadDLL(t, path) + if mode == "read-only loaded DLL" { + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + } + } + oldDLL, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + metadata, err := os.ReadFile(u.l.at("mas-avd.json")) + if err != nil { + t.Fatal(err) + } + closeGuard := rootBusyBackupGuard(t, u.l.at("0-backup.txt")) + if _, err := u.Install(context.Background()); errs.CodeOf(err) != errs.Busy || !strings.Contains(err.Error(), filepath.FromSlash(target)) { + t.Fatalf("backup ran before the busy check, or occupant missed: %v", err) + } + if st := u.Status(); st.Phase != phaseStaged || st.PreparedVersion != "v0.37.0" { + t.Fatalf("stage lost: %+v", st) + } + if err := u.l.checkStaged(u.l.readState()); err != nil { + t.Fatalf("stage no longer valid: %v", err) + } + if lexists(u.l.previous()) { + t.Error("busy install left a backup") + } + if got := read(t, r, "sdk/emulator/emulator.exe"); got != "emulator mas-36" { + t.Fatalf("old SDK changed: %q", got) + } + if got, err := os.ReadFile(path); err != nil || string(got) != string(oldDLL) { + t.Fatalf("occupied file changed: %v", err) + } + if got, err := os.ReadFile(u.l.at("mas-avd.json")); err != nil || string(got) != string(metadata) { + t.Fatalf("metadata changed: %v", err) + } + closeGuard() + release() + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("retry: %+v %v", st, err) + } + if mode == "deleted file" { + if lexists(path) { + t.Error("obsolete file survived retry") + } + } else if got := read(t, r, target); got != "new DLL" { + t.Fatalf("retry installed %q", got) + } + }) + } +} + +func TestRootBusySelfThroughDirectoryLink(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + for _, action := range []string{"check", "apply", "restore"} { + t.Run(action, func(t *testing.T) { + dir := testutil.TempDir(t) + real := filepath.Join(dir, "root") + cli := filepath.Join(real, "tools", "modavd.exe") + if err := copyFile(self, cli); err != nil { + t.Fatal(err) + } + alias := filepath.Join(dir, "alias") + // The target exists, so this is a directory link on Windows too. + if err := os.Symlink(real, alias); err != nil { + t.Skipf("directory symlink unavailable: %v", err) + } + if st, err := os.Stat(filepath.Join(alias, "tools")); err != nil || !st.IsDir() { + t.Fatalf("invalid directory symlink: %v", err) + } + cmd := exec.Command(cli, "-test.run=^TestRootBusySelfAliasChild$", "-test.v") + cmd.Env = append(os.Environ(), "MODAVD_ROOT_BUSY_SELF="+alias, "MODAVD_ROOT_BUSY_ACTION="+action) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("self %s through link: %v\n%s", action, err, out) + } + }) + } +} + +func TestRootBusySelfAliasChild(t *testing.T) { + dir := os.Getenv("MODAVD_ROOT_BUSY_SELF") + if dir == "" { + t.Skip("self-update fixture only") + } + l := layout{root: dir} + const rel = "tools/modavd.exe" + plan := rootPlan{replace: []string{rel}} + switch action := os.Getenv("MODAVD_ROOT_BUSY_ACTION"); action { + case "check": + if busy := l.rootFileInUse(plan); busy != "" { + t.Fatalf("self treated as occupant: %s", busy) + } + case "apply", "restore": + source := l.pkgAt(rel) + if action == "restore" { + source = l.prevAt(rel) + } + if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(source, []byte(action), 0o600); err != nil { + t.Fatal(err) + } + var err error + if action == "apply" { + err = l.applyRootFiles(plan) + } else { + err = l.restoreRootFiles(plan) + } + if err != nil { + t.Fatalf("%s: %v", action, err) + } + if got, err := os.ReadFile(l.at(rel)); err != nil || string(got) != action { + t.Fatalf("wrong replacement: %q %v", got, err) + } + default: + t.Fatalf("unknown action: %s", action) + } +} + +func TestRootBusyDeniedACLIsNotUse(t *testing.T) { + path := filepath.Join(t.TempDir(), "denied.txt") + if err := os.WriteFile(path, []byte("unchanged"), 0o600); err != nil { + t.Fatal(err) + } + run := func(args ...string) { + t.Helper() + cmd := exec.Command("icacls.exe", append([]string{path}, args...)...) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("icacls: %v\n%s", err, out) + } + } + run("/deny", "*S-1-1-0:(WD,AD,DE)") + t.Cleanup(func() { run("/remove:d", "*S-1-1-0") }) + if f, err := os.OpenFile(path, os.O_WRONLY, 0); !errors.Is(err, syscall.ERROR_ACCESS_DENIED) { + if err == nil { + f.Close() + } + t.Fatalf("ACL fixture did not deny writing: %v", err) + } + if fileInUse(path) { + t.Error("ACL access denial counts as occupied") + } +} + +func TestRootBusyDeleteSharedReadOnlyFile(t *testing.T) { + const obsolete = "extras/obsolete.txt" + withSettings(t, SourceGitHub, "") + r := writeRoot(t, 36, map[string]string{obsolete: "obsolete"}) + f := newSources(t, 37, zipOf(t, 37, pkgFiles(37, nil), false)) + u := f.updater(r) + u.scan = func(bool) ([]processUse, error) { return nil, nil } + path := u.l.at(obsolete) + if err := os.Chmod(path, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(path, 0o666) }) + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + t.Fatal(err) + } + h, err := syscall.CreateFile(ptr, syscall.GENERIC_READ, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil, syscall.OPEN_EXISTING, 0, 0) + if err != nil { + t.Fatal(err) + } + defer syscall.CloseHandle(h) + if st, err := u.Install(context.Background()); err != nil || !st.Updated { + t.Fatalf("shared reader blocked obsolete file deletion: %+v %v", st, err) + } + if lexists(path) { + t.Error("obsolete file was not removed") + } +} + +func TestRootBusyExternalReadOnlyDLL(t *testing.T) { + for _, tc := range []struct { + name string + deep, short bool + }{{"deep=false", false, false}, {"deep=true", true, false}, {"short name", false, true}} { + t.Run(tc.name, func(t *testing.T) { + dir := testutil.TempDir(t) + dll := filepath.Join(dir, "external.dll") + if tc.deep { + dll = filepath.Join(dir, strings.Repeat("nested-", 14), strings.Repeat("nested-", 14), "external.dll") + } + if err := copyFile(filepath.Join(os.Getenv("SystemRoot"), "System32", "msimg32.dll"), dll); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dll, 0o444); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(dll, 0o666) }) + loadPath := dll + if tc.short { + ptr, err := syscall.UTF16PtrFromString(dll) + if err != nil { + t.Fatal(err) + } + var short [32768]uint16 + proc := syscall.NewLazyDLL("kernel32.dll").NewProc("GetShortPathNameW") + n, _, err := proc.Call(uintptr(unsafe.Pointer(ptr)), uintptr(unsafe.Pointer(&short[0])), uintptr(len(short))) + if n == 0 || n >= uintptr(len(short)) { + t.Fatalf("GetShortPathName: %v", err) + } + loadPath = syscall.UTF16ToString(short[:n]) + if strings.EqualFold(loadPath, dll) { + t.Skip("test volume does not create 8.3 names") + } + } + ready := filepath.Join(dir, "ready") + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, self, "-test.run=^TestRootBusyDLLChild$") + cmd.Env = append(os.Environ(), "MODAVD_ROOT_BUSY_DLL="+loadPath, "MODAVD_ROOT_BUSY_READY="+ready) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + var output bytes.Buffer + cmd.Stdout, cmd.Stderr = &output, &output + input, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + waited := false + t.Cleanup(func() { + input.Close() + if !waited { + cancel() + cmd.Wait() + } + }) + deadline := time.Now().Add(5 * time.Second) + for !lexists(ready) { + if time.Now().After(deadline) { + t.Fatal("DLL child never became ready") + } + time.Sleep(10 * time.Millisecond) + } + // Query through a valid directory link too, so mapped image and + // target filenames must resolve to the same actual file. + alias := filepath.Join(dir, "alias") + probe := dll + if err := os.Symlink(filepath.Dir(dll), alias); err == nil { + probe = filepath.Join(alias, filepath.Base(dll)) + } + if !fileInUse(probe) { + t.Error("another process's read-only DLL was missed") + } + input.Close() + err = cmd.Wait() + waited = true + if err != nil { + t.Fatalf("DLL child: %v\n%s", err, output.String()) + } + if fileInUse(probe) { + t.Error("exited DLL child still blocks the file") + } + }) + } +} + +func TestRootBusyDLLChild(t *testing.T) { + path := os.Getenv("MODAVD_ROOT_BUSY_DLL") + if path == "" { + t.Skip("DLL occupancy fixture only") + } + h, err := syscall.LoadLibrary(path) + if err != nil { + t.Fatal(err) + } + defer syscall.FreeLibrary(h) + if err := os.WriteFile(os.Getenv("MODAVD_ROOT_BUSY_READY"), nil, 0o600); err != nil { + t.Fatal(err) + } + var release [1]byte + os.Stdin.Read(release[:]) +} diff --git a/internal/update/state_other.go b/internal/update/state_other.go index 90f6af4..4f2d117 100644 --- a/internal/update/state_other.go +++ b/internal/update/state_other.go @@ -2,10 +2,15 @@ package update -import "os" +import ( + "os" + "path/filepath" +) func isReparsePoint(st os.FileInfo) bool { return st.Mode()&os.ModeSymlink != 0 } func removeLink(p string) bool { return os.Remove(p) == nil } // fileInUse: a loaded library or an open file never stops a rename or an unlink here. func fileInUse(string) bool { return false } + +func resolvePath(path string) (string, error) { return filepath.EvalSymlinks(path) } diff --git a/internal/update/state_windows.go b/internal/update/state_windows.go index 78ae678..330e693 100644 --- a/internal/update/state_windows.go +++ b/internal/update/state_windows.go @@ -5,25 +5,167 @@ package update import ( "errors" "os" + "strings" "syscall" + "unsafe" ) const ( fileAttributeReparsePoint = 0x400 errorSharingViolation = syscall.Errno(32) errorLockViolation = syscall.Errno(33) + deleteAccess = 0x00010000 ) -// fileInUse: another process holds path so that it can be neither replaced nor deleted, e.g. a DLL MAA has loaded -// (a mapped image refuses write access) or a file opened without sharing. Opened for writing without truncating and -// closed at once; any other error (read-only, missing) is not "in use": the swap reports those itself. +// Probe writes (loaded images) and deletes (handles without FILE_SHARE_DELETE). +// Other errors remain the swap's to report; no attributes or contents are changed. func fileInUse(path string) bool { - f, err := os.OpenFile(path, os.O_WRONLY, 0) - if err == nil { + f, writeErr := os.OpenFile(path, os.O_WRONLY, 0) + if writeErr == nil { f.Close() + } + if errors.Is(writeErr, errorSharingViolation) || errors.Is(writeErr, errorLockViolation) { + return true + } + ptr, err := syscall.UTF16PtrFromString(path) + if err == nil { + h, err := syscall.CreateFile(ptr, deleteAccess, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, + nil, syscall.OPEN_EXISTING, 0, 0) + if err == nil { + syscall.CloseHandle(h) + } else if errors.Is(err, errorSharingViolation) || errors.Is(err, errorLockViolation) { + return true + } + } + // Read-only masks a loaded image's write sharing violation, while DELETE + // alone still succeeds. Confirm an actual loaded image: an ordinary reader + // with delete sharing must not prevent removal of an obsolete read-only file. + if errors.Is(writeErr, syscall.ERROR_ACCESS_DENIED) { + st, err := os.Stat(path) + return err == nil && !st.IsDir() && st.Mode().Perm()&0o200 == 0 && loadedImageInUse(path) + } + return false +} + +var ( + occupancyKernel32 = syscall.NewLazyDLL("kernel32.dll") + enumProcessModulesEx = occupancyKernel32.NewProc("K32EnumProcessModulesEx") + getMappedFileNameW = occupancyKernel32.NewProc("K32GetMappedFileNameW") + getModuleFileNameExW = occupancyKernel32.NewProc("K32GetModuleFileNameExW") + getFinalPathNameW = occupancyKernel32.NewProc("GetFinalPathNameByHandleW") +) + +// loadedImageInUse is best effort, like the write/delete probes. A protected or +// exiting process that cannot be inspected leaves the existing swap/recovery path. +func loadedImageInUse(path string) bool { + target := finalImagePath(path) + if target == "" { + return false + } + snapshot, err := syscall.CreateToolhelp32Snapshot(syscall.TH32CS_SNAPPROCESS, 0) + if err != nil { + return false + } + defer syscall.CloseHandle(snapshot) + var entry syscall.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + for err := syscall.Process32First(snapshot, &entry); err == nil; err = syscall.Process32Next(snapshot, &entry) { + if processHasImage(entry.ProcessID, target) { + return true + } + } + return false +} + +// resolvePath returns an extended DOS path suitable for both comparisons and I/O. +// Handle queries resolve junctions and short names without EvalSymlinks. +func resolvePath(path string) (string, error) { + return finalPath(path, 0) +} + +func finalImagePath(path string) string { + const volumeNameNT = 2 + name, _ := finalPath(path, volumeNameNT) + return name +} + +func finalPath(path string, flags uint32) (string, error) { + ptr, err := syscall.UTF16PtrFromString(path) + if err != nil { + return "", err + } + h, err := syscall.CreateFile(ptr, 0, syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, + nil, syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return "", err + } + var name [32768]uint16 + n, _, err := getFinalPathNameW.Call(uintptr(h), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name)), uintptr(flags)) + syscall.CloseHandle(h) + if n == 0 { + return "", err + } + if n >= uintptr(len(name)) { + return "", syscall.ERROR_INSUFFICIENT_BUFFER + } + return syscall.UTF16ToString(name[:n]), nil +} + +func processHasImage(pid uint32, target string) bool { + const processQueryInformation = 0x0400 + const processVMRead = 0x0010 + process, err := syscall.OpenProcess(processQueryInformation|processVMRead, false, pid) + if err != nil { + return false + } + defer syscall.CloseHandle(process) + modules := make([]syscall.Handle, 128) + var needed uint32 + const listModulesAll = 3 + const handleSize = uint32(unsafe.Sizeof(syscall.Handle(0))) + // The module list can grow between calls. Retry with the reported size, + // then leave an unstable process to the swap's normal error handling. + for attempt := 0; attempt < 3; attempt++ { + size := uint32(len(modules)) * handleSize + ok, _, _ := enumProcessModulesEx.Call(uintptr(process), uintptr(unsafe.Pointer(&modules[0])), uintptr(size), + uintptr(unsafe.Pointer(&needed)), listModulesAll) + if ok == 0 { + return false + } + if needed > size { + modules = make([]syscall.Handle, (needed+handleSize-1)/handleSize) + continue + } + // Toolhelp's MODULEENTRY32 path is limited to MAX_PATH. The mapped name + // accepts full paths and resolves links to the actual image on disk. + var name [32768]uint16 + for _, module := range modules[:needed/handleSize] { + n, _, err := getMappedFileNameW.Call(uintptr(process), uintptr(module), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name))) + // GetMappedFileName can report MORE_DATA for deep paths even with + // this full-sized buffer. Read the loader's path and resolve it then. + if n == 0 && errors.Is(err, syscall.ERROR_MORE_DATA) { + n, _, _ = getModuleFileNameExW.Call(uintptr(process), uintptr(module), uintptr(unsafe.Pointer(&name[0])), uintptr(len(name))) + if n > 0 && n < uintptr(len(name)) && strings.EqualFold(finalImagePath(syscall.UTF16ToString(name[:n])), target) { + return true + } + continue + } + if n == 0 || n >= uintptr(len(name)) { + continue + } + image := syscall.UTF16ToString(name[:n]) + if strings.EqualFold(image, target) { + return true + } + // A mapped name can retain an 8.3 component such as RUNNER~1 even + // though the target's final name is expanded. Resolve both sides. + if strings.EqualFold(finalImagePath(`\\?\GLOBALROOT`+image), target) { + return true + } + } return false } - return errors.Is(err, errorSharingViolation) || errors.Is(err, errorLockViolation) + return false } func isReparsePoint(st os.FileInfo) bool { diff --git a/internal/update/swap.go b/internal/update/swap.go index 4c21c01..6b2db1f 100644 --- a/internal/update/swap.go +++ b/internal/update/swap.go @@ -224,7 +224,7 @@ func (l layout) applyRootFiles(p rootPlan) error { continue } dst := l.at(rel) - if self != "" && strings.EqualFold(dst, self) && isFile(dst) { + if self != "" && strings.EqualFold(realOrSame(dst), self) && isFile(dst) { if err := l.toTrash(dst); err != nil { return err } @@ -249,7 +249,7 @@ func (l layout) rootFileInUse(p rootPlan) string { self := selfExe() for _, rel := range append(append([]string{}, p.replace...), p.delete...) { dst := l.at(rel) - if inFold(p.created, rel) || (self != "" && strings.EqualFold(dst, self)) || !isFile(dst) { + if inFold(p.created, rel) || (self != "" && strings.EqualFold(realOrSame(dst), self)) || !isFile(dst) { continue } if fileInUse(dst) { @@ -272,7 +272,7 @@ func (l layout) restoreRootFiles(p rootPlan) error { continue } dst := l.at(rel) - if self != "" && strings.EqualFold(dst, self) && isFile(dst) { + if self != "" && strings.EqualFold(realOrSame(dst), self) && isFile(dst) { if err := l.toTrash(dst); err != nil { return err } @@ -289,10 +289,7 @@ func selfExe() string { if err != nil { return "" } - if real, err := filepath.EvalSymlinks(exe); err == nil { - exe = real - } - return exe + return realOrSame(exe) } // ---- swap @@ -325,9 +322,6 @@ func (u *Updater) swapIn() (string, error) { if err != nil { return "", err } - if err := l.backupRootFiles(plan); err != nil { - return "", err - } // A root file another program holds (MAA loads extras\modavd_extras.dll and // mumu-shim\shell\sdk\external_renderer_ipc.dll) cannot be replaced: found out after the renames, it would leave // the root half swapped until that program exits. Checked while nothing has been changed yet. @@ -335,6 +329,9 @@ func (u *Updater) swapIn() (string, error) { l.backToStaged(st) return "", swapBlocked("%s 正被别的程序占用(多半是 MAA 之类载入了它),什么都没动。请先关掉它再装", filepath.FromSlash(rel)) } + if err := l.backupRootFiles(plan); err != nil { + return "", err + } st.Set("phase", jsonord.NewString(phaseSwapping)) st.Set("plan", plan.value()) st.Set("swap", stepVal(stepBegin)) @@ -712,7 +709,7 @@ func l2abs(p string) string { } func realOrSame(p string) string { - if r, err := filepath.EvalSymlinks(p); err == nil { + if r, err := resolvePath(p); err == nil { return r } return l2abs(p) diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 216945f..9c6fb98 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -443,8 +443,8 @@ func TestMirrorChyan(t *testing.T) { t.Fatalf("check query %v", seen[0]) } st, err := u.Install(context.Background()) - // every file changed between the two builds: GitHub's range path falls back to the whole package (second notice) - if err != nil || !st.Updated || len(st.Notices) != 2 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") || !strings.Contains(st.Notices[1], "变了的文件太多") { + // The normal GitHub range threshold adds no notice; the bad CDK still does. + if err != nil || !st.Updated || len(st.Notices) != 1 || !strings.Contains(st.Notices[0], "填入的 CDK 错误") { t.Fatalf("install with a bad CDK: %+v %v", st, err) } if last := seen[len(seen)-1]; last["cdk"] != "cdk-secret-1" || last["current_version"] != "v0.36.0" { diff --git a/internal/update/updater.go b/internal/update/updater.go index f5ab045..f1f9849 100644 --- a/internal/update/updater.go +++ b/internal/update/updater.go @@ -516,7 +516,9 @@ func (u *Updater) stage(ctx context.Context, r *release, st *Status) (err error) case errors.As(err, &ru): note := rangeNotice(err) u.Log.Infof("%s", note) - st.Notices = append(st.Notices, note) + if !ru.normal { + st.Notices = append(st.Notices, note) + } default: return err } diff --git a/internal/win/edge_windows.go b/internal/win/edge_windows.go new file mode 100644 index 0000000..ce571e7 --- /dev/null +++ b/internal/win/edge_windows.go @@ -0,0 +1,148 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package win + +import ( + "fmt" + "path/filepath" + "strings" + "sync" + "syscall" + "unsafe" +) + +// EdgeWindow is a browser/app window of one identified Edge process. Helpers such as its message and GPU windows +// are excluded. The process identity is checked again before touching the HWND, including its creation time. +type EdgeWindow struct { + hwnd uintptr + process Process +} + +func (w EdgeWindow) HWND() uintptr { return w.hwnd } + +func (w EdgeWindow) valid() bool { + var pid uint32 + procGetWindowThreadProcessId.Call(w.hwnd, uintptr(unsafe.Pointer(&pid))) + return pid == w.process.PID && w.process.Alive() +} + +func (w EdgeWindow) Visible() bool { + if !w.valid() { + return false + } + r, _, _ := procIsWindowVisible.Call(w.hwnd) + return r != 0 +} + +// Minimized distinguishes an iconic but still visible window from a restored one. +func (w EdgeWindow) Minimized() bool { + if !w.valid() { + return false + } + r, _, _ := procIsIconic.Call(w.hwnd) + return r != 0 +} + +// SetVisible posts the change to Edge's thread rather than blocking on a hung browser. Showing a minimized window +// restores it, while a maximized window keeps its size. +func (w EdgeWindow) SetVisible(visible bool) error { + if !w.valid() { + return nil + } + cmd := uintptr(swHide) + if visible { + cmd = swShow + if w.Minimized() { + cmd = 9 + } // SW_RESTORE + } + if r, err := call(procShowWindowAsync, w.hwnd, cmd); r == 0 { + return fmt.Errorf("切换 Edge 窗口失败: %w", err) + } + return nil +} + +// Foreground reports whether Windows accepted the activation request. Activation of Edge's input queue may +// complete asynchronously. A background caller without foreground permission gets false; showing still works. +func (w EdgeWindow) Foreground() bool { + if !w.valid() { + return false + } + r, _, _ := procSetForegroundWindow.Call(w.hwnd) + return r != 0 +} + +var ( + procIsIconic = moduser32.NewProc("IsIconic") + edgeWindowMu sync.Mutex + edgeWindowTargets map[uint32]Process + edgeWindowsFound []EdgeWindow + edgeWindowEnumCb = sync.OnceValue(func() uintptr { return syscall.NewCallback(enumEdgeWindows) }) +) + +func enumEdgeWindows(hwnd, _ uintptr) uintptr { + var pid uint32 + procGetWindowThreadProcessId.Call(hwnd, uintptr(unsafe.Pointer(&pid))) + p, ok := edgeWindowTargets[pid] + if !ok { + return 1 + } + // Chrome_WidgetWin_0 and titleless windows are browser helpers. Include owned top-level dialogs too: they + // belong to this dedicated profile and must not be left behind when the manager is hidden. + if windowText(procGetClassNameW, hwnd) == "Chrome_WidgetWin_1" && windowText(procGetWindowTextW, hwnd) != "" { + edgeWindowsFound = append(edgeWindowsFound, EdgeWindow{hwnd: hwnd, process: p}) + } + return 1 +} + +// edgeProfileMatches compares parsed switches rather than command-line substrings, so a sibling profile, URL or +// renderer argument cannot make an unrelated Edge window belong to the manager. +func edgeProfileMatches(args []string, profile string) bool { + value := "" + for i := 1; i < len(args); i++ { + if strings.HasPrefix(args[i], "--type=") { + return false + } + if v, ok := strings.CutPrefix(args[i], "--user-data-dir="); ok { + value = v + } else if args[i] == "--user-data-dir" && i+1 < len(args) { + i++ + value = args[i] + } + } + return value != "" && filepath.IsAbs(value) && strings.EqualFold(filepath.Clean(value), filepath.Clean(profile)) +} + +// EdgeProfileWindows finds all manager windows, including hidden ones and ones handed to an existing Edge. It +// never uses the short-lived process returned by LaunchWindow. Unreadable or exited processes are left alone. +func EdgeProfileWindows(profile string) ([]EdgeWindow, error) { + procs, err := ListProcesses() + if err != nil { + return nil, err + } + targets := map[uint32]Process{} + for _, p := range procs { + if !strings.EqualFold(p.Name, "msedge.exe") || p.Created == 0 { + continue + } + args, err := p.CommandLine() + if err == nil && edgeProfileMatches(args, profile) { + targets[p.PID] = p + } + } + return edgeProcessWindows(targets) +} + +func edgeProcessWindows(targets map[uint32]Process) ([]EdgeWindow, error) { + edgeWindowMu.Lock() + defer edgeWindowMu.Unlock() + edgeWindowTargets, edgeWindowsFound = targets, nil + defer func() { edgeWindowTargets, edgeWindowsFound = nil, nil }() + if r, err := call(procEnumWindows, edgeWindowEnumCb(), 0); r == 0 { + return nil, err + } + return edgeWindowsFound, nil +} diff --git a/internal/win/edge_windows_test.go b/internal/win/edge_windows_test.go new file mode 100644 index 0000000..fe19372 --- /dev/null +++ b/internal/win/edge_windows_test.go @@ -0,0 +1,115 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package win + +import ( + "fmt" + "os" + "slices" + "strings" + "testing" + "time" +) + +func TestEdgeProfileMatches(t *testing.T) { + profile := `C:\profiles\manager ui-edge2` + for _, tc := range []struct { + name string + args []string + want bool + }{ + {"equals", []string{"msedge.exe", "--user-data-dir=" + profile}, true}, + {"separate", []string{"msedge.exe", "--user-data-dir", profile}, true}, + {"case and clean", []string{"msedge.exe", `--user-data-dir=c:\PROFILES\other\..\manager ui-edge2\`}, true}, + {"sibling", []string{"msedge.exe", "--user-data-dir=" + profile + "-other"}, false}, + {"URL substring", []string{"msedge.exe", "--app=http://localhost/?--user-data-dir=" + profile}, false}, + {"renderer", []string{"msedge.exe", "--user-data-dir=" + profile, "--type=renderer"}, false}, + {"last switch", []string{"msedge.exe", "--user-data-dir=" + profile, `--user-data-dir=C:\other`}, false}, + {"relative", []string{"msedge.exe", "--user-data-dir=manager ui-edge2"}, false}, + {"missing", []string{"msedge.exe", "--user-data-dir"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := edgeProfileMatches(tc.args, profile); got != tc.want { + t.Fatalf("match = %v, want %v", got, tc.want) + } + }) + } +} + +func TestEdgeProcessWindows(t *testing.T) { + windows := testWindows(t, []testWindow{ + {class: "Chrome_WidgetWin_1", title: "manager one"}, + {class: "Chrome_WidgetWin_1", title: "manager two"}, + {class: "Chrome_WidgetWin_0", title: "helper"}, + {class: "Chrome_WidgetWin_1", title: ""}, + {class: "Chrome_WidgetWin_1", title: "dialog", owned: true}, + {class: fmt.Sprint("OtherWindow", os.Getpid()), title: "other app"}, + }) + p, ok := FindProcess(uint32(os.Getpid())) + if !ok { + t.Fatal("self not found") + } + found, err := edgeProcessWindows(map[uint32]Process{p.PID: p}) + if err != nil { + t.Fatal(err) + } + var got []uintptr + for _, w := range found { + got = append(got, w.HWND()) + } + slices.Sort(got) + want := []uintptr{windows[0], windows[1], windows[4]} + slices.Sort(want) + if !slices.Equal(got, want) { + t.Fatalf("windows %v, want %v", got, want) + } + for _, visible := range []bool{false, true} { + for _, w := range found { + if err := w.SetVisible(visible); err != nil { + t.Fatal(err) + } + } + if !waitFor(3*time.Second, func() bool { + for _, w := range found { + if w.Visible() != visible { + return false + } + } + return true + }) { + t.Fatalf("visibility did not become %v", visible) + } + } + // An HWND is not enough: a mismatched process creation time must prevent a recycled PID from being touched. + stale := found[0] + stale.process.Created++ + if err := stale.SetVisible(false); err != nil { + t.Fatal(err) + } + // SetVisible is asynchronous: keep observing long enough to catch an incorrectly posted hide. + until := time.Now().Add(150 * time.Millisecond) + for time.Now().Before(until) { + if !found[0].Visible() { + t.Fatal("stale identity hid window") + } + time.Sleep(5 * time.Millisecond) + } + for _, h := range []uintptr{windows[2], windows[3], windows[5]} { + r, _, _ := procIsWindowVisible.Call(h) + if r == 0 { + t.Fatalf("unrelated window %d hidden", h) + } + } + found, err = edgeProcessWindows(map[uint32]Process{}) + if err != nil || len(found) != 0 { + t.Fatalf("empty targets: %v %v", found, err) + } + // A nonexistent profile must not claim the user's ordinary Edge windows. + found, err = EdgeProfileWindows(`C:\modavd-test-nonexistent-profile-` + strings.Repeat("x", 40)) + if err != nil || len(found) != 0 { + t.Fatalf("unrelated Edge matched: %v %v", found, err) + } +} diff --git a/internal/win/mem.go b/internal/win/mem.go index 2ae5992..3794269 100644 --- a/internal/win/mem.go +++ b/internal/win/mem.go @@ -28,7 +28,8 @@ type processMemoryCountersEx struct { } // Memory is GetProcessMemoryInfo of p, in bytes: commit is its private commit charge (Task Manager's "Commit size"), -// peakCommit the highest that has been, workingSet what it has in physical memory now. +// peakCommit is the OS PeakPagefileUsage at this read, workingSet what it has in physical memory now. +// Counters after process exit are not guaranteed, even if a handle keeps the process object alive. func (p Process) Memory() (commit, peakCommit, workingSet uint64, err error) { h, err := p.open(0) if err != nil { @@ -40,6 +41,13 @@ func (p Process) Memory() (commit, peakCommit, workingSet uint64, err error) { if r, _, e := procK32GetProcessMemoryInfo.Call(uintptr(h), uintptr(unsafe.Pointer(&c)), uintptr(c.cb)); r == 0 { return 0, 0, 0, e } + // The API can succeed after exit with torn-down counters while a handle retains the process object. Check + // after the read as well, so exit during the call cannot masquerade as a successful live sample. + if _, exited, _, _, e := handleTimes(h); e != nil { + return 0, 0, 0, e + } else if exited != 0 { + return 0, 0, 0, errGone + } return uint64(c.privateUsage), uint64(c.peakPagefileUsage), uint64(c.workingSetSize), nil } diff --git a/internal/win/tray.go b/internal/win/tray.go index 13f3b28..d8c2be7 100644 --- a/internal/win/tray.go +++ b/internal/win/tray.go @@ -28,18 +28,26 @@ type Tray struct { ownIcon bool hotkeys map[int]bool + // Only the window thread changes click state. KillTimer leaves queued WM_TIMER messages, so ids are not reused. + clickTimer, nextTimer uintptr + skipButtonUp bool + calls chan func() done chan struct{} closeOnce sync.Once + eventMu sync.Mutex + events []func() + eventWake chan struct{} } -// TrayEvents are called on their own goroutine, never on the window's thread, so they may take their time and call -// back into the Tray. +// TrayEvents run in delivery order on a worker goroutine, never on the window thread. They may call back into +// Tray, including Close. Close drops queued events and does not wait for a callback already running. type TrayEvents struct { - HotKey func(id int) // a registered hot key was pressed - Click func() // the icon was clicked (left button) - Open func() // menu: 打开管理器 - Exit func() // menu: 退出 + HotKey func(id int) // a registered hot key was pressed + Click func() // a single left click, after the system double-click interval + DoubleClick func() // a double left click; neither button-up also fires Click + Open func() // menu: 打开管理器 + Exit func() // menu: 退出 } // TrayOptions of StartTray. @@ -60,14 +68,17 @@ const ( // tryHotKeyID is the id of TryHotKey's register-and-release; ids of applications are 0x0000–0xBFFF. tryHotKeyID = 0xBFFF - wmNull = 0x0000 - wmDestroy = 0x0002 - wmHotKey = 0x0312 - wmLButtonUp = 0x0202 - wmRButtonUp = 0x0205 - wmApp = 0x8000 - wmTrayNotify = wmApp + 1 // the icon's callback message - wmTrayCall = wmApp + 2 // run the queued calls + wmNull = 0x0000 + wmDestroy = 0x0002 + wmHotKey = 0x0312 + wmTimer = 0x0113 + wmLButtonDblClk = 0x0203 + csDblClks = 0x0008 + wmLButtonUp = 0x0202 + wmRButtonUp = 0x0205 + wmApp = 0x8000 + wmTrayNotify = wmApp + 1 // the icon's callback message + wmTrayCall = wmApp + 2 // run the queued calls nimAdd, nimDelete = 0, 2 nifMessage, nifIcon, nifTip = 0x1, 0x2, 0x4 @@ -142,7 +153,7 @@ func StartTray(opt TrayOptions) (*Tray, error) { return nil, errors.New("托盘窗口已经开着") } t := &Tray{ev: opt.Events, noIcon: opt.NoIcon, hotkeys: map[int]bool{}, calls: make(chan func(), 64), - done: make(chan struct{})} + done: make(chan struct{}), eventWake: make(chan struct{}, 1)} tip, _ := syscall.UTF16FromString(opt.Tip) copy(t.tip[:len(t.tip)-1], tip) ready := make(chan error, 1) @@ -150,6 +161,7 @@ func StartTray(opt TrayOptions) (*Tray, error) { if err := <-ready; err != nil { return nil, err } + go t.eventLoop() return t, nil } @@ -159,7 +171,7 @@ func (t *Tray) loop(ready chan<- error) { runtime.LockOSThread() inst, _, _ := procGetModuleHandleW.Call(0) cls, _ := syscall.UTF16PtrFromString(trayClassName) - wc := wndClassEx{WndProc: trayProcCb(), Instance: inst, ClassName: cls} + wc := wndClassEx{Style: csDblClks, WndProc: trayProcCb(), Instance: inst, ClassName: cls} wc.Size = uint32(unsafe.Sizeof(wc)) if r, err := call(procRegisterClassExW, uintptr(unsafe.Pointer(&wc))); r == 0 && !errors.Is(err, errorClassAlreadyExists) { ready <- errors.New("注册托盘窗口类失败: " + err.Error()) @@ -234,16 +246,26 @@ func trayProc(hwnd, msg, wparam, lparam uintptr) uintptr { switch m := uint32(msg); { case m == wmHotKey: if f := t.ev.HotKey; f != nil { - go f(int(wparam)) + t.dispatch(func() { f(int(wparam)) }) } return 0 case m == wmTrayNotify: switch uint32(lparam) & 0xFFFF { case wmLButtonUp: - if f := t.ev.Click; f != nil { - go f() + if t.skipButtonUp { + t.skipButtonUp = false + } else { + t.deferClick() + } + case wmLButtonDblClk: + t.cancelClick() + t.skipButtonUp = true // the final button-up belongs to this double click + if f := t.ev.DoubleClick; f != nil { + t.dispatch(f) } case wmRButtonUp: + t.cancelClick() + t.skipButtonUp = false var f func() switch t.menu() { case menuOpen: @@ -252,7 +274,15 @@ func trayProc(hwnd, msg, wparam, lparam uintptr) uintptr { f = t.ev.Exit } if f != nil { - go f() + t.dispatch(f) + } + } + return 0 + case m == wmTimer: + if t.clickTimer != 0 && wparam == t.clickTimer { + t.cancelClick() + if f := t.ev.Click; f != nil { + t.dispatch(f) } } return 0 @@ -265,6 +295,7 @@ func trayProc(hwnd, msg, wparam, lparam uintptr) uintptr { } return 0 case m == wmDestroy: + t.cancelClick() procPostQuitMessage.Call(0) return 0 } @@ -272,6 +303,66 @@ func trayProc(hwnd, msg, wparam, lparam uintptr) uintptr { return r } +// dispatch never waits for a handler: it may itself be waiting for a call on the window thread. +func (t *Tray) dispatch(f func()) { + t.eventMu.Lock() + t.events = append(t.events, f) + t.eventMu.Unlock() + select { + case t.eventWake <- struct{}{}: + default: + } +} + +func (t *Tray) eventLoop() { + defer func() { t.eventMu.Lock(); t.events = nil; t.eventMu.Unlock() }() + for { + select { + case <-t.done: + return + default: + } + t.eventMu.Lock() + var f func() + if len(t.events) > 0 { + f = t.events[0] + t.events[0] = nil + t.events = t.events[1:] + } + t.eventMu.Unlock() + if f != nil { + f() + continue + } + select { + case <-t.done: + return + case <-t.eventWake: + } + } +} + +// deferClick keeps cancellation and delivery on the window thread. If SetTimer fails, drop this click rather than +// accidentally turn a double click into an instance restore. +func (t *Tray) deferClick() { + t.cancelClick() + if t.ev.Click == nil { + return + } + t.nextTimer++ + ms, _, _ := procGetDoubleClickTime.Call() + if r, _, _ := procSetTimer.Call(t.hwnd, t.nextTimer, ms+1, 0); r != 0 { + t.clickTimer = t.nextTimer + } +} + +func (t *Tray) cancelClick() { + if t.clickTimer != 0 { + procKillTimer.Call(t.hwnd, t.clickTimer) + t.clickTimer = 0 + } +} + // menu shows the right-click menu at the cursor and returns the chosen item (0: none). The window comes to the // foreground first, or the menu would not close when the user clicks elsewhere (TrackPopupMenu remarks). func (t *Tray) menu() int { @@ -376,6 +467,7 @@ func (t *Tray) TryHotKey(modifiers, vk uint32) error { func (t *Tray) Close() { t.closeOnce.Do(func() { t.call(func() { + t.cancelClick() for id := range t.hotkeys { procUnregisterHotKey.Call(t.hwnd, uintptr(id)) } diff --git a/internal/win/tray_click_windows_test.go b/internal/win/tray_click_windows_test.go new file mode 100644 index 0000000..313386d --- /dev/null +++ b/internal/win/tray_click_windows_test.go @@ -0,0 +1,184 @@ +//go:build windows + +// Copyright 2026 jinghero +// SPDX-License-Identifier: AGPL-3.0-or-later + +package win + +import ( + "testing" + "time" +) + +func trayClickWait() time.Duration { + ms, _, _ := moduser32.NewProc("GetDoubleClickTime").Call() + return time.Duration(ms) * time.Millisecond +} + +func TestTraySingleClickWaitsForDoubleClick(t *testing.T) { + clicks := make(chan struct{}, 4) + tray, err := StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{Click: func() { clicks <- struct{}{} }}}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + started := time.Now() + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonUp) + select { + case <-clicks: + if elapsed := time.Since(started); elapsed < trayClickWait() { + t.Fatalf("single click ran after %v, before double-click interval", elapsed) + } + case <-time.After(trayClickWait() + 2*time.Second): + t.Fatal("single click never ran") + } + select { + case <-clicks: + t.Fatal("single click repeated") + case <-time.After(trayClickWait() + 100*time.Millisecond): + } +} + +func TestTrayDoubleClickNeverLeaksSingleClick(t *testing.T) { + clicks := make(chan struct{}, 4) + tray, err := StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{Click: func() { clicks <- struct{}{} }}}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + for _, msg := range []uintptr{wmLButtonUp, 0x0203, wmLButtonUp} { + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, msg) + } + select { + case <-clicks: + t.Fatal("double click leaked a single click") + case <-time.After(trayClickWait() + 200*time.Millisecond): + } +} + +func TestTrayCloseCancelsPendingClick(t *testing.T) { + clicks := make(chan struct{}, 4) + tray, err := StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{Click: func() { clicks <- struct{}{} }}}) + if err != nil { + t.Fatal(err) + } + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonUp) + if !tray.call(func() {}) { + t.Fatal("tray closed early") + } + tray.Close() + select { + case <-clicks: + t.Fatal("pending click ran during or after close") + case <-time.After(trayClickWait() + 200*time.Millisecond): + } +} + +// A canceled timer may still have a queued WM_TIMER. Its id must never deliver a newer pending click. +func TestTrayStaleTimerAndNextSingleClick(t *testing.T) { + clicks, doubles := make(chan struct{}, 4), make(chan struct{}, 4) + tray, err := StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{Click: func() { clicks <- struct{}{} }, DoubleClick: func() { doubles <- struct{}{} }}}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + var old, current uintptr + tray.call(func() { + tray.deferClick() + old = tray.clickTimer + tray.cancelClick() + tray.deferClick() + current = tray.clickTimer + }) + if old == 0 || current == 0 || old == current { + t.Fatalf("timer ids %d %d", old, current) + } + procPostMessageW.Call(tray.HWND(), wmTimer, old, 0) + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonDblClk) + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonUp) + select { + case <-doubles: + case <-time.After(2 * time.Second): + t.Fatal("no double click") + } + select { + case <-clicks: + t.Fatal("stale timer or final button-up leaked click") + case <-time.After(trayClickWait() + 100*time.Millisecond): + } + procPostMessageW.Call(tray.HWND(), wmTrayNotify, trayIconID, wmLButtonUp) + select { + case <-clicks: + case <-time.After(trayClickWait() + 2*time.Second): + t.Fatal("next single click was swallowed") + } +} + +func TestTrayCallbacksAreOrderedAndCanClose(t *testing.T) { + entered, release, closed := make(chan struct{}), make(chan struct{}), make(chan struct{}) + order := make(chan int, 4) + var tray *Tray + var err error + tray, err = StartTray(TrayOptions{NoIcon: true, Events: TrayEvents{HotKey: func(id int) { + if id == 1 { + close(entered) + <-release + } + order <- id + if id == 2 { + tray.Close() + close(closed) + } + }}}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + procPostMessageW.Call(tray.HWND(), wmHotKey, 1, 0) + <-entered + procPostMessageW.Call(tray.HWND(), wmHotKey, 2, 0) + procPostMessageW.Call(tray.HWND(), wmHotKey, 3, 0) + if !tray.call(func() {}) { + t.Fatal("callback blocked window thread") + } + select { + case id := <-order: + t.Fatalf("event %d overtook first callback", id) + case <-time.After(100 * time.Millisecond): + } + close(release) + select { + case <-closed: + case <-time.After(3 * time.Second): + t.Fatal("Close from callback deadlocked") + } + for _, want := range []int{1, 2} { + select { + case got := <-order: + if got != want { + t.Fatalf("got %d want %d", got, want) + } + case <-time.After(time.Second): + t.Fatal("missing callback") + } + } + select { + case id := <-order: + t.Fatalf("callback %d survived close", id) + case <-time.After(50 * time.Millisecond): + } +} + +func TestTrayWindowClassAllowsDoubleClicks(t *testing.T) { + tray, err := StartTray(TrayOptions{NoIcon: true}) + if err != nil { + t.Fatal(err) + } + defer tray.Close() + // GCL_STYLE is -26, sign-extended for GetClassLongPtrW. + index := -26 + style, _, _ := moduser32.NewProc("GetClassLongPtrW").Call(tray.HWND(), uintptr(index)) + if style&csDblClks == 0 { + t.Fatalf("window class style %#x lacks CS_DBLCLKS", style) + } +} diff --git a/internal/win/win.go b/internal/win/win.go index c69eecc..9f017c1 100644 --- a/internal/win/win.go +++ b/internal/win/win.go @@ -113,6 +113,9 @@ var ( procLoadImageW = moduser32.NewProc("LoadImageW") procLoadIconW = moduser32.NewProc("LoadIconW") procDestroyIcon = moduser32.NewProc("DestroyIcon") + procGetDoubleClickTime = moduser32.NewProc("GetDoubleClickTime") + procSetTimer = moduser32.NewProc("SetTimer") + procKillTimer = moduser32.NewProc("KillTimer") procGetSystemMetrics = moduser32.NewProc("GetSystemMetrics") procCreatePopupMenu = moduser32.NewProc("CreatePopupMenu") procAppendMenuW = moduser32.NewProc("AppendMenuW") diff --git a/release/0.10.0.SHA256SUMS.txt b/release/0.10.0.SHA256SUMS.txt new file mode 100644 index 0000000..230a7b7 --- /dev/null +++ b/release/0.10.0.SHA256SUMS.txt @@ -0,0 +1,3 @@ +c2fd1a1437be4267c39e8e990a272b2b6140461877b89db3f8cf0c361d576762 ModAVD.exe +71a95dda149057090fd2ecfa406829a1ec77bbc56b3cf1caa298f067a29b27b1 ModAVDManager-0.10.0-win-x64.zip +c7fd4fe56022d17b0ef10d5cab80181df643bd3c5fd6f69dce8bcc52c84b599b tools/modavd.exe diff --git a/release/0.10.0.md b/release/0.10.0.md new file mode 100644 index 0000000..66528ec --- /dev/null +++ b/release/0.10.0.md @@ -0,0 +1,80 @@ +# ModAVDManager v0.10.0 + +- `ModAVD.exe`:`c2fd1a1437be4267c39e8e990a272b2b6140461877b89db3f8cf0c361d576762` +- `tools/modavd.exe`:`c7fd4fe56022d17b0ef10d5cab80181df643bd3c5fd6f69dce8bcc52c84b599b` + +随 ModAVD 0.41.0 进包。下面是相对 v0.9.0 的改动。这一版主要是:开着的实例也登记到默认的 adb server(5037),`adb devices` 和 MAA 这类工具能直接看到;整包更新更稳(断网时能用已经下好的新版、目录链接进入的根目录能自更新、被只读 DLL 占着时换包前就停);关窗口时卡片不再误显示「开机中」;托盘双击能隐藏和恢复窗口。 + +## 变更 + +- 实例开着时,开机守护每 30 秒看一下默认的 adb server(5037)。有人开着它、它又不认识这台实例,就替实例登记过去,那边显示成 `emulator-<控制台端口>`。这样用 `adb devices` 找设备的工具也能看到 ModAVD 的实例,不用再手动 `adb connect`。5037 上没有 server 时什么都不做,不替别人起 server;不想登记就在 `mas-avd.json` 里写 `"shareDefaultAdbServer": false`。modavd 自己的 adb 命令照旧只走根目录的私有 server。(ModAVDManager#155,ModAVD/ModAVD#196) +- 双击托盘图标隐藏或恢复管理器窗口,页面和窗口状态保留;没有窗口时打开一个。单击照旧恢复实例窗口,只是要等系统的双击间隔过去才动,双击不再顺带恢复实例窗口。右键「打开管理器」优先恢复已有窗口。(ModAVDManager#148,ModAVD/ModAVD#188) +- 实例卡片标题下面不再显示运行时长,详情的「状态」一行里照旧有。新建实例去掉「编号」选项,一律用第一个空闲编号;名称的占位文字去掉「比如:」。(ModAVDManager#145) +- 带窗口开机的实例要先装执行桥时,装桥那几次准备用的开机一律无头,装好后正常关机、最后一次才带窗口开,窗口不再弹出、关掉、再弹出好几次。(ModAVDManager#146) +- 开机守护改成每分钟采样一次模拟器内存:提交量比上次记的变化超过 256 MiB 时提前记一行,平稳时照旧每 10 分钟一行;模拟器退出时写一行汇总(最大提交、工作集、采样次数)。(ModAVDManager#153,ModAVD/ModAVD#159) + +## 修复 + +- 关掉模拟器窗口,或者在安卓里关机以后,卡片不再先显示好几秒「开机中」:客体已经够不到、又没有 modavd 在给它开机时,报「关机中」。客体自己重启时,adb 断开的那几秒也报「关机中」。(ModAVDManager#144) +- 整包更新: + - 根目录里只读的 DLL 被别的程序载入着,或者文件被不许删除的方式打开着时,以前的占用检查查不出来,换到一半才失败。现在换包前就报忙,什么都不动,暂存的新版留着,关掉那个程序再装就行。(ModAVDManager#149,ModAVD/ModAVD#191) + - 根目录经目录链接(junction、符号链接、8.3 短名)进入时,更新程序认得出自己,能完成自占用检查、替换和中断后的恢复,不再一直报忙。(ModAVDManager#152,ModAVD/ModAVD#195) + - 断网时,已经下好、核对过、又比装着的版本新的暂存,现在能接着装。管理器点「更新」后的结果提示,每次只在一个窗口里显示一次。(ModAVDManager#154,ModAVD/ModAVD#170) + - 按区间更新时清单一律从远端取;改动太多时照旧改下整包,原因只写日志。超过 8 MiB 的文件改成边下边写临时文件,不再整块放进内存。读本地文件出错时直接报错,不再误当成包坏了去重下整包。(ModAVDManager#147,ModAVD/ModAVD#182) + +## 说明 + +- 从 v0.9.0 在管理器里点「更新」升到这一版时,换包的还是旧版命令行,它不会把结果交给重新打开的新管理器,所以这一次不会弹「已更新到 …」的提示,实际已经装上了。之后的更新照常提示。 + +## 给调用方(AUTO-MAS) + +- `protocol` 还是 2:没有删命令、参数和 `--json` 字段,没有新的错误码和退出码。调用方不用改。 +- `status` / `list --json`:实例自己在关(关了窗口、安卓里关机)时,`status` 照旧是 `starting`,`busy` 是 `stopping`。AUTO-MAS 本来就把 `busy == "stopping"` 当「关闭中」。(ModAVDManager#144) +- `update install` 多一个管理器用的 `--handoff-id`,只和 `--reopen` 一起用;结果改存 `.update\manager-results\.json`。不用 `--reopen` 的调用不受影响。(ModAVDManager#154) +- 管理器自己的接口多了 `POST /api/update/handoff/take`、`POST /api/update/handoff/receive`,`handoff` 多一个 `id` 字段。(ModAVDManager#154) +- `mas-avd.json` 多一个可选键 `shareDefaultAdbServer`(默认开)。(ModAVDManager#155) + +## 下载与核对 + +用 Go 1.26.5、`GOTOOLCHAIN=local` 运行仓库里的 `build.ps1`,能编出相同的字节。单独下载的包里还有 Go 的许可原文和文件清单。请用附件 `SHA256SUMS.txt` 核对 zip 和解压出的两个 exe,例如在 PowerShell 里运行 `Get-FileHash .\ModAVDManager-0.10.0-win-x64.zip -Algorithm SHA256`。 + +单独下载的管理器不会自己更新,新版本要手动替换;ModAVD 包里的那份随 ModAVD 整包一起更新。单独使用时请给 `--root` 或设置 `MODAVD_ROOT`,否则会提示找不到 ModAVD 根目录。不要把单独下载的 zip 解压到 ModAVD 根目录,覆盖整包里的文件。 + +## English + +Shipped in ModAVD 0.41.0. Changes since v0.9.0. This release mostly brings: running instances are also registered with the default adb server (5037), so `adb devices` and tools like MAA see them; sturdier whole-package updates (an already downloaded package installs offline, roots entered through a directory link update themselves, a read-only DLL in use stops the update before the swap); the card no longer shows "starting" while a window is being closed; and double-clicking the tray icon hides and restores the window. + +### Changes + +- While an instance runs, its boot guardian looks at the default adb server (5037) every 30 seconds. When something runs that server and it does not know the instance, the guardian registers the instance there, where it shows as `emulator-`, so tools that find devices with `adb devices` see ModAVD's instances without a manual `adb connect`. Nothing happens while no server runs on 5037: it never starts one. To turn it off, write `"shareDefaultAdbServer": false` in `mas-avd.json`. modavd's own adb commands still use only the root's private server. (ModAVDManager#155, ModAVD/ModAVD#196) +- Double-clicking the tray icon hides or restores the manager window, keeping the page and the window state; without a window it opens one. A single click still restores the instance windows, but only once the system's double-click time has passed, and a double click no longer restores them too. "打开管理器" (open manager) in the tray menu restores an existing window first. (ModAVDManager#148, ModAVD/ModAVD#188) +- The instance card no longer shows the uptime under its title; the "状态" (status) row of the details still has it. Creating an instance no longer offers an index choice and always takes the first free index; the name placeholder drops "比如:" (e.g.). (ModAVDManager#145) +- When an instance started with a window first needs the arm64 bridge installed, the preparation starts for installing it are all headless; once it is installed the instance shuts down normally and only the last start opens the window, so the window no longer pops up and closes several times. (ModAVDManager#146) +- The boot guardian samples the emulator's memory every minute: a line is written early when the commit changed by more than 256 MiB since the last line, and every 10 minutes as before while it stays steady; when the emulator exits it writes a summary (largest commit, working set, sample counts). (ModAVDManager#153, ModAVD/ModAVD#159) + +### Fixes + +- After closing the emulator window or shutting down from inside Android, the card no longer shows "开机中" (starting) for several seconds: when the guest cannot be reached and no modavd is starting it, it reports "关机中" (stopping). A guest restarting by itself also shows "stopping" for the seconds adb is gone. (ModAVDManager#144) +- Whole-package updates: + - A read-only DLL in the root loaded by another program, or a file opened without delete sharing, was missed by the busy check and failed halfway through the swap. Now the update reports busy before the swap, changes nothing and keeps the staged package; close that program and install again. (ModAVDManager#149, ModAVD/ModAVD#191) + - With the root entered through a directory link (junction, symbolic link, 8.3 short name), the updater recognizes itself and completes the self-occupancy check, the swap and recovery after an interruption instead of reporting busy forever. (ModAVDManager#152, ModAVD/ModAVD#195) + - Offline, a staged package that was downloaded and verified and is newer than the installed version now installs. After "更新" (update) in the manager, the result is shown once, in one window. (ModAVDManager#154, ModAVD/ModAVD#170) + - Range updates always take the manifest from the server; with too many changes they still fall back to the whole package, with the reason only in the log. Files over 8 MiB go to a temporary file as they download instead of into memory. Local read errors are reported as such instead of being taken for a broken package and downloading the whole package again. (ModAVDManager#147, ModAVD/ModAVD#182) + +### Notes + +- When "更新" in a v0.9.0 manager updates to this version, the old command line does the swap and does not hand its result to the reopened manager, so this one time there is no "updated to …" message although the update was installed. Later updates show it as usual. + +### For callers (AUTO-MAS) + +- `protocol` is still 2: no command, option or `--json` field was removed, and there are no new error codes or exit codes. Callers need no change. +- `status` / `list --json`: while an instance is shutting itself down (window closed, shut down inside Android), `status` is still `starting` and `busy` is `stopping`. AUTO-MAS already treats `busy == "stopping"` as stopping. (ModAVDManager#144) +- `update install` has a new manager-only `--handoff-id`, only with `--reopen`; the result is stored in `.update\manager-results\.json`. Calls without `--reopen` are not affected. (ModAVDManager#154) +- The manager's own API adds `POST /api/update/handoff/take` and `POST /api/update/handoff/receive`, and `handoff` gets an `id` field. (ModAVDManager#154) +- `mas-avd.json` gets an optional key `shareDefaultAdbServer` (on by default). (ModAVDManager#155) + +### Download and verification + +Running the repository's `build.ps1` with Go 1.26.5 and `GOTOOLCHAIN=local` builds the same bytes. The standalone package also carries Go's license text and the file list. Check the zip and the two extracted exe files with the attached `SHA256SUMS.txt`, for example `Get-FileHash .\ModAVDManager-0.10.0-win-x64.zip -Algorithm SHA256` in PowerShell. + +The standalone manager does not update itself; replace it by hand for a new version. The copy in the ModAVD package is updated with the whole ModAVD package. When using it standalone, pass `--root` or set `MODAVD_ROOT`, or it reports that it cannot find the ModAVD root. Do not extract the standalone zip into a ModAVD root over the package's files. diff --git a/release/0.9.0.md b/release/0.9.0.md index 484a578..6788ab3 100644 --- a/release/0.9.0.md +++ b/release/0.9.0.md @@ -3,7 +3,7 @@ - `ModAVD.exe`:`5dbdd688052859a8e0b0cea5a0ac77a255ac2df9b3bd4b468fd251734f786259` - `tools/modavd.exe`:`bc35ebd7d67759e2bf9bb8519d52027d6321f46758e1bc9d29c08c872c58f62c` -随 ModAVD 0.41.0 进包。下面是相对 v0.8.0 的改动。这一版主要是:没起名的实例显示 `emu-N`,不再显示文件名 `mas_N`,卡片上能看到实例占了多少硬盘;公开版本号和模拟器构建号分开;整包更新遇到根目录文件被别的程序(比如 MAA)占着时,换包前就停,不再换到一半;问题包里能看出模拟器占了多少内存。 +随 ModAVD 0.40.1 进包。下面是相对 v0.8.0 的改动。这一版主要是:没起名的实例显示 `emu-N`,不再显示文件名 `mas_N`,卡片上能看到实例占了多少硬盘;公开版本号和模拟器构建号分开;整包更新遇到根目录文件被别的程序(比如 MAA)占着时,换包前就停,不再换到一半;问题包里能看出模拟器占了多少内存。 ## 变更 @@ -11,7 +11,7 @@ - 卡片上的规格不再写「盘自动扩」:自动扩盘是默认,不用写;指定了大小的照旧写「盘 N GB」。(ModAVDManager#139) - 卡片上的规格末尾加上数据盘现在实际占的电脑硬盘,比如「占 12.3 GB」,只算写进去的;读不到就不写。(ModAVDManager#140) - 电脑检查去掉「检查对象」下拉框:0.7.0 起开机前不再按实例查内存,所有实例的检查结果都一样。检查结果的标题是「暂时不能开机」,顶栏状态去掉「内存不足」。(ModAVDManager#136) -- 版本号和模拟器构建号分开:0.41 起 ModAVD 的公开版本号自己往上加,模拟器构建号(`mas-N`)只记在包里 `modavd-manifest.json` 的 `"build"`。管理器认本地版本、装包时核对模拟器都改看 `"build"`;没有 `"build"` 的老包照旧按版本中间那位核对。开机要求的最低构建号照旧按 `mas-N`。(ModAVDManager#135) +- 版本号和模拟器构建号分开:ModAVD 0.40.1 以后的公开版本号自己往上加,模拟器构建号(`mas-N`)只记在包里 `modavd-manifest.json` 的 `"build"`。管理器认本地版本、装包时核对模拟器都改看 `"build"`;没有 `"build"` 的老包照旧按版本中间那位核对。0.8.0 及更早的管理器还要求版本中间那位等于构建号,所以带它们的根目录要先更新到 0.40.1,以后的版本才能用「更新」装上。开机要求的最低构建号照旧按 `mas-N`。(ModAVDManager#135) - 开机守护日志 `logs\modavd-bootwatch.log` 在开机后和之后每 10 分钟写一行模拟器的提交内存、工作集和电脑的剩余内存;问题包 `summary.txt` 里每个 qemu-system 进程多一行内存,另加一节「占内存最多的程序」(按提交排前 10 个,同名合并,只写程序名、进程数和内存,不写命令行和路径)。只在 Windows 上有。(ModAVDManager#138) ## 修复 @@ -33,7 +33,7 @@ ## English -Shipped in ModAVD 0.41.0. Changes since v0.8.0. This release mostly brings: instances without a name of their own show as `emu-N` instead of the file name `mas_N`, and the card shows how much disk each instance takes; the public version number is separate from the emulator build number; a whole-package update stops before swapping anything when another program (such as MAA) holds a root file, instead of stopping halfway; and problem reports show how much memory the emulator takes. +Shipped in ModAVD 0.40.1. Changes since v0.8.0. This release mostly brings: instances without a name of their own show as `emu-N` instead of the file name `mas_N`, and the card shows how much disk each instance takes; the public version number is separate from the emulator build number; a whole-package update stops before swapping anything when another program (such as MAA) holds a root file, instead of stopping halfway; and problem reports show how much memory the emulator takes. ### Changes @@ -41,7 +41,7 @@ Shipped in ModAVD 0.41.0. Changes since v0.8.0. This release mostly brings: inst - The card no longer says "盘自动扩" (disk grows by itself): it is the default and needs no mention; a set size still shows as "盘 N GB". (ModAVDManager#139) - The card ends with what the data disk takes on the computer's disk now, such as "占 12.3 GB" (takes 12.3 GB), counting only what was written; left out when it cannot be read. (ModAVDManager#140) - The host check drops its "检查对象" (checked for) drop-down: since 0.7.0 nothing is checked per instance before a start, so every instance gets the same result. The result title is "暂时不能开机" (cannot start yet), and the top bar no longer has an "out of memory" state. (ModAVDManager#136) -- The version number is separate from the emulator build number: from 0.41 ModAVD's public version goes up on its own, and the emulator build (`mas-N`) is recorded only as `"build"` in the package's `modavd-manifest.json`. The manager reads `"build"` to tell the local version and to check the emulator when installing a package; old packages without `"build"` are checked against the middle number of the version as before. The minimum build needed to start is still in `mas-N`. (ModAVDManager#135) +- The version number is separate from the emulator build number: after ModAVD 0.40.1 the public version goes up on its own, and the emulator build (`mas-N`) is recorded only as `"build"` in the package's `modavd-manifest.json`. The manager reads `"build"` to tell the local version and to check the emulator when installing a package; old packages without `"build"` are checked against the middle number of the version as before. Managers 0.8.0 and older still require the middle number to equal the build, so a root with one of them has to update to 0.40.1 first before later versions install through "更新" (update). The minimum build needed to start is still in `mas-N`. (ModAVDManager#135) - The boot watcher log `logs\modavd-bootwatch.log` gets a line after boot and every 10 minutes with the emulator's commit and working set and the computer's free memory; the problem report's `summary.txt` gets a memory line for each qemu-system process and a section with the 10 programs that commit the most memory (same names merged; only program names, process counts and memory, no command lines or paths). Windows only. (ModAVDManager#138) ### Fixes diff --git a/rsrc_windows_amd64.syso b/rsrc_windows_amd64.syso index 555b9cb..1c2d80b 100644 Binary files a/rsrc_windows_amd64.syso and b/rsrc_windows_amd64.syso differ 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); +});