新增: 自动升级体系 + WorkerW重建检测 + 知识卡片优化

自动升级:
- 版本变量注入 (-ldflags -X main.version)
- 远程 version.json 检查 + 弹窗提示(非强制右下角/强制居中)
- updater.exe 嵌入主程序, 运行时释放到临时目录
- 下载+SHA256校验+备份+替换+回滚 全流程
- 托盘菜单"检查更新"

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

优化:
- 知识卡片prompt改为面向开发者的硬核内容
- 配置目录统一到 ~/.u-desktop/
- 设置窗口WebView2数据目录改为configDir下
This commit is contained in:
2026-05-28 19:06:26 +08:00
parent aee7997195
commit f5a473c4b8
23 changed files with 859 additions and 90 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

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

@@ -71,6 +71,9 @@ 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"`
}
const defaultZodiac = "射手座"

View File

@@ -17,7 +17,7 @@ import (
)
const cpaURL = "https://cpa.1216.top/v1/chat/completions"
const cpaModel = "glm-4.5-air"
const cpaModel = "glm-5.1"
const minKnowledgeRunes = 80
type knowledgeData struct {
@@ -100,17 +100,28 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt)
messages := []map[string]string{
{
"role": "system",
"content": "你是严谨的中文知识卡片作者,输出必须具体、准确、有信息密度。不要写空泛鸡汤,不要只给一句定义。",
"role": "system",
"content": `你是一位资深技术专家,面向有多年经验的开发者撰写知识卡片。
内容标准:
- 必须包含可直接用于生产的硬核知识:底层原理、性能陷阱、边界条件、源码级细节
- 优先讲"为什么"而非"是什么"——机制、权衡、踩坑经验
- 用具体数值、代码片段(仅关键词级别)、或真实场景佐证
- 读者看完能立刻在项目中应用或避免踩坑
禁止:
- 入门级科普、"是什么"式定义、百科搬运
- 空泛鸡汤("很重要"、"提升效率"、"广泛应用"
- 标题、序号、Markdown标记、emoji`,
},
{"role": "user", "content": basePrompt},
}
body := map[string]interface{}{
"model": cpaModel,
"max_tokens": 512,
"temperature": 0.55,
"messages": messages,
"model": cpaModel,
"max_completion_tokens": 2048,
"temperature": 0.55,
"messages": messages,
}
content := requestKnowledgeCompletion(body)
content = normalizeKnowledgeContent(content)
@@ -137,13 +148,13 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
}
func buildKnowledgePrompt(keyword, customPrompt string) string {
basePrompt := fmt.Sprintf(`围绕关键词「%s」生成一条桌面知识卡片。
硬性要求:
1. 120-180个中文字符分成2-3句
2. 必须讲清一个具体机制、原理、权衡或实践经验;
3. 必须包含一个具体例子、场景或判断标准;
4. 避免“很重要、非常有用、提升效率”这类空泛表述;
5. 不要标题、号、Markdown、表情,直接输出正文。`, keyword)
basePrompt := fmt.Sprintf(`围绕「%s」写一条面向资深开发者的技术知识卡片。
要求:
- 100-160字2-3句连贯正文
- 必须触及底层机制、性能细节或生产踩坑经验
- 必须包含一个具体场景或数值佐证(如延迟数据、内存阈值、版本差异等)
- 直接输出正文,不要标题、号、Markdown`, keyword)
if customPrompt != "" {
basePrompt += "\n附加风格要求不能覆盖上面的字数和质量要求" + customPrompt
}
@@ -173,10 +184,16 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Printf("知识API返回状态码: %d", resp.StatusCode)
return ""
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
}
@@ -185,7 +202,11 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
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 ""
}
@@ -256,6 +277,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

