feat(core): M73 桌面壁纸路径探测(D38 第②级输入)

新增 platform::wallpaper:只做「桌面壁纸可能是哪个文件」的路径探测,
不解码图片、不取色(莫奈取色与三级降级留给 crates/design 的后续 mission)。

- 唯一入口 detect() / 注入式 detect_with(io, env, platform):返回有序候选列表
  + 各步失败原因,全部找不到时是空列表而不是错误;
- Windows 三步:TranscodedWallpaper(无扩展名,靠魔数认)→ *.theme 的
  Wallpaper= → reg.exe query HKCU\Control Panel\Desktop /v WallPaper;
- Linux 四步:gsettings picture-uri(-dark) → KDE Image= → hyprpaper/sway
  配置 → XFCE xfce4-desktop.xml,每步失败不影响下一步;
- macOS 两步:com.apple.wallpaper/Store/Index.plist 字符串扫描(不引 plist
  依赖,兼容 bplist00)→ defaults read com.apple.desktop Background;
  代码与文档明确标注「未在真机验证」;
- 零 unsafe / 零 FFI(core 是 #![forbid(unsafe_code)]),文件读取与子进程
  全部经 ProbeIo 注入,环境变量表经 EnvSource 注入;三平台采集逻辑不带 cfg,
  CI 的 Linux 上也能跑 Windows / macOS 夹具用例;
- 不新增第三方依赖;6 个文件均 ≤400 行,32 条单测覆盖三平台的
  正常命中 / 多候选 / 全部失败。
This commit is contained in:
AskaEth committed 2026-11-01 21:17:52 +08:00
1 parent 614bd253c8
commit d3e9a62b21
7 files changed
+1946

No files matched your search

+349
View File
@@ -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<WallpaperSource>,
notes: Vec<String>,
}
impl WallpaperProbe {
/// 候选列表,按可信度从高到低(第一个就是首选)。
#[must_use]
pub fn sources(&self) -> &[WallpaperSource] {
&self.sources
}
/// 首选候选(等价于 `sources().first()`)。
#[must_use]
pub fn best(&self) -> Option<&WallpaperSource> {
self.sources.first()
}
/// 候选迭代器(按可信度顺序)。
pub fn iter(&self) -> impl Iterator<Item = &WallpaperSource> {
self.sources.iter()
}
/// 候选个数。
#[must_use]
pub fn len(&self) -> usize {
self.sources.len()
}
/// 一个候选都没有(调用方应落到 D38 的第③级色卡)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.sources.is_empty()
}
/// 各步的失败原因(`通道: 原因`),可直接展示在设置页的提示里。
#[must_use]
pub fn notes(&self) -> &[String] {
&self.notes
}
/// 取走候选列表。
#[must_use]
pub fn into_sources(self) -> Vec<WallpaperSource> {
self.sources
}
}
/// 真实探测宿主平台([`HostIo`] + 进程环境 + 编译目标平台)。
#[must_use]
pub fn detect() -> WallpaperProbe {
detect_with(&HostIo, &ProcessEnv, Platform::current())
}
/// 注入式探测:IO、环境变量表、平台族全部由调用方给。
///
/// 三平台采集逻辑都不带 `cfg`,所以任意宿主都能用固定夹具跑某一平台的用例。
#[must_use]
pub fn detect_with(io: &dyn ProbeIo, env: &dyn EnvSource, platform: Platform) -> WallpaperProbe {
let Some(roots) = HostRoots::from_env(env) else {
return WallpaperProbe {
sources: Vec::new(),
notes: vec!["host: HOME / USERPROFILE 都读不到,无法定位用户目录".to_owned()],
};
};
let mut collect = Collect::new(io, roots, platform);
match platform {
Platform::Windows => windows::collect(&mut collect),
Platform::MacOs => macos::collect(&mut collect),
// 其它 Unix 按 Linux 的桌面习惯兜底(与 `paths` 模块的处理一致)。
Platform::Linux | Platform::Other => linux::collect(&mut collect),
}
collect.finish()
}
/// 采集过程的累加器:负责归一化、去重、图片校验与失败原因记录。
pub(crate) struct Collect<'io> {
io: &'io dyn ProbeIo,
roots: HostRoots,
platform: Platform,
sources: Vec<WallpaperSource>,
notes: Vec<String>,
seen: Vec<String>,
}
impl<'io> Collect<'io> {
/// 构造空累加器。
pub(crate) fn new(io: &'io dyn ProbeIo, roots: HostRoots, platform: Platform) -> Self {
Self {
io,
roots,
platform,
sources: Vec::new(),
notes: Vec::new(),
seen: Vec::new(),
}
}
/// 注入的 IO(文件 / 目录 / 子进程)。
pub(crate) fn io(&self) -> &'io dyn ProbeIo {
self.io
}
/// 宿主根目录(由环境变量表推出)。
pub(crate) fn roots(&self) -> &HostRoots {
&self.roots
}
/// 记一条失败原因,探测继续往下走。
pub(crate) fn note(&mut self, step: &str, detail: impl std::fmt::Display) {
self.notes.push(format!("{step}: {detail}"));
}
/// 收一个候选:归一化 → 去重 → 图片校验,任何一步不过都只记原因、不中断。
///
/// 返回是否真的收下(调用方据此决定要不要继续试别名)。
pub(crate) fn add(&mut self, kind: SourceKind, evidence: &str, raw: &str) -> bool {
match normalize_path(raw, &self.roots) {
Some(path) => self.add_path(kind, evidence, path),
None => {
self.note(evidence, format!("不是可用的绝对路径({raw})"));
false
}
}
}
/// 收一个已经算好的路径(调用方自己拼的,不再归一化)。
pub(crate) fn add_path(&mut self, kind: SourceKind, evidence: &str, path: PathBuf) -> bool {
let key = dedupe_key(self.platform, &path);
if self.seen.contains(&key) {
return false;
}
let head = self.io.read_head(&path, IMAGE_HEAD_LEN);
if !head.as_deref().is_some_and(looks_like_image) {
self.note(
evidence,
format!("读不到或认不出图片格式({})", path.display()),
);
return false;
}
self.seen.push(key);
self.sources.push(WallpaperSource {
kind,
path,
evidence: evidence.to_owned(),
});
true
}
/// 收尾。
pub(crate) fn finish(self) -> WallpaperProbe {
WallpaperProbe {
sources: self.sources,
notes: self.notes,
}
}
}
/// 去重键:Windows 路径大小写不敏感(同一文件可能被写出两种大小写),其它平台按原样。
fn dedupe_key(platform: Platform, path: &std::path::Path) -> String {
let text = path.to_string_lossy().into_owned();
if platform == Platform::Windows {
text.to_ascii_lowercase()
} else {
text
}
}
#[cfg(test)]
mod tests {
use super::io::{HostIo, HostRoots};
use super::*;
use crate::platform::paths::MapEnv;
#[test]
fn empty_probe_is_empty_not_error() {
let probe = WallpaperProbe::default();
assert!(probe.is_empty());
assert_eq!(probe.len(), 0);
assert!(probe.best().is_none());
assert_eq!(probe.iter().count(), 0);
assert!(probe.notes().is_empty());
assert!(probe.into_sources().is_empty());
}
#[test]
fn missing_home_yields_note_and_no_candidates() {
let env = MapEnv::new();
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
let probe = detect_with(&HostIo, &env, platform);
assert!(probe.is_empty(), "{platform:?} 没有 HOME 时不该有候选");
assert_eq!(probe.notes().len(), 1);
assert!(probe.notes()[0].contains("HOME"));
}
}
#[test]
fn source_kind_exposes_platform_and_stable_name() {
assert_eq!(SourceKind::Gnome.platform(), Platform::Linux);
assert_eq!(SourceKind::WindowsRegistry.platform(), Platform::Windows);
assert_eq!(SourceKind::MacOsStore.platform(), Platform::MacOs);
assert_eq!(SourceKind::WindowsTranscoded.as_str(), "windows-transcoded");
assert!(SourceKind::Kde.as_str().starts_with("linux-"));
}
#[test]
fn collect_dedupes_and_keeps_priority_order() {
let env = MapEnv::new().with("HOME", "/home/u");
let roots = HostRoots::from_env(&env).expect("HOME 已给");
let io = io::fake::FakeIo::new()
.with_image("/home/u/a.png")
.with_image("/home/u/b.jpg");
let mut collect = Collect::new(&io, roots, Platform::Linux);
collect.add(SourceKind::Gnome, "gsettings", "/home/u/a.png");
collect.add(SourceKind::Kde, "kde", "file:///home/u/b.jpg");
// 同一条路径再来一次(换了个通道)不该重复计入。
collect.add(SourceKind::Xfce, "xfce", "/home/u/a.png");
collect.add(SourceKind::Xfce, "xfce", "/home/u/missing.png");
let probe = collect.finish();
assert_eq!(probe.len(), 2);
assert_eq!(probe.best().expect("有候选").kind, SourceKind::Gnome);
assert_eq!(probe.sources()[1].path, PathBuf::from("/home/u/b.jpg"));
assert!(
probe
.notes()
.iter()
.any(|note| note.contains("missing.png")),
"落选的候选要留原因:{:?}",
probe.notes()
);
}
}