Initial GoWallpaper implementation

This commit is contained in:
2026-07-31 21:25:56 +08:00
commit 1a4bb858f3
18 changed files with 1432 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
/dist/
/rsrc_windows_*.syso
/config.json
/cache/
/*.exe
*.log
.idea/
.vscode/
+69
View File
@@ -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
```
缓存索引记录历史顺序和当前位置。程序退出后不会清空缓存。
+341
View File
@@ -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
}
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="GoWallpaper" type="win32"/>
<description>GoWallpaper</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
+82
View File
@@ -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
}
+177
View File
@@ -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
}
+60
View File
@@ -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)
}
}
+119
View File
@@ -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)
}
+43
View File
@@ -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)
}
}
+87
View File
@@ -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
}
+52
View File
@@ -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")
}
}
+14
View File
@@ -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
)
+11
View File
@@ -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=
+66
View File
@@ -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()
}
+113
View File
@@ -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
}
+39
View File
@@ -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,
)
}
+39
View File
@@ -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
}
+89
View File
@@ -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
}