@@ -104,10 +104,12 @@ func refreshVisibleCards(cfg, oldCfg *Config) {
func openSettingsWindow() {
settingsMu.Lock()
if settingsOpen && settingsHwnd != 0 {
if settingsOpen {
settingsMu.Unlock()
procShowWindow.Call(settingsHwnd, 9)
procGetForegroundWindow.Call(settingsHwnd)
if settingsHwnd != 0 {
procShowWindow.Call(settingsHwnd, 9)
procGetForegroundWindow.Call(settingsHwnd)
}
return
}
settingsOpen = true
@@ -123,7 +125,7 @@ func openSettingsWindow() {
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{
@@ -194,7 +196,7 @@ func openSettingsWindow() {
"photoDir": cfg.PhotoDir,
"photoInterval": cfg.PhotoInterval,
"photoCard": !cfg.HidePhoto,
"photoFrameMode": cfg.PhotoFrameMode,
"photoFrameMode": cfg.PhotoFrameMode,
"autoStart": isAutoStartEnabled(),
})
return string(data)
@@ -252,13 +254,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 +268,9 @@ func openSettingsWindow() {
stopPhotoLoop()
}
}
} else {
saveConfig(cfg)
refreshVisibleCards(cfg, &oldCfg)
}
saveConfig(cfg)
refreshVisibleCards(cfg, &oldCfg)
return ""
})
@@ -383,15 +384,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
@@ -494,9 +495,10 @@ func openSettingsWindow() {
w.SetHtml(settingsHTML)
hwnd := uintptr(w.Window())
// disable resize
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox))
// 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
w.Bind("resizeToFit", func(contentW, contentH int) string {

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,7 @@ func onSystrayReady() {
go bingWallpaperLoop()
go knowledgeLoop()
go startPhotoLoop()
go runAutoUpdateCheck()
}
func startWebView() {
@@ -89,10 +115,10 @@ func startWebView() {
wvHwnd = uintptr(wv.Window())
procShowWindow.Call(wvHwnd, 0) // SW_HIDE
procSetWindowLongPtrW.Call(wvHwnd, gwlStyle, wsPopup|wsVisible|wsChild)
procSetWindowLongPtrW.Call(wvHwnd, gwlExStyle, wsExTransparent|wsExLayered)
// 立即嵌入 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 {
@@ -158,11 +184,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

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

View File

@@ -1,10 +1,15 @@
<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>
</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>
<span class="knowledge-text">{{ state.knowledge.content }}</span>
</div>
<div class="knowledge-placeholder" v-else>请在设置中配置知识关键字</div>
</div>
</template>
@@ -13,19 +18,49 @@ import { state } from '../composables/useOverlayState'
</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-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: 8;
-webkit-box-orient: vertical; overflow: hidden;
}
.knowledge-placeholder {
font-size: 13px; color: var(--text-faint);
font-style: italic; padding-top: 8px;
}
</style>

View File

@@ -68,10 +68,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

@@ -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,7 +26,7 @@
</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>
@@ -135,7 +135,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))
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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>

View File

@@ -29,6 +29,7 @@ var (
procPostMessageW = user32.NewProc("PostMessageW")
procTranslateMessage = user32.NewProc("TranslateMessage")
procDispatchMessageW = user32.NewProc("DispatchMessageW")
procGetParent = user32.NewProc("GetParent")
)
var wv webview2.WebView
@@ -42,12 +43,19 @@ const (
)
const (
gwlStyle = uintptr(0xFFFFFFF0) // GWL_STYLE = -16
wsPopup = uintptr(0x80000000)
wsVisible = uintptr(0x10000000)
wsChild = uintptr(0x02000000)
wsSizebox = uintptr(0x00040000)
wsMaxbox = uintptr(0x00010000)
gwlStyle = uintptr(0xFFFFFFF0) // GWL_STYLE = -16
gwlExStyle = uintptr(0xFFFFFFFEC) // GWL_EXSTYLE = -20
wsPopup = uintptr(0x80000000)
wsVisible = uintptr(0x10000000)
wsChild = uintptr(0x02000000)
wsSizebox = uintptr(0x00040000)
wsMaxbox = uintptr(0x00010000)
)
const (
wsExTransparent = uintptr(0x00000020)
wsExLayered = uintptr(0x00080000)
wsExToolwindow = uintptr(0x00000080)
)
var htmlQueue = make(chan string, 1)