优化: 知识卡片交互+deepseek模型+鼠标钩子
This commit is contained in:
175
docs/04-功能迭代/overlay鼠标交互实践.md
Normal file
175
docs/04-功能迭代/overlay鼠标交互实践.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# 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 解码为 float64,int32 静默失败
|
||||
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 |
|
||||
189
knowledge.go
189
knowledge.go
@@ -9,16 +9,19 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync/atomic"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const cpaURL = "https://cpa.1216.top/v1/chat/completions"
|
||||
const cpaModel = "glm-5.1"
|
||||
const minKnowledgeRunes = 80
|
||||
const cpaModel = "deepseek-v4-pro"
|
||||
const minKnowledgeRunes = 120
|
||||
|
||||
type knowledgeData struct {
|
||||
Content string `json:"content"`
|
||||
@@ -96,31 +99,34 @@ func getKnowledgeCardCount(keyword string) int {
|
||||
return count
|
||||
}
|
||||
|
||||
// systemPromptCodeMark 用于在 Go raw string 中拼接反引号
|
||||
const systemPromptCodeMark = "`"
|
||||
|
||||
const knowledgeSystemPrompt = `你是一位在一线写了 15 年底层系统代码的工程师,只在踩过真实生产级坑之后才写知识卡片。
|
||||
|
||||
内容铁律:
|
||||
- 必须是大多数人不知道的冷知识、反直觉行为、或版本变更导致的隐蔽 break
|
||||
- 必须包含至少一个精确数值(阈值/延迟/版本号/缓冲区大小/默认值等)
|
||||
- 必须讲清因果链:触发条件 → 内部机制 → 实际后果 → 正确做法
|
||||
- 禁止泛泛而谈,禁止复述文档,禁止入门级内容
|
||||
- 每次从不同角度切入,不要重复之前的知识点
|
||||
|
||||
格式:
|
||||
- Markdown:**加粗**重点、` + systemPromptCodeMark + `行内代码` + systemPromptCodeMark + `标记术语、简短列表
|
||||
- 120-200字,紧凑无废话
|
||||
- 不要 emoji`
|
||||
|
||||
func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
||||
basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt)
|
||||
messages := []map[string]string{
|
||||
{
|
||||
"role": "system",
|
||||
"content": `你是一位资深技术专家,面向有多年经验的开发者撰写知识卡片。
|
||||
|
||||
内容标准:
|
||||
- 必须包含可直接用于生产的硬核知识:底层原理、性能陷阱、边界条件、源码级细节
|
||||
- 优先讲"为什么"而非"是什么"——机制、权衡、踩坑经验
|
||||
- 用具体数值、代码片段(仅关键词级别)、或真实场景佐证
|
||||
- 读者看完能立刻在项目中应用或避免踩坑
|
||||
|
||||
禁止:
|
||||
- 入门级科普、"是什么"式定义、百科搬运
|
||||
- 空泛鸡汤("很重要"、"提升效率"、"广泛应用")
|
||||
- 标题、序号、Markdown标记、emoji`,
|
||||
},
|
||||
{"role": "system", "content": knowledgeSystemPrompt},
|
||||
{"role": "user", "content": basePrompt},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": cpaModel,
|
||||
"max_completion_tokens": 2048,
|
||||
"temperature": 0.55,
|
||||
"temperature": 0.8,
|
||||
"messages": messages,
|
||||
}
|
||||
content := requestKnowledgeCompletion(body)
|
||||
@@ -131,9 +137,13 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
||||
|
||||
log.Printf("知识卡片质量不足,重试: %q", content)
|
||||
messages = append(messages, map[string]string{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}, map[string]string{
|
||||
"role": "user",
|
||||
"content": fmt.Sprintf(
|
||||
"上一条太短或信息密度不足。请重写一条「%s」知识卡片:120-180个中文字符,必须包含一个明确机制/原理、一个具体例子或应用场景、一个结论。只输出正文。",
|
||||
"上面这条「%s」知识卡片质量不行:要么太短、要么没有具体数值、要么信息密度不够。"+
|
||||
"请换一个完全不同的角度重写,必须包含:一个精确数值 + 一个底层机制 + 一个生产踩坑场景。",
|
||||
keyword,
|
||||
),
|
||||
})
|
||||
@@ -147,14 +157,38 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
const promptCodeMark = "`"
|
||||
|
||||
// 知识卡片随机切入角度,确保每次生成不同内容
|
||||
var knowledgeAngles = []string{
|
||||
"性能陷阱与调优",
|
||||
"并发/竞态/死锁",
|
||||
"内存泄漏与 GC",
|
||||
"网络协议底层行为",
|
||||
"安全漏洞与防御",
|
||||
"错误处理的隐蔽坑",
|
||||
"版本升级导致的 break change",
|
||||
"配置默认值的陷阱",
|
||||
"日志/监控/可观测性",
|
||||
"序列化/反序列化边界",
|
||||
"连接池/资源耗尽",
|
||||
"编译器/运行时的反直觉行为",
|
||||
"缓存一致性与失效",
|
||||
"时区/时间处理陷阱",
|
||||
"文件系统/IO 边界条件",
|
||||
}
|
||||
|
||||
func buildKnowledgePrompt(keyword, customPrompt string) string {
|
||||
basePrompt := fmt.Sprintf(`围绕「%s」写一条面向资深开发者的技术知识卡片。
|
||||
angle := knowledgeAngles[rand.Intn(len(knowledgeAngles))]
|
||||
basePrompt := fmt.Sprintf(`围绕「%s」从「%s」角度写一条面向资深开发者的技术知识卡片。
|
||||
|
||||
要求:
|
||||
- 100-160字,2-3句连贯正文
|
||||
- 必须触及底层机制、性能细节或生产踩坑经验
|
||||
- 必须包含一个具体场景或数值佐证(如延迟数据、内存阈值、版本差异等)
|
||||
- 直接输出正文,不要标题、编号、Markdown`, keyword)
|
||||
- 120-180字,信息密度极高
|
||||
- 必须是反常识、冷门陷阱、或版本差异等非常规知识点
|
||||
- 必须包含至少一个精确数值(如:默认超时 30s、缓冲区 4KB、Go 1.21 修复等)
|
||||
- 必须讲清因果链:什么场景触发 → 底层发生了什么 → 有什么后果 → 正确做法
|
||||
- 用 Markdown 格式:**加粗重点**、`+promptCodeMark+`行内代码`+promptCodeMark+`标记术语
|
||||
- 不要写常见知识点,不要复述官方文档`, keyword, angle)
|
||||
if customPrompt != "" {
|
||||
basePrompt += "\n附加风格要求(不能覆盖上面的字数和质量要求):" + customPrompt
|
||||
}
|
||||
@@ -186,7 +220,29 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Printf("知识API返回状态码: %d", resp.StatusCode)
|
||||
return ""
|
||||
if resp.StatusCode == 429 {
|
||||
// 限流:等待后用新 body 重试
|
||||
time.Sleep(3 * time.Second)
|
||||
req2, err2 := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
|
||||
if err2 != nil {
|
||||
return ""
|
||||
}
|
||||
req2.Header.Set("Authorization", "Bearer "+key)
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
resp2, err2 := httpClient.Do(req2)
|
||||
if err2 != nil {
|
||||
log.Println("知识API重试失败:", err2)
|
||||
return ""
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != 200 {
|
||||
log.Printf("知识API重试状态码: %d", resp2.StatusCode)
|
||||
return ""
|
||||
}
|
||||
resp = resp2
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var result struct {
|
||||
@@ -201,6 +257,10 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
|
||||
log.Println("知识API响应解析失败")
|
||||
return ""
|
||||
}
|
||||
if len(result.Choices) == 0 {
|
||||
log.Println("知识API: choices 为空")
|
||||
return ""
|
||||
}
|
||||
if len(result.Choices) > 0 {
|
||||
c := result.Choices[0].Message.Content
|
||||
if c == "" {
|
||||
@@ -211,29 +271,58 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeKnowledgeContent 清洗 LLM 输出的格式残留(Markdown标记、标题前缀、多余空白)
|
||||
// normalizeKnowledgeContent 清洗 LLM 输出的格式残留,保留 Markdown 标记
|
||||
func normalizeKnowledgeContent(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
content = strings.Trim(content, "` \t\r\n")
|
||||
// 去除整段代码块包裹
|
||||
if strings.HasPrefix(content, "```") && strings.HasSuffix(content, "```") {
|
||||
content = strings.TrimPrefix(content, "```")
|
||||
// 去除语言标识行
|
||||
if idx := strings.Index(content, "\n"); idx >= 0 && !strings.Contains(content[:idx], " ") {
|
||||
content = content[idx+1:]
|
||||
}
|
||||
content = strings.TrimSuffix(content, "```")
|
||||
content = strings.TrimSpace(content)
|
||||
}
|
||||
// 去除常见前缀
|
||||
content = strings.TrimPrefix(content, "知识卡片:")
|
||||
content = strings.TrimPrefix(content, "知识卡片:")
|
||||
content = strings.TrimPrefix(content, "正文:")
|
||||
content = strings.TrimPrefix(content, "正文:")
|
||||
// 统一换行
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
lines := strings.FieldsFunc(content, func(r rune) bool {
|
||||
return r == '\n' || r == '\r' || r == '\t'
|
||||
})
|
||||
content = strings.Join(lines, " ")
|
||||
content = strings.Join(strings.Fields(content), " ")
|
||||
// 连续空行压缩为两个换行(Markdown 段落分隔)
|
||||
for strings.Contains(content, "\n\n\n") {
|
||||
content = strings.ReplaceAll(content, "\n\n\n", "\n\n")
|
||||
}
|
||||
// 行尾空白清理
|
||||
lines := strings.Split(content, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimRight(line, " \t")
|
||||
}
|
||||
content = strings.Join(lines, "\n")
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
// isQualityKnowledgeCard 检查字数 ≥80 且空泛表述 <3 条
|
||||
var numberRe = regexp.MustCompile(`\d+`)
|
||||
|
||||
// weakPhrases 空泛表述列表
|
||||
var weakPhrases = []string{
|
||||
"很重要", "非常重要", "很有用", "提升效率", "值得关注",
|
||||
"可以帮助", "广泛应用", "具有重要作用", "不可忽视", "具有重要意义",
|
||||
}
|
||||
|
||||
// isQualityKnowledgeCard 检查质量:字数≥120、有空泛表述上限、必须含数字、信息密度
|
||||
func isQualityKnowledgeCard(content string) bool {
|
||||
if utf8.RuneCountInString(content) < minKnowledgeRunes {
|
||||
runes := utf8.RuneCountInString(content)
|
||||
if runes < minKnowledgeRunes {
|
||||
return false
|
||||
}
|
||||
weakPhrases := []string{"很重要", "非常重要", "很有用", "提升效率", "值得关注", "可以帮助", "广泛应用"}
|
||||
// 必须包含数字(确保有具体数值)
|
||||
if !numberRe.MatchString(content) {
|
||||
return false
|
||||
}
|
||||
// 空泛表述检查
|
||||
weakHits := 0
|
||||
for _, phrase := range weakPhrases {
|
||||
if strings.Contains(content, phrase) {
|
||||
@@ -243,7 +332,21 @@ func isQualityKnowledgeCard(content string) bool {
|
||||
if weakHits >= 3 {
|
||||
return false
|
||||
}
|
||||
return strings.ContainsAny(content, "。;;::,,")
|
||||
// 必须有标点(非空内容)
|
||||
if !strings.ContainsAny(content, "。;;::,,") {
|
||||
return false
|
||||
}
|
||||
// 信息密度:非中文字符(代码、数字、标点、Markdown)占比 ≥ 15%
|
||||
nonCJK := 0
|
||||
for _, r := range content {
|
||||
if !unicode.Is(unicode.Han, r) && !unicode.IsSpace(r) {
|
||||
nonCJK++
|
||||
}
|
||||
}
|
||||
if float64(nonCJK)/float64(runes) < 0.10 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pushKnowledgeJSON(content, keyword string) {
|
||||
@@ -251,7 +354,21 @@ func pushKnowledgeJSON(content, keyword string) {
|
||||
evalJS(fmt.Sprintf(`if(window.updateKnowledgeFromGo) window.updateKnowledgeFromGo(%s)`, string(data)))
|
||||
}
|
||||
|
||||
var lastFetchTime int64 // unix timestamp, 限流冷却
|
||||
|
||||
func fetchAndPushKnowledge() {
|
||||
// 30s 冷却,防止频繁点击触发限流
|
||||
now := time.Now().Unix()
|
||||
if now-atomic.LoadInt64(&lastFetchTime) < 30 {
|
||||
// 冷却中,回退显示缓存
|
||||
cfg := loadConfig()
|
||||
if cached := getRandomKnowledgeCard(cfg.KnowledgeKeyword); cached != "" {
|
||||
pushKnowledgeJSON(cached, cfg.KnowledgeKeyword)
|
||||
}
|
||||
return
|
||||
}
|
||||
atomic.StoreInt64(&lastFetchTime, now)
|
||||
|
||||
cfg := loadConfig()
|
||||
keyword := cfg.KnowledgeKeyword
|
||||
if keyword == "" {
|
||||
|
||||
68
settings.go
68
settings.go
@@ -12,6 +12,7 @@ import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/jchv/go-webview2"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
@@ -19,9 +20,12 @@ import (
|
||||
var settingsHTML string
|
||||
|
||||
var (
|
||||
settingsMu sync.Mutex
|
||||
settingsOpen bool
|
||||
settingsHwnd uintptr
|
||||
settingsMu sync.Mutex
|
||||
settingsWv webview2.WebView // 持久实例,nil 直到首次打开
|
||||
settingsHwnd uintptr
|
||||
settingsOldWndProc uintptr // 子类化前的原始 wndproc
|
||||
settingsWndProcCb uintptr // NewCallback 指针,防止 GC
|
||||
settingsCreated bool // 首次创建完成后 true
|
||||
)
|
||||
|
||||
const runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
@@ -71,6 +75,16 @@ func isSystemLightTheme() bool {
|
||||
return v == 1
|
||||
}
|
||||
|
||||
// settingsWndProc 拦截 WM_CLOSE,隐藏窗口而非销毁
|
||||
func settingsWndProc(hwnd, msg, wp, lp uintptr) uintptr {
|
||||
if msg == 0x0010 { // WM_CLOSE
|
||||
procShowWindow.Call(hwnd, 0) // SW_HIDE
|
||||
return 0
|
||||
}
|
||||
r, _, _ := procCallWindowProcW.Call(settingsOldWndProc, hwnd, msg, wp, lp)
|
||||
return r
|
||||
}
|
||||
|
||||
var themeNames = []struct {
|
||||
Name ThemeName
|
||||
Label string
|
||||
@@ -105,26 +119,20 @@ func refreshVisibleCards(cfg, oldCfg *Config) {
|
||||
|
||||
func openSettingsWindow() {
|
||||
settingsMu.Lock()
|
||||
if settingsOpen {
|
||||
if settingsCreated {
|
||||
settingsMu.Unlock()
|
||||
if settingsHwnd != 0 {
|
||||
procShowWindow.Call(settingsHwnd, 9)
|
||||
procGetForegroundWindow.Call(settingsHwnd)
|
||||
}
|
||||
procShowWindow.Call(settingsHwnd, 9) // SW_RESTORE
|
||||
procSetForegroundWindow.Call(settingsHwnd)
|
||||
settingsWv.Dispatch(func() {
|
||||
settingsWv.Eval("if(window.__refreshSettings) window.__refreshSettings()")
|
||||
})
|
||||
return
|
||||
}
|
||||
settingsOpen = true
|
||||
settingsMu.Unlock()
|
||||
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
defer func() {
|
||||
settingsMu.Lock()
|
||||
settingsOpen = false
|
||||
settingsHwnd = 0
|
||||
settingsMu.Unlock()
|
||||
runtime.UnlockOSThread()
|
||||
}()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
dataDir := filepath.Join(configDir(), "settings-data")
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
@@ -135,7 +143,7 @@ func openSettingsWindow() {
|
||||
WindowOptions: webview2.WindowOptions{
|
||||
Title: "桌面设置",
|
||||
Width: 760,
|
||||
Height: 1350,
|
||||
Height: 800,
|
||||
},
|
||||
})
|
||||
if w == nil {
|
||||
@@ -143,6 +151,17 @@ func openSettingsWindow() {
|
||||
return
|
||||
}
|
||||
|
||||
settingsWv = w
|
||||
hwnd := uintptr(w.Window())
|
||||
|
||||
// 子类化:拦截 WM_CLOSE → 隐藏而非销毁
|
||||
settingsWndProcCb = windows.NewCallback(settingsWndProc)
|
||||
oldProc, _, _ := procSetWindowLongPtrW.Call(hwnd, gwlpWndProc, settingsWndProcCb)
|
||||
settingsOldWndProc = oldProc
|
||||
|
||||
// 初始隐藏,resizeToFit 完成后再显示
|
||||
procShowWindow.Call(hwnd, 0) // SW_HIDE
|
||||
|
||||
w.Bind("loadAllSettings", func() string {
|
||||
cfg := loadConfig()
|
||||
|
||||
@@ -536,13 +555,12 @@ func openSettingsWindow() {
|
||||
|
||||
w.SetHtml(settingsHTML)
|
||||
|
||||
hwnd := uintptr(w.Window())
|
||||
// disable resize + hide from taskbar
|
||||
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
|
||||
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox))
|
||||
procSetWindowLongPtrW.Call(hwnd, gwlExStyle, wsExToolwindow)
|
||||
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
|
||||
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox))
|
||||
procSetWindowLongPtrW.Call(hwnd, gwlExStyle, wsExToolwindow)
|
||||
|
||||
// resizeToFit: JS measures content, Go adjusts window frame
|
||||
// resizeToFit: JS measures content, Go adjusts window frame, then shows window
|
||||
w.Bind("resizeToFit", func(contentW, contentH int) string {
|
||||
type rect struct{ Left, Top, Right, Bottom int32 }
|
||||
var wr, cr rect
|
||||
@@ -562,15 +580,19 @@ func openSettingsWindow() {
|
||||
x := (int(screenW) - winW) / 2
|
||||
y := (int(screenH) - winH) / 2
|
||||
procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(winW), uintptr(winH), 1)
|
||||
// 首次 resize 完成后显示窗口
|
||||
procShowWindow.Call(hwnd, 9) // SW_RESTORE
|
||||
procSetForegroundWindow.Call(hwnd)
|
||||
return ""
|
||||
})
|
||||
|
||||
settingsMu.Lock()
|
||||
settingsHwnd = hwnd
|
||||
settingsCreated = true
|
||||
settingsMu.Unlock()
|
||||
|
||||
log.Println("设置窗口已打开")
|
||||
w.Run()
|
||||
log.Println("设置窗口已关闭")
|
||||
log.Println("设置窗口消息循环退出")
|
||||
}()
|
||||
}
|
||||
|
||||
21
systray.go
21
systray.go
@@ -124,7 +124,7 @@ func startWebView() {
|
||||
wvHwnd = uintptr(wv.Window())
|
||||
procShowWindow.Call(wvHwnd, 0) // SW_HIDE
|
||||
procSetWindowLongPtrW.Call(wvHwnd, gwlStyle, wsPopup|wsVisible|wsChild)
|
||||
procSetWindowLongPtrW.Call(wvHwnd, gwlExStyle, wsExTransparent|wsExLayered)
|
||||
procSetWindowLongPtrW.Call(wvHwnd, gwlExStyle, wsExLayered)
|
||||
|
||||
// 立即嵌入 WorkerW
|
||||
embedWorkerW(workerw, screenW, screenH)
|
||||
@@ -136,6 +136,20 @@ func startWebView() {
|
||||
return saveConfig(cfg)
|
||||
})
|
||||
|
||||
wv.Bind("refreshKnowledge", func() string {
|
||||
go fetchAndPushKnowledge()
|
||||
return ""
|
||||
})
|
||||
|
||||
wv.Bind("setInteractiveRect", func(left, top, right, bottom float64) string {
|
||||
overlayHitRect.Left = int32(left)
|
||||
overlayHitRect.Top = int32(top)
|
||||
overlayHitRect.Right = int32(right)
|
||||
overlayHitRect.Bottom = int32(bottom)
|
||||
overlayHasHitRect = true
|
||||
return ""
|
||||
})
|
||||
|
||||
log.Println("设置 HTML...")
|
||||
cfg := loadConfig()
|
||||
wv.SetHtml(buildWallpaperHTML(cfg))
|
||||
@@ -145,6 +159,11 @@ func startWebView() {
|
||||
procShowWindow.Call(wvHwnd, 5)
|
||||
log.Println("壁纸窗口已显示")
|
||||
|
||||
// 子类化:WM_NCHITTEST 动态穿透(WebView2 初始化完成后)
|
||||
subclassOverlayWindows()
|
||||
// 全局鼠标钩子:桌面图标层拦截了 WM_NCHITTEST,用钩子直接捕获点击
|
||||
installMouseHook()
|
||||
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
reloadAllCards()
|
||||
|
||||
@@ -3,18 +3,73 @@
|
||||
<div class="knowledge-header">
|
||||
<span class="knowledge-label">知识卡片</span>
|
||||
<span class="knowledge-keyword-tag" v-if="state.knowledge.keyword">#{{ state.knowledge.keyword }}</span>
|
||||
<button class="knowledge-refresh-btn" @click="refresh" title="换一条">↻</button>
|
||||
</div>
|
||||
<div class="knowledge-divider"></div>
|
||||
<div class="knowledge-body" v-if="state.knowledge.content">
|
||||
<span class="knowledge-quote">"</span>
|
||||
<span class="knowledge-text">{{ state.knowledge.content }}</span>
|
||||
<div class="knowledge-text" v-html="renderedContent"></div>
|
||||
</div>
|
||||
<div class="knowledge-placeholder" v-else>请在设置中配置知识关键字</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { state } from '../composables/useOverlayState'
|
||||
|
||||
function refresh() {
|
||||
state.knowledge = { ...state.knowledge, content: '加载中...' }
|
||||
;(window as any).refreshKnowledge?.()
|
||||
}
|
||||
|
||||
function updateHitRect() {
|
||||
const btn = document.querySelector('.knowledge-refresh-btn') as HTMLElement
|
||||
if (!btn) return
|
||||
const r = btn.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
// 扩大点击区域 8px 方便点击
|
||||
const pad = 8 * dpr
|
||||
;(window as any).setInteractiveRect?.(
|
||||
Math.round(r.left * dpr - pad), Math.round(r.top * dpr - pad),
|
||||
Math.round(r.right * dpr + pad), Math.round(r.bottom * dpr + pad)
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateHitRect()
|
||||
window.addEventListener('resize', updateHitRect)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateHitRect)
|
||||
})
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
let html = text
|
||||
// 代码块 (```...```) → <pre><code>
|
||||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
|
||||
// 行内代码 (`...`) → <code>
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// 加粗 (**...**) → <strong>
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
// 斜体 (*...*) → <em>
|
||||
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>')
|
||||
// ## 标题 → <h3>
|
||||
html = html.replace(/^##\s+(.+)$/gm, '<h3>$1</h3>')
|
||||
// 无序列表 → <li>
|
||||
html = html.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>')
|
||||
// 连续 <li> 包裹 <ul>
|
||||
html = html.replace(/((?:<li>[\s\S]*?<\/li>\s*)+)/g, '<ul>$1</ul>')
|
||||
// 段落(双换行分隔)
|
||||
html = html.replace(/\n\n+/g, '</p><p>')
|
||||
// 单换行 → <br>(非 <li>、<h3>、<pre> 内部)
|
||||
html = html.replace(/(?<!<\/li>|<\/h3>|<\/pre>|<\/code>)\n(?!<li>|<h3>|<pre>|<\/ul>)/g, '<br>')
|
||||
// 包裹在 <p> 中
|
||||
if (!html.startsWith('<')) html = '<p>' + html + '</p>'
|
||||
return html
|
||||
}
|
||||
|
||||
const renderedContent = computed(() => renderMarkdown(state.knowledge.content))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -38,6 +93,25 @@ import { state } from '../composables/useOverlayState'
|
||||
font-size: 10px; font-weight: 600;
|
||||
border: 1px solid rgba(255,216,107,0.15);
|
||||
}
|
||||
.knowledge-refresh-btn {
|
||||
margin-left: auto;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--accent-warm); font-size: 16px; line-height: 1;
|
||||
padding: 2px 4px; border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
opacity: 0.7;
|
||||
animation: pulse-hint 3s ease-in-out infinite;
|
||||
}
|
||||
.knowledge-refresh-btn:hover,
|
||||
.knowledge-refresh-btn.hover-active {
|
||||
opacity: 1;
|
||||
transform: rotate(180deg) scale(1.2);
|
||||
background: rgba(255,216,107,0.15);
|
||||
}
|
||||
@keyframes pulse-hint {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
.knowledge-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--accent-warm), rgba(255,216,107,0.08), transparent);
|
||||
@@ -56,9 +130,41 @@ import { state } from '../composables/useOverlayState'
|
||||
.knowledge-text {
|
||||
font-size: 15px; color: var(--text-main);
|
||||
line-height: 1.7; text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
display: -webkit-box; -webkit-line-clamp: 8;
|
||||
display: -webkit-box; -webkit-line-clamp: 10;
|
||||
-webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.knowledge-text :deep(h3) {
|
||||
font-size: 14px; font-weight: 700;
|
||||
color: var(--accent-warm); margin: 10px 0 6px;
|
||||
}
|
||||
.knowledge-text :deep(strong) {
|
||||
color: var(--text-strong); font-weight: 600;
|
||||
}
|
||||
.knowledge-text :deep(code) {
|
||||
background: rgba(255,255,255,0.08);
|
||||
padding: 1px 5px; border-radius: 3px;
|
||||
font-size: 13px; font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
color: var(--accent-warm);
|
||||
}
|
||||
.knowledge-text :deep(pre) {
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 8px 10px; border-radius: 6px;
|
||||
margin: 8px 0; overflow-x: auto;
|
||||
}
|
||||
.knowledge-text :deep(pre code) {
|
||||
background: none; padding: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.knowledge-text :deep(ul) {
|
||||
padding-left: 16px; margin: 6px 0;
|
||||
}
|
||||
.knowledge-text :deep(li) {
|
||||
font-size: 14px; line-height: 1.6;
|
||||
list-style: disc;
|
||||
}
|
||||
.knowledge-text :deep(em) {
|
||||
color: var(--text-faint); font-style: italic;
|
||||
}
|
||||
.knowledge-placeholder {
|
||||
font-size: 13px; color: var(--text-faint);
|
||||
font-style: italic; padding-top: 8px;
|
||||
|
||||
@@ -48,6 +48,7 @@ import PhotoCard from './PhotoCard.vue'
|
||||
pointer-events: none;
|
||||
}
|
||||
#layout-multi > .card { pointer-events: none; }
|
||||
#card-knowledge { pointer-events: auto; }
|
||||
#card-time {
|
||||
grid-area: time; text-align: left;
|
||||
width: 100%; height: 100%; padding: 22px 30px;
|
||||
|
||||
@@ -38,6 +38,16 @@ const s = reactive<SettingsData>({
|
||||
audioVizStyle: 'bars', audioSensitivity: 1.5, audioSmoothing: 0.7, audioColorScheme: 'neon',
|
||||
})
|
||||
|
||||
async function refreshFromGo() {
|
||||
const raw = await go.loadAllSettings()
|
||||
const data = JSON.parse(raw) as SettingsData
|
||||
if (data.lightTheme) document.documentElement.className = 'light'
|
||||
Object.assign(s, { ...data, citiesByProv: data.citiesByProv || {}, provinces: data.provinces || [], themes: data.themes || [], savedColors: data.savedColors || [] })
|
||||
if (data.color1) { currentColor.c1 = data.color1; currentColor.c2 = data.color2 || ''; currentColor.gradient = data.colorGradient || false }
|
||||
}
|
||||
|
||||
;(window as any).__refreshSettings = refreshFromGo
|
||||
|
||||
// Shared state for color section
|
||||
const currentColor = reactive({ c1: '', c2: '', gradient: false })
|
||||
|
||||
@@ -76,11 +86,7 @@ async function onRemoveSavedColor(idx: number) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const raw = await go.loadAllSettings()
|
||||
const data = JSON.parse(raw) as SettingsData
|
||||
if (data.lightTheme) document.documentElement.className = 'light'
|
||||
Object.assign(s, { ...data, citiesByProv: data.citiesByProv || {}, provinces: data.provinces || [], themes: data.themes || [], savedColors: data.savedColors || [] })
|
||||
if (data.color1) { currentColor.c1 = data.color1; currentColor.c2 = data.color2 || ''; currentColor.gradient = data.colorGradient || false }
|
||||
await refreshFromGo()
|
||||
setTimeout(() => {
|
||||
const el = document.documentElement
|
||||
if (go.resizeToFit) go.resizeToFit(el.scrollWidth, el.scrollHeight + 8)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
144
win32.go
144
win32.go
@@ -30,6 +30,14 @@ var (
|
||||
procTranslateMessage = user32.NewProc("TranslateMessage")
|
||||
procDispatchMessageW = user32.NewProc("DispatchMessageW")
|
||||
procGetParent = user32.NewProc("GetParent")
|
||||
procCallWindowProcW = user32.NewProc("CallWindowProcW")
|
||||
procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
|
||||
procScreenToClient = user32.NewProc("ScreenToClient")
|
||||
procEnumChildWindows = user32.NewProc("EnumChildWindows")
|
||||
procGetWindow = user32.NewProc("GetWindow")
|
||||
procSetWindowsHookExW = user32.NewProc("SetWindowsHookExW")
|
||||
procCallNextHookEx = user32.NewProc("CallNextHookEx")
|
||||
procWindowFromPoint = user32.NewProc("WindowFromPoint")
|
||||
)
|
||||
|
||||
var wv webview2.WebView
|
||||
@@ -52,14 +60,149 @@ const (
|
||||
wsMaxbox = uintptr(0x00010000)
|
||||
)
|
||||
|
||||
const gwlpWndProc = ^uintptr(3) // GWLP_WNDPROC = -4
|
||||
|
||||
const (
|
||||
wsExTransparent = uintptr(0x00000020)
|
||||
wsExLayered = uintptr(0x00080000)
|
||||
wsExToolwindow = uintptr(0x00000080)
|
||||
)
|
||||
|
||||
const (
|
||||
gwChild = 5 // GW_CHILD
|
||||
gwHwndNext = 2 // GW_HWNDNEXT
|
||||
)
|
||||
|
||||
var htmlQueue = make(chan string, 1)
|
||||
|
||||
// overlay 窗口子类化:动态 hit test
|
||||
var (
|
||||
overlayOldWndProc uintptr
|
||||
overlayWndProcCb uintptr
|
||||
overlayHitRect struct{ Left, Top, Right, Bottom int32 }
|
||||
overlayHasHitRect bool
|
||||
childOldWndProcs = make(map[uintptr]uintptr) // 子窗口 hwnd -> 旧 wndproc
|
||||
)
|
||||
|
||||
// overlayWndProc 拦截 WM_NCHITTEST:交互区域返回 HTCLIENT,其余 HTTRANSPARENT(穿透)
|
||||
// 注意:由于 overlay 在桌面图标层之下,此 wndproc 主要作为备用
|
||||
func overlayWndProc(hwnd, msg, wp, lp uintptr) uintptr {
|
||||
if msg == 0x0084 && overlayHasHitRect { // WM_NCHITTEST
|
||||
x := int32(int16(lp & 0xFFFF))
|
||||
y := int32(int16(lp >> 16))
|
||||
pt := struct{ X, Y int32 }{x, y}
|
||||
procScreenToClient.Call(wvHwnd, uintptr(unsafe.Pointer(&pt)))
|
||||
if pt.X >= overlayHitRect.Left && pt.X <= overlayHitRect.Right &&
|
||||
pt.Y >= overlayHitRect.Top && pt.Y <= overlayHitRect.Bottom {
|
||||
return uintptr(1) // HTCLIENT
|
||||
}
|
||||
return 0xFFFFFFFF // HTTRANSPARENT
|
||||
}
|
||||
// 查找该窗口的旧 wndproc
|
||||
oldProc := overlayOldWndProc
|
||||
if v, ok := childOldWndProcs[hwnd]; ok {
|
||||
oldProc = v
|
||||
}
|
||||
r, _, _ := procCallWindowProcW.Call(oldProc, hwnd, msg, wp, lp)
|
||||
return r
|
||||
}
|
||||
|
||||
// installMouseHook 安装 WH_MOUSE_LL 全局钩子拦截刷新按钮点击
|
||||
// overlay 在 WorkerW 中,位于桌面图标层之下,WM_NCHITTEST 无法到达
|
||||
// 因此用低级鼠标钩子直接在 Go 层处理点击
|
||||
var llMouseHook uintptr
|
||||
var llMouseHookCb uintptr // 防止 GC 回收回调函数
|
||||
var progmanHwnd uintptr // Progman 句柄,用于判断点击是否在桌面
|
||||
var btnHoverActive bool // 按钮悬停状态,避免重复推送 JS
|
||||
|
||||
// isMouseInBtnRect 检查鼠标是否在按钮 hit rect 内(屏幕坐标转 wvHwnd 客户端坐标)
|
||||
func isMouseInBtnRect(screenX, screenY int32) bool {
|
||||
if !overlayHasHitRect {
|
||||
return false
|
||||
}
|
||||
pt := struct{ X, Y int32 }{screenX, screenY}
|
||||
procScreenToClient.Call(wvHwnd, uintptr(unsafe.Pointer(&pt)))
|
||||
return pt.X >= overlayHitRect.Left && pt.X <= overlayHitRect.Right &&
|
||||
pt.Y >= overlayHitRect.Top && pt.Y <= overlayHitRect.Bottom
|
||||
}
|
||||
|
||||
// isDesktopAtPoint 检查屏幕坐标处是否为桌面背景(无其他窗口遮挡)
|
||||
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
|
||||
}
|
||||
|
||||
func installMouseHook() {
|
||||
llMouseHookCb = windows.NewCallback(func(nCode, wp, lp uintptr) uintptr {
|
||||
if int32(nCode) >= 0 {
|
||||
type msll struct {
|
||||
X, Y int32
|
||||
MouseData uint32
|
||||
Flags uint32
|
||||
Time uint32
|
||||
DwExtraInfo uintptr
|
||||
}
|
||||
s := (*msll)(unsafe.Pointer(lp))
|
||||
|
||||
switch wp {
|
||||
case 0x0200: // WM_MOUSEMOVE — 悬停检测
|
||||
inBtn := isMouseInBtnRect(s.X, s.Y)
|
||||
if inBtn && !btnHoverActive {
|
||||
btnHoverActive = true
|
||||
evalJS("document.querySelector('.knowledge-refresh-btn')?.classList.add('hover-active')")
|
||||
} else if !inBtn && btnHoverActive {
|
||||
btnHoverActive = false
|
||||
evalJS("document.querySelector('.knowledge-refresh-btn')?.classList.remove('hover-active')")
|
||||
}
|
||||
|
||||
case 0x0201: // WM_LBUTTONDOWN — 点击
|
||||
if !isDesktopAtPoint(s.X, s.Y) {
|
||||
break
|
||||
}
|
||||
if isMouseInBtnRect(s.X, s.Y) {
|
||||
pushKnowledgeLoading("")
|
||||
go fetchAndPushKnowledge()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
r, _, _ := procCallNextHookEx.Call(llMouseHook, nCode, wp, lp)
|
||||
return r
|
||||
})
|
||||
var llMouseHookVal uintptr
|
||||
llMouseHookVal, _, _ = procSetWindowsHookExW.Call(
|
||||
14, // WH_MOUSE_LL
|
||||
llMouseHookCb, 0, 0,
|
||||
)
|
||||
llMouseHook = llMouseHookVal
|
||||
log.Printf("mouse hook installed, progman=0x%x, hook=0x%x", progmanHwnd, llMouseHookVal)
|
||||
}
|
||||
|
||||
// subclassOverlayWindows 子类化 WebView2 父窗口及其所有子窗口
|
||||
func subclassOverlayWindows() {
|
||||
overlayWndProcCb = windows.NewCallback(overlayWndProc)
|
||||
oldProc, _, _ := procSetWindowLongPtrW.Call(wvHwnd, gwlpWndProc, overlayWndProcCb)
|
||||
overlayOldWndProc = oldProc
|
||||
subclassChildTree(wvHwnd)
|
||||
}
|
||||
|
||||
// subclassChildTree 递归子类化 parent 下的所有子窗口
|
||||
func subclassChildTree(parent uintptr) {
|
||||
child, _, _ := procGetWindow.Call(parent, uintptr(gwChild))
|
||||
for child != 0 {
|
||||
old, _, _ := procSetWindowLongPtrW.Call(child, gwlpWndProc, overlayWndProcCb)
|
||||
childOldWndProcs[child] = old
|
||||
subclassChildTree(child)
|
||||
child, _, _ = procGetWindow.Call(child, uintptr(gwHwndNext))
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
classProgman = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman")))
|
||||
classShellDefView = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("SHELLDLL_DefView")))
|
||||
@@ -85,6 +228,7 @@ func findWorkerW() uintptr {
|
||||
log.Println("findWorkerW: Progman not found")
|
||||
return 0
|
||||
}
|
||||
progmanHwnd = progman // 保存 Progman 句柄供鼠标钩子判断
|
||||
// 发送 0x052C 触发 Progman 创建 WorkerW
|
||||
var result uintptr
|
||||
procSendMessageTimeoutW.Call(progman, 0x052C, 0, 0, 0x0000, 1000, uintptr(unsafe.Pointer(&result)))
|
||||
|
||||
Reference in New Issue
Block a user