Compare commits
20
Commits
c20c742b60
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352dd7937e | ||
|
|
69d0025d09 | ||
|
|
d3e9a62b21 | ||
|
|
614bd253c8 | ||
|
|
9e30d2cf15 | ||
|
|
6cfac9c823 | ||
|
|
4069204379 | ||
|
|
0cb1454067 | ||
|
|
aca280fd30 | ||
|
|
5a44870de5 | ||
|
|
a426cb5dcb | ||
|
|
9d9ab3e7e0 | ||
|
|
d0d24d42e7 | ||
|
|
a4bd4d2b38 | ||
|
|
2a0493c3eb | ||
|
|
cf52751124 | ||
|
|
40864d93d0 | ||
|
|
e37b452345 | ||
|
|
9519a89472 | ||
|
|
79404fa9d1 |
No files matched your search
@@ -2,57 +2,91 @@
|
||||
|
||||
**English** · [简体中文](README.zh-CN.md)
|
||||
|
||||
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher
|
||||
> **Official project home**: https://github.com/AskaEth/Unlucky-Launcher | downloads, issues and
|
||||
> release notes all live there: <https://github.com/AskaEth/Unlucky-Launcher/releases>
|
||||
|
||||
Unlucky Launcher (ULL) is a free, open-source Minecraft: Java Edition launcher written in Rust
|
||||
(Apache-2.0, non-commercial), with a Slint + Metro UI. It embeds an AI agent client (chat /
|
||||
configuration / crash diagnosis) and integrates with the broader agent tooling ecosystem via MCP.
|
||||
Minimum baseline: Windows 10.
|
||||
(Apache-2.0). It ships as one native binary per platform — no installer, no bundled runtime, no
|
||||
WebView. The launcher embeds an AI agent client (chat / configuration / crash diagnosis) and can be
|
||||
attached to other agent tooling as an MCP server.
|
||||
|
||||
- Runtime: a single native Rust binary — no JVM, no WebView, no external runtime
|
||||
- UI: Slint (Royalty-Free 2.0; GL preferred, CPU software-rendering fallback so older machines
|
||||
without a usable GPU still get the full UI)
|
||||
- License: Apache-2.0 © AskaEth (YeiJ)
|
||||
## Highlights
|
||||
|
||||
## Compliance statement
|
||||
|
||||
Authentication uses only official Microsoft/Mojang endpoints — Microsoft OAuth 2.0 device code
|
||||
flow, Xbox Live, XSTS, and api.minecraftservices.com — so players sign in with their own Microsoft
|
||||
account and launch the game they already own. Account tokens are stored only on the user's local
|
||||
machine and are never sent to any server operated by the project. ULL does not distribute, mirror,
|
||||
proxy or modify any Minecraft game files. Online play always requires a Microsoft account with
|
||||
valid entitlements. Local offline profiles never contact Microsoft or Mojang services and cannot
|
||||
join online-mode servers. Not affiliated with Mojang or Microsoft.
|
||||
- **Single-file native binary**: the launcher itself needs no JVM, no WebView and no sidecar
|
||||
runtime. The JDK a game needs is downloaded and managed per instance, not as a launcher
|
||||
dependency.
|
||||
- **Two UI stacks during the rewrite**: the released UI is Slint (Royalty-Free 2.0); the
|
||||
next-generation UI is iced 0.14 + Material 3 Expressive and is still being written (see Status).
|
||||
- **CPU software-rendering fallback**: `--software-renderer` keeps the interface rendering
|
||||
normally on machines with no usable GPU or with broken drivers.
|
||||
- **AI agent + MCP**: chat, setting edits and crash diagnosis inside the launcher; `ull agent
|
||||
serve` exposes the same core as an MCP server (stdio or Streamable HTTP) to external agent tools.
|
||||
- **Mod loaders**: Fabric, Quilt, NeoForge, Forge, with modpack and CurseForge / Modrinth clients in
|
||||
the core.
|
||||
- **Java management**: a Temurin → Oracle → Microsoft → Zulu fallback chain, region-aware.
|
||||
- **Accounts**: sign in with a Microsoft account over OAuth 2.0 device code flow → Xbox Live →
|
||||
XSTS → `api.minecraftservices.com`; account tokens are kept on this machine only.
|
||||
- **Engine**: multi-source download (health scoring, mirrors, chunked transfers, hash while
|
||||
downloading) and a YAML rule-based crash diagnosis engine.
|
||||
- **License**: Apache-2.0 © AskaEth (YeiJ).
|
||||
|
||||
## Status
|
||||
|
||||
The core engine is complete (workspace: 983+ tests passing):
|
||||
Two tracks. They are not the same product yet, so read the headings before downloading anything.
|
||||
|
||||
- **Microsoft sign-in**: OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services, with
|
||||
entitlement verification
|
||||
- **Account credentials are stored only on the local machine** — never uploaded, and inaccessible
|
||||
to the AI features
|
||||
- Mod loaders: Forge / NeoForge / Fabric / Quilt, verified on real machines
|
||||
- Java management: Temurin / Oracle / Microsoft / Zulu with a regional fallback chain
|
||||
- Multi-source download engine; YAML rule-based crash diagnosis
|
||||
- Usable as an MCP server for external agent tooling (`ull agent serve`)
|
||||
- Local offline profiles: for local / isolated-network testing only; they perform no Microsoft
|
||||
authentication and cannot join servers with online-mode enabled
|
||||
### Track A — released: v0.1.0, Slint UI (this is what you can download today)
|
||||
|
||||
**CLI (`ull`) is fully usable**: `install / launch / accounts / java / diagnose / agent`.
|
||||
- **GitHub Releases `v0.1.0`** (2026-11-01, flagged pre-release): single-file assets for Windows
|
||||
x64 (a bare `.exe`), Linux x64 and macOS (arm64 and x64) `.tar.gz`. Every release also carries
|
||||
`SHA256SUMS.txt` and the `LICENSE` / `NOTICE` / `README.md` texts as separate assets; on Windows
|
||||
those texts sit next to the `.exe` because a bare exe cannot hold them, while the unix archives
|
||||
already contain them.
|
||||
- **UI**: Slint + Metro. This is the shipped GUI — the instance, download, account, Java, log and
|
||||
settings pages all run on the core engine (the in-app AI chat panel is a scripted placeholder).
|
||||
- **CLI `ull`** is built from the same tree at the same version — but the `v0.1.0` assets ship
|
||||
only the GUI binary. If you want the CLI, build it yourself:
|
||||
`cargo build --release --bin ull`. Commands: `instances`, `install`, `launch`, `accounts`,
|
||||
`sessions`, `logs`, `diag`, `config`, `java`, `agent`.
|
||||
- Windows ARM64 and Linux ARM64 slots are wired into the release pipeline; the next tagged release
|
||||
builds them. The `v0.1.0` assets predate those two slots.
|
||||
|
||||
**GUI in development**: `crates/app` is currently a Slint + Metro skeleton; the theme system and
|
||||
the full interface ship with subsequent versions — use the CLI until then.
|
||||
### Track B — in progress on `dev`: next-generation UI (iced + Material 3 Expressive)
|
||||
|
||||
**This track is not released yet — you can see it only by building from source.**
|
||||
|
||||
- **Locked direction**: the look is Material 3 Expressive and the framework is iced 0.14; the UI
|
||||
layer is written from scratch. Neither the style nor the framework will change again.
|
||||
- **Done**: the token crate `crates/design` (no UI framework, no third-party crates — real HCT
|
||||
color, type scale, shape scale, spacing, elevation, motion, icon geometry, per-component
|
||||
metrics) and the skeleton crate `crates/app-iced` (opens a window, self-drawn title bar and
|
||||
window buttons, navigation rail, colors and type taken from tokens only, GPU→CPU backend probe
|
||||
with automatic fallback, and the screenshot/diff channel the visual reviews use).
|
||||
- **Not done**: the component library (buttons, dialogs, lists, menus, search, and the rest of the
|
||||
M3 set), page migration, and the i18n pass. Every page in the iced binary is a placeholder.
|
||||
- **Slint stays until the flip**: `crates/app` remains the reference implementation until the iced
|
||||
build passes equivalence acceptance; only then is `unlucky-launcher-iced` renamed to
|
||||
`unlucky-launcher` and the Slint crate retired.
|
||||
|
||||
Binaries in the workspace, so you can tell them apart:
|
||||
|
||||
| Binary | Crate | State |
|
||||
|--------|-------|-------|
|
||||
| `unlucky-launcher` | `crates/app` (Slint) | released in v0.1.0; still the usable GUI |
|
||||
| `unlucky-launcher-iced` | `crates/app-iced` (iced 0.14) | under construction; skeleton only, not shipped |
|
||||
| `ull` | `crates/cli` | built from the same tree at the same version; not in the v0.1.0 assets |
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
Unlucky-Launcher (cargo workspace)
|
||||
├── crates/core # domain library, zero UI dependencies, eight modules
|
||||
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one shared core)
|
||||
├── crates/app # Slint UI: the only crate allowed to depend on a UI framework
|
||||
├── crates/cli # CLI (bin `ull`): headless, zero UI, depends on core + agent
|
||||
└── packaging # single-file artifact matrix and packaging assets (planned)
|
||||
├── crates/core # domain library: zero UI dependencies, eight modules
|
||||
├── crates/agent # AI agent: ToolSpec registry + built-in client + MCP server (one core, two shapes)
|
||||
├── crates/design # design tokens: real HCT color / type / shape / spacing / elevation / motion / icons
|
||||
│ # / component metrics — zero UI dependencies, no third-party crates
|
||||
├── crates/app # Slint UI (bin `unlucky-launcher`): the released GUI, kept until equivalence acceptance
|
||||
├── crates/app-iced # next-generation iced UI (bin `unlucky-launcher-iced`): under construction
|
||||
├── crates/cli # CLI (bin `ull`): headless, zero UI
|
||||
└── packaging # single-file artifact matrix and release scripts
|
||||
```
|
||||
|
||||
The eight modules of `crates/core`:
|
||||
@@ -63,7 +97,7 @@ The eight modules of `crates/core`:
|
||||
| `download` | multi-source health scoring, mirrors, chunked downloads, hash-while-downloading |
|
||||
| `game` | version / instance repository, snapshots and inheritance resolution |
|
||||
| `launch` | 10-stage launch pipeline, processes, JSONL event stream |
|
||||
| `auth` | Microsoft sign-in / local offline profiles |
|
||||
| `auth` | Microsoft sign-in (OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services) |
|
||||
| `addon` | mod loader installation, modpacks, CurseForge / Modrinth |
|
||||
| `diagnose` | five signal collectors, YAML rule engine, hs_err parsing |
|
||||
| `platform` | OS abstraction: paths, processes, file locks, capability probing, runtime OS detection |
|
||||
@@ -72,53 +106,85 @@ The eight modules of `crates/core`:
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; components `rustfmt` /
|
||||
`clippy`)
|
||||
- Windows: MSVC toolchain (used by CI) or MinGW-w64 (`x86_64-pc-windows-gnu`)
|
||||
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
|
||||
(Slint winit backend + femtovg build dependencies)
|
||||
- Rust stable (`rust-toolchain.toml` selects the toolchain automatically; the gates also need the
|
||||
`rustfmt` and `clippy` components)
|
||||
- Windows: MSVC toolchain (what CI uses) or MinGW-w64 (`x86_64-pc-windows-gnu`)
|
||||
- Linux: `libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
|
||||
libxcb1-dev` — needed by both UI stacks (Slint's winit + femtovg, iced's winit + wgpu/tiny-skia)
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check # formatting
|
||||
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
|
||||
cargo test --workspace # tests
|
||||
cargo build --release # artifact: target/release/unlucky-launcher[.exe]
|
||||
cargo fmt --all --check # formatting
|
||||
cargo clippy --workspace --all-targets -- -D warnings # lints (zero-warning gate)
|
||||
cargo test --workspace # tests
|
||||
cargo build --release # binaries land in target/release/
|
||||
```
|
||||
|
||||
Software-rendering switch (machines without a usable GPU / with broken drivers):
|
||||
`unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`).
|
||||
What the build produces:
|
||||
|
||||
- `unlucky-launcher` — the Slint UI (the released one)
|
||||
- `unlucky-launcher-iced` — the iced UI skeleton. The name is temporary: it can only become
|
||||
`unlucky-launcher` at the flip, because two crates in one workspace cannot emit the same binary
|
||||
name into the same target directory.
|
||||
- `ull` — the CLI
|
||||
|
||||
Rendering switches:
|
||||
|
||||
- `unlucky-launcher --software-renderer` (equivalent to `SLINT_BACKEND=winit-software`) forces the
|
||||
CPU path on the Slint build
|
||||
- `unlucky-launcher-iced --software-renderer` forces tiny-skia (`ICED_BACKEND=tiny-skia`);
|
||||
`--screenshot <png>` writes one frame and exits, which is the acceptance channel
|
||||
- Both stacks fall back to the CPU path when no usable adapter is there: the Slint build through its
|
||||
own renderer fallback chain, the iced build through its explicit probe
|
||||
|
||||
Dependency license check (optional locally, required in CI):
|
||||
`cargo deny check licenses bans sources`.
|
||||
|
||||
## CI gates
|
||||
|
||||
Every push to `main` and every pull request runs fmt / clippy (warnings are errors) / the full test
|
||||
suite, and the Linux headless render smoke is a hard gate on the same footing: a red result blocks
|
||||
the merge. The macOS runtime probe is not there yet — it only runs on a manual `workflow_dispatch`
|
||||
and its result is recorded, not enforced (`continue-on-error`), until the slot passes a first run on
|
||||
a real runner.
|
||||
|
||||
| Workflow | Content |
|
||||
|----------|---------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + build, Windows / Linux matrix |
|
||||
| `.github/workflows/guards.yml` | zero-UI guard for core, 400-line-per-file guard, cargo-deny license guard |
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + release build (Windows / Linux matrix); Linux runtime smoke (Xvfb: renders one real frame, then asserts the PNG is not blank); macOS runtime probe (manual dispatch only) |
|
||||
| `.github/workflows/guards.yml` | zero-UI guard for `crates/core` (manifest plus `cargo tree`), 400-lines-per-file guard, cargo-deny license and source guard |
|
||||
| `.github/workflows/release.yml` | tag `v*` → per-slot builds → single-file artifacts + `SHA256SUMS.txt` → GitHub Release |
|
||||
|
||||
## Platform baseline
|
||||
|
||||
Minimum baseline: **Windows 10**. Windows 8.1 / 8 / 7 are not supported:
|
||||
Minimum baseline: **Windows 10**; Windows 8.1 / 8 / 7 are not supported.
|
||||
|
||||
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU) — no custom target specs, no
|
||||
`-Zbuild-std`, no import-table allowlists
|
||||
- Other platforms: macOS 12+ first class; Linux x64 with X11 preferred, Wayland as the long-term
|
||||
direction; Android Termux experimental, not committed
|
||||
- UI fallback: Slint software rendering (fully usable on machines without a GPU or with broken
|
||||
drivers)
|
||||
- Platform independence is expressed through **separate test matrices**, not separate artifact
|
||||
pipelines; capability differences use feature gates with runtime probing
|
||||
- Windows 10/11 builds use the **standard toolchain** (MSVC / GNU), built the same way as
|
||||
upstream cargo
|
||||
- macOS 12+ (Apple Silicon and Intel) and Linux x64 / arm64 are supported build slots; on Linux,
|
||||
X11 is the preferred path today and Wayland is the long-term direction. macOS builds are unsigned,
|
||||
so the first launch needs the Gatekeeper quarantine attribute cleared
|
||||
(`xattr -d com.apple.quarantine`)
|
||||
- Android Termux is experimental and not promised: CLI only, GUI unverified
|
||||
- Each platform runs its own test matrix; capability differences use feature gates with runtime
|
||||
probing.
|
||||
|
||||
## License and acknowledgements
|
||||
|
||||
- This project: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in [NOTICE](NOTICE)
|
||||
- The GUI uses [Slint](https://slint.dev) (Royalty-Free 2.0 track). Per the RF 2.0 obligations an
|
||||
`AboutSlint` widget will be placed on the about page (P2); until then NOTICE and this section
|
||||
serve the attribution duty.
|
||||
- **This project**: Apache License 2.0, see [LICENSE](LICENSE); third-party notices in
|
||||
[NOTICE](NOTICE).
|
||||
- **Slint** (<https://slint.dev>), Royalty-Free 2.0: obligation for the Slint build that v0.1.0
|
||||
ships — an `AboutSlint` widget on the about page (still outstanding) and, until it lands, the
|
||||
NOTICE file, this section and a "Made with Slint" note on the release page. The obligation is
|
||||
kept for as long as a Slint-linked binary ships.
|
||||
- **iced** (<https://github.com/iced-rs/iced>), MIT: the framework of the next-generation UI,
|
||||
together with `winit` and, through it, `wgpu` / `tiny-skia` (Apache-2.0 / MIT).
|
||||
- **Google Material Color Utilities** (Apache-2.0): the color tokens in `crates/design` are a Rust
|
||||
port of its HCT / ColorSpec2021 algorithms; details and the exact ported functions are listed in
|
||||
[NOTICE](NOTICE).
|
||||
- **Other major dependencies**: `rmcp` and `axum` (Apache-2.0 / MIT) for the MCP server, `tokio`
|
||||
(MIT), `reqwest` with rustls (MIT / Apache-2.0), `serde` / `serde_json` (MIT / Apache-2.0),
|
||||
`zip` / `flate2` / `tar` (MIT / Apache-2.0). The allow-list cargo-deny enforces is in `deny.toml`;
|
||||
GPL-family dependencies may not enter the main line.
|
||||
|
||||
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and
|
||||
[PCL2](https://github.com/Meloong-Git/PCL) (both GPL-3.0) — these two launchers have accompanied
|
||||
the vast majority of Minecraft players for many years. ULL learned a great deal about design and
|
||||
implementation from them, and this project is dedicated to those two pioneers.
|
||||
Thanks to [HMCL](https://github.com/HMCL-dev/HMCL) and [PCL2](https://github.com/Meloong-Git/PCL)
|
||||
(both GPL-3.0). They have been with the vast majority of Minecraft players for years, and this
|
||||
project has benefited a great deal from their design choices and implementation details.
|
||||
+112
-45
@@ -2,41 +2,81 @@
|
||||
|
||||
[English](README.md) · **简体中文**
|
||||
|
||||
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher
|
||||
> **官方项目主页**:https://github.com/AskaEth/Unlucky-Launcher | 下载、议题、发布说明都在这里:
|
||||
> <https://github.com/AskaEth/Unlucky-Launcher/releases>
|
||||
|
||||
Rust 原生单文件 Minecraft 启动器:**Slint + Metro 视觉**,内置 **AI Agent 客户端**(对话 / 改配置 / 崩溃诊断),
|
||||
融合主流 Agent 工具(**MCP**),**最低基线 Win10+**。
|
||||
Unlucky Launcher(ULL)是一个用 Rust 写的免费开源 Minecraft: Java 版启动器(Apache-2.0)。
|
||||
每个平台就一个原生二进制——没有安装包、没有捆绑运行时、没有 WebView。启动器内置 AI Agent
|
||||
客户端(对话 / 改配置 / 崩溃诊断),也能作为 MCP server 接进别的 Agent 工具。
|
||||
|
||||
- 运行时:纯 Rust 原生二进制——无 JVM、无 WebView、无外部运行时
|
||||
- UI:Slint(Royalty-Free 2.0;GL 优先,CPU 软渲染兜底,无可用 GPU 的老机也有完整 UI)
|
||||
- 许可:Apache-2.0 © AskaEth (YeiJ)
|
||||
## 特性
|
||||
|
||||
- **单文件原生二进制**:启动器本体不需要 JVM、不需要 WebView、不带旁挂运行时。游戏要的 JDK 由
|
||||
启动器按实例下载管理,不是启动器的依赖。
|
||||
- **重写期两套 UI 并存**:已发布版本是 Slint(Royalty-Free 2.0);下一代 UI 是 iced 0.14 +
|
||||
Material 3 Expressive,目前仍在写(见「当前状态」)。
|
||||
- **CPU 软渲染兜底**:`--software-renderer` 让没有可用 GPU 或驱动有问题的老机也能正常显示界面。
|
||||
- **AI Agent + MCP**:启动器内可对话、改设置、读崩溃诊断;`ull agent serve` 把同一个内核以 MCP
|
||||
server 形态(stdio 或 Streamable HTTP)开放给外部 Agent 工具。
|
||||
- **加载器**:Fabric / Quilt / NeoForge / Forge;整合包与 CurseForge / Modrinth 客户端在 core 里。
|
||||
- **Java 管理**:Temurin → Oracle → Microsoft → Zulu 回退链,带地区判定。
|
||||
- **账户**:用 Microsoft 账户登录,认证走 OAuth 2.0 device code flow → Xbox Live → XSTS →
|
||||
`api.minecraftservices.com`;账户令牌只保存在本机。
|
||||
- **引擎**:多源下载(健康度评分、镜像、分块传输、边下边算)与 YAML 规则引擎的崩溃诊断。
|
||||
- **许可**:Apache-2.0 © AskaEth (YeiJ)。
|
||||
|
||||
## 当前状态
|
||||
|
||||
**核心引擎已完成**(workspace 983+ 测试全绿):
|
||||
两条轨道,还不是同一个东西——看小标题再决定下载什么。
|
||||
|
||||
- **正版登录**:Microsoft OAuth 2.0 device code flow → Xbox Live → XSTS → Minecraft Services,校验游戏拥有状态
|
||||
- **账户凭据仅保存在本机**,不上传任何服务器,AI 功能无法访问
|
||||
- 加载器:Forge / NeoForge / Fabric / Quilt 四家真机验证
|
||||
- Java 管理:Temurin / Oracle / Microsoft / Zulu 四发行版回退链
|
||||
- 多源下载引擎、崩溃诊断规则引擎
|
||||
- 可作为 MCP server 接入外部 Agent 工具(`ull agent serve`)
|
||||
- 本地离线档案:仅用于本地/隔离网络环境的测试,不经过 Microsoft 账户认证,无法连接启用正版验证的服务器
|
||||
### 轨道 A —— 已发布:v0.1.0,Slint 版(现在能下载的就是它)
|
||||
|
||||
**CLI(`ull`)完整可用**:`install / launch / accounts / java / diagnose / agent` 等全量命令。
|
||||
- **GitHub Releases `v0.1.0`**(2026-11-01,标记为 pre-release):单文件产物覆盖 Windows x64(裸
|
||||
`.exe`)、Linux x64、macOS(arm64 与 x64)`.tar.gz`。每个 Release 另挂 `SHA256SUMS.txt` 与
|
||||
`LICENSE` / `NOTICE` / `README.md` 三份文本——Windows 是「一个 exe 装不下文本」所以并列挂出,
|
||||
unix 归档里本来就装着。
|
||||
- **UI**:Slint + Metro。这是已交付的图形界面——实例、下载、账户、Java、日志、设置
|
||||
各页都由 core 引擎驱动(应用内 AI 对话面板仍是脚本化的占位)。
|
||||
- **CLI `ull`** 与 v0.1.0 同源同版本,但发布资产里只有 GUI 单文件——要用它请自己构建:
|
||||
`cargo build --release --bin ull`。命令:`instances`、`install`、`launch`、`accounts`、`sessions`、
|
||||
`logs`、`diag`、`config`、`java`、`agent`。
|
||||
- Windows ARM64 与 Linux ARM64 两个槽位已接进发布流水线,随下一个 tag 发布;`v0.1.0` 的资产早于
|
||||
这两个槽位。
|
||||
|
||||
**图形界面(GUI)开发中**:`crates/app` 当前为 Slint + Metro 风格骨架,主题系统与完整界面
|
||||
随后续版本交付——在此之前请使用 CLI。
|
||||
### 轨道 B —— dev 上的下一代 UI(iced + M3 Expressive,施工中)
|
||||
|
||||
**这条轨道还没发布——要看只能自己从源码编。**
|
||||
|
||||
- **方向已锁死**:视觉 = Material 3 Expressive,框架 = iced 0.14,UI 层从头写;风格与框架都不会再换。
|
||||
- **已完成**:令牌 crate `crates/design`(零 UI 框架依赖、零第三方 crate——真 HCT 色彩、排版档位、
|
||||
形状刻度、间距、高程、动效、图标几何、组件度量)与骨架 crate `crates/app-iced`(能开窗、自绘标题栏
|
||||
与窗口键、导航轨、颜色与字号只从令牌取、GPU→CPU 后端探测与自动降级、视觉验收用的截图与逐像素
|
||||
对比通道)。
|
||||
- **没完成**:组件库(按钮、对话框、列表、菜单、搜索栏,以及 M3 其余组件)、页面搬迁、i18n 文案。
|
||||
iced 版里现在每一个页面都是占位。
|
||||
- **Slint 版在翻牌前不退场**:`crates/app` 作为参考实现保留,直到 iced 版通过等价验收;那时才把
|
||||
`unlucky-launcher-iced` 改名成 `unlucky-launcher`,并让 Slint 版退役。
|
||||
|
||||
工作区里的三个 bin,先认清楚:
|
||||
|
||||
| 二进制 | crate | 状态 |
|
||||
|--------|-------|------|
|
||||
| `unlucky-launcher` | `crates/app`(Slint) | v0.1.0 已发布,仍是当前能用的图形界面 |
|
||||
| `unlucky-launcher-iced` | `crates/app-iced`(iced 0.14) | 施工中,仅骨架,不随发布包分发 |
|
||||
| `ull` | `crates/cli` | 与发布版同源同版本构建;未随 v0.1.0 资产分发 |
|
||||
|
||||
## 模块地图
|
||||
|
||||
```
|
||||
Unlucky-Launcher (cargo workspace)
|
||||
├── crates/core # 领域库:零 UI 依赖,八模块
|
||||
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
|
||||
├── crates/app # Slint UI:唯一允许依赖 UI 框架的 crate
|
||||
├── crates/cli # CLI(bin `ull`):headless,零 UI,只依赖 core + agent
|
||||
└── packaging # 单文件产物矩阵与打包资源(规划中)
|
||||
├── crates/core # 领域库:零 UI 依赖,八模块
|
||||
├── crates/agent # AI Agent:ToolSpec 注册表 + 内置客户端 + MCP server(双形态共用一个内核)
|
||||
├── crates/design # 设计令牌:真 HCT 色彩 / 排版 / 形状 / 间距 / 高程 / 动效 / 图标 / 组件度量
|
||||
│ # —— 零 UI 依赖、零第三方 crate
|
||||
├── crates/app # Slint UI(bin `unlucky-launcher`):已发布的图形界面,保留到等价验收通过
|
||||
├── crates/app-iced # 下一代 iced UI(bin `unlucky-launcher-iced`):施工中
|
||||
├── crates/cli # CLI(bin `ull`):headless,零 UI
|
||||
└── packaging # 单文件产物矩阵与发布脚本
|
||||
```
|
||||
|
||||
`crates/core` 的八个模块:
|
||||
@@ -47,7 +87,7 @@ Unlucky-Launcher (cargo workspace)
|
||||
| `download` | 多源健康度、镜像、分块下载、边下边算 |
|
||||
| `game` | 版本/实例仓库、快照与继承解析 |
|
||||
| `launch` | 10 阶段启动管线、进程、JSONL 事件流 |
|
||||
| `auth` | Microsoft 正版登录 / 本地离线档案 |
|
||||
| `auth` | Microsoft 账户登录(OAuth 2.0 device code → Xbox Live → XSTS → Minecraft Services) |
|
||||
| `addon` | 加载器安装、整合包、CurseForge / Modrinth |
|
||||
| `diagnose` | 五类信号采集、YAML 规则引擎、hs_err 解析 |
|
||||
| `platform` | OS 抽象:路径、进程、文件锁、能力探测、运行期 OS 检测 |
|
||||
@@ -56,44 +96,71 @@ Unlucky-Launcher (cargo workspace)
|
||||
|
||||
前置:
|
||||
|
||||
- Rust stable(`rust-toolchain.toml` 会自动选择工具链;组件 `rustfmt` / `clippy`)
|
||||
- Windows:MSVC 工具链(CI 用)或 MinGW-w64(`x86_64-pc-windows-gnu`)
|
||||
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev`
|
||||
(Slint winit 后端 + femtovg 编译依赖)
|
||||
- Rust stable(`rust-toolchain.toml` 会自动选工具链;门禁还需要 `rustfmt` / `clippy` 组件)
|
||||
- Windows:MSVC 工具链(CI 用这套)或 MinGW-w64(`x86_64-pc-windows-gnu`)
|
||||
- Linux:`libxkbcommon-dev libfontconfig1-dev libgl1-mesa-dev libegl1-mesa-dev libwayland-dev
|
||||
libxcb1-dev`——两套 UI 都要用(Slint 的 winit + femtovg,iced 的 winit + wgpu/tiny-skia)
|
||||
|
||||
```bash
|
||||
cargo fmt --all --check # 格式
|
||||
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
|
||||
cargo test --workspace # 测试
|
||||
cargo build --release # 产物:target/release/unlucky-launcher[.exe]
|
||||
cargo fmt --all --check # 格式
|
||||
cargo clippy --workspace --all-targets -- -D warnings # 静态检查(零 warning 门禁)
|
||||
cargo test --workspace # 测试
|
||||
cargo build --release # 三个 bin 都落在 target/release/
|
||||
```
|
||||
|
||||
软渲染开关(无可用 GPU / 烂驱动的老机):`unlucky-launcher --software-renderer`
|
||||
(等价于 `SLINT_BACKEND=winit-software`)。
|
||||
编出来的三个 bin:
|
||||
|
||||
- `unlucky-launcher`——Slint 版(已发布的那个)
|
||||
- `unlucky-launcher-iced`——iced 版骨架。名字是临时的:只有翻牌时才能改成 `unlucky-launcher`,
|
||||
因为同一个工作区里两个 crate 不能往同一个 target 目录里产出同名 bin。
|
||||
- `ull`——CLI
|
||||
|
||||
渲染开关:
|
||||
|
||||
- `unlucky-launcher --software-renderer`(等价于 `SLINT_BACKEND=winit-software`)强制 Slint 版走 CPU
|
||||
- `unlucky-launcher-iced --software-renderer` 强制 tiny-skia(`ICED_BACKEND=tiny-skia`);
|
||||
`--screenshot <png>` 出一帧图后退出,这是验收通道
|
||||
- 两套栈在没有可用适配器时都会退到 CPU:Slint 版走它自己的渲染器回退链,iced 版走显式探测
|
||||
|
||||
依赖许可检查(本地可选,CI 必跑):`cargo deny check licenses bans sources`。
|
||||
|
||||
## CI 门禁
|
||||
|
||||
每次推送到 `main` 与每个 PR 都跑 fmt / clippy(warnings 视为错误)/ 全量测试,Linux 无头渲染冒烟
|
||||
与它们同级、是硬门禁:红了就不许合。macOS 运行期探针还不算——它只在手动 `workflow_dispatch` 时跑,
|
||||
结果留证据但不作为流水线结论(`continue-on-error`),直到该槽位在真实 runner 上首跑通过。
|
||||
|
||||
| Workflow | 内容 |
|
||||
|----------|------|
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + build,Windows / Linux 矩阵 |
|
||||
| `.github/workflows/guards.yml` | core 零 UI 守门、单文件 ≤400 行守门、cargo-deny 许可守门 |
|
||||
| `.github/workflows/ci.yml` | fmt + clippy + test + release build(Windows / Linux 矩阵);Linux runtime smoke(Xvfb 里真渲染一帧,再断言 PNG 非空白);macOS 运行期探针(仅手动 dispatch) |
|
||||
| `.github/workflows/guards.yml` | `crates/core` 零 UI 守门(依赖表 + `cargo tree`)、单文件 ≤400 行守门、cargo-deny 许可与来源守门 |
|
||||
| `.github/workflows/release.yml` | tag `v*` → 分槽位构建 → 单文件产物 + `SHA256SUMS.txt` → GitHub Release |
|
||||
|
||||
## 平台基线
|
||||
|
||||
最低基线 **Windows 10**。Windows 8.1 / 8 / 7 不在支持范围内:
|
||||
最低基线 **Windows 10**;Windows 8.1 / 8 / 7 不在支持范围内。
|
||||
|
||||
- Windows 10/11 走**标准工具链**产物(MSVC / GNU),无自定义 target spec、无 `-Zbuild-std`、无导入表白名单
|
||||
- 其他平台:macOS 12+ 一等;Linux x64 X11 优先、Wayland 为**大方向**;Android Termux 试验级不承诺
|
||||
- UI 兜底:Slint 软渲染(无 GPU / 烂驱动的老机可完整使用)
|
||||
- 平台独立性体现在**独立测试矩阵**,而非独立产物链;能力差异用 feature gate + 运行期探测表达
|
||||
- Windows 10/11 的产物由**标准工具链**(MSVC / GNU)构建,构建方式与上游 cargo 一致
|
||||
- macOS 12+(Apple Silicon 与 Intel)与 Linux x64 / arm64 都是支持槽位;Linux 上 X11 是当前首选路径,
|
||||
Wayland 是大方向。macOS 产物未签名,首次启动要自己清掉 Gatekeeper 隔离标记
|
||||
(`xattr -d com.apple.quarantine`)
|
||||
- Android Termux 试验级、不承诺:只保证 CLI,GUI 未验证
|
||||
- 每个平台跑各自的测试矩阵;能力差异用 feature gate + 运行期探测表达。
|
||||
|
||||
## 许可证与致谢
|
||||
|
||||
- 本项目:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)
|
||||
- GUI 使用 [Slint](https://slint.dev)(Royalty-Free 2.0 轨道)。按 RF 2.0 义务,关于页将放置
|
||||
`AboutSlint` 部件(P2),在此之前由 NOTICE 与本节承担告知义务
|
||||
- **本项目**:Apache License 2.0,见 [LICENSE](LICENSE);第三方声明见 [NOTICE](NOTICE)。
|
||||
- **Slint**(<https://slint.dev>),Royalty-Free 2.0:v0.1.0 发布的正是 Slint 版,这项义务继续有效
|
||||
——关于页放 `AboutSlint` 部件(尚未落地),在此之前由 NOTICE、本节与发布页的「Made with Slint」
|
||||
说明承担告知义务。只要还有链接 Slint 的产物在发,这条义务就保留。
|
||||
- **iced**(<https://github.com/iced-rs/iced>),MIT:下一代 UI 的框架;与它同栈的 `winit`、以及经它
|
||||
引入的 `wgpu` / `tiny-skia`(Apache-2.0 / MIT)。
|
||||
- **Google Material Color Utilities**(Apache-2.0):`crates/design` 的色彩令牌是它 HCT /
|
||||
ColorSpec2021 算法的 Rust 重写移植,移植清单与逐函数对照写在 [NOTICE](NOTICE)。
|
||||
- **其余主要依赖**:MCP server 的 `rmcp` 与 `axum`(Apache-2.0 / MIT)、`tokio`(MIT)、`reqwest`
|
||||
与 rustls(MIT / Apache-2.0)、`serde` / `serde_json`(MIT / Apache-2.0)、`zip` / `flate2` / `tar`
|
||||
(MIT / Apache-2.0)。cargo-deny 强制执行的白名单在 `deny.toml`;GPL 系依赖不得进主干。
|
||||
|
||||
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)(均 GPL-3.0)这两个启动器陪伴了绝大多数 MC 玩家好多年。
|
||||
ULL 的设计与实现从它们身上学到了太多,谨以此项目向两位前辈致敬。
|
||||
感谢 [HMCL](https://github.com/HMCL-dev/HMCL) 和 [PCL2](https://github.com/Meloong-Git/PCL)
|
||||
(均 GPL-3.0)。这两款启动器陪着绝大多数 MC 玩家走了很多年,
|
||||
本项目在设计取舍与实现细节上都从中受益良多。
|
||||
@@ -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;
|
||||
@@ -0,0 +1,358 @@
|
||||
//! 桌面壁纸**路径探测**(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:`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,
|
||||
/// 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::WindowsCurrentTheme => "windows-current-theme",
|
||||
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::WindowsCurrentTheme
|
||||
| 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! 壁纸探测要用的外部 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<String>;
|
||||
|
||||
/// 读文件头最多 `limit` 字节(图片魔数嗅探用;不存在或读不了返回 `None`)。
|
||||
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>>;
|
||||
|
||||
/// 列出目录里的**文件**(非递归、不排序;目录不存在返回空列表)。排序由调用方做。
|
||||
fn list_dir(&self, dir: &Path) -> Vec<PathBuf>;
|
||||
|
||||
/// 跑一个外部命令取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String>;
|
||||
}
|
||||
|
||||
/// 真实宿主 IO。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct HostIo;
|
||||
|
||||
impl ProbeIo for HostIo {
|
||||
fn read_text(&self, path: &Path) -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
let output = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
// `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())
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测要用的宿主根目录(全部由环境变量表推出,因此测试可以整体替换)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HostRoots {
|
||||
/// 用户主目录(`HOME`,退 `USERPROFILE`)。
|
||||
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%\...` 展开用),无则不展开。
|
||||
pub system_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl HostRoots {
|
||||
/// 从环境变量表推出根目录;连主目录都没有时返回 `None`(调用方记一条原因)。
|
||||
///
|
||||
/// 顺序固定(`HOME` 优先于 `USERPROFILE`),**不按编译目标分支**:否则在 Windows 上
|
||||
/// 跑 Linux 夹具时会被 `USERPROFILE` 抢走主目录,夹具就不再确定。
|
||||
#[must_use]
|
||||
pub fn from_env(env: &dyn EnvSource) -> Option<Self> {
|
||||
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 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)
|
||||
.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,
|
||||
local_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<PathBuf, String>,
|
||||
images: Vec<PathBuf>,
|
||||
dirs: BTreeMap<PathBuf, Vec<PathBuf>>,
|
||||
commands: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FakeIo {
|
||||
/// 空世界(一切探测都会失败)。
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 登记一个文本文件(配置文件、`.theme`、plist、xml……)。
|
||||
pub(crate) fn with_file(mut self, path: impl AsRef<Path>, 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<Path>) -> Self {
|
||||
self.images.push(path.as_ref().to_path_buf());
|
||||
self
|
||||
}
|
||||
|
||||
/// 登记目录内容(按文件名给,路径拼在 `dir` 上)。
|
||||
pub(crate) fn with_dir(mut self, dir: impl AsRef<Path>, 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<String> {
|
||||
self.texts.get(path).cloned()
|
||||
}
|
||||
|
||||
fn read_head(&self, path: &Path, limit: usize) -> Option<Vec<u8>> {
|
||||
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<PathBuf> {
|
||||
self.dirs.get(dir).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||
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.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")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roots_prefer_explicit_appdata_and_absolute_xdg() {
|
||||
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()
|
||||
.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());
|
||||
}
|
||||
}
|
||||
@@ -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 <name> bg <path>`);
|
||||
//! 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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),
|
||||
"<property name=\"last-image\" value=\"/home/u/x.png\"/>\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),
|
||||
"<property name=\"image-path\" value=\"/home/u/x.png\"/>\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(
|
||||
"<property name=\"last-image\" value=\"/home/u/a.png\"/>\n<property name=\"image-style\" value=\"3\"/>\n"
|
||||
),
|
||||
vec!["/home/u/a.png".to_owned()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(`<string>file:///…</string>`)也可能是二进制 plist 的 lossy 文本
|
||||
/// (可读 ASCII 串之间夹着不可打印字节)。因此先按「可打印 ASCII 连续段」切,再在每段里认:
|
||||
///
|
||||
/// - 任意位置的 `file://` 开头;
|
||||
/// - 「段首 / 分隔符之后 / `~` 之后」的 `/` 开头(路径中间的 `/` 不算新起点,避免同一路径
|
||||
/// 被切成好几段)。
|
||||
#[must_use]
|
||||
pub(super) fn scan_string_paths(text: &str) -> Vec<String> {
|
||||
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<String> {
|
||||
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<usize> {
|
||||
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),
|
||||
// 闭合标签 `</string>` 里的那个斜杠不是路径。
|
||||
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 序列化里的终止字符(`<string>` 的尖括号、引号、实体)切掉。
|
||||
fn push_candidate(found: &mut Vec<String>, 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!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<plist version=\"1.0\">\n{body}\n</plist>\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_index_hit_decodes_file_url() {
|
||||
let io = FakeIo::new()
|
||||
.with_file(
|
||||
STORE_PATH,
|
||||
&xml_plist("<string>file:///Users/me/Pictures/my%20wall.heic</string>"),
|
||||
)
|
||||
.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(
|
||||
"<array>\n\
|
||||
<string>file:///System/Library/Desktop Pictures/a.heic</string>\n\
|
||||
<string>/Users/me/Library/Application Support/com.apple.wallpaper/Store/b.heic</string>\n\
|
||||
</array>",
|
||||
),
|
||||
)
|
||||
.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<SourceKind> = 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(
|
||||
"<key>Content</key>\n<string>/Users/me/a.png</string>",
|
||||
));
|
||||
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("<string>~/Library/Desktop Pictures/a.heic</string>");
|
||||
assert_eq!(found, vec!["~/Library/Desktop Pictures/a.heic".to_owned()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! 路径归一化与图片头嗅探:把各平台读出的「原始串」变成可用的绝对路径候选。
|
||||
//!
|
||||
//! 三平台读出来的东西形状都不一样(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,外加头部出现 `<svg` 的文本。
|
||||
/// 认不出的一律不收——壁纸探测宁可少一个候选,也不要把随便一个文件喂给取色器。
|
||||
#[must_use]
|
||||
pub(super) fn looks_like_image(head: &[u8]) -> 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"<svg")
|
||||
}
|
||||
|
||||
/// 把一条原始串归一化成绝对路径;不像壁纸路径的返回 `None`(具体原因由调用方记)。
|
||||
///
|
||||
/// 处理顺序:去引号 → 认 `file://` URI(含百分号解码与 `/C:/` 盘符还原)→ 展开
|
||||
/// `%USERPROFILE%` 之类的 Windows 变量 → 展开 `~/` → 要求绝对路径。
|
||||
#[must_use]
|
||||
pub(super) fn normalize_path(raw: &str, roots: &HostRoots) -> Option<PathBuf> {
|
||||
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()),
|
||||
// Win10+ 的「当前主题」`Custom.theme` 在 `%LOCALAPPDATA%` 一侧。
|
||||
"localappdata" => Some(roots.local_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"<?xml version=\"1.0\"?><svg xmlns=\"...\""
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_sniff_rejects_non_images_and_short_heads() {
|
||||
assert!(!looks_like_image(b""));
|
||||
assert!(!looks_like_image(b"BM"));
|
||||
assert!(!looks_like_image(
|
||||
b"[Control Panel\\Desktop]\r\nWallpaper=x\r\n"
|
||||
));
|
||||
assert!(!looks_like_image(b"RIFF\x24\x00\x00\x00AVI LIST"));
|
||||
assert!(!looks_like_image(b"\x00\x00\x00\x18ftypmp42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_handles_file_uris_quotes_and_percent_encoding() {
|
||||
let roots = roots();
|
||||
assert_eq!(
|
||||
normalize_path("'file:///home/u/Pictures/my%20wall.png'", &roots),
|
||||
Some(PathBuf::from("/home/u/Pictures/my wall.png"))
|
||||
);
|
||||
// Windows 的 URI 会多一个前导斜杠,盘符要还原。
|
||||
assert_eq!(
|
||||
normalize_path("file:///C:/Users/u/a.jpg", &roots),
|
||||
Some(PathBuf::from("C:/Users/u/a.jpg"))
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_path("\"C:\\Users\\u\\a.jpg\"", &roots),
|
||||
Some(PathBuf::from("C:\\Users\\u\\a.jpg"))
|
||||
);
|
||||
// 非法百分号转义原样保留,不 panic(多字节字符也要安全)。
|
||||
assert_eq!(
|
||||
normalize_path("file:///home/u/100%zz%日.png", &roots),
|
||||
Some(PathBuf::from("/home/u/100%zz%日.png"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_expands_windows_vars_and_home() {
|
||||
let roots = roots();
|
||||
assert_eq!(
|
||||
normalize_path(r"%SystemRoot%\web\wallpaper\a.jpg", &roots),
|
||||
Some(PathBuf::from("C:/Windows\\web\\wallpaper\\a.jpg"))
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_path(
|
||||
"%APPDATA%\\Microsoft\\Windows\\Themes\\TranscodedWallpaper",
|
||||
&roots
|
||||
),
|
||||
Some(PathBuf::from(
|
||||
"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"))
|
||||
);
|
||||
// 认不出的变量不猜:整串保留,随后被「必须绝对路径」这条校验判掉。
|
||||
assert_eq!(normalize_path("%NOPE%\\a.jpg", &roots), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_placeholders_relative_and_empty() {
|
||||
let roots = roots();
|
||||
for raw in [
|
||||
"",
|
||||
" ",
|
||||
"none",
|
||||
"'none'",
|
||||
"default",
|
||||
"relative/a.png",
|
||||
"./a.png",
|
||||
] {
|
||||
assert_eq!(
|
||||
normalize_path(raw, &roots),
|
||||
None,
|
||||
"{raw:?} 不该被当成壁纸路径"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_detection_is_host_independent() {
|
||||
assert!(looks_absolute("/home/u/a.png"));
|
||||
assert!(looks_absolute(r"\\server\share\a.png"));
|
||||
assert!(looks_absolute("C:/Users/u/a.png"));
|
||||
assert!(looks_absolute(r"C:\Users\u\a.png"));
|
||||
assert!(!looks_absolute("a.png"));
|
||||
assert!(!looks_absolute("C:"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! 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. `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::{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"];
|
||||
|
||||
/// 转码副本的两个已知文件名(无扩展名的是主力)。
|
||||
const TRANSCODED_NAMES: &[&str] = &["TranscodedWallpaper", "TranscodedWallpaper.jpg"];
|
||||
|
||||
/// 主题文件后缀。
|
||||
const THEME_EXTENSION: &str = "theme";
|
||||
|
||||
/// 四步依次探测。
|
||||
pub(super) fn collect(collect: &mut Collect<'_>) {
|
||||
transcoded(collect);
|
||||
current_theme(collect);
|
||||
registry(collect);
|
||||
saved_theme_packages(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 第二步:当前生效主题(`CurrentTheme` 指向的 `.theme`)里的 `Wallpaper=`。
|
||||
fn current_theme(collect: &mut Collect<'_>) {
|
||||
let Some(raw) = query_reg(
|
||||
collect,
|
||||
SourceKind::WindowsCurrentTheme,
|
||||
THEMES_KEY,
|
||||
CURRENT_THEME_VALUE,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let Some(theme) = normalize_path(&raw, collect.roots()) else {
|
||||
collect.note(
|
||||
SourceKind::WindowsCurrentTheme.as_str(),
|
||||
format!("{CURRENT_THEME_VALUE} 不是可用的绝对路径({raw})"),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let label = format!(
|
||||
"reg query {THEMES_KEY} /v {CURRENT_THEME_VALUE} → {}",
|
||||
theme.display()
|
||||
);
|
||||
collect_theme_file(collect, SourceKind::WindowsCurrentTheme, &theme, &label);
|
||||
}
|
||||
|
||||
/// 第三步:注册表里的权威壁纸值。
|
||||
fn registry(collect: &mut Collect<'_>) {
|
||||
let Some(value) = query_reg(
|
||||
collect,
|
||||
SourceKind::WindowsRegistry,
|
||||
DESKTOP_KEY,
|
||||
WALLPAPER_VALUE,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let label = format!("reg query {DESKTOP_KEY} /v {WALLPAPER_VALUE}");
|
||||
collect.add(SourceKind::WindowsRegistry, &label, &value);
|
||||
}
|
||||
|
||||
/// 第四步(兜底):`%APPDATA%\...\Themes\*.theme`——用户另存的主题包,**可能未启用**。
|
||||
fn saved_theme_packages(collect: &mut Collect<'_>) {
|
||||
let dir = themes_dir(collect.roots());
|
||||
let mut themes: Vec<PathBuf> = collect
|
||||
.io()
|
||||
.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()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
for theme in themes {
|
||||
let label = format!("{}(用户另存的主题包,可能未启用)", theme.display());
|
||||
collect_theme_file(collect, SourceKind::WindowsThemeFile, &theme, &label);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读一个 `.theme` 文件,把里面的 `Wallpaper=` 收成候选;读不到 / 没值只记原因。
|
||||
fn collect_theme_file(collect: &mut Collect<'_>, kind: SourceKind, file: &Path, label: &str) {
|
||||
let Some(text) = collect.io().read_text(file) else {
|
||||
collect.note(kind.as_str(), format!("读不到 {}", file.display()));
|
||||
return;
|
||||
};
|
||||
let values = parse_theme_wallpaper(&text);
|
||||
if values.is_empty() {
|
||||
collect.note(
|
||||
kind.as_str(),
|
||||
format!("{} 里没有 Wallpaper= 行", file.display()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let evidence = format!("{label} 的 Wallpaper=");
|
||||
for value in values {
|
||||
collect.add(kind, &evidence, &value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一次 `reg query <键> /v <值名>` 并取出值;失败或缺值都只记原因、返回 `None`。
|
||||
fn query_reg(
|
||||
collect: &mut Collect<'_>,
|
||||
kind: SourceKind,
|
||||
key: &str,
|
||||
value_name: &str,
|
||||
) -> Option<String> {
|
||||
let query = format!("reg query {key} /v {value_name}");
|
||||
match collect
|
||||
.io()
|
||||
.run("reg.exe", &["query", key, "/v", value_name])
|
||||
{
|
||||
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(
|
||||
kind.as_str(),
|
||||
format!("{query} 失败(命令缺失 / 非零退出 / 空输出)"),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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")
|
||||
}
|
||||
|
||||
fn roots() -> HostRoots {
|
||||
HostRoots::from_env(&env()).expect("HOME 已给")
|
||||
}
|
||||
|
||||
/// 夹具用**生产代码同一个拼法**造路径,所以 Windows / Linux 宿主上都能对上
|
||||
/// (另有一条字面量断言盯着这个拼法本身,见 `transcoded_wallpaper_hit_comes_first`)。
|
||||
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_eq!(
|
||||
best.path,
|
||||
PathBuf::from(
|
||||
"C:/Users/u/AppData/Roaming/Microsoft/Windows/Themes/TranscodedWallpaper"
|
||||
)
|
||||
);
|
||||
assert!(best.evidence.contains("TranscodedWallpaper"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(
|
||||
package.clone(),
|
||||
"Wallpaper=%SystemRoot%\\web\\wallpaper\\three.jpg\r\n",
|
||||
)
|
||||
.with_image(r"C:\Users\u\Pictures\one.jpg")
|
||||
.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 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
|
||||
);
|
||||
}
|
||||
|
||||
#[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 query"), "注册表失败要留原因:{notes}");
|
||||
assert!(
|
||||
notes.contains("CurrentTheme"),
|
||||
"当前主题失败要留原因:{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(
|
||||
DESKTOP_QUERY,
|
||||
&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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Windows 侧的纯解析函数:`.theme` 的 `Wallpaper=` 行与 `reg query` 输出的取值。
|
||||
//!
|
||||
//! 单独一个文件只为守住「单文件 ≤400 行」(`guards.yml` 的 file-size 闸):这里没有任何
|
||||
//! IO,全部是纯函数,跨平台编译,夹具用例覆盖在文件尾部。
|
||||
|
||||
/// 取 `.theme` 文本里所有 `Wallpaper=` 行的值(键名大小写不敏感,值去首尾空白)。
|
||||
///
|
||||
/// `.theme` 是 INI:`[Control Panel\Desktop]` 段下的 `Wallpaper=...`。`Wallpaper` 这个键名
|
||||
/// 本身不随系统语言变(Windows 只本地化少数键),所以直接按 ASCII 比对。
|
||||
pub(super) fn parse_theme_wallpaper(text: &str) -> Vec<String> {
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
if !key.trim().eq_ignore_ascii_case("wallpaper") {
|
||||
return None;
|
||||
}
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 取 `reg query <键> /v <值名>` 输出里某个值的原文。
|
||||
///
|
||||
/// 形如 ` WallPaper REG_SZ C:\Users\me\Pictures\a.jpg`;值里的空格要保留,所以
|
||||
/// 「值名 + 类型」之后的部分整段取原文(不能 `split_whitespace` 再拼,会吃掉连续空格)。
|
||||
/// 值名为 `WallPaper` 时是壁纸路径,`CurrentTheme` 时是 `.theme` 文件路径。
|
||||
pub(super) fn parse_reg_sz_value(text: &str, value_name: &str) -> Option<String> {
|
||||
text.lines().find_map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
let name_end = trimmed.find(char::is_whitespace)?;
|
||||
if !trimmed[..name_end].eq_ignore_ascii_case(value_name) {
|
||||
return None;
|
||||
}
|
||||
let after_name = trimmed[name_end..].trim_start();
|
||||
let kind_end = after_name.find(char::is_whitespace)?;
|
||||
let value = after_name[kind_end..].trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{CURRENT_THEME_VALUE, WALLPAPER_VALUE};
|
||||
use super::*;
|
||||
|
||||
const CUSTOM_THEME: &str = r"C:\Users\u\AppData\Local\Microsoft\Windows\Themes\Custom.theme";
|
||||
|
||||
#[test]
|
||||
fn theme_text_yields_only_wallpaper_values() {
|
||||
let text = "[Control Panel\\Desktop]\r\n\
|
||||
Wallpaper=C:\\Users\\u\\a.jpg\r\n\
|
||||
wallpaper = %SystemRoot%\\web\\wallpaper\\b.jpg\r\n\
|
||||
[Slideshow]\r\n\
|
||||
ImagesRootPath=C:\\Users\\u\\Pictures\r\n\
|
||||
Wallpaper=\r\n";
|
||||
assert_eq!(
|
||||
parse_theme_wallpaper(text),
|
||||
vec![
|
||||
r"C:\Users\u\a.jpg".to_owned(),
|
||||
r"%SystemRoot%\web\wallpaper\b.jpg".to_owned(),
|
||||
]
|
||||
);
|
||||
assert!(parse_theme_wallpaper("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reg_output_parses_value_and_keeps_spaces() {
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(
|
||||
"HKEY_CURRENT_USER\\Control Panel\\Desktop\r\n \
|
||||
WallPaper REG_SZ C:\\Users\\Jak e\\Desktop Pictures\\a.jpg\r\n",
|
||||
WALLPAPER_VALUE
|
||||
),
|
||||
Some(r"C:\Users\Jak e\Desktop Pictures\a.jpg".to_owned())
|
||||
);
|
||||
// 纯色壁纸 = 空值。
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(" WallPaper REG_SZ \r\n", WALLPAPER_VALUE),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_reg_sz_value("", WALLPAPER_VALUE), None);
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(" TileWallpaper REG_SZ 0\r\n", WALLPAPER_VALUE),
|
||||
None
|
||||
);
|
||||
// 当前主题:值名换成 CurrentTheme,取到的是 `.theme` 的绝对路径。
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(
|
||||
&format!(" CurrentTheme REG_SZ {CUSTOM_THEME}\r\n"),
|
||||
CURRENT_THEME_VALUE
|
||||
),
|
||||
Some(CUSTOM_THEME.to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_reg_sz_value(" CurrentTheme REG_SZ \r\n", CURRENT_THEME_VALUE),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@
|
||||
//! | tone | CIELAB `L*` | CIELAB `L*`(同一个 `Y` 轴) | 逐值一致 |
|
||||
//! | 色相 / 彩度 | CAM16 | CAM16(D65 默认观察条件) | 逐值一致(误差 < 1e-4) |
|
||||
//! | gamut 映射 | `HctSolver` | 同一套两段求解器,关键平面表原样搬运 | 逐通道一致 |
|
||||
//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同 | 逐值一致 |
|
||||
//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同(primary/secondary/tertiary/neutral-variant/error 逐值一致) | 逐值一致 |
|
||||
//! | 中性彩度 | 6(几乎无色) | 官方口径 6;**产品默认用 12**(有意偏离,见下节「出厂默认色」) | 仅此处偏离 |
|
||||
//! | 角色 tone 表 | ColorSpec2021 | 同(49 角色 × light/dark) | 逐值一致 |
|
||||
//!
|
||||
//! 对齐证据在 `tests/official_baseline.rs` 与 `tests/official_accent.rs`:期望值全部由
|
||||
@@ -50,11 +51,52 @@
|
||||
//! tertiary 三族,中性色与 error 保持基线。强调族的色值与官方 `from_seed(accent)` 逐个对得上
|
||||
//! (`tests/official_accent.rs`,8 个种子 × 12 个强调族角色 × 2 模式 = 192 项)。
|
||||
//!
|
||||
//! ## 出厂默认色(M70:浅冷天蓝)——含**两处**有意偏离(同一处偏离的两半)
|
||||
//!
|
||||
//! 产品默认不走官方紫,而是「浅冷天蓝」。默认值收在两个常量里,**换默认只改这两行**
|
||||
//! (用户 2026-11-01 拍板采用候选 **④ 天蓝**;③ 浅冷天蓝 `#87CEEB` 留作备选,值仍 pin 在
|
||||
//! `tests/skyseed_candidates.rs`):
|
||||
//!
|
||||
//! | 项 | 当前值 | 官方值 | 说明 |
|
||||
//! |---|---|---|---|
|
||||
//! | [`palettes::DEFAULT_SEED`] | `#3C8DBC`(CAM16 hue ≈ 240°) | `#6750A4`([`palettes::BASELINE_SEED`]) | 种子只贡献**色相**(CAM16 240°);种子彩度会被 Tonal Spot 丢掉 |
|
||||
//! | [`palettes::DEFAULT_NEUTRAL_CHROMA`](neutral) | `12` | `6` | 让 surface / surface-container-* 带淡蓝调 |
|
||||
//! | 联动的 neutral-variant | `14` | `8`(**绝对值**;官方不是「neutral + 2」) | `outline` / `outline-variant` / `surface-variant` 一起偏蓝(light `outline` = `#6A7985` 就是 14 彩度的口径) |
|
||||
//!
|
||||
//! **定位(D38)**:这套默认只是「**莫奈关闭时的出厂预选色卡**」(首次启动、用户还没选过色卡时
|
||||
//! 用的那一套),**不是唯一的配色来源**。用户的配色来源有三级优先:① 启动器背景图取色(Monet)
|
||||
//! → ② 桌面壁纸取色 → ③ 莫奈关闭(默认)时从内置多套色卡里选。取色与「内置色卡库」都不在本 crate,
|
||||
//! 也还没实现;本模块只负责「给定一个种子 + 中性彩度,派生出 49 个角色」。
|
||||
//!
|
||||
//! > ⚠️ **有意偏离登记(两处,同一处偏离的两半)**:
|
||||
//! >
|
||||
//! > 1. **neutral 彩度 `6 → 12`**。理由=产品要「浅冷」的冷调表面:官方 6 的表面几乎无色,单靠换
|
||||
//! > 种子做不到「浅」——primary 的 tone 被官方钉在 40,种子再浅主色也还是中蓝,所以「浅」只能
|
||||
//! > 落在中性色上。
|
||||
//! > 2. **neutral-variant 彩度 `8 → 14`**。这是第 1 条的连带结果:官方
|
||||
//! > `getNeutralVariantPalette` 写死的是**绝对值 8**(不是「neutral + 2」这种相对式),
|
||||
//! > 本实现为了让层次不倒挂,让它跟着 neutral 联动(`nv = neutral + 2`)。若把 nv 钉回 8,
|
||||
//! > 表面(12)会比中性变体(8)更「有色」,破坏官方的「neutral < neutral-variant」层次。
|
||||
//! > 影响面不只表面:`outline` / `outline-variant` / `surface-variant` 三个角色一起偏蓝。
|
||||
//! >
|
||||
//! > 除这两处外全部走官方常数(primary 36 / secondary 16 / tertiary 24 且色相 +60° /
|
||||
//! > error 25°·84),tone 表与 gamut 映射一行未动;[`Scheme::official_baseline`] 仍是官方的
|
||||
//! > 6(neutral)/ 8(neutral-variant)口径。
|
||||
//!
|
||||
//! 官方口径的锚点仍然完整保留:[`Scheme::official_baseline`](种子 `#6750A4` + 中性 6)
|
||||
//! 是 `tests/official_baseline.rs` 的对账入口,98 项仍是每通道 ≤1;
|
||||
//! `tests/skyseed_candidates.rs` 把四档候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)的色值逐个 pin 住,
|
||||
//! 换默认时它会指着要改的行。
|
||||
//!
|
||||
//! ✅ 默认色已由用户 2026-11-01 拍板:**④ 天蓝 `#3C8DBC`**。备选 ③ 浅冷蓝 `#87CEEB`
|
||||
//! (主色偏青 `#0C6780`)仍在候选表里,换回去只需改 [`palettes::DEFAULT_SEED`] 一行。
|
||||
//!
|
||||
//! ## 对比度纪律(WCAG AA)
|
||||
//!
|
||||
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
|
||||
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
|
||||
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`)。
|
||||
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`;中性彩度同理,
|
||||
//! `sky_seed_candidates_meet_wcag_aa` 逐档验证了 M70 的四档候选:4 档 × 2 模式 × 26 对 = 208 项)。
|
||||
//! - 例外(显式登记,非缺陷):
|
||||
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
|
||||
//! 目标 3:1,实测 light 4.26:1 / dark 5.89:1;
|
||||
@@ -76,7 +118,7 @@ mod space;
|
||||
mod viewing_conditions;
|
||||
|
||||
pub use hct::Hct;
|
||||
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED};
|
||||
pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED};
|
||||
pub use roles::Role;
|
||||
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
|
||||
pub use state::StateLayer;
|
||||
|
||||
@@ -8,13 +8,56 @@
|
||||
//! (色相 +60°)、neutral **6**、neutral-variant **8**、error 固定色相 **25** 彩度 **84**。
|
||||
//! 注意 primary 的 36 与种子自身的彩度无关——这是官方口径:Tonal Spot 是「镇静」变体,
|
||||
//! 再鲜艳的种子也会被收到 36,再灰的种子也会被提到 36。
|
||||
//!
|
||||
//! ## 两个入口:官方口径 vs 产品默认(M70)
|
||||
//!
|
||||
//! | 入口 | 种子 | 中性彩度 | 用途 |
|
||||
//! |---|---|---|---|
|
||||
//! | [`Palettes::official_baseline`] / [`super::Scheme::official_baseline`] | [`BASELINE_SEED`](`#6750A4`) | 6(官方) | 与官方 Java 实现逐角色对齐的**锚点**,`tests/official_baseline.rs` 用它 |
|
||||
//! | [`Palettes::baseline`] / [`super::Scheme::baseline`] | [`DEFAULT_SEED`] | [`DEFAULT_NEUTRAL_CHROMA`] | **产品出厂默认**(消费方拿到的就是它) |
|
||||
//!
|
||||
//! 换默认色只改 [`DEFAULT_SEED`] 与 [`DEFAULT_NEUTRAL_CHROMA`] 两行常量(中性彩度不动就只改一行),
|
||||
//! 然后重跑 `tests/skyseed_candidates.rs` 的 pin(它会打印新值并指出哪几行要同步)。
|
||||
//! 官方锚点不受影响——**算法没动,动的只是入参**。
|
||||
//!
|
||||
//! ⚠️ 中性彩度 6 → 12 是**有意偏离**官方 Tonal Spot(理由:产品要「浅冷」的冷调表面),
|
||||
//! 偏离登记见 [`super`] 模块文档的「出厂默认色」小节。
|
||||
|
||||
use super::tonal::TonalPalette;
|
||||
use super::Color;
|
||||
|
||||
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
|
||||
///
|
||||
/// **不是**产品默认色——产品默认见 [`DEFAULT_SEED`]。
|
||||
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
|
||||
|
||||
/// 产品出厂默认种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**。
|
||||
///
|
||||
/// `#3C8DBC`(CAM16 hue ≈ 239.5°,主色 `#236488`)。注意种子的彩度(42.6)会被 Tonal Spot
|
||||
/// 丢掉——主色板的彩度恒为 36,种子只贡献**色相**。
|
||||
///
|
||||
/// ## 定位(D38):这是「莫奈关闭时的出厂预选色卡」,不是唯一的配色来源
|
||||
///
|
||||
/// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭
|
||||
/// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里
|
||||
/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套)。取色(Monet / 壁纸 /
|
||||
/// `QuantizerCelebi`)与「内置多色卡库」都不在本 crate,也还没实现。
|
||||
pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC);
|
||||
|
||||
/// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。
|
||||
///
|
||||
/// 官方 6 让表面几乎无色;12 让 surface / surface-container-* 带一层淡蓝调,
|
||||
/// 这是「浅冷天蓝」唯一能落在表面上的手段(primary 的 tone 被官方钉在 40,
|
||||
/// 种子再浅也变不成浅蓝)。
|
||||
///
|
||||
/// **数值安全区间 `6..=13`,产品取 12**:neutral-variant 跟 neutral 联动(`nv = neutral + 2`),
|
||||
/// 13 时 nv = 15 仍低于 secondary 的 16;**14 才会撞上**(nv = 16 = secondary),届时要一并改
|
||||
/// `NEUTRAL_VARIANT_OFFSET`(私有常量)的规则。nv 的那一半偏离见 `color` 模块文档的偏离登记。
|
||||
///
|
||||
/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。将来若做「内置多套色卡」,
|
||||
/// 每套色卡各带自己的中性彩度更合适——本常量到时候会退化成「默认那套的取值」。
|
||||
pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0;
|
||||
|
||||
/// 调色板槽位(M3 的六条 tonal palette)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaletteSlot {
|
||||
@@ -24,9 +67,9 @@ pub enum PaletteSlot {
|
||||
Secondary,
|
||||
/// 第三调色板(种子色相 +60°,彩度 24)。
|
||||
Tertiary,
|
||||
/// 中性调色板(同色相,彩度 6)。
|
||||
/// 中性调色板(同色相;官方彩度 6,产品默认 12——见 [`DEFAULT_NEUTRAL_CHROMA`])。
|
||||
Neutral,
|
||||
/// 中性变体调色板(同色相,彩度 8)。
|
||||
/// 中性变体调色板(同色相,彩度 = neutral + 2;官方 8)。
|
||||
NeutralVariant,
|
||||
/// 错误调色板(固定红相 25°,彩度 84)。
|
||||
Error,
|
||||
@@ -38,10 +81,15 @@ const PRIMARY_CHROMA: f64 = 36.0;
|
||||
const SECONDARY_CHROMA: f64 = 16.0;
|
||||
/// tertiary 彩度。
|
||||
const TERTIARY_CHROMA: f64 = 24.0;
|
||||
/// neutral 彩度(几乎是灰)。
|
||||
const NEUTRAL_CHROMA: f64 = 6.0;
|
||||
/// neutral-variant 彩度。
|
||||
const NEUTRAL_VARIANT_CHROMA: f64 = 8.0;
|
||||
/// 官方 Tonal Spot 的 neutral 彩度(几乎是灰)。产品默认用 [`DEFAULT_NEUTRAL_CHROMA`]。
|
||||
const TONAL_SPOT_NEUTRAL_CHROMA: f64 = 6.0;
|
||||
/// neutral-variant 相对 neutral 的彩度偏移。
|
||||
///
|
||||
/// ⚠️ 官方 `getNeutralVariantPalette` 写死的是**绝对值 8**,**不是**「neutral + 2」这种相对式;
|
||||
/// 本实现选择让 neutral-variant 随 neutral 联动,于是产品默认(neutral 12)取到 **14**。
|
||||
/// 这是**有意偏离**的另一半(见 `color` 模块文档的偏离登记),不是官方口径——
|
||||
/// 官方的 6 / 8 由 [`Palettes::official_baseline`] 原样保留。
|
||||
const NEUTRAL_VARIANT_OFFSET: f64 = 2.0;
|
||||
/// error 的色相(HCT 红相)。
|
||||
const ERROR_HUE_DEG: f64 = 25.0;
|
||||
/// error 的彩度。
|
||||
@@ -67,15 +115,41 @@ pub struct Palettes {
|
||||
}
|
||||
|
||||
impl Palettes {
|
||||
/// 出厂基线(种子 `#6750A4`)。
|
||||
/// **产品出厂默认**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。
|
||||
///
|
||||
/// 消费方(`DesignTokens::baseline`、iced 适配层)拿到的就是这套,所以产品默认色
|
||||
/// 只需要改那两个常量。
|
||||
pub fn baseline() -> Self {
|
||||
Self::from_seed_with_neutral_chroma(DEFAULT_SEED, DEFAULT_NEUTRAL_CHROMA)
|
||||
}
|
||||
|
||||
/// **官方基线的锚点**:种子 [`BASELINE_SEED`] + 官方中性彩度 6(等价官方
|
||||
/// `SchemeTonalSpot(0xFF6750A4)`)。只给对齐测试与文档表格用。
|
||||
pub fn official_baseline() -> Self {
|
||||
Self::from_seed(BASELINE_SEED)
|
||||
}
|
||||
|
||||
/// 从一个种子色派生全套调色板(官方 Tonal Spot 变体)。
|
||||
/// 从一个种子色派生全套调色板(官方 Tonal Spot 变体,中性彩度取官方 6)。
|
||||
pub fn from_seed(seed: Color) -> Self {
|
||||
Self::from_seed_with_neutral_chroma(seed, TONAL_SPOT_NEUTRAL_CHROMA)
|
||||
}
|
||||
|
||||
/// 从一个种子色派生,并显式指定中性调色板彩度(`6.0` 即官方 Tonal Spot)。
|
||||
///
|
||||
/// ⚠️ **调用方负责传 `6.0..=13.0`(产品取 12)**。本函数**不做范围校验**,越界的表现是
|
||||
/// 静默退化而不是 panic:
|
||||
/// - `neutral_chroma < 0` 或 `NaN` → 彩度被夹到 0(`TonalPalette::new` 里 `.max(0.0)`),
|
||||
/// 得到一套纯灰的表面(surface ≈ `#F9F9F9`),**不报错**;
|
||||
/// - `neutral_chroma >= 14` → neutral-variant(= neutral + 2)够到甚至超过 secondary 的 16,
|
||||
/// 「中性 < 中性变体 < 次色」的层次被破坏(描边会明显发蓝)。
|
||||
///
|
||||
/// 除 neutral / neutral-variant 外**全部走官方常数**:primary 恒定 36、secondary 16、
|
||||
/// tertiary 24(色相 +60°)、error 固定 25°/84。所以这条入口不绕算法,只是把「官方写死在
|
||||
/// `getNeutralPalette` / `getNeutralVariantPalette` 里的那两个数」变成可调入参
|
||||
/// (neutral-variant 这里取相对式 `neutral + 2`,属**有意偏离**,见 `NEUTRAL_VARIANT_OFFSET`)。
|
||||
pub fn from_seed_with_neutral_chroma(seed: Color, neutral_chroma: f64) -> Self {
|
||||
let hue = super::Hct::from_color(seed).hue;
|
||||
Self::tonal_spot(hue)
|
||||
Self::tonal_spot(hue, neutral_chroma)
|
||||
}
|
||||
|
||||
/// 只换强调色:primary/secondary/tertiary 跟着新强调色的色相走,
|
||||
@@ -94,14 +168,14 @@ impl Palettes {
|
||||
}
|
||||
}
|
||||
|
||||
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数)。
|
||||
fn tonal_spot(hue: f64) -> Self {
|
||||
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数,neutral 可调)。
|
||||
fn tonal_spot(hue: f64, neutral_chroma: f64) -> Self {
|
||||
Self {
|
||||
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
|
||||
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
|
||||
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
|
||||
neutral: TonalPalette::new(hue, NEUTRAL_CHROMA),
|
||||
neutral_variant: TonalPalette::new(hue, NEUTRAL_VARIANT_CHROMA),
|
||||
neutral: TonalPalette::new(hue, neutral_chroma),
|
||||
neutral_variant: TonalPalette::new(hue, neutral_chroma + NEUTRAL_VARIANT_OFFSET),
|
||||
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
|
||||
}
|
||||
}
|
||||
@@ -129,8 +203,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn baseline_palettes_use_the_official_tonal_spot_chromas() {
|
||||
let palettes = Palettes::baseline();
|
||||
fn official_baseline_uses_the_official_tonal_spot_chromas() {
|
||||
let palettes = Palettes::official_baseline();
|
||||
assert_eq!(palettes.primary.chroma(), 36.0);
|
||||
assert_eq!(palettes.secondary.chroma(), 16.0);
|
||||
assert_eq!(palettes.tertiary.chroma(), 24.0);
|
||||
@@ -140,9 +214,64 @@ mod tests {
|
||||
assert_eq!(palettes.error.chroma(), 84.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_default_only_moves_the_named_knobs() {
|
||||
// 产品默认与官方锚点的差别必须**只**在种子色相与中性彩度两处:
|
||||
// primary/secondary/tertiary/error 的取法一模一样(算法没动)。
|
||||
let official = Palettes::official_baseline();
|
||||
let product = Palettes::baseline();
|
||||
assert_eq!(product.primary.chroma(), official.primary.chroma());
|
||||
assert_eq!(product.secondary.chroma(), official.secondary.chroma());
|
||||
assert_eq!(product.tertiary.chroma(), official.tertiary.chroma());
|
||||
assert_eq!(product.error, official.error);
|
||||
assert_eq!(product.neutral.chroma(), DEFAULT_NEUTRAL_CHROMA);
|
||||
assert_eq!(
|
||||
product.neutral_variant.chroma(),
|
||||
DEFAULT_NEUTRAL_CHROMA + NEUTRAL_VARIANT_OFFSET
|
||||
);
|
||||
let seed = super::super::Hct::from_color(DEFAULT_SEED);
|
||||
assert!((product.primary.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_chroma_knob_keeps_the_official_ordering() {
|
||||
// 官方关系 neutral(6) < neutral-variant(8) < secondary(16) 在安全区间里必须一直成立,
|
||||
// 否则「表面比次色还艳」或两族撞色。
|
||||
for neutral in [6.0, 8.0, 10.0, 12.0] {
|
||||
let p = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, neutral);
|
||||
assert!(p.neutral.chroma() < p.neutral_variant.chroma());
|
||||
assert!(p.neutral_variant.chroma() < p.secondary.chroma());
|
||||
// 中性彩度不影响 tone 轴:tone 98 的 surface 仍是最亮的那个。
|
||||
let surface = p.tone(PaletteSlot::Neutral, 98.0);
|
||||
assert!(surface.relative_luminance() > 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_default_neutral_chroma_is_a_registered_deviation() {
|
||||
// 官方 neutral / neutral-variant 是 6 / 8;产品默认是 12 / 14。
|
||||
// 这**不是官方口径**,是「有意偏离」(见 `color` 模块文档的偏离登记)。
|
||||
// 想把 nv 拉回官方绝对值 8 的人,请先改登记再改这里:直接钉成 8 会让表面(12)
|
||||
// 比中性变体(8)更「有色」,破坏官方的 neutral < neutral-variant 层次。
|
||||
let product = Palettes::baseline();
|
||||
assert_eq!(product.neutral.chroma(), 12.0, "产品默认 neutral 彩度");
|
||||
assert_eq!(
|
||||
product.neutral_variant.chroma(),
|
||||
14.0,
|
||||
"产品默认的 neutral-variant 彩度 14 是**有意偏离**(官方绝对值 8),改前先更新偏离登记"
|
||||
);
|
||||
let official = Palettes::official_baseline();
|
||||
assert_eq!(official.neutral.chroma(), 6.0, "官方锚点仍是 6");
|
||||
assert_eq!(
|
||||
official.neutral_variant.chroma(),
|
||||
8.0,
|
||||
"官方锚点仍是绝对值 8(不是 neutral + 2 的相对式)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_hue_comes_from_the_seed() {
|
||||
let palettes = Palettes::baseline();
|
||||
let palettes = Palettes::official_baseline();
|
||||
let seed = super::super::Hct::from_color(BASELINE_SEED);
|
||||
assert!((palettes.primary.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||
assert!((palettes.neutral.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||
@@ -168,11 +297,4 @@ mod tests {
|
||||
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
|
||||
assert_eq!(swapped.primary.chroma(), 36.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_variant_sits_between_neutral_and_secondary() {
|
||||
let palettes = Palettes::baseline();
|
||||
assert!(palettes.neutral.chroma() < palettes.neutral_variant.chroma());
|
||||
assert!(palettes.neutral_variant.chroma() < palettes.secondary.chroma());
|
||||
}
|
||||
}
|
||||
@@ -178,11 +178,21 @@ pub struct Scheme {
|
||||
}
|
||||
|
||||
impl Scheme {
|
||||
/// 出厂基线方案(种子 `#6750A4`,官方 Tonal Spot 输出)。
|
||||
/// **产品出厂默认方案**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度
|
||||
/// [`super::palettes::DEFAULT_NEUTRAL_CHROMA`],见 [`Palettes::baseline`])。
|
||||
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
||||
Self::from_palettes(&Palettes::baseline(), flavor)
|
||||
}
|
||||
|
||||
/// **官方基线锚点**(种子 `#6750A4` + 官方中性彩度 6,等价官方
|
||||
/// `SchemeTonalSpot(0xFF6750A4)`)。
|
||||
///
|
||||
/// 与 [`Scheme::baseline`] 的区别只有入参:算法、tone 表、gamut 映射完全同一条路径,
|
||||
/// 所以 `tests/official_baseline.rs` 拿它跟官方 Java 输出逐角色对账。
|
||||
pub fn official_baseline(flavor: SchemeFlavor) -> Self {
|
||||
Self::from_palettes(&Palettes::official_baseline(), flavor)
|
||||
}
|
||||
|
||||
/// 指定种子色的方案(表面色与强调色同源,等价官方 `SchemeTonalSpot`)。
|
||||
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
|
||||
Self::from_palettes(&Palettes::from_seed(seed), flavor)
|
||||
|
||||
@@ -112,7 +112,11 @@ pub struct DesignTokens {
|
||||
}
|
||||
|
||||
impl DesignTokens {
|
||||
/// 按模式给一套出厂令牌(baseline seed `#6750A4`,等价 M3 baseline 调色板)。
|
||||
/// 按模式给一套**产品默认**令牌:种子 `#3C8DBC`(M70 ④ 天蓝,用户 2026-11-01 拍板)
|
||||
/// + 中性彩度 12,即 `color::palettes::DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA`。
|
||||
///
|
||||
/// 想要**官方 M3 baseline** 口径(种子 `#6750A4` + 中性彩度 6 / neutral-variant 8)请走
|
||||
/// `color::Scheme::official_baseline(flavor)`——两条路径的算法完全相同,只差入参。
|
||||
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
||||
Self {
|
||||
flavor,
|
||||
|
||||
@@ -157,6 +157,62 @@ fn baseline_light_and_dark_both_pass() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sky_seed_candidates_meet_wcag_aa() {
|
||||
// M70:四档默认色候选(种子 + 中性彩度)必须过同一条 26 组门禁。
|
||||
// 中性彩度改变只影响色相/彩度,不影响 tone 轴,所以理论上恒过——这里就是要**证明**它恒过,
|
||||
// 而不是相信它。任何一档不达标,该档直接判为不可用(不许为了好看放宽阈值)。
|
||||
let candidates = [
|
||||
("① 保守蓝", "#2196F3", 6.0_f64),
|
||||
("② 冷调蓝", "#2196F3", 12.0),
|
||||
("③ 浅冷蓝", "#87CEEB", 12.0),
|
||||
("④ 天蓝", "#3C8DBC", 12.0),
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
let mut worst = (f32::MAX, String::new());
|
||||
let mut checked = 0;
|
||||
for (title, seed_hex, neutral_chroma) in candidates {
|
||||
let seed = Color::from_hex(seed_hex).expect("候选种子");
|
||||
let palettes =
|
||||
unlucky_design::color::Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma);
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::from_palettes(&palettes, flavor);
|
||||
for (fg, bg) in TEXT_PAIRS {
|
||||
let ratio = scheme.contrast(fg, bg);
|
||||
checked += 1;
|
||||
if ratio < worst.0 {
|
||||
worst = (
|
||||
ratio,
|
||||
format!(
|
||||
"{title}/{} 的 {} on {}",
|
||||
flavor.name(),
|
||||
fg.name(),
|
||||
bg.name()
|
||||
),
|
||||
);
|
||||
}
|
||||
if ratio < AA_TEXT {
|
||||
failures.push(format!(
|
||||
"{title}({seed_hex} / 中性 {neutral_chroma})/ {} / {} on {} = {ratio:.2}",
|
||||
flavor.name(),
|
||||
fg.name(),
|
||||
bg.name()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"候选 WCAG:{checked} 项,最紧的一对 {} = {:.2}:1(阈值 {AA_TEXT})",
|
||||
worst.1, worst.0
|
||||
);
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"以下候选未达 WCAG AA(4.5:1),该档不可用:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_override_cannot_break_contrast() {
|
||||
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
|
||||
|
||||
@@ -94,8 +94,9 @@ fn worst_channel(got: Color, want: Color) -> i16 {
|
||||
|
||||
#[test]
|
||||
fn baseline_matches_official_on_all_forty_nine_roles() {
|
||||
let light = Scheme::baseline(SchemeFlavor::Light);
|
||||
let dark = Scheme::baseline(SchemeFlavor::Dark);
|
||||
// 注意:对齐的是**官方锚点**(种子 #6750A4 + 中性彩度 6),不是产品默认(M70 起是浅冷蓝)。
|
||||
let light = Scheme::official_baseline(SchemeFlavor::Light);
|
||||
let dark = Scheme::official_baseline(SchemeFlavor::Dark);
|
||||
let mut worst = 0;
|
||||
let mut off_by_one = 0;
|
||||
let mut failures = Vec::new();
|
||||
@@ -146,7 +147,7 @@ fn hct_solver_matches_official_color_spec_2021() {
|
||||
|
||||
#[test]
|
||||
fn baseline_primary_is_not_the_seed_and_that_is_official() {
|
||||
// 常见误解:baseline() 的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把
|
||||
// 常见误解:官方基线的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把
|
||||
// primary 的彩度固定成 36,而 #6750A4 的 HCT 彩度是 47.86,所以 primary 被收到 #65558F。
|
||||
// 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。
|
||||
let seed = Hct::from_color(BASELINE_SEED);
|
||||
@@ -155,7 +156,7 @@ fn baseline_primary_is_not_the_seed_and_that_is_official() {
|
||||
"种子彩度 {}",
|
||||
seed.chroma
|
||||
);
|
||||
let primary = Scheme::baseline(SchemeFlavor::Light).get(Role::Primary);
|
||||
let primary = Scheme::official_baseline(SchemeFlavor::Light).get(Role::Primary);
|
||||
assert_eq!(primary.to_hex(), "#65558F");
|
||||
assert_ne!(primary, BASELINE_SEED);
|
||||
}
|
||||
@@ -164,7 +165,7 @@ fn baseline_primary_is_not_the_seed_and_that_is_official() {
|
||||
fn baseline_tone_axis_is_still_exact_lstar() {
|
||||
// gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = Scheme::baseline(flavor);
|
||||
let scheme = Scheme::official_baseline(flavor);
|
||||
for (role, color) in scheme.iter() {
|
||||
let tone = Scheme::tone_of(role, flavor);
|
||||
let got = f64::from(color.lstar());
|
||||
@@ -182,7 +183,7 @@ fn baseline_tone_axis_is_still_exact_lstar() {
|
||||
fn same_family_containers_are_separated_by_hct_chroma() {
|
||||
// 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。
|
||||
// 现在两者彩度不同(36 vs 16),在 tone 90 上都不会被色域裁到同一个上限。
|
||||
let light = Scheme::baseline(SchemeFlavor::Light);
|
||||
let light = Scheme::official_baseline(SchemeFlavor::Light);
|
||||
let primary = light.get(Role::PrimaryContainer);
|
||||
let secondary = light.get(Role::SecondaryContainer);
|
||||
assert_ne!(primary, secondary);
|
||||
@@ -192,6 +193,6 @@ fn same_family_containers_are_separated_by_hct_chroma() {
|
||||
c_secondary < c_primary,
|
||||
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
|
||||
);
|
||||
// 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅紫」而不是被裁到灰。
|
||||
// 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅色」而不是被裁到灰。
|
||||
assert!(c_primary > 12.0, "primary-container 彩度只剩 {c_primary}");
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)——四档的**唯一真源**与 pin。
|
||||
//!
|
||||
//! ## 这档候选到底改了什么
|
||||
//!
|
||||
//! 四档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021`
|
||||
//! tone 表 → `HctSolver` gamut 映射),差别只有两个入参:
|
||||
//!
|
||||
//! | 档 | 种子 | 种子 HCT 色相 | 中性调色板彩度 | 表面 | 主色观感 |
|
||||
//! |---|---|---|---|---|---|
|
||||
//! | ① 保守蓝 | `#2196F3` | 253.7° | 6(官方) | 近白/近黑,几乎无色 | 钢蓝 |
|
||||
//! | ② 冷调蓝 | `#2196F3` | 253.7° | 12 | 冷白/冷黑,带淡蓝调 | 钢蓝 |
|
||||
//! | ③ 浅冷蓝(备选) | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) |
|
||||
//! | ④ 天蓝(**出厂默认,用户 2026-11-01 拍板**) | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 |
|
||||
//!
|
||||
//! ③ 与 ④ 是同一件事的两种「蓝」:色相越低越偏青(teal),越高越偏钢蓝;
|
||||
//! 239.5° 正好夹在中间,主色从 ③ 的 `#0C6780` 变成 `#236488`。
|
||||
//!
|
||||
//! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」):
|
||||
//!
|
||||
//! 1. **种子彩度是废参数**:Tonal Spot 把 primary 彩度钉在 36,种子只贡献色相。
|
||||
//! 所以「低彩度天蓝种子」得到的色板与「高彩度天蓝种子」**逐像素相同**
|
||||
//! (`seed_chroma_never_leaks_into_the_primary_ramp` 在 `palettes.rs` 里也钉了这条)。
|
||||
//! ③ 与 ② 的真正差别是**色相**(226° vs 254°),不是彩度。
|
||||
//! 2. **primary 的 tone 被钉在 40**,所以四档主色都是「中蓝」,不会更浅。
|
||||
//! 「浅」只能落在中性色上——这就是②/③/④提高中性彩度的原因,也是本项目唯一一处
|
||||
//! 对官方 Tonal Spot 的**有意偏离**(登记在 `color` 模块文档的「出厂默认色」小节)。
|
||||
//!
|
||||
//! ## 换默认色要动哪里
|
||||
//!
|
||||
//! `palettes::DEFAULT_SEED` / `palettes::DEFAULT_NEUTRAL_CHROMA` 两行常量,
|
||||
//! 然后重跑本文件:pin 表会逐角色告诉你哪几个 hex 变了,照抄即可(pin 是**抄本**,不是推导)。
|
||||
|
||||
use unlucky_design::color::{
|
||||
Color, Hct, Palettes, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED,
|
||||
};
|
||||
|
||||
/// 一档候选:只有两个变量(种子 + 中性彩度),其余全是官方常数。
|
||||
struct Candidate {
|
||||
/// 短标签(色卡与 CSV 用)。
|
||||
tag: &'static str,
|
||||
/// 中文档名。
|
||||
title: &'static str,
|
||||
/// 种子 hex。
|
||||
seed_hex: &'static str,
|
||||
/// 中性调色板彩度(官方 Tonal Spot 是 6)。
|
||||
neutral_chroma: f64,
|
||||
/// 9 个展示角色的 **(角色, light, dark)** 抄本。
|
||||
pins: [(Role, &'static str, &'static str); 9],
|
||||
}
|
||||
|
||||
/// 四档候选(色卡 `DevDocs/ops/skyseed-candidates/` 用的就是这份数据)。
|
||||
const CANDIDATES: [Candidate; 4] = [
|
||||
Candidate {
|
||||
tag: "C1",
|
||||
title: "① 保守蓝(中性彩度 6 = 官方)",
|
||||
seed_hex: "#2196F3",
|
||||
neutral_chroma: 6.0,
|
||||
pins: [
|
||||
(Role::Primary, "#36618E", "#A0CAFD"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#003258"),
|
||||
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
|
||||
(Role::Secondary, "#535F70", "#BBC7DB"),
|
||||
(Role::Tertiary, "#6B5778", "#D6BEE4"),
|
||||
(Role::Surface, "#F8F9FF", "#111418"),
|
||||
(Role::SurfaceContainerLow, "#F2F3FA", "#191C20"),
|
||||
(Role::SurfaceContainerHighest, "#E1E2E8", "#32353A"),
|
||||
(Role::Outline, "#73777F", "#8D9199"),
|
||||
],
|
||||
},
|
||||
Candidate {
|
||||
tag: "C2",
|
||||
title: "② 冷调蓝(中性彩度 12)",
|
||||
seed_hex: "#2196F3",
|
||||
neutral_chroma: 12.0,
|
||||
pins: [
|
||||
(Role::Primary, "#36618E", "#A0CAFD"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#003258"),
|
||||
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
|
||||
(Role::Secondary, "#535F70", "#BBC7DB"),
|
||||
(Role::Tertiary, "#6B5778", "#D6BEE4"),
|
||||
(Role::Surface, "#F8F9FF", "#0C141E"),
|
||||
(Role::SurfaceContainerLow, "#EEF4FF", "#141C26"),
|
||||
(Role::SurfaceContainerHighest, "#DBE3F1", "#2D3540"),
|
||||
(Role::Outline, "#6D7887", "#8791A1"),
|
||||
],
|
||||
},
|
||||
Candidate {
|
||||
tag: "C3",
|
||||
title: "③ 浅冷蓝(中性彩度 12 + 偏青天蓝种子)",
|
||||
seed_hex: "#87CEEB",
|
||||
neutral_chroma: 12.0,
|
||||
pins: [
|
||||
(Role::Primary, "#0C6780", "#89D0ED"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#003544"),
|
||||
(Role::PrimaryContainer, "#BAEAFF", "#004D62"),
|
||||
(Role::Secondary, "#4C626B", "#B3CAD5"),
|
||||
(Role::Tertiary, "#5B5B7E", "#C4C3EB"),
|
||||
(Role::Surface, "#F3FAFF", "#07151B"),
|
||||
(Role::SurfaceContainerLow, "#E7F6FE", "#0F1D23"),
|
||||
(Role::SurfaceContainerHighest, "#D6E5ED", "#29373D"),
|
||||
(Role::Outline, "#687A82", "#81949C"),
|
||||
],
|
||||
},
|
||||
Candidate {
|
||||
tag: "C4",
|
||||
title: "④ 天蓝(中性彩度 12 + 夹在中间的天蓝种子)",
|
||||
seed_hex: "#3C8DBC",
|
||||
neutral_chroma: 12.0,
|
||||
pins: [
|
||||
(Role::Primary, "#236488", "#93CDF6"),
|
||||
(Role::OnPrimary, "#FFFFFF", "#00344C"),
|
||||
(Role::PrimaryContainer, "#C8E6FF", "#004C6D"),
|
||||
(Role::Secondary, "#4F616E", "#B7C9D8"),
|
||||
(Role::Tertiary, "#63597C", "#CDC0E9"),
|
||||
(Role::Surface, "#F6FAFF", "#09151C"),
|
||||
(Role::SurfaceContainerLow, "#EBF5FF", "#111D25"),
|
||||
(Role::SurfaceContainerHighest, "#D8E4EF", "#2B363F"),
|
||||
(Role::Outline, "#6A7985", "#84939F"),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/// 四档共同点:种子的 HCT 坐标(色相是候选之间**唯一**有效的差别)。
|
||||
fn seed_hct(candidate: &Candidate) -> Hct {
|
||||
Hct::from_color(Color::from_hex(candidate.seed_hex).expect("候选种子必须是合法 hex"))
|
||||
}
|
||||
|
||||
/// 按候选参数走**产品同一条**派生路径。
|
||||
fn scheme(candidate: &Candidate, flavor: SchemeFlavor) -> Scheme {
|
||||
Scheme::from_palettes(
|
||||
&Palettes::from_seed_with_neutral_chroma(
|
||||
seed_hct(candidate).to_color(),
|
||||
candidate.neutral_chroma,
|
||||
),
|
||||
flavor,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_match_their_pins() {
|
||||
let mut drift = Vec::new();
|
||||
for candidate in &CANDIDATES {
|
||||
for (role, want_light, want_dark) in candidate.pins {
|
||||
for (flavor, want_hex) in [
|
||||
(SchemeFlavor::Light, want_light),
|
||||
(SchemeFlavor::Dark, want_dark),
|
||||
] {
|
||||
let got = scheme(candidate, flavor).get(role).to_hex();
|
||||
if got != want_hex {
|
||||
drift.push(format!(
|
||||
"{}{} / {}/{} 实测 {got},pin {want_hex}",
|
||||
candidate.tag,
|
||||
if flavor.is_dark() { "-dark" } else { "" },
|
||||
candidate.title,
|
||||
role.name()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
drift.is_empty(),
|
||||
"候选色值漂了(换默认色后请照抄下面这些 hex 到 pin 表):\n{}",
|
||||
drift.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_only_move_the_two_declared_inputs() {
|
||||
// 「不绕算法」的证据:四档的调色板彩度与官方 Tonal Spot 逐项一致,
|
||||
// 唯一的差别是中性彩度的入参(以及种子色相)。
|
||||
let official = Palettes::official_baseline();
|
||||
for candidate in &CANDIDATES {
|
||||
let p = Palettes::from_seed_with_neutral_chroma(
|
||||
seed_hct(candidate).to_color(),
|
||||
candidate.neutral_chroma,
|
||||
);
|
||||
assert_eq!(p.primary.chroma(), official.primary.chroma());
|
||||
assert_eq!(p.secondary.chroma(), official.secondary.chroma());
|
||||
assert_eq!(p.tertiary.chroma(), official.tertiary.chroma());
|
||||
assert_eq!(p.error, official.error, "error 族不该被候选影响");
|
||||
assert_eq!(p.neutral.chroma(), candidate.neutral_chroma);
|
||||
assert!(
|
||||
(p.tertiary.hue_deg() - (p.primary.hue_deg() + 60.0)).abs() < 1.0e-9,
|
||||
"{} 的 tertiary 不再是 +60°",
|
||||
candidate.tag
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_candidates_are_actually_distinguishable() {
|
||||
// 「四档不像同一档」的机器判据。注意 light 的 surface(tone 98)在 8 bit 下已经贴顶,
|
||||
// 中性彩度 6→12 会量化到同一个 #F8F9FF——所以 ①② 的判据落在 container 系与 dark 模式上,
|
||||
// 这也是色卡上 ①② 的差别主要体现在卡片/浮层与深色模式的原因。
|
||||
let light = |c: &Candidate| scheme(c, SchemeFlavor::Light);
|
||||
let dark = |c: &Candidate| scheme(c, SchemeFlavor::Dark);
|
||||
let (c1, c2, c3, c4) = (
|
||||
light(&CANDIDATES[0]),
|
||||
light(&CANDIDATES[1]),
|
||||
light(&CANDIDATES[2]),
|
||||
light(&CANDIDATES[3]),
|
||||
);
|
||||
for role in [Role::SurfaceContainerLow, Role::SurfaceContainerHighest] {
|
||||
assert_ne!(
|
||||
c1.get(role),
|
||||
c2.get(role),
|
||||
"①与②的 {} 相同:中性彩度没起作用",
|
||||
role.name()
|
||||
);
|
||||
assert_ne!(c2.get(role), c3.get(role), "②与③的 {} 相同", role.name());
|
||||
// ③④ 的中性彩度相同,差别只允许来自色相
|
||||
assert_ne!(c3.get(role), c4.get(role), "③与④的 {} 相同", role.name());
|
||||
}
|
||||
assert_ne!(
|
||||
c3.get(Role::Primary),
|
||||
c4.get(Role::Primary),
|
||||
"③与④的主色必须真的不同(否则第 4 档白加)"
|
||||
);
|
||||
assert_ne!(
|
||||
dark(&CANDIDATES[0]).get(Role::Surface),
|
||||
dark(&CANDIDATES[1]).get(Role::Surface),
|
||||
"深色模式的 surface 应当能看出中性彩度差别"
|
||||
);
|
||||
let hue_gap = |a: &Candidate, b: &Candidate| {
|
||||
let (x, y) = (seed_hct(a).hue, seed_hct(b).hue);
|
||||
(x - y).abs().min(360.0 - (x - y).abs())
|
||||
};
|
||||
assert!(
|
||||
hue_gap(&CANDIDATES[0], &CANDIDATES[1]) < 0.01,
|
||||
"①② 应当只差中性彩度"
|
||||
);
|
||||
// ③ 偏青 / ④ 居中的分界靠色相:②→③ 与 ③→④ 都要肉眼可分的量级。
|
||||
for (i, j) in [(1, 2), (2, 3)] {
|
||||
assert!(
|
||||
(10.0..=40.0).contains(&hue_gap(&CANDIDATES[i], &CANDIDATES[j])),
|
||||
"{}→{} 的色相差应当明显(10°~40°),实测 {}",
|
||||
CANDIDATES[i].tag,
|
||||
CANDIDATES[j].tag,
|
||||
hue_gap(&CANDIDATES[i], &CANDIDATES[j])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_default_is_the_adopted_candidate() {
|
||||
// 用户 2026-11-01 拍板采用 **④ 天蓝**。这条用例就是「默认值 ↔ 候选表」的绑定器:
|
||||
// 谁改了 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 却没同步这里(或反过来),它会立刻红。
|
||||
// 断言一律比 **hex / 数值**,这样失败信息里直接能读到「期望 ④、实测几」。
|
||||
let adopted = &CANDIDATES[3];
|
||||
assert_eq!(
|
||||
DEFAULT_SEED.to_hex(),
|
||||
"#3C8DBC",
|
||||
"DEFAULT_SEED 必须仍是已采用的 ④ 天蓝:期望 #3C8DBC(④),实测 {}({})",
|
||||
DEFAULT_SEED.to_hex(),
|
||||
if DEFAULT_SEED.to_hex() == CANDIDATES[2].seed_hex {
|
||||
"③ 浅冷蓝——要改回 ③ 请连偏离登记与 pin 一起改"
|
||||
} else {
|
||||
"不是候选表里的任何一个种子"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
DEFAULT_SEED.to_hex(),
|
||||
adopted.seed_hex,
|
||||
"DEFAULT_SEED({})与已采用档 {} 的种子({})不一致",
|
||||
DEFAULT_SEED.to_hex(),
|
||||
adopted.tag,
|
||||
adopted.seed_hex
|
||||
);
|
||||
assert_eq!(
|
||||
DEFAULT_NEUTRAL_CHROMA, 12.0,
|
||||
"DEFAULT_NEUTRAL_CHROMA 必须仍是已采用的 12,实测 {}",
|
||||
DEFAULT_NEUTRAL_CHROMA
|
||||
);
|
||||
assert_eq!(
|
||||
DEFAULT_NEUTRAL_CHROMA, adopted.neutral_chroma,
|
||||
"DEFAULT_NEUTRAL_CHROMA({})与已采用档 {} 的中性彩度({})不一致",
|
||||
DEFAULT_NEUTRAL_CHROMA, adopted.tag, adopted.neutral_chroma
|
||||
);
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let product = Scheme::baseline(flavor);
|
||||
let expected = scheme(adopted, flavor);
|
||||
for (role, got) in product.iter() {
|
||||
assert_eq!(
|
||||
got.to_hex(),
|
||||
expected.get(role).to_hex(),
|
||||
"产品默认与已采用档 {} 的 {}({} 模式)不一致",
|
||||
adopted.tag,
|
||||
role.name(),
|
||||
flavor.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_tint_stays_a_tint() {
|
||||
// 门禁:中性彩度提高是「淡蓝调」不是「把窗口染成蓝纸」。surface 的 HCT 彩度必须
|
||||
// 明显低于 primary 的 36,且 surface 的 Y 仍 > 0.85(浅色模式还是浅底)。
|
||||
for candidate in &CANDIDATES {
|
||||
let light = scheme(candidate, SchemeFlavor::Light);
|
||||
let surface = light.get(Role::Surface);
|
||||
let chroma = Hct::from_color(surface).chroma;
|
||||
assert!(
|
||||
chroma <= 1.2 * candidate.neutral_chroma,
|
||||
"{} 的 surface 彩度 {chroma} 超过中性彩度入参的两倍",
|
||||
candidate.tag
|
||||
);
|
||||
assert!(surface.relative_luminance() > 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dump_candidates_for_the_color_card() {
|
||||
// 色卡脚本读的就是这两行格式(`SEED|` / `ROLE|`)。跑法:
|
||||
// cargo test -p unlucky-design --test skyseed_candidates -- --nocapture
|
||||
for candidate in &CANDIDATES {
|
||||
let hct = seed_hct(candidate);
|
||||
println!(
|
||||
"SEED|{}|{}|{}|{}|hue={:.2}|chroma={:.2}",
|
||||
candidate.tag,
|
||||
candidate.title,
|
||||
candidate.seed_hex,
|
||||
candidate.neutral_chroma,
|
||||
hct.hue,
|
||||
hct.chroma
|
||||
);
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let s = scheme(candidate, flavor);
|
||||
for role in Role::ALL {
|
||||
println!(
|
||||
"ROLE|{}|{}|{}|{}",
|
||||
candidate.tag,
|
||||
flavor.name(),
|
||||
role.name(),
|
||||
s.get(role).to_hex().trim_start_matches('#')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user