Compare commits
5 Commits
aee7997195
...
0302546267
| Author | SHA1 | Date | |
|---|---|---|---|
| 0302546267 | |||
| 004dba7db6 | |||
| 1f892fc390 | |||
| cd7247d880 | |||
| f5a473c4b8 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
||||
*.exe
|
||||
*.exe~
|
||||
|
||||
# Build artifacts (updater.exe at root is build output for embed)
|
||||
updater.exe
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
|
||||
106
README.md
106
README.md
@@ -1,17 +1,18 @@
|
||||
# u-desktop
|
||||
|
||||
Windows 桌面壁纸增强工具 — 将 WebView2 嵌入桌面壁纸层,在壁纸上叠加时间、天气、星座运势、AI 资讯等信息卡片。
|
||||
Windows 桌面壁纸增强工具 — 将 WebView2 嵌入桌面壁纸层,在壁纸上叠加时间、天气、星座运势、AI 资讯、电子相册等信息卡片。
|
||||
|
||||
## 功能
|
||||
|
||||
| 模块 | 说明 |
|
||||
|------|------|
|
||||
| 壁纸 | 6 种主题动画(极光/星空/渐变/粒子/极光流体/文字)、本地图片、Bing 每日壁纸、纯色/渐变 |
|
||||
| 壁纸 | 6 种主题动画(极光/星空/渐变/粒子/极光流体/文字)、本地图片、Bing 每日壁纸(导航+收藏)、纯色/渐变 |
|
||||
| 时间 | 大字时钟,支持秒显示开关,整点光晕动画,节日倒计时 |
|
||||
| 天气 | 和风天气 API,当前天气 + 24h/7d 预报,IP 自动定位 + 手动选城 |
|
||||
| 星座运势 | 天聚数行 API,5 维指数进度条 + 幸运标签 + 今日概述 |
|
||||
| AI 资讯 | 天聚数行 API,图文布局 5 条展示 |
|
||||
| 知识卡片 | AI 生成,自定义关键字 + 提示词 |
|
||||
| 电子相册 | 本地目录轮播,可调间隔,支持电子相册模式(照片铺满壁纸) |
|
||||
| 桌面设置 | 独立 WebView2 窗口,所有配置可视化操作 |
|
||||
|
||||
## 项目结构
|
||||
@@ -19,28 +20,33 @@ Windows 桌面壁纸增强工具 — 将 WebView2 嵌入桌面壁纸层,在壁
|
||||
```
|
||||
u-desktop/
|
||||
├── main.go # 入口: 单实例互斥锁 + 配置目录 + 托盘启动
|
||||
├── win32.go # Win32 API 声明 (user32/kernel32/ole32)
|
||||
├── systray.go # 系统托盘 + WebView2 壁纸嵌入 + 消息循环
|
||||
├── win32.go # Win32 API 声明 (user32/comdlg32/shell32)
|
||||
├── systray.go # 系统托盘 + WebView2 壁纸嵌入 + 消息循环 + WorkerW 监控
|
||||
├── wallpaper.go # 壁纸 HTML 构建 + 主题注入 + 重载
|
||||
├── config.go # 配置结构体 + JSON 持久化
|
||||
├── settings.go # 设置窗口 (独立 WebView2)
|
||||
├── weather.go # 天气 API + IP 定位 + 城市列表
|
||||
├── horoscope.go # 星座运势 API + 文件缓存
|
||||
├── ainews.go # AI 资讯 API + 文件缓存
|
||||
├── knowledge.go # 知识卡片 AI 生成
|
||||
├── knowledge.go # 知识卡片 AI 生成 + SQLite 缓存
|
||||
├── bing.go # Bing 壁纸下载 + 历史导航 + 收藏
|
||||
├── dialog.go # Win32 对话框 (文件选择/颜色选择)
|
||||
├── photo.go # 电子相册: 目录扫描 + 定时轮播 + 预缓存
|
||||
├── dialog.go # Win32 对话框 (文件选择/颜色选择/目录选择)
|
||||
├── web-ui/ # Vue 3 前端源码
|
||||
│ └── src/
|
||||
│ ├── overlay/ # 桌面覆盖层组件 (TimeCard/WeatherCard/ZodiacCard/...)
|
||||
│ ├── settings/ # 设置窗口组件 (ToggleSection/WallpaperSection/...)
|
||||
│ └── shared/ # 类型定义 + 工具函数
|
||||
├── web/
|
||||
│ ├── overlay.html # 桌面覆盖层 (时间/天气/星座/资讯/知识)
|
||||
│ ├── settings.html # 设置窗口 UI
|
||||
│ ├── overlay.html # 构建产物: 桌面覆盖层 (单文件内联)
|
||||
│ ├── settings.html # 构建产物: 设置窗口 UI
|
||||
│ └── themes/ # 壁纸主题 HTML
|
||||
│ ├── aurora.html
|
||||
│ ├── starfield.html
|
||||
│ ├── gradient.html
|
||||
│ ├── particles.html
|
||||
│ ├── fractal.html
|
||||
│ └── text.html
|
||||
├── config/ # 运行时配置 (settings.json + 缓存)
|
||||
│ ├── aurora.html # WebGL 极光动画
|
||||
│ ├── starfield.html # Canvas 2D 星空
|
||||
│ ├── gradient.html # CSS 渐变动画
|
||||
│ ├── particles.html # Canvas 2D 粒子
|
||||
│ ├── fractal.html # WebGL 分形流体
|
||||
│ └── text.html # CSS 文字动画
|
||||
└── docs/
|
||||
└── wallpaper-embedding.md # 壁纸嵌入技术笔记
|
||||
```
|
||||
@@ -50,29 +56,34 @@ u-desktop/
|
||||
```
|
||||
┌──────────────┐ ┌─────────────────┐
|
||||
│ systray.go │────▶│ WebView2 主窗口 │ SetParent → WorkerW (壁纸层)
|
||||
│ (托盘+消息循环) │ │ overlay.html │
|
||||
│ (托盘+消息循环) │ │ overlay.html │ WS_EX_TRANSPARENT (鼠标穿透)
|
||||
└──────┬───────┘ └─────────────────┘
|
||||
│ evalJS / SetHtml
|
||||
│ evalJS / SetHtml / PostMessage
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ ┌─────────────────┐
|
||||
│ settings.go │────▶│ WebView2 设置窗口 │ 独立窗口, 760x1350
|
||||
│ settings.go │────▶│ WebView2 设置窗口 │ 独立窗口, 自适应高度
|
||||
│ │ │ settings.html │
|
||||
└──────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
**核心通信机制**:
|
||||
- `evalJS(cmd)` — 通过 channel + PostMessage 向壁纸 WebView 注入 JS
|
||||
- `htmlQueue` — 通过 SetHtml 替换整个壁纸 HTML(触发完整重载)
|
||||
- WebView2 `Bind` — 设置窗口通过 Go 绑定函数读写配置
|
||||
- `htmlQueue` — 通过 SetHtml 替换整个壁纸 HTML(主题切换时触发)
|
||||
- WebView2 `Bind` — 设置窗口通过 Go 绑定函数(30+)读写配置
|
||||
- `TrayClickCh` — 托盘图标左键点击通道,触发设置窗口打开
|
||||
|
||||
**数据刷新策略**:
|
||||
- 启动时推送文件缓存(即时显示),后台拉取新数据后替换
|
||||
- 星座: 24h / AI 资讯: 2h / 知识卡片: 30min / 天气: 10min / Bing: 4h
|
||||
|
||||
**WorkerW 监控**:
|
||||
- 每 10 秒检测 WebView2 父窗口是否变化(`GetParent`)
|
||||
- Windows 桌面重建 WorkerW 时自动重新嵌入,无感知恢复
|
||||
|
||||
## 配置
|
||||
|
||||
配置文件: `config/settings.json`(与 exe 同级目录)
|
||||
配置文件: `~/.u-desktop/settings.json`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
@@ -88,34 +99,63 @@ u-desktop/
|
||||
| hideZodiac | bool | false | 隐藏星座 |
|
||||
| hideAINews | bool | false | 隐藏 AI 资讯 |
|
||||
| hideKnowledge | bool | false | 隐藏知识卡片 |
|
||||
| bingAutoRefresh | bool | false | Bing 每小时自动切换 |
|
||||
| hidePhoto | bool | false | 隐藏相册 |
|
||||
| photoFrameMode | bool | false | 电子相册模式(照片铺满壁纸) |
|
||||
| photoDir | string | "" | 相册图片目录 |
|
||||
| photoInterval | int | 15 | 相册切换间隔(秒) |
|
||||
| bingAutoRefresh | bool | false | Bing 定时自动切换 |
|
||||
| knowledgeKeyword | string | "" | 知识卡片关键字 |
|
||||
| knowledgePrompt | string | "" | 知识卡片提示词 |
|
||||
| wallpaperText | string | "" | 文字主题自定义内容 |
|
||||
| savedColors | array | [] | 收藏的颜色方案 |
|
||||
| autoStart | bool | false | 开机自启 |
|
||||
| color1 | string | "#1a1a2e" | 纯色/渐变颜色 1 |
|
||||
| color2 | string | "" | 渐变颜色 2 |
|
||||
| colorGradient | bool | false | 启用渐变 |
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
go build -o u-desktop.exe .
|
||||
```powershell
|
||||
# 完整构建(含前端 + UPX 压缩)
|
||||
pwsh -File build.ps1
|
||||
|
||||
# 构建并打包 zip(含 WebView2 bootstrapper)
|
||||
pwsh -File build.ps1 -Pack
|
||||
```
|
||||
|
||||
需要 Windows 10+ 和 WebView2 Runtime(Win11 已内置)。
|
||||
|
||||
## 交互
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 左键托盘图标 | 打开桌面设置 |
|
||||
| 右键托盘图标 | 弹出菜单(桌面设置/重启/退出) |
|
||||
| 设置窗口 | 所有配置实时生效,无需重启 |
|
||||
|
||||
## 依赖
|
||||
|
||||
```
|
||||
github.com/jchv/go-webview2 # WebView2 绑定
|
||||
github.com/getlantern/systray # 系统托盘
|
||||
golang.org/x/sys/windows # Win32 API
|
||||
github.com/anthropics/anthropic-sdk-go # Claude API (知识卡片)
|
||||
```
|
||||
**Go:**
|
||||
- `github.com/jchv/go-webview2` — WebView2 绑定
|
||||
- `github.com/getlantern/systray` — 系统托盘(已修改:左键点击支持)
|
||||
- `golang.org/x/sys/windows` — Win32 API
|
||||
- `modernc.org/sqlite` — 纯 Go SQLite(知识卡片缓存)
|
||||
|
||||
## 开机自启
|
||||
|
||||
注册表 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\UDesktopWallpaper`
|
||||
**前端:**
|
||||
- Vue 3.5 + TypeScript 5.8 + Vite 6 + Tailwind CSS 3
|
||||
- `vite-plugin-singlefile` — 构建产物单文件内联
|
||||
|
||||
## 技术要点
|
||||
|
||||
- WebView2 嵌入桌面壁纸层通过 `SetParent` 到 WorkerW 实现(详见 [docs/wallpaper-embedding.md](docs/wallpaper-embedding.md))
|
||||
- `WS_EX_TRANSPARENT | WS_EX_LAYERED` 扩展样式实现鼠标键盘事件穿透,桌面图标正常操作
|
||||
- 自定义 Win32 消息循环替代 `wv.Run()`(SetParent 后 Run() 消息路由异常)
|
||||
- WorkerW 重建监控:定时 `GetParent` 检测,Windows 桌面刷新后自动重新嵌入
|
||||
- 全屏应用检测:定时检查前台窗口是否全屏,自动暂停/恢复壁纸渲染
|
||||
- 配置存储于 `~/.u-desktop/`,不依赖 exe 同级目录,构建清理不影响用户配置
|
||||
- 配置变更即时生效:设置窗口通过 evalJS 直接操作壁纸层 DOM,无需重启
|
||||
- 构建参数 `-H windowsgui` 隐藏控制台窗口,双击 exe 无黑窗口
|
||||
|
||||
## 开机自启
|
||||
|
||||
注册表 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\u-desktop`
|
||||
|
||||
427
audio.go
Normal file
427
audio.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"math/cmplx"
|
||||
"runtime"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// FFT: 纯 Go radix-2 Cooley-Tukey (1024 点)
|
||||
// ============================================================
|
||||
|
||||
const fftSize = 1024
|
||||
const targetBins = 128
|
||||
|
||||
func fft(x []complex128) {
|
||||
n := len(x)
|
||||
if n <= 1 {
|
||||
return
|
||||
}
|
||||
for i, j := 1, 0; i < n; i++ {
|
||||
bit := n >> 1
|
||||
for j&bit != 0 {
|
||||
j ^= bit
|
||||
bit >>= 1
|
||||
}
|
||||
j ^= bit
|
||||
if i < j {
|
||||
x[i], x[j] = x[j], x[i]
|
||||
}
|
||||
}
|
||||
for length := 2; length <= n; length <<= 1 {
|
||||
half := length >> 1
|
||||
angle := -2 * math.Pi / float64(length)
|
||||
wn := complex(math.Cos(angle), math.Sin(angle))
|
||||
for i := 0; i < n; i += length {
|
||||
w := complex(1, 0)
|
||||
for j := 0; j < half; j++ {
|
||||
u := x[i+j]
|
||||
v := x[i+j+half] * w
|
||||
x[i+j] = u + v
|
||||
x[i+j+half] = u - v
|
||||
w *= wn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fftMagnitude(input []float32) []float64 {
|
||||
n := len(input)
|
||||
if n > fftSize {
|
||||
n = fftSize
|
||||
input = input[:fftSize]
|
||||
}
|
||||
x := make([]complex128, fftSize)
|
||||
for i := 0; i < n; i++ {
|
||||
w := 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(n-1)))
|
||||
x[i] = complex(float64(input[i])*w, 0)
|
||||
}
|
||||
fft(x)
|
||||
mag := make([]float64, fftSize/2)
|
||||
for i := range mag {
|
||||
mag[i] = cmplx.Abs(x[i]) / float64(fftSize)
|
||||
}
|
||||
return mag
|
||||
}
|
||||
|
||||
// downsampleBins: 分段映射 — 低频线性保细节,中高频对数保覆盖
|
||||
// 不再做额外 tilt 补偿,映射本身就是均衡的
|
||||
func downsampleBins(magnitudes []float64, target int) []float64 {
|
||||
srcLen := len(magnitudes)
|
||||
if srcLen == 0 || target == 0 {
|
||||
return make([]float64, target)
|
||||
}
|
||||
|
||||
// 低频线性区间:前20%的 display bins 覆盖 FFT 1-80 (约47Hz-3.7kHz)
|
||||
// 中高频对数区间:后80%覆盖 FFT 80-512 (3.7kHz-24kHz)
|
||||
const lowEnd = 80 // 低频截止 FFT 索引
|
||||
const lowFrac = 0.2
|
||||
|
||||
bins := make([]float64, target)
|
||||
for i := 0; i < target; i++ {
|
||||
frac := float64(i) / float64(target)
|
||||
fracNext := float64(i+1) / float64(target)
|
||||
|
||||
var start, end int
|
||||
if frac < lowFrac {
|
||||
start = int(frac / lowFrac * float64(lowEnd))
|
||||
end = int(fracNext / lowFrac * float64(lowEnd))
|
||||
} else {
|
||||
f := (frac - lowFrac) / (1 - lowFrac)
|
||||
fNext := (fracNext - lowFrac) / (1 - lowFrac)
|
||||
start = lowEnd + int(float64(srcLen-lowEnd)*math.Pow(f, 0.7))
|
||||
end = lowEnd + int(float64(srcLen-lowEnd)*math.Pow(fNext, 0.7))
|
||||
}
|
||||
|
||||
if end <= start {
|
||||
end = start + 1
|
||||
}
|
||||
if end > srcLen {
|
||||
end = srcLen
|
||||
}
|
||||
|
||||
// 取最大值保留峰值冲击力
|
||||
maxVal := 0.0
|
||||
for j := start; j < end; j++ {
|
||||
if magnitudes[j] > maxVal {
|
||||
maxVal = magnitudes[j]
|
||||
}
|
||||
}
|
||||
bins[i] = maxVal
|
||||
}
|
||||
return bins
|
||||
}
|
||||
|
||||
func smoothBinsGo(prev, curr []float64, factor float64) []float64 {
|
||||
n := len(curr)
|
||||
if len(prev) != n {
|
||||
return curr
|
||||
}
|
||||
out := make([]float64, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = prev[i]*factor + curr[i]*(1-factor)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COM vtable 调用辅助
|
||||
// ============================================================
|
||||
|
||||
func comCall(obj uintptr, vtableIdx uintptr, args ...uintptr) uintptr {
|
||||
if obj == 0 {
|
||||
return 0x80004003 // E_POINTER
|
||||
}
|
||||
vtable := *(*uintptr)(unsafe.Pointer(obj))
|
||||
method := *(*uintptr)(unsafe.Pointer(vtable + vtableIdx*unsafe.Sizeof(uintptr(0))))
|
||||
a := make([]uintptr, 12)
|
||||
a[0] = obj
|
||||
for i, arg := range args {
|
||||
a[i+1] = arg
|
||||
}
|
||||
n := uintptr(len(args) + 1)
|
||||
switch {
|
||||
case n <= 3:
|
||||
r, _, _ := syscall.Syscall(method, n, a[0], a[1], a[2])
|
||||
return r
|
||||
case n <= 6:
|
||||
r, _, _ := syscall.Syscall6(method, n, a[0], a[1], a[2], a[3], a[4], a[5])
|
||||
return r
|
||||
case n <= 9:
|
||||
r, _, _ := syscall.Syscall9(method, n, a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8])
|
||||
return r
|
||||
case n <= 12:
|
||||
r, _, _ := syscall.Syscall12(method, n, a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11])
|
||||
return r
|
||||
default:
|
||||
return 0x80070057 // E_INVALIDARG
|
||||
}
|
||||
}
|
||||
|
||||
// COM GUID 常量
|
||||
var (
|
||||
clsidMMDeviceEnumerator = windows.GUID{Data1: 0xBCDE0395, Data2: 0xE52F, Data3: 0x467C, Data4: [8]byte{0x8E, 0x3D, 0xC4, 0x57, 0x92, 0x91, 0x69, 0x2E}}
|
||||
iidIMMDeviceEnumerator = windows.GUID{Data1: 0xA95664D2, Data2: 0x9614, Data3: 0x4F35, Data4: [8]byte{0xA7, 0x46, 0xDE, 0x8D, 0xB6, 0x36, 0x17, 0xE6}}
|
||||
iidIAudioClient = windows.GUID{Data1: 0x1CB9AD4C, Data2: 0xDBFA, Data3: 0x4C32, Data4: [8]byte{0xB1, 0x78, 0xC2, 0xF5, 0x68, 0xA7, 0x03, 0xB2}}
|
||||
iidIAudioCaptureClient = windows.GUID{Data1: 0xC8ADBD64, Data2: 0xE71E, Data3: 0x48A0, Data4: [8]byte{0xA4, 0xDE, 0x18, 0x5C, 0x39, 0x5C, 0xD3, 0x17}}
|
||||
)
|
||||
|
||||
const (
|
||||
enumeratorGetDefaultAudioEndpoint = 4
|
||||
)
|
||||
|
||||
const (
|
||||
deviceActivate = 3
|
||||
)
|
||||
|
||||
const (
|
||||
audioClientInitialize = 3
|
||||
audioClientGetBufferSize = 4
|
||||
audioClientGetMixFormat = 8
|
||||
audioClientStart = 10
|
||||
audioClientStop = 11
|
||||
audioClientGetService = 14
|
||||
)
|
||||
|
||||
const (
|
||||
captureGetBuffer = 3
|
||||
captureReleaseBuffer = 4
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// WASAPI 捕获生命周期
|
||||
// ============================================================
|
||||
|
||||
var (
|
||||
audioMu sync.Mutex
|
||||
audioStop chan struct{}
|
||||
audioDone chan struct{}
|
||||
audioActive bool
|
||||
smoothed []float64
|
||||
runningMax float64
|
||||
)
|
||||
|
||||
func startAudioCapture() {
|
||||
audioMu.Lock()
|
||||
defer audioMu.Unlock()
|
||||
if audioActive {
|
||||
return
|
||||
}
|
||||
audioStop = make(chan struct{})
|
||||
audioDone = make(chan struct{})
|
||||
audioActive = true
|
||||
smoothed = nil
|
||||
runningMax = 0
|
||||
go audioCaptureLoop()
|
||||
log.Println("音频捕获: 已启动")
|
||||
}
|
||||
|
||||
func stopAudioCapture() {
|
||||
audioMu.Lock()
|
||||
stop := audioStop
|
||||
audioMu.Unlock()
|
||||
if stop == nil {
|
||||
return
|
||||
}
|
||||
close(stop)
|
||||
if audioDone != nil {
|
||||
<-audioDone
|
||||
}
|
||||
audioMu.Lock()
|
||||
audioActive = false
|
||||
audioStop = nil
|
||||
audioDone = nil
|
||||
audioMu.Unlock()
|
||||
log.Println("音频捕获: 已停止")
|
||||
}
|
||||
|
||||
func audioActiveState() bool {
|
||||
audioMu.Lock()
|
||||
defer audioMu.Unlock()
|
||||
return audioActive
|
||||
}
|
||||
|
||||
func audioCaptureLoop() {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
ole32dll.NewProc("CoInitializeEx").Call(0, 0)
|
||||
defer ole32dll.NewProc("CoUninitialize").Call()
|
||||
|
||||
var enumerator uintptr
|
||||
hr, _, _ := ole32dll.NewProc("CoCreateInstance").Call(
|
||||
uintptr(unsafe.Pointer(&clsidMMDeviceEnumerator)),
|
||||
0, 1,
|
||||
uintptr(unsafe.Pointer(&iidIMMDeviceEnumerator)),
|
||||
uintptr(unsafe.Pointer(&enumerator)),
|
||||
)
|
||||
if hr != 0 || enumerator == 0 {
|
||||
log.Printf("音频捕获: 创建枚举器失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer comRelease(enumerator)
|
||||
|
||||
var device uintptr
|
||||
hr = comCall(enumerator, enumeratorGetDefaultAudioEndpoint, 0, 0, uintptr(unsafe.Pointer(&device)))
|
||||
if hr != 0 || device == 0 {
|
||||
log.Printf("音频捕获: 获取默认音频设备失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer comRelease(device)
|
||||
|
||||
var audioClient uintptr
|
||||
hr = comCall(device, deviceActivate,
|
||||
uintptr(unsafe.Pointer(&iidIAudioClient)),
|
||||
1, 0,
|
||||
uintptr(unsafe.Pointer(&audioClient)),
|
||||
)
|
||||
if hr != 0 || audioClient == 0 {
|
||||
log.Printf("音频捕获: Activate(IAudioClient) 失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer comRelease(audioClient)
|
||||
|
||||
var pwfx uintptr
|
||||
hr = comCall(audioClient, audioClientGetMixFormat, uintptr(unsafe.Pointer(&pwfx)))
|
||||
if hr != 0 || pwfx == 0 {
|
||||
log.Printf("音频捕获: GetMixFormat 失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer ole32dll.NewProc("CoTaskMemFree").Call(pwfx)
|
||||
|
||||
nChannels := *(*uint16)(unsafe.Pointer(pwfx + 2))
|
||||
nSamplesPerSec := *(*uint32)(unsafe.Pointer(pwfx + 4))
|
||||
log.Printf("音频捕获: 格式 ch=%d rate=%d", nChannels, nSamplesPerSec)
|
||||
|
||||
hr = comCall(audioClient, audioClientInitialize,
|
||||
0,
|
||||
0x00020000,
|
||||
10000000,
|
||||
0,
|
||||
pwfx,
|
||||
0,
|
||||
)
|
||||
if hr != 0 {
|
||||
log.Printf("音频捕获: Initialize 失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
|
||||
var captureClient uintptr
|
||||
hr = comCall(audioClient, audioClientGetService,
|
||||
uintptr(unsafe.Pointer(&iidIAudioCaptureClient)),
|
||||
uintptr(unsafe.Pointer(&captureClient)),
|
||||
)
|
||||
if hr != 0 || captureClient == 0 {
|
||||
log.Printf("音频捕获: GetService(IAudioCaptureClient) 失败 hr=0x%x", hr)
|
||||
close(audioDone)
|
||||
return
|
||||
}
|
||||
defer comRelease(captureClient)
|
||||
|
||||
comCall(audioClient, audioClientStart)
|
||||
defer comCall(audioClient, audioClientStop)
|
||||
|
||||
ch := int(nChannels)
|
||||
bufSize := int(nSamplesPerSec)/10*ch + fftSize*ch
|
||||
ringBuf := make([]float32, bufSize)
|
||||
writePos := 0
|
||||
|
||||
cfg := loadConfig()
|
||||
smoothingFactor := cfg.AudioSmoothing
|
||||
if smoothingFactor <= 0 {
|
||||
smoothingFactor = 0.7
|
||||
}
|
||||
|
||||
// 预分配 mono slice
|
||||
mono := make([]float32, fftSize)
|
||||
|
||||
ticker := time.NewTicker(33 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-audioStop:
|
||||
close(audioDone)
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
for {
|
||||
var pData, nFrames, flags uintptr
|
||||
hr = comCall(captureClient, captureGetBuffer,
|
||||
uintptr(unsafe.Pointer(&pData)),
|
||||
uintptr(unsafe.Pointer(&nFrames)),
|
||||
uintptr(unsafe.Pointer(&flags)),
|
||||
0, 0,
|
||||
)
|
||||
if hr != 0 || nFrames == 0 {
|
||||
break
|
||||
}
|
||||
samples := unsafe.Slice((*float32)(unsafe.Pointer(pData)), nFrames*uintptr(ch))
|
||||
for _, s := range samples {
|
||||
ringBuf[writePos] = s
|
||||
writePos = (writePos + 1) % bufSize
|
||||
}
|
||||
comCall(captureClient, captureReleaseBuffer, uintptr(nFrames))
|
||||
}
|
||||
|
||||
for i := 0; i < fftSize; i++ {
|
||||
pos := (writePos - fftSize*ch + i*ch + bufSize) % bufSize
|
||||
var sum float32
|
||||
for c := 0; c < ch; c++ {
|
||||
sum += ringBuf[(pos+c)%bufSize]
|
||||
}
|
||||
mono[i] = sum / float32(ch)
|
||||
}
|
||||
|
||||
mag := fftMagnitude(mono)
|
||||
bins := downsampleBins(mag, targetBins)
|
||||
|
||||
if smoothed != nil {
|
||||
bins = smoothBinsGo(smoothed, bins, smoothingFactor)
|
||||
}
|
||||
smoothed = bins
|
||||
|
||||
// 全局归一化(慢衰减 runningMax)
|
||||
curMax := 0.0
|
||||
for _, v := range bins {
|
||||
if v > curMax {
|
||||
curMax = v
|
||||
}
|
||||
}
|
||||
if curMax > runningMax {
|
||||
runningMax = curMax
|
||||
} else {
|
||||
runningMax *= 0.985
|
||||
}
|
||||
if runningMax > 0.001 {
|
||||
for i := range bins {
|
||||
bins[i] /= runningMax
|
||||
}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(bins)
|
||||
evalJS(fmt.Sprintf(`if(window.updateAudioDataFromGo) updateAudioDataFromGo(%s)`, string(data)))
|
||||
}
|
||||
}
|
||||
|
||||
func comRelease(obj uintptr) {
|
||||
if obj != 0 {
|
||||
comCall(obj, 2)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,15 @@ $gitHash = git rev-parse --short HEAD
|
||||
$version = "$(Get-Date -Format 'yyyyMMdd').$gitHash"
|
||||
Write-Host "=== 构建 $project v$version ===" -ForegroundColor Cyan
|
||||
|
||||
# 构建 updater.exe(先编译,主程序需要 embed)
|
||||
Write-Host "=== 构建 updater ===" -ForegroundColor Cyan
|
||||
Push-Location updater
|
||||
go build -ldflags="-s -w -H windowsgui" -o updater.exe .
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "updater 构建失败" -ForegroundColor Red; exit 1 }
|
||||
Pop-Location
|
||||
Copy-Item "updater/updater.exe" "updater.exe" -Force
|
||||
Write-Host "updater.exe 已复制到根目录" -ForegroundColor Green
|
||||
|
||||
# 构建(隐藏控制台窗口)
|
||||
$env:GOOS = "windows"
|
||||
$env:GOARCH = "amd64"
|
||||
|
||||
22
config.go
22
config.go
@@ -33,6 +33,7 @@ const (
|
||||
ThemeParticle ThemeName = "particles"
|
||||
ThemeFractal ThemeName = "fractal"
|
||||
ThemeText ThemeName = "text"
|
||||
ThemeAudioViz ThemeName = "audio-viz"
|
||||
)
|
||||
|
||||
type SavedColor struct {
|
||||
@@ -71,6 +72,13 @@ type Config struct {
|
||||
QWeatherKey string `json:"qweatherKey,omitempty"`
|
||||
TianapiKey string `json:"tianapiKey,omitempty"`
|
||||
CPAKey string `json:"cpaKey,omitempty"`
|
||||
UpdateURL string `json:"updateUrl,omitempty"`
|
||||
AutoCheckUpdate bool `json:"autoCheckUpdate"`
|
||||
SkipVersion string `json:"skipVersion,omitempty"`
|
||||
AudioVizStyle string `json:"audioVizStyle"`
|
||||
AudioSensitivity float64 `json:"audioSensitivity"`
|
||||
AudioSmoothing float64 `json:"audioSmoothing"`
|
||||
AudioColorScheme string `json:"audioColorScheme"`
|
||||
}
|
||||
|
||||
const defaultZodiac = "射手座"
|
||||
@@ -113,11 +121,15 @@ func loadConfig() *Config {
|
||||
|
||||
func defaultConfig() *Config {
|
||||
return &Config{
|
||||
Zodiac: defaultZodiac,
|
||||
WallpaperType: WPTheme,
|
||||
Theme: ThemeAurora,
|
||||
Color1: "#1a1a2e",
|
||||
Color2: "#16213e",
|
||||
Zodiac: defaultZodiac,
|
||||
WallpaperType: WPTheme,
|
||||
Theme: ThemeAurora,
|
||||
Color1: "#1a1a2e",
|
||||
Color2: "#16213e",
|
||||
AudioVizStyle: "bars",
|
||||
AudioSensitivity: 1.5,
|
||||
AudioSmoothing: 0.7,
|
||||
AudioColorScheme: "neon",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
docs/04-功能迭代/U-DESKTOP-1.自动升级/README.md
Normal file
24
docs/04-功能迭代/U-DESKTOP-1.自动升级/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 自动升级模块
|
||||
|
||||
**模块状态**:待开发
|
||||
**最后更新**:2026-05-28
|
||||
|
||||
---
|
||||
|
||||
## 快速导航
|
||||
|
||||
| 类型 | 文档 | 说明 |
|
||||
|------|------|------|
|
||||
| 规划 | [任务规划.md](./任务规划.md) | 实施步骤和检查清单 |
|
||||
| 设计 | [设计文档/自动升级功能设计.md](./设计文档/自动升级功能设计.md) | 完整功能设计 |
|
||||
| 决策 | [决策记录/](./决策记录/) | 架构和设计决策 |
|
||||
| 问题 | [问题追踪/](./问题追踪/) | 待解决问题 |
|
||||
| 检查 | [核对报告/](./核对报告/) | 实现检查报告 |
|
||||
| 测试 | [测试用例/](./测试用例/) | 测试用例 |
|
||||
|
||||
## 核心设计
|
||||
|
||||
- **弹窗策略**:非强制 → 右下角气泡(稍后 + 立即升级),强制 → 居中弹窗(仅立即升级)
|
||||
- **架构**:主程序检查版本 → 弹窗提示 → 启动 updater.exe → 下载替换重启
|
||||
- **updater.exe**:通用升级器,参数化驱动,可复用于其他产品(u-tabs 等)
|
||||
- **商业化预留**:API 预留 tier/license 字段,升级器不含商业逻辑
|
||||
53
docs/04-功能迭代/U-DESKTOP-1.自动升级/任务规划.md
Normal file
53
docs/04-功能迭代/U-DESKTOP-1.自动升级/任务规划.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 自动升级 — 任务规划
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
|
||||
## 阶段一:版本基础设施
|
||||
|
||||
- [ ] `main.go` 增加版本变量,`-ldflags` 编译注入
|
||||
- [ ] `config.go` 增加 `update_url`、`auto_check_update`、`skip_version` 字段
|
||||
- [ ] `updater.go` 实现版本检查函数 `checkUpdate()`
|
||||
|
||||
## 阶段二:升级弹窗
|
||||
|
||||
- [ ] `web/update.html` 升级弹窗页面(右下角气泡 + 居中弹窗两种模式)
|
||||
- [ ] Go 端创建无边框 WebView2 弹窗窗口
|
||||
- [ ] 弹窗内容渲染:版本号、更新说明、按钮
|
||||
- [ ] 根据 `force` 字段切换弹窗位置和按钮
|
||||
|
||||
## 阶段三:升级器程序
|
||||
|
||||
- [ ] `updater/main.go` 独立程序骨架
|
||||
- [ ] HTTP 下载安装包 + 进度日志
|
||||
- [ ] SHA256 校验
|
||||
- [ ] 备份旧版本
|
||||
- [ ] 替换 + 启动新版本
|
||||
- [ ] 启动失败自动回滚
|
||||
- [ ] 日志写入 `updater.log`
|
||||
|
||||
## 阶段四:主程序集成
|
||||
|
||||
- [ ] 主程序释放 updater.exe 到临时目录
|
||||
- [ ] 构造命令行参数启动 updater
|
||||
- [ ] 主程序退出
|
||||
- [ ] 托盘菜单增加"检查更新"
|
||||
|
||||
## 阶段五:发布流程
|
||||
|
||||
- [ ] 构建脚本同时构建 u-desktop.exe + updater.exe
|
||||
- [ ] publish 技能自动生成 version.json(版本号 + SHA256 + 下载 URL)
|
||||
- [ ] 上传 version.json 和安装包到 c.1216.top
|
||||
|
||||
## 阶段六:验证
|
||||
|
||||
- [ ] 手动模拟版本更新,测试弹窗流程
|
||||
- [ ] 测试 updater 下载 + 替换 + 重启
|
||||
- [ ] 测试回滚机制
|
||||
- [ ] 测试强制升级流程
|
||||
|
||||
## 技术要点
|
||||
|
||||
- 版本号通过 `-ldflags "-X main.version=x.x.x"` 注入
|
||||
- updater.exe 通用设计,参数化驱动,不含业务逻辑
|
||||
- 弹窗用 WebView2 无边框窗口,风格与设置窗口一致
|
||||
- version.json 放 c.1216.top,静态文件
|
||||
17
docs/04-功能迭代/U-DESKTOP-1.自动升级/决策记录/README.md
Normal file
17
docs/04-功能迭代/U-DESKTOP-1.自动升级/决策记录/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 决策记录
|
||||
|
||||
记录自动升级模块的架构和设计决策。
|
||||
|
||||
## 格式
|
||||
|
||||
```
|
||||
ADR-{编号} - {标题}
|
||||
状态:提议 | 已接受 | 已废弃
|
||||
上下文:...
|
||||
决策:...
|
||||
理由:...
|
||||
```
|
||||
|
||||
## 已记录
|
||||
|
||||
(暂无)
|
||||
3
docs/04-功能迭代/U-DESKTOP-1.自动升级/核对报告/README.md
Normal file
3
docs/04-功能迭代/U-DESKTOP-1.自动升级/核对报告/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 核对报告
|
||||
|
||||
实现过程中的检查报告。
|
||||
3
docs/04-功能迭代/U-DESKTOP-1.自动升级/测试用例/README.md
Normal file
3
docs/04-功能迭代/U-DESKTOP-1.自动升级/测试用例/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 测试用例
|
||||
|
||||
自动升级功能的测试用例。
|
||||
278
docs/04-功能迭代/U-DESKTOP-1.自动升级/设计文档/自动升级功能设计.md
Normal file
278
docs/04-功能迭代/U-DESKTOP-1.自动升级/设计文档/自动升级功能设计.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# 自动升级系统 — 实现规格
|
||||
|
||||
> 来源:需求探索会话 2026-05-28
|
||||
> 状态:待开发
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ 远程版本 JSON │ ← 静态文件,放 CDN / OSS
|
||||
│ version.json │
|
||||
└──────┬───────┘
|
||||
│ HTTP GET
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ u-desktop.exe │ ← 主程序
|
||||
│ · 启动时检查版本 │
|
||||
│ · 弹窗提示升级 │
|
||||
│ · 释放 updater │
|
||||
└──────┬───────────┘
|
||||
│ 启动进程
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ updater.exe │ ← 通用升级器,参数化驱动
|
||||
│ · 下载安装包 │
|
||||
│ · SHA256 校验 │
|
||||
│ · 备份旧版本 │
|
||||
│ · 替换 + 启动 │
|
||||
│ · 失败自动回滚 │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 主程序改动
|
||||
|
||||
### 2.1 版本号注入
|
||||
|
||||
`main.go` 增加版本变量,编译时注入:
|
||||
|
||||
```go
|
||||
var version = "dev" // -ldflags "-X main.version=0.6.0"
|
||||
```
|
||||
|
||||
`build` 脚本 / publish 技能同步更新 ldflags。
|
||||
|
||||
### 2.2 版本检查模块
|
||||
|
||||
新增 `updater.go`:
|
||||
|
||||
```
|
||||
func checkUpdate() (*UpdateInfo, error)
|
||||
```
|
||||
|
||||
- 启动时异步调用(不阻塞主流程)
|
||||
- 请求远程 `version.json`
|
||||
- 对比当前 version,返回更新信息或 nil
|
||||
|
||||
远程 version.json 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "0.7.0",
|
||||
"force": false,
|
||||
"release_notes": "· 新增视频壁纸\n· 修复全屏检测偶发失效",
|
||||
"package": {
|
||||
"url": "https://c.1216.top/u-desktop/u-desktop-0.7.0.zip",
|
||||
"size": 8388608,
|
||||
"sha256": "abc123def456..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
version.json URL 存入 config 或硬编码默认值。
|
||||
|
||||
### 2.3 升级弹窗
|
||||
|
||||
用 WebView2 新建一个无边框小窗口,HTML 渲染弹窗内容。
|
||||
|
||||
#### 弹窗策略
|
||||
|
||||
| force 字段 | 位置 | 按钮 |
|
||||
|-----------|------|------|
|
||||
| `false` | 右下角,托盘图标上方 | [稍后] [立即升级] |
|
||||
| `true` | 屏幕居中 | [立即升级](仅一个按钮) |
|
||||
|
||||
#### 弹窗 HTML 结构
|
||||
|
||||
右下角气泡(非强制):
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ 发现新版本 v0.7.0 │
|
||||
│ │
|
||||
│ · 新增视频壁纸 │
|
||||
│ · 修复全屏检测偶发失效 │
|
||||
│ │
|
||||
│ [稍后] [立即升级] │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
居中弹窗(强制):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ │
|
||||
│ 发现新版本 v0.7.0 │
|
||||
│ │
|
||||
│ · 新增视频壁纸 │
|
||||
│ · 修复全屏检测偶发失效 │
|
||||
│ │
|
||||
│ 大小: 8MB | 预计用时: ~10秒 │
|
||||
│ │
|
||||
│ [立即升级] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 弹窗窗口参数
|
||||
|
||||
| 参数 | 右下角 | 居中 |
|
||||
|------|--------|------|
|
||||
| 宽度 | 340px | 420px |
|
||||
| 高度 | auto(内容自适应) | auto |
|
||||
| 位置 | 屏幕右下角 - 边距 20px | 屏幕居中 |
|
||||
| 样式 | 无边框,圆角,毛玻璃 | 无边框,圆角,毛玻璃 |
|
||||
| 置顶 | TOPMOST | TOPMOST |
|
||||
|
||||
### 2.4 用户点击"立即升级"
|
||||
|
||||
1. 从自身嵌入资源释放 `updater.exe` 到临时目录(或主程序同级目录)
|
||||
2. 构造命令行参数启动 updater.exe
|
||||
3. 主程序退出
|
||||
|
||||
```go
|
||||
exec.Command(tmpUpdaterPath,
|
||||
"--package-url", info.Package.URL,
|
||||
"--sha256", info.Package.SHA256,
|
||||
"--target", selfExePath,
|
||||
"--version", info.Version,
|
||||
"--force", strconv.FormatBool(info.Force),
|
||||
).Start()
|
||||
// 主程序退出
|
||||
```
|
||||
|
||||
### 2.5 托盘菜单
|
||||
|
||||
新增菜单项:"检查更新",手动触发版本检查。
|
||||
|
||||
---
|
||||
|
||||
## 3. updater.exe
|
||||
|
||||
### 3.1 职责
|
||||
|
||||
通用升级器,不含业务逻辑,纯参数驱动。可复用于其他产品(u-tabs 等)。
|
||||
|
||||
### 3.2 命令行参数
|
||||
|
||||
```bash
|
||||
updater.exe \
|
||||
--package-url <下载地址> \
|
||||
--sha256 <SHA256校验值> \
|
||||
--target <目标exe路径> \
|
||||
--version <目标版本号> \
|
||||
--force \
|
||||
--backup-dir <备份目录,默认 target 同级 backup/>
|
||||
```
|
||||
|
||||
### 3.3 流程
|
||||
|
||||
```
|
||||
1. 显示进度窗口(WebView2 或控制台)
|
||||
2. HTTP GET 下载 package-url → 临时文件
|
||||
- 显示下载进度(已下载/总大小)
|
||||
- 支持断点续传(可选,初期不做)
|
||||
3. 计算下载文件 SHA256,与参数比对
|
||||
- 不匹配 → 报错退出
|
||||
4. 备份当前 target → backup-dir/old-{timestamp}.exe
|
||||
5. 如果是 zip 包:解压后替换
|
||||
如果是 exe:直接覆盖
|
||||
6. 启动新版本 target
|
||||
7. updater 退出
|
||||
|
||||
异常处理:
|
||||
- 下载失败 → 重试 3 次,间隔 5s
|
||||
- 校验失败 → 报错,不替换
|
||||
- 启动新版本后监听 5s,新进程退出则回滚
|
||||
- 回滚:将 backup 中的旧 exe 还原
|
||||
```
|
||||
|
||||
### 3.4 进度显示
|
||||
|
||||
初期:无 UI,日志写 `updater.log`(`-H windowsgui` 隐藏控制台)。
|
||||
后续:可加 WebView2 进度窗口。
|
||||
|
||||
### 3.5 日志
|
||||
|
||||
写入 `updater.log`(与 target 同目录),包含时间戳、步骤、错误信息。
|
||||
|
||||
---
|
||||
|
||||
## 4. 构建 & 发布流程
|
||||
|
||||
### 4.1 主程序构建
|
||||
|
||||
```bash
|
||||
go build -ldflags "-H windowsgui -X main.version=0.7.0" -o u-desktop.exe
|
||||
```
|
||||
|
||||
### 4.2 updater 构建
|
||||
|
||||
独立 main 包,单独目录(如 `updater/`):
|
||||
|
||||
```bash
|
||||
cd updater && go build -ldflags "-H windowsgui" -o updater.exe
|
||||
```
|
||||
|
||||
### 4.3 发布
|
||||
|
||||
1. 构建 `u-desktop.exe` + `updater.exe`
|
||||
2. UPX 压缩(可选)
|
||||
3. 计算 SHA256
|
||||
4. 上传到 `c.1216.top`
|
||||
5. 更新远程 `version.json`
|
||||
6. 更新 publish 技能(自动生成 version.json + 上传)
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置
|
||||
|
||||
`config.go` 新增字段:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
// ... 现有字段
|
||||
UpdateURL string `json:"update_url"` // 版本检查 URL,默认 https://c.1216.top/u-desktop/version.json
|
||||
AutoCheckUpdate bool `json:"auto_check_update"` // 启动时自动检查,默认 true
|
||||
SkipVersion string `json:"skip_version"` // 用户跳过的版本号
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 目录结构
|
||||
|
||||
```
|
||||
u-desktop/
|
||||
├── main.go # 增加版本变量
|
||||
├── updater.go # 新增:版本检查 + 弹窗逻辑
|
||||
├── config.go # 增加 update 相关字段
|
||||
├── systray.go # 托盘菜单增加"检查更新"
|
||||
├── web/
|
||||
│ ├── update.html # 新增:升级弹窗 HTML
|
||||
│ └── ...
|
||||
├── updater/ # 新增:升级器独立目录
|
||||
│ └── main.go
|
||||
├── docs/
|
||||
│ ├── feature-roadmap.md
|
||||
│ └── auto-update-spec.md # 本文档
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施顺序
|
||||
|
||||
| 步骤 | 内容 | 依赖 |
|
||||
|------|------|------|
|
||||
| 1 | `main.go` 加版本变量,构建脚本加 ldflags | 无 |
|
||||
| 2 | `updater.go` 版本检查函数 + config 字段 | 步骤 1 |
|
||||
| 3 | 远程 `version.json` 手动创建 + 上传 | 步骤 2 |
|
||||
| 4 | 升级弹窗 `update.html` + Go 端窗口创建 | 步骤 2 |
|
||||
| 5 | 主程序释放 + 启动 updater.exe | 步骤 4 |
|
||||
| 6 | `updater/` 目录,下载 + 校验 + 替换 + 回滚 | 步骤 5 |
|
||||
| 7 | 托盘菜单"检查更新" | 步骤 2 |
|
||||
| 8 | publish 技能集成 version.json 生成 | 步骤 3 |
|
||||
10
docs/04-功能迭代/U-DESKTOP-1.自动升级/问题追踪/README.md
Normal file
10
docs/04-功能迭代/U-DESKTOP-1.自动升级/问题追踪/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 问题追踪
|
||||
|
||||
## 目录
|
||||
|
||||
- `待讨论/` — 需要讨论后决定的问题
|
||||
- `待实现/` — 已确定但未实现的功能点
|
||||
|
||||
## 当前问题
|
||||
|
||||
(暂无)
|
||||
38
docs/04-功能迭代/U-DESKTOP-2.便签待办卡片/需求契约.md
Normal file
38
docs/04-功能迭代/U-DESKTOP-2.便签待办卡片/需求契约.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 便签/待办卡片 — 需求契约
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
> 状态:需求待确认
|
||||
|
||||
## 目标
|
||||
|
||||
桌面显示轻量 TODO 清单,数据持久化。
|
||||
|
||||
## 用户场景
|
||||
|
||||
1. 用户在设置页添加待办事项
|
||||
2. 桌面卡片显示待办列表(勾选框 + 文字)
|
||||
3. 点击勾选完成,已完成的显示删除线
|
||||
4. 数据本地持久化
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 添加/删除待办事项
|
||||
- [ ] 勾选完成/取消完成
|
||||
- [ ] 最多显示 N 条(如 10 条),超出滚动
|
||||
- [ ] 数据持久化到本地 JSON
|
||||
|
||||
## 本次范围
|
||||
|
||||
- 基础 TODO 列表
|
||||
|
||||
## 非目标
|
||||
|
||||
- 分组/标签/优先级
|
||||
- 截止日期提醒
|
||||
- 同步到云端
|
||||
|
||||
## 约束
|
||||
|
||||
- 前端组件 + config JSON 持久化
|
||||
- 复用卡片架构
|
||||
- 设置页提供添加/删除/编辑入口
|
||||
38
docs/04-功能迭代/U-DESKTOP-2.壁纸播放列表/需求契约.md
Normal file
38
docs/04-功能迭代/U-DESKTOP-2.壁纸播放列表/需求契约.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 壁纸播放列表 — 需求契约
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
> 状态:需求待确认
|
||||
|
||||
## 目标
|
||||
|
||||
用户将 Bing 收藏 + 本地图片 + 视频组成播放列表,定时轮播。
|
||||
|
||||
## 用户场景
|
||||
|
||||
1. 用户在设置页勾选多个壁纸加入播放列表
|
||||
2. 设置轮播间隔(5 分钟 / 10 分钟 / 30 分钟 / 1 小时)
|
||||
3. 壁纸按顺序或随机切换,淡入淡出过渡
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 播放列表支持图片 + 视频 + Bing 壁纸
|
||||
- [ ] 可配置轮播间隔
|
||||
- [ ] 顺序 / 随机模式
|
||||
- [ ] 切换时淡入淡出过渡
|
||||
- [ ] 播放列表持久化
|
||||
|
||||
## 本次范围
|
||||
|
||||
- 基础播放列表 + 定时轮播
|
||||
|
||||
## 非目标
|
||||
|
||||
- 按时间段分配不同列表
|
||||
- 按星期几分配不同列表
|
||||
- 在线同步播放列表
|
||||
|
||||
## 约束
|
||||
|
||||
- 复用现有壁纸切换逻辑
|
||||
- config 新增 playlist 数组 + interval + shuffle 字段
|
||||
- Go 端定时器驱动切换
|
||||
45
docs/04-功能迭代/U-DESKTOP-2.壁纸颜色提取/需求契约.md
Normal file
45
docs/04-功能迭代/U-DESKTOP-2.壁纸颜色提取/需求契约.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 壁纸颜色提取 + 卡片自适应 — 需求契约
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
> 状态:需求已确认
|
||||
|
||||
## 目标
|
||||
|
||||
从当前壁纸自动提取主色调,动态调整信息卡片的文字颜色和背景,确保在任何壁纸上都可读。
|
||||
|
||||
## 用户场景
|
||||
|
||||
1. 用户切换壁纸后,卡片文字颜色自动适配(深色壁纸白字、浅色壁纸黑字)
|
||||
2. 卡片背景增加半透明遮罩,提升文字可读性
|
||||
3. 用户无需手动调色,全程自动
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 壁纸切换后 1s 内完成颜色提取
|
||||
- [ ] 文字颜色自动切换:深底白字 / 浅底黑字
|
||||
- [ ] 卡片背景增加自适应半透明遮罩
|
||||
- [ ] 支持所有壁纸类型(主题/图片/Bing/视频/颜色)
|
||||
- [ ] 提取结果缓存,同一壁纸不重复计算
|
||||
|
||||
## 本次范围
|
||||
|
||||
- 壁纸主色调提取
|
||||
- 卡片文字颜色自适应
|
||||
- 卡片背景遮罩
|
||||
|
||||
## 非目标
|
||||
|
||||
- 完整调色板提取(只取主色调)
|
||||
- 用户手动覆盖颜色
|
||||
|
||||
## 约束
|
||||
|
||||
- 前端 Canvas 采样,不依赖 Go 端图像处理
|
||||
- 采样频率:壁纸切换时触发一次
|
||||
- 不影响壁纸渲染性能
|
||||
|
||||
## 技术方案
|
||||
|
||||
- 壁纸切换后,Canvas 绘制壁纸帧 → 采样若干像素点 → 取平均值 → 判断明暗
|
||||
- 前端状态管理增加 `isDarkWallpaper` 变量
|
||||
- 卡片组件根据 `isDarkWallpaper` 切换 CSS class
|
||||
47
docs/04-功能迭代/U-DESKTOP-2.时间段壁纸/需求契约.md
Normal file
47
docs/04-功能迭代/U-DESKTOP-2.时间段壁纸/需求契约.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 时间段动态壁纸 — 需求契约
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
> 状态:需求已确认
|
||||
|
||||
## 目标
|
||||
|
||||
根据用户地理位置的日出日落时间,自动切换壁纸主题(白天明亮主题,夜晚深色主题)。
|
||||
|
||||
## 用户场景
|
||||
|
||||
1. 白天自动使用 gradient/aurora 等明亮主题
|
||||
2. 日落后自动切换为 starfield/fractal 等深色主题
|
||||
3. 用户可自定义白天/夜晚对应的主题
|
||||
4. 切换过程平滑过渡,无突兀跳变
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 根据天气 API 已有的经纬度数据计算日出日落时间
|
||||
- [ ] 日出后切换到"白天主题",日落后切换到"夜晚主题"
|
||||
- [ ] 默认配对:白天 gradient / 夜晚 starfield
|
||||
- [ ] 用户可在设置页自定义白天/夜晚主题
|
||||
- [ ] 主题切换有淡入淡出过渡效果
|
||||
- [ ] 无网络时使用固定时间(6:00 日出 / 18:00 日落)
|
||||
|
||||
## 本次范围
|
||||
|
||||
- 基于日出日落的两种主题自动切换
|
||||
|
||||
## 非目标
|
||||
|
||||
- 多时段(上午/下午/傍晚/深夜)细分
|
||||
- 基于天气的主题切换(晴天/阴天)
|
||||
- 渐变过渡动画(日出/日落期间缓慢变化壁纸色调)
|
||||
|
||||
## 约束
|
||||
|
||||
- 复用天气 API 已有的城市经纬度,不新增 API 调用
|
||||
- 日出日落算法用前端 JS 计算(SunCalc 库或简单公式)
|
||||
- config 新增 `dayTheme`、`nightTheme`、`autoTheme bool` 字段
|
||||
|
||||
## 风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| 天气城市未设置 | 降级为固定时间(6:00/18:00) |
|
||||
| 用户关闭天气卡片 | 经纬度使用上次缓存值 |
|
||||
37
docs/04-功能迭代/U-DESKTOP-2.电池感知/需求契约.md
Normal file
37
docs/04-功能迭代/U-DESKTOP-2.电池感知/需求契约.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 电池感知 — 需求契约
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
> 状态:需求待确认
|
||||
|
||||
## 目标
|
||||
|
||||
笔记本拔电时自动暂停动画壁纸,省电。插电后恢复。
|
||||
|
||||
## 用户场景
|
||||
|
||||
1. 用户拔掉电源线 → 动画壁纸暂停,显示静态帧
|
||||
2. 重新插电 → 壁纸恢复动画
|
||||
3. 设置页可开关此功能
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 检测电源状态变化(`GetSystemPowerStatus`)
|
||||
- [ ] 拔电暂停动画壁纸
|
||||
- [ ] 插电恢复动画
|
||||
- [ ] 桌面右下角托盘气泡提示"已切换为省电模式"
|
||||
- [ ] 静态壁纸(图片/纯色)不受影响
|
||||
|
||||
## 本次范围
|
||||
|
||||
- 拔电暂停 / 插电恢复
|
||||
|
||||
## 非目标
|
||||
|
||||
- 用户自定义省电策略(如暂停哪些卡片)
|
||||
- 电量低于 X% 自动暂停
|
||||
|
||||
## 约束
|
||||
|
||||
- Go 端 `GetSystemPowerStatus` 轮询(每 10 秒)
|
||||
- evalJS 调用前端 pause/resume
|
||||
- config 新增 `batteryAware bool`
|
||||
51
docs/04-功能迭代/U-DESKTOP-2.番茄钟与倒计时/需求契约.md
Normal file
51
docs/04-功能迭代/U-DESKTOP-2.番茄钟与倒计时/需求契约.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 番茄钟 + 自定义倒计时卡片 — 需求契约
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
> 状态:需求已确认
|
||||
|
||||
## 目标
|
||||
|
||||
新增两种效率类卡片:番茄钟(25/5 循环倒计时)和自定义倒计时(用户设定目标日期)。
|
||||
|
||||
## 用户场景
|
||||
|
||||
### 番茄钟
|
||||
|
||||
1. 桌面显示番茄钟进度环,25 分钟工作倒计时
|
||||
2. 倒计时结束,进度环变色提示休息(5 分钟)
|
||||
3. 显示今日完成番茄数
|
||||
4. 设置页可开关番茄钟卡片
|
||||
|
||||
### 自定义倒计时
|
||||
|
||||
1. 用户在设置页添加倒计时事件(名称 + 日期)
|
||||
2. 桌面显示"距离 XX 还有 N 天"
|
||||
3. 支持多个倒计时事件
|
||||
4. 过期事件显示"已过去 N 天"或自动归档
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 番茄钟:25 分钟工作 / 5 分钟休息自动循环
|
||||
- [ ] 番茄钟:进度环可视化
|
||||
- [ ] 番番钟:今日完成数显示
|
||||
- [ ] 倒计时:支持添加/删除事件
|
||||
- [ ] 倒计时:显示剩余天数
|
||||
- [ ] 数据持久化,重启后恢复
|
||||
|
||||
## 本次范围
|
||||
|
||||
- 番茄钟卡片(基础 25/5 循环)
|
||||
- 自定义倒计时卡片(多事件)
|
||||
|
||||
## 非目标
|
||||
|
||||
- 番茄钟声音提醒(仅视觉)
|
||||
- 番茄钟统计报表(日/周/月)
|
||||
- 倒计时精确到小时/分钟(只显示天数)
|
||||
|
||||
## 约束
|
||||
|
||||
- 复用现有卡片架构(Vue 组件 + Go 数据推送)
|
||||
- 番茄钟状态前端本地管理(localStorage),不需要 Go 端计时
|
||||
- 倒计时事件存储在 config JSON 中
|
||||
- 布局:单栏模式追加在底部,多栏模式适配现有 grid
|
||||
38
docs/04-功能迭代/U-DESKTOP-2.系统监控卡片/需求契约.md
Normal file
38
docs/04-功能迭代/U-DESKTOP-2.系统监控卡片/需求契约.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 系统监控卡片 — 需求契约
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
> 状态:需求待确认
|
||||
|
||||
## 目标
|
||||
|
||||
桌面显示 CPU、内存、网速等系统指标的实时数据。
|
||||
|
||||
## 用户场景
|
||||
|
||||
1. 桌面卡片显示 CPU 使用率、内存占用、上传/下载网速
|
||||
2. 数据每秒刷新
|
||||
3. 紧凑数字 + 进度条风格,不占太多空间
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] CPU 使用率(百分比 + 进度条)
|
||||
- [ ] 内存使用率(已用/总量 + 百分比)
|
||||
- [ ] 网速(上传/下载,自动切换单位 KB/s MB/s)
|
||||
- [ ] 1 秒刷新间隔
|
||||
- [ ] Go 端 gopsutil 采集
|
||||
|
||||
## 本次范围
|
||||
|
||||
- CPU / 内存 / 网速 三个指标
|
||||
|
||||
## 非目标
|
||||
|
||||
- 磁盘 I/O
|
||||
- GPU 温度/使用率
|
||||
- 历史曲线图
|
||||
|
||||
## 约束
|
||||
|
||||
- Go 端 `github.com/shirou/gopsutil/v3` 采集
|
||||
- evalJS 推送到前端,1s 间隔
|
||||
- 卡片样式紧凑,不抢视觉焦点
|
||||
45
docs/04-功能迭代/U-DESKTOP-2.网页壁纸/需求契约.md
Normal file
45
docs/04-功能迭代/U-DESKTOP-2.网页壁纸/需求契约.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 网页壁纸 — 需求契约
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
> 状态:需求待确认
|
||||
|
||||
## 目标
|
||||
|
||||
用户输入 URL,WebView2 直接渲染任意网页作为桌面壁纸。
|
||||
|
||||
## 用户场景
|
||||
|
||||
1. 用户在设置页输入网址(如 Shadertoy、CodePen、任意网页)
|
||||
2. 桌面壁纸变为该网页的实时渲染
|
||||
3. 支持交互式网页(鼠标穿透可选开关)
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 支持输入任意 HTTPS URL
|
||||
- [ ] 网页实时渲染为壁纸
|
||||
- [ ] URL 持久化,重启恢复
|
||||
- [ ] 网页加载失败时显示错误提示
|
||||
|
||||
## 本次范围
|
||||
|
||||
- 单个 URL 渲染为壁纸
|
||||
|
||||
## 非目标
|
||||
|
||||
- 网页收藏列表
|
||||
- 网页交互(穿透模式下无法点击)
|
||||
- 本地 HTML 文件
|
||||
|
||||
## 约束
|
||||
|
||||
- 复用 WebView2 实例,替换壁纸 HTML 为 `<iframe src="url">`
|
||||
- iframe 需考虑跨域限制
|
||||
- 安全:禁止加载本地文件路径(file:///)
|
||||
|
||||
## 风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| 部分网站禁止 iframe 嵌入 | 提示用户该网站不支持 |
|
||||
| 网页 JS 报错影响壁纸 | sandbox 属性隔离 |
|
||||
| 网页持续消耗资源 | 全屏暂停复用 |
|
||||
59
docs/04-功能迭代/U-DESKTOP-2.视频壁纸/需求契约.md
Normal file
59
docs/04-功能迭代/U-DESKTOP-2.视频壁纸/需求契约.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 视频壁纸 — 需求契约
|
||||
|
||||
> 创建时间:2026-05-28
|
||||
> 状态:需求已确认
|
||||
|
||||
## 目标
|
||||
|
||||
支持将视频文件(mp4/WebM/GIF)设为桌面壁纸,WebView2 `<video>` 标签播放。
|
||||
|
||||
## 用户场景
|
||||
|
||||
1. 用户在设置页选择"视频壁纸",弹出文件选择对话框(过滤 mp4/WebM/GIF)
|
||||
2. 选中视频后,桌面壁纸立即切换为视频循环播放
|
||||
3. 视频静音播放,不干扰用户工作
|
||||
4. 全屏应用(游戏/视频播放器)时自动暂停,退出全屏后恢复
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 支持 mp4、WebM、GIF 格式
|
||||
- [ ] 视频循环播放,无黑帧闪烁
|
||||
- [ ] 默认静音,设置页可选开启声音
|
||||
- [ ] 全屏应用时暂停视频,退出恢复
|
||||
- [ ] 视频文件路径持久化,重启后恢复
|
||||
- [ ] 视频填充方式:cover(默认),不拉伸变形
|
||||
- [ ] 设置页可预览当前视频壁纸
|
||||
|
||||
## 本次范围
|
||||
|
||||
- 本地视频文件作为壁纸
|
||||
- 视频播放控制(静音/循环/暂停恢复)
|
||||
|
||||
## 非目标
|
||||
|
||||
- 在线视频 URL 播放(属于"网页壁纸"方向)
|
||||
- 视频编辑/裁剪
|
||||
- 多显示器不同视频
|
||||
|
||||
## 约束
|
||||
|
||||
- WebView2 `<video>` 原生能力,不引入额外播放器库
|
||||
- 视频文件不嵌入 exe,用户自行管理文件路径
|
||||
- 大视频文件(>500MB)需考虑内存占用,不做完整 dataURI 转换,用文件 URL
|
||||
- 复用现有壁纸类型切换架构(`saveWallpaperType`)
|
||||
|
||||
## 风险
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| 大视频内存占用 | 不转 dataURI,用 `file:///` 协议加载 |
|
||||
| WebView2 视频解码兼容性 | 测试 H.264/VP9 编码,不支持的格式提示用户 |
|
||||
| 视频切换时闪烁 | 预加载下一帧,CSS transition 淡入 |
|
||||
|
||||
## 技术方案
|
||||
|
||||
- `wallpaper.go` 新增壁纸类型 `video`
|
||||
- 视频 HTML 模板:全屏 `<video>` 标签,autoplay/loop/muted/cover
|
||||
- `config.go` 新增 `VideoPath string` 字段
|
||||
- `settings.go` 新增 `pickVideoFile` 函数(复用文件对话框,过滤视频格式)
|
||||
- 前端设置页 WallpaperSection 新增"视频壁纸"选项
|
||||
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 |
|
||||
141
docs/feature-roadmap.md
Normal file
141
docs/feature-roadmap.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# u-desktop 功能调研 & 路线图
|
||||
|
||||
> 调研日期:2026-05-28
|
||||
|
||||
---
|
||||
|
||||
## 现有能力
|
||||
|
||||
| 分类 | 功能 |
|
||||
|------|------|
|
||||
| 壁纸 | 6 种动态主题(aurora/starfield/gradient/particles/fractal/text)、本地图片、Bing 每日壁纸(80 张历史+收藏+自动轮播)、纯色/渐变 |
|
||||
| 卡片 | 时钟(秒+节日倒计时+整点动画)、天气(24h+7d)、星座运势(5 维+幸运标签)、AI 知识(GLM-4.5 生成+SQLite 缓存)、AI 新闻、电子相册 |
|
||||
| 布局 | 单栏/多栏切换、相框全屏模式 |
|
||||
| 系统 | 托盘图标、开机启动、单实例、DPI 适配、全屏暂停、WorkerW 重建恢复 |
|
||||
| 技术 | Go + WebView2 + Vue3 + Tailwind + Vite,单文件嵌入 |
|
||||
|
||||
---
|
||||
|
||||
## 可扩展方向
|
||||
|
||||
### P1 — 低成本高收益(1-3 天)
|
||||
|
||||
| # | 方向 | 说明 | 参考产品 |
|
||||
|---|------|------|----------|
|
||||
| 1 | **视频壁纸** | mp4/WebM/GIF 作为壁纸,WebView2 `<video>` 原生播放 | Lively / Wallpaper Engine 核心功能 |
|
||||
| 2 | **时间段动态壁纸** | 根据日出日落或时间段自动切换壁纸主题(白天明亮,夜晚深色) | WinDynamicDesktop |
|
||||
| 3 | **壁纸播放列表** | Bing 收藏 + 本地图片组成列表,定时轮播 | Wallpaper Engine playlist |
|
||||
| 4 | **电池感知** | 笔记本拔电时暂停动画壁纸,省电 | Lively / Wallpaper Engine |
|
||||
| 5 | **番茄钟卡片** | 倒计时 + 休息提醒 | Rainmeter 常见皮肤 |
|
||||
| 6 | **自定义倒计时卡片** | 用户设定日期(考试/生日/纪念日) | 已有节日倒计时基础可复用 |
|
||||
|
||||
### P2 — 中等投入(3-7 天)
|
||||
|
||||
| # | 方向 | 说明 | 参考产品 |
|
||||
|---|------|------|----------|
|
||||
| 7 | **网页壁纸** | 用户输入 URL,WebView2 直接渲染任意网页 | Lively webpage 壁纸 |
|
||||
| 8 | **壁纸颜色提取** | 从当前壁纸提取主色调,自动适配卡片文字颜色(深底白字/浅底黑字) | 提升可读性 |
|
||||
| 9 | **音频可视化壁纸** | 捕获系统音频,驱动粒子/波形动画 | Lively API |
|
||||
| 10 | **多显示器支持** | 不同显示器设置不同壁纸 | 所有主流工具标配 |
|
||||
| 11 | **RGB 灯效同步** | 取壁纸主色调,Corsair iCUE / Razer Chroma SDK 同步外设灯效 | Wallpaper Engine |
|
||||
| 12 | **便签/待办卡片** | 轻量 TODO 显示在桌面,数据持久化 | Rainmeter 高需求皮肤 |
|
||||
| 13 | **系统监控卡片** | CPU/GPU/RAM/网速实时曲线 | Rainmeter 经典皮肤 |
|
||||
|
||||
### P3 — 高投入高价值(1-3 周)
|
||||
|
||||
| # | 方向 | 说明 | 参考产品 |
|
||||
|---|------|------|----------|
|
||||
| 14 | **AI 壁纸生成** | 接入 SD / DALL-E / GLM-4V,文字描述直接生成壁纸 | DeskScapes Clairvoyance |
|
||||
| 15 | **壁纸社区/分享** | 用户上传壁纸,在线浏览订阅,内容生态 | Wallpaper Engine Workshop(百万级) |
|
||||
| 16 | **壁纸编辑器** | 可视化编辑粒子、动画、图层,创建动态壁纸 | Wallpaper Engine Editor |
|
||||
| 17 | **跨设备同步** | PC 壁纸推送到手机 | Wallpaper Engine Android |
|
||||
| 18 | **插件/脚本系统** | JS/TS 编写自定义壁纸和卡片 | Lively API / SceneScript |
|
||||
|
||||
---
|
||||
|
||||
## 竞品参考
|
||||
|
||||
| 产品 | 定位 | 核心差异化 | 价格 |
|
||||
|------|------|-----------|------|
|
||||
| Wallpaper Engine | 壁纸引擎标杆 | 百万社区壁纸 + 编辑器 + RGB 同步 | $3.99 (Steam) |
|
||||
| Lively Wallpaper | 开源免费 | ML 深度视差 + CLI + 开发者 API | 免费 |
|
||||
| DeskScapes 11 | 商业软件 | 120+ 特效滤镜 + DreamMaker 编辑器 | $9.99 |
|
||||
| WinDynamicDesktop | macOS 移植 | 日出日落动态壁纸 + PowerShell 脚本 | 免费 |
|
||||
| Rainmeter | 桌面挂件 | 硬件监控 + 音频可视化 + 皮肤生态 | 免费 |
|
||||
|
||||
---
|
||||
|
||||
## 自动升级体系
|
||||
|
||||
> 已分流独立开发,详细文档:[04-功能迭代/U-DESKTOP-1.自动升级/](./04-功能迭代/U-DESKTOP-1.自动升级/README.md)
|
||||
|
||||
## AI 深度视差壁纸
|
||||
|
||||
> 状态:需求已确认,待详细设计
|
||||
|
||||
### 架构(远程推理方案)
|
||||
|
||||
```
|
||||
用户选图片 → 计算 SHA256 → 检查本地缓存
|
||||
→ 无缓存:POST /api/depth(图片压缩上传 ~200KB)
|
||||
→ 服务端 Depth-Anything 推理(GPU ~0.5s)
|
||||
→ 返回深度图(灰度 PNG ~50KB)
|
||||
→ 客户端缓存到 ~/.u-desktop/depth/{sha256}.png
|
||||
→ WebGL shader 渲染视差(鼠标位置 + 深度图 = 像素偏移,60fps)
|
||||
```
|
||||
|
||||
### 优势(相比本地 WASM 推理)
|
||||
|
||||
- 客户端不增加体积(模型 30-50MB 放服务端)
|
||||
- 服务端 GPU 推理 ~0.5s,用户 CPU 本地 ~3-5s
|
||||
- 可用更大更精确的模型
|
||||
- 深度图按图片 SHA256 缓存,同一张图只推理一次
|
||||
|
||||
### 服务端
|
||||
|
||||
- 接口:`POST /api/v1/depth`,multipart 图片 → 返回深度图 base64
|
||||
- 技术栈:Python FastAPI + Depth-Anything-small,Docker 部署
|
||||
- 部署位置:复用 cpa.1216.top 基础设施
|
||||
|
||||
### 客户端改动
|
||||
|
||||
| 模块 | 改动 |
|
||||
|------|------|
|
||||
| `depth.go`(新增) | 上传图片 → 接收深度图 → 本地缓存 |
|
||||
| 壁纸渲染 | 新增 WebGL shader 主题,接收原图 + 深度图 |
|
||||
| 鼠标位置 | Go 全局钩子 `SetWindowsHookEx` → evalJS 注入 |
|
||||
| 设置页 | "3D 视差" 开关 |
|
||||
|
||||
### 应用场景
|
||||
|
||||
- 风景壁纸(山川湖海,前景浮动背景不动)
|
||||
- 人物/宠物写真(主体突出,背景位移)
|
||||
- 动漫/插画(分层明显,天然适合视差)
|
||||
|
||||
### 商业化空间
|
||||
|
||||
| 层级 | 免费 | Pro |
|
||||
|------|------|-----|
|
||||
| 每日推理次数 | 5 次/天 | 无限 |
|
||||
| 模型 | Depth-Anything-small | Depth-Anything-large |
|
||||
| 深度图分辨率 | 512px | 原图分辨率 |
|
||||
|
||||
---
|
||||
|
||||
## 建议实施顺序
|
||||
|
||||
| # | 方向 | 需求契约 | 状态 |
|
||||
|---|------|---------|------|
|
||||
| 1 | **视频壁纸** | [需求契约](./04-功能迭代/U-DESKTOP-2.视频壁纸/需求契约.md) | 已确认,待实现 |
|
||||
| 2 | **壁纸颜色提取** | [需求契约](./04-功能迭代/U-DESKTOP-2.壁纸颜色提取/需求契约.md) | 已确认,待实现 |
|
||||
| 3 | **时间段壁纸** | [需求契约](./04-功能迭代/U-DESKTOP-2.时间段壁纸/需求契约.md) | 已确认,待实现 |
|
||||
| 4 | **番茄钟 + 倒计时** | [需求契约](./04-功能迭代/U-DESKTOP-2.番茄钟与倒计时/需求契约.md) | 已确认,待实现 |
|
||||
| 5 | **壁纸播放列表** | [需求契约](./04-功能迭代/U-DESKTOP-2.壁纸播放列表/需求契约.md) | 待确认 |
|
||||
| 6 | **电池感知** | [需求契约](./04-功能迭代/U-DESKTOP-2.电池感知/需求契约.md) | 待确认 |
|
||||
| 7 | **网页壁纸** | [需求契约](./04-功能迭代/U-DESKTOP-2.网页壁纸/需求契约.md) | 待确认 |
|
||||
| 8 | **便签/待办** | [需求契约](./04-功能迭代/U-DESKTOP-2.便签待办卡片/需求契约.md) | 待确认 |
|
||||
| 9 | **系统监控** | [需求契约](./04-功能迭代/U-DESKTOP-2.系统监控卡片/需求契约.md) | 待确认 |
|
||||
| 10 | **AI 深度视差** | 已在 roadmap 内 | 待设计 |
|
||||
| 11 | **AI 壁纸生成** | — | 待讨论 |
|
||||
| 12 | **音频可视化** | 已分流实现 | 开发中 |
|
||||
| 13 | **自动升级** | [独立模块](./04-功能迭代/U-DESKTOP-1.自动升级/README.md) | 开发中 |
|
||||
@@ -219,3 +219,47 @@ go build -o u-desktop.exe .
|
||||
3. Modal 在壁纸层渲染,用户看不到也点不到
|
||||
|
||||
**修复**: 星座设置改为托盘子菜单直接选择(`AddSubMenuItem`),不依赖 WebView 交互。城市选择通过点击天气文字触发,因壁纸层可接收鼠标事件(WorkerW 的子窗口不在图标层之上但仍可点击)。
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-28 追加
|
||||
|
||||
### Windows 桌面周期性重置壁纸
|
||||
|
||||
**症状**: 程序运行一段时间后,壁纸和卡片消失,被系统默认壁纸覆盖。
|
||||
|
||||
**根因**: Windows 桌面窗口管理器(DWM)会周期性重建 WorkerW,导致 WebView2 的 SetParent 关系断裂。
|
||||
|
||||
**修复**: 在 `fullscreenMonitor` 中每 10 秒检测 `GetParent(wvHwnd)`,父窗口变化时自动重新嵌入新 WorkerW。
|
||||
|
||||
```go
|
||||
parent, _, _ := procGetParent.Call(wvHwnd)
|
||||
if parent != lastParent {
|
||||
ww := findWorkerW()
|
||||
if ww != 0 && ww != parent {
|
||||
embedWorkerW(ww, screenW, screenH)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 鼠标键盘事件穿透
|
||||
|
||||
**问题**: WebView2 覆盖在桌面上方,拦截所有鼠标/键盘事件,桌面图标无法操作。
|
||||
|
||||
**修复**: 设置 `WS_EX_TRANSPARENT | WS_EX_LAYERED` 扩展样式,事件穿透到下层桌面。
|
||||
|
||||
```go
|
||||
procSetWindowLongPtrW.Call(wvHwnd, gwlExStyle, wsExTransparent|wsExLayered)
|
||||
```
|
||||
|
||||
### 托盘左键点击
|
||||
|
||||
**问题**: 原版 systray 库左键/右键都弹菜单,无法区分。
|
||||
|
||||
**修复**: 修改 vendor 中的 `systray_windows.go`,左键 `WM_LBUTTONUP` 发送信号到 `TrayClickCh` 通道而非弹菜单。主程序监听此通道打开设置窗口。
|
||||
|
||||
### 配置路径迁移
|
||||
|
||||
**变更**: 配置从 `{exeDir}/config/settings.json` 迁移到 `~/.u-desktop/settings.json`。
|
||||
|
||||
**原因**: 构建清理 `dist/` 目录会删除用户配置。用户目录不受影响,且多版本共用同一配置。
|
||||
|
||||
201
knowledge.go
201
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-4.5-air"
|
||||
const minKnowledgeRunes = 80
|
||||
const cpaModel = "deepseek-v4-pro"
|
||||
const minKnowledgeRunes = 120
|
||||
|
||||
type knowledgeData struct {
|
||||
Content string `json:"content"`
|
||||
@@ -96,21 +99,35 @@ 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": "你是严谨的中文知识卡片作者,输出必须具体、准确、有信息密度。不要写空泛鸡汤,不要只给一句定义。",
|
||||
},
|
||||
{"role": "system", "content": knowledgeSystemPrompt},
|
||||
{"role": "user", "content": basePrompt},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": cpaModel,
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.55,
|
||||
"messages": messages,
|
||||
"model": cpaModel,
|
||||
"max_completion_tokens": 2048,
|
||||
"temperature": 0.8,
|
||||
"messages": messages,
|
||||
}
|
||||
content := requestKnowledgeCompletion(body)
|
||||
content = normalizeKnowledgeContent(content)
|
||||
@@ -120,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,
|
||||
),
|
||||
})
|
||||
@@ -136,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」生成一条桌面知识卡片。
|
||||
硬性要求:
|
||||
1. 120-180个中文字符,分成2-3句;
|
||||
2. 必须讲清一个具体机制、原理、权衡或实践经验;
|
||||
3. 必须包含一个具体例子、场景或判断标准;
|
||||
4. 避免“很重要、非常有用、提升效率”这类空泛表述;
|
||||
5. 不要标题、序号、Markdown、表情,直接输出正文。`, keyword)
|
||||
angle := knowledgeAngles[rand.Intn(len(knowledgeAngles))]
|
||||
basePrompt := fmt.Sprintf(`围绕「%s」从「%s」角度写一条面向资深开发者的技术知识卡片。
|
||||
|
||||
要求:
|
||||
- 120-180字,信息密度极高
|
||||
- 必须是反常识、冷门陷阱、或版本差异等非常规知识点
|
||||
- 必须包含至少一个精确数值(如:默认超时 30s、缓冲区 4KB、Go 1.21 修复等)
|
||||
- 必须讲清因果链:什么场景触发 → 底层发生了什么 → 有什么后果 → 正确做法
|
||||
- 用 Markdown 格式:**加粗重点**、`+promptCodeMark+`行内代码`+promptCodeMark+`标记术语
|
||||
- 不要写常见知识点,不要复述官方文档`, keyword, angle)
|
||||
if customPrompt != "" {
|
||||
basePrompt += "\n附加风格要求(不能覆盖上面的字数和质量要求):" + customPrompt
|
||||
}
|
||||
@@ -173,10 +218,38 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Printf("知识API返回状态码: %d", resp.StatusCode)
|
||||
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 {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
@@ -184,35 +257,72 @@ 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 {
|
||||
return result.Choices[0].Message.Content
|
||||
c := result.Choices[0].Message.Content
|
||||
if c == "" {
|
||||
c = result.Choices[0].Message.ReasoningContent
|
||||
}
|
||||
return c
|
||||
}
|
||||
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) {
|
||||
@@ -222,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) {
|
||||
@@ -230,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 == "" {
|
||||
@@ -256,6 +394,7 @@ func fetchAndPushKnowledge() {
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
pushKnowledgeJSON("知识卡片加载失败,稍后重试", keyword)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
6
main.go
6
main.go
@@ -28,6 +28,8 @@ func setupLog() {
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
||||
}
|
||||
|
||||
var version = "dev" // -ldflags "-X main.version=0.6.0"
|
||||
|
||||
func main() {
|
||||
setupLog()
|
||||
|
||||
@@ -57,8 +59,8 @@ func main() {
|
||||
}
|
||||
defer windows.CloseHandle(mutex)
|
||||
|
||||
exePath, _ := os.Executable()
|
||||
cfgDir := filepath.Join(filepath.Dir(exePath), "config")
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
cfgDir := filepath.Join(homeDir, ".u-desktop")
|
||||
os.MkdirAll(cfgDir, 0755)
|
||||
configPath = filepath.Join(cfgDir, "settings.json")
|
||||
procSetProcessDPIAware.Call()
|
||||
|
||||
124
settings.go
124
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
|
||||
@@ -81,6 +95,7 @@ var themeNames = []struct {
|
||||
{ThemeParticle, "粒子"},
|
||||
{ThemeFractal, "极光流体"},
|
||||
{ThemeText, "文字"},
|
||||
{ThemeAudioViz, "音频可视化"},
|
||||
}
|
||||
|
||||
func refreshVisibleCards(cfg, oldCfg *Config) {
|
||||
@@ -104,26 +119,22 @@ func refreshVisibleCards(cfg, oldCfg *Config) {
|
||||
|
||||
func openSettingsWindow() {
|
||||
settingsMu.Lock()
|
||||
if settingsOpen && settingsHwnd != 0 {
|
||||
if settingsCreated {
|
||||
settingsMu.Unlock()
|
||||
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(os.TempDir(), "u-desktop-settings")
|
||||
dataDir := filepath.Join(configDir(), "settings-data")
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
|
||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||
@@ -132,7 +143,7 @@ func openSettingsWindow() {
|
||||
WindowOptions: webview2.WindowOptions{
|
||||
Title: "桌面设置",
|
||||
Width: 760,
|
||||
Height: 1350,
|
||||
Height: 800,
|
||||
},
|
||||
})
|
||||
if w == nil {
|
||||
@@ -140,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()
|
||||
|
||||
@@ -194,8 +216,12 @@ func openSettingsWindow() {
|
||||
"photoDir": cfg.PhotoDir,
|
||||
"photoInterval": cfg.PhotoInterval,
|
||||
"photoCard": !cfg.HidePhoto,
|
||||
"photoFrameMode": cfg.PhotoFrameMode,
|
||||
"photoFrameMode": cfg.PhotoFrameMode,
|
||||
"autoStart": isAutoStartEnabled(),
|
||||
"audioVizStyle": cfg.AudioVizStyle,
|
||||
"audioSensitivity": cfg.AudioSensitivity,
|
||||
"audioSmoothing": cfg.AudioSmoothing,
|
||||
"audioColorScheme": cfg.AudioColorScheme,
|
||||
})
|
||||
return string(data)
|
||||
})
|
||||
@@ -252,13 +278,13 @@ func openSettingsWindow() {
|
||||
for _, card := range []string{"time", "weather", "zodiac", "ainews", "knowledge"} {
|
||||
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('%s',false)", card))
|
||||
}
|
||||
} else {
|
||||
reloadWallpaper()
|
||||
}
|
||||
}
|
||||
if v, ok := data["photoCard"]; ok {
|
||||
cfg.HidePhoto = !v
|
||||
saveConfig(cfg)
|
||||
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('photo',%v)", v))
|
||||
refreshVisibleCards(cfg, &oldCfg)
|
||||
if cfg.PhotoDir != "" {
|
||||
if v {
|
||||
restartPhotoLoop()
|
||||
@@ -266,10 +292,9 @@ func openSettingsWindow() {
|
||||
stopPhotoLoop()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
saveConfig(cfg)
|
||||
refreshVisibleCards(cfg, &oldCfg)
|
||||
}
|
||||
saveConfig(cfg)
|
||||
refreshVisibleCards(cfg, &oldCfg)
|
||||
return ""
|
||||
})
|
||||
|
||||
@@ -383,15 +408,15 @@ func openSettingsWindow() {
|
||||
|
||||
w.Bind("pickGradientColor", func() string {
|
||||
hwnd := uintptr(w.Window())
|
||||
c1 := colorPickerDialog(hwnd, "")
|
||||
cfg := loadConfig()
|
||||
c1 := colorPickerDialog(hwnd, cfg.Color1)
|
||||
if c1 == "" {
|
||||
return ""
|
||||
}
|
||||
c2 := colorPickerDialog(hwnd, "")
|
||||
c2 := colorPickerDialog(hwnd, cfg.Color2)
|
||||
if c2 == "" {
|
||||
c2 = "#16213e"
|
||||
}
|
||||
cfg := loadConfig()
|
||||
cfg.WallpaperType = WPColor
|
||||
cfg.Color1 = c1
|
||||
cfg.Color2 = c2
|
||||
@@ -491,14 +516,51 @@ func openSettingsWindow() {
|
||||
return ""
|
||||
})
|
||||
|
||||
w.Bind("saveAudioVizStyle", func(style string) string {
|
||||
cfg := loadConfig()
|
||||
cfg.AudioVizStyle = style
|
||||
saveConfig(cfg)
|
||||
if cfg.Theme == ThemeAudioViz {
|
||||
evalJS(fmt.Sprintf(`window.__audioVizStyle=%q; if(window.setAudioVizStyle) setAudioVizStyle(%q)`, style, style))
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
w.Bind("saveAudioSensitivity", func(val float64) string {
|
||||
cfg := loadConfig()
|
||||
cfg.AudioSensitivity = val
|
||||
saveConfig(cfg)
|
||||
if cfg.Theme == ThemeAudioViz {
|
||||
evalJS(fmt.Sprintf(`window.__audioVizSensitivity=%f; if(window.setAudioSensitivity) setAudioSensitivity(%f)`, val, val))
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
w.Bind("saveAudioSmoothing", func(val float64) string {
|
||||
cfg := loadConfig()
|
||||
cfg.AudioSmoothing = val
|
||||
saveConfig(cfg)
|
||||
return ""
|
||||
})
|
||||
|
||||
w.Bind("saveAudioColorScheme", func(scheme string) string {
|
||||
cfg := loadConfig()
|
||||
cfg.AudioColorScheme = scheme
|
||||
saveConfig(cfg)
|
||||
if cfg.Theme == ThemeAudioViz {
|
||||
evalJS(fmt.Sprintf(`window.__audioVizColors=%q; if(window.setAudioColorScheme) setAudioColorScheme(%q)`, scheme, scheme))
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
w.SetHtml(settingsHTML)
|
||||
|
||||
hwnd := uintptr(w.Window())
|
||||
// disable resize
|
||||
// disable resize + hide from taskbar
|
||||
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
|
||||
@@ -518,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("设置窗口消息循环退出")
|
||||
}()
|
||||
}
|
||||
|
||||
82
systray.go
82
systray.go
@@ -18,10 +18,35 @@ func onSystrayReady() {
|
||||
systray.SetIcon(trayIcon)
|
||||
systray.SetTooltip("动态壁纸引擎")
|
||||
|
||||
// 左键点击托盘图标 → 打开设置(防抖: 连续点击只响应一次)
|
||||
go func() {
|
||||
for range systray.TrayClickCh {
|
||||
openSettingsWindow()
|
||||
// 消费后续积压的点击
|
||||
for {
|
||||
select {
|
||||
case <-systray.TrayClickCh:
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
}
|
||||
}()
|
||||
|
||||
mSettings := systray.AddMenuItem("桌面设置", "打开设置窗口")
|
||||
mCheckUpdate := systray.AddMenuItem("检查更新", "检查是否有新版本")
|
||||
mRestart := systray.AddMenuItem("重启", "重启程序")
|
||||
mQuit := systray.AddMenuItem("退出", "退出程序")
|
||||
|
||||
// 检查更新
|
||||
go func() {
|
||||
for {
|
||||
<-mCheckUpdate.ClickedCh
|
||||
manualCheckUpdate()
|
||||
}
|
||||
}()
|
||||
|
||||
// 设置窗口
|
||||
go func() {
|
||||
for {
|
||||
@@ -53,6 +78,16 @@ func onSystrayReady() {
|
||||
go bingWallpaperLoop()
|
||||
go knowledgeLoop()
|
||||
go startPhotoLoop()
|
||||
go runAutoUpdateCheck()
|
||||
|
||||
// 音频可视化:启动后检查是否需要音频捕获
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
cfg := loadConfig()
|
||||
if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeAudioViz {
|
||||
startAudioCapture()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func startWebView() {
|
||||
@@ -89,10 +124,10 @@ func startWebView() {
|
||||
wvHwnd = uintptr(wv.Window())
|
||||
procShowWindow.Call(wvHwnd, 0) // SW_HIDE
|
||||
procSetWindowLongPtrW.Call(wvHwnd, gwlStyle, wsPopup|wsVisible|wsChild)
|
||||
procSetWindowLongPtrW.Call(wvHwnd, gwlExStyle, wsExLayered)
|
||||
|
||||
// 立即嵌入 WorkerW
|
||||
procSetParent.Call(wvHwnd, workerw)
|
||||
procMoveWindow.Call(wvHwnd, uintptr(^uint(0)), uintptr(^uint(0)), uintptr(screenW+2), uintptr(screenH+2), 1)
|
||||
embedWorkerW(workerw, screenW, screenH)
|
||||
log.Printf("已嵌入 WorkerW: HWND=0x%x, %dx%d", wvHwnd, screenW, screenH)
|
||||
|
||||
wv.Bind("setZodiacFromGo", func(zodiac string) error {
|
||||
@@ -101,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))
|
||||
@@ -110,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()
|
||||
@@ -158,11 +212,35 @@ func startWebView() {
|
||||
}
|
||||
}
|
||||
|
||||
func embedWorkerW(workerw uintptr, screenW, screenH int32) {
|
||||
procSetParent.Call(wvHwnd, workerw)
|
||||
procMoveWindow.Call(wvHwnd, uintptr(^uint(0)), uintptr(^uint(0)), uintptr(screenW+2), uintptr(screenH+2), 1)
|
||||
}
|
||||
|
||||
func fullscreenMonitor() {
|
||||
type rect struct{ Left, Top, Right, Bottom int32 }
|
||||
var lastState string
|
||||
var lastParent uintptr
|
||||
var parentTick int
|
||||
for {
|
||||
if atomic.LoadInt32(&paused) == 0 && wv != nil {
|
||||
// 每 5 轮(10s)检测一次父窗口,极轻量系统调用
|
||||
parentTick++
|
||||
if parentTick >= 5 {
|
||||
parentTick = 0
|
||||
parent, _, _ := procGetParent.Call(wvHwnd)
|
||||
if parent != lastParent {
|
||||
lastParent = parent
|
||||
ww := findWorkerW()
|
||||
if ww != 0 && ww != parent {
|
||||
screenW, screenH := getScreenSize()
|
||||
embedWorkerW(ww, screenW, screenH)
|
||||
procShowWindow.Call(wvHwnd, 5)
|
||||
log.Printf("WorkerW 变更,已重新嵌入: 0x%x → 0x%x", parent, ww)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fg, _, _ := procGetForegroundWindow.Call()
|
||||
if fg != 0 {
|
||||
var r rect
|
||||
|
||||
271
updater.go
Normal file
271
updater.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jchv/go-webview2"
|
||||
)
|
||||
|
||||
//go:embed web/update.html
|
||||
var updateHTML string
|
||||
|
||||
//go:embed updater.exe
|
||||
var updaterExe []byte
|
||||
|
||||
type PackageInfo struct {
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type UpdateInfo struct {
|
||||
Version string `json:"version"`
|
||||
Force bool `json:"force"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
Package PackageInfo `json:"package"`
|
||||
}
|
||||
|
||||
var (
|
||||
updateMu sync.Mutex
|
||||
updateOpen bool
|
||||
updateInfo *UpdateInfo
|
||||
)
|
||||
|
||||
const defaultUpdateURL = "https://c.1216.top/u-desktop/version.json"
|
||||
|
||||
func getUpdateURL() string {
|
||||
cfg := loadConfig()
|
||||
if cfg.UpdateURL != "" {
|
||||
return cfg.UpdateURL
|
||||
}
|
||||
return defaultUpdateURL
|
||||
}
|
||||
|
||||
func checkUpdate() (*UpdateInfo, error) {
|
||||
resp, err := httpClient.Get(getUpdateURL())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求 version.json 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("version.json 返回状态 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info UpdateInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return nil, fmt.Errorf("解析 version.json 失败: %w", err)
|
||||
}
|
||||
|
||||
if info.Version == "" || info.Version == version || info.Version == "dev" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 用户跳过过此版本且非强制
|
||||
cfg := loadConfig()
|
||||
if !info.Force && cfg.SkipVersion == info.Version {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func runAutoUpdateCheck() {
|
||||
cfg := loadConfig()
|
||||
if !cfg.AutoCheckUpdate {
|
||||
return
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
info, err := checkUpdate()
|
||||
if err != nil {
|
||||
log.Println("自动更新检查失败:", err)
|
||||
return
|
||||
}
|
||||
if info != nil {
|
||||
showUpdateWindow(info)
|
||||
}
|
||||
}
|
||||
|
||||
func showUpdateWindow(info *UpdateInfo) {
|
||||
updateMu.Lock()
|
||||
if updateOpen {
|
||||
updateMu.Unlock()
|
||||
return
|
||||
}
|
||||
updateOpen = true
|
||||
updateInfo = info
|
||||
updateMu.Unlock()
|
||||
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
defer func() {
|
||||
updateMu.Lock()
|
||||
updateOpen = false
|
||||
updateInfo = nil
|
||||
updateMu.Unlock()
|
||||
runtime.UnlockOSThread()
|
||||
}()
|
||||
|
||||
winW := 340
|
||||
winH := 220
|
||||
if info.Force {
|
||||
winW = 420
|
||||
winH = 260
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(configDir(), "update-data")
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
|
||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||
AutoFocus: true,
|
||||
DataPath: dataDir,
|
||||
WindowOptions: webview2.WindowOptions{
|
||||
Title: "更新提示",
|
||||
Width: uint(winW),
|
||||
Height: uint(winH),
|
||||
},
|
||||
})
|
||||
if w == nil {
|
||||
log.Println("更新窗口: 创建失败")
|
||||
return
|
||||
}
|
||||
|
||||
hwnd := uintptr(w.Window())
|
||||
|
||||
// 无边框
|
||||
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
|
||||
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox|0x00C00000))
|
||||
// 置顶 + 工具窗口
|
||||
procSetWindowLongPtrW.Call(hwnd, gwlExStyle, wsExToolwindow|0x00000008)
|
||||
|
||||
// 定位窗口
|
||||
screenW, screenH := getScreenSize()
|
||||
if info.Force {
|
||||
x := (int(screenW) - winW) / 2
|
||||
y := (int(screenH) - winH) / 2
|
||||
procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(winW), uintptr(winH), 1)
|
||||
} else {
|
||||
x := int(screenW) - winW - 20
|
||||
y := int(screenH) - winH - 60
|
||||
procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(winW), uintptr(winH), 1)
|
||||
}
|
||||
|
||||
w.Bind("getUpdateInfo", func() string {
|
||||
if updateInfo == nil {
|
||||
return "{}"
|
||||
}
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"version": updateInfo.Version,
|
||||
"force": updateInfo.Force,
|
||||
"releaseNotes": updateInfo.ReleaseNotes,
|
||||
"size": updateInfo.Package.Size,
|
||||
"currentVersion": version,
|
||||
})
|
||||
return string(data)
|
||||
})
|
||||
|
||||
w.Bind("skipUpdate", func() string {
|
||||
if updateInfo != nil && !updateInfo.Force {
|
||||
cfg := loadConfig()
|
||||
cfg.SkipVersion = updateInfo.Version
|
||||
saveConfig(cfg)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
w.Bind("startUpdate", func() string {
|
||||
if updateInfo == nil {
|
||||
return ""
|
||||
}
|
||||
go doStartUpdate(updateInfo)
|
||||
return ""
|
||||
})
|
||||
|
||||
w.SetHtml(updateHTML)
|
||||
log.Println("更新窗口已打开")
|
||||
w.Run()
|
||||
log.Println("更新窗口已关闭")
|
||||
}()
|
||||
}
|
||||
|
||||
func doStartUpdate(info *UpdateInfo) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Println("获取自身路径失败:", err)
|
||||
return
|
||||
}
|
||||
exeDir := filepath.Dir(exePath)
|
||||
|
||||
// 释放嵌入的 updater.exe 到临时目录
|
||||
updaterPath := filepath.Join(os.TempDir(), "u-desktop-updater.exe")
|
||||
if err := os.WriteFile(updaterPath, updaterExe, 0755); err != nil {
|
||||
log.Println("释放 updater.exe 失败:", err)
|
||||
showError("释放升级器失败: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(exeDir, "backup")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
|
||||
args := []string{
|
||||
"--package-url", info.Package.URL,
|
||||
"--sha256", info.Package.SHA256,
|
||||
"--target", exePath,
|
||||
"--version", info.Version,
|
||||
"--backup-dir", backupDir,
|
||||
}
|
||||
if info.Force {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
|
||||
cmd := exec.Command(updaterPath, args...)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Println("启动 updater 失败:", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("updater 已启动, 主程序退出")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func manualCheckUpdate() {
|
||||
info, err := checkUpdate()
|
||||
if err != nil {
|
||||
log.Println("手动更新检查失败:", err)
|
||||
showError("检查更新失败: " + err.Error())
|
||||
return
|
||||
}
|
||||
if info == nil {
|
||||
showError("当前已是最新版本 (" + version + ")")
|
||||
return
|
||||
}
|
||||
showUpdateWindow(info)
|
||||
}
|
||||
|
||||
func formatSize(size int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
)
|
||||
switch {
|
||||
case size >= MB:
|
||||
return strconv.FormatFloat(float64(size)/float64(MB), 'f', 1, 64) + " MB"
|
||||
case size >= KB:
|
||||
return strconv.FormatFloat(float64(size)/float64(KB), 'f', 1, 64) + " KB"
|
||||
default:
|
||||
return strconv.FormatInt(size, 10) + " B"
|
||||
}
|
||||
}
|
||||
3
updater/go.mod
Normal file
3
updater/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module updater
|
||||
|
||||
go 1.26.3
|
||||
218
updater/main.go
Normal file
218
updater/main.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
packageURL := flag.String("package-url", "", "下载地址")
|
||||
sha256Hash := flag.String("sha256", "", "SHA256 校验值")
|
||||
target := flag.String("target", "", "目标 exe 路径")
|
||||
ver := flag.String("version", "", "目标版本号")
|
||||
force := flag.Bool("force", false, "强制升级")
|
||||
backupDir := flag.String("backup-dir", "", "备份目录")
|
||||
flag.Parse()
|
||||
|
||||
logPath := filepath.Join(filepath.Dir(*target), "updater.log")
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
fmt.Println("无法创建日志文件:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer logFile.Close()
|
||||
log.SetOutput(logFile)
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
||||
|
||||
log.Printf("updater 启动: version=%s target=%s force=%v", *ver, *target, *force)
|
||||
|
||||
if *packageURL == "" || *target == "" {
|
||||
log.Fatal("缺少必要参数: --package-url, --target")
|
||||
}
|
||||
if *sha256Hash == "" {
|
||||
log.Fatal("缺少 --sha256 参数")
|
||||
}
|
||||
|
||||
// 等待主程序退出
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// 下载
|
||||
tmpFile, err := downloadWithRetry(*packageURL, 3)
|
||||
if err != nil {
|
||||
log.Fatal("下载失败: ", err)
|
||||
}
|
||||
defer os.Remove(tmpFile)
|
||||
log.Println("下载完成:", tmpFile)
|
||||
|
||||
// 校验
|
||||
if err := verifySHA256(tmpFile, *sha256Hash); err != nil {
|
||||
log.Fatal("校验失败: ", err)
|
||||
}
|
||||
log.Println("SHA256 校验通过")
|
||||
|
||||
// 备份
|
||||
if *backupDir == "" {
|
||||
*backupDir = filepath.Join(filepath.Dir(*target), "backup")
|
||||
}
|
||||
os.MkdirAll(*backupDir, 0755)
|
||||
backupPath := filepath.Join(*backupDir, fmt.Sprintf("old-%s.exe", time.Now().Format("20060102-150405")))
|
||||
if err := copyFile(*target, backupPath); err != nil {
|
||||
log.Println("备份失败(继续):", err)
|
||||
} else {
|
||||
log.Println("已备份:", backupPath)
|
||||
}
|
||||
|
||||
// 替换: zip 包解压,exe 直接覆盖
|
||||
if strings.HasSuffix(strings.ToLower(*packageURL), ".zip") {
|
||||
if err := extractAndReplace(tmpFile, *target); err != nil {
|
||||
rollback(backupPath, *target)
|
||||
log.Fatal("解压替换失败: ", err)
|
||||
}
|
||||
} else {
|
||||
if err := copyFile(tmpFile, *target); err != nil {
|
||||
rollback(backupPath, *target)
|
||||
log.Fatal("替换失败: ", err)
|
||||
}
|
||||
}
|
||||
log.Println("替换完成")
|
||||
|
||||
// 启动新版本
|
||||
cmd := exec.Command(*target)
|
||||
if err := cmd.Start(); err != nil {
|
||||
rollback(backupPath, *target)
|
||||
log.Fatal("启动新版本失败: ", err)
|
||||
}
|
||||
|
||||
// 监听新进程 5 秒
|
||||
time.Sleep(5 * time.Second)
|
||||
if cmd.Process == nil {
|
||||
rollback(backupPath, *target)
|
||||
log.Fatal("新进程异常")
|
||||
}
|
||||
|
||||
// 检查新进程是否仍在运行
|
||||
// NOTE: Windows 下无法直接用 cmd.Process.Signals,用简单的进程存活性判断
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
log.Println("新进程已退出,回滚")
|
||||
rollback(backupPath, *target)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log.Println("升级成功, updater 退出")
|
||||
}
|
||||
|
||||
func downloadWithRetry(url string, maxRetries int) (string, error) {
|
||||
var lastErr error
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
if i > 0 {
|
||||
log.Printf("重试下载 (%d/%d)...", i+1, maxRetries)
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
tmpFile, err := os.CreateTemp("", "updater-*.download")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
tmpFile.Close()
|
||||
os.Remove(tmpPath)
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = io.Copy(tmpFile, resp.Body)
|
||||
resp.Body.Close()
|
||||
tmpFile.Close()
|
||||
if err != nil {
|
||||
os.Remove(tmpPath)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return tmpPath, nil
|
||||
}
|
||||
return "", fmt.Errorf("下载失败(重试 %d 次): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func verifySHA256(path, expected string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actual := fmt.Sprintf("%x", h.Sum(nil))
|
||||
if actual != strings.ToLower(expected) {
|
||||
return fmt.Errorf("SHA256 不匹配: 期望 %s, 实际 %s", expected, actual)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
// 复制可执行权限
|
||||
if fi, err := os.Stat(src); err == nil {
|
||||
out.Chmod(fi.Mode())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractAndReplace(zipPath, target string) error {
|
||||
// TODO: 如需 zip 解压支持,引入 archive/zip
|
||||
// 初期可直接分发 exe,此路径不执行
|
||||
return fmt.Errorf("zip 解压暂未实现,请使用 exe 分发")
|
||||
}
|
||||
|
||||
func rollback(backupPath, target string) {
|
||||
if backupPath == "" {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(backupPath); err != nil {
|
||||
return
|
||||
}
|
||||
log.Println("回滚: 从备份恢复", backupPath)
|
||||
if err := copyFile(backupPath, target); err != nil {
|
||||
log.Println("回滚失败:", err)
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(target)
|
||||
cmd.Start()
|
||||
log.Println("已回滚并启动旧版本")
|
||||
}
|
||||
23
wallpaper.go
23
wallpaper.go
@@ -33,6 +33,9 @@ var themeFractal string
|
||||
//go:embed web/themes/text.html
|
||||
var themeText string
|
||||
|
||||
//go:embed web/themes/audio-viz.html
|
||||
var themeAudioViz string
|
||||
|
||||
var themeMap = map[ThemeName]string{
|
||||
ThemeAurora: themeAurora,
|
||||
ThemeStar: themeStarfield,
|
||||
@@ -40,6 +43,7 @@ var themeMap = map[ThemeName]string{
|
||||
ThemeParticle: themeParticles,
|
||||
ThemeFractal: themeFractal,
|
||||
ThemeText: themeText,
|
||||
ThemeAudioViz: themeAudioViz,
|
||||
}
|
||||
|
||||
func buildWallpaperHTML(cfg *Config) string {
|
||||
@@ -88,6 +92,14 @@ func buildWallpaperHTML(cfg *Config) string {
|
||||
inject += fmt.Sprintf(`<script>window.wallpaperText=%s;</script>`, string(escaped))
|
||||
}
|
||||
|
||||
// 注入音频可视化配置
|
||||
if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeAudioViz {
|
||||
inject += fmt.Sprintf(
|
||||
`<script>window.__audioVizStyle=%q;window.__audioVizSensitivity=%f;window.__audioVizColors=%q;</script>`,
|
||||
cfg.AudioVizStyle, cfg.AudioSensitivity, cfg.AudioColorScheme,
|
||||
)
|
||||
}
|
||||
|
||||
return strings.Replace(overlayHTML, "</head>", inject+"</head>", 1)
|
||||
}
|
||||
|
||||
@@ -118,6 +130,17 @@ func reloadWallpaper() {
|
||||
}
|
||||
cfg := loadConfig()
|
||||
|
||||
// 音频捕获生命周期
|
||||
if cfg.WallpaperType == WPTheme && cfg.Theme == ThemeAudioViz {
|
||||
if !audioActiveState() {
|
||||
startAudioCapture()
|
||||
}
|
||||
} else {
|
||||
if audioActiveState() {
|
||||
stopAudioCapture()
|
||||
}
|
||||
}
|
||||
|
||||
// 非主题壁纸切换:仅替换 #bg-layer,不销毁卡片状态
|
||||
if cfg.WallpaperType != WPTheme {
|
||||
updateBackground(cfg)
|
||||
|
||||
@@ -44,7 +44,7 @@ var cities = []City{
|
||||
|
||||
var defaultCity = City{"101200101", "武汉", "武汉", "湖北"}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
var httpClient = &http.Client{Timeout: 60 * time.Second}
|
||||
|
||||
var weatherIcons = map[string]string{
|
||||
"晴": "☀️", "多云": "⛅", "阴": "☁️",
|
||||
|
||||
@@ -4,28 +4,41 @@ function parseArg(data: any) {
|
||||
return typeof data === 'string' ? JSON.parse(data) : data
|
||||
}
|
||||
|
||||
// JSON 去重:相同数据不触发 Vue 重渲染
|
||||
const lastJSON = new Map<string, string>()
|
||||
function shouldUpdate(key: string, data: any): boolean {
|
||||
const json = JSON.stringify(data)
|
||||
if (lastJSON.get(key) === json) return false
|
||||
lastJSON.set(key, json)
|
||||
return true
|
||||
}
|
||||
|
||||
export function registerGoBridge() {
|
||||
const w = window as any
|
||||
|
||||
w.updateHoroscopeFromGo = (data: any) => {
|
||||
state.horoscope = parseArg(data)
|
||||
const parsed = parseArg(data)
|
||||
if (shouldUpdate('horoscope', parsed)) state.horoscope = parsed
|
||||
}
|
||||
|
||||
w.updateAINewsFromGo = (items: any) => {
|
||||
const parsed = parseArg(items)
|
||||
if (parsed?.length) state.aiNews = parsed
|
||||
if (parsed?.length && shouldUpdate('ainews', parsed)) state.aiNews = parsed
|
||||
}
|
||||
|
||||
w.updateKnowledgeFromGo = (data: any) => {
|
||||
state.knowledge = parseArg(data)
|
||||
const parsed = parseArg(data)
|
||||
if (shouldUpdate('knowledge', parsed)) state.knowledge = parsed
|
||||
}
|
||||
|
||||
w.updateWeatherFromGo = (data: any) => {
|
||||
state.weather = parseArg(data)
|
||||
const parsed = parseArg(data)
|
||||
if (shouldUpdate('weather', parsed)) state.weather = parsed
|
||||
}
|
||||
|
||||
w.updatePhotoFromGo = (data: any) => {
|
||||
state.photo = parseArg(data)
|
||||
const parsed = parseArg(data)
|
||||
if (parsed?.src && shouldUpdate('photo', parsed)) state.photo = parsed
|
||||
}
|
||||
|
||||
w.setCardVisible = (card: string, visible: boolean) => {
|
||||
@@ -54,4 +67,12 @@ export function registerGoBridge() {
|
||||
w.__updateBackgroundHtml = (html: string) => {
|
||||
state.backgroundHtml = html
|
||||
}
|
||||
|
||||
// 音频可视化:频率数据直接写入 window,不经过 Vue reactive
|
||||
w.updateAudioDataFromGo = (data: any) => {
|
||||
const parsed = parseArg(data)
|
||||
if (Array.isArray(parsed)) {
|
||||
;(window as any).__audioBins = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,172 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="knowledge-card">
|
||||
<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-content">{{ state.knowledge.content || '请设置知识关键字' }}</div>
|
||||
<div class="knowledge-divider"></div>
|
||||
<div class="knowledge-body" v-if="state.knowledge.content">
|
||||
<span class="knowledge-quote">"</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>
|
||||
.knowledge-card {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.knowledge-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.knowledge-label {
|
||||
font-size: 11px; color: var(--text-faint);
|
||||
margin-bottom: 10px; display: flex;
|
||||
align-items: center; gap: 6px;
|
||||
letter-spacing: 0; font-weight: 700;
|
||||
font-weight: 700; letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.knowledge-keyword-tag {
|
||||
background: rgba(255,255,255,0.09);
|
||||
padding: 3px 8px; border-radius: 6px;
|
||||
font-size: 10px; color: var(--text-soft);
|
||||
background: rgba(255,216,107,0.12);
|
||||
color: var(--accent-warm);
|
||||
padding: 2px 9px; border-radius: 4px;
|
||||
font-size: 10px; font-weight: 600;
|
||||
border: 1px solid rgba(255,216,107,0.15);
|
||||
}
|
||||
.knowledge-content {
|
||||
font-size: 16px; color: var(--text-main);
|
||||
line-height: 1.55; text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
.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);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.knowledge-body {
|
||||
position: relative;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.knowledge-quote {
|
||||
position: absolute; top: -10px; left: -6px;
|
||||
font-size: 52px; line-height: 1;
|
||||
color: var(--accent-warm); opacity: 0.18;
|
||||
font-family: Georgia, serif; pointer-events: none;
|
||||
}
|
||||
.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: 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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
@@ -68,10 +69,7 @@ import PhotoCard from './PhotoCard.vue'
|
||||
width: 100%; min-width: 0; text-align: right;
|
||||
padding: 22px 28px 24px;
|
||||
}
|
||||
#card-knowledge :deep(.knowledge-content) {
|
||||
display: -webkit-box; -webkit-line-clamp: 8;
|
||||
-webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
#card-knowledge { overflow: hidden; }
|
||||
#layout-multi :deep(#card-photo) {
|
||||
grid-area: photo;
|
||||
position: relative; top: auto; left: auto;
|
||||
|
||||
@@ -35,9 +35,7 @@ html, body {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(145deg, rgba(255,255,255,0.075), rgba(255,255,255,0.025)), var(--card-bg);
|
||||
backdrop-filter: blur(28px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(1.2);
|
||||
background: linear-gradient(145deg, rgba(255,255,255,0.075), rgba(255,255,255,0.025)), var(--card-bg-strong);
|
||||
border: 1px solid var(--card-line);
|
||||
border-radius: 8px;
|
||||
padding: 22px 24px;
|
||||
|
||||
@@ -35,8 +35,19 @@ const s = reactive<SettingsData>({
|
||||
bingAutoRefresh: false, knowledgeKeyword: '', knowledgePrompt: '',
|
||||
wallpaperText: '', imagePath: '', showSeconds: true, ainewsCard: true,
|
||||
photoDir: '', photoInterval: 15, photoCard: true, photoFrameMode: false, autoStart: false,
|
||||
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 })
|
||||
|
||||
@@ -75,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)
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div><div class="text-xs font-medium text-[var(--text-muted)]">知识关键字</div><div class="text-[10px] text-[var(--text-weak)] mt-px">AI 将根据关键字生成知识小卡片</div></div>
|
||||
<input type="text" v-model="s.knowledgeKeyword" @input="debounced(go.saveKnowledgeKeyword, s.knowledgeKeyword)"
|
||||
<input type="text" v-model="s.knowledgeKeyword" @input="debounced('kw', go.saveKnowledgeKeyword, s.knowledgeKeyword)"
|
||||
placeholder="如: 历史、科学、冷知识"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div><div class="text-xs font-medium text-[var(--text-muted)]">知识提示词</div><div class="text-[10px] text-[var(--text-weak)] mt-px">补充风格或方向,系统会保证内容密度</div></div>
|
||||
<input type="text" v-model="s.knowledgePrompt" @input="debounced(go.saveKnowledgePrompt, s.knowledgePrompt)"
|
||||
<input type="text" v-model="s.knowledgePrompt" @input="debounced('kp', go.saveKnowledgePrompt, s.knowledgePrompt)"
|
||||
placeholder="如: 偏实践、给出判断标准"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||
</div>
|
||||
@@ -76,12 +76,10 @@ function onDocClick(e: MouseEvent) {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick)
|
||||
// Find initial city
|
||||
for (const p of Object.keys(props.s.citiesByProv)) {
|
||||
for (const c of (props.s.citiesByProv as any)[p]) {
|
||||
if (c.id === props.s.city) { activeProv.value = p; selectedCityName.value = c.name; break }
|
||||
}
|
||||
if (activeProv.value) break
|
||||
const entries = Object.entries(props.s.citiesByProv as Record<string, { id: string; name: string }[]>)
|
||||
for (const [p, cs] of entries) {
|
||||
const found = cs.find(c => c.id === props.s.city)
|
||||
if (found) { activeProv.value = p; selectedCityName.value = found.name; break }
|
||||
}
|
||||
})
|
||||
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
|
||||
@@ -29,12 +29,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { go } from '../composables/useGoBridge'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { SettingsData } from '@shared/types'
|
||||
|
||||
const props = defineProps<{ s: SettingsData }>()
|
||||
|
||||
watch(
|
||||
() => [props.s.photoCard, props.s.photoFrameMode] as [boolean, boolean],
|
||||
([photoCard, photoFrameMode]) => {
|
||||
go.saveToggles({ photoCard, photoFrameMode })
|
||||
}
|
||||
)
|
||||
|
||||
async function onPickDir() {
|
||||
const dir = await go.pickPhotoDir()
|
||||
if (dir) props.s.photoDir = dir
|
||||
|
||||
@@ -26,10 +26,47 @@
|
||||
</div>
|
||||
<div v-if="s.theme === 'text'" class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">自定义文字</div>
|
||||
<input type="text" v-model="s.wallpaperText" @input="debounced(go.saveWallpaperText, s.wallpaperText)"
|
||||
<input type="text" v-model="s.wallpaperText" @input="debounced('wt', go.saveWallpaperText, s.wallpaperText)"
|
||||
placeholder="输入显示文字"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||
</div>
|
||||
<!-- 音频可视化设置 -->
|
||||
<template v-if="s.theme === 'audio-viz'">
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">可视化样式</div>
|
||||
<div class="flex flex-wrap gap-1 justify-end">
|
||||
<button v-for="st in audioVizStyles" :key="st.value"
|
||||
@click="s.audioVizStyle = st.value; go.saveAudioVizStyle(st.value)"
|
||||
:class="s.audioVizStyle === st.value
|
||||
? 'bg-[var(--accent)] border-[var(--accent)] text-[var(--text-strong)]'
|
||||
: 'bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--text)] hover:bg-[var(--card-border)]'"
|
||||
class="border rounded-md text-[11px] py-0.5 px-2 font-[inherit] cursor-pointer transition-colors">{{ st.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">配色方案</div>
|
||||
<div class="flex gap-1">
|
||||
<button v-for="c in audioColors" :key="c.value"
|
||||
@click="s.audioColorScheme = c.value; go.saveAudioColorScheme(c.value)"
|
||||
:class="s.audioColorScheme === c.value
|
||||
? 'bg-[var(--accent)] border-[var(--accent)] text-[var(--text-strong)]'
|
||||
: 'bg-[var(--input-bg)] border-[var(--input-border)] text-[var(--text)] hover:bg-[var(--card-border)]'"
|
||||
class="border rounded-md text-[11px] py-0.5 px-2 font-[inherit] cursor-pointer transition-colors">{{ c.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">灵敏度</div>
|
||||
<input type="range" min="0.5" max="3" step="0.1" :value="s.audioSensitivity"
|
||||
@input="s.audioSensitivity = +($event.target as HTMLInputElement).value; debounced('as', go.saveAudioSensitivity, s.audioSensitivity)"
|
||||
class="w-[100px]">
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div class="text-xs font-medium text-[var(--text-muted)]">平滑度</div>
|
||||
<input type="range" min="0" max="0.95" step="0.05" :value="s.audioSmoothing"
|
||||
@input="s.audioSmoothing = +($event.target as HTMLInputElement).value; debounced('am', go.saveAudioSmoothing, s.audioSmoothing)"
|
||||
class="w-[100px]">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 本地图片 -->
|
||||
@@ -126,6 +163,27 @@ const wpTypes = [
|
||||
{ value: 'color', label: '纯色/渐变' },
|
||||
]
|
||||
|
||||
const audioVizStyles = [
|
||||
{ value: 'bars', label: '柱状' },
|
||||
{ value: 'waveform', label: '波形' },
|
||||
{ value: 'circular', label: '环形' },
|
||||
{ value: 'particles', label: '粒子' },
|
||||
{ value: 'ripples', label: '涟漪' },
|
||||
{ value: 'text', label: '文字' },
|
||||
{ value: 'stars', label: '星空' },
|
||||
{ value: 'dna', label: 'DNA' },
|
||||
{ value: 'matrix', label: '矩阵' },
|
||||
{ value: 'jellyfish', label: '水母' },
|
||||
{ value: 'aurora', label: '极光' },
|
||||
]
|
||||
|
||||
const audioColors = [
|
||||
{ value: 'neon', label: '霓虹' },
|
||||
{ value: 'ocean', label: '海洋' },
|
||||
{ value: 'fire', label: '火焰' },
|
||||
{ value: 'rainbow', label: '彩虹' },
|
||||
]
|
||||
|
||||
// Bing
|
||||
const bing = ref<BingState>({ date: '', title: '', copyright: '', url: '', fav: false, idx: 0, total: 0 })
|
||||
const bingThumb = ref('')
|
||||
@@ -135,7 +193,10 @@ async function loadBingInfo() {
|
||||
const raw = await go.getBingInfo()
|
||||
const st = JSON.parse(raw) as BingState
|
||||
bing.value = st
|
||||
if (st.url) bingThumb.value = st.url.replace('_1920x1080', '_400x240')
|
||||
if (st.url) {
|
||||
const t = st.url.replace('_1920x1080', '_400x240')
|
||||
bingThumb.value = t !== st.url ? t : await go.bingThumbDataURI(st.url.split('/').pop() || '')
|
||||
}
|
||||
}
|
||||
|
||||
async function onBingPrev() { bing.value = JSON.parse(await go.bingPrev()) }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
export function debounced(fn: (v: string) => void, val: string, ms = 500) {
|
||||
clearTimeout(timers.get(fn.name))
|
||||
timers.set(fn.name, setTimeout(() => fn(val), ms))
|
||||
export function debounced(key: string, fn: (v: string) => void, val: string, ms = 500) {
|
||||
clearTimeout(timers.get(key))
|
||||
timers.set(key, setTimeout(() => fn(val), ms))
|
||||
}
|
||||
|
||||
@@ -29,4 +29,8 @@ export const go = {
|
||||
clearPhotoDir: () => w.clearPhotoDir(),
|
||||
savePhotoInterval: (val: number) => w.savePhotoInterval(val),
|
||||
resizeToFit: (w: number, h: number) => w.resizeToFit(w, h),
|
||||
saveAudioVizStyle: (style: string) => w.saveAudioVizStyle(style),
|
||||
saveAudioSensitivity: (val: number) => w.saveAudioSensitivity(val),
|
||||
saveAudioSmoothing: (val: number) => w.saveAudioSmoothing(val),
|
||||
saveAudioColorScheme: (scheme: string) => w.saveAudioColorScheme(scheme),
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ export interface SettingsData {
|
||||
photoCard: boolean
|
||||
photoFrameMode: boolean
|
||||
autoStart: boolean
|
||||
audioVizStyle: string
|
||||
audioSensitivity: number
|
||||
audioSmoothing: number
|
||||
audioColorScheme: string
|
||||
}
|
||||
|
||||
export interface BingState {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1275
web/themes/audio-viz.html
Normal file
1275
web/themes/audio-viz.html
Normal file
File diff suppressed because it is too large
Load Diff
126
web/update.html
Normal file
126
web/update.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
background: transparent;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
.container {
|
||||
background: rgba(30, 30, 46, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
padding: 20px 24px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.version-tag {
|
||||
color: #89b4fa;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.notes {
|
||||
font-size: 13px;
|
||||
color: #a0a0b8;
|
||||
line-height: 1.8;
|
||||
white-space: pre-line;
|
||||
flex: 1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn {
|
||||
padding: 7px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #89b4fa;
|
||||
color: #1e1e2e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: #74c7ec; }
|
||||
.btn-secondary {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #a0a0b8;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.btn-secondary:hover { background: rgba(255,255,255,0.1); }
|
||||
.force-layout {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.force-layout .version-tag { font-size: 18px; }
|
||||
.force-layout .btn-row { justify-content: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" id="app">
|
||||
<div class="version-tag" id="versionTag"></div>
|
||||
<div class="notes" id="notes"></div>
|
||||
<div class="meta" id="meta"></div>
|
||||
<div class="btn-row" id="btnRow"></div>
|
||||
</div>
|
||||
<script>
|
||||
const info = JSON.parse(window.getUpdateInfo());
|
||||
const container = document.getElementById('app');
|
||||
const versionTag = document.getElementById('versionTag');
|
||||
const notes = document.getElementById('notes');
|
||||
const meta = document.getElementById('meta');
|
||||
const btnRow = document.getElementById('btnRow');
|
||||
|
||||
versionTag.textContent = '发现新版本 v' + info.version;
|
||||
notes.textContent = info.releaseNotes || '';
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + 'MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KB';
|
||||
return bytes + 'B';
|
||||
}
|
||||
|
||||
if (info.force) {
|
||||
container.classList.add('force-layout');
|
||||
if (info.size) {
|
||||
const sec = info.size > 5242880 ? '~30秒' : '~10秒';
|
||||
meta.textContent = '大小: ' + formatSize(info.size) + ' | 预计用时: ' + sec;
|
||||
}
|
||||
btnRow.innerHTML = '<button class="btn btn-primary" onclick="doUpdate()">立即升级</button>';
|
||||
} else {
|
||||
btnRow.innerHTML =
|
||||
'<button class="btn btn-secondary" onclick="skip()">稍后</button>' +
|
||||
'<button class="btn btn-primary" onclick="doUpdate()">立即升级</button>';
|
||||
}
|
||||
|
||||
function doUpdate() {
|
||||
window.startUpdate();
|
||||
window.close();
|
||||
}
|
||||
|
||||
function skip() {
|
||||
window.skipUpdate();
|
||||
window.close();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
164
win32.go
164
win32.go
@@ -29,6 +29,15 @@ var (
|
||||
procPostMessageW = user32.NewProc("PostMessageW")
|
||||
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
|
||||
@@ -42,16 +51,158 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
gwlStyle = uintptr(0xFFFFFFF0) // GWL_STYLE = -16
|
||||
wsPopup = uintptr(0x80000000)
|
||||
wsVisible = uintptr(0x10000000)
|
||||
wsChild = uintptr(0x02000000)
|
||||
wsSizebox = uintptr(0x00040000)
|
||||
wsMaxbox = uintptr(0x00010000)
|
||||
gwlStyle = uintptr(0xFFFFFFF0) // GWL_STYLE = -16
|
||||
gwlExStyle = uintptr(0xFFFFFFFEC) // GWL_EXSTYLE = -20
|
||||
wsPopup = uintptr(0x80000000)
|
||||
wsVisible = uintptr(0x10000000)
|
||||
wsChild = uintptr(0x02000000)
|
||||
wsSizebox = uintptr(0x00040000)
|
||||
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")))
|
||||
@@ -77,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