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

176 lines
5.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 常量值错误
```go
// ❌ 错误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 导致全局鼠标异常
```go
// ❌ 错误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 传参方式
```go
// ❌ 错误:传指针
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 中无效
```go
// ❌ 无效:被后续窗口的 WM_SETCURSOR 覆盖
procSetCursor.Call(handCursor) // 闪一下就被桌面窗口重设为箭头
```
**原因**:钩子返回后 WM_MOUSEMOVE 继续传递给桌面窗口SysListView32其在处理 WM_SETCURSOR 时将自己的光标设回去。
**结论**:在 overlay 这种底层窗口架构下,无法可靠地改变鼠标光标样式。可行的替代方案:
- 按钮视觉高亮(旋转 + 放大 + 背景色)✅
- 全局 `SetSystemCursor`(影响整个系统,副作用大)❌ 不推荐
### 5. go-webview2 Bind 参数类型
```go
// ❌ 错误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
```javascript
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 被消耗
```go
// ❌ 错误:第一次请求消耗了 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 判断桌面遮挡
```go
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 |