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:
1 parent
d3e9a62b21
commit
69d0025d09
5 files changed
+369
-165
No files matched your search
@@ -52,7 +52,14 @@ const IMAGE_HEAD_LEN: usize = 512;
|
||||
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,
|
||||
@@ -76,6 +83,7 @@ impl SourceKind {
|
||||
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",
|
||||
@@ -91,9 +99,10 @@ impl SourceKind {
|
||||
#[must_use]
|
||||
pub const fn platform(self) -> Platform {
|
||||
match self {
|
||||
Self::WindowsTranscoded | Self::WindowsThemeFile | Self::WindowsRegistry => {
|
||||
Platform::Windows
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -66,7 +66,10 @@ impl ProbeIo for HostIo {
|
||||
.args(args)
|
||||
.output()
|
||||
.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;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
@@ -80,6 +83,9 @@ pub struct HostRoots {
|
||||
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%\...` 展开用),无则不展开。
|
||||
@@ -101,6 +107,10 @@ impl HostRoots {
|
||||
.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)
|
||||
@@ -113,6 +123,7 @@ impl HostRoots {
|
||||
Some(Self {
|
||||
home,
|
||||
app_data,
|
||||
local_app_data,
|
||||
config_home,
|
||||
system_root,
|
||||
})
|
||||
@@ -226,6 +237,7 @@ mod tests {
|
||||
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")));
|
||||
}
|
||||
@@ -235,14 +247,29 @@ mod tests {
|
||||
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()
|
||||
|
||||
@@ -141,6 +141,8 @@ fn expand_vars(text: &str, roots: &HostRoots) -> String {
|
||||
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()
|
||||
@@ -268,6 +270,16 @@ mod tests {
|
||||
"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"))
|
||||
|
||||
@@ -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`——系统**当前**壁纸转码后的
|
||||
//! 副本,没有扩展名(不同版本还有 `TranscodedWallpaper.jpg` 这个别名),所以「像不像图片」
|
||||
//! 只能看文件头魔数,不看后缀;
|
||||
//! 2. 同目录 `*.theme` 里的 `Wallpaper=` 行(主题文件是纯文本 INI,值可能是
|
||||
//! `%SystemRoot%\web\wallpaper\...` 这类带变量的路径);
|
||||
//! 3. 注册表 `HKCU\Control Panel\Desktop` 的 `WallPaper` 值——**走 `reg.exe` 子进程**,
|
||||
//! 不用 win32 API(core 是 `#![forbid(unsafe_code)]`,见 [`super`] 模块文档)。
|
||||
//! 副本,没有扩展名(部分版本还有 `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::PathBuf;
|
||||
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"];
|
||||
|
||||
@@ -33,11 +51,12 @@ const TRANSCODED_NAMES: &[&str] = &["TranscodedWallpaper", "TranscodedWallpaper.
|
||||
/// 主题文件后缀。
|
||||
const THEME_EXTENSION: &str = "theme";
|
||||
|
||||
/// 三步依次探测。
|
||||
/// 四步依次探测。
|
||||
pub(super) fn collect(collect: &mut Collect<'_>) {
|
||||
transcoded(collect);
|
||||
theme_files(collect);
|
||||
current_theme(collect);
|
||||
registry(collect);
|
||||
saved_theme_packages(collect);
|
||||
}
|
||||
|
||||
fn themes_dir(roots: &HostRoots) -> PathBuf {
|
||||
@@ -60,8 +79,46 @@ fn transcoded(collect: &mut Collect<'_>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 第二步:`*.theme` 里的 `Wallpaper=` 行(按文件名排序,保证候选顺序确定)。
|
||||
fn theme_files(collect: &mut Collect<'_>) {
|
||||
/// 第二步:当前生效主题(`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()
|
||||
@@ -78,86 +135,64 @@ fn theme_files(collect: &mut Collect<'_>) {
|
||||
SourceKind::WindowsThemeFile.as_str(),
|
||||
format!("{} 下没有 .theme 文件", dir.display()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for theme in themes {
|
||||
let evidence = format!("{} 的 Wallpaper=", theme.display());
|
||||
let Some(text) = collect.io().read_text(&theme) else {
|
||||
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);
|
||||
}
|
||||
let label = format!("{}(用户另存的主题包,可能未启用)", theme.display());
|
||||
collect_theme_file(collect, SourceKind::WindowsThemeFile, &theme, &label);
|
||||
}
|
||||
}
|
||||
|
||||
/// 第三步:注册表(子进程,允许失败)。
|
||||
fn registry(collect: &mut Collect<'_>) {
|
||||
let evidence = format!("reg query {DESKTOP_KEY} /v {WALLPAPER_VALUE}");
|
||||
/// 读一个 `.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", DESKTOP_KEY, "/v", WALLPAPER_VALUE])
|
||||
.run("reg.exe", &["query", key, "/v", value_name])
|
||||
{
|
||||
Some(text) => match parse_registered_wallpaper(&text) {
|
||||
Some(value) => {
|
||||
collect.add(SourceKind::WindowsRegistry, &evidence, &value);
|
||||
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(
|
||||
SourceKind::WindowsRegistry.as_str(),
|
||||
"reg.exe 输出里没有 WallPaper 值(可能被策略禁用或壁纸为纯色)",
|
||||
),
|
||||
},
|
||||
None => collect.note(
|
||||
SourceKind::WindowsRegistry.as_str(),
|
||||
"reg.exe 查询失败(命令缺失 / 非零退出 / 空输出)",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 取 `.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;
|
||||
None => {
|
||||
collect.note(
|
||||
kind.as_str(),
|
||||
format!("{query} 失败(命令缺失 / 非零退出 / 空输出)"),
|
||||
);
|
||||
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)]
|
||||
@@ -168,12 +203,17 @@ mod tests {
|
||||
use crate::platform::paths::{MapEnv, Platform};
|
||||
|
||||
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 {
|
||||
MapEnv::new()
|
||||
.with("HOME", "C:/Users/u")
|
||||
.with("APPDATA", APP_DATA)
|
||||
.with("LOCALAPPDATA", LOCAL_APP_DATA)
|
||||
.with("SYSTEMROOT", "C:/Windows")
|
||||
}
|
||||
|
||||
@@ -181,7 +221,8 @@ mod tests {
|
||||
HostRoots::from_env(&env()).expect("HOME 已给")
|
||||
}
|
||||
|
||||
/// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上。
|
||||
/// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上
|
||||
/// (另有一条字面量断言盯着这个拼法本身,见 `transcoded_wallpaper_hit_comes_first`)。
|
||||
fn themes() -> PathBuf {
|
||||
themes_dir(&roots())
|
||||
}
|
||||
@@ -194,52 +235,102 @@ mod tests {
|
||||
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 multiple_candidates_keep_step_order() {
|
||||
// 第一步缺失 → 两份 .theme(第二步)→ 注册表(第三步)。
|
||||
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(
|
||||
themes().join("a.theme"),
|
||||
"[Control Panel\\Desktop]\r\nWallpaper=C:\\Users\\u\\Pictures\\one.jpg\r\n",
|
||||
)
|
||||
.with_file(
|
||||
themes().join("b.theme"),
|
||||
"wallpaper=%SystemRoot%\\web\\wallpaper\\two.jpg\r\n",
|
||||
package.clone(),
|
||||
"Wallpaper=%SystemRoot%\\web\\wallpaper\\three.jpg\r\n",
|
||||
)
|
||||
.with_image(r"C:\Users\u\Pictures\one.jpg")
|
||||
.with_image("C:/Windows\\web\\wallpaper\\two.jpg")
|
||||
.with_image(r"C:\Users\u\Pictures\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",
|
||||
);
|
||||
.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 kinds: Vec<SourceKind> = probe.iter().map(|source| source.kind).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
vec![
|
||||
SourceKind::WindowsThemeFile,
|
||||
SourceKind::WindowsThemeFile,
|
||||
SourceKind::WindowsRegistry,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
probe.sources()[0].path,
|
||||
PathBuf::from(r"C:\Users\u\Pictures\one.jpg")
|
||||
);
|
||||
// %SystemRoot% 被展开,走的是变量表而不是猜路径。
|
||||
assert_eq!(
|
||||
probe.sources()[1].path,
|
||||
PathBuf::from("C:/Windows\\web\\wallpaper\\two.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
probe.best().expect("有候选").kind.platform(),
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,7 +339,11 @@ mod tests {
|
||||
let probe = detect_with(&FakeIo::new(), &env(), Platform::Windows);
|
||||
assert!(probe.is_empty(), "全部失败要返回空列表而不是错误");
|
||||
let notes = probe.notes().join("\n");
|
||||
assert!(notes.contains("reg.exe"), "注册表失败要留原因:{notes}");
|
||||
assert!(notes.contains("reg query"), "注册表失败要留原因:{notes}");
|
||||
assert!(
|
||||
notes.contains("CurrentTheme"),
|
||||
"当前主题失败要留原因:{notes}"
|
||||
);
|
||||
assert!(
|
||||
notes.contains("TranscodedWallpaper"),
|
||||
"转码副本缺失要留原因:{notes}"
|
||||
@@ -263,7 +358,7 @@ mod tests {
|
||||
.with_image(&transcoded)
|
||||
// 注册表指向同一个文件,大小写还不同(Windows 路径大小写不敏感)。
|
||||
.with_command(
|
||||
REG_KEY,
|
||||
DESKTOP_QUERY,
|
||||
&format!(" WallPaper REG_SZ {registry_value}\r\n"),
|
||||
);
|
||||
let probe = detect_with(&io, &env(), Platform::Windows);
|
||||
@@ -273,43 +368,4 @@ mod tests {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user