Compare commits

...

5 Commits

Author SHA1 Message Date
0302546267 优化: 知识卡片交互+deepseek模型+鼠标钩子 2026-06-05 21:45:45 +08:00
004dba7db6 新增: 音频可视化8种主题+节拍检测+交互效果 2026-06-05 09:50:32 +08:00
1f892fc390 新增: 音频可视化壁纸主题(WASAPI+FFT+Canvas) 2026-05-30 22:29:19 +08:00
cd7247d880 新增: 功能路线图 + 9份需求契约
- feature-roadmap.md: 现有能力盘点 + P1/P2/P3方向 + 竞品参考
- AI深度视差: 远程推理方案(服务端Depth-Anything + 客户端WebGL)
- 自动升级模块文档: 设计规格/任务规划/决策记录
- 9份需求契约: 视频/颜色提取/时间段/番茄钟/播放列表/电池/网页/便签/监控
2026-05-28 19:06:39 +08:00
f5a473c4b8 新增: 自动升级体系 + WorkerW重建检测 + 知识卡片优化
自动升级:
- 版本变量注入 (-ldflags -X main.version)
- 远程 version.json 检查 + 弹窗提示(非强制右下角/强制居中)
- updater.exe 嵌入主程序, 运行时释放到临时目录
- 下载+SHA256校验+备份+替换+回滚 全流程
- 托盘菜单"检查更新"

稳定性:
- WorkerW 重建自动检测(每10s)并重新嵌入
- 左键托盘点击防抖
- 设置窗口已打开时正确前置

优化:
- 知识卡片prompt改为面向开发者的硬核内容
- 配置目录统一到 ~/.u-desktop/
- 设置窗口WebView2数据目录改为configDir下
2026-05-28 19:06:26 +08:00
49 changed files with 4392 additions and 162 deletions

3
.gitignore vendored
View File

@@ -2,6 +2,9 @@
*.exe
*.exe~
# Build artifacts (updater.exe at root is build output for embed)
updater.exe
# Logs
*.log

106
README.md
View File

