diff --git a/crates/core/src/platform/wallpaper.rs b/crates/core/src/platform/wallpaper.rs index 9658115..03e803a 100644 --- a/crates/core/src/platform/wallpaper.rs +++ b/crates/core/src/platform/wallpaper.rs @@ -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, } diff --git a/crates/core/src/platform/wallpaper/io.rs b/crates/core/src/platform/wallpaper/io.rs index 402227a..3655957 100644 --- a/crates/core/src/platform/wallpaper/io.rs +++ b/crates/core/src/platform/wallpaper/io.rs @@ -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() diff --git a/crates/core/src/platform/wallpaper/parse.rs b/crates/core/src/platform/wallpaper/parse.rs index 2f8fe06..41a7ce6 100644 --- a/crates/core/src/platform/wallpaper/parse.rs +++ b/crates/core/src/platform/wallpaper/parse.rs @@ -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")) diff --git a/crates/core/src/platform/wallpaper/windows.rs b/crates/core/src/platform/wallpaper/windows.rs index 1474341..f10ad58 100644 --- a/crates/core/src/platform/wallpaper/windows.rs +++ b/crates/core/src/platform/wallpaper/windows.rs @@ -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 = 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 { + 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 { - 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 { - 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 = 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 - ); - } } diff --git a/crates/core/src/platform/wallpaper/windows/parse.rs b/crates/core/src/platform/wallpaper/windows/parse.rs new file mode 100644 index 0000000..c737e72 --- /dev/null +++ b/crates/core/src/platform/wallpaper/windows/parse.rs @@ -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 { + 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 { + 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 + ); + } +}