From d3e9a62b214feac986c0ab2eca74aa022317d26b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 18:25:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20M73=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E5=A3=81=E7=BA=B8=E8=B7=AF=E5=BE=84=E6=8E=A2=E6=B5=8B=EF=BC=88?= =?UTF-8?q?D38=20=E7=AC=AC=E2=91=A1=E7=BA=A7=E8=BE=93=E5=85=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 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 条单测覆盖三平台的 正常命中 / 多候选 / 全部失败。 --- crates/core/src/platform/mod.rs | 4 + crates/core/src/platform/wallpaper.rs | 349 +++++++++++++++ crates/core/src/platform/wallpaper/io.rs | 270 ++++++++++++ crates/core/src/platform/wallpaper/linux.rs | 396 ++++++++++++++++++ crates/core/src/platform/wallpaper/macos.rs | 304 ++++++++++++++ crates/core/src/platform/wallpaper/parse.rs | 308 ++++++++++++++ crates/core/src/platform/wallpaper/windows.rs | 315 ++++++++++++++ 7 files changed, 1946 insertions(+) create mode 100644 crates/core/src/platform/wallpaper.rs create mode 100644 crates/core/src/platform/wallpaper/io.rs create mode 100644 crates/core/src/platform/wallpaper/linux.rs create mode 100644 crates/core/src/platform/wallpaper/macos.rs create mode 100644 crates/core/src/platform/wallpaper/parse.rs create mode 100644 crates/core/src/platform/wallpaper/windows.rs diff --git a/crates/core/src/platform/mod.rs b/crates/core/src/platform/mod.rs index d8ee16f..5691acb 100644 --- a/crates/core/src/platform/mod.rs +++ b/crates/core/src/platform/mod.rs @@ -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; diff --git a/crates/core/src/platform/wallpaper.rs b/crates/core/src/platform/wallpaper.rs new file mode 100644 index 0000000..9658115 --- /dev/null +++ b/crates/core/src/platform/wallpaper.rs @@ -0,0 +1,349 @@ +//! 桌面壁纸**路径探测**(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:`%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::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::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, + notes: Vec, +} + +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 { + 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 { + 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, + notes: Vec, + seen: Vec, +} + +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() + ); + } +} diff --git a/crates/core/src/platform/wallpaper/io.rs b/crates/core/src/platform/wallpaper/io.rs new file mode 100644 index 0000000..402227a --- /dev/null +++ b/crates/core/src/platform/wallpaper/io.rs @@ -0,0 +1,270 @@ +//! 壁纸探测要用的外部 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; + + /// 读文件头最多 `limit` 字节(图片魔数嗅探用;不存在或读不了返回 `None`)。 + fn read_head(&self, path: &Path, limit: usize) -> Option>; + + /// 列出目录里的**文件**(非递归、不排序;目录不存在返回空列表)。排序由调用方做。 + fn list_dir(&self, dir: &Path) -> Vec; + + /// 跑一个外部命令取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。 + fn run(&self, program: &str, args: &[&str]) -> Option; +} + +/// 真实宿主 IO。 +#[derive(Debug, Clone, Copy, Default)] +pub struct HostIo; + +impl ProbeIo for HostIo { + fn read_text(&self, path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + Some(String::from_utf8_lossy(&bytes).into_owned()) + } + + fn read_head(&self, path: &Path, limit: usize) -> Option> { + 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 { + 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 { + let output = std::process::Command::new(program) + .args(args) + .output() + .ok()?; + if 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, + /// `$XDG_CONFIG_HOME`(仅接受绝对路径),退 `~/.config`。 + pub config_home: PathBuf, + /// Windows `%SystemRoot%`(`.theme` 里 `Wallpaper=%SystemRoot%\...` 展开用),无则不展开。 + pub system_root: Option, +} + +impl HostRoots { + /// 从环境变量表推出根目录;连主目录都没有时返回 `None`(调用方记一条原因)。 + /// + /// 顺序固定(`HOME` 优先于 `USERPROFILE`),**不按编译目标分支**:否则在 Windows 上 + /// 跑 Linux 夹具时会被 `USERPROFILE` 抢走主目录,夹具就不再确定。 + #[must_use] + pub fn from_env(env: &dyn EnvSource) -> Option { + 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 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, + 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, + images: Vec, + dirs: BTreeMap>, + commands: BTreeMap, + } + + impl FakeIo { + /// 空世界(一切探测都会失败)。 + pub(crate) fn new() -> Self { + Self::default() + } + + /// 登记一个文本文件(配置文件、`.theme`、plist、xml……)。 + pub(crate) fn with_file(mut self, path: impl AsRef, 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) -> Self { + self.images.push(path.as_ref().to_path_buf()); + self + } + + /// 登记目录内容(按文件名给,路径拼在 `dir` 上)。 + pub(crate) fn with_dir(mut self, dir: impl AsRef, 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 { + self.texts.get(path).cloned() + } + + fn read_head(&self, path: &Path, limit: usize) -> Option> { + 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 { + self.dirs.get(dir).cloned().unwrap_or_default() + } + + fn run(&self, program: &str, args: &[&str]) -> Option { + 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.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") + // 相对路径按 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.config_home, PathBuf::from("/home/u/.config")); + assert_eq!(roots.system_root, 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()); + } +} diff --git a/crates/core/src/platform/wallpaper/linux.rs b/crates/core/src/platform/wallpaper/linux.rs new file mode 100644 index 0000000..e2f7219 --- /dev/null +++ b/crates/core/src/platform/wallpaper/linux.rs @@ -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 bg `); +//! 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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), + "\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), + "\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( + "\n\n" + ), + vec!["/home/u/a.png".to_owned()] + ); + } +} diff --git a/crates/core/src/platform/wallpaper/macos.rs b/crates/core/src/platform/wallpaper/macos.rs new file mode 100644 index 0000000..a3223a3 --- /dev/null +++ b/crates/core/src/platform/wallpaper/macos.rs @@ -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(`file:///…`)也可能是二进制 plist 的 lossy 文本 +/// (可读 ASCII 串之间夹着不可打印字节)。因此先按「可打印 ASCII 连续段」切,再在每段里认: +/// +/// - 任意位置的 `file://` 开头; +/// - 「段首 / 分隔符之后 / `~` 之后」的 `/` 开头(路径中间的 `/` 不算新起点,避免同一路径 +/// 被切成好几段)。 +#[must_use] +pub(super) fn scan_string_paths(text: &str) -> Vec { + 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 { + 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 { + 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), + // 闭合标签 `` 里的那个斜杠不是路径。 + 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 序列化里的终止字符(`` 的尖括号、引号、实体)切掉。 +fn push_candidate(found: &mut Vec, 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!("\n\n{body}\n\n") + } + + #[test] + fn store_index_hit_decodes_file_url() { + let io = FakeIo::new() + .with_file( + STORE_PATH, + &xml_plist("file:///Users/me/Pictures/my%20wall.heic"), + ) + .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( + "\n\ + file:///System/Library/Desktop Pictures/a.heic\n\ + /Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic\n\ + ", + ), + ) + .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 = 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( + "Content\n/Users/me/a.png", + )); + 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("~/Library/Desktop Pictures/a.heic"); + assert_eq!(found, vec!["~/Library/Desktop Pictures/a.heic".to_owned()]); + } +} diff --git a/crates/core/src/platform/wallpaper/parse.rs b/crates/core/src/platform/wallpaper/parse.rs new file mode 100644 index 0000000..2f8fe06 --- /dev/null +++ b/crates/core/src/platform/wallpaper/parse.rs @@ -0,0 +1,308 @@ +//! 路径归一化与图片头嗅探:把各平台读出的「原始串」变成可用的绝对路径候选。 +//! +//! 三平台读出来的东西形状都不一样(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,外加头部出现 ` 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" Option { + 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()), + "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") { + transcoded(collect); + theme_files(collect); + registry(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; + } + } +} + +/// 第二步:`*.theme` 里的 `Wallpaper=` 行(按文件名排序,保证候选顺序确定)。 +fn theme_files(collect: &mut Collect<'_>) { + let dir = themes_dir(collect.roots()); + let mut themes: Vec = 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()), + ); + } + 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); + } + } +} + +/// 第三步:注册表(子进程,允许失败)。 +fn registry(collect: &mut Collect<'_>) { + let evidence = format!("reg query {DESKTOP_KEY} /v {WALLPAPER_VALUE}"); + match collect + .io() + .run("reg.exe", &["query", DESKTOP_KEY, "/v", WALLPAPER_VALUE]) + { + Some(text) => match parse_registered_wallpaper(&text) { + Some(value) => { + collect.add(SourceKind::WindowsRegistry, &evidence, &value); + } + 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; + } + 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::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 REG_KEY: &str = "reg.exe query HKCU\\Control Panel\\Desktop /v WallPaper"; + + fn env() -> MapEnv { + MapEnv::new() + .with("HOME", "C:/Users/u") + .with("APPDATA", APP_DATA) + .with("SYSTEMROOT", "C:/Windows") + } + + fn roots() -> HostRoots { + HostRoots::from_env(&env()).expect("HOME 已给") + } + + /// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上。 + 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!(best.evidence.contains("TranscodedWallpaper")); + } + + #[test] + fn multiple_candidates_keep_step_order() { + // 第一步缺失 → 两份 .theme(第二步)→ 注册表(第三步)。 + let io = FakeIo::new() + .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", + ) + .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", + ); + 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 + ); + } + + #[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.exe"), "注册表失败要留原因:{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( + REG_KEY, + &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 + ); + } + + #[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 + ); + } +} From 69d0025d091686ef1cf79f22c91c0cf03c32809e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 20:02:59 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(core):=20M73=20r1=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20Windows=20=E5=A3=81=E7=BA=B8=E6=9D=A5=E6=BA=90=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E5=BD=93=E5=89=8D=E4=B8=BB=E9=A2=98=E4=BC=98?= =?UTF-8?q?=E5=85=88=E3=80=8D=EF=BC=8C=E9=80=9A=E9=85=8D=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=8C=85=E9=99=8D=E4=B8=BA=E6=9C=AB=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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 照旧。 --- crates/core/src/platform/wallpaper.rs | 15 +- crates/core/src/platform/wallpaper/io.rs | 29 +- crates/core/src/platform/wallpaper/parse.rs | 12 + crates/core/src/platform/wallpaper/windows.rs | 378 ++++++++++-------- .../src/platform/wallpaper/windows/parse.rs | 100 +++++ 5 files changed, 369 insertions(+), 165 deletions(-) create mode 100644 crates/core/src/platform/wallpaper/windows/parse.rs 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 + ); + } +}