@@ -1,17 +1,18 @@
# u-desktop
Windows 桌面壁纸增强工具 — 将 WebView2 嵌入桌面壁纸层在壁纸上叠加时间、天气、星座运势、AI 资讯等信息卡片。
Windows 桌面壁纸增强工具 — 将 WebView2 嵌入桌面壁纸层在壁纸上叠加时间、天气、星座运势、AI 资讯、电子相册等信息卡片。
## 功能
| 模块 | 说明 |
|------|------|
| 壁纸 | 6 种主题动画(极光/星空/渐变/粒子/极光流体/文字、本地图片、Bing 每日壁纸、纯色/渐变 |
| 壁纸 | 6 种主题动画(极光/星空/渐变/粒子/极光流体/文字、本地图片、Bing 每日壁纸(导航+收藏)、纯色/渐变 |
| 时间 | 大字时钟,支持秒显示开关,整点光晕动画,节日倒计时 |
| 天气 | 和风天气 API当前天气 + 24h/7d 预报IP 自动定位 + 手动选城 |
| 星座运势 | 天聚数行 API5 维指数进度条 + 幸运标签 + 今日概述 |
| 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 RuntimeWin11 已内置)。
## 交互
| 操作 | 行为 |
|------|------|
| 左键托盘图标 | 打开桌面设置 |
| 右键托盘图标 | 弹出菜单(桌面设置/重启/退出) |
| 设置窗口 | 所有配置实时生效,无需重启 |
## 依赖
```
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
View 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)
}
}

View File

@@ -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"

View File

@@ -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 = "射手座"
@@ -118,6 +126,10 @@ func defaultConfig() *Config {
Theme: ThemeAurora,
Color1: "#1a1a2e",
Color2: "#16213e",
AudioVizStyle: "bars",
AudioSensitivity: 1.5,
AudioSmoothing: 0.7,
AudioColorScheme: "neon",
}
}

View File

@@ -0,0 +1,24 @@
# 自动升级模块
**模块状态**:待开发
**最后更新**2026-05-28
---
## 快速导航
| 类型 | 文档 | 说明 |
|------|------|------|
| 规划 | [任务规划.md](./任务规划.md) | 实施步骤和检查清单 |
| 设计 | [设计文档/自动升级功能设计.md](./设计文档/自动升级功能设计.md) | 完整功能设计 |
| 决策 | [决策记录/](./决策记录/) | 架构和设计决策 |
| 问题 | [问题追踪/](./问题追踪/) | 待解决问题 |
| 检查 | [核对报告/](./核对报告/) | 实现检查报告 |
| 测试 | [测试用例/](./测试用例/) | 测试用例 |
## 核心设计
- **弹窗策略**:非强制 → 右下角气泡(稍后 + 立即升级),强制 → 居中弹窗(仅立即升级)
- **架构**:主程序检查版本 → 弹窗提示 → 启动 updater.exe → 下载替换重启
- **updater.exe**通用升级器参数化驱动可复用于其他产品u-tabs 等)
- **商业化预留**API 预留 tier/license 字段,升级器不含商业逻辑

View 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静态文件

View File

@@ -0,0 +1,17 @@
# 决策记录
记录自动升级模块的架构和设计决策。
## 格式
```
ADR-{编号} - {标题}
状态:提议 | 已接受 | 已废弃
上下文:...
决策:...
理由:...
```
## 已记录
(暂无)

View File

@@ -0,0 +1,3 @@
# 核对报告
实现过程中的检查报告。

View File

@@ -0,0 +1,3 @@
# 测试用例
自动升级功能的测试用例。

View 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 |

View File

@@ -0,0 +1,10 @@
# 问题追踪
## 目录
- `待讨论/` — 需要讨论后决定的问题
- `待实现/` — 已确定但未实现的功能点
## 当前问题
(暂无)

View File

@@ -0,0 +1,38 @@
# 便签/待办卡片 — 需求契约
> 创建时间2026-05-28
> 状态:需求待确认
## 目标
桌面显示轻量 TODO 清单,数据持久化。
## 用户场景
1. 用户在设置页添加待办事项
2. 桌面卡片显示待办列表(勾选框 + 文字)
3. 点击勾选完成,已完成的显示删除线
4. 数据本地持久化
## 验收标准
- [ ] 添加/删除待办事项
- [ ] 勾选完成/取消完成
- [ ] 最多显示 N 条(如 10 条),超出滚动
- [ ] 数据持久化到本地 JSON
## 本次范围
- 基础 TODO 列表
## 非目标
- 分组/标签/优先级
- 截止日期提醒
- 同步到云端
## 约束
- 前端组件 + config JSON 持久化
- 复用卡片架构
- 设置页提供添加/删除/编辑入口

View File

@@ -0,0 +1,38 @@
# 壁纸播放列表 — 需求契约
> 创建时间2026-05-28
> 状态:需求待确认
## 目标
用户将 Bing 收藏 + 本地图片 + 视频组成播放列表,定时轮播。
## 用户场景
1. 用户在设置页勾选多个壁纸加入播放列表
2. 设置轮播间隔5 分钟 / 10 分钟 / 30 分钟 / 1 小时)
3. 壁纸按顺序或随机切换,淡入淡出过渡
## 验收标准
- [ ] 播放列表支持图片 + 视频 + Bing 壁纸
- [ ] 可配置轮播间隔
- [ ] 顺序 / 随机模式
- [ ] 切换时淡入淡出过渡
- [ ] 播放列表持久化
## 本次范围
- 基础播放列表 + 定时轮播
## 非目标
- 按时间段分配不同列表
- 按星期几分配不同列表
- 在线同步播放列表
## 约束
- 复用现有壁纸切换逻辑
- config 新增 playlist 数组 + interval + shuffle 字段
- Go 端定时器驱动切换

View File

@@ -0,0 +1,45 @@
# 壁纸颜色提取 + 卡片自适应 — 需求契约
> 创建时间2026-05-28
> 状态:需求已确认
## 目标
从当前壁纸自动提取主色调,动态调整信息卡片的文字颜色和背景,确保在任何壁纸上都可读。
## 用户场景
1. 用户切换壁纸后,卡片文字颜色自动适配(深色壁纸白字、浅色壁纸黑字)
2. 卡片背景增加半透明遮罩,提升文字可读性
3. 用户无需手动调色,全程自动
## 验收标准
- [ ] 壁纸切换后 1s 内完成颜色提取
- [ ] 文字颜色自动切换:深底白字 / 浅底黑字
- [ ] 卡片背景增加自适应半透明遮罩
- [ ] 支持所有壁纸类型(主题/图片/Bing/视频/颜色)
- [ ] 提取结果缓存,同一壁纸不重复计算
## 本次范围
- 壁纸主色调提取
- 卡片文字颜色自适应
- 卡片背景遮罩
## 非目标
- 完整调色板提取(只取主色调)
- 用户手动覆盖颜色
## 约束
- 前端 Canvas 采样,不依赖 Go 端图像处理
- 采样频率:壁纸切换时触发一次
- 不影响壁纸渲染性能
## 技术方案
- 壁纸切换后Canvas 绘制壁纸帧 → 采样若干像素点 → 取平均值 → 判断明暗
- 前端状态管理增加 `isDarkWallpaper` 变量
- 卡片组件根据 `isDarkWallpaper` 切换 CSS class

View 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 |
| 用户关闭天气卡片 | 经纬度使用上次缓存值 |

View File

@@ -0,0 +1,37 @@
# 电池感知 — 需求契约
> 创建时间2026-05-28
> 状态:需求待确认
## 目标
笔记本拔电时自动暂停动画壁纸,省电。插电后恢复。
## 用户场景
1. 用户拔掉电源线 → 动画壁纸暂停,显示静态帧
2. 重新插电 → 壁纸恢复动画
3. 设置页可开关此功能
## 验收标准
- [ ] 检测电源状态变化(`GetSystemPowerStatus`
- [ ] 拔电暂停动画壁纸
- [ ] 插电恢复动画
- [ ] 桌面右下角托盘气泡提示"已切换为省电模式"
- [ ] 静态壁纸(图片/纯色)不受影响
## 本次范围
- 拔电暂停 / 插电恢复
## 非目标
- 用户自定义省电策略(如暂停哪些卡片)
- 电量低于 X% 自动暂停
## 约束
- Go 端 `GetSystemPowerStatus` 轮询(每 10 秒)
- evalJS 调用前端 pause/resume
- config 新增 `batteryAware bool`

View 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

View 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 间隔
- 卡片样式紧凑,不抢视觉焦点

View File

@@ -0,0 +1,45 @@
# 网页壁纸 — 需求契约
> 创建时间2026-05-28
> 状态:需求待确认
## 目标
用户输入 URLWebView2 直接渲染任意网页作为桌面壁纸。
## 用户场景
1. 用户在设置页输入网址(如 Shadertoy、CodePen、任意网页
2. 桌面壁纸变为该网页的实时渲染
3. 支持交互式网页(鼠标穿透可选开关)
## 验收标准
- [ ] 支持输入任意 HTTPS URL
- [ ] 网页实时渲染为壁纸
- [ ] URL 持久化,重启恢复
- [ ] 网页加载失败时显示错误提示
## 本次范围
- 单个 URL 渲染为壁纸
## 非目标
- 网页收藏列表
- 网页交互(穿透模式下无法点击)
- 本地 HTML 文件
## 约束
- 复用 WebView2 实例,替换壁纸 HTML 为 `<iframe src="url">`
- iframe 需考虑跨域限制
- 安全禁止加载本地文件路径file:///
## 风险
| 风险 | 缓解 |
|------|------|
| 部分网站禁止 iframe 嵌入 | 提示用户该网站不支持 |
| 网页 JS 报错影响壁纸 | sandbox 属性隔离 |
| 网页持续消耗资源 | 全屏暂停复用 |

View 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 新增"视频壁纸"选项

View 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 解码为 float64int32 静默失败
wv.Bind("setInteractiveRect", func(left, top, right, bottom int32) string { ... })
// ✅ 正确:用 float64
wv.Bind("setInteractiveRect", func(left, top, right, bottom float64) string { ... })
```
### 6. DPR 缩放坐标转换
JS `getBoundingClientRect()` 返回 CSS 像素,`ScreenToClient` 返回物理像素。必须乘 DPR
```javascript
const dpr = window.devicePixelRatio || 1
setInteractiveRect(
Math.round(r.left * dpr), Math.round(r.top * dpr),
Math.round(r.right * dpr), Math.round(r.bottom * dpr)
)
```
### 7. HTTP 429 重试时 body 被消耗
```go
// ❌ 错误:第一次请求消耗了 body重试发送空 body
resp, err := httpClient.Do(req)
if resp.StatusCode == 429 {
time.Sleep(3 * time.Second)
resp, err = httpClient.Do(req) // body 已经 EOF
}
// ✅ 正确:创建新 Request
req2, _ := http.NewRequest("POST", cpaURL, bytes.NewReader(jsonData))
resp2, _ := httpClient.Do(req2)
```
### 8. WindowFromPoint + Progman 判断桌面遮挡
```go
func isDesktopAtPoint(screenX, screenY int32) bool {
ptPacked := uintptr(screenX) | (uintptr(uint32(screenY)) << 32)
hwndAt, _, _ := procWindowFromPoint.Call(ptPacked)
for h := hwndAt; h != 0; h, _, _ = procGetParent.Call(h) {
if h == progmanHwnd {
return true
}
}
return false
}
```
**用途**:判断点击位置是否有其他窗口遮挡。只有桌面背景可见时才消费点击事件,避免拦截其他软件的点击。
---
## 最终架构
```
WH_MOUSE_LL 全局钩子
├── WM_MOUSEMOVE (0x0200)
│ └── 检查鼠标是否在按钮 hit rect 内
│ ├── 进入 → 按钮添加 hover-active 样式(旋转+放大+高亮)
│ └── 离开 → 移除样式
├── WM_LBUTTONDOWN (0x0201)
│ ├── WindowFromPoint 检查是否桌面背景可见
│ ├── ScreenToClient 转换到 wvHwnd 坐标
│ └── 命中按钮 rect → pushKnowledgeLoading + goroutine 刷新
└── 其他事件 → CallNextHookEx 传递
```
**hit rect 来源**JS 端 `getBoundingClientRect() * DPR`,仅覆盖刷新按钮(+8px 扩展)。
**冷却机制**30s 内重复点击回退显示缓存内容,不停留在"加载中"。
---
## 关键文件
| 文件 | 改动 |
|------|------|
| `win32.go` | 鼠标钩子、WindowFromPoint 桌面判断、Progman 句柄 |
| `knowledge.go` | deepseek-v4-pro 模型、质量检查、冷却回退 |
| `KnowledgeCard.vue` | 按钮 hit rect、hover-active 样式 |
| `systray.go` | setInteractiveRect float64 参数 |
---
## 速查Win32 API 要点
| API | 陷阱 | 正确做法 |
|-----|------|----------|
| `SetWindowLongPtrW` | 负数索引常量 | `^uintptr(3)` 表示 -4 |
| `windows.NewCallback` | GC 不跟踪外部引用 | 存包级变量 |
| `WindowFromPoint` | POINT 按值非指针 | 打包为 uintptr |
| `SetCursor` (WH_MOUSE_LL) | 被后续 WM_SETCURSOR 覆盖 | 不可靠,放弃 |
| `ScreenToClient` | 物理像素 | JS 侧乘 DPR |
| `go-webview2 Bind` | JSON number = float64 | 参数用 float64 |

141
docs/feature-roadmap.md Normal file
View 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 | **网页壁纸** | 用户输入 URLWebView2 直接渲染任意网页 | 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-smallDocker 部署
- 部署位置:复用 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) | 开发中 |

