新增: 自动升级体系 + WorkerW重建检测 + 知识卡片优化
自动升级: - 版本变量注入 (-ldflags -X main.version) - 远程 version.json 检查 + 弹窗提示(非强制右下角/强制居中) - updater.exe 嵌入主程序, 运行时释放到临时目录 - 下载+SHA256校验+备份+替换+回滚 全流程 - 托盘菜单"检查更新" 稳定性: - WorkerW 重建自动检测(每10s)并重新嵌入 - 左键托盘点击防抖 - 设置窗口已打开时正确前置 优化: - 知识卡片prompt改为面向开发者的硬核内容 - 配置目录统一到 ~/.u-desktop/ - 设置窗口WebView2数据目录改为configDir下
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
|||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
|
|
||||||
|
# Build artifacts (updater.exe at root is build output for embed)
|
||||||
|
updater.exe
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ $gitHash = git rev-parse --short HEAD
|
|||||||
$version = "$(Get-Date -Format 'yyyyMMdd').$gitHash"
|
$version = "$(Get-Date -Format 'yyyyMMdd').$gitHash"
|
||||||
Write-Host "=== 构建 $project v$version ===" -ForegroundColor Cyan
|
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:GOOS = "windows"
|
||||||
$env:GOARCH = "amd64"
|
$env:GOARCH = "amd64"
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ type Config struct {
|
|||||||
QWeatherKey string `json:"qweatherKey,omitempty"`
|
QWeatherKey string `json:"qweatherKey,omitempty"`
|
||||||
TianapiKey string `json:"tianapiKey,omitempty"`
|
TianapiKey string `json:"tianapiKey,omitempty"`
|
||||||
CPAKey string `json:"cpaKey,omitempty"`
|
CPAKey string `json:"cpaKey,omitempty"`
|
||||||
|
UpdateURL string `json:"updateUrl,omitempty"`
|
||||||
|
AutoCheckUpdate bool `json:"autoCheckUpdate"`
|
||||||
|
SkipVersion string `json:"skipVersion,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultZodiac = "射手座"
|
const defaultZodiac = "射手座"
|
||||||
|
|||||||
54
knowledge.go
54
knowledge.go
@@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const cpaURL = "https://cpa.1216.top/v1/chat/completions"
|
const cpaURL = "https://cpa.1216.top/v1/chat/completions"
|
||||||
const cpaModel = "glm-4.5-air"
|
const cpaModel = "glm-5.1"
|
||||||
const minKnowledgeRunes = 80
|
const minKnowledgeRunes = 80
|
||||||
|
|
||||||
type knowledgeData struct {
|
type knowledgeData struct {
|
||||||
@@ -100,17 +100,28 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
|||||||
basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt)
|
basePrompt := buildKnowledgePrompt(keyword, cfg.KnowledgePrompt)
|
||||||
messages := []map[string]string{
|
messages := []map[string]string{
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": "你是严谨的中文知识卡片作者,输出必须具体、准确、有信息密度。不要写空泛鸡汤,不要只给一句定义。",
|
"content": `你是一位资深技术专家,面向有多年经验的开发者撰写知识卡片。
|
||||||
|
|
||||||
|
内容标准:
|
||||||
|
- 必须包含可直接用于生产的硬核知识:底层原理、性能陷阱、边界条件、源码级细节
|
||||||
|
- 优先讲"为什么"而非"是什么"——机制、权衡、踩坑经验
|
||||||
|
- 用具体数值、代码片段(仅关键词级别)、或真实场景佐证
|
||||||
|
- 读者看完能立刻在项目中应用或避免踩坑
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
- 入门级科普、"是什么"式定义、百科搬运
|
||||||
|
- 空泛鸡汤("很重要"、"提升效率"、"广泛应用")
|
||||||
|
- 标题、序号、Markdown标记、emoji`,
|
||||||
},
|
},
|
||||||
{"role": "user", "content": basePrompt},
|
{"role": "user", "content": basePrompt},
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"model": cpaModel,
|
"model": cpaModel,
|
||||||
"max_tokens": 512,
|
"max_completion_tokens": 2048,
|
||||||
"temperature": 0.55,
|
"temperature": 0.55,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
}
|
}
|
||||||
content := requestKnowledgeCompletion(body)
|
content := requestKnowledgeCompletion(body)
|
||||||
content = normalizeKnowledgeContent(content)
|
content = normalizeKnowledgeContent(content)
|
||||||
@@ -137,13 +148,13 @@ func fetchKnowledgeFromLLM(keyword string, cfg *Config) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildKnowledgePrompt(keyword, customPrompt string) string {
|
func buildKnowledgePrompt(keyword, customPrompt string) string {
|
||||||
basePrompt := fmt.Sprintf(`围绕关键词「%s」生成一条桌面知识卡片。
|
basePrompt := fmt.Sprintf(`围绕「%s」写一条面向资深开发者的技术知识卡片。
|
||||||
硬性要求:
|
|
||||||
1. 120-180个中文字符,分成2-3句;
|
要求:
|
||||||
2. 必须讲清一个具体机制、原理、权衡或实践经验;
|
- 100-160字,2-3句连贯正文
|
||||||
3. 必须包含一个具体例子、场景或判断标准;
|
- 必须触及底层机制、性能细节或生产踩坑经验
|
||||||
4. 避免“很重要、非常有用、提升效率”这类空泛表述;
|
- 必须包含一个具体场景或数值佐证(如延迟数据、内存阈值、版本差异等)
|
||||||
5. 不要标题、序号、Markdown、表情,直接输出正文。`, keyword)
|
- 直接输出正文,不要标题、编号、Markdown`, keyword)
|
||||||
if customPrompt != "" {
|
if customPrompt != "" {
|
||||||
basePrompt += "\n附加风格要求(不能覆盖上面的字数和质量要求):" + customPrompt
|
basePrompt += "\n附加风格要求(不能覆盖上面的字数和质量要求):" + customPrompt
|
||||||
}
|
}
|
||||||
@@ -173,10 +184,16 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
log.Printf("知识API返回状态码: %d", resp.StatusCode)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
}
|
}
|
||||||
@@ -185,7 +202,11 @@ func requestKnowledgeCompletion(body map[string]interface{}) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if len(result.Choices) > 0 {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -256,6 +277,7 @@ func fetchAndPushKnowledge() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if content == "" {
|
if content == "" {
|
||||||
|
pushKnowledgeJSON("知识卡片加载失败,稍后重试", keyword)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
main.go
6
main.go
@@ -28,6 +28,8 @@ func setupLog() {
|
|||||||
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var version = "dev" // -ldflags "-X main.version=0.6.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
setupLog()
|
setupLog()
|
||||||
|
|
||||||
@@ -57,8 +59,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer windows.CloseHandle(mutex)
|
defer windows.CloseHandle(mutex)
|
||||||
|
|
||||||
exePath, _ := os.Executable()
|
homeDir, _ := os.UserHomeDir()
|
||||||
cfgDir := filepath.Join(filepath.Dir(exePath), "config")
|
cfgDir := filepath.Join(homeDir, ".u-desktop")
|
||||||
os.MkdirAll(cfgDir, 0755)
|
os.MkdirAll(cfgDir, 0755)
|
||||||
configPath = filepath.Join(cfgDir, "settings.json")
|
configPath = filepath.Join(cfgDir, "settings.json")
|
||||||
procSetProcessDPIAware.Call()
|
procSetProcessDPIAware.Call()
|
||||||
|
|||||||
34
settings.go
34
settings.go
@@ -104,10 +104,12 @@ func refreshVisibleCards(cfg, oldCfg *Config) {
|
|||||||
|
|
||||||
func openSettingsWindow() {
|
func openSettingsWindow() {
|
||||||
settingsMu.Lock()
|
settingsMu.Lock()
|
||||||
if settingsOpen && settingsHwnd != 0 {
|
if settingsOpen {
|
||||||
settingsMu.Unlock()
|
settingsMu.Unlock()
|
||||||
procShowWindow.Call(settingsHwnd, 9)
|
if settingsHwnd != 0 {
|
||||||
procGetForegroundWindow.Call(settingsHwnd)
|
procShowWindow.Call(settingsHwnd, 9)
|
||||||
|
procGetForegroundWindow.Call(settingsHwnd)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
settingsOpen = true
|
settingsOpen = true
|
||||||
@@ -123,7 +125,7 @@ func openSettingsWindow() {
|
|||||||
runtime.UnlockOSThread()
|
runtime.UnlockOSThread()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
dataDir := filepath.Join(os.TempDir(), "u-desktop-settings")
|
dataDir := filepath.Join(configDir(), "settings-data")
|
||||||
os.MkdirAll(dataDir, 0755)
|
os.MkdirAll(dataDir, 0755)
|
||||||
|
|
||||||
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||||
@@ -194,7 +196,7 @@ func openSettingsWindow() {
|
|||||||
"photoDir": cfg.PhotoDir,
|
"photoDir": cfg.PhotoDir,
|
||||||
"photoInterval": cfg.PhotoInterval,
|
"photoInterval": cfg.PhotoInterval,
|
||||||
"photoCard": !cfg.HidePhoto,
|
"photoCard": !cfg.HidePhoto,
|
||||||
"photoFrameMode": cfg.PhotoFrameMode,
|
"photoFrameMode": cfg.PhotoFrameMode,
|
||||||
"autoStart": isAutoStartEnabled(),
|
"autoStart": isAutoStartEnabled(),
|
||||||
})
|
})
|
||||||
return string(data)
|
return string(data)
|
||||||
@@ -252,13 +254,13 @@ func openSettingsWindow() {
|
|||||||
for _, card := range []string{"time", "weather", "zodiac", "ainews", "knowledge"} {
|
for _, card := range []string{"time", "weather", "zodiac", "ainews", "knowledge"} {
|
||||||
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('%s',false)", card))
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('%s',false)", card))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
reloadWallpaper()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v, ok := data["photoCard"]; ok {
|
if v, ok := data["photoCard"]; ok {
|
||||||
cfg.HidePhoto = !v
|
cfg.HidePhoto = !v
|
||||||
saveConfig(cfg)
|
|
||||||
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('photo',%v)", v))
|
evalJS(fmt.Sprintf("if(window.setCardVisible) setCardVisible('photo',%v)", v))
|
||||||
refreshVisibleCards(cfg, &oldCfg)
|
|
||||||
if cfg.PhotoDir != "" {
|
if cfg.PhotoDir != "" {
|
||||||
if v {
|
if v {
|
||||||
restartPhotoLoop()
|
restartPhotoLoop()
|
||||||
@@ -266,10 +268,9 @@ func openSettingsWindow() {
|
|||||||
stopPhotoLoop()
|
stopPhotoLoop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
saveConfig(cfg)
|
|
||||||
refreshVisibleCards(cfg, &oldCfg)
|
|
||||||
}
|
}
|
||||||
|
saveConfig(cfg)
|
||||||
|
refreshVisibleCards(cfg, &oldCfg)
|
||||||
return ""
|
return ""
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -383,15 +384,15 @@ func openSettingsWindow() {
|
|||||||
|
|
||||||
w.Bind("pickGradientColor", func() string {
|
w.Bind("pickGradientColor", func() string {
|
||||||
hwnd := uintptr(w.Window())
|
hwnd := uintptr(w.Window())
|
||||||
c1 := colorPickerDialog(hwnd, "")
|
cfg := loadConfig()
|
||||||
|
c1 := colorPickerDialog(hwnd, cfg.Color1)
|
||||||
if c1 == "" {
|
if c1 == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
c2 := colorPickerDialog(hwnd, "")
|
c2 := colorPickerDialog(hwnd, cfg.Color2)
|
||||||
if c2 == "" {
|
if c2 == "" {
|
||||||
c2 = "#16213e"
|
c2 = "#16213e"
|
||||||
}
|
}
|
||||||
cfg := loadConfig()
|
|
||||||
cfg.WallpaperType = WPColor
|
cfg.WallpaperType = WPColor
|
||||||
cfg.Color1 = c1
|
cfg.Color1 = c1
|
||||||
cfg.Color2 = c2
|
cfg.Color2 = c2
|
||||||
@@ -494,9 +495,10 @@ func openSettingsWindow() {
|
|||||||
w.SetHtml(settingsHTML)
|
w.SetHtml(settingsHTML)
|
||||||
|
|
||||||
hwnd := uintptr(w.Window())
|
hwnd := uintptr(w.Window())
|
||||||
// disable resize
|
// disable resize + hide from taskbar
|
||||||
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
|
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
|
||||||
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox))
|
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
|
||||||
w.Bind("resizeToFit", func(contentW, contentH int) string {
|
w.Bind("resizeToFit", func(contentW, contentH int) string {
|
||||||
|
|||||||
54
systray.go
54
systray.go
@@ -18,10 +18,35 @@ func onSystrayReady() {
|
|||||||
systray.SetIcon(trayIcon)
|
systray.SetIcon(trayIcon)
|
||||||
systray.SetTooltip("动态壁纸引擎")
|
systray.SetTooltip("动态壁纸引擎")
|
||||||
|
|
||||||
|
// 左键点击托盘图标 → 打开设置(防抖: 连续点击只响应一次)
|
||||||
|
go func() {
|
||||||
|
for range systray.TrayClickCh {
|
||||||
|
openSettingsWindow()
|
||||||
|
// 消费后续积压的点击
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-systray.TrayClickCh:
|
||||||
|
default:
|
||||||
|
goto done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
mSettings := systray.AddMenuItem("桌面设置", "打开设置窗口")
|
mSettings := systray.AddMenuItem("桌面设置", "打开设置窗口")
|
||||||
|
mCheckUpdate := systray.AddMenuItem("检查更新", "检查是否有新版本")
|
||||||
mRestart := systray.AddMenuItem("重启", "重启程序")
|
mRestart := systray.AddMenuItem("重启", "重启程序")
|
||||||
mQuit := systray.AddMenuItem("退出", "退出程序")
|
mQuit := systray.AddMenuItem("退出", "退出程序")
|
||||||
|
|
||||||
|
// 检查更新
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
<-mCheckUpdate.ClickedCh
|
||||||
|
manualCheckUpdate()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 设置窗口
|
// 设置窗口
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
@@ -53,6 +78,7 @@ func onSystrayReady() {
|
|||||||
go bingWallpaperLoop()
|
go bingWallpaperLoop()
|
||||||
go knowledgeLoop()
|
go knowledgeLoop()
|
||||||
go startPhotoLoop()
|
go startPhotoLoop()
|
||||||
|
go runAutoUpdateCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startWebView() {
|
func startWebView() {
|
||||||
@@ -89,10 +115,10 @@ func startWebView() {
|
|||||||
wvHwnd = uintptr(wv.Window())
|
wvHwnd = uintptr(wv.Window())
|
||||||
procShowWindow.Call(wvHwnd, 0) // SW_HIDE
|
procShowWindow.Call(wvHwnd, 0) // SW_HIDE
|
||||||
procSetWindowLongPtrW.Call(wvHwnd, gwlStyle, wsPopup|wsVisible|wsChild)
|
procSetWindowLongPtrW.Call(wvHwnd, gwlStyle, wsPopup|wsVisible|wsChild)
|
||||||
|
procSetWindowLongPtrW.Call(wvHwnd, gwlExStyle, wsExTransparent|wsExLayered)
|
||||||
|
|
||||||
// 立即嵌入 WorkerW
|
// 立即嵌入 WorkerW
|
||||||
procSetParent.Call(wvHwnd, workerw)
|
embedWorkerW(workerw, screenW, screenH)
|
||||||
procMoveWindow.Call(wvHwnd, uintptr(^uint(0)), uintptr(^uint(0)), uintptr(screenW+2), uintptr(screenH+2), 1)
|
|
||||||
log.Printf("已嵌入 WorkerW: HWND=0x%x, %dx%d", wvHwnd, screenW, screenH)
|
log.Printf("已嵌入 WorkerW: HWND=0x%x, %dx%d", wvHwnd, screenW, screenH)
|
||||||
|
|
||||||
wv.Bind("setZodiacFromGo", func(zodiac string) error {
|
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() {
|
func fullscreenMonitor() {
|
||||||
type rect struct{ Left, Top, Right, Bottom int32 }
|
type rect struct{ Left, Top, Right, Bottom int32 }
|
||||||
var lastState string
|
var lastState string
|
||||||
|
var lastParent uintptr
|
||||||
|
var parentTick int
|
||||||
for {
|
for {
|
||||||
if atomic.LoadInt32(&paused) == 0 && wv != nil {
|
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()
|
fg, _, _ := procGetForegroundWindow.Call()
|
||||||
if fg != 0 {
|
if fg != 0 {
|
||||||
var r rect
|
var r rect
|
||||||
|
|||||||
271
updater.go
Normal file
271
updater.go
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jchv/go-webview2"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web/update.html
|
||||||
|
var updateHTML string
|
||||||
|
|
||||||
|
//go:embed updater.exe
|
||||||
|
var updaterExe []byte
|
||||||
|
|
||||||
|
type PackageInfo struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateInfo struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Force bool `json:"force"`
|
||||||
|
ReleaseNotes string `json:"release_notes"`
|
||||||
|
Package PackageInfo `json:"package"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
updateMu sync.Mutex
|
||||||
|
updateOpen bool
|
||||||
|
updateInfo *UpdateInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultUpdateURL = "https://c.1216.top/u-desktop/version.json"
|
||||||
|
|
||||||
|
func getUpdateURL() string {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if cfg.UpdateURL != "" {
|
||||||
|
return cfg.UpdateURL
|
||||||
|
}
|
||||||
|
return defaultUpdateURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkUpdate() (*UpdateInfo, error) {
|
||||||
|
resp, err := httpClient.Get(getUpdateURL())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求 version.json 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("version.json 返回状态 %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info UpdateInfo
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 version.json 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Version == "" || info.Version == version || info.Version == "dev" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户跳过过此版本且非强制
|
||||||
|
cfg := loadConfig()
|
||||||
|
if !info.Force && cfg.SkipVersion == info.Version {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAutoUpdateCheck() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
if !cfg.AutoCheckUpdate {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
|
||||||
|
info, err := checkUpdate()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("自动更新检查失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info != nil {
|
||||||
|
showUpdateWindow(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showUpdateWindow(info *UpdateInfo) {
|
||||||
|
updateMu.Lock()
|
||||||
|
if updateOpen {
|
||||||
|
updateMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateOpen = true
|
||||||
|
updateInfo = info
|
||||||
|
updateMu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer func() {
|
||||||
|
updateMu.Lock()
|
||||||
|
updateOpen = false
|
||||||
|
updateInfo = nil
|
||||||
|
updateMu.Unlock()
|
||||||
|
runtime.UnlockOSThread()
|
||||||
|
}()
|
||||||
|
|
||||||
|
winW := 340
|
||||||
|
winH := 220
|
||||||
|
if info.Force {
|
||||||
|
winW = 420
|
||||||
|
winH = 260
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir := filepath.Join(configDir(), "update-data")
|
||||||
|
os.MkdirAll(dataDir, 0755)
|
||||||
|
|
||||||
|
w := webview2.NewWithOptions(webview2.WebViewOptions{
|
||||||
|
AutoFocus: true,
|
||||||
|
DataPath: dataDir,
|
||||||
|
WindowOptions: webview2.WindowOptions{
|
||||||
|
Title: "更新提示",
|
||||||
|
Width: uint(winW),
|
||||||
|
Height: uint(winH),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if w == nil {
|
||||||
|
log.Println("更新窗口: 创建失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hwnd := uintptr(w.Window())
|
||||||
|
|
||||||
|
// 无边框
|
||||||
|
style, _, _ := procGetWindowLongPtrW.Call(hwnd, gwlStyle)
|
||||||
|
procSetWindowLongPtrW.Call(hwnd, gwlStyle, style & ^uintptr(wsSizebox|wsMaxbox|0x00C00000))
|
||||||
|
// 置顶 + 工具窗口
|
||||||
|
procSetWindowLongPtrW.Call(hwnd, gwlExStyle, wsExToolwindow|0x00000008)
|
||||||
|
|
||||||
|
// 定位窗口
|
||||||
|
screenW, screenH := getScreenSize()
|
||||||
|
if info.Force {
|
||||||
|
x := (int(screenW) - winW) / 2
|
||||||
|
y := (int(screenH) - winH) / 2
|
||||||
|
procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(winW), uintptr(winH), 1)
|
||||||
|
} else {
|
||||||
|
x := int(screenW) - winW - 20
|
||||||
|
y := int(screenH) - winH - 60
|
||||||
|
procMoveWindow.Call(hwnd, uintptr(x), uintptr(y), uintptr(winW), uintptr(winH), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Bind("getUpdateInfo", func() string {
|
||||||
|
if updateInfo == nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"version": updateInfo.Version,
|
||||||
|
"force": updateInfo.Force,
|
||||||
|
"releaseNotes": updateInfo.ReleaseNotes,
|
||||||
|
"size": updateInfo.Package.Size,
|
||||||
|
"currentVersion": version,
|
||||||
|
})
|
||||||
|
return string(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("skipUpdate", func() string {
|
||||||
|
if updateInfo != nil && !updateInfo.Force {
|
||||||
|
cfg := loadConfig()
|
||||||
|
cfg.SkipVersion = updateInfo.Version
|
||||||
|
saveConfig(cfg)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.Bind("startUpdate", func() string {
|
||||||
|
if updateInfo == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
go doStartUpdate(updateInfo)
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
w.SetHtml(updateHTML)
|
||||||
|
log.Println("更新窗口已打开")
|
||||||
|
w.Run()
|
||||||
|
log.Println("更新窗口已关闭")
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func doStartUpdate(info *UpdateInfo) {
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("获取自身路径失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exeDir := filepath.Dir(exePath)
|
||||||
|
|
||||||
|
// 释放嵌入的 updater.exe 到临时目录
|
||||||
|
updaterPath := filepath.Join(os.TempDir(), "u-desktop-updater.exe")
|
||||||
|
if err := os.WriteFile(updaterPath, updaterExe, 0755); err != nil {
|
||||||
|
log.Println("释放 updater.exe 失败:", err)
|
||||||
|
showError("释放升级器失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backupDir := filepath.Join(exeDir, "backup")
|
||||||
|
os.MkdirAll(backupDir, 0755)
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--package-url", info.Package.URL,
|
||||||
|
"--sha256", info.Package.SHA256,
|
||||||
|
"--target", exePath,
|
||||||
|
"--version", info.Version,
|
||||||
|
"--backup-dir", backupDir,
|
||||||
|
}
|
||||||
|
if info.Force {
|
||||||
|
args = append(args, "--force")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(updaterPath, args...)
|
||||||
|
cmd.Stdout = nil
|
||||||
|
cmd.Stderr = nil
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Println("启动 updater 失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("updater 已启动, 主程序退出")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func manualCheckUpdate() {
|
||||||
|
info, err := checkUpdate()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("手动更新检查失败:", err)
|
||||||
|
showError("检查更新失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info == nil {
|
||||||
|
showError("当前已是最新版本 (" + version + ")")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showUpdateWindow(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSize(size int64) string {
|
||||||
|
const (
|
||||||
|
KB = 1024
|
||||||
|
MB = KB * 1024
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case size >= MB:
|
||||||
|
return strconv.FormatFloat(float64(size)/float64(MB), 'f', 1, 64) + " MB"
|
||||||
|
case size >= KB:
|
||||||
|
return strconv.FormatFloat(float64(size)/float64(KB), 'f', 1, 64) + " KB"
|
||||||
|
default:
|
||||||
|
return strconv.FormatInt(size, 10) + " B"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
updater/go.mod
Normal file
3
updater/go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module updater
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
218
updater/main.go
Normal file
218
updater/main.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
packageURL := flag.String("package-url", "", "下载地址")
|
||||||
|
sha256Hash := flag.String("sha256", "", "SHA256 校验值")
|
||||||
|
target := flag.String("target", "", "目标 exe 路径")
|
||||||
|
ver := flag.String("version", "", "目标版本号")
|
||||||
|
force := flag.Bool("force", false, "强制升级")
|
||||||
|
backupDir := flag.String("backup-dir", "", "备份目录")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
logPath := filepath.Join(filepath.Dir(*target), "updater.log")
|
||||||
|
logFile, err := os.Create(logPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("无法创建日志文件:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer logFile.Close()
|
||||||
|
log.SetOutput(logFile)
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
||||||
|
|
||||||
|
log.Printf("updater 启动: version=%s target=%s force=%v", *ver, *target, *force)
|
||||||
|
|
||||||
|
if *packageURL == "" || *target == "" {
|
||||||
|
log.Fatal("缺少必要参数: --package-url, --target")
|
||||||
|
}
|
||||||
|
if *sha256Hash == "" {
|
||||||
|
log.Fatal("缺少 --sha256 参数")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待主程序退出
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
// 下载
|
||||||
|
tmpFile, err := downloadWithRetry(*packageURL, 3)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("下载失败: ", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile)
|
||||||
|
log.Println("下载完成:", tmpFile)
|
||||||
|
|
||||||
|
// 校验
|
||||||
|
if err := verifySHA256(tmpFile, *sha256Hash); err != nil {
|
||||||
|
log.Fatal("校验失败: ", err)
|
||||||
|
}
|
||||||
|
log.Println("SHA256 校验通过")
|
||||||
|
|
||||||
|
// 备份
|
||||||
|
if *backupDir == "" {
|
||||||
|
*backupDir = filepath.Join(filepath.Dir(*target), "backup")
|
||||||
|
}
|
||||||
|
os.MkdirAll(*backupDir, 0755)
|
||||||
|
backupPath := filepath.Join(*backupDir, fmt.Sprintf("old-%s.exe", time.Now().Format("20060102-150405")))
|
||||||
|
if err := copyFile(*target, backupPath); err != nil {
|
||||||
|
log.Println("备份失败(继续):", err)
|
||||||
|
} else {
|
||||||
|
log.Println("已备份:", backupPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换: zip 包解压,exe 直接覆盖
|
||||||
|
if strings.HasSuffix(strings.ToLower(*packageURL), ".zip") {
|
||||||
|
if err := extractAndReplace(tmpFile, *target); err != nil {
|
||||||
|
rollback(backupPath, *target)
|
||||||
|
log.Fatal("解压替换失败: ", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := copyFile(tmpFile, *target); err != nil {
|
||||||
|
rollback(backupPath, *target)
|
||||||
|
log.Fatal("替换失败: ", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Println("替换完成")
|
||||||
|
|
||||||
|
// 启动新版本
|
||||||
|
cmd := exec.Command(*target)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
rollback(backupPath, *target)
|
||||||
|
log.Fatal("启动新版本失败: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听新进程 5 秒
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
if cmd.Process == nil {
|
||||||
|
rollback(backupPath, *target)
|
||||||
|
log.Fatal("新进程异常")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查新进程是否仍在运行
|
||||||
|
// NOTE: Windows 下无法直接用 cmd.Process.Signals,用简单的进程存活性判断
|
||||||
|
if err := cmd.Process.Release(); err != nil {
|
||||||
|
log.Println("新进程已退出,回滚")
|
||||||
|
rollback(backupPath, *target)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("升级成功, updater 退出")
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadWithRetry(url string, maxRetries int) (string, error) {
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
log.Printf("重试下载 (%d/%d)...", i+1, maxRetries)
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
tmpFile, err := os.CreateTemp("", "updater-*.download")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("创建临时文件失败: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
tmpFile.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
resp.Body.Close()
|
||||||
|
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(tmpFile, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
tmpFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return tmpPath, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("下载失败(重试 %d 次): %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySHA256(path, expected string) error {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
actual := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
if actual != strings.ToLower(expected) {
|
||||||
|
return fmt.Errorf("SHA256 不匹配: 期望 %s, 实际 %s", expected, actual)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 复制可执行权限
|
||||||
|
if fi, err := os.Stat(src); err == nil {
|
||||||
|
out.Chmod(fi.Mode())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAndReplace(zipPath, target string) error {
|
||||||
|
// TODO: 如需 zip 解压支持,引入 archive/zip
|
||||||
|
// 初期可直接分发 exe,此路径不执行
|
||||||
|
return fmt.Errorf("zip 解压暂未实现,请使用 exe 分发")
|
||||||
|
}
|
||||||
|
|
||||||
|
func rollback(backupPath, target string) {
|
||||||
|
if backupPath == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(backupPath); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("回滚: 从备份恢复", backupPath)
|
||||||
|
if err := copyFile(backupPath, target); err != nil {
|
||||||
|
log.Println("回滚失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd := exec.Command(target)
|
||||||
|
cmd.Start()
|
||||||
|
log.Println("已回滚并启动旧版本")
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ var cities = []City{
|
|||||||
|
|
||||||
var defaultCity = City{"101200101", "武汉", "武汉", "湖北"}
|
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{
|
var weatherIcons = map[string]string{
|
||||||
"晴": "☀️", "多云": "⛅", "阴": "☁️",
|
"晴": "☀️", "多云": "⛅", "阴": "☁️",
|
||||||
|
|||||||
@@ -4,28 +4,41 @@ function parseArg(data: any) {
|
|||||||
return typeof data === 'string' ? JSON.parse(data) : data
|
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() {
|
export function registerGoBridge() {
|
||||||
const w = window as any
|
const w = window as any
|
||||||
|
|
||||||
w.updateHoroscopeFromGo = (data: any) => {
|
w.updateHoroscopeFromGo = (data: any) => {
|
||||||
state.horoscope = parseArg(data)
|
const parsed = parseArg(data)
|
||||||
|
if (shouldUpdate('horoscope', parsed)) state.horoscope = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
w.updateAINewsFromGo = (items: any) => {
|
w.updateAINewsFromGo = (items: any) => {
|
||||||
const parsed = parseArg(items)
|
const parsed = parseArg(items)
|
||||||
if (parsed?.length) state.aiNews = parsed
|
if (parsed?.length && shouldUpdate('ainews', parsed)) state.aiNews = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
w.updateKnowledgeFromGo = (data: any) => {
|
w.updateKnowledgeFromGo = (data: any) => {
|
||||||
state.knowledge = parseArg(data)
|
const parsed = parseArg(data)
|
||||||
|
if (shouldUpdate('knowledge', parsed)) state.knowledge = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
w.updateWeatherFromGo = (data: any) => {
|
w.updateWeatherFromGo = (data: any) => {
|
||||||
state.weather = parseArg(data)
|
const parsed = parseArg(data)
|
||||||
|
if (shouldUpdate('weather', parsed)) state.weather = parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
w.updatePhotoFromGo = (data: any) => {
|
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) => {
|
w.setCardVisible = (card: string, visible: boolean) => {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="knowledge-card">
|
||||||
<div class="knowledge-header">
|
<div class="knowledge-header">
|
||||||
💡 知识卡片
|
<span class="knowledge-label">知识卡片</span>
|
||||||
<span class="knowledge-keyword-tag" v-if="state.knowledge.keyword">#{{ state.knowledge.keyword }}</span>
|
<span class="knowledge-keyword-tag" v-if="state.knowledge.keyword">#{{ state.knowledge.keyword }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -13,19 +18,49 @@ import { state } from '../composables/useOverlayState'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.knowledge-card {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
.knowledge-header {
|
.knowledge-header {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.knowledge-label {
|
||||||
font-size: 11px; color: var(--text-faint);
|
font-size: 11px; color: var(--text-faint);
|
||||||
margin-bottom: 10px; display: flex;
|
font-weight: 700; letter-spacing: 1px;
|
||||||
align-items: center; gap: 6px;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0; font-weight: 700;
|
|
||||||
}
|
}
|
||||||
.knowledge-keyword-tag {
|
.knowledge-keyword-tag {
|
||||||
background: rgba(255,255,255,0.09);
|
background: rgba(255,216,107,0.12);
|
||||||
padding: 3px 8px; border-radius: 6px;
|
color: var(--accent-warm);
|
||||||
font-size: 10px; color: var(--text-soft);
|
padding: 2px 9px; border-radius: 4px;
|
||||||
|
font-size: 10px; font-weight: 600;
|
||||||
|
border: 1px solid rgba(255,216,107,0.15);
|
||||||
}
|
}
|
||||||
.knowledge-content {
|
.knowledge-divider {
|
||||||
font-size: 16px; color: var(--text-main);
|
height: 1px;
|
||||||
line-height: 1.55; text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -68,10 +68,7 @@ import PhotoCard from './PhotoCard.vue'
|
|||||||
width: 100%; min-width: 0; text-align: right;
|
width: 100%; min-width: 0; text-align: right;
|
||||||
padding: 22px 28px 24px;
|
padding: 22px 28px 24px;
|
||||||
}
|
}
|
||||||
#card-knowledge :deep(.knowledge-content) {
|
#card-knowledge { overflow: hidden; }
|
||||||
display: -webkit-box; -webkit-line-clamp: 8;
|
|
||||||
-webkit-box-orient: vertical; overflow: hidden;
|
|
||||||
}
|
|
||||||
#layout-multi :deep(#card-photo) {
|
#layout-multi :deep(#card-photo) {
|
||||||
grid-area: photo;
|
grid-area: photo;
|
||||||
position: relative; top: auto; left: auto;
|
position: relative; top: auto; left: auto;
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ html, body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(145deg, rgba(255,255,255,0.075), rgba(255,255,255,0.025)), var(--card-bg);
|
background: linear-gradient(145deg, rgba(255,255,255,0.075), rgba(255,255,255,0.025)), var(--card-bg-strong);
|
||||||
backdrop-filter: blur(28px) saturate(1.2);
|
|
||||||
-webkit-backdrop-filter: blur(28px) saturate(1.2);
|
|
||||||
border: 1px solid var(--card-line);
|
border: 1px solid var(--card-line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
<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>
|
<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="如: 历史、科学、冷知识"
|
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)]">
|
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>
|
||||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
<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>
|
<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="如: 偏实践、给出判断标准"
|
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)]">
|
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>
|
||||||
@@ -76,12 +76,10 @@ function onDocClick(e: MouseEvent) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onDocClick)
|
document.addEventListener('click', onDocClick)
|
||||||
// Find initial city
|
const entries = Object.entries(props.s.citiesByProv as Record<string, { id: string; name: string }[]>)
|
||||||
for (const p of Object.keys(props.s.citiesByProv)) {
|
for (const [p, cs] of entries) {
|
||||||
for (const c of (props.s.citiesByProv as any)[p]) {
|
const found = cs.find(c => c.id === props.s.city)
|
||||||
if (c.id === props.s.city) { activeProv.value = p; selectedCityName.value = c.name; break }
|
if (found) { activeProv.value = p; selectedCityName.value = found.name; break }
|
||||||
}
|
|
||||||
if (activeProv.value) break
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||||
|
|||||||
@@ -29,12 +29,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue'
|
||||||
import { go } from '../composables/useGoBridge'
|
import { go } from '../composables/useGoBridge'
|
||||||
import ToggleSwitch from './ToggleSwitch.vue'
|
import ToggleSwitch from './ToggleSwitch.vue'
|
||||||
import type { SettingsData } from '@shared/types'
|
import type { SettingsData } from '@shared/types'
|
||||||
|
|
||||||
const props = defineProps<{ s: SettingsData }>()
|
const props = defineProps<{ s: SettingsData }>()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.s.photoCard, props.s.photoFrameMode] as [boolean, boolean],
|
||||||
|
([photoCard, photoFrameMode]) => {
|
||||||
|
go.saveToggles({ photoCard, photoFrameMode })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async function onPickDir() {
|
async function onPickDir() {
|
||||||
const dir = await go.pickPhotoDir()
|
const dir = await go.pickPhotoDir()
|
||||||
if (dir) props.s.photoDir = dir
|
if (dir) props.s.photoDir = dir
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</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 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>
|
<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="输入显示文字"
|
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)]">
|
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>
|
||||||
@@ -135,7 +135,10 @@ async function loadBingInfo() {
|
|||||||
const raw = await go.getBingInfo()
|
const raw = await go.getBingInfo()
|
||||||
const st = JSON.parse(raw) as BingState
|
const st = JSON.parse(raw) as BingState
|
||||||
bing.value = st
|
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()) }
|
async function onBingPrev() { bing.value = JSON.parse(await go.bingPrev()) }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
export function debounced(fn: (v: string) => void, val: string, ms = 500) {
|
export function debounced(key: string, fn: (v: string) => void, val: string, ms = 500) {
|
||||||
clearTimeout(timers.get(fn.name))
|
clearTimeout(timers.get(key))
|
||||||
timers.set(fn.name, setTimeout(() => fn(val), ms))
|
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
126
web/update.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: "Microsoft YaHei", sans-serif;
|
||||||
|
background: transparent;
|
||||||
|
color: #e0e0e0;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: rgba(30, 30, 46, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
padding: 20px 24px;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.version-tag {
|
||||||
|
color: #89b4fa;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.notes {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #a0a0b8;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-line;
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 7px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #89b4fa;
|
||||||
|
color: #1e1e2e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #74c7ec; }
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: #a0a0b8;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: rgba(255,255,255,0.1); }
|
||||||
|
.force-layout {
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.force-layout .version-tag { font-size: 18px; }
|
||||||
|
.force-layout .btn-row { justify-content: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container" id="app">
|
||||||
|
<div class="version-tag" id="versionTag"></div>
|
||||||
|
<div class="notes" id="notes"></div>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
<div class="btn-row" id="btnRow"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const info = JSON.parse(window.getUpdateInfo());
|
||||||
|
const container = document.getElementById('app');
|
||||||
|
const versionTag = document.getElementById('versionTag');
|
||||||
|
const notes = document.getElementById('notes');
|
||||||
|
const meta = document.getElementById('meta');
|
||||||
|
const btnRow = document.getElementById('btnRow');
|
||||||
|
|
||||||
|
versionTag.textContent = '发现新版本 v' + info.version;
|
||||||
|
notes.textContent = info.releaseNotes || '';
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + 'MB';
|
||||||
|
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KB';
|
||||||
|
return bytes + 'B';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.force) {
|
||||||
|
container.classList.add('force-layout');
|
||||||
|
if (info.size) {
|
||||||
|
const sec = info.size > 5242880 ? '~30秒' : '~10秒';
|
||||||
|
meta.textContent = '大小: ' + formatSize(info.size) + ' | 预计用时: ' + sec;
|
||||||
|
}
|
||||||
|
btnRow.innerHTML = '<button class="btn btn-primary" onclick="doUpdate()">立即升级</button>';
|
||||||
|
} else {
|
||||||
|
btnRow.innerHTML =
|
||||||
|
'<button class="btn btn-secondary" onclick="skip()">稍后</button>' +
|
||||||
|
'<button class="btn btn-primary" onclick="doUpdate()">立即升级</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function doUpdate() {
|
||||||
|
window.startUpdate();
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
window.skipUpdate();
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
win32.go
20
win32.go
@@ -29,6 +29,7 @@ var (
|
|||||||
procPostMessageW = user32.NewProc("PostMessageW")
|
procPostMessageW = user32.NewProc("PostMessageW")
|
||||||
procTranslateMessage = user32.NewProc("TranslateMessage")
|
procTranslateMessage = user32.NewProc("TranslateMessage")
|
||||||
procDispatchMessageW = user32.NewProc("DispatchMessageW")
|
procDispatchMessageW = user32.NewProc("DispatchMessageW")
|
||||||
|
procGetParent = user32.NewProc("GetParent")
|
||||||
)
|
)
|
||||||
|
|
||||||
var wv webview2.WebView
|
var wv webview2.WebView
|
||||||
@@ -42,12 +43,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
gwlStyle = uintptr(0xFFFFFFF0) // GWL_STYLE = -16
|
gwlStyle = uintptr(0xFFFFFFF0) // GWL_STYLE = -16
|
||||||
wsPopup = uintptr(0x80000000)
|
gwlExStyle = uintptr(0xFFFFFFFEC) // GWL_EXSTYLE = -20
|
||||||
wsVisible = uintptr(0x10000000)
|
wsPopup = uintptr(0x80000000)
|
||||||
wsChild = uintptr(0x02000000)
|
wsVisible = uintptr(0x10000000)
|
||||||
wsSizebox = uintptr(0x00040000)
|
wsChild = uintptr(0x02000000)
|
||||||
wsMaxbox = uintptr(0x00010000)
|
wsSizebox = uintptr(0x00040000)
|
||||||
|
wsMaxbox = uintptr(0x00010000)
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wsExTransparent = uintptr(0x00000020)
|
||||||
|
wsExLayered = uintptr(0x00080000)
|
||||||
|
wsExToolwindow = uintptr(0x00000080)
|
||||||
)
|
)
|
||||||
|
|
||||||
var htmlQueue = make(chan string, 1)
|
var htmlQueue = make(chan string, 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user