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