View File

@@ -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/` 目录会删除用户配置。用户目录不受影响,且多版本共用同一配置。

View File

@@ -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,20 +99,34 @@ func getKnowledgeCardCount(keyword string) int {
return count
}
// systemPromptCodeMark 用于在 Go raw string 中拼接反引号
const systemPromptCodeMark = "`"
const knowledgeSystemPrompt = `你是一位在一线写了 15 年底层系统代码的工程师,只在踩过真实生产级坑之后才写知识卡片。
内容铁律:
- 必须是大多数人不知道的冷知识、反直觉行为、或版本变更导致的隐蔽 break
- 必须包含至少一个精确数值(阈值/延迟/版本号/缓冲区大小/默认值等)
- 必须讲清因果链:触发条件 → 内部机制 → 实际后果 → 正确做法
- 禁止泛泛而谈,禁止复述文档,禁止入门级内容
- 每次从不同角度切入,不要重复之前的知识点
格式:
- Markdown**加粗**重点、` + systemPromptCodeMark + `行内代码` + systemPromptCodeMark + `标记术语、简短列表
- 120-200字紧凑无废话
- 不要 emoji`
func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt)
messages := []map[string]string{
{
"role": "system",
"content": "你是严谨的中文知识卡片作者,输出必须具体、准确、有信息密度。不要写空泛鸡汤,不要只给一句定义。",
},
{"role": "system", "content": knowledgeSystemPrompt},
{"role": "user", "content": basePrompt},
}
body := map[string]interface{}{
"model": cpaModel,
"max_tokens": 512,
"temperature": 0.55,
"max_completion_tokens": 2048,
"temperature": 0.8,
"messages": messages,
}
content := requestKnowledgeCompletion(body)
@@ -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"`
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
}

