新增: 自动升级体系 + 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~
|
||||
|
||||
# Build artifacts (updater.exe at root is build output for embed)
|
||||
updater.exe
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "射手座"
|
||||
|
||||
54
knowledge.go
54
knowledge.go
@@ -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
|
||||
}
|
||||
|
||||
|
||||
6
main.go
6
main.go
@@ -28,6 +28,8 @@ func setupLog() {
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
||||
}
|
||||
|
||||
var version = "dev" // -ldflags "-X main.version=0.6.0"
|
||||
|
||||
func main() {
|
||||
setupLog()
|
||||
|
||||
@@ -57,8 +59,8 @@ func main() {
|
||||
}
|
||||
defer windows.CloseHandle(mutex)
|
||||
|
||||
exePath, _ := os.Executable()
|
||||
cfgDir := filepath.Join(filepath.Dir(exePath), "config")
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
cfgDir := filepath.Join(homeDir, ".u-desktop")
|
||||
os.MkdirAll(cfgDir, 0755)
|
||||
configPath = filepath.Join(cfgDir, "settings.json")
|
||||
procSetProcessDPIAware.Call()
|
||||
|
||||
34
settings.go
34
settings.go
@@ -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 {
|
||||
|
||||
54
systray.go
54
systray.go
@@ -18,10 +18,35 @@ func onSystrayReady() {
|
||||
systray.SetIcon(trayIcon)
|
||||
systray.SetTooltip("动态壁纸引擎")
|
||||
|
||||
// 左键点击托盘图标 → 打开设置(防抖: 连续点击只响应一次)
|
||||
go func() {
|
||||
for range systray.TrayClickCh {
|
||||
openSettingsWindow()
|
||||
// 消费后续积压的点击
|
||||
for {
|
||||
select {
|
||||
case <-systray.TrayClickCh:
|
||||
default:
|
||||
goto done
|
||||
}
|
||||
}
|
||||
done:
|
||||
}
|
||||
}()
|
||||
|
||||
mSettings := systray.AddMenuItem("桌面设置", "打开设置窗口")
|
||||
mCheckUpdate := systray.AddMenuItem("检查更新", "检查是否有新版本")
|
||||
mRestart := systray.AddMenuItem("重启", "重启程序")
|
||||
mQuit := systray.AddMenuItem("退出", "退出程序")
|
||||
|
||||
// 检查更新
|
||||
go func() {
|
||||
for {
|
||||
<-mCheckUpdate.ClickedCh
|
||||
manualCheckUpdate()
|
||||
}
|
||||
}()
|
||||
|
||||
// 设置窗口
|
||||
go func() {
|
||||
for {
|
||||
@@ -53,6 +78,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
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 httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
var httpClient = &http.Client{Timeout: 60 * time.Second}
|
||||
|
||||
var weatherIcons = map[string]string{
|
||||
"晴": "☀️", "多云": "⛅", "阴": "☁️",
|
||||
|
||||
@@ -4,28 +4,41 @@ function parseArg(data: any) {
|
||||
return typeof data === 'string' ? JSON.parse(data) : data
|
||||
}
|
||||
|
||||
// JSON 去重:相同数据不触发 Vue 重渲染
|
||||
const lastJSON = new Map<string, string>()
|
||||
function shouldUpdate(key: string, data: any): boolean {
|
||||
const json = JSON.stringify(data)
|
||||
if (lastJSON.get(key) === json) return false
|
||||
lastJSON.set(key, json)
|
||||
return true
|
||||
}
|
||||
|
||||
export function registerGoBridge() {
|
||||
const w = window as any
|
||||
|
||||
w.updateHoroscopeFromGo = (data: any) => {
|
||||
state.horoscope = parseArg(data)
|
||||
const parsed = parseArg(data)
|
||||
if (shouldUpdate('horoscope', parsed)) state.horoscope = parsed
|
||||
}
|
||||
|
||||
w.updateAINewsFromGo = (items: any) => {
|
||||
const parsed = parseArg(items)
|
||||
if (parsed?.length) state.aiNews = parsed
|
||||
if (parsed?.length && shouldUpdate('ainews', parsed)) state.aiNews = parsed
|
||||
}
|
||||
|
||||
w.updateKnowledgeFromGo = (data: any) => {
|
||||
state.knowledge = parseArg(data)
|
||||
const parsed = parseArg(data)
|
||||
if (shouldUpdate('knowledge', parsed)) state.knowledge = parsed
|
||||
}
|
||||
|
||||
w.updateWeatherFromGo = (data: any) => {
|
||||
state.weather = parseArg(data)
|
||||
const parsed = parseArg(data)
|
||||
if (shouldUpdate('weather', parsed)) state.weather = parsed
|
||||
}
|
||||
|
||||
w.updatePhotoFromGo = (data: any) => {
|
||||
state.photo = parseArg(data)
|
||||
const parsed = parseArg(data)
|
||||
if (parsed?.src && shouldUpdate('photo', parsed)) state.photo = parsed
|
||||
}
|
||||
|
||||
w.setCardVisible = (card: string, visible: boolean) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div><div class="text-xs font-medium text-[var(--text-muted)]">知识关键字</div><div class="text-[10px] text-[var(--text-weak)] mt-px">AI 将根据关键字生成知识小卡片</div></div>
|
||||
<input type="text" v-model="s.knowledgeKeyword" @input="debounced(go.saveKnowledgeKeyword, s.knowledgeKeyword)"
|
||||
<input type="text" v-model="s.knowledgeKeyword" @input="debounced('kw', go.saveKnowledgeKeyword, s.knowledgeKeyword)"
|
||||
placeholder="如: 历史、科学、冷知识"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||
</div>
|
||||
<div class="flex justify-between items-center px-3.5 py-2 border-t border-[var(--card-divider)]">
|
||||
<div><div class="text-xs font-medium text-[var(--text-muted)]">知识提示词</div><div class="text-[10px] text-[var(--text-weak)] mt-px">补充风格或方向,系统会保证内容密度</div></div>
|
||||
<input type="text" v-model="s.knowledgePrompt" @input="debounced(go.saveKnowledgePrompt, s.knowledgePrompt)"
|
||||
<input type="text" v-model="s.knowledgePrompt" @input="debounced('kp', go.saveKnowledgePrompt, s.knowledgePrompt)"
|
||||
placeholder="如: 偏实践、给出判断标准"
|
||||
class="bg-[var(--input-bg)] border border-[var(--input-border)] rounded-md text-[var(--text)] text-[11px] py-1 px-2 outline-none w-[140px] focus:border-[var(--input-border-focus)]">
|
||||
</div>
|
||||
@@ -76,12 +76,10 @@ function onDocClick(e: MouseEvent) {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick)
|
||||
// Find initial city
|
||||
for (const p of Object.keys(props.s.citiesByProv)) {
|
||||
for (const c of (props.s.citiesByProv as any)[p]) {
|
||||
if (c.id === props.s.city) { activeProv.value = p; selectedCityName.value = c.name; break }
|
||||
}
|
||||
if (activeProv.value) break
|
||||
const entries = Object.entries(props.s.citiesByProv as Record<string, { id: string; name: string }[]>)
|
||||
for (const [p, cs] of entries) {
|
||||
const found = cs.find(c => c.id === props.s.city)
|
||||
if (found) { activeProv.value = p; selectedCityName.value = found.name; break }
|
||||
}
|
||||
})
|
||||
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
|
||||
@@ -29,12 +29,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { go } from '../composables/useGoBridge'
|
||||
import ToggleSwitch from './ToggleSwitch.vue'
|
||||
import type { SettingsData } from '@shared/types'
|
||||
|
||||
const props = defineProps<{ s: SettingsData }>()
|
||||
|
||||
watch(
|
||||
() => [props.s.photoCard, props.s.photoFrameMode] as [boolean, boolean],
|
||||
([photoCard, photoFrameMode]) => {
|
||||
go.saveToggles({ photoCard, photoFrameMode })
|
||||
}
|
||||
)
|
||||
|
||||
async function onPickDir() {
|
||||
const dir = await go.pickPhotoDir()
|
||||
if (dir) props.s.photoDir = dir
|
||||
|
||||
@@ -26,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()) }
|
||||
|
||||
@@ -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
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")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user