初始提交

This commit is contained in:
2026-07-31 12:51:28 +08:00
commit 971764c47d
11 changed files with 809 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
dist/
build/
*.spec
logs/
cache/
assets/
config.json
__pycache__/
*.pyc
*.pyo
.vscode/
.idea/
*.egg-info/
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env powershell
# -*- coding: utf-8 -*-
<#
.SYNOPSIS
壁纸更换器 - 编译脚本
.DESCRIPTION
使用 PyInstaller 将 Python 源码打包为单个 .exe 文件。
输出: .\dist\WallpaperChanger.exe
#>
$ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 壁纸更换器 - 编译打包" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "[1/4] 激活 conda 环境 ..." -ForegroundColor Yellow
conda activate 312
if ($LASTEXITCODE -ne 0) { throw "激活 conda 环境失败 (312)" }
Write-Host "[2/4] 安装编译依赖 ..." -ForegroundColor Yellow
python -m pip install pyinstaller --quiet
if ($LASTEXITCODE -ne 0) { throw "安装 PyInstaller 失败" }
python -m pip install -r "$ScriptDir\requirements.txt" --quiet
Write-Host "[3/4] 清理旧产物 ..." -ForegroundColor Yellow
Remove-Item -Recurse -Force "$ScriptDir\dist" -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force "$ScriptDir\build" -ErrorAction SilentlyContinue
Remove-Item -Force "$ScriptDir\*.spec" -ErrorAction SilentlyContinue
Write-Host "[4/4] PyInstaller 打包 ..." -ForegroundColor Yellow
Set-Location -LiteralPath $ScriptDir
python -m PyInstaller `
--onefile `
--noconsole `
--name "WallpaperChanger" `
--clean `
"$ScriptDir\main.py"
if ($LASTEXITCODE -ne 0) { throw "PyInstaller 打包失败" }
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host " 编译完成!" -ForegroundColor Green
Write-Host " 输出: $ScriptDir\dist\WallpaperChanger.exe" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
+103
View File
@@ -0,0 +1,103 @@
import json
import os
import sys
def _get_app_dir():
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def _get_config_path():
return os.path.join(_get_app_dir(), "config.json")
DEFAULT_CONFIG = {
"api": {
"url": "http://localhost:8080/wallpaper",
"mode": "direct",
"json_image_field": "url",
"timeout": 30,
},
"wallpaper": {
"fill_style": "fill",
"cache_dir": "./cache",
"max_cache_files": 10,
"show_notification": True,
},
"auto_refresh": {
"enabled": False,
"interval_minutes": 10,
},
}
_config = None
def _resolve_path(path: str) -> str:
if os.path.isabs(path):
return path
return os.path.normpath(os.path.join(_get_app_dir(), path))
def load() -> dict:
global _config
if _config is not None:
return _config
config_path = _get_config_path()
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
_config = json.load(f)
else:
_config = DEFAULT_CONFIG
merged = dict(DEFAULT_CONFIG)
_deep_merge(merged, _config)
_config = merged
return _config
def save(cfg: dict = None) -> None:
global _config
if cfg is not None:
_config = cfg
with open(_get_config_path(), "w", encoding="utf-8") as f:
json.dump(_config, f, indent=4, ensure_ascii=False)
def reload() -> dict:
global _config
_config = None
return load()
def get(path: str, default=None):
cfg = load()
keys = path.split(".")
for k in keys:
if isinstance(cfg, dict):
cfg = cfg.get(k)
if cfg is None:
return default
else:
return default
return cfg
def set_(path: str, value):
global _config
cfg = load()
keys = path.split(".")
d = cfg
for k in keys[:-1]:
d = d.setdefault(k, {})
d[keys[-1]] = value
save(cfg)
def _deep_merge(base, override):
for key, value in override.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
_deep_merge(base[key], value)
else:
base[key] = value
+107
View File
@@ -0,0 +1,107 @@
import os
import uuid
import logging
import mimetypes
import requests
import config
logger = logging.getLogger(__name__)
IMAGE_MIME_TYPES = {
"image/jpeg",
"image/png",
"image/bmp",
"image/gif",
"image/webp",
"image/tiff",
}
EXT_MAP = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/bmp": ".bmp",
"image/gif": ".gif",
"image/webp": ".webp",
"image/tiff": ".tiff",
}
def _detect_ext(response) -> str:
content_type = response.headers.get("Content-Type", "").split(";")[0].strip().lower()
if content_type in EXT_MAP:
return EXT_MAP[content_type]
url_path = response.url.split("?")[0]
guessed, _ = mimetypes.guess_type(url_path)
if guessed and guessed in EXT_MAP:
return EXT_MAP[guessed]
return ".jpg"
def fetch_wallpaper() -> str:
cfg = config.load()
api = cfg["api"]
wallpaper_cfg = cfg["wallpaper"]
url = api["url"]
mode = api.get("mode", "direct")
timeout = api.get("timeout", 30)
cache_dir = wallpaper_cfg.get("cache_dir", "./cache")
cache_dir = _resolve_cache(cache_dir)
os.makedirs(cache_dir, exist_ok=True)
if mode == "json":
image_url = _fetch_json(url, api.get("json_image_field", "url"), timeout)
response = _download(image_url, timeout)
else:
response = _download(url, timeout)
ext = _detect_ext(response)
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(cache_dir, filename)
with open(filepath, "wb") as f:
f.write(response.content)
logger.info("Wallpaper saved to: %s", filepath)
max_cache = cfg["wallpaper"].get("max_cache_files", 10)
_cleanup_cache(cache_dir, keep=max_cache)
return filepath
def _fetch_json(url: str, field: str, timeout: int) -> str:
r = requests.get(url, timeout=timeout)
r.raise_for_status()
data = r.json()
image_url = data.get(field)
if not image_url:
raise ValueError(f"Field '{field}' not found in JSON response")
return image_url
def _download(url: str, timeout: int) -> requests.Response:
r = requests.get(url, timeout=timeout, stream=True)
r.raise_for_status()
return r
def _resolve_cache(cache_dir: str) -> str:
if os.path.isabs(cache_dir):
return cache_dir
base = os.path.dirname(os.path.abspath(__file__))
return os.path.normpath(os.path.join(base, cache_dir))
def _cleanup_cache(cache_dir: str, keep: int) -> None:
try:
files = [os.path.join(cache_dir, f) for f in os.listdir(cache_dir)]
files = [f for f in files if os.path.isfile(f)]
files.sort(key=os.path.getmtime, reverse=True)
for old in files[keep:]:
os.remove(old)
except Exception:
pass
+88
View File
@@ -0,0 +1,88 @@
import os
import sys
import logging
import winreg
logger = logging.getLogger(__name__)
REG_KEY = r"Software\Classes\Directory\Background\shell\RefreshWallpaper"
COMMAND_KEY = REG_KEY + r"\command"
MENU_NAME = "刷新壁纸(&R)"
def _get_app_dir() -> str:
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def _get_command() -> str:
if getattr(sys, 'frozen', False):
return f'"{sys.executable}" --refresh'
else:
base = os.path.dirname(sys.executable)
pythonw = os.path.join(base, "pythonw.exe")
if not os.path.exists(pythonw):
pythonw = sys.executable
script = os.path.join(_get_app_dir(), "refresh.py")
return f'"{pythonw}" "{script}"'
def _get_icon_path() -> str:
return os.path.join(_get_app_dir(), "assets", "icon.ico")
def install() -> bool:
command = _get_command()
icon = _get_icon_path()
try:
key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, REG_KEY)
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, MENU_NAME)
if os.path.exists(icon):
winreg.SetValueEx(key, "Icon", 0, winreg.REG_SZ, icon)
winreg.CloseKey(key)
key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, COMMAND_KEY)
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, command)
winreg.CloseKey(key)
logger.info("Desktop right-click menu installed: %s", MENU_NAME)
return True
except Exception as e:
logger.error("Failed to install menu: %s", e)
return False
def uninstall() -> bool:
try:
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_KEY)
winreg.CloseKey(key)
except FileNotFoundError:
logger.info("Menu not installed, nothing to uninstall")
return True
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, COMMAND_KEY)
logger.info("Deleted registry key: %s", COMMAND_KEY)
except Exception as e:
logger.warning("Failed to delete command key: %s", e)
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, REG_KEY)
logger.info("Deleted registry key: %s", REG_KEY)
except Exception as e:
logger.error("Failed to delete menu key: %s", e)
return False
logger.info("Desktop right-click menu uninstalled")
return True
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
if len(sys.argv) > 1 and sys.argv[1] == "uninstall":
uninstall()
else:
install()
+113
View File
@@ -0,0 +1,113 @@
import os
import sys
import json
import ctypes
import logging
LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
MUTEX_NAME = r"Global\WallpaperChanger_SingleInstance"
ERROR_ALREADY_EXISTS = 183
def _bootstrap():
import config
app_dir = config._get_app_dir()
for d in ["assets", "cache", "logs"]:
os.makedirs(os.path.join(app_dir, d), exist_ok=True)
config_path = config._get_config_path()
if not os.path.exists(config_path):
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config.DEFAULT_CONFIG, f, indent=4, ensure_ascii=False)
def _do_refresh():
logging.basicConfig(
level=logging.INFO,
format=LOG_FORMAT,
datefmt=LOG_DATE_FORMAT,
)
logger = logging.getLogger("refresh")
import config
import fetcher
import wallpaper
try:
image_path = fetcher.fetch_wallpaper()
fill_style = config.get("wallpaper.fill_style", "fill")
wallpaper.set_wallpaper(image_path, fill_style)
logger.info("Wallpaper updated successfully")
except Exception as e:
logger.error("Refresh failed: %s", e)
sys.exit(1)
def _setup_logging():
import config
log_dir = os.path.join(config._get_app_dir(), "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, "app.log")
logging.basicConfig(
level=logging.INFO,
format=LOG_FORMAT,
datefmt=LOG_DATE_FORMAT,
handlers=[
logging.FileHandler(log_file, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
def _ensure_icon():
import config
icon_path = os.path.join(config._get_app_dir(), "assets", "icon.ico")
if os.path.exists(icon_path):
return
from PIL import Image
img = Image.new("RGB", (64, 64), color=(66, 133, 244))
sizes = [(16, 16), (32, 32), (48, 48), (64, 64)]
img.save(icon_path, format="ICO", sizes=sizes)
def _check_single_instance():
handle = ctypes.windll.kernel32.CreateMutexW(None, False, MUTEX_NAME)
if ctypes.windll.kernel32.GetLastError() == ERROR_ALREADY_EXISTS:
ctypes.windll.user32.MessageBoxW(0, "程序已运行", "壁纸更换器", 0x40)
return False
return True
def main():
_bootstrap()
if "--refresh" in sys.argv:
_do_refresh()
return
if not _check_single_instance():
return
_setup_logging()
logger = logging.getLogger("main")
try:
_ensure_icon()
except Exception as e:
logger.warning("Failed to generate icon: %s", e)
from tray_app import TrayApp
app = TrayApp()
logger.info("Starting Wallpaper Changer...")
app.run()
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
# 壁纸更换器
从 HTTP API 自动拉取壁纸并设置为 Windows 桌面背景,支持托盘常驻和桌面右键菜单。
## 功能
- 托盘程序常驻,右键菜单手动/自动刷新壁纸
- 桌面右键菜单「刷新壁纸」一键更换
- 支持 API 直接返回图片二进制或 JSON 含图片 URL 两种模式
- 可配置填充方式(填充/适应/拉伸/平铺/居中)
- 可配置缓存目录及最大缓存数量
- 可开关换壁纸后气泡通知
- 单实例运行,重复启动弹窗提示
- 首次运行自动创建配置和目录
## 使用
```powershell
# 运行托盘程序
python main.py
# 右键菜单刷新
python main.py --refresh
# 安装桌面右键菜单(首次需要,从托盘菜单操作)
右键托盘 安装右键菜单
```
## 编译为 .exe
```powershell
powershell -ExecutionPolicy Bypass -File build.ps1
```
产物:`dist/WallpaperChanger.exe`
- 双击运行 → 启动托盘
- `WallpaperChanger.exe --refresh` → 拉取并设置壁纸后退出(右键菜单用)
- 首次运行在同目录下自动创建 `config.json``cache/``logs/``assets/`
## 配置
`config.json` 字段说明:
| 路径 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `api.url` | string | — | 壁纸 API 地址 |
| `api.mode` | string | `direct` | `direct` 返回图片二进制 / `json` 返回 JSON |
| `api.json_image_field` | string | `url` | JSON 模式下图片 URL 的字段名 |
| `api.timeout` | int | `30` | 请求超时秒数 |
| `wallpaper.fill_style` | string | `fill` | `fill`/`fit`/`stretch`/`tile`/`center` |
| `wallpaper.cache_dir` | string | `./cache` | 壁纸缓存目录 |
| `wallpaper.max_cache_files` | int | `10` | 最大缓存文件数 |
| `wallpaper.show_notification` | bool | `true` | 换壁纸后是否弹气泡通知 |
| `auto_refresh.enabled` | bool | `false` | 是否开启定时自动刷新 |
| `auto_refresh.interval_minutes` | int | `10` | 自动刷新间隔分钟数 |
## 依赖
- Python 3.12+
- requests, pystray, Pillow
+33
View File
@@ -0,0 +1,33 @@
import sys
import os
import logging
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import config
import fetcher
import wallpaper
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("refresh")
try:
logger.info("Fetching wallpaper...")
image_path = fetcher.fetch_wallpaper()
fill_style = config.get("wallpaper.fill_style", "fill")
wallpaper.set_wallpaper(image_path, fill_style)
logger.info("Wallpaper updated successfully")
except Exception as e:
logger.error("Refresh failed: %s", e)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -0,0 +1,3 @@
requests>=2.28.0
pystray>=0.19.0
Pillow>=9.0.0
+176
View File
@@ -0,0 +1,176 @@
import os
import threading
import logging
from PIL import Image
import pystray
import config
import fetcher
import wallpaper
logger = logging.getLogger(__name__)
STYLES = ["fill", "fit", "stretch", "tile", "center"]
STYLE_LABELS = {"fill": "填充", "fit": "适应", "stretch": "拉伸", "tile": "平铺", "center": "居中"}
def _get_icon_path():
return os.path.join(config._get_app_dir(), "assets", "icon.ico")
class TrayApp:
def __init__(self):
self._icon = None
self._auto_timer = None
self._running = False
def run(self):
self._running = True
image = Image.open(_get_icon_path())
self._icon = pystray.Icon(
"WallpaperChanger",
image,
"壁纸更换器",
menu=self._build_menu(),
)
self._icon.title = "壁纸更换器"
if config.get("auto_refresh.enabled"):
self._start_auto_refresh()
self._icon.run()
def stop(self):
self._running = False
self._stop_auto_refresh()
if self._icon:
self._icon.stop()
def _build_menu(self):
show_notify = config.get("wallpaper.show_notification", True)
notify_label = "弹出通知: 开" if show_notify else "弹出通知: 关"
return pystray.Menu(
pystray.MenuItem("刷新壁纸", self._on_refresh, default=True),
pystray.MenuItem("自动刷新", self._auto_refresh_submenu()),
pystray.MenuItem("填充方式", self._fill_style_submenu()),
pystray.Menu.SEPARATOR,
pystray.MenuItem(notify_label, self._on_toggle_notification),
pystray.Menu.SEPARATOR,
pystray.MenuItem("安装右键菜单", self._on_install_menu),
pystray.MenuItem("卸载右键菜单", self._on_uninstall_menu),
pystray.Menu.SEPARATOR,
pystray.MenuItem("退出", self._on_exit),
)
def _auto_refresh_submenu(self):
enabled = config.get("auto_refresh.enabled", False)
label = "状态: 开启" if enabled else "状态: 关闭"
return pystray.Menu(
pystray.MenuItem(label, None),
pystray.Menu.SEPARATOR,
pystray.MenuItem("开启", self._on_auto_on),
pystray.MenuItem("关闭", self._on_auto_off),
)
def _fill_style_submenu(self):
items = []
for s in STYLES:
items.append(
pystray.MenuItem(
STYLE_LABELS[s],
self._on_set_fill_style,
checked=lambda item, _s=s: _s == config.get("wallpaper.fill_style", "fill"),
)
)
return pystray.Menu(*items)
def _on_refresh(self, icon, item=None):
threading.Thread(target=self._do_refresh, daemon=True).start()
def _do_refresh(self):
try:
image_path = fetcher.fetch_wallpaper()
fill_style = config.get("wallpaper.fill_style", "fill")
ok = wallpaper.set_wallpaper(image_path, fill_style)
if ok and self._icon:
if config.get("wallpaper.show_notification", True):
self._icon.notify("壁纸已更新")
except Exception as e:
logger.exception("Refresh failed")
if self._icon and config.get("wallpaper.show_notification", True):
self._icon.notify(f"刷新失败: {e}")
def _on_auto_on(self, icon, item):
config.set_("auto_refresh.enabled", True)
self._start_auto_refresh()
icon.update_menu()
def _on_auto_off(self, icon, item):
config.set_("auto_refresh.enabled", False)
self._stop_auto_refresh()
icon.update_menu()
def _on_set_fill_style(self, icon, item):
for s in STYLES:
if STYLE_LABELS[s] == str(item):
config.set_("wallpaper.fill_style", s)
break
icon.update_menu()
def _on_toggle_notification(self, icon, item):
current = config.get("wallpaper.show_notification", True)
config.set_("wallpaper.show_notification", not current)
icon.update_menu()
def _on_install_menu(self, icon, item):
threading.Thread(target=self._do_install_menu, daemon=True).start()
def _do_install_menu(self):
try:
from install_menu import install
install()
if self._icon:
self._icon.notify("右键菜单已安装")
except Exception as e:
logger.exception("Install menu failed")
if self._icon:
self._icon.notify(f"安装失败: {e}")
def _on_uninstall_menu(self, icon, item):
threading.Thread(target=self._do_uninstall_menu, daemon=True).start()
def _do_uninstall_menu(self):
try:
from install_menu import uninstall
uninstall()
if self._icon:
self._icon.notify("右键菜单已卸载")
except Exception as e:
logger.exception("Uninstall menu failed")
if self._icon:
self._icon.notify(f"卸载失败: {e}")
def _on_exit(self, icon, item):
self.stop()
def _start_auto_refresh(self):
self._stop_auto_refresh()
interval = config.get("auto_refresh.interval_minutes", 10) * 60
self._auto_timer = threading.Timer(interval, self._auto_refresh_tick)
self._auto_timer.daemon = True
self._auto_timer.start()
logger.info("Auto refresh started, interval=%ds", interval)
def _stop_auto_refresh(self):
if self._auto_timer:
self._auto_timer.cancel()
self._auto_timer = None
def _auto_refresh_tick(self):
if not self._running:
return
self._do_refresh()
if config.get("auto_refresh.enabled"):
interval = config.get("auto_refresh.interval_minutes", 10) * 60
self._auto_timer = threading.Timer(interval, self._auto_refresh_tick)
self._auto_timer.daemon = True
self._auto_timer.start()
+63
View File
@@ -0,0 +1,63 @@
import ctypes
import os
import logging
from ctypes import wintypes
logger = logging.getLogger(__name__)
SPI_SETDESKWALLPAPER = 0x0014
SPIF_UPDATEINIFILE = 0x01
SPIF_SENDWININICHANGE = 0x02
FILL_STYLES = {
"fill": {"WallpaperStyle": "10", "TileWallpaper": "0"},
"fit": {"WallpaperStyle": "6", "TileWallpaper": "0"},
"stretch": {"WallpaperStyle": "2", "TileWallpaper": "0"},
"tile": {"WallpaperStyle": "0", "TileWallpaper": "1"},
"center": {"WallpaperStyle": "0", "TileWallpaper": "0"},
}
def set_fill_style(style: str) -> None:
import winreg
values = FILL_STYLES.get(style)
if not values:
values = FILL_STYLES["fill"]
try:
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Control Panel\Desktop",
0,
winreg.KEY_SET_VALUE,
)
winreg.SetValueEx(key, "WallpaperStyle", 0, winreg.REG_SZ, values["WallpaperStyle"])
winreg.SetValueEx(key, "TileWallpaper", 0, winreg.REG_SZ, values["TileWallpaper"])
winreg.CloseKey(key)
logger.info("Fill style set to: %s", style)
except Exception as e:
logger.error("Failed to set fill style: %s", e)
def set_wallpaper(image_path: str, fill_style: str = "fill") -> bool:
if not os.path.exists(image_path):
logger.error("Image not found: %s", image_path)
return False
set_fill_style(fill_style)
abs_path = os.path.abspath(image_path)
result = ctypes.windll.user32.SystemParametersInfoW(
SPI_SETDESKWALLPAPER,
0,
abs_path,
SPIF_UPDATEINIFILE | SPIF_SENDWININICHANGE,
)
if result:
logger.info("Wallpaper set: %s", abs_path)
else:
logger.error("Failed to set wallpaper: %s", abs_path)
return bool(result)