From 1a4bb858f30f4eca715ed8b9d193a2ee8c5afd41 Mon Sep 17 00:00:00 2001 From: chun_qiu Date: Fri, 31 Jul 2026 21:25:56 +0800 Subject: [PATCH] Initial GoWallpaper implementation --- .gitignore | 8 + README.md | 69 ++++++++ app.go | 341 +++++++++++++++++++++++++++++++++++++ app.manifest | 23 +++ build.ps1 | 82 +++++++++ cache.go | 177 +++++++++++++++++++ cache_test.go | 60 +++++++ config.go | 119 +++++++++++++ config_test.go | 43 +++++ downloader.go | 87 ++++++++++ downloader_test.go | 52 ++++++ go.mod | 14 ++ go.sum | 11 ++ main.go | 66 +++++++ settings.go | 113 ++++++++++++ single_instance_windows.go | 39 +++++ startup_windows.go | 39 +++++ wallpaper_windows.go | 89 ++++++++++ 18 files changed, 1432 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.go create mode 100644 app.manifest create mode 100644 build.ps1 create mode 100644 cache.go create mode 100644 cache_test.go create mode 100644 config.go create mode 100644 config_test.go create mode 100644 downloader.go create mode 100644 downloader_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 settings.go create mode 100644 single_instance_windows.go create mode 100644 startup_windows.go create mode 100644 wallpaper_windows.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f1d5c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/dist/ +/rsrc_windows_*.syso +/config.json +/cache/ +/*.exe +*.log +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..18bce4e --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# GoWallpaper + +GoWallpaper 是一个仅面向 Windows 的轻量托盘壁纸切换工具。它通过 HTTP GET 请求随机图片 API,将响应图片缓存到程序同目录,并支持手动刷新、定时刷新以及缓存历史前后切换。 + +## 功能 + +- 启动后隐藏到 Windows 托盘并显示运行提示 +- 托盘菜单手动刷新壁纸 +- 上一张、下一张缓存壁纸 +- 可开关并自定义间隔的自动切换 +- 可配置最大缓存数量 +- 保持系统壁纸样式,或选择填充、适应、拉伸、平铺、居中、跨区 +- 当前用户开机自动启动 +- 命名互斥体阻止多开 +- 首次运行自动创建 `config.json` 和 `cache` 目录 + +## API 要求 + +API 必须支持公开的 HTTP GET 请求,并直接在响应体中返回 JPEG、PNG、GIF 或 WebP 图片。程序允许标准 HTTP 重定向,单张图片最大 50 MB。 + +首次运行时 API 地址为空,程序会自动打开设置窗口。也可以退出程序后直接编辑同目录下的 `config.json`: + +```json +{ + "api_url": "https://example.com/random-image", + "auto_change_enabled": false, + "interval_minutes": 30, + "max_cache_count": 10, + "wallpaper_style": "keep", + "start_with_windows": false +} +``` + +`wallpaper_style` 可使用 `keep`、`fill`、`fit`、`stretch`、`tile`、`center` 或 `span`。 + +## 构建 + +需要 Go 1.25 或兼容版本,以及 `rsrc`: + +```powershell +go install github.com/akavel/rsrc@latest +``` + +项目统一通过 `build.ps1` 执行格式化、测试和构建,禁止绕过脚本直接执行编译命令: + +```powershell +# 格式化、测试并构建 dist/GoWallpaper.exe +.\build.ps1 + +# 仅格式化和测试 +.\build.ps1 -TestOnly + +# 清理构建产物 +.\build.ps1 -Clean +``` + +构建完成后请将 `GoWallpaper.exe` 放在普通可写目录中运行。由于配置及缓存严格保存在程序同目录,不建议放入 `Program Files` 等通常需要管理员权限的目录。 + +## 运行文件 + +```text +GoWallpaper.exe +config.json +cache/ + index.json + 20260731_120000_000000000.jpg +``` + +缓存索引记录历史顺序和当前位置。程序退出后不会清空缓存。 diff --git a/app.go b/app.go new file mode 100644 index 0000000..587891f --- /dev/null +++ b/app.go @@ -0,0 +1,341 @@ +//go:build windows + +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/lxn/walk" +) + +const appName = "GoWallpaper" + +type App struct { + mw *walk.MainWindow + notifyIcon *walk.NotifyIcon + configPath string + cacheDir string + config Config + cache *CacheIndex + downloader *Downloader + autoAction *walk.Action + refreshAction *walk.Action + previousAction *walk.Action + nextAction *walk.Action + settingsAction *walk.Action + timerMu sync.Mutex + timer *time.Timer + busy atomic.Bool + closing atomic.Bool +} + +func newApp(mw *walk.MainWindow, appDir string, cfg Config, cache *CacheIndex) (*App, error) { + ni, err := walk.NewNotifyIcon(mw) + if err != nil { + return nil, err + } + icon, err := walk.NewIconFromSysDLL("shell32.dll", 4) + if err != nil { + ni.Dispose() + return nil, fmt.Errorf("加载托盘图标: %w", err) + } + if err := ni.SetIcon(icon); err != nil { + ni.Dispose() + return nil, err + } + if err := ni.SetToolTip(appName + " - 右键打开菜单"); err != nil { + ni.Dispose() + return nil, err + } + + a := &App{ + mw: mw, + notifyIcon: ni, + configPath: filepath.Join(appDir, configFileName), + cacheDir: filepath.Join(appDir, "cache"), + config: cfg, + cache: cache, + downloader: newDownloader(), + } + if err := a.createMenu(); err != nil { + ni.Dispose() + return nil, err + } + if err := ni.SetVisible(true); err != nil { + ni.Dispose() + return nil, err + } + return a, nil +} + +func (a *App) createMenu() error { + var err error + if a.refreshAction, err = a.addAction("刷新壁纸", func() { a.refresh(false) }); err != nil { + return err + } + if a.previousAction, err = a.addAction("上一张", func() { a.moveHistory(-1) }); err != nil { + return err + } + if a.nextAction, err = a.addAction("下一张", func() { a.moveHistory(1) }); err != nil { + return err + } + if err := a.notifyIcon.ContextMenu().Actions().Add(walk.NewSeparatorAction()); err != nil { + return err + } + if a.autoAction, err = a.addAction("自动切换壁纸", a.toggleAutoChange); err != nil { + return err + } + if err := a.autoAction.SetCheckable(true); err != nil { + return err + } + if err := a.autoAction.SetChecked(a.config.AutoChangeEnabled); err != nil { + return err + } + if a.settingsAction, err = a.addAction("设置...", a.showSettings); err != nil { + return err + } + if _, err = a.addAction("打开缓存目录", a.openCacheDirectory); err != nil { + return err + } + if err := a.notifyIcon.ContextMenu().Actions().Add(walk.NewSeparatorAction()); err != nil { + return err + } + if _, err = a.addAction("关于", func() { + walk.MsgBox(a.mw, "关于 GoWallpaper", "GoWallpaper\n\n通过随机图片 HTTP API 手动或定时更换 Windows 壁纸。", walk.MsgBoxIconInformation) + }); err != nil { + return err + } + _, err = a.addAction("退出", a.close) + return err +} + +func (a *App) addAction(text string, handler func()) (*walk.Action, error) { + action := walk.NewAction() + if err := action.SetText(text); err != nil { + return nil, err + } + action.Triggered().Attach(handler) + if err := a.notifyIcon.ContextMenu().Actions().Add(action); err != nil { + return nil, err + } + return action, nil +} + +func (a *App) start(firstRun bool) { + a.scheduleTimer() + _ = a.notifyIcon.ShowInfo(appName, "软件已经运行,可右键托盘图标进行操作。") + if firstRun || a.config.APIURL == "" { + time.AfterFunc(300*time.Millisecond, func() { + a.mw.Synchronize(func() { a.showSettings() }) + }) + } +} + +func (a *App) close() { + if !a.closing.CompareAndSwap(false, true) { + return + } + a.stopTimer() + a.notifyIcon.SetVisible(false) + a.notifyIcon.Dispose() + walk.App().Exit(0) +} + +func (a *App) setBusy(busy bool) { + a.refreshAction.SetEnabled(!busy) + a.previousAction.SetEnabled(!busy) + a.nextAction.SetEnabled(!busy) + a.settingsAction.SetEnabled(!busy) +} + +func (a *App) refresh(automatic bool) { + if err := a.config.validate(true); err != nil { + if !automatic { + a.notifyWarning(err.Error()) + a.showSettings() + } else { + a.scheduleTimer() + } + return + } + if !a.busy.CompareAndSwap(false, true) { + if !automatic { + a.notifyWarning("正在刷新壁纸,请稍候。") + } else { + a.scheduleTimer() + } + return + } + a.setBusy(true) + cfg := a.config + go func() { + path := uniqueCachePath(a.cacheDir) + wallpaperSet := false + err := a.downloader.download(context.Background(), cfg.APIURL, path) + if err == nil { + err = setWallpaper(path, cfg.WallpaperStyle) + wallpaperSet = err == nil + } + if err == nil { + err = a.cache.add(path, cfg.MaxCacheCount) + } + if err != nil && !wallpaperSet { + _ = os.Remove(path) + } + a.mw.Synchronize(func() { + a.busy.Store(false) + a.setBusy(false) + if err != nil { + a.notifyError("刷新壁纸失败:" + err.Error()) + } else { + _ = a.notifyIcon.ShowInfo(appName, "壁纸已刷新。") + } + if automatic { + a.scheduleTimer() + } + }) + }() +} + +func (a *App) moveHistory(direction int) { + oldCurrent := a.cache.Current + var path string + var moved bool + var err error + if direction < 0 { + path, moved, err = a.cache.previous() + } else { + path, moved, err = a.cache.next() + } + if err != nil { + a.notifyError(err.Error()) + return + } + if !moved { + if direction < 0 { + a.notifyWarning("已经是缓存中最旧的壁纸。") + } else { + a.notifyWarning("已经是缓存中最新的壁纸。") + } + return + } + if err := setWallpaper(path, a.config.WallpaperStyle); err != nil { + a.cache.Current = oldCurrent + _ = a.cache.save() + a.notifyError("切换壁纸失败:" + err.Error()) + return + } + _ = a.notifyIcon.ShowInfo(appName, "已切换到缓存壁纸。") +} + +func (a *App) toggleAutoChange() { + old := a.config.AutoChangeEnabled + a.config.AutoChangeEnabled = !old + if err := saveConfig(a.configPath, a.config); err != nil { + a.config.AutoChangeEnabled = old + _ = a.autoAction.SetChecked(old) + a.notifyError(err.Error()) + return + } + _ = a.autoAction.SetChecked(a.config.AutoChangeEnabled) + a.scheduleTimer() + if a.config.AutoChangeEnabled { + a.notifyInfo(fmt.Sprintf("自动切换已开启,每 %d 分钟刷新一次。", a.config.IntervalMinutes)) + } else { + a.notifyInfo("自动切换已关闭。") + } +} + +func (a *App) scheduleTimer() { + a.timerMu.Lock() + defer a.timerMu.Unlock() + if a.timer != nil { + a.timer.Stop() + a.timer = nil + } + if a.config.AutoChangeEnabled && !a.closing.Load() { + duration := time.Duration(a.config.IntervalMinutes) * time.Minute + a.timer = time.AfterFunc(duration, func() { + if !a.closing.Load() { + a.mw.Synchronize(func() { a.refresh(true) }) + } + }) + } +} + +func (a *App) stopTimer() { + a.timerMu.Lock() + defer a.timerMu.Unlock() + if a.timer != nil { + a.timer.Stop() + a.timer = nil + } +} + +func (a *App) openCacheDirectory() { + if err := exec.Command("explorer.exe", a.cacheDir).Start(); err != nil { + a.notifyError("无法打开缓存目录:" + err.Error()) + } +} + +func (a *App) applyConfig(cfg Config) error { + if err := cfg.validate(false); err != nil { + return err + } + old := a.config + if err := setStartWithWindows(cfg.StartWithWindows); err != nil { + return err + } + if err := saveConfig(a.configPath, cfg); err != nil { + _ = setStartWithWindows(old.StartWithWindows) + return err + } + a.config = cfg + if err := a.cache.trim(cfg.MaxCacheCount); err != nil { + a.config = old + _ = saveConfig(a.configPath, old) + _ = setStartWithWindows(old.StartWithWindows) + return err + } + _ = a.autoAction.SetChecked(cfg.AutoChangeEnabled) + a.scheduleTimer() + return nil +} + +func (a *App) notifyInfo(message string) { + _ = a.notifyIcon.ShowInfo(appName, message) +} + +func (a *App) notifyWarning(message string) { + _ = a.notifyIcon.ShowWarning(appName, message) +} + +func (a *App) notifyError(message string) { + _ = a.notifyIcon.ShowError(appName, message) +} + +func appDirectory() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + dir := filepath.Dir(exe) + probe, err := os.CreateTemp(dir, ".gowallpaper-write-test-*") + if err != nil { + return "", fmt.Errorf("程序目录不可写:%w", err) + } + name := probe.Name() + probe.Close() + if err := os.Remove(name); err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + return dir, nil +} diff --git a/app.manifest b/app.manifest new file mode 100644 index 0000000..ca615a4 --- /dev/null +++ b/app.manifest @@ -0,0 +1,23 @@ + + + + GoWallpaper + + + + + + + + + + + + + + + true/pm + true + + + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..5061e61 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,82 @@ +[CmdletBinding()] +param( + [switch]$TestOnly, + [switch]$Clean, + [switch]$SkipFormat +) + +$ErrorActionPreference = "Stop" +$ProjectRoot = $PSScriptRoot +$DistDir = Join-Path $ProjectRoot "dist" +$OutputPath = Join-Path $DistDir "GoWallpaper.exe" +$ResourcePath = Join-Path $ProjectRoot "rsrc_windows_amd64.syso" + +function Invoke-External { + param( + [Parameter(Mandatory = $true)] + [string]$Command, + + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "命令执行失败(退出码 $LASTEXITCODE):$Command $($Arguments -join ' ')" + } +} + +Push-Location $ProjectRoot +try { + if ($Clean) { + if (Test-Path -LiteralPath $DistDir) { + Remove-Item -LiteralPath $DistDir -Recurse -Force + } + if (Test-Path -LiteralPath $ResourcePath) { + Remove-Item -LiteralPath $ResourcePath -Force + } + Write-Host "清理完成。" + if (-not $TestOnly) { + return + } + } + + if (-not (Get-Command go -ErrorAction SilentlyContinue)) { + throw "未找到 Go,请先安装 Go 并将其加入 PATH。" + } + + if (-not $SkipFormat) { + $GoFiles = @(Get-ChildItem -LiteralPath $ProjectRoot -Filter "*.go" -File | ForEach-Object { $_.FullName }) + if ($GoFiles.Count -gt 0) { + Write-Host "[1/4] 格式化 Go 源码..." + Invoke-External -Command "gofmt" -Arguments (@("-w") + $GoFiles) + } + } + + Write-Host "[2/4] 运行测试..." + Invoke-External -Command "go" -Arguments @("test", "./...") + + if ($TestOnly) { + Write-Host "测试通过。" + return + } + + if (-not (Get-Command rsrc -ErrorAction SilentlyContinue)) { + throw "未找到 rsrc。请先安装:go install github.com/akavel/rsrc@latest" + } + + Write-Host "[3/4] 生成 Windows manifest 资源..." + Invoke-External -Command "rsrc" -Arguments @("-manifest", "app.manifest", "-o", $ResourcePath) + + if (-not (Test-Path -LiteralPath $DistDir)) { + New-Item -ItemType Directory -Path $DistDir | Out-Null + } + + Write-Host "[4/4] 构建 Windows GUI 程序..." + Invoke-External -Command "go" -Arguments @("build", "-ldflags=-H windowsgui -s -w", "-trimpath", "-o", $OutputPath, ".") + + Write-Host "构建完成:$OutputPath" +} +finally { + Pop-Location +} diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..8e91f85 --- /dev/null +++ b/cache.go @@ -0,0 +1,177 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +const cacheIndexFileName = "index.json" + +type CacheEntry struct { + FileName string `json:"file_name"` + CreatedAt time.Time `json:"created_at"` +} + +type CacheIndex struct { + Entries []CacheEntry `json:"entries"` + Current int `json:"current"` + cacheDir string +} + +func loadCacheIndex(cacheDir string) (*CacheIndex, error) { + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return nil, fmt.Errorf("创建缓存目录: %w", err) + } + idx := &CacheIndex{Current: -1, cacheDir: cacheDir} + path := filepath.Join(cacheDir, cacheIndexFileName) + data, err := os.ReadFile(path) + if err == nil { + if err := json.Unmarshal(data, idx); err != nil { + return nil, fmt.Errorf("解析缓存索引: %w", err) + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("读取缓存索引: %w", err) + } + idx.cacheDir = cacheDir + changed := idx.repair() + if errors.Is(err, os.ErrNotExist) || changed { + if err := idx.save(); err != nil { + return nil, err + } + } + return idx, nil +} + +func (idx *CacheIndex) repair() bool { + changed := false + valid := make([]CacheEntry, 0, len(idx.Entries)) + seen := make(map[string]bool) + for _, entry := range idx.Entries { + if entry.FileName == "" || filepath.Base(entry.FileName) != entry.FileName || seen[entry.FileName] { + changed = true + continue + } + if _, err := os.Stat(filepath.Join(idx.cacheDir, entry.FileName)); err != nil { + changed = true + continue + } + seen[entry.FileName] = true + valid = append(valid, entry) + } + idx.Entries = valid + if len(idx.Entries) == 0 { + if idx.Current != -1 { + changed = true + } + idx.Current = -1 + } else if idx.Current < 0 || idx.Current >= len(idx.Entries) { + idx.Current = len(idx.Entries) - 1 + changed = true + } + return changed +} + +func (idx *CacheIndex) save() error { + data, err := json.MarshalIndent(struct { + Entries []CacheEntry `json:"entries"` + Current int `json:"current"` + }{idx.Entries, idx.Current}, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + if err := atomicWriteFile(filepath.Join(idx.cacheDir, cacheIndexFileName), data, 0o644); err != nil { + return fmt.Errorf("保存缓存索引: %w", err) + } + return nil +} + +func (idx *CacheIndex) add(path string, maxCount int) error { + oldEntries := append([]CacheEntry(nil), idx.Entries...) + oldCurrent := idx.Current + name := filepath.Base(path) + idx.Entries = append(idx.Entries, CacheEntry{FileName: name, CreatedAt: time.Now()}) + idx.Current = len(idx.Entries) - 1 + if err := idx.trim(maxCount); err != nil { + idx.Entries = oldEntries + idx.Current = oldCurrent + return err + } + return nil +} + +func (idx *CacheIndex) trim(maxCount int) error { + if maxCount <= 0 || len(idx.Entries) <= maxCount { + return idx.save() + } + oldEntries := append([]CacheEntry(nil), idx.Entries...) + oldCurrent := idx.Current + removeCount := len(idx.Entries) - maxCount + removed := append([]CacheEntry(nil), idx.Entries[:removeCount]...) + idx.Entries = append([]CacheEntry(nil), idx.Entries[removeCount:]...) + idx.Current -= removeCount + if idx.Current < 0 && len(idx.Entries) > 0 { + idx.Current = 0 + } + if err := idx.save(); err != nil { + idx.Entries = oldEntries + idx.Current = oldCurrent + return err + } + for _, entry := range removed { + _ = os.Remove(filepath.Join(idx.cacheDir, entry.FileName)) + } + return nil +} + +func (idx *CacheIndex) previous() (string, bool, error) { + if idx.Current <= 0 || len(idx.Entries) == 0 { + return "", false, nil + } + idx.Current-- + if err := idx.save(); err != nil { + idx.Current++ + return "", false, err + } + return filepath.Join(idx.cacheDir, idx.Entries[idx.Current].FileName), true, nil +} + +func (idx *CacheIndex) next() (string, bool, error) { + if idx.Current < 0 || idx.Current >= len(idx.Entries)-1 { + return "", false, nil + } + idx.Current++ + if err := idx.save(); err != nil { + idx.Current-- + return "", false, err + } + return filepath.Join(idx.cacheDir, idx.Entries[idx.Current].FileName), true, nil +} + +func uniqueCachePath(cacheDir string) string { + base := time.Now().Format("20060102_150405_000000000") + path := filepath.Join(cacheDir, base+".jpg") + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return path + } + for i := 1; ; i++ { + candidate := filepath.Join(cacheDir, fmt.Sprintf("%s_%d.jpg", base, i)) + if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate + } + } +} + +func cachedImageFiles(cacheDir string) ([]string, error) { + matches, err := filepath.Glob(filepath.Join(cacheDir, "*.jpg")) + if err != nil { + return nil, err + } + sort.Strings(matches) + return matches, nil +} diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..e47b799 --- /dev/null +++ b/cache_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCacheHistoryAndTrim(t *testing.T) { + dir := t.TempDir() + idx, err := loadCacheIndex(dir) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"one.jpg", "two.jpg", "three.jpg"} { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(name), 0o644); err != nil { + t.Fatal(err) + } + if err := idx.add(path, 2); err != nil { + t.Fatal(err) + } + } + if len(idx.Entries) != 2 || idx.Entries[0].FileName != "two.jpg" || idx.Current != 1 { + t.Fatalf("unexpected trimmed index: %+v", idx) + } + if _, err := os.Stat(filepath.Join(dir, "one.jpg")); !os.IsNotExist(err) { + t.Fatal("oldest cached image was not removed") + } + path, moved, err := idx.previous() + if err != nil || !moved || filepath.Base(path) != "two.jpg" { + t.Fatalf("unexpected previous result: %q %v %v", path, moved, err) + } + if _, moved, err := idx.previous(); err != nil || moved { + t.Fatalf("expected oldest boundary, moved=%v err=%v", moved, err) + } + path, moved, err = idx.next() + if err != nil || !moved || filepath.Base(path) != "three.jpg" { + t.Fatalf("unexpected next result: %q %v %v", path, moved, err) + } +} + +func TestCacheIndexRepairsMissingFiles(t *testing.T) { + dir := t.TempDir() + idx := &CacheIndex{ + Entries: []CacheEntry{{FileName: "missing.jpg"}}, + Current: 0, + cacheDir: dir, + } + if err := idx.save(); err != nil { + t.Fatal(err) + } + loaded, err := loadCacheIndex(dir) + if err != nil { + t.Fatal(err) + } + if len(loaded.Entries) != 0 || loaded.Current != -1 { + t.Fatalf("missing file was not repaired: %+v", loaded) + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..c9a3521 --- /dev/null +++ b/config.go @@ -0,0 +1,119 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" +) + +const configFileName = "config.json" + +type Config struct { + APIURL string `json:"api_url"` + AutoChangeEnabled bool `json:"auto_change_enabled"` + IntervalMinutes int `json:"interval_minutes"` + MaxCacheCount int `json:"max_cache_count"` + WallpaperStyle string `json:"wallpaper_style"` + StartWithWindows bool `json:"start_with_windows"` +} + +func defaultConfig() Config { + return Config{ + IntervalMinutes: 30, + MaxCacheCount: 10, + WallpaperStyle: "keep", + } +} + +func loadOrCreateConfig(path string) (Config, bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + cfg := defaultConfig() + if err := saveConfig(path, cfg); err != nil { + return Config{}, false, err + } + return cfg, true, nil + } + if err != nil { + return Config{}, false, fmt.Errorf("读取配置文件: %w", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, false, fmt.Errorf("解析配置文件: %w", err) + } + if err := cfg.validate(false); err != nil { + return Config{}, false, fmt.Errorf("配置文件无效: %w", err) + } + return cfg, false, nil +} + +func saveConfig(path string, cfg Config) error { + if err := cfg.validate(false); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("编码配置文件: %w", err) + } + data = append(data, '\n') + if err := atomicWriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("保存配置文件: %w", err) + } + return nil +} + +func (c Config) validate(requireURL bool) error { + if c.IntervalMinutes <= 0 { + return errors.New("自动切换间隔必须大于 0 分钟") + } + if c.MaxCacheCount <= 0 { + return errors.New("缓存数量必须大于 0") + } + if requireURL && c.APIURL == "" { + return errors.New("请先设置随机图片 API 地址") + } + if c.APIURL != "" { + u, err := url.ParseRequestURI(c.APIURL) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return errors.New("API 地址必须是有效的 HTTP 或 HTTPS 地址") + } + } + if !validWallpaperStyle(c.WallpaperStyle) { + return errors.New("不支持的壁纸显示方式") + } + return nil +} + +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".gowallpaper-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if err := tmp.Chmod(perm); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return os.Rename(tmpName, path) +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..c909eec --- /dev/null +++ b/config_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestLoadOrCreateConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), configFileName) + cfg, created, err := loadOrCreateConfig(path) + if err != nil { + t.Fatal(err) + } + if !created { + t.Fatal("expected configuration to be created") + } + if cfg.IntervalMinutes != 30 || cfg.MaxCacheCount != 10 || cfg.WallpaperStyle != "keep" { + t.Fatalf("unexpected defaults: %+v", cfg) + } + + loaded, created, err := loadOrCreateConfig(path) + if err != nil { + t.Fatal(err) + } + if created || loaded != cfg { + t.Fatalf("unexpected loaded config: %+v", loaded) + } +} + +func TestConfigValidation(t *testing.T) { + cfg := defaultConfig() + if err := cfg.validate(true); err == nil { + t.Fatal("expected empty API URL to fail when required") + } + cfg.APIURL = "ftp://example.com/image.jpg" + if err := cfg.validate(false); err == nil { + t.Fatal("expected non-http URL to fail") + } + cfg.APIURL = "https://example.com/random" + if err := cfg.validate(true); err != nil { + t.Fatal(err) + } +} diff --git a/downloader.go b/downloader.go new file mode 100644 index 0000000..c3e36ee --- /dev/null +++ b/downloader.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "fmt" + "image" + "image/color" + "image/draw" + _ "image/gif" + "image/jpeg" + _ "image/png" + "io" + "net/http" + "os" + "path/filepath" + "time" + + _ "golang.org/x/image/webp" +) + +const maxImageBytes = 50 << 20 + +type Downloader struct { + client *http.Client +} + +func newDownloader() *Downloader { + return &Downloader{client: &http.Client{Timeout: 45 * time.Second}} +} + +func (d *Downloader) download(ctx context.Context, apiURL, destination string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return fmt.Errorf("创建图片请求: %w", err) + } + req.Header.Set("User-Agent", "GoWallpaper/1.0") + req.Header.Set("Accept", "image/*") + + resp, err := d.client.Do(req) + if err != nil { + return fmt.Errorf("请求图片 API: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("图片 API 返回 HTTP %d", resp.StatusCode) + } + if resp.ContentLength > maxImageBytes { + return fmt.Errorf("图片超过 %d MB 限制", maxImageBytes>>20) + } + + limited := io.LimitReader(resp.Body, maxImageBytes+1) + img, _, err := image.Decode(limited) + if err != nil { + return fmt.Errorf("API 响应不是支持的图片: %w", err) + } + if img.Bounds().Dx() <= 0 || img.Bounds().Dy() <= 0 { + return fmt.Errorf("API 返回了无效尺寸的图片") + } + + // JPEG 没有透明通道,先铺黑色背景可避免透明图片出现不可预测颜色。 + bounds := img.Bounds() + flat := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy())) + draw.Draw(flat, flat.Bounds(), &image.Uniform{C: color.Black}, image.Point{}, draw.Src) + draw.Draw(flat, flat.Bounds(), img, bounds.Min, draw.Over) + + tmp, err := os.CreateTemp(filepath.Dir(destination), ".download-*.jpg") + if err != nil { + return fmt.Errorf("创建缓存文件: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := jpeg.Encode(tmp, flat, &jpeg.Options{Quality: 92}); err != nil { + tmp.Close() + return fmt.Errorf("保存图片: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, destination); err != nil { + return fmt.Errorf("保存缓存图片: %w", err) + } + return nil +} diff --git a/downloader_test.go b/downloader_test.go new file mode 100644 index 0000000..0d75d74 --- /dev/null +++ b/downloader_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestDownloaderConvertsImageToJPEG(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, A: 255}) + _ = png.Encode(w, img) + })) + defer server.Close() + + destination := filepath.Join(t.TempDir(), "wallpaper.jpg") + if err := newDownloader().download(context.Background(), server.URL, destination); err != nil { + t.Fatal(err) + } + f, err := os.Open(destination) + if err != nil { + t.Fatal(err) + } + defer f.Close() + _, format, err := image.Decode(f) + if err != nil { + t.Fatal(err) + } + if format != "jpeg" { + t.Fatalf("expected jpeg, got %s", format) + } +} + +func TestDownloaderRejectsNonImage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not an image")) + })) + defer server.Close() + + err := newDownloader().download(context.Background(), server.URL, filepath.Join(t.TempDir(), "wallpaper.jpg")) + if err == nil { + t.Fatal("expected non-image response to fail") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9cce031 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module gowallpaper + +go 1.25.0 + +require ( + github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 + golang.org/x/image v0.24.0 + golang.org/x/sys v0.30.0 +) + +require ( + github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect + gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f52edde --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/main.go b/main.go new file mode 100644 index 0000000..bcde580 --- /dev/null +++ b/main.go @@ -0,0 +1,66 @@ +//go:build windows + +package main + +import ( + "fmt" + "log" + "path/filepath" + + "github.com/lxn/walk" + "golang.org/x/sys/windows" +) + +func main() { + mutex, alreadyRunning, err := acquireSingleInstance() + if err != nil { + showNativeMessage(appName, "无法检查程序运行状态:"+err.Error()) + return + } + defer windows.CloseHandle(mutex) + if alreadyRunning { + showNativeMessage(appName, "GoWallpaper 已经运行,请使用右下角托盘图标。") + return + } + + appDir, err := appDirectory() + if err != nil { + showNativeMessage(appName, err.Error()) + return + } + cfg, firstRun, err := loadOrCreateConfig(filepath.Join(appDir, configFileName)) + if err != nil { + showNativeMessage(appName, err.Error()) + return + } + cache, err := loadCacheIndex(filepath.Join(appDir, "cache")) + if err != nil { + showNativeMessage(appName, err.Error()) + return + } + if cfg.StartWithWindows { + if err := setStartWithWindows(true); err != nil { + showNativeMessage(appName, err.Error()) + } + } + + mw, err := walk.NewMainWindow() + if err != nil { + log.Printf("创建消息窗口失败: %v", err) + showNativeMessage(appName, fmt.Sprintf("程序启动失败:%v", err)) + return + } + defer mw.Dispose() + app, err := newApp(mw, appDir, cfg, cache) + if err != nil { + showNativeMessage(appName, fmt.Sprintf("创建托盘图标失败:%v", err)) + return + } + defer func() { + if !app.closing.Load() { + app.close() + } + }() + app.start(firstRun) + mw.Run() +} diff --git a/settings.go b/settings.go new file mode 100644 index 0000000..a02df6c --- /dev/null +++ b/settings.go @@ -0,0 +1,113 @@ +//go:build windows + +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/lxn/walk" + . "github.com/lxn/walk/declarative" +) + +var styleOptions = []struct { + Label string + Value string +}{ + {"保持 Windows 当前设置", "keep"}, + {"填充", "fill"}, + {"适应", "fit"}, + {"拉伸", "stretch"}, + {"平铺", "tile"}, + {"居中", "center"}, + {"跨区", "span"}, +} + +func (a *App) showSettings() { + var dlg *walk.Dialog + var apiEdit, intervalEdit, cacheEdit *walk.LineEdit + var autoCheck, startupCheck *walk.CheckBox + var styleCombo *walk.ComboBox + + labels := make([]string, len(styleOptions)) + styleIndex := 0 + for i, option := range styleOptions { + labels[i] = option.Label + if option.Value == a.config.WallpaperStyle { + styleIndex = i + } + } + + _, err := Dialog{ + AssignTo: &dlg, + Title: "GoWallpaper 设置", + MinSize: Size{520, 300}, + Layout: VBox{MarginsZero: false}, + Children: []Widget{ + Label{Text: "随机图片 API 地址(HTTP GET 直接返回图片)"}, + LineEdit{AssignTo: &apiEdit, Text: a.config.APIURL}, + Composite{ + Layout: Grid{Columns: 2}, + Children: []Widget{ + CheckBox{AssignTo: &autoCheck, Text: "启用自动切换", Checked: a.config.AutoChangeEnabled}, + CheckBox{AssignTo: &startupCheck, Text: "开机自动启动", Checked: a.config.StartWithWindows}, + Label{Text: "自动切换间隔(分钟)"}, + LineEdit{AssignTo: &intervalEdit, Text: strconv.Itoa(a.config.IntervalMinutes)}, + Label{Text: "最多保留缓存图片数"}, + LineEdit{AssignTo: &cacheEdit, Text: strconv.Itoa(a.config.MaxCacheCount)}, + Label{Text: "壁纸显示方式"}, + ComboBox{AssignTo: &styleCombo, Model: labels, CurrentIndex: styleIndex}, + }, + }, + VSpacer{}, + Composite{ + Layout: HBox{}, + Children: []Widget{ + HSpacer{}, + PushButton{Text: "保存", OnClicked: func() { + interval, parseErr := parsePositiveInt(intervalEdit.Text(), "自动切换间隔") + if parseErr != nil { + walk.MsgBox(dlg, "设置无效", parseErr.Error(), walk.MsgBoxIconWarning) + return + } + maxCache, parseErr := parsePositiveInt(cacheEdit.Text(), "缓存数量") + if parseErr != nil { + walk.MsgBox(dlg, "设置无效", parseErr.Error(), walk.MsgBoxIconWarning) + return + } + index := styleCombo.CurrentIndex() + if index < 0 || index >= len(styleOptions) { + index = 0 + } + cfg := Config{ + APIURL: strings.TrimSpace(apiEdit.Text()), + AutoChangeEnabled: autoCheck.Checked(), + IntervalMinutes: interval, + MaxCacheCount: maxCache, + WallpaperStyle: styleOptions[index].Value, + StartWithWindows: startupCheck.Checked(), + } + if err := a.applyConfig(cfg); err != nil { + walk.MsgBox(dlg, "保存失败", err.Error(), walk.MsgBoxIconError) + return + } + dlg.Accept() + }}, + PushButton{Text: "取消", OnClicked: func() { dlg.Cancel() }}, + }, + }, + }, + }.Run(a.mw) + if err != nil { + a.notifyError("无法打开设置窗口:" + err.Error()) + } +} + +func parsePositiveInt(value, field string) (int, error) { + n, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || n <= 0 { + return 0, fmt.Errorf("%s必须是大于 0 的整数", field) + } + return n, nil +} diff --git a/single_instance_windows.go b/single_instance_windows.go new file mode 100644 index 0000000..d6e96eb --- /dev/null +++ b/single_instance_windows.go @@ -0,0 +1,39 @@ +//go:build windows + +package main + +import ( + "errors" + "unsafe" + + "golang.org/x/sys/windows" +) + +const instanceMutexName = `Local\GoWallpaper-4F831CA6-6890-4D95-91A3-A819D7F29718` + +func acquireSingleInstance() (windows.Handle, bool, error) { + name, err := windows.UTF16PtrFromString(instanceMutexName) + if err != nil { + return 0, false, err + } + handle, err := windows.CreateMutex(nil, false, name) + if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + return handle, true, nil + } + if err != nil { + return 0, false, err + } + return handle, false, nil +} + +func showNativeMessage(title, message string) { + titlePtr, _ := windows.UTF16PtrFromString(title) + messagePtr, _ := windows.UTF16PtrFromString(message) + procMessageBoxW := user32.NewProc("MessageBoxW") + procMessageBoxW.Call( + 0, + uintptr(unsafe.Pointer(messagePtr)), + uintptr(unsafe.Pointer(titlePtr)), + 0x00000040, + ) +} diff --git a/startup_windows.go b/startup_windows.go new file mode 100644 index 0000000..e7f2d54 --- /dev/null +++ b/startup_windows.go @@ -0,0 +1,39 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/sys/windows/registry" +) + +const ( + runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` + runValueName = "GoWallpaper" +) + +func setStartWithWindows(enabled bool) error { + key, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE|registry.SET_VALUE) + if err != nil { + return fmt.Errorf("打开开机启动设置: %w", err) + } + defer key.Close() + if !enabled { + if err := key.DeleteValue(runValueName); err != nil && err != registry.ErrNotExist { + return fmt.Errorf("删除开机启动项: %w", err) + } + return nil + } + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("获取程序路径: %w", err) + } + command := `"` + strings.ReplaceAll(exe, `"`, `\"`) + `"` + if err := key.SetStringValue(runValueName, command); err != nil { + return fmt.Errorf("写入开机启动项: %w", err) + } + return nil +} diff --git a/wallpaper_windows.go b/wallpaper_windows.go new file mode 100644 index 0000000..a72a68c --- /dev/null +++ b/wallpaper_windows.go @@ -0,0 +1,89 @@ +//go:build windows + +package main + +import ( + "errors" + "fmt" + "path/filepath" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +const ( + spiSetDesktopWallpaper = 0x0014 + spifUpdateINIFile = 0x0001 + spifSendChange = 0x0002 +) + +var ( + user32 = windows.NewLazySystemDLL("user32.dll") + procSystemParametersInfoW = user32.NewProc("SystemParametersInfoW") +) + +func validWallpaperStyle(style string) bool { + switch style { + case "keep", "fill", "fit", "stretch", "tile", "center", "span": + return true + default: + return false + } +} + +func setWallpaper(path, style string) error { + absPath, err := filepath.Abs(path) + if err != nil { + return err + } + if style != "keep" { + if err := setWallpaperStyle(style); err != nil { + return err + } + } + pathPtr, err := windows.UTF16PtrFromString(absPath) + if err != nil { + return err + } + r1, _, callErr := procSystemParametersInfoW.Call( + spiSetDesktopWallpaper, + 0, + uintptr(unsafe.Pointer(pathPtr)), + spifUpdateINIFile|spifSendChange, + ) + if r1 == 0 { + if callErr != nil && !errors.Is(callErr, windows.ERROR_SUCCESS) { + return fmt.Errorf("调用 Windows 壁纸接口: %w", callErr) + } + return errors.New("Windows 拒绝设置壁纸") + } + return nil +} + +func setWallpaperStyle(style string) error { + values := map[string][2]string{ + "fill": {"10", "0"}, + "fit": {"6", "0"}, + "stretch": {"2", "0"}, + "tile": {"0", "1"}, + "center": {"0", "0"}, + "span": {"22", "0"}, + } + value, ok := values[style] + if !ok { + return fmt.Errorf("不支持的壁纸显示方式: %s", style) + } + key, err := registry.OpenKey(registry.CURRENT_USER, `Control Panel\Desktop`, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("打开壁纸设置: %w", err) + } + defer key.Close() + if err := key.SetStringValue("WallpaperStyle", value[0]); err != nil { + return fmt.Errorf("设置壁纸样式: %w", err) + } + if err := key.SetStringValue("TileWallpaper", value[1]); err != nil { + return fmt.Errorf("设置壁纸平铺方式: %w", err) + } + return nil +}