5 Commits
Author SHA1 Message Date
AskaEth 352dd7937e Merge branch 'feat/p4-core-platform-wallpaper-source-detect' 2026-11-01 21:18:02 +08:00
AskaEth 69d0025d09 fix(core): M73 r1 —— Windows 壁纸来源改为「当前主题优先」,通配主题包降为末档
复审 p2-1(medium):Win10+ 上 `%APPDATA%\Microsoft\Windows\Themes\` 通常只有
`TranscodedWallpaper`,一个 `.theme` 都没有;当前生效的主题记在
`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme` 值里,
指向 `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`。

- 新增第二步 `windows-current-theme`:`reg.exe query ... /v CurrentTheme` →
  归一化(含 `%LOCALAPPDATA%` 展开)→ 读该 `.theme` 的 `Wallpaper=`;
- `%APPDATA%\...\Themes\*.theme` 通配扫描从「第二步」降为第四步兜底,
  evidence 如实标注「用户另存的主题包,可能未启用」;
- 新顺序:TranscodedWallpaper → CurrentTheme → 注册表 WallPaper → 另存主题包;
- `HostRoots` 增加 `local_app_data`(`%LOCALAPPDATA%`,退 `~/AppData/Local`)。

复审 p2-2(low):`transcoded_wallpaper_hit_comes_first` 补一条字面量路径断言,
防止夹具与生产代码用同一构造函数一起拼错。

复审 p2-3(low):`HostIo::run` 现在检查退出码(`!output.status.success()` 也返回
`None`),与四处「非零退出 → 失败」的文案对齐;`io.rs` 补一条夹具用例
(有 stdout 但退出码非零)证明旧行为会被挡掉。

行长:`windows.rs` 371 → 拆出纯解析到 `wallpaper/windows/parse.rs`(100 行),
全部 7 个文件 ≤400 行。真机复跑 `detect()`:第 1 条 transcoded、第 2 条
`windows-current-theme` → `C:\Windows\web\wallpaper\Windows\img0.jpg`(与注册表同源,
去重后只剩一条),`%APPDATA%` 下无主题包的 note 照旧。
2026-11-01 21:17:55 +08:00
AskaEth d3e9a62b21 feat(core): M73 桌面壁纸路径探测(D38 第②级输入)
新增 platform::wallpaper:只做「桌面壁纸可能是哪个文件」的路径探测,
不解码图片、不取色(莫奈取色与三级降级留给 crates/design 的后续 mission)。

- 唯一入口 detect() / 注入式 detect_with(io, env, platform):返回有序候选列表
  + 各步失败原因,全部找不到时是空列表而不是错误;
- Windows 三步:TranscodedWallpaper(无扩展名,靠魔数认)→ *.theme 的
  Wallpaper= → reg.exe query HKCU\Control Panel\Desktop /v WallPaper;
- Linux 四步:gsettings picture-uri(-dark) → KDE Image= → hyprpaper/sway
  配置 → XFCE xfce4-desktop.xml,每步失败不影响下一步;
- macOS 两步:com.apple.wallpaper/Store/Index.plist 字符串扫描(不引 plist
  依赖,兼容 bplist00)→ defaults read com.apple.desktop Background;
  代码与文档明确标注「未在真机验证」;
- 零 unsafe / 零 FFI(core 是 #![forbid(unsafe_code)]),文件读取与子进程
  全部经 ProbeIo 注入,环境变量表经 EnvSource 注入;三平台采集逻辑不带 cfg,
  CI 的 Linux 上也能跑 Windows / macOS 夹具用例;
- 不新增第三方依赖;6 个文件均 ≤400 行,32 条单测覆盖三平台的
  正常命中 / 多候选 / 全部失败。
2026-11-01 21:17:52 +08:00
AskaEth 614bd253c8 Merge branch 'feat/docs-remove-affiliation-disclaimer-foote' 2026-11-01 20:26:14 +08:00
AskaEth 9e30d2cf15 docs(readme): 删掉文末「与 Mojang / Microsoft 无关联」免责脚注
中英两份都删,不补替代句;文件以感谢段落 + 单个换行结束。
2026-11-01 20:24:14 +08:00
10 changed files with 2150 additions and 4 deletions

No files matched your search

-2
View File
@@ -188,5 +188,3 @@ Minimum baseline: **Windows 10**; Windows 8.1 / 8 / 7 are not supported.
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and [PCL2](https://github.com/Meloong-Git/PCL)
(both GPL-3.0). They have been with the vast majority of Minecraft players for years, and this
project has benefited a great deal from their design choices and implementation details.
Not affiliated with Mojang or Microsoft.
-2
View File
@@ -164,5 +164,3 @@ cargo build --release # 三个 bin 都落在 ta
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)
(均 GPL-3.0)。这两款启动器陪着绝大多数 MC 玩家走了很多年,
本项目在设计取舍与实现细节上都从中受益良多。
与 Mojang、Microsoft 无关联。
+4
View File
@@ -21,6 +21,9 @@
//! - [`linux_caps`]:发行版(仅显示)+ 内核 release(喂 `os.version`)+ 会话/GL 能力探测;
//! - [`vk_probe`]:Vulkan 能力的安全层探测(loader 文件 / ICD 注册 / 环境变量,无 `unsafe`);
//! - [`theme_probe`]:系统主题(浅色/深色)探测,供主题三态的 `auto` 初始化对齐与兜底跟随。
//! - [`wallpaper`]:桌面壁纸**路径探测**(D38 配色三级优先里第②级的输入)。本模块只做路径
//! 探测——不解码图片、不取色、不生成调色板,莫奈取色与三级降级在 `crates/design` 的
//! 后续 mission。
//!
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
@@ -39,4 +42,5 @@ pub mod receipt;
pub mod redirect;
pub mod theme_probe;
pub mod vk_probe;
pub mod wallpaper;
pub mod windows_version;
+358
View File
@@ -0,0 +1,358 @@
//! 桌面壁纸**路径探测**(D38 配色三级优先里的第②级输入)。
//!
//! 用户原话(D38,2026-11-01):「如果用户设置了启动器背景图,则使用莫奈取色从背景图取,
//! 没有背景图时用户也可以开启莫奈取色,此时从桌面壁纸取色。」本模块只承担中间那一级的
//! **输入**:按可信度给出「这台机器的桌面壁纸可能是哪个文件」的有序候选列表。
//!
//! # 边界(只做路径探测)
//!
//! - **不解码图片、不取色、不生成调色板**。莫奈取色本身、D38 三级降级、
//! 「取色失败回落到色卡当前选择 + 设置页可读提示」都在 `crates/design` 的后续 mission。
//! - 全部找不到时返回**空列表**([`WallpaperProbe::is_empty`])而不是错误:没有桌面壁纸是
//! 正常状态(服务器、无桌面环境、壁纸是纯色),调用方按 D38 直接落到第③级色卡。
//! - **零 `unsafe`、零 FFI**(core 是 `#![forbid(unsafe_code)]`):不用
//! `SystemParametersInfo(SPI_GETDESKWALLPAPER)`、不碰 win32 注册表 API,全部走文件读取与
//! `reg.exe` / `gsettings` / `defaults` 子进程——与 [`super::theme_probe`]、
//! [`super::windows_version`] 同一套路子。
//!
//! # 分层与可测性
//!
//! - 采集逻辑按平台分文件(`windows` / `linux` / `macos` 子模块),**无条件编译**、不按
//! `cfg` 裁剪:CI 是 Linux,Windows / macOS 的夹具用例同样要能在那里跑;
//! - 平台分流只发生在 [`detect_with`] 一处;
//! - 外部 IO 全部经 [`ProbeIo`](文件内容 / 文件头 / 目录列表 / 子进程)与
//! [`EnvSource`](环境变量表)注入,[`detect`] 只是把它们换成真实实现。
//!
//! # 什么算「候选壁纸」
//!
//! 只收「归一化后是绝对路径 + 文件头认得出是图片」的条目(魔数集合见 `parse` 子模块的
//! `looks_like_image`)。这条校验既挡掉过期配置里指向已删除文件的路径,也挡掉 macOS
//! 二进制 plist 字符串扫描出的碎片串。
use std::path::PathBuf;
use crate::platform::paths::{EnvSource, Platform, ProcessEnv};
use self::io::{HostIo, HostRoots, ProbeIo};
use self::parse::{looks_like_image, normalize_path};
mod io;
mod linux;
mod macos;
mod parse;
mod windows;
/// 读文件头做魔数嗅探时的字节数(够 `ftyp` 分支与文本 SVG 头)。
const IMAGE_HEAD_LEN: usize = 512;
/// 一个候选壁纸是**怎么被找到的**:平台 + 桌面环境标识。
///
/// 标识只表示「这条路径从哪个通道读出来」,不承诺该通道一定存在(读不到就走下一个)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
/// Windows:`%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`(系统转码后的当前壁纸)。
WindowsTranscoded,
/// Windows:`HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme`
/// 指向的 `.theme`(Win10+ 落在 `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`)。
///
/// 这是**当前生效**的那份主题,比 `%APPDATA%` 下的主题包可靠得多。
WindowsCurrentTheme,
/// Windows:`%APPDATA%\Microsoft\Windows\Themes\*.theme` 里的 `Wallpaper=` 行。
///
/// 用户另存的主题包(不一定被启用),只作最后一档兜底。
WindowsThemeFile,
/// Windows:注册表 `HKCU\Control Panel\Desktop` 的 `WallPaper` 值(`reg.exe` 子进程)。
WindowsRegistry,
/// Linux · GNOME:`gsettings get org.gnome.desktop.background picture-uri`(`-dark`)。
Gnome,
/// Linux · KDE Plasma:`plasma-org.kde.plasma.desktop-appletsrc` 的 `Image=` 行。
Kde,
/// Linux · swaybg / hyprpaper 配置里的 wallpaper 行。
WlrootsCompositor,
/// Linux · XFCE:`xfce4-desktop.xml` 的 `last-image` / `image-path` 属性。
Xfce,
/// macOS:`com.apple.wallpaper/Store/Index.plist` 里的 file URL / 绝对路径串。
MacOsStore,
/// macOS:`defaults read com.apple.desktop Background`(老式每屏壁纸设置)。
MacOsDesktopDefaults,
}
impl SourceKind {
/// 稳定标识(日志 / 设置页提示用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::WindowsTranscoded => "windows-transcoded",
Self::WindowsCurrentTheme => "windows-current-theme",
Self::WindowsThemeFile => "windows-theme-file",
Self::WindowsRegistry => "windows-registry",
Self::Gnome => "linux-gnome",
Self::Kde => "linux-kde",
Self::WlrootsCompositor => "linux-wlroots",
Self::Xfce => "linux-xfce",
Self::MacOsStore => "macos-store",
Self::MacOsDesktopDefaults => "macos-desktop-defaults",
}
}
/// 这条通道属于哪个平台族。
#[must_use]
pub const fn platform(self) -> Platform {
match self {
Self::WindowsTranscoded
| Self::WindowsCurrentTheme
| Self::WindowsThemeFile
| Self::WindowsRegistry => Platform::Windows,
Self::MacOsStore | Self::MacOsDesktopDefaults => Platform::MacOs,
Self::Gnome | Self::Kde | Self::WlrootsCompositor | Self::Xfce => Platform::Linux,
}
}
}
/// 一个候选壁纸来源。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WallpaperSource {
/// 通道标识(平台 + 桌面环境)。
pub kind: SourceKind,
/// 归一化后的绝对路径。
pub path: PathBuf,
/// 原始证据:配置文件 / 注册表键 / 命令行,用于日志与设置页的「从哪读到的」。
pub evidence: String,
}
/// 探测结果:有序候选列表 + 各步失败原因。
///
/// 列表**不是** `Result`:找不到壁纸不是错误,见模块文档的边界一节。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WallpaperProbe {
sources: Vec<WallpaperSource>,
notes: Vec<String>,
}
impl WallpaperProbe {
/// 候选列表,按可信度从高到低(第一个就是首选)。
#[must_use]
pub fn sources(&self) -> &[WallpaperSource] {
&self.sources
}
/// 首选候选(等价于 `sources().first()`)。
#[must_use]
pub fn best(&self) -> Option<&WallpaperSource> {
self.sources.first()
}
/// 候选迭代器(按可信度顺序)。
pub fn iter(&self) -> impl Iterator<Item = &WallpaperSource> {
self.sources.iter()
}
/// 候选个数。
#[must_use]
pub fn len(&self) -> usize {
self.sources.len()
}
/// 一个候选都没有(调用方应落到 D38 的第③级色卡)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.sources.is_empty()
}
/// 各步的失败原因(`通道: 原因`),可直接展示在设置页的提示里。
#[must_use]
pub fn notes(&self) -> &[String] {
&self.notes
}
/// 取走候选列表。
#[must_use]
pub fn into_sources(self) -> Vec<WallpaperSource> {
self.sources
}
}
/// 真实探测宿主平台([`HostIo`] + 进程环境 + 编译目标平台)。
#[must_use]
pub fn detect() -> WallpaperProbe {
detect_with(&HostIo, &ProcessEnv, Platform::current())
}
/// 注入式探测:IO、环境变量表、平台族全部由调用方给。
///
/// 三平台采集逻辑都不带 `cfg`,所以任意宿主都能用固定夹具跑某一平台的用例。
#[must_use]
pub fn detect_with(io: &dyn ProbeIo, env: &dyn EnvSource, platform: Platform) -> WallpaperProbe {
let Some(roots) = HostRoots::from_env(env) else {
return WallpaperProbe {
sources: Vec::new(),
notes: vec!["host: HOME / USERPROFILE 都读不到,无法定位用户目录".to_owned()],
};
};
let mut collect = Collect::new(io, roots, platform);
match platform {
Platform::Windows => windows::collect(&mut collect),
Platform::MacOs => macos::collect(&mut collect),
// 其它 Unix 按 Linux 的桌面习惯兜底(与 `paths` 模块的处理一致)。
Platform::Linux | Platform::Other => linux::collect(&mut collect),
}
collect.finish()
}
/// 采集过程的累加器:负责归一化、去重、图片校验与失败原因记录。
pub(crate) struct Collect<'io> {
io: &'io dyn ProbeIo,
roots: HostRoots,
platform: Platform,
sources: Vec<WallpaperSource>,
notes: Vec<String>,
seen: Vec<String>,
}
impl<'io> Collect<'io> {
/// 构造空累加器。
pub(crate) fn new(io: &'io dyn ProbeIo, roots: HostRoots, platform: Platform) -> Self {
Self {
io,
roots,
platform,
sources: Vec::new(),
notes: Vec::new(),
seen: Vec::new(),
}
}
/// 注入的 IO(文件 / 目录 / 子进程)。
pub(crate) fn io(&self) -> &'io dyn ProbeIo {
self.io
}
/// 宿主根目录(由环境变量表推出)。
pub(crate) fn roots(&self) -> &HostRoots {
&self.roots
}
/// 记一条失败原因,探测继续往下走。
pub(crate) fn note(&mut self, step: &str, detail: impl std::fmt::Display) {
self.notes.push(format!("{step}: {detail}"));
}
/// 收一个候选:归一化 → 去重 → 图片校验,任何一步不过都只记原因、不中断。
///
/// 返回是否真的收下(调用方据此决定要不要继续试别名)。
pub(crate) fn add(&mut self, kind: SourceKind, evidence: &str, raw: &str) -> bool {
match normalize_path(raw, &self.roots) {
Some(path) => self.add_path(kind, evidence, path),
None => {
self.note(evidence, format!("不是可用的绝对路径({raw})"));
false
}
}
}
/// 收一个已经算好的路径(调用方自己拼的,不再归一化)。
pub(crate) fn add_path(&mut self, kind: SourceKind, evidence: &str, path: PathBuf) -> bool {
let key = dedupe_key(self.platform, &path);
if self.seen.contains(&key) {
return false;
}
let head = self.io.read_head(&path, IMAGE_HEAD_LEN);
if !head.as_deref().is_some_and(looks_like_image) {
self.note(
evidence,
format!("读不到或认不出图片格式({})", path.display()),
);
return false;
}
self.seen.push(key);
self.sources.push(WallpaperSource {
kind,
path,
evidence: evidence.to_owned(),
});
true
}
/// 收尾。
pub(crate) fn finish(self) -> WallpaperProbe {
WallpaperProbe {
sources: self.sources,
notes: self.notes,
}
}
}
/// 去重键:Windows 路径大小写不敏感(同一文件可能被写出两种大小写),其它平台按原样。
fn dedupe_key(platform: Platform, path: &std::path::Path) -> String {
let text = path.to_string_lossy().into_owned();
if platform == Platform::Windows {
text.to_ascii_lowercase()
} else {
text
}
}
#[cfg(test)]
mod tests {
use super::io::{HostIo, HostRoots};
use super::*;
use crate::platform::paths::MapEnv;
#[test]
fn empty_probe_is_empty_not_error() {
let probe = WallpaperProbe::default();
assert!(probe.is_empty());
assert_eq!(probe.len(), 0);
assert!(probe.best().is_none());
assert_eq!(probe.iter().count(), 0);
assert!(probe.notes().is_empty());
assert!(probe.into_sources().is_empty());
}
#[test]
fn missing_home_yields_note_and_no_candidates() {
let env = MapEnv::new();
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
let probe = detect_with(&HostIo, &env, platform);
assert!(probe.is_empty(), "{platform:?} 没有 HOME 时不该有候选");
assert_eq!(probe.notes().len(), 1);
assert!(probe.notes()[0].contains("HOME"));
}
}
#[test]
fn source_kind_exposes_platform_and_stable_name() {
assert_eq!(SourceKind::Gnome.platform(), Platform::Linux);
assert_eq!(SourceKind::WindowsRegistry.platform(), Platform::Windows);
assert_eq!(SourceKind::MacOsStore.platform(), Platform::MacOs);
assert_eq!(SourceKind::WindowsTranscoded.as_str(), "windows-transcoded");
assert!(SourceKind::Kde.as_str().starts_with("linux-"));
}
#[test]
fn collect_dedupes_and_keeps_priority_order() {
let env = MapEnv::new().with("HOME", "/home/u");
let roots = HostRoots::from_env(&env).expect("HOME 已给");
let io = io::fake::FakeIo::new()
.with_image("/home/u/a.png")
.with_image("/home/u/b.jpg");
let mut collect = Collect::new(&io, roots, Platform::Linux);
collect.add(SourceKind::Gnome, "gsettings", "/home/u/a.png");
collect.add(SourceKind::Kde, "kde", "file:///home/u/b.jpg");
// 同一条路径再来一次(换了个通道)不该重复计入。
collect.add(SourceKind::Xfce, "xfce", "/home/u/a.png");
collect.add(SourceKind::Xfce, "xfce", "/home/u/missing.png");
let probe = collect.finish();
assert_eq!(probe.len(), 2);
assert_eq!(probe.best().expect("有候选").kind, SourceKind::Gnome);
assert_eq!(probe.sources()[1].path, PathBuf::from("/home/u/b.jpg"));
assert!(
probe
.notes()
.iter()
.any(|note| note.contains("missing.png")),
"落选的候选要留原因:{:?}",
probe.notes()
);
}
}
+297
View File
@@ -0,0 +1,297 @@
//! 壁纸探测要用的外部 IO:文件读取、文件头、目录列表、子进程,以及宿主根目录。
//!
//! 生产实现是 [`HostIo`],测试实现是 `fake::FakeIo`(固定夹具,不碰真机壁纸)。
//! 这一层的存在只为把「外部世界」变成注入参数,不含任何平台判断。
use std::path::{Path, PathBuf};
use crate::platform::paths::EnvSource;
/// 探测需要的外部世界:四个方法覆盖三平台的全部读法。
pub trait ProbeIo {
/// 读整个文件并按 UTF-8 lossy 转文本。
///
/// 二进制 plist 也走这里:路径串本身是可读 ASCII,坏字节会被替换成 U+FFFD 而不影响
/// 路径串的完整性(macOS 的 `Index.plist` 就是 bplist00)。
fn read_text(&self, path: &Path) -> Option<String>;
/// 读文件头最多 `limit` 字节(图片魔数嗅探用;不存在或读不了返回 `None`)。
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>>;
/// 列出目录里的**文件**(非递归、不排序;目录不存在返回空列表)。排序由调用方做。
fn list_dir(&self, dir: &Path) -> Vec<PathBuf>;
/// 跑一个外部命令取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。
fn run(&self, program: &str, args: &[&str]) -> Option<String>;
}
/// 真实宿主 IO。
#[derive(Debug, Clone, Copy, Default)]
pub struct HostIo;
impl ProbeIo for HostIo {
fn read_text(&self, path: &Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buffer = vec![0_u8; limit];
let mut handle = file.take(limit as u64);
let read = handle.read(&mut buffer).ok()?;
buffer.truncate(read);
Some(buffer)
}
fn list_dir(&self, dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_type()
.map(|kind| kind.is_file())
.unwrap_or(false)
})
.map(|entry| entry.path())
.collect()
}
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
let output = std::process::Command::new(program)
.args(args)
.output()
.ok()?;
// `Command::output` 对非零退出同样返回 `Ok`,所以退出码要单独判:三条命令
// (`reg.exe` / `gsettings` / `defaults`)出错时都走 stderr,只看 stdout 会把
// 「查不到」和「命令失败」混成一种,调用方的原因文案就名不副实了。
if !output.status.success() || output.stdout.is_empty() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
}
/// 探测要用的宿主根目录(全部由环境变量表推出,因此测试可以整体替换)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostRoots {
/// 用户主目录(`HOME`,退 `USERPROFILE`)。
pub home: PathBuf,
/// Windows `%APPDATA%`(退 `~/AppData/Roaming`);其它平台用不到,但仍填上以免分支取空。
pub app_data: PathBuf,
/// Windows `%LOCALAPPDATA%`(退 `~/AppData/Local`):Win10+ 的「当前主题」
/// `Custom.theme` 落在这一侧。
pub local_app_data: PathBuf,
/// `$XDG_CONFIG_HOME`(仅接受绝对路径),退 `~/.config`。
pub config_home: PathBuf,
/// Windows `%SystemRoot%`(`.theme` 里 `Wallpaper=%SystemRoot%\...` 展开用),无则不展开。
pub system_root: Option<PathBuf>,
}
impl HostRoots {
/// 从环境变量表推出根目录;连主目录都没有时返回 `None`(调用方记一条原因)。
///
/// 顺序固定(`HOME` 优先于 `USERPROFILE`),**不按编译目标分支**:否则在 Windows 上
/// 跑 Linux 夹具时会被 `USERPROFILE` 抢走主目录,夹具就不再确定。
#[must_use]
pub fn from_env(env: &dyn EnvSource) -> Option<Self> {
let home = env
.var("HOME")
.or_else(|| env.var("USERPROFILE"))
.map(PathBuf::from)?;
let app_data = env
.var("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Roaming"));
let local_app_data = env
.var("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Local"));
let config_home = env
.var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|dir| dir.is_absolute())
.unwrap_or_else(|| home.join(".config"));
let system_root = env
.var("SYSTEMROOT")
.or_else(|| env.var("WINDIR"))
.map(PathBuf::from);
Some(Self {
home,
app_data,
local_app_data,
config_home,
system_root,
})
}
}
#[cfg(test)]
pub(crate) mod fake {
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::ProbeIo;
/// PNG 魔数(夹具用它冒充「像图片」的文件)。
pub(crate) const PNG_HEAD: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
/// 固定夹具 IO:文件内容、图片、目录列表、命令输出全部预先给定。
///
/// 未登记的路径一律「不存在 / 读不出 / 目录为空」,命令也未登记则报失败。
#[derive(Debug, Default, Clone)]
pub struct FakeIo {
texts: BTreeMap<PathBuf, String>,
images: Vec<PathBuf>,
dirs: BTreeMap<PathBuf, Vec<PathBuf>>,
commands: BTreeMap<String, String>,
}
impl FakeIo {
/// 空世界(一切探测都会失败)。
pub(crate) fn new() -> Self {
Self::default()
}
/// 登记一个文本文件(配置文件、`.theme`、plist、xml……)。
pub(crate) fn with_file(mut self, path: impl AsRef<Path>, text: &str) -> Self {
self.texts
.insert(path.as_ref().to_path_buf(), text.to_owned());
self
}
/// 登记一个「图片」文件(`read_head` 返回 PNG 魔数)。
pub(crate) fn with_image(mut self, path: impl AsRef<Path>) -> Self {
self.images.push(path.as_ref().to_path_buf());
self
}
/// 登记目录内容(按文件名给,路径拼在 `dir` 上)。
pub(crate) fn with_dir(mut self, dir: impl AsRef<Path>, names: &[&str]) -> Self {
let dir = dir.as_ref();
let entries = names.iter().map(|name| dir.join(name)).collect();
self.dirs.insert(dir.to_path_buf(), entries);
self
}
/// 登记一条命令的 stdout;`key` 是「程序名 + 空格分隔的参数」整串。
pub(crate) fn with_command(mut self, key: &str, stdout: &str) -> Self {
self.commands.insert(key.to_owned(), stdout.to_owned());
self
}
}
fn command_key(program: &str, args: &[&str]) -> String {
let mut key = program.to_owned();
for arg in args {
key.push(' ');
key.push_str(arg);
}
key
}
impl ProbeIo for FakeIo {
fn read_text(&self, path: &Path) -> Option<String> {
self.texts.get(path).cloned()
}
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
if self.images.iter().any(|image| image.as_path() == path) {
return Some(PNG_HEAD[..limit.min(PNG_HEAD.len())].to_vec());
}
let text = self.texts.get(path)?;
let bytes = text.as_bytes();
Some(bytes[..limit.min(bytes.len())].to_vec())
}
fn list_dir(&self, dir: &Path) -> Vec<PathBuf> {
self.dirs.get(dir).cloned().unwrap_or_default()
}
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
let key = command_key(program, args);
let stdout = self.commands.get(&key)?;
if stdout.is_empty() {
return None;
}
Some(stdout.clone())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::paths::MapEnv;
#[test]
fn roots_come_from_env_in_fixed_order() {
let env = MapEnv::new()
.with("HOME", "/home/u")
.with("USERPROFILE", "C:/Users/ignored")
.with("SYSTEMROOT", "C:/Windows");
let roots = HostRoots::from_env(&env).expect("HOME 已给");
assert_eq!(roots.home, PathBuf::from("/home/u"));
assert_eq!(roots.app_data, PathBuf::from("/home/u/AppData/Roaming"));
assert_eq!(roots.local_app_data, PathBuf::from("/home/u/AppData/Local"));
assert_eq!(roots.config_home, PathBuf::from("/home/u/.config"));
assert_eq!(roots.system_root, Some(PathBuf::from("C:/Windows")));
}
#[test]
fn roots_prefer_explicit_appdata_and_absolute_xdg() {
let env = MapEnv::new()
.with("HOME", "/home/u")
.with("APPDATA", "C:/Users/u/AppData/Roaming")
.with("LOCALAPPDATA", "C:/Users/u/AppData/Local")
// 相对路径按 XDG 规范忽略。
.with("XDG_CONFIG_HOME", "relative/dir");
let roots = HostRoots::from_env(&env).expect("HOME 已给");
assert_eq!(roots.app_data, PathBuf::from("C:/Users/u/AppData/Roaming"));
assert_eq!(
roots.local_app_data,
PathBuf::from("C:/Users/u/AppData/Local")
);
assert_eq!(roots.config_home, PathBuf::from("/home/u/.config"));
assert_eq!(roots.system_root, None);
}
#[test]
fn host_io_treats_non_zero_exit_as_failure() {
// 有 stdout 但退出码非零:只看 stdout 的旧实现会把这种输出当成功。
#[cfg(windows)]
let (program, args) = ("cmd", ["/c", "echo hi & exit 3"]);
#[cfg(not(windows))]
let (program, args) = ("sh", ["-c", "echo hi; exit 3"]);
assert!(HostIo.run(program, &args).is_none());
}
#[test]
fn fake_io_serves_only_registered_world() {
let io = fake::FakeIo::new()
.with_file("/home/u/note.txt", "hello")
.with_image("/home/u/a.png")
.with_dir("/home/u/dir", &["a.png", "b.theme"])
.with_command("gsettings get x y", "'file:///home/u/a.png'");
assert_eq!(
io.read_text(Path::new("/home/u/note.txt")).as_deref(),
Some("hello")
);
assert_eq!(
io.read_head(Path::new("/home/u/note.txt"), 4).as_deref(),
Some(&b"hell"[..])
);
assert_eq!(
io.read_head(Path::new("/home/u/a.png"), 8).as_deref(),
Some(&fake::PNG_HEAD[..8])
);
assert_eq!(io.list_dir(Path::new("/home/u/dir")).len(), 2);
assert!(io.run("gsettings", &["get", "x", "y"]).is_some());
assert!(io.run("reg.exe", &["query"]).is_none());
assert!(io.read_text(Path::new("/nope")).is_none());
}
}
+396
View File
@@ -0,0 +1,396 @@
//! Linux 壁纸路径探测:GNOME → KDE Plasma → swaybg / hyprpaper → XFCE。
//!
//! 四步互相独立:`gsettings` 缺失、配置文件不存在、解析不到值都只记一条原因,探测继续往下
//! 走(Wayland 上一条配置都读不到是常态,此时应直接落到 D38 的第③级色卡)。
//!
//! 探测路径全部来自「桌面环境自己记在磁盘上的东西」,不需要 D-Bus 与 xdg-desktop-portal:
//!
//! 1. GNOME / Ubuntu 系:`gsettings get org.gnome.desktop.background picture-uri`,再看
//! `picture-uri-dark`(深色壁纸是独立键);
//! 2. KDE Plasma:`~/.config/plasma-org.kde.plasma.desktop-appletsrc` 的 `Image=` 行;
//! 3. swaybg / hyprpaper:`~/.config/hypr/hyprpaper.conf`(`wallpaper =` / `preload =`)与
//! `~/.config/sway/config`(`output <name> bg <path>`);
//! 4. XFCE:`~/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml` 的
//! `last-image` / `image-path` 属性。
//!
//! 本文件不带 `cfg`:平台分流只在 [`super::detect_with`] 一处,任何宿主都能跑夹具用例。
use super::{Collect, SourceKind};
/// GNOME 壁纸 schema。
pub(super) const GNOME_SCHEMA: &str = "org.gnome.desktop.background";
/// 探测的键,顺序即优先级(浅色在前,深色壁纸在后)。
pub(super) const GNOME_KEYS: &[&str] = &["picture-uri", "picture-uri-dark"];
const KDE_CONFIG: &str = "plasma-org.kde.plasma.desktop-appletsrc";
const HYPRPAPER_CONFIG: &str = "hypr/hyprpaper.conf";
const SWAY_CONFIG: &str = "sway/config";
const XFCE_DESKTOP_XML: &str = "xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml";
/// 四步依次探测。
pub(super) fn collect(collect: &mut Collect<'_>) {
gnome(collect);
kde(collect);
compositors(collect);
xfce(collect);
}
/// 第一步:GNOME 的 `picture-uri` / `picture-uri-dark`。
fn gnome(collect: &mut Collect<'_>) {
for key in GNOME_KEYS {
let evidence = format!("gsettings get {GNOME_SCHEMA} {key}");
match collect.io().run("gsettings", &["get", GNOME_SCHEMA, key]) {
Some(text) => match parse_gsettings_value(&text) {
Some(value) => {
collect.add(SourceKind::Gnome, &evidence, &value);
}
None => collect.note(
SourceKind::Gnome.as_str(),
format!("{evidence} = {}(没有设壁纸)", text.trim()),
),
},
None => collect.note(
SourceKind::Gnome.as_str(),
format!("{evidence} 失败(命令缺失 / 非零退出 / 空输出)"),
),
}
}
}
/// 第二步:KDE Plasma 的壁纸插件配置。
fn kde(collect: &mut Collect<'_>) {
let Some(text) = read_config(collect, SourceKind::Kde, KDE_CONFIG) else {
return;
};
let values = parse_kde_images(&text);
if values.is_empty() {
collect.note(
SourceKind::Kde.as_str(),
format!("{KDE_CONFIG} 里没有 Image= 行"),
);
}
let evidence = format!("{KDE_CONFIG} 的 Image=");
for value in values {
collect.add(SourceKind::Kde, &evidence, &value);
}
}
/// 第三步:swaybg(sway 配置)/ hyprpaper。
fn compositors(collect: &mut Collect<'_>) {
for (relative, what) in [
(HYPRPAPER_CONFIG, "wallpaper/preload 行"),
(SWAY_CONFIG, "output ... bg 行"),
] {
let Some(text) = read_config(collect, SourceKind::WlrootsCompositor, relative) else {
continue;
};
let values = match relative {
HYPRPAPER_CONFIG => parse_hyprpaper_wallpapers(&text),
_ => parse_sway_backgrounds(&text),
};
if values.is_empty() {
collect.note(
SourceKind::WlrootsCompositor.as_str(),
format!("{relative} 里没有 {what}"),
);
}
let evidence = format!("{relative} 的 {what}");
for value in values {
collect.add(SourceKind::WlrootsCompositor, &evidence, &value);
}
}
}
/// 第四步:XFCE 的 xfconf XML。
fn xfce(collect: &mut Collect<'_>) {
let Some(text) = read_config(collect, SourceKind::Xfce, XFCE_DESKTOP_XML) else {
return;
};
let values = parse_xfce_last_image(&text);
if values.is_empty() {
collect.note(
SourceKind::Xfce.as_str(),
format!("{XFCE_DESKTOP_XML} 里没有 last-image/image-path 属性"),
);
}
let evidence = format!("{XFCE_DESKTOP_XML} 的 last-image/image-path");
for value in values {
collect.add(SourceKind::Xfce, &evidence, &value);
}
}
/// 读 `$XDG_CONFIG_HOME` 下的一个配置文件;读不到只记原因并返回 `None`。
fn read_config(collect: &mut Collect<'_>, kind: SourceKind, relative: &str) -> Option<String> {
let path = collect.roots().config_home.join(relative);
match collect.io().read_text(&path) {
Some(text) => Some(text),
None => {
collect.note(kind.as_str(), format!("读不到 {}", path.display()));
None
}
}
}
/// 解析 `gsettings get` 的输出:`'file:///home/u/a.png'`;`'none'` / 空串返回 `None`。
pub(super) fn parse_gsettings_value(text: &str) -> Option<String> {
let trimmed = text.trim();
let value = trimmed
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
.unwrap_or(trimmed)
.trim();
if value.is_empty() || value.eq_ignore_ascii_case("none") {
return None;
}
Some(value.to_owned())
}
/// 取 Plasma 配置里的 `Image=` 值(壁纸插件是 `[org.kde.image][General]` 段)。
///
/// 只收绝对路径与 `file://` URI:Plasma 的其它插件也有 `Image=` 键,相对值当成非壁纸。
pub(super) fn parse_kde_images(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let (key, value) = line.trim().split_once('=')?;
if !key.trim().eq_ignore_ascii_case("image") {
return None;
}
let value = value.trim();
let usable = value.starts_with('/')
|| value.starts_with("file://")
|| value
.get(..2)
.is_some_and(|head| head.eq_ignore_ascii_case("~/"));
usable.then(|| value.to_owned())
})
.collect()
}
/// 取 hyprpaper 的 `wallpaper = <监视器>,<路径>` 与 `preload = <路径>` 行。
pub(super) fn parse_hyprpaper_wallpapers(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with('#') {
return None;
}
let (key, value) = line.split_once('=')?;
let value = value.trim();
match key.trim().to_ascii_lowercase().as_str() {
"preload" => non_empty(value),
// `wallpaper = eDP-1,/path/a.png`(监视器可留空);没写逗号时整段就是路径。
"wallpaper" => match value.split_once(',') {
Some((_, path)) => non_empty(path),
None => non_empty(value),
},
_ => None,
}
})
.collect()
}
/// 取 sway 配置里 `output <名字|*> bg <路径> [填充方式]` 的路径。
///
/// sway 允许用引号包住含空格的对象名/路径,这里把引号拼回来;解析不出来的行直接跳过。
pub(super) fn parse_sway_backgrounds(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.starts_with('#') {
return None;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.len() < 4 || tokens[0] != "output" || tokens[2] != "bg" {
return None;
}
let raw = tokens[3];
let mut value = raw.trim_start_matches('"').to_owned();
if raw.starts_with('"') && !raw.ends_with('"') {
for token in &tokens[4..] {
value.push(' ');
match token.strip_suffix('"') {
Some(tail) => {
value.push_str(tail);
break;
}
None => value.push_str(token),
}
}
}
non_empty(value.trim_end_matches('"'))
})
.collect()
}
/// 取 XFCE `xfce4-desktop.xml` 里 `last-image` / `image-path` 属性的值。
pub(super) fn parse_xfce_last_image(text: &str) -> Vec<String> {
text.lines()
.filter(|line| line.contains("last-image") || line.contains("image-path"))
.filter_map(|line| non_empty(&attribute(line, "value")?))
.collect()
}
/// 取 XML 一行里 `name="..."` 属性的值。
fn attribute(line: &str, name: &str) -> Option<String> {
let needle = format!("{name}=\"");
let start = line.find(&needle)? + needle.len();
let rest = &line[start..];
let end = rest.find('"')?;
Some(rest[..end].to_owned())
}
fn non_empty(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
#[cfg(test)]
mod tests {
use super::super::io::fake::FakeIo;
use super::super::{detect_with, SourceKind};
use super::*;
use crate::platform::paths::{MapEnv, Platform};
const CONFIG: &str = "/home/u/.config";
fn env() -> MapEnv {
MapEnv::new().with("HOME", "/home/u")
}
fn gsettings(key: &str) -> String {
format!("gsettings get {GNOME_SCHEMA} {key}")
}
fn config(name: &str) -> String {
format!("{CONFIG}/{name}")
}
#[test]
fn gnome_picture_uri_hit_decodes_percent_escapes() {
let io = FakeIo::new()
.with_command(
&gsettings("picture-uri"),
"'file:///home/u/my%20wall.png'\n",
)
.with_image("/home/u/my wall.png");
let probe = detect_with(&io, &env(), Platform::Linux);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
let best = probe.best().expect("有候选");
assert_eq!(best.kind, SourceKind::Gnome);
assert_eq!(best.path.display().to_string(), "/home/u/my wall.png");
}
#[test]
fn four_steps_yield_ordered_candidates() {
let io = ["a", "b", "k", "h", "h2", "s", "x"]
.into_iter()
.map(|name| format!("/home/u/{name}.png"))
.fold(
FakeIo::new()
.with_command(&gsettings("picture-uri"), "'file:///home/u/a.png'\n")
.with_command(&gsettings("picture-uri-dark"), "'file:///home/u/b.png'\n")
.with_file(config(KDE_CONFIG), "Image=file:///home/u/k.png\n")
.with_file(
config(HYPRPAPER_CONFIG),
"preload = /home/u/h.png\nwallpaper = eDP-1, /home/u/h2.png\n",
)
.with_file(config(SWAY_CONFIG), "output * bg /home/u/s.png fill\n")
.with_file(
config(XFCE_DESKTOP_XML),
"<property name=\"last-image\" value=\"/home/u/x.png\"/>\n",
),
FakeIo::with_image,
);
let probe = detect_with(&io, &env(), Platform::Linux);
let seen: Vec<(SourceKind, String)> = probe
.iter()
.map(|source| (source.kind, source.path.display().to_string()))
.collect();
assert_eq!(
seen,
[
(SourceKind::Gnome, "/home/u/a.png"),
(SourceKind::Gnome, "/home/u/b.png"),
(SourceKind::Kde, "/home/u/k.png"),
(SourceKind::WlrootsCompositor, "/home/u/h.png"),
(SourceKind::WlrootsCompositor, "/home/u/h2.png"),
(SourceKind::WlrootsCompositor, "/home/u/s.png"),
(SourceKind::Xfce, "/home/u/x.png"),
]
.map(|(kind, path)| (kind, path.to_owned()))
);
}
#[test]
fn failing_step_does_not_stop_later_steps() {
// gsettings 说「没设壁纸」,KDE 指向一个不存在的文件,XFCE 才是真的。
let io = FakeIo::new()
.with_command(&gsettings("picture-uri"), "'none'\n")
.with_file(config(KDE_CONFIG), "Image=file:///home/u/gone.png\n")
.with_file(
config(XFCE_DESKTOP_XML),
"<property name=\"image-path\" value=\"/home/u/x.png\"/>\n",
)
.with_image("/home/u/x.png");
let probe = detect_with(&io, &env(), Platform::Linux);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
assert_eq!(probe.best().expect("有候选").kind, SourceKind::Xfce);
assert!(
probe.notes().iter().any(|note| note.contains("gone.png")),
"落选原因要留:{:?}",
probe.notes()
);
}
#[test]
fn everything_missing_returns_empty_with_reasons() {
let probe = detect_with(&FakeIo::new(), &env(), Platform::Linux);
assert!(probe.is_empty(), "全部失败要返回空列表");
let notes = probe.notes().join("\n");
assert!(notes.contains("gsettings"), "{notes}");
assert!(notes.contains("xfce4-desktop.xml"), "{notes}");
assert!(notes.contains("hyprpaper.conf"), "{notes}");
}
#[test]
fn gsettings_output_parsing() {
assert_eq!(
parse_gsettings_value("'file:///home/u/a.png'\n"),
Some("file:///home/u/a.png".to_owned())
);
assert_eq!(parse_gsettings_value("'none'\n"), None);
assert_eq!(parse_gsettings_value("''"), None);
assert_eq!(parse_gsettings_value(""), None);
}
#[test]
fn config_line_parsing() {
assert_eq!(
parse_kde_images("Image=file:///home/u/a.png\nImage=relative/b.png\n"),
vec!["file:///home/u/a.png".to_owned()]
);
assert_eq!(
parse_hyprpaper_wallpapers(
"# 注释行\npreload = /home/u/a.png\nwallpaper = eDP-1, /home/u/b.png\nwallpaper = ,/home/u/c.png\nsplash = false\n"
),
vec![
"/home/u/a.png".to_owned(),
"/home/u/b.png".to_owned(),
"/home/u/c.png".to_owned(),
]
);
assert_eq!(
parse_sway_backgrounds(
"output * bg /home/u/a.png fill\noutput eDP-1 bg \"/home/u/b c.png\" stretch\n"
),
vec!["/home/u/a.png".to_owned(), "/home/u/b c.png".to_owned()]
);
assert_eq!(
parse_xfce_last_image(
"<property name=\"last-image\" value=\"/home/u/a.png\"/>\n<property name=\"image-style\" value=\"3\"/>\n"
),
vec!["/home/u/a.png".to_owned()]
);
}
}
+304
View File
@@ -0,0 +1,304 @@
//! macOS 壁纸路径探测:`com.apple.wallpaper` 的 `Index.plist` → 老式 `com.apple.desktop` 域。
//!
//! 两条通道:
//!
//! 1. `~/Library/Application Support/com.apple.wallpaper/Store/Index.plist`——Big Sur 之后
//! 壁纸设置的真实落点,**二进制 plist(`bplist00`)**,里面记着
//! `file:///…/xxx.heic` 与裸绝对路径;
//! 2. `defaults read com.apple.desktop Background`——Catalina 及更早的每屏壁纸配置
//! (`ImageFilePath = "/Library/Desktop Pictures/…"`),新系统上该域通常已不存在。
//!
//! # 未在真机验证
//!
//! 本机(Windows)与 CI(Linux)都没有 macOS 通道:**下列代码没有在 macOS 真机上跑过**。
//! 因此这里刻意不引入 plist 解析依赖(`plist` crate 会为「读几个路径串」多背一个依赖),
//! 改成手写的字符串扫描:plist 里的路径串本身是可读 ASCII,按「可打印 ASCII 连续段」切出来
//! 再按「行首/分隔符后的 `/`」认路径开头即可,二进制 plist 的二进制部分天然成为分隔符。
//! 扫出的碎片串由 [`super::Collect`] 的图片头校验兜掉,不会喂给取色器。
//! 真机行为待后续 mission 在 macOS 上补充验证(`Index.plist` 的真实结构、`.heic` 壁纸路径)。
use super::{Collect, SourceKind};
/// Big Sur 之后的壁纸设置存储(相对用户主目录)。
pub(super) const STORE_INDEX: &str =
"Library/Application Support/com.apple.wallpaper/Store/Index.plist";
/// 老式壁纸配置域。
pub(super) const DESKTOP_DOMAIN: &str = "com.apple.desktop";
/// 一次扫描最多收多少个候选串(防止异常文件把内存打满;正常情况下只有个位数)。
const MAX_SCANNED: usize = 64;
/// 两步依次探测。
pub(super) fn collect(collect: &mut Collect<'_>) {
store_index(collect);
desktop_defaults(collect);
}
/// 第一步:壁纸 Store 的 `Index.plist`。
fn store_index(collect: &mut Collect<'_>) {
let path = collect.roots().home.join(STORE_INDEX);
let Some(text) = collect.io().read_text(&path) else {
collect.note(
SourceKind::MacOsStore.as_str(),
format!("读不到 {}(Catalina 及更早没有这个文件)", path.display()),
);
return;
};
let values = scan_string_paths(&text);
if values.is_empty() {
collect.note(
SourceKind::MacOsStore.as_str(),
format!("{} 里没有路径串", path.display()),
);
return;
}
let evidence = "Library/Application Support/com.apple.wallpaper/Store/Index.plist 的路径串";
for value in values {
collect.add(SourceKind::MacOsStore, evidence, &value);
}
}
/// 第二步:`defaults read com.apple.desktop Background`(老系统)。
fn desktop_defaults(collect: &mut Collect<'_>) {
let evidence = format!("defaults read {DESKTOP_DOMAIN} Background");
match collect
.io()
.run("defaults", &["read", DESKTOP_DOMAIN, "Background"])
{
Some(text) => {
let values = scan_defaults_background(&text);
if values.is_empty() {
collect.note(
SourceKind::MacOsDesktopDefaults.as_str(),
format!("{evidence} 里没有 ImageFilePath"),
);
return;
}
for value in values {
collect.add(SourceKind::MacOsDesktopDefaults, &evidence, &value);
}
}
None => collect.note(
SourceKind::MacOsDesktopDefaults.as_str(),
format!("{evidence} 失败(命令缺失 / 非零退出 / 该域不存在)"),
),
}
}
/// 从 plist 原文里扫出候选串:`file://` URL 与裸绝对路径。
///
/// 输入既可能是 XML plist(`<string>file:///…</string>`)也可能是二进制 plist 的 lossy 文本
/// (可读 ASCII 串之间夹着不可打印字节)。因此先按「可打印 ASCII 连续段」切,再在每段里认:
///
/// - 任意位置的 `file://` 开头;
/// - 「段首 / 分隔符之后 / `~` 之后」的 `/` 开头(路径中间的 `/` 不算新起点,避免同一路径
/// 被切成好几段)。
#[must_use]
pub(super) fn scan_string_paths(text: &str) -> Vec<String> {
let mut found = Vec::new();
for run in printable_runs(text) {
let mut cursor = 0;
while let Some(offset) = run[cursor..].find("file://") {
let start = cursor + offset;
push_candidate(&mut found, &run[start..]);
cursor = start + "file://".len();
}
for (position, _) in run.match_indices('/') {
let Some(start) = path_start(run, position) else {
continue;
};
push_candidate(&mut found, &run[start..]);
}
if found.len() >= MAX_SCANNED {
break;
}
}
found.truncate(MAX_SCANNED);
found
}
/// 解析 `defaults read com.apple.desktop Background` 的文本:只认 `ImageFilePath` / `NewPath`。
#[must_use]
pub(super) fn scan_defaults_background(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
let key = key.trim().trim_matches('"');
if !key.eq_ignore_ascii_case("ImageFilePath") && !key.eq_ignore_ascii_case("NewPath") {
return None;
}
let value = value.trim().trim_end_matches(';').trim().trim_matches('"');
(!value.is_empty()).then(|| value.to_owned())
})
.collect()
}
/// 可打印 ASCII 连续段(二进制 plist 的二进制字节在这里变成分隔符)。
fn printable_runs(text: &str) -> Vec<&str> {
text.split(|ch: char| !(ch.is_ascii_graphic() || ch == ' '))
.map(str::trim)
.filter(|run| !run.is_empty())
.collect()
}
/// 判断 `run[position]` 的 `/` 是不是一个路径起点;是则返回该路径在 `run` 里的起点。
fn path_start(run: &str, position: usize) -> Option<usize> {
let next = run[position + 1..].chars().next()?;
if !(next.is_ascii_alphanumeric() || next == '_') {
return None;
}
match run[..position].chars().next_back() {
// 段首:路径直接跟在二进制分隔符后面。
None => Some(position),
// `~/Library/...`:连 `~` 一起收,交给路径归一化展开。
Some('~') => Some(position - 1),
// 闭合标签 `</string>` 里的那个斜杠不是路径。
Some('<') => None,
// 路径中间的 `/`(前面还是路径字符)不当新起点。
Some(prev) if is_path_char(prev) => None,
Some(_) => Some(position),
}
}
fn is_path_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '%' | '+' | '@' | '~' | ' ')
}
/// plist/XML 序列化里的终止字符(`<string>` 的尖括号、引号、实体)切掉。
fn push_candidate(found: &mut Vec<String>, raw: &str) {
let end = raw.find(['<', '>', '"', '&', '\'']).unwrap_or(raw.len());
let candidate = raw[..end].trim_end();
if !candidate.is_empty() && !found.iter().any(|seen| seen.as_str() == candidate) {
found.push(candidate.to_owned());
}
}
#[cfg(test)]
mod tests {
use super::super::io::fake::FakeIo;
use super::super::{detect_with, SourceKind};
use super::*;
use crate::platform::paths::{MapEnv, Platform};
const STORE_PATH: &str =
"/Users/me/Library/Application Support/com.apple.wallpaper/Store/Index.plist";
const DEFAULTS_KEY: &str = "defaults read com.apple.desktop Background";
fn env() -> MapEnv {
MapEnv::new().with("HOME", "/Users/me")
}
fn xml_plist(body: &str) -> String {
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n{body}\n</plist>\n")
}
#[test]
fn store_index_hit_decodes_file_url() {
let io = FakeIo::new()
.with_file(
STORE_PATH,
&xml_plist("<string>file:///Users/me/Pictures/my%20wall.heic</string>"),
)
.with_image("/Users/me/Pictures/my wall.heic");
let probe = detect_with(&io, &env(), Platform::MacOs);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
let best = probe.best().expect("有候选");
assert_eq!(best.kind, SourceKind::MacOsStore);
assert_eq!(
best.path,
std::path::PathBuf::from("/Users/me/Pictures/my wall.heic")
);
}
#[test]
fn multiple_candidates_from_both_channels_keep_order() {
let io = FakeIo::new()
.with_file(
STORE_PATH,
&xml_plist(
"<array>\n\
<string>file:///System/Library/Desktop Pictures/a.heic</string>\n\
<string>/Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic</string>\n\
</array>",
),
)
.with_image("/System/Library/Desktop Pictures/a.heic")
.with_image(
"/Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic",
)
.with_image("/Library/Desktop Pictures/legacy.jpg")
.with_command(
DEFAULTS_KEY,
"{\n \"Display Identifier\" = \"Main\";\n \
ImageFilePath = \"/Library/Desktop Pictures/legacy.jpg\";\n}\n",
);
let probe = detect_with(&io, &env(), Platform::MacOs);
let kinds: Vec<SourceKind> = probe.iter().map(|source| source.kind).collect();
assert_eq!(
kinds,
vec![
SourceKind::MacOsStore,
SourceKind::MacOsStore,
SourceKind::MacOsDesktopDefaults,
]
);
assert_eq!(
probe.best().expect("有候选").path,
std::path::PathBuf::from("/System/Library/Desktop Pictures/a.heic")
);
}
#[test]
fn everything_missing_returns_empty_with_reasons() {
let probe = detect_with(&FakeIo::new(), &env(), Platform::MacOs);
assert!(probe.is_empty(), "全部失败要返回空列表");
let notes = probe.notes().join("\n");
assert!(notes.contains("Index.plist"), "{notes}");
assert!(notes.contains("defaults"), "{notes}");
}
#[test]
fn binary_plist_strings_are_scanned_between_binary_bytes() {
// bplist00 的形态:控制字符/长度字节把可读 ASCII 串隔开。
let raw = "\u{0}\u{1}\u{2}/Users/me/Pictures/a.heic\u{0}\u{3}file:///Users/me/b.jpg\u{0}";
let found = scan_string_paths(raw);
assert_eq!(
found,
vec![
"/Users/me/Pictures/a.heic".to_owned(),
"file:///Users/me/b.jpg".to_owned(),
]
);
}
#[test]
fn xml_tags_are_not_part_of_the_candidate() {
let found = scan_string_paths(&xml_plist(
"<key>Content</key>\n<string>/Users/me/a.png</string>",
));
assert_eq!(found, vec!["/Users/me/a.png".to_owned()]);
}
#[test]
fn defaults_output_parsing() {
assert_eq!(
scan_defaults_background(
"{\n \"Display Identifier\" = \"Main\";\n \
ImageFilePath = \"/Library/Desktop Pictures/a.jpg\";\n \
NewPath = \"/Users/me/b.png\";\n}\n"
),
vec![
"/Library/Desktop Pictures/a.jpg".to_owned(),
"/Users/me/b.png".to_owned(),
]
);
assert!(scan_defaults_background("{\n BackgroundColor = \"0 0 0\";\n}\n").is_empty());
}
#[test]
fn tilde_paths_keep_their_prefix() {
let found = scan_string_paths("<string>~/Library/Desktop Pictures/a.heic</string>");
assert_eq!(found, vec!["~/Library/Desktop Pictures/a.heic".to_owned()]);
}
}
+320
View File
@@ -0,0 +1,320 @@
//! 路径归一化与图片头嗅探:把各平台读出的「原始串」变成可用的绝对路径候选。
//!
//! 三平台读出来的东西形状都不一样(gsettings 的 `'file:///x'`、KDE 的 `file:///x`、
//! Windows `.theme` 的 `%SystemRoot%\x`、plist 里裸的 `/x`),全部在这里收口,
//! 上层子模块只管「从哪读」。
use std::path::PathBuf;
use super::io::HostRoots;
/// 图片文件头识别(不用解码库,只看魔数与文本 SVG 头)。
///
/// 认得的:PNG / JPEG / GIF / BMP / WebP / HEIF-AVIF / TIFF,外加头部出现 `<svg` 的文本。
/// 认不出的一律不收——壁纸探测宁可少一个候选,也不要把随便一个文件喂给取色器。
#[must_use]
pub(super) fn looks_like_image(head: &[u8]) -> bool {
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n";
if head.starts_with(PNG) {
return true;
}
if head.starts_with(&[0xFF, 0xD8, 0xFF]) {
return true; // JPEG
}
if head.starts_with(b"GIF87a") || head.starts_with(b"GIF89a") {
return true;
}
if head.len() >= 14 && head.starts_with(b"BM") {
return true; // BMP
}
if head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"WEBP" {
return true;
}
if head.len() >= 12 && &head[4..8] == b"ftyp" {
// ISO-BMFF 家族:HEIC / HEIF 是 macOS 的默认壁纸容器(`.heic`),AVIF 是新格式。
let brand = &head[8..12];
return matches!(
brand,
b"avif" | b"avis" | b"heic" | b"heix" | b"mif1" | b"msf1"
);
}
if head.starts_with(b"II*\0") || head.starts_with(b"MM\0*") {
return true; // TIFF
}
contains_ignore_ascii_case(head, b"<svg")
}
/// 把一条原始串归一化成绝对路径;不像壁纸路径的返回 `None`(具体原因由调用方记)。
///
/// 处理顺序:去引号 → 认 `file://` URI(含百分号解码与 `/C:/` 盘符还原)→ 展开
/// `%USERPROFILE%` 之类的 Windows 变量 → 展开 `~/` → 要求绝对路径。
#[must_use]
pub(super) fn normalize_path(raw: &str, roots: &HostRoots) -> Option<PathBuf> {
let unquoted = unquote(raw).trim();
if unquoted.is_empty() {
return None;
}
// GNOME 的「无壁纸」与 macOS `defaults` 的「未设置」占位值。
if unquoted.eq_ignore_ascii_case("none") || unquoted.eq_ignore_ascii_case("default") {
return None;
}
let text = match strip_prefix_ignore_ascii_case(unquoted, "file://") {
Some(rest) => strip_uri_drive_prefix(&percent_decode(rest)),
None => expand_vars(unquoted, roots),
};
let text = text.trim();
if let Some(rest) = text.strip_prefix("~/") {
return Some(roots.home.join(rest));
}
if !looks_absolute(text) {
return None;
}
Some(PathBuf::from(text))
}
/// 去掉成对的单/双引号(`gsettings get` 的输出是 `'file:///x'`)。
fn unquote(raw: &str) -> &str {
let trimmed = raw.trim();
let bytes = trimmed.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
if (first == b'\'' || first == b'"') && bytes[bytes.len() - 1] == first {
return &trimmed[1..trimmed.len() - 1];
}
}
trimmed
}
fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
let head = text.get(..prefix.len())?;
head.eq_ignore_ascii_case(prefix)
.then(|| &text[prefix.len()..])
}
/// `file://` URI 的路径部分:`/C:/Users/me/a.jpg` 在 Windows 上是 `C:/Users/me/a.jpg`。
///
/// 非 Windows 上这条分支不会命中(Unix 路径不会以 `/C:` 开头),统一处理不影响正确性。
fn strip_uri_drive_prefix(text: &str) -> String {
let bytes = text.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
text[1..].to_owned()
} else {
text.to_owned()
}
}
/// 百分号解码(GNOME 会把空格编码成 `%20`;非法转义原样保留)。
fn percent_decode(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%'
&& index + 2 < bytes.len()
&& bytes[index + 1].is_ascii_hexdigit()
&& bytes[index + 2].is_ascii_hexdigit()
{
let hex = &text[index + 1..index + 3];
if let Ok(byte) = u8::from_str_radix(hex, 16) {
out.push(byte);
index += 3;
continue;
}
}
out.push(bytes[index]);
index += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// 展开 Windows 路径里的 `%VAR%`(`.theme` 的 `Wallpaper=%SystemRoot%\web\...`)。
///
/// 认不出的变量原样保留,让后面的图片校验把它判掉——这里不猜路径。
fn expand_vars(text: &str, roots: &HostRoots) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find('%') {
let Some(offset) = rest[start + 1..].find('%') else {
break;
};
let name = &rest[start + 1..start + 1 + offset];
let value = match name.to_ascii_lowercase().as_str() {
"userprofile" | "home" => Some(roots.home.to_string_lossy()),
"appdata" => Some(roots.app_data.to_string_lossy()),
// Win10+ 的「当前主题」`Custom.theme` 在 `%LOCALAPPDATA%` 一侧。
"localappdata" => Some(roots.local_app_data.to_string_lossy()),
"systemroot" | "windir" => roots
.system_root
.as_deref()
.map(std::path::Path::to_string_lossy),
_ => None,
};
match value {
Some(value) => {
out.push_str(&rest[..start]);
out.push_str(&value);
rest = &rest[start + offset + 2..];
}
None => {
out.push_str(&rest[..=start]);
rest = &rest[start + 1..];
}
}
}
out.push_str(rest);
out
}
/// 字符串层面判绝对路径:`/x`、`\\server\share`、`C:/x`、`C:\x`。
///
/// 不用 `Path::is_absolute` 是因为它按**编译目标**判,Windows 夹具在 Linux CI 上会被判成
/// 相对路径,夹具就不跨宿主了。
fn looks_absolute(text: &str) -> bool {
let bytes = text.as_bytes();
if text.starts_with('/') || text.starts_with('\\') {
return true;
}
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'/' | b'\\')
}
fn contains_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::paths::MapEnv;
fn roots() -> HostRoots {
let env = MapEnv::new()
.with("HOME", "/home/u")
.with("APPDATA", "C:/Users/u/AppData/Roaming")
.with("SYSTEMROOT", "C:/Windows");
HostRoots::from_env(&env).expect("HOME 已给")
}
#[test]
fn image_sniff_accepts_common_wallpaper_containers() {
assert!(looks_like_image(b"\x89PNG\r\n\x1a\n\x00\x00"));
assert!(looks_like_image(b"\xFF\xD8\xFF\xE0\x00\x10JFIF"));
assert!(looks_like_image(b"GIF89a---"));
assert!(looks_like_image(
b"BM\x36\x00\x00\x00\x00\x00\x00\x00\x36\x00\x00\x00"
));
assert!(looks_like_image(b"RIFF\x24\x00\x00\x00WEBPVP8 "));
assert!(looks_like_image(
b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heicmif1"
));
assert!(looks_like_image(b"II*\x00\x08\x00\x00\x00"));
assert!(looks_like_image(
b"<?xml version=\"1.0\"?><svg xmlns=\"...\""
));
}
#[test]
fn image_sniff_rejects_non_images_and_short_heads() {
assert!(!looks_like_image(b""));
assert!(!looks_like_image(b"BM"));
assert!(!looks_like_image(
b"[Control Panel\\Desktop]\r\nWallpaper=x\r\n"
));
assert!(!looks_like_image(b"RIFF\x24\x00\x00\x00AVI LIST"));
assert!(!looks_like_image(b"\x00\x00\x00\x18ftypmp42"));
}
#[test]
fn normalize_handles_file_uris_quotes_and_percent_encoding() {
let roots = roots();
assert_eq!(
normalize_path("'file:///home/u/Pictures/my%20wall.png'", &roots),
Some(PathBuf::from("/home/u/Pictures/my wall.png"))
);
// Windows 的 URI 会多一个前导斜杠,盘符要还原。
assert_eq!(
normalize_path("file:///C:/Users/u/a.jpg", &roots),
Some(PathBuf::from("C:/Users/u/a.jpg"))
);
assert_eq!(
normalize_path("\"C:\\Users\\u\\a.jpg\"", &roots),
Some(PathBuf::from("C:\\Users\\u\\a.jpg"))
);
// 非法百分号转义原样保留,不 panic(多字节字符也要安全)。
assert_eq!(
normalize_path("file:///home/u/100%zz%日.png", &roots),
Some(PathBuf::from("/home/u/100%zz%日.png"))
);
}
#[test]
fn normalize_expands_windows_vars_and_home() {
let roots = roots();
assert_eq!(
normalize_path(r"%SystemRoot%\web\wallpaper\a.jpg", &roots),
Some(PathBuf::from("C:/Windows\\web\\wallpaper\\a.jpg"))
);
assert_eq!(
normalize_path(
"%APPDATA%\\Microsoft\\Windows\\Themes\\TranscodedWallpaper",
&roots
),
Some(PathBuf::from(
"C:/Users/u/AppData/Roaming\\Microsoft\\Windows\\Themes\\TranscodedWallpaper"
))
);
// Win10+ 的当前主题在 %LOCALAPPDATA%,展开失败就会白丢最可靠的候选。
assert_eq!(
normalize_path(
"%LOCALAPPDATA%\\Microsoft\\Windows\\Themes\\Custom.theme",
&roots
),
Some(PathBuf::from(
"/home/u/AppData/Local\\Microsoft\\Windows\\Themes\\Custom.theme"
))
);
assert_eq!(
normalize_path("~/Library/Desktop Pictures/a.heic", &roots),
Some(PathBuf::from("/home/u/Library/Desktop Pictures/a.heic"))
);
// 认不出的变量不猜:整串保留,随后被「必须绝对路径」这条校验判掉。
assert_eq!(normalize_path("%NOPE%\\a.jpg", &roots), None);
}
#[test]
fn normalize_rejects_placeholders_relative_and_empty() {
let roots = roots();
for raw in [
"",
" ",
"none",
"'none'",
"default",
"relative/a.png",
"./a.png",
] {
assert_eq!(
normalize_path(raw, &roots),
None,
"{raw:?} 不该被当成壁纸路径"
);
}
}
#[test]
fn absolute_detection_is_host_independent() {
assert!(looks_absolute("/home/u/a.png"));
assert!(looks_absolute(r"\\server\share\a.png"));
assert!(looks_absolute("C:/Users/u/a.png"));
assert!(looks_absolute(r"C:\Users\u\a.png"));
assert!(!looks_absolute("a.png"));
assert!(!looks_absolute("C:"));
}
}
@@ -0,0 +1,371 @@
//! Windows 壁纸路径探测:四步,可信度从高到低。
//!
//! Win10+ 的真机事实(Windows 11 24H2 实测):`%APPDATA%\Microsoft\Windows\Themes\` 里
//! **通常只有** `TranscodedWallpaper`,一个 `.theme` 都没有;当前生效的主题记在
//! `HKCU\Software\Microsoft\Windows\CurrentVersion\Themes` 的 `CurrentTheme` 值里,指向
//! `%LOCALAPPDATA%\Microsoft\Windows\Themes\Custom.theme`。所以「扫 `%APPDATA%` 下的
//! `*.theme`」只能当最后一档兜底:那里的主题包是用户另存的,**可能从未启用**,拿它取色会
//! 取到与当前桌面无关的图(宁可少给一个候选,也不给错路径)。
//!
//! 1. `%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`——系统**当前**壁纸转码后的
//! 副本,没有扩展名(部分版本还有 `TranscodedWallpaper.jpg` 别名),所以「像不像图片」
//! 只看文件头魔数,不看后缀;
//! 2. `CurrentTheme` 指向的 `.theme`——当前生效主题里的 `Wallpaper=` 行(Win10+ 在
//! `%LOCALAPPDATA%` 一侧,支持 `%LOCALAPPDATA%` / `%SystemRoot%` 之类变量展开);
//! 3. `HKCU\Control Panel\Desktop` 的 `WallPaper` 值——注册表权威来源;
//! 4. `%APPDATA%\...\Themes\*.theme`——用户另存的主题包,`evidence` 里如实标「可能未启用」。
//!
//! 第 2、3 步走 `reg.exe` 子进程,不用 win32 API(core 是 `#![forbid(unsafe_code)]`,
//! 见 [`super`] 模块文档);命令缺失 / 非零退出 / 空输出都只记一条原因,探测继续。
//!
//! 本文件不带 `cfg`:Linux CI 上同样要能跑夹具用例,平台分流只在
//! [`super::detect_with`] 一处。
use std::path::{Path, PathBuf};
use self::parse::{parse_reg_sz_value, parse_theme_wallpaper};
use super::io::HostRoots;
use super::parse::normalize_path;
use super::{Collect, SourceKind};
mod parse;
/// `reg query` 用的桌面键(`HKCU\Control Panel\Desktop`,壁纸值名 `WallPaper`)。
pub(super) const DESKTOP_KEY: &str = r"HKCU\Control Panel\Desktop";
/// `reg query` 用的主题键(Win10+ 当前主题记录在这里)。
pub(super) const THEMES_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes";
/// 壁纸值名(注册表里是 `WallPaper`,`.theme` 里是 `Wallpaper=`,比对时都忽略大小写)。
pub(super) const WALLPAPER_VALUE: &str = "WallPaper";
/// 当前主题文件名(值是指向 `.theme` 的绝对路径)。
pub(super) const CURRENT_THEME_VALUE: &str = "CurrentTheme";
/// `%APPDATA%` 下的主题目录(分段拼,避免 Windows 上出现混用的分隔符)。
const THEMES_PARTS: &[&str] = &["Microsoft", "Windows", "Themes"];
/// 转码副本的两个已知文件名(无扩展名的是主力)。
const TRANSCODED_NAMES: &[&str] = &["TranscodedWallpaper", "TranscodedWallpaper.jpg"];
/// 主题文件后缀。
const THEME_EXTENSION: &str = "theme";
/// 四步依次探测。
pub(super) fn collect(collect: &mut Collect<'_>) {
transcoded(collect);
current_theme(collect);
registry(collect);
saved_theme_packages(collect);
}
fn themes_dir(roots: &HostRoots) -> PathBuf {
let mut dir = roots.app_data.clone();
for part in THEMES_PARTS {
dir.push(part);
}
dir
}
/// 第一步:系统转码后的当前壁纸副本(第一个名字命中后就不再试别名)。
fn transcoded(collect: &mut Collect<'_>) {
let dir = themes_dir(collect.roots());
for name in TRANSCODED_NAMES {
let path = dir.join(name);
let evidence = format!("%APPDATA%\\Microsoft\\Windows\\Themes\\{name}");
if collect.add_path(SourceKind::WindowsTranscoded, &evidence, path) {
return;
}
}
}
/// 第二步:当前生效主题(`CurrentTheme` 指向的 `.theme`)里的 `Wallpaper=`。
fn current_theme(collect: &mut Collect<'_>) {
let Some(raw) = query_reg(
collect,
SourceKind::WindowsCurrentTheme,
THEMES_KEY,
CURRENT_THEME_VALUE,
) else {
return;
};
let Some(theme) = normalize_path(&raw, collect.roots()) else {
collect.note(
SourceKind::WindowsCurrentTheme.as_str(),
format!("{CURRENT_THEME_VALUE} 不是可用的绝对路径({raw})"),
);
return;
};
let label = format!(
"reg query {THEMES_KEY} /v {CURRENT_THEME_VALUE} → {}",
theme.display()
);
collect_theme_file(collect, SourceKind::WindowsCurrentTheme, &theme, &label);
}
/// 第三步:注册表里的权威壁纸值。
fn registry(collect: &mut Collect<'_>) {
let Some(value) = query_reg(
collect,
SourceKind::WindowsRegistry,
DESKTOP_KEY,
WALLPAPER_VALUE,
) else {
return;
};
let label = format!("reg query {DESKTOP_KEY} /v {WALLPAPER_VALUE}");
collect.add(SourceKind::WindowsRegistry, &label, &value);
}
/// 第四步(兜底):`%APPDATA%\...\Themes\*.theme`——用户另存的主题包,**可能未启用**。
fn saved_theme_packages(collect: &mut Collect<'_>) {
let dir = themes_dir(collect.roots());
let mut themes: Vec<PathBuf> = collect
.io()
.list_dir(&dir)
.into_iter()
.filter(|path| {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case(THEME_EXTENSION))
})
.collect();
themes.sort();
if themes.is_empty() {
collect.note(
SourceKind::WindowsThemeFile.as_str(),
format!("{} 下没有 .theme 文件", dir.display()),
);
return;
}
for theme in themes {
let label = format!("{}(用户另存的主题包,可能未启用)", theme.display());
collect_theme_file(collect, SourceKind::WindowsThemeFile, &theme, &label);
}
}
/// 读一个 `.theme` 文件,把里面的 `Wallpaper=` 收成候选;读不到 / 没值只记原因。
fn collect_theme_file(collect: &mut Collect<'_>, kind: SourceKind, file: &Path, label: &str) {
let Some(text) = collect.io().read_text(file) else {
collect.note(kind.as_str(), format!("读不到 {}", file.display()));
return;
};
let values = parse_theme_wallpaper(&text);
if values.is_empty() {
collect.note(
kind.as_str(),
format!("{} 里没有 Wallpaper= 行", file.display()),
);
return;
}
let evidence = format!("{label} 的 Wallpaper=");
for value in values {
collect.add(kind, &evidence, &value);
}
}
/// 跑一次 `reg query <键> /v <值名>` 并取出值;失败或缺值都只记原因、返回 `None`。
fn query_reg(
collect: &mut Collect<'_>,
kind: SourceKind,
key: &str,
value_name: &str,
) -> Option<String> {
let query = format!("reg query {key} /v {value_name}");
match collect
.io()
.run("reg.exe", &["query", key, "/v", value_name])
{
Some(text) => match parse_reg_sz_value(&text, value_name) {
Some(value) => Some(value),
None => {
collect.note(
kind.as_str(),
format!("{query} 输出里没有 {value_name} 值(可能被策略禁用或壁纸为纯色)"),
);
None
}
},
None => {
collect.note(
kind.as_str(),
format!("{query} 失败(命令缺失 / 非零退出 / 空输出)"),
);
None
}
}
}
#[cfg(test)]
mod tests {
use super::super::io::fake::FakeIo;
use super::super::{detect_with, SourceKind};
use super::*;
use crate::platform::paths::{MapEnv, Platform};
const APP_DATA: &str = "C:/Users/u/AppData/Roaming";
const LOCAL_APP_DATA: &str = "C:/Users/u/AppData/Local";
const DESKTOP_QUERY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper";
const THEMES_QUERY: &str =
"reg.exe query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes /v CurrentTheme";
const CUSTOM_THEME: &str = r"C:\Users\u\AppData\Local\Microsoft\Windows\Themes\Custom.theme";
fn env() -> MapEnv {
MapEnv::new()
.with("HOME", "C:/Users/u")
.with("APPDATA", APP_DATA)
.with("LOCALAPPDATA", LOCAL_APP_DATA)
.with("SYSTEMROOT", "C:/Windows")
}
fn roots() -> HostRoots {
HostRoots::from_env(&env()).expect("HOME 已给")
}
/// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上
/// (另有一条字面量断言盯着这个拼法本身,见 `transcoded_wallpaper_hit_comes_first`)。
fn themes() -> PathBuf {
themes_dir(&roots())
}
#[test]
fn transcoded_wallpaper_hit_comes_first() {
let io = FakeIo::new().with_image(themes().join("TranscodedWallpaper"));
let probe = detect_with(&io, &env(), Platform::Windows);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
let best = probe.best().expect("有候选");
assert_eq!(best.kind, SourceKind::WindowsTranscoded);
assert_eq!(best.path, themes().join("TranscodedWallpaper"));
// 字面量:夹具路径是拿生产代码的构造函数造出来的,这条断言防止两边一起拼错。
assert_eq!(
best.path,
PathBuf::from(
"C:/Users/u/AppData/Roaming/Microsoft/Windows/Themes/TranscodedWallpaper"
)
);
assert!(best.evidence.contains("TranscodedWallpaper"));
}
#[test]
fn current_theme_is_read_from_local_app_data() {
// Win10+ 的真机形态:%APPDATA% 下没有 .theme,当前主题在 %LOCALAPPDATA%。
let io = FakeIo::new()
.with_command(
THEMES_QUERY,
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
)
.with_file(
CUSTOM_THEME,
"[Control Panel\\Desktop]\r\nWallpaper=%SystemRoot%\\web\\wallpaper\\Windows\\img0.jpg\r\n",
)
.with_image("C:/Windows\\web\\wallpaper\\Windows\\img0.jpg");
let probe = detect_with(&io, &env(), Platform::Windows);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
let best = probe.best().expect("有候选");
assert_eq!(best.kind, SourceKind::WindowsCurrentTheme);
assert_eq!(
best.path,
PathBuf::from("C:/Windows\\web\\wallpaper\\Windows\\img0.jpg")
);
assert!(best.evidence.contains("CurrentTheme"), "{}", best.evidence);
assert!(best.evidence.contains("Windows\\Themes\\Custom.theme"));
}
#[test]
fn current_theme_expands_local_app_data_env_var() {
let io = FakeIo::new()
.with_command(
THEMES_QUERY,
" CurrentTheme REG_SZ %LOCALAPPDATA%\\Microsoft\\Windows\\Themes\\Custom.theme\r\n",
)
.with_file(
CUSTOM_THEME,
"Wallpaper=C:\\Users\\u\\Pictures\\a.jpg\r\n",
)
.with_image(r"C:\Users\u\Pictures\a.jpg");
let probe = detect_with(&io, &env(), Platform::Windows);
assert_eq!(probe.len(), 1, "{:?}", probe.notes());
assert_eq!(
probe.best().expect("有候选").kind,
SourceKind::WindowsCurrentTheme
);
}
#[test]
fn authority_order_puts_saved_packages_last() {
// 四步全在场,路径互不相同:顺序必须是 转码副本 > 当前主题 > 注册表 > 另存包。
let package = themes().join("a.theme");
let io = FakeIo::new()
.with_image(themes().join("TranscodedWallpaper"))
.with_command(
THEMES_QUERY,
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
)
.with_file(
CUSTOM_THEME,
"Wallpaper=C:\\Users\\u\\Pictures\\one.jpg\r\n",
)
.with_command(
DESKTOP_QUERY,
" WallPaper REG_SZ C:\\Users\\u\\Pictures\\two.jpg\r\n",
)
.with_dir(themes(), &["a.theme", "b.theme", "notes.txt"])
.with_file(
package.clone(),
"Wallpaper=%SystemRoot%\\web\\wallpaper\\three.jpg\r\n",
)
.with_image(r"C:\Users\u\Pictures\one.jpg")
.with_image(r"C:\Users\u\Pictures\two.jpg")
.with_image("C:/Windows\\web\\wallpaper\\three.jpg");
let probe = detect_with(&io, &env(), Platform::Windows);
let seen: Vec<(SourceKind, String)> = probe
.iter()
.map(|source| (source.kind, source.path.display().to_string()))
.collect();
assert_eq!(seen.len(), 4, "{:?}", probe.notes());
assert_eq!(seen[0].0, SourceKind::WindowsTranscoded);
assert_eq!(seen[1].0, SourceKind::WindowsCurrentTheme);
assert_eq!(seen[2].0, SourceKind::WindowsRegistry);
assert_eq!(seen[3].0, SourceKind::WindowsThemeFile);
// 兜底档在 evidence 里如实标明「可能未启用」,调用方据此可降权。
assert!(
probe.sources()[3].evidence.contains("可能未启用"),
"{}",
probe.sources()[3].evidence
);
}
#[test]
fn everything_missing_returns_empty_with_reasons() {
let probe = detect_with(&FakeIo::new(), &env(), Platform::Windows);
assert!(probe.is_empty(), "全部失败要返回空列表而不是错误");
let notes = probe.notes().join("\n");
assert!(notes.contains("reg query"), "注册表失败要留原因:{notes}");
assert!(
notes.contains("CurrentTheme"),
"当前主题失败要留原因:{notes}"
);
assert!(
notes.contains("TranscodedWallpaper"),
"转码副本缺失要留原因:{notes}"
);
}
#[test]
fn same_path_from_two_steps_is_deduped() {
let transcoded = themes().join("TranscodedWallpaper");
let registry_value = transcoded.display().to_string().to_ascii_uppercase();
let io = FakeIo::new()
.with_image(&transcoded)
// 注册表指向同一个文件,大小写还不同(Windows 路径大小写不敏感)。
.with_command(
DESKTOP_QUERY,
&format!(" WallPaper REG_SZ {registry_value}\r\n"),
);
let probe = detect_with(&io, &env(), Platform::Windows);
assert_eq!(probe.len(), 1, "{:?}", probe.sources());
assert_eq!(
probe.best().expect("有候选").kind,
SourceKind::WindowsTranscoded
);
}
}
@@ -0,0 +1,100 @@
//! Windows 侧的纯解析函数:`.theme` 的 `Wallpaper=` 行与 `reg query` 输出的取值。
//!
//! 单独一个文件只为守住「单文件 ≤400 行」(`guards.yml` 的 file-size 闸):这里没有任何
//! IO,全部是纯函数,跨平台编译,夹具用例覆盖在文件尾部。
/// 取 `.theme` 文本里所有 `Wallpaper=` 行的值(键名大小写不敏感,值去首尾空白)。
///
/// `.theme` 是 INI:`[Control Panel\Desktop]` 段下的 `Wallpaper=...`。`Wallpaper` 这个键名
/// 本身不随系统语言变(Windows 只本地化少数键),所以直接按 ASCII 比对。
pub(super) fn parse_theme_wallpaper(text: &str) -> Vec<String> {
text.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
if !key.trim().eq_ignore_ascii_case("wallpaper") {
return None;
}
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
.collect()
}
/// 取 `reg query <键> /v <值名>` 输出里某个值的原文。
///
/// 形如 ` WallPaper REG_SZ C:\Users\me\Pictures\a.jpg`;值里的空格要保留,所以
/// 「值名 + 类型」之后的部分整段取原文(不能 `split_whitespace` 再拼,会吃掉连续空格)。
/// 值名为 `WallPaper` 时是壁纸路径,`CurrentTheme` 时是 `.theme` 文件路径。
pub(super) fn parse_reg_sz_value(text: &str, value_name: &str) -> Option<String> {
text.lines().find_map(|line| {
let trimmed = line.trim_start();
let name_end = trimmed.find(char::is_whitespace)?;
if !trimmed[..name_end].eq_ignore_ascii_case(value_name) {
return None;
}
let after_name = trimmed[name_end..].trim_start();
let kind_end = after_name.find(char::is_whitespace)?;
let value = after_name[kind_end..].trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
#[cfg(test)]
mod tests {
use super::super::{CURRENT_THEME_VALUE, WALLPAPER_VALUE};
use super::*;
const CUSTOM_THEME: &str = r"C:\Users\u\AppData\Local\Microsoft\Windows\Themes\Custom.theme";
#[test]
fn theme_text_yields_only_wallpaper_values() {
let text = "[Control Panel\\Desktop]\r\n\
Wallpaper=C:\\Users\\u\\a.jpg\r\n\
wallpaper = %SystemRoot%\\web\\wallpaper\\b.jpg\r\n\
[Slideshow]\r\n\
ImagesRootPath=C:\\Users\\u\\Pictures\r\n\
Wallpaper=\r\n";
assert_eq!(
parse_theme_wallpaper(text),
vec![
r"C:\Users\u\a.jpg".to_owned(),
r"%SystemRoot%\web\wallpaper\b.jpg".to_owned(),
]
);
assert!(parse_theme_wallpaper("").is_empty());
}
#[test]
fn reg_output_parses_value_and_keeps_spaces() {
assert_eq!(
parse_reg_sz_value(
"HKEY_CURRENT_USER\\Control Panel\\Desktop\r\n \
WallPaper REG_SZ C:\\Users\\Jak e\\Desktop Pictures\\a.jpg\r\n",
WALLPAPER_VALUE
),
Some(r"C:\Users\Jak e\Desktop Pictures\a.jpg".to_owned())
);
// 纯色壁纸 = 空值。
assert_eq!(
parse_reg_sz_value(" WallPaper REG_SZ \r\n", WALLPAPER_VALUE),
None
);
assert_eq!(parse_reg_sz_value("", WALLPAPER_VALUE), None);
assert_eq!(
parse_reg_sz_value(" TileWallpaper REG_SZ 0\r\n", WALLPAPER_VALUE),
None
);
// 当前主题:值名换成 CurrentTheme,取到的是 `.theme` 的绝对路径。
assert_eq!(
parse_reg_sz_value(
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
CURRENT_THEME_VALUE
),
Some(CUSTOM_THEME.to_owned())
);
assert_eq!(
parse_reg_sz_value(" CurrentTheme REG_SZ \r\n", CURRENT_THEME_VALUE),
None
);
}
}