View File

@@ -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()

View File

@@ -12,6 +12,7 @@ import (
"unsafe"
"github.com/jchv/go-webview2"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
@@ -20,8 +21,11 @@ var settingsHTML string
var (
settingsMu sync.Mutex
settingsOpen bool
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()
@@ -196,6 +218,10 @@ func openSettingsWindow() {
"photoCard": !cfg.HidePhoto,
"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)
}
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("设置窗口消息循环退出")
}()
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,3 @@
module updater
go 1.26.3

218
updater/main.go Normal file
View 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("已回滚并启动旧版本")
}

View File

@@ -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)

View File

@@ -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{
"晴": "☀️", "多云": "⛅", "阴": "☁️",

View File

@@ -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
}
}
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)

View File

@@ -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))

View File

@@ -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

View File

@@ -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()) }

View File

@@ -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))
}

View File

@@ -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),
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

126
web/update.html Normal file
View 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>

152
win32.go
View File

@@ -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
@@ -43,6 +52,7 @@ const (
const (
gwlStyle = uintptr(0xFFFFFFF0) // GWL_STYLE = -16
gwlExStyle = uintptr(0xFFFFFFFEC) // GWL_EXSTYLE = -20
wsPopup = uintptr(0x80000000)
wsVisible = uintptr(0x10000000)
wsChild = uintptr(0x02000000)
@@ -50,8 +60,149 @@ const (
wsMaxbox = uintptr(0x00010000)
)
const gwlpWndProc = ^uintptr(3) // GWLP_WNDPROC = -4
const (
wsExTransparent = uintptr(0x00000020)
wsExLayered = uintptr(0x00080000)
wsExToolwindow = uintptr(0x00000080)
)
const (
gwChild = 5 // GW_CHILD
gwHwndNext = 2 // GW_HWNDNEXT
)
var htmlQueue = make(chan string, 1)
// overlay 窗口子类化:动态 hit test
var (
overlayOldWndProc uintptr
overlayWndProcCb uintptr
overlayHitRect struct{ Left, Top, Right, Bottom int32 }
overlayHasHitRect bool
childOldWndProcs = make(map[uintptr]uintptr) // 子窗口 hwnd -> 旧 wndproc
)
// overlayWndProc 拦截 WM_NCHITTEST交互区域返回 HTCLIENT其余 HTTRANSPARENT穿透
// 注意:由于 overlay 在桌面图标层之下,此 wndproc 主要作为备用
func overlayWndProc(hwnd, msg, wp, lp uintptr) uintptr {
if msg == 0x0084 && overlayHasHitRect { // WM_NCHITTEST
x := int32(int16(lp & 0xFFFF))
y := int32(int16(lp >> 16))
pt := struct{ X, Y int32 }{x, y}
procScreenToClient.Call(wvHwnd, uintptr(unsafe.Pointer(&pt)))
if pt.X >= overlayHitRect.Left && pt.X <= overlayHitRect.Right &&
pt.Y >= overlayHitRect.Top && pt.Y <= overlayHitRect.Bottom {
return uintptr(1) // HTCLIENT
}
return 0xFFFFFFFF // HTTRANSPARENT
}
// 查找该窗口的旧 wndproc
oldProc := overlayOldWndProc
if v, ok := childOldWndProcs[hwnd]; ok {
oldProc = v
}
r, _, _ := procCallWindowProcW.Call(oldProc, hwnd, msg, wp, lp)
return r
}
// installMouseHook 安装 WH_MOUSE_LL 全局钩子拦截刷新按钮点击
// overlay 在 WorkerW 中位于桌面图标层之下WM_NCHITTEST 无法到达
// 因此用低级鼠标钩子直接在 Go 层处理点击
var llMouseHook uintptr
var llMouseHookCb uintptr // 防止 GC 回收回调函数
var progmanHwnd uintptr // Progman 句柄,用于判断点击是否在桌面
var btnHoverActive bool // 按钮悬停状态,避免重复推送 JS
// isMouseInBtnRect 检查鼠标是否在按钮 hit rect 内(屏幕坐标转 wvHwnd 客户端坐标)
func isMouseInBtnRect(screenX, screenY int32) bool {
if !overlayHasHitRect {
return false
}
pt := struct{ X, Y int32 }{screenX, screenY}
procScreenToClient.Call(wvHwnd, uintptr(unsafe.Pointer(&pt)))
return pt.X >= overlayHitRect.Left && pt.X <= overlayHitRect.Right &&
pt.Y >= overlayHitRect.Top && pt.Y <= overlayHitRect.Bottom
}
// isDesktopAtPoint 检查屏幕坐标处是否为桌面背景(无其他窗口遮挡)
func isDesktopAtPoint(screenX, screenY int32) bool {
ptPacked := uintptr(screenX) | (uintptr(uint32(screenY)) << 32)
hwndAt, _, _ := procWindowFromPoint.Call(ptPacked)
for h := hwndAt; h != 0; h, _, _ = procGetParent.Call(h) {
if h == progmanHwnd {
return true
}
}
return false
}
func installMouseHook() {
llMouseHookCb = windows.NewCallback(func(nCode, wp, lp uintptr) uintptr {
if int32(nCode) >= 0 {
type msll struct {
X, Y int32
MouseData uint32
Flags uint32
Time uint32
DwExtraInfo uintptr
}
s := (*msll)(unsafe.Pointer(lp))
switch wp {
case 0x0200: // WM_MOUSEMOVE — 悬停检测
inBtn := isMouseInBtnRect(s.X, s.Y)
if inBtn && !btnHoverActive {
btnHoverActive = true
evalJS("document.querySelector('.knowledge-refresh-btn')?.classList.add('hover-active')")
} else if !inBtn && btnHoverActive {
btnHoverActive = false
evalJS("document.querySelector('.knowledge-refresh-btn')?.classList.remove('hover-active')")
}
case 0x0201: // WM_LBUTTONDOWN — 点击
if !isDesktopAtPoint(s.X, s.Y) {
break
}
if isMouseInBtnRect(s.X, s.Y) {
pushKnowledgeLoading("")
go fetchAndPushKnowledge()
return 1
}
}
}
r, _, _ := procCallNextHookEx.Call(llMouseHook, nCode, wp, lp)
return r
})
var llMouseHookVal uintptr
llMouseHookVal, _, _ = procSetWindowsHookExW.Call(
14, // WH_MOUSE_LL
llMouseHookCb, 0, 0,
)
llMouseHook = llMouseHookVal
log.Printf("mouse hook installed, progman=0x%x, hook=0x%x", progmanHwnd, llMouseHookVal)
}
// subclassOverlayWindows 子类化 WebView2 父窗口及其所有子窗口
func subclassOverlayWindows() {
overlayWndProcCb = windows.NewCallback(overlayWndProc)
oldProc, _, _ := procSetWindowLongPtrW.Call(wvHwnd, gwlpWndProc, overlayWndProcCb)
overlayOldWndProc = oldProc
subclassChildTree(wvHwnd)
}
// subclassChildTree 递归子类化 parent 下的所有子窗口
func subclassChildTree(parent uintptr) {
child, _, _ := procGetWindow.Call(parent, uintptr(gwChild))
for child != 0 {
old, _, _ := procSetWindowLongPtrW.Call(child, gwlpWndProc, overlayWndProcCb)
childOldWndProcs[child] = old
subclassChildTree(child)
child, _, _ = procGetWindow.Call(child, uintptr(gwHwndNext))
}
}
var (
classProgman = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("Progman")))
classShellDefView = uintptr(unsafe.Pointer(windows.StringToUTF16Ptr("SHELLDLL_DefView")))
@@ -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)))