From aca5b83b91f53eb402b77a31d1c9de48455012ae Mon Sep 17 00:00:00 2001 From: jinghero <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:40:12 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8F=8C=E5=87=BB?= =?UTF-8?q?=E6=89=98=E7=9B=98=E9=9A=90=E8=97=8F=E5=92=8C=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单击等系统双击间隔后恢复实例窗口,双击只切换独立 Edge 配置里的管理器窗口。退出时恢复隐藏窗口,并阻止晚到的回调再次隐藏。 Refs ModAVD/ModAVD#188 --- README.en.md | 2 +- README.md | 2 +- internal/ui/API.en.md | 2 +- internal/ui/API.md | 2 +- internal/ui/bosskey.go | 42 ++- internal/ui/tray_manager_edge_windows_test.go | 304 ++++++++++++++++++ internal/ui/tray_manager_windows_test.go | 154 +++++++++ internal/ui/tray_other.go | 2 + internal/ui/tray_windows.go | 2 +- internal/ui/ui.go | 7 +- internal/ui/window_visibility_windows.go | 64 ++++ internal/win/edge_windows.go | 145 +++++++++ internal/win/edge_windows_test.go | 115 +++++++ internal/win/tray.go | 132 ++++++-- internal/win/tray_click_windows_test.go | 184 +++++++++++ internal/win/win.go | 3 + 16 files changed, 1129 insertions(+), 33 deletions(-) create mode 100644 internal/ui/tray_manager_edge_windows_test.go create mode 100644 internal/ui/tray_manager_windows_test.go create mode 100644 internal/ui/window_visibility_windows.go create mode 100644 internal/win/edge_windows.go create mode 100644 internal/win/edge_windows_test.go create mode 100644 internal/win/tray_click_windows_test.go diff --git a/README.en.md b/README.en.md index c673656..e89c7af 100644 --- a/README.en.md +++ b/README.en.md @@ -279,7 +279,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..eccb6ad 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,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/internal/ui/API.en.md b/internal/ui/API.en.md index f938e0b..3bf164c 100644 --- a/internal/ui/API.en.md +++ b/internal/ui/API.en.md @@ -220,7 +220,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..887b142 100644 --- a/internal/ui/API.md +++ b/internal/ui/API.md @@ -220,7 +220,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/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..396ccf2 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -149,8 +149,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 +220,8 @@ func newServer(opt Options) (*server, error) { proxyEvery: proxyEvery, hub: newHub(), marks: newProxyMarks(), + + managerWindows: changeManagerWindows, } if s.openPath == nil { s.openPath = shellOpen 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/win/edge_windows.go b/internal/win/edge_windows.go new file mode 100644 index 0000000..64abf0b --- /dev/null +++ b/internal/win/edge_windows.go @@ -0,0 +1,145 @@ +//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 requests activation. Windows may refuse to steal focus from another application. +func (w EdgeWindow) Foreground() { + if w.valid() { + procSetForegroundWindow.Call(w.hwnd) + } +} + +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/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") From 0db8ed9131e8aec51f5c1ae8e5512c6668b2d4a9 Mon Sep 17 00:00:00 2001 From: jinghero <44921326+jinghero@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:14:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=AF=B9=E7=85=A7=EF=BC=8C=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=B3=A8=E5=85=A5=E4=B8=8E=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录 Windows 激活请求的返回值。用独立测试窗口授予本次测试进程前台权限,对照原生请求和托盘回调的激活结果,并核对窗口身份、消息应答与最近输入。 Refs ModAVD/ModAVD#188 --- internal/ui/tray_foreground_windows_test.go | 365 ++++++++++++++++++++ internal/win/edge_windows.go | 11 +- 2 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 internal/ui/tray_foreground_windows_test.go 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/win/edge_windows.go b/internal/win/edge_windows.go index 64abf0b..ce571e7 100644 --- a/internal/win/edge_windows.go +++ b/internal/win/edge_windows.go @@ -65,11 +65,14 @@ func (w EdgeWindow) SetVisible(visible bool) error { return nil } -// Foreground requests activation. Windows may refuse to steal focus from another application. -func (w EdgeWindow) Foreground() { - if w.valid() { - procSetForegroundWindow.Call(w.hwnd) +// 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 (