Files
u-desktop/docs/04-功能迭代/overlay鼠标交互实践.md

5.7 KiB
Raw Blame History

Overlay 鼠标交互实践

2026-06-05 | 知识卡片刷新按钮点击 + 悬停效果


背景

桌面壁纸 overlay 通过 WebView2 嵌入 WorkerW 实现位于桌面图标层SysListView32之下。这意味着

  • WM_NCHITTEST 永远到不了 overlay——WindowFromPoint 返回的是 SysListView32
  • CSS hover/click 无效——鼠标事件被上层桌面图标窗口拦截
  • 需要 WH_MOUSE_LL 全局低级钩子Hook Type 14从 Go 层直接拦截

踩坑记录

1. GWLP_WNDPROC 常量值错误

// ❌ 错误0xFFFFFFF4 = -12 = GWLP_ID不是 GWLP_WNDPROC
const gwlpWndProc = uintptr(0xFFFFFFF4)

// ✅ 正确:-4 = GWLP_WNDPROC
const gwlpWndProc = ^uintptr(3) // 按位取反(3) = -4

教训Windows 常量用负数表示时,不要手动换算十六进制,用 Go 的 ^uintptr(N) 表达。

2. GC 回收 NewCallback 导致全局鼠标异常

// ❌ 错误cb 是局部变量,函数返回后可被 GC 回收
func installMouseHook() {
    cb := windows.NewCallback(func(...) uintptr { ... })
    procSetWindowsHookExW.Call(14, cb, 0, 0)
}

// ✅ 正确:存储到包级变量
var llMouseHookCb uintptr
func installMouseHook() {
    llMouseHookCb = windows.NewCallback(func(...) uintptr { ... })
    procSetWindowsHookExW.Call(14, llMouseHookCb, 0, 0)
}

现象GC 回收回调后,钩子指向无效内存,导致所有鼠标点击被拦截——桌面、其他软件全部点不了。

教训windows.NewCallback 返回的引用必须保存在 Go 可达的变量中Go GC 不知道 Windows 持有回调引用。

3. WindowFromPoint 传参方式

// ❌ 错误:传指针
hwndAt, _, _ := procWindowFromPoint.Call(uintptr(unsafe.Pointer(&screenPt)))

// ✅ 正确POINT 按值传递,打包到单个 uintptr
ptPacked := uintptr(s.X) | (uintptr(uint32(s.Y)) << 32)
hwndAt, _, _ := procWindowFromPoint.Call(ptPacked)

教训Win32 API 中 WindowFromPoint(POINT) 的 POINT 是按值传递的,不是指针。在 64 位下两个 int32 打包为一个 uintptr。

4. SetCursor 在 WH_MOUSE_LL 中无效

// ❌ 无效:被后续窗口的 WM_SETCURSOR 覆盖
procSetCursor.Call(handCursor) // 闪一下就被桌面窗口重设为箭头

原因:钩子返回后 WM_MOUSEMOVE 继续传递给桌面窗口SysListView32其在处理 WM_SETCURSOR 时将自己的光标设回去。

结论:在 overlay 这种底层窗口架构下,无法可靠地改变鼠标光标样式。可行的替代方案:

  • 按钮视觉高亮(旋转 + 放大 + 背景色)
  • 全局 SetSystemCursor(影响整个系统,副作用大) 不推荐

5. go-webview2 Bind 参数类型

// ❌ 错误JSON number 解码为 float64int32 静默失败
wv.Bind("setInteractiveRect", func(left, top, right, bottom int32) string { ... })

// ✅ 正确:用 float64
wv.Bind("setInteractiveRect", func(left, top, right, bottom float64) string { ... })

6. DPR 缩放坐标转换

JS getBoundingClientRect() 返回 CSS 像素,ScreenToClient 返回物理像素。必须乘 DPR

const dpr = window.devicePixelRatio || 1
setInteractiveRect(
    Math.round(r.left * dpr), Math.round(r.top * dpr),
    Math.round(r.right * dpr), Math.round(r.bottom * dpr)
)

7. HTTP 429 重试时 body 被消耗

// ❌ 错误:第一次请求消耗了 body重试发送空 body
resp, err := httpClient.Do(req)
if resp.StatusCode == 429 {
    time.Sleep(3 * time.Second)
    resp, err = httpClient.Do(req) // body 已经 EOF
}

// ✅ 正确:创建新 Request
req2, _ := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
resp2, _ := httpClient.Do(req2)

8. WindowFromPoint + Progman 判断桌面遮挡

func isDesktopAtPoint(screenX, screenY int32) bool {
    ptPacked := uintptr(screenX) | (uintptr(uint32(screenY)) << 32)
    hwndAt, _, _ := procWindowFromPoint.Call(ptPacked)
    for h := hwndAt; h != 0; h, _, _ = procGetParent.Call(h) {
        if h == progmanHwnd {
            return true
        }
    }
    return false
}

用途:判断点击位置是否有其他窗口遮挡。只有桌面背景可见时才消费点击事件,避免拦截其他软件的点击。


最终架构

WH_MOUSE_LL 全局钩子
├── WM_MOUSEMOVE (0x0200)
│   └── 检查鼠标是否在按钮 hit rect 内
│       ├── 进入 → 按钮添加 hover-active 样式(旋转+放大+高亮)
│       └── 离开 → 移除样式
├── WM_LBUTTONDOWN (0x0201)
│   ├── WindowFromPoint 检查是否桌面背景可见
│   ├── ScreenToClient 转换到 wvHwnd 坐标
│   └── 命中按钮 rect → pushKnowledgeLoading + goroutine 刷新
└── 其他事件 → CallNextHookEx 传递

hit rect 来源JS 端 getBoundingClientRect() * DPR,仅覆盖刷新按钮(+8px 扩展)。

冷却机制30s 内重复点击回退显示缓存内容,不停留在"加载中"。


关键文件

文件 改动
win32.go 鼠标钩子、WindowFromPoint 桌面判断、Progman 句柄
knowledge.go deepseek-v4-pro 模型、质量检查、冷却回退
KnowledgeCard.vue 按钮 hit rect、hover-active 样式
systray.go setInteractiveRect float64 参数

速查Win32 API 要点

API 陷阱 正确做法
SetWindowLongPtrW 负数索引常量 ^uintptr(3) 表示 -4
windows.NewCallback GC 不跟踪外部引用 存包级变量
WindowFromPoint POINT 按值非指针 打包为 uintptr
SetCursor (WH_MOUSE_LL) 被后续 WM_SETCURSOR 覆盖 不可靠,放弃
ScreenToClient 物理像素 JS 侧乘 DPR
go-webview2 Bind JSON number = float64 参数用 float64