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 照旧。
This commit is contained in:
AskaEth committed 2026-11-01 21:17:55 +08:00
1 parent d3e9a62b21
commit 69d0025d09
5 files changed
+369 -165

No files matched your search

+12 -3
View File
@@ -52,7 +52,14 @@ const IMAGE_HEAD_LEN: usize = 512;
pub enum SourceKind { pub enum SourceKind {
/// Windows:`%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`(系统转码后的当前壁纸)。 /// Windows:`%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`(系统转码后的当前壁纸)。
WindowsTranscoded, 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=` 行。 /// Windows:`%APPDATA%\Microsoft\Windows\Themes\*.theme` 里的 `Wallpaper=` 行。
///
/// 用户另存的主题包(不一定被启用),只作最后一档兜底。
WindowsThemeFile, WindowsThemeFile,
/// Windows:注册表 `HKCU\Control Panel\Desktop` 的 `WallPaper` 值(`reg.exe` 子进程)。 /// Windows:注册表 `HKCU\Control Panel\Desktop` 的 `WallPaper` 值(`reg.exe` 子进程)。
WindowsRegistry, WindowsRegistry,
@@ -76,6 +83,7 @@ impl SourceKind {
pub const fn as_str(self) -> &'static str { pub const fn as_str(self) -> &'static str {
match self { match self {
Self::WindowsTranscoded => "windows-transcoded", Self::WindowsTranscoded => "windows-transcoded",
Self::WindowsCurrentTheme => "windows-current-theme",
Self::WindowsThemeFile => "windows-theme-file", Self::WindowsThemeFile => "windows-theme-file",
Self::WindowsRegistry => "windows-registry", Self::WindowsRegistry => "windows-registry",
Self::Gnome => "linux-gnome", Self::Gnome => "linux-gnome",
@@ -91,9 +99,10 @@ impl SourceKind {
#[must_use] #[must_use]
pub const fn platform(self) -> Platform { pub const fn platform(self) -> Platform {
match self { match self {
Self::WindowsTranscoded | Self::WindowsThemeFile | Self::WindowsRegistry => { Self::WindowsTranscoded
Platform::Windows | Self::WindowsCurrentTheme
} | Self::WindowsThemeFile
| Self::WindowsRegistry => Platform::Windows,
Self::MacOsStore | Self::MacOsDesktopDefaults => Platform::MacOs, Self::MacOsStore | Self::MacOsDesktopDefaults => Platform::MacOs,
Self::Gnome | Self::Kde | Self::WlrootsCompositor | Self::Xfce => Platform::Linux, Self::Gnome | Self::Kde | Self::WlrootsCompositor | Self::Xfce => Platform::Linux,
} }
+28 -1
View File
@@ -66,7 +66,10 @@ impl ProbeIo for HostIo {
.args(args) .args(args)
.output() .output()
.ok()?; .ok()?;
if output.stdout.is_empty() { // `Command::output` 对非零退出同样返回 `Ok`,所以退出码要单独判:三条命令
// (`reg.exe` / `gsettings` / `defaults`)出错时都走 stderr,只看 stdout 会把
// 「查不到」和「命令失败」混成一种,调用方的原因文案就名不副实了。
if !output.status.success() || output.stdout.is_empty() {
return None; return None;
} }
Some(String::from_utf8_lossy(&output.stdout).into_owned()) Some(String::from_utf8_lossy(&output.stdout).into_owned())
@@ -80,6 +83,9 @@ pub struct HostRoots {
pub home: PathBuf, pub home: PathBuf,
/// Windows `%APPDATA%`(退 `~/AppData/Roaming`);其它平台用不到,但仍填上以免分支取空。 /// Windows `%APPDATA%`(退 `~/AppData/Roaming`);其它平台用不到,但仍填上以免分支取空。
pub app_data: PathBuf, pub app_data: PathBuf,
/// Windows `%LOCALAPPDATA%`(退 `~/AppData/Local`):Win10+ 的「当前主题」
/// `Custom.theme` 落在这一侧。
pub local_app_data: PathBuf,
/// `$XDG_CONFIG_HOME`(仅接受绝对路径),退 `~/.config`。 /// `$XDG_CONFIG_HOME`(仅接受绝对路径),退 `~/.config`。
pub config_home: PathBuf, pub config_home: PathBuf,
/// Windows `%SystemRoot%`(`.theme` 里 `Wallpaper=%SystemRoot%\...` 展开用),无则不展开。 /// Windows `%SystemRoot%`(`.theme` 里 `Wallpaper=%SystemRoot%\...` 展开用),无则不展开。
@@ -101,6 +107,10 @@ impl HostRoots {
.var("APPDATA") .var("APPDATA")
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Roaming")); .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 let config_home = env
.var("XDG_CONFIG_HOME") .var("XDG_CONFIG_HOME")
.map(PathBuf::from) .map(PathBuf::from)
@@ -113,6 +123,7 @@ impl HostRoots {
Some(Self { Some(Self {
home, home,
app_data, app_data,
local_app_data,
config_home, config_home,
system_root, system_root,
}) })
@@ -226,6 +237,7 @@ mod tests {
let roots = HostRoots::from_env(&env).expect("HOME 已给"); let roots = HostRoots::from_env(&env).expect("HOME 已给");
assert_eq!(roots.home, PathBuf::from("/home/u")); assert_eq!(roots.home, PathBuf::from("/home/u"));
assert_eq!(roots.app_data, PathBuf::from("/home/u/AppData/Roaming")); 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.config_home, PathBuf::from("/home/u/.config"));
assert_eq!(roots.system_root, Some(PathBuf::from("C:/Windows"))); assert_eq!(roots.system_root, Some(PathBuf::from("C:/Windows")));
} }
@@ -235,14 +247,29 @@ mod tests {
let env = MapEnv::new() let env = MapEnv::new()
.with("HOME", "/home/u") .with("HOME", "/home/u")
.with("APPDATA", "C:/Users/u/AppData/Roaming") .with("APPDATA", "C:/Users/u/AppData/Roaming")
.with("LOCALAPPDATA", "C:/Users/u/AppData/Local")
// 相对路径按 XDG 规范忽略。 // 相对路径按 XDG 规范忽略。
.with("XDG_CONFIG_HOME", "relative/dir"); .with("XDG_CONFIG_HOME", "relative/dir");
let roots = HostRoots::from_env(&env).expect("HOME 已给"); let roots = HostRoots::from_env(&env).expect("HOME 已给");
assert_eq!(roots.app_data, PathBuf::from("C:/Users/u/AppData/Roaming")); 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.config_home, PathBuf::from("/home/u/.config"));
assert_eq!(roots.system_root, None); 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] #[test]
fn fake_io_serves_only_registered_world() { fn fake_io_serves_only_registered_world() {
let io = fake::FakeIo::new() let io = fake::FakeIo::new()
@@ -141,6 +141,8 @@ fn expand_vars(text: &str, roots: &HostRoots) -> String {
let value = match name.to_ascii_lowercase().as_str() { let value = match name.to_ascii_lowercase().as_str() {
"userprofile" | "home" => Some(roots.home.to_string_lossy()), "userprofile" | "home" => Some(roots.home.to_string_lossy()),
"appdata" => Some(roots.app_data.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 "systemroot" | "windir" => roots
.system_root .system_root
.as_deref() .as_deref()
@@ -268,6 +270,16 @@ mod tests {
"C:/Users/u/AppData/Roaming\\Microsoft\\Windows\\Themes\\TranscodedWallpaper" "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!( assert_eq!(
normalize_path("~/Library/Desktop Pictures/a.heic", &roots), normalize_path("~/Library/Desktop Pictures/a.heic", &roots),
Some(PathBuf::from("/home/u/Library/Desktop Pictures/a.heic")) Some(PathBuf::from("/home/u/Library/Desktop Pictures/a.heic"))
+217 -161
View File
@@ -1,29 +1,47 @@
//! Windows 壁纸路径探测:`TranscodedWallpaper` → `*.theme` 的 `Wallpaper=` → 注册表 `WallPaper`。 //! 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`——系统**当前**壁纸转码后的 //! 1. `%APPDATA%\Microsoft\Windows\Themes\TranscodedWallpaper`——系统**当前**壁纸转码后的
//! 副本,没有扩展名(不同版本还有 `TranscodedWallpaper.jpg` 这个别名),所以「像不像图片」 //! 副本,没有扩展名(部分版本还有 `TranscodedWallpaper.jpg` 别名),所以「像不像图片」
//! 只能看文件头魔数,不看后缀; //! 只看文件头魔数,不看后缀;
//! 2. 同目录 `*.theme` 里的 `Wallpaper=` 行(主题文件是纯文本 INI,值可能是 //! 2. `CurrentTheme` 指向的 `.theme`——当前生效主题里的 `Wallpaper=` 行(Win10+ 在
//! `%SystemRoot%\web\wallpaper\...` 这类带变量的路径); //! `%LOCALAPPDATA%` 一侧,支持 `%LOCALAPPDATA%` / `%SystemRoot%` 之类变量展开);
//! 3. 注册表 `HKCU\Control Panel\Desktop` 的 `WallPaper` 值——**走 `reg.exe` 子进程**, //! 3. `HKCU\Control Panel\Desktop` 的 `WallPaper` 值——注册表权威来源;
//! 不用 win32 API(core 是 `#![forbid(unsafe_code)]`,见 [`super`] 模块文档)。 //! 4. `%APPDATA%\...\Themes\*.theme`——用户另存的主题包,`evidence` 里如实标「可能未启用」。
//!
//! 第 2、3 步走 `reg.exe` 子进程,不用 win32 API(core 是 `#![forbid(unsafe_code)]`,
//! 见 [`super`] 模块文档);命令缺失 / 非零退出 / 空输出都只记一条原因,探测继续。
//! //!
//! 本文件不带 `cfg`:Linux CI 上同样要能跑夹具用例,平台分流只在 //! 本文件不带 `cfg`:Linux CI 上同样要能跑夹具用例,平台分流只在
//! [`super::detect_with`] 一处。 //! [`super::detect_with`] 一处。
use std::path::PathBuf; use std::path::{Path, PathBuf};
use self::parse::{parse_reg_sz_value, parse_theme_wallpaper};
use super::io::HostRoots; use super::io::HostRoots;
use super::parse::normalize_path;
use super::{Collect, SourceKind}; use super::{Collect, SourceKind};
mod parse;
/// `reg query` 用的桌面键(`HKCU\Control Panel\Desktop`,壁纸值名 `WallPaper`)。 /// `reg query` 用的桌面键(`HKCU\Control Panel\Desktop`,壁纸值名 `WallPaper`)。
pub(super) const DESKTOP_KEY: &str = r"HKCU\Control Panel\Desktop"; 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=`,比对时都忽略大小写)。 /// 壁纸值名(注册表里是 `WallPaper`,`.theme` 里是 `Wallpaper=`,比对时都忽略大小写)。
pub(super) const WALLPAPER_VALUE: &str = "WallPaper"; pub(super) const WALLPAPER_VALUE: &str = "WallPaper";
/// 当前主题文件名(值是指向 `.theme` 的绝对路径)。
pub(super) const CURRENT_THEME_VALUE: &str = "CurrentTheme";
/// `%APPDATA%` 下的主题目录(分段拼,避免 Windows 上出现混用的分隔符)。 /// `%APPDATA%` 下的主题目录(分段拼,避免 Windows 上出现混用的分隔符)。
const THEMES_PARTS: &[&str] = &["Microsoft", "Windows", "Themes"]; const THEMES_PARTS: &[&str] = &["Microsoft", "Windows", "Themes"];
@@ -33,11 +51,12 @@ const TRANSCODED_NAMES: &[&str] = &["TranscodedWallpaper", "TranscodedWallpaper.
/// 主题文件后缀。 /// 主题文件后缀。
const THEME_EXTENSION: &str = "theme"; const THEME_EXTENSION: &str = "theme";
/// 三步依次探测。 /// 四步依次探测。
pub(super) fn collect(collect: &mut Collect<'_>) { pub(super) fn collect(collect: &mut Collect<'_>) {
transcoded(collect); transcoded(collect);
theme_files(collect); current_theme(collect);
registry(collect); registry(collect);
saved_theme_packages(collect);
} }
fn themes_dir(roots: &HostRoots) -> PathBuf { fn themes_dir(roots: &HostRoots) -> PathBuf {
@@ -60,8 +79,46 @@ fn transcoded(collect: &mut Collect<'_>) {
} }
} }
/// 第二步:`*.theme` 里的 `Wallpaper=` 行(按文件名排序,保证候选顺序确定)。 /// 第二步:当前生效主题(`CurrentTheme` 指向的 `.theme`)里的 `Wallpaper=`。
fn theme_files(collect: &mut Collect<'_>) { 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 dir = themes_dir(collect.roots());
let mut themes: Vec<PathBuf> = collect let mut themes: Vec<PathBuf> = collect
.io() .io()
@@ -78,86 +135,64 @@ fn theme_files(collect: &mut Collect<'_>) {
SourceKind::WindowsThemeFile.as_str(), SourceKind::WindowsThemeFile.as_str(),
format!("{} 下没有 .theme 文件", dir.display()), format!("{} 下没有 .theme 文件", dir.display()),
); );
return;
} }
for theme in themes { for theme in themes {
let evidence = format!("{} 的 Wallpaper=", theme.display()); let label = format!("{}(用户另存的主题包,可能未启用)", theme.display());
let Some(text) = collect.io().read_text(&theme) else { collect_theme_file(collect, SourceKind::WindowsThemeFile, &theme, &label);
collect.note(
SourceKind::WindowsThemeFile.as_str(),
format!("读不到 {}", theme.display()),
);
continue;
};
let values = parse_theme_wallpaper(&text);
if values.is_empty() {
collect.note(
SourceKind::WindowsThemeFile.as_str(),
format!("{} 里没有 Wallpaper= 行", theme.display()),
);
continue;
}
for value in values {
collect.add(SourceKind::WindowsThemeFile, &evidence, &value);
}
} }
} }
/// 第三步:注册表(子进程,允许失败)。 /// 读一个 `.theme` 文件,把里面的 `Wallpaper=` 收成候选;读不到 / 没值只记原因。
fn registry(collect: &mut Collect<'_>) { fn collect_theme_file(collect: &mut Collect<'_>, kind: SourceKind, file: &Path, label: &str) {
let evidence = format!("reg query {DESKTOP_KEY} /v {WALLPAPER_VALUE}"); 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 match collect
.io() .io()
.run("reg.exe", &["query", DESKTOP_KEY, "/v", WALLPAPER_VALUE]) .run("reg.exe", &["query", key, "/v", value_name])
{ {
Some(text) => match parse_registered_wallpaper(&text) { Some(text) => match parse_reg_sz_value(&text, value_name) {
Some(value) => { Some(value) => Some(value),
collect.add(SourceKind::WindowsRegistry, &evidence, &value); None => {
collect.note(
kind.as_str(),
format!("{query} 输出里没有 {value_name} 值(可能被策略禁用或壁纸为纯色)"),
);
None
} }
None => collect.note(
SourceKind::WindowsRegistry.as_str(),
"reg.exe 输出里没有 WallPaper 值(可能被策略禁用或壁纸为纯色)",
),
}, },
None => collect.note( None => {
SourceKind::WindowsRegistry.as_str(), collect.note(
"reg.exe 查询失败(命令缺失 / 非零退出 / 空输出)", kind.as_str(),
), format!("{query} 失败(命令缺失 / 非零退出 / 空输出)"),
} );
} None
/// 取 `.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` 的输出。
///
/// 形如 ` WallPaper REG_SZ C:\Users\me\Pictures\a.jpg`;值里的空格要保留,所以
/// 「值名 + 类型」之后的部分整段取原文(不能 `split_whitespace` 再拼,会吃掉连续空格)。
pub(super) fn parse_registered_wallpaper(text: &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(WALLPAPER_VALUE) {
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)] #[cfg(test)]
@@ -168,12 +203,17 @@ mod tests {
use crate::platform::paths::{MapEnv, Platform}; use crate::platform::paths::{MapEnv, Platform};
const APP_DATA: &str = "C:/Users/u/AppData/Roaming"; const APP_DATA: &str = "C:/Users/u/AppData/Roaming";
const REG_KEY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper"; 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 { fn env() -> MapEnv {
MapEnv::new() MapEnv::new()
.with("HOME", "C:/Users/u") .with("HOME", "C:/Users/u")
.with("APPDATA", APP_DATA) .with("APPDATA", APP_DATA)
.with("LOCALAPPDATA", LOCAL_APP_DATA)
.with("SYSTEMROOT", "C:/Windows") .with("SYSTEMROOT", "C:/Windows")
} }
@@ -181,7 +221,8 @@ mod tests {
HostRoots::from_env(&env()).expect("HOME 已给") HostRoots::from_env(&env()).expect("HOME 已给")
} }
/// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上。 /// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上
/// (另有一条字面量断言盯着这个拼法本身,见 `transcoded_wallpaper_hit_comes_first`)。
fn themes() -> PathBuf { fn themes() -> PathBuf {
themes_dir(&roots()) themes_dir(&roots())
} }
@@ -194,52 +235,102 @@ mod tests {
let best = probe.best().expect("有候选"); let best = probe.best().expect("有候选");
assert_eq!(best.kind, SourceKind::WindowsTranscoded); assert_eq!(best.kind, SourceKind::WindowsTranscoded);
assert_eq!(best.path, themes().join("TranscodedWallpaper")); 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")); assert!(best.evidence.contains("TranscodedWallpaper"));
} }
#[test] #[test]
fn multiple_candidates_keep_step_order() { fn current_theme_is_read_from_local_app_data() {
// 第一步缺失 → 两份 .theme(第二步)→ 注册表(第三步)。 // Win10+ 的真机形态:%APPDATA% 下没有 .theme,当前主题在 %LOCALAPPDATA%。
let io = FakeIo::new() 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_dir(themes(), &["a.theme", "b.theme", "notes.txt"])
.with_file( .with_file(
themes().join("a.theme"), package.clone(),
"[Control Panel\\Desktop]\r\nWallpaper=C:\\Users\\u\\Pictures\\one.jpg\r\n", "Wallpaper=%SystemRoot%\\web\\wallpaper\\three.jpg\r\n",
)
.with_file(
themes().join("b.theme"),
"wallpaper=%SystemRoot%\\web\\wallpaper\\two.jpg\r\n",
) )
.with_image(r"C:\Users\u\Pictures\one.jpg") .with_image(r"C:\Users\u\Pictures\one.jpg")
.with_image("C:/Windows\\web\\wallpaper\\two.jpg") .with_image(r"C:\Users\u\Pictures\two.jpg")
.with_image(r"C:\Users\u\Pictures\three.jpg") .with_image("C:/Windows\\web\\wallpaper\\three.jpg");
.with_command(
REG_KEY,
"HKEY_CURRENT_USER\\Control Panel\\Desktop\r\n \
WallPaper REG_SZ C:\\Users\\u\\Pictures\\three.jpg\r\n",
);
let probe = detect_with(&io, &env(), Platform::Windows); let probe = detect_with(&io, &env(), Platform::Windows);
let kinds: Vec<SourceKind> = probe.iter().map(|source| source.kind).collect(); let seen: Vec<(SourceKind, String)> = probe
assert_eq!( .iter()
kinds, .map(|source| (source.kind, source.path.display().to_string()))
vec![ .collect();
SourceKind::WindowsThemeFile, assert_eq!(seen.len(), 4, "{:?}", probe.notes());
SourceKind::WindowsThemeFile, assert_eq!(seen[0].0, SourceKind::WindowsTranscoded);
SourceKind::WindowsRegistry, assert_eq!(seen[1].0, SourceKind::WindowsCurrentTheme);
] assert_eq!(seen[2].0, SourceKind::WindowsRegistry);
); assert_eq!(seen[3].0, SourceKind::WindowsThemeFile);
assert_eq!( // 兜底档在 evidence 里如实标明「可能未启用」,调用方据此可降权。
probe.sources()[0].path, assert!(
PathBuf::from(r"C:\Users\u\Pictures\one.jpg") probe.sources()[3].evidence.contains("可能未启用"),
); "{}",
// %SystemRoot% 被展开,走的是变量表而不是猜路径。 probe.sources()[3].evidence
assert_eq!(
probe.sources()[1].path,
PathBuf::from("C:/Windows\\web\\wallpaper\\two.jpg")
);
assert_eq!(
probe.best().expect("有候选").kind.platform(),
Platform::Windows
); );
} }
@@ -248,7 +339,11 @@ mod tests {
let probe = detect_with(&FakeIo::new(), &env(), Platform::Windows); let probe = detect_with(&FakeIo::new(), &env(), Platform::Windows);
assert!(probe.is_empty(), "全部失败要返回空列表而不是错误"); assert!(probe.is_empty(), "全部失败要返回空列表而不是错误");
let notes = probe.notes().join("\n"); let notes = probe.notes().join("\n");
assert!(notes.contains("reg.exe"), "注册表失败要留原因:{notes}"); assert!(notes.contains("reg query"), "注册表失败要留原因:{notes}");
assert!(
notes.contains("CurrentTheme"),
"当前主题失败要留原因:{notes}"
);
assert!( assert!(
notes.contains("TranscodedWallpaper"), notes.contains("TranscodedWallpaper"),
"转码副本缺失要留原因:{notes}" "转码副本缺失要留原因:{notes}"
@@ -263,7 +358,7 @@ mod tests {
.with_image(&transcoded) .with_image(&transcoded)
// 注册表指向同一个文件,大小写还不同(Windows 路径大小写不敏感)。 // 注册表指向同一个文件,大小写还不同(Windows 路径大小写不敏感)。
.with_command( .with_command(
REG_KEY, DESKTOP_QUERY,
&format!(" WallPaper REG_SZ {registry_value}\r\n"), &format!(" WallPaper REG_SZ {registry_value}\r\n"),
); );
let probe = detect_with(&io, &env(), Platform::Windows); let probe = detect_with(&io, &env(), Platform::Windows);
@@ -273,43 +368,4 @@ mod tests {
SourceKind::WindowsTranscoded SourceKind::WindowsTranscoded
); );
} }
#[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 registry_output_parses_value_and_keeps_spaces() {
assert_eq!(
parse_registered_wallpaper(
"HKEY_CURRENT_USER\\Control Panel\\Desktop\r\n \
WallPaper REG_SZ C:\\Users\\Jak e\\Desktop Pictures\\a.jpg\r\n"
),
Some(r"C:\Users\Jak e\Desktop Pictures\a.jpg".to_owned())
);
// 纯色壁纸 = 空值。
assert_eq!(
parse_registered_wallpaper(" WallPaper REG_SZ \r\n"),
None
);
assert_eq!(parse_registered_wallpaper(""), None);
assert_eq!(
parse_registered_wallpaper(" TileWallpaper REG_SZ 0\r\n"),
None
);
}
} }
@@ -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
);
}
}