Files
Unlucky-Launcher/crates/presenter/src/settings_bridge.rs
T
AskaEth 1cb36da1e2 docs(presenter): M116 r4 P3 —— settings_bridge 的「三个键」漂移对齐到四个字段
AppearanceBase 自 M116 起有四个字段(整窗 α / 标题栏三态 / 配色来源 / 窗口圆角),
load_base 的文档与测试名还写着「三个键」。只改注释与测试函数名,行为零改动。
2026-11-05 06:35:19 +08:00

353 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 设置持久化桥:把 UI 偏好落到 `core::setting` 的 schema 文件里,并支持**外部热改热切**。
//!
//! 三条纪律(`DevDocs/SubAgentsTalk/app-DTO通道约定.md` §6 / AGENTS §6):
//!
//! 1. **不另立配置文件**:读写的就是 `ull config set/get` 与 AI `set_config` 用的那一份
//! `<data root>/config/launcher.json`(`core::platform::data_dir` 解析数据根、`layout` 给路径);
//! 2. **不自己拼 JSON**:一律过 `core::setting::store::SettingStore`(schema 校验 + 原子写);
//! 3. **不 import 领域类型**:跨边界只看「键名 + 取值」,领域模型留在 core。
//!
//! 热切:文件 `mtime` 变了就整份重读(AI / CLI 改完下一拍即生效),见 [`SettingsBridge::reload_if_changed`]。
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, Resolver};
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::appearance::Appearance;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::prefs::{AppearanceBase, AppearancePrefs, ThemeMode};
/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
"core.data_custom_root",
"core.launcher_home",
"core.game_home",
];
/// 设置文件桥(一个进程一份)。
#[derive(Debug)]
pub struct SettingsBridge {
/// `config/launcher.json`;`None` = 数据根解析不出来(内存态运行,不落盘)
pub(super) path: Option<PathBuf>,
/// 设置文档(schema 校验 + 原子写的唯一入口)
pub(super) store: Option<SettingStore>,
/// 上次读/写之后的文件 `mtime`(热切探测用)
pub(super) last_mtime: Option<SystemTime>,
}
impl SettingsBridge {
/// 按三模式数据根打开真实配置文件(启动时调用一次)。
#[must_use]
pub fn open() -> Self {
match resolve_config_path() {
Some(path) => Self::with_path(path),
None => Self::memory_only(),
}
}
/// 指定路径打开(测试与特殊数据根用)。
#[must_use]
pub fn with_path(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let store = SettingStore::load(&path, launcher_schema());
let last_mtime = mtime(&path);
Self {
path: Some(path),
store: Some(store),
last_mtime,
}
}
/// 不落盘(数据根解析失败 / 单测)。
#[must_use]
pub fn memory_only() -> Self {
Self {
path: None,
store: None,
last_mtime: None,
}
}
/// 配置文件路径(诊断/日志用)。
#[must_use]
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
/// 当前生效的外观配置与主题模式。
#[must_use]
pub fn load(&self) -> (AppearancePrefs, ThemeMode) {
let Some(store) = &self.store else {
return (AppearancePrefs::factory(), ThemeMode::Auto);
};
let appearance = Appearance::from_store(store);
let theme = ThemeMode::from_key_value(&appearance.theme_mode);
(
AppearancePrefs {
bg_image_enabled: appearance.bg_image_enabled,
bg_image: appearance.bg_image,
bg_fill: appearance.bg_fill,
bg_dim: appearance.bg_dim as f32,
panel_alpha: appearance.panel_alpha as f32,
glass: appearance.glass,
accent_color: appearance.accent_color,
},
theme,
)
}
/// 外观**下层**的只读快照(D36·D37·D38 · M116):整窗 α / 标题栏三态 / 配色来源档 /
/// 窗口圆角策略(`AppearanceBase` 的**四个**字段)。
///
/// 为什么不并进 [`SettingsBridge::load`] 的返回值:那个结构体(`AppearancePrefs`)是
/// **UI 写入用**的(每个字段都会被 `persist` 回写),而这**四个**键**只读不写**——
/// 落盘走 core 的 `Appearance::write`。并进去会让每个 `AppearancePrefs` 构造点都要补字段,
/// 还会让「这四项只读」这件事在类型上消失。
///
/// UI 侧(`crates/app-iced`)拿它读启动值:`app-iced` 不直接依赖 `unlucky-core`,
/// 所以「把 core 的 `SettingDocument` 翻译成只读快照」这一步必须留在本 crate。
#[must_use]
pub fn load_base(&self) -> AppearanceBase {
match &self.store {
Some(store) => AppearanceBase::from_document(store.doc()),
None => AppearanceBase::factory(),
}
}
/// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) {
let Some(store) = self.store.as_mut() else {
return;
};
let mut snapshot = Appearance::from_store(store);
snapshot.theme_mode = theme.as_str().to_owned();
snapshot.bg_image_enabled = appearance.bg_image_enabled;
snapshot.bg_image = appearance.bg_image.clone();
snapshot.bg_fill = appearance.bg_fill;
snapshot.bg_dim = f64::from(appearance.bg_dim);
snapshot.panel_alpha = f64::from(appearance.panel_alpha);
snapshot.glass = appearance.glass;
snapshot.accent_color = appearance.accent_color.clone();
if snapshot.write(store).is_err() {
return;
}
let _ = store.save();
if let Some(path) = self.path() {
self.last_mtime = mtime(path);
}
}
/// 文件被外部(AI / `ull config set`)改过就重读;返回新的生效值。
///
/// `mtime` 采样不到(某些文件系统精度/权限)时返回 `None`,退化为「只在启动时读一次」。
pub fn reload_if_changed(&mut self) -> Option<(AppearancePrefs, ThemeMode)> {
let path = self.path.clone()?;
let current = mtime(&path);
if current.is_none() || current == self.last_mtime {
return None;
}
self.last_mtime = current;
let store = self.store.as_mut()?;
store.reload();
Some(self.load())
}
}
/// 数据根解析 → `config/launcher.json`。
///
/// 与 CLI 同一套语义:先读便携目录那份 `launcher.json` 的 `core.*`(数据根决定键),
/// 再交 `Resolver` 解析(含 `redirect.json` 跟随)。任何一步失败都退回内存态。
fn resolve_config_path() -> Option<PathBuf> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf));
let exe_dir = exe_dir?;
let request = request_from_portable(&exe_dir);
let resolver = Resolver::new(Platform::current(), Some(exe_dir.clone()));
match resolver.resolve_default(&request) {
Ok(resolved) => Some(resolved.layout().launcher_config()),
// 连 exe 目录都解析不了(极端情况):仍旧用便携落点,至少设置能存下来
Err(_) => Some(LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()),
}
}
/// 便携目录那份 `launcher.json` → 三模式请求(只读 `core.*` 四个键)。
fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
let mut request = DataDirRequest::default();
let path = portable_config_path(exe_dir);
if !path.is_file() {
return request;
}
let store = SettingStore::load(&path, launcher_schema());
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) {
request.mode = mode;
}
if let Some(root) = text(DATA_ROOT_KEYS[1]) {
request.custom_root = Some(PathBuf::from(root));
}
if let Some(home) = text(DATA_ROOT_KEYS[2]) {
request.launcher_home = Some(PathBuf::from(home));
}
if let Some(home) = text(DATA_ROOT_KEYS[3]) {
request.game_home = Some(PathBuf::from(home));
}
request
}
/// `<exe 目录>/.ULL/config/launcher.json`。
fn portable_config_path(exe_dir: &Path) -> PathBuf {
LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()
}
fn mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::prefs::PaletteSource;
use unlucky_core::setting::appearance::BgFill;
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-bridge-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
path
}
#[test]
fn factory_defaults_when_file_missing() {
let path = temp_config("factory");
let bridge = SettingsBridge::with_path(&path);
let (appearance, theme) = bridge.load();
assert_eq!(appearance, AppearancePrefs::factory());
assert_eq!(theme, ThemeMode::Auto);
assert!(!path.exists(), "只读不写:缺文件不该被创建");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn persist_then_reload_round_trips() {
let path = temp_config("roundtrip");
let mut bridge = SettingsBridge::with_path(&path);
let mut appearance = AppearancePrefs::factory();
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/p.png".to_owned();
appearance.bg_fill = BgFill::Contain;
appearance.bg_dim = 0.5;
appearance.panel_alpha = 0.8;
appearance.glass = true;
assert!(appearance.set_accent("#0f6cbd"));
bridge.persist(&appearance, ThemeMode::Dark);
let reloaded = SettingsBridge::with_path(&path);
let (value, theme) = reloaded.load();
assert_eq!(value, appearance);
assert_eq!(theme, ThemeMode::Dark);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn external_edit_is_picked_up_by_reload() {
let path = temp_config("hotreload");
let mut bridge = SettingsBridge::with_path(&path);
// 先落一份(建立 mtime 基线)
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
assert!(bridge.reload_if_changed().is_none(), "自己写完不该报告变更");
// 模拟外部写入(AI / `ull config set`):改主题 + 打开背景图
let mut external = SettingsBridge::with_path(&path);
let mut appearance = AppearancePrefs::factory();
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/x.png".to_owned();
external.persist(&appearance, ThemeMode::Dark);
// mtime 精度:有的文件系统 1s 粒度,这里显式等到时间戳变化
for _ in 0..50 {
if let Some((value, theme)) = bridge.reload_if_changed() {
assert_eq!(theme, ThemeMode::Dark);
assert!(value.bg_image_enabled);
assert_eq!(value.bg_image, "D:/wall/x.png");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("外部修改没有被 mtime 热切捕获");
}
#[test]
fn accent_preset_and_validation() {
let mut appearance = AppearancePrefs::factory();
assert_eq!(appearance.accent_color, "");
assert!(appearance.set_accent_preset(1));
assert_eq!(appearance.accent_color, "#0F6CBD");
assert!(!appearance.set_accent("not-a-color"), "非法输入不改现状");
assert_eq!(appearance.accent_color, "#0F6CBD");
assert!(appearance.set_accent(""), "空串恢复出厂");
assert_eq!(appearance.accent_color, "");
assert!(!appearance.set_accent_preset(99));
}
#[test]
fn bg_active_needs_both_switch_and_path() {
let mut appearance = AppearancePrefs::factory();
appearance.bg_image = "D:/wall/p.png".to_owned();
assert!(!appearance.bg_active(), "开关没开不算生效");
appearance.bg_image_enabled = true;
assert!(appearance.bg_active());
appearance.bg_image.clear();
assert!(!appearance.bg_active(), "路径空也不算生效");
}
/// `load_base()` 是「重启后读回整窗 α / 配色来源 / 窗口圆角」的唯一通道(M93 · M116)。
///
/// 抓得住:①文件里写着 `window_opacity=0.6` / `palette_source=wallpaper` 时能被**新开的**
/// bridge 读到(= 重启语义);②缺键时回出厂(1.0 / preset),而不是 `0.0` 或随机档。
/// 抓不住:UI 侧是否真的用它来下发窗口 α(那由 `app-iced` 的日志与单测证明)。
#[test]
fn load_base_reads_the_four_read_only_keys_and_defaults_when_missing() {
let path = temp_config("base");
// 文件由「外部写者」(CLI / AI / 设置页的 SetSetting)产生,这里直接落 JSON。
std::fs::write(
&path,
r#"{"appearance":{"theme_mode":"dark","window_opacity":0.6,"palette_source":"wallpaper","panel_alpha":0.5}}"#,
)
.expect("写配置文件");
let bridge = SettingsBridge::with_path(&path);
let base = bridge.load_base();
assert_eq!(base.window_opacity, 0.6, "重启后读回整窗 α");
assert_eq!(base.palette_source, PaletteSource::Wallpaper);
assert!(base.is_transparent());
// 缺键 → 出厂值(与 `AppearanceBase::factory()` 逐字段相等)。
let empty = path.with_file_name("launcher-empty.json");
std::fs::write(&empty, "{}").expect("写空配置");
let factory = SettingsBridge::with_path(&empty).load_base();
assert_eq!(factory, AppearanceBase::factory());
assert_eq!(factory.window_opacity, 1.0);
assert_eq!(factory.palette_source, PaletteSource::Preset);
// 数据根解析不出来的内存态桥也要给得出出厂值(不 panic、不返回 0)。
assert_eq!(
SettingsBridge::memory_only().load_base(),
AppearanceBase::factory()
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
}