Initial GoWallpaper implementation
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/dist/
|
||||
/rsrc_windows_*.syso
|
||||
/config.json
|
||||
/cache/
|
||||
/*.exe
|
||||
*.log
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -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
|
||||
```
|
||||
|
||||
缓存索引记录历史顺序和当前位置。程序退出后不会清空缓存。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user