Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
//! 「外观(高级)」配置簇的读写(D17 · `09-ui-layout.md` §2 G6 + §111 约束)。
//!
//! 为什么放在 core:主题令牌的**取值与校验**是配置事实,视觉实现(Slint 侧)只消费结果。
//! 分层纪律要求 core 零 UI 依赖——本文件只认 `appearance.*` 这些键与它们的取值形状,
//! 不认任何像素/控件([`super::keys`] 登记同一份键表,两边由 `tests/setting/appearance_keys.rs` 固定)。
//!
//! 设计约束(09 §111):一切高级特性**默认关闭**(出厂 Metro 纯色);全部走 D6 schema
//! (AI 可写白名单内,可被 agent 调节)。因此默认值必须等于「什么都没开」的出厂态:
//! `panel_alpha = 1.0`、`glass = false`、`bg_image_enabled = false`、`accent` 空串(= 出厂 accent)。
use serde_json::Value;
use super::document::SettingDocument;
use super::store::SettingStore;
/// `appearance.bg_fill` 的允许值(09 §101:cover / contain / tile)。**顺序即 UI 分段控件的顺序**。
pub const BG_FILLS: &[&str] = &["cover", "contain", "tile"];
/// 出厂强调色(Metro 蓝,D14);`appearance.accent_color` 为空串时用它。
pub const DEFAULT_ACCENT: &str = "#0078D4";
/// `appearance.panel_alpha` 下限:再低前景就压不住背景图(09 §111「对比度检查提示」的硬底)。
pub const PANEL_ALPHA_MIN: f64 = 0.4;
/// `appearance.bg_dim` 下限(0 = 不暗化,允许但设置页会提示对比度风险)。
pub const BG_DIM_MIN: f64 = 0.0;
/// `appearance.bg_dim` 上限:再高背景图基本看不见,等于关掉。
pub const BG_DIM_MAX: f64 = 0.95;
/// 背景图填充模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BgFill {
/// 等比铺满、裁切溢出(默认:不露底色)
#[default]
Cover,
/// 等比完整显示、留底色
Contain,
/// 原始尺寸平铺
Tile,
}
impl BgFill {
/// 落盘取值。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cover => "cover",
Self::Contain => "contain",
Self::Tile => "tile",
}
}
/// 从落盘取值解析;未知值按 `cover` 兜底(手改坏配置也不崩)。
#[must_use]
pub fn from_key_value(value: &str) -> Self {
match value {
"contain" => Self::Contain,
"tile" => Self::Tile,
_ => Self::Cover,
}
}
}
/// 外观(高级)配置簇:**只是取值快照**,读写都走 [`super::store::SettingStore`]。
#[derive(Debug, Clone, PartialEq)]
pub struct Appearance {
/// 主题三态:`auto` / `light` / `dark`(与 `prefs::ThemeMode` 同名同值)。
pub theme_mode: String,
/// 自定义窗口背景图开关(默认关)。
pub bg_image_enabled: bool,
/// 背景图路径(PNG;空 = 未设置)。
pub bg_image: String,
/// 背景图填充模式。
pub bg_fill: BgFill,
/// 叠加暗化层强度 0.0~0.95(越大前景越清晰)。
pub bg_dim: f64,
/// 全局面板半透明度 0.4~1.0(1.0 = 出厂不透明)。
pub panel_alpha: f64,
/// 玻璃拟态卡片(预生成模糊背景层)。
pub glass: bool,
/// 自定义强调色 `#RRGGBB`(空串 = 出厂 [`DEFAULT_ACCENT`])。
pub accent_color: String,
}
impl Default for Appearance {
fn default() -> Self {
Self {
theme_mode: "auto".to_owned(),
bg_image_enabled: false,
bg_image: String::new(),
bg_fill: BgFill::Cover,
bg_dim: 0.35,
panel_alpha: 1.0,
glass: false,
accent_color: String::new(),
}
}
}
impl Appearance {
/// 从配置文档读一份快照(文件值 → 默认值;坏值按默认值兜底,绝不 panic)。
#[must_use]
pub fn read(doc: &SettingDocument) -> Self {
let default = Self::default();
let text = |key: &str| doc.effective_str(key).map(str::to_owned);
let boolean = |key: &str| doc.effective(key).and_then(Value::as_bool);
let number = |key: &str| doc.effective(key).and_then(Value::as_f64);
Self {
theme_mode: text("appearance.theme_mode").unwrap_or(default.theme_mode),
bg_image_enabled: boolean("appearance.bg_image_enabled").unwrap_or(false),
bg_image: text("appearance.bg_image").unwrap_or_default(),
bg_fill: text("appearance.bg_fill")
.map_or(default.bg_fill, |value| BgFill::from_key_value(&value)),
bg_dim: number("appearance.bg_dim")
.map_or(default.bg_dim, |value| clamp(value, BG_DIM_MIN, BG_DIM_MAX)),
panel_alpha: number("appearance.panel_alpha").map_or(default.panel_alpha, |value| {
clamp(value, PANEL_ALPHA_MIN, 1.0)
}),
glass: boolean("appearance.glass").unwrap_or(false),
accent_color: text("appearance.accent_color")
.map(|value| normalize_hex(&value).unwrap_or_default())
.unwrap_or_default(),
}
}
/// 从 store 读(配置读写的统一入口)。
#[must_use]
pub fn from_store(store: &SettingStore) -> Self {
Self::read(store.doc())
}
/// 实际生效的强调色:未自定义时回落到出厂 accent。
#[must_use]
pub fn accent_or_default(&self) -> &str {
if self.accent_color.is_empty() {
DEFAULT_ACCENT
} else {
&self.accent_color
}
}
/// 背景图是否真的会用上(开关打开 **且** 路径非空)。
#[must_use]
pub fn bg_image_active(&self) -> bool {
self.bg_image_enabled && !self.bg_image.trim().is_empty()
}
/// 写回一组键(调用方负责 `save()`;默认值不落盘由 `SettingDocument` 决定)。
///
/// 值先过一遍规范化:`bg_fill` 认枚举、`bg_dim`/`panel_alpha` 夹到范围内、
/// `accent_color` 只接受 `#RRGGBB`(空串表示清掉自定义)。
///
/// # Errors
/// 键值被 schema 拒绝(只读文件 / 未知键 / 越界)时返回 [`super::error::SettingError`]。
pub fn write(&self, store: &mut SettingStore) -> super::error::SettingResult<()> {
store.set(
"appearance.theme_mode",
Value::from(self.theme_mode.as_str()),
)?;
store.set(
"appearance.bg_image_enabled",
Value::from(self.bg_image_enabled),
)?;
store.set("appearance.bg_image", Value::from(self.bg_image.as_str()))?;
store.set("appearance.bg_fill", Value::from(self.bg_fill.as_str()))?;
store.set(
"appearance.bg_dim",
Value::from(clamp(self.bg_dim, BG_DIM_MIN, BG_DIM_MAX)),
)?;
store.set(
"appearance.panel_alpha",
Value::from(clamp(self.panel_alpha, PANEL_ALPHA_MIN, 1.0)),
)?;
store.set("appearance.glass", Value::from(self.glass))?;
store.set(
"appearance.accent_color",
Value::from(self.accent_color.as_str()),
)?;
Ok(())
}
}
/// `#RRGGBB` 规范化(大写、去空格);不合法返回 `None`。
#[must_use]
pub fn normalize_hex(text: &str) -> Option<String> {
let trimmed = text.trim();
let body = trimmed.strip_prefix('#').unwrap_or(trimmed);
if body.len() != 6 || !body.chars().all(|ch| ch.is_ascii_hexdigit()) {
return None;
}
Some(format!("#{}", body.to_ascii_uppercase()))
}
/// `#RRGGBB` → `(r, g, b)`(UI 侧构造颜色用)。
#[must_use]
pub fn hex_to_rgb(text: &str) -> Option<(u8, u8, u8)> {
let hex = normalize_hex(text)?;
let byte = |range: std::ops::Range<usize>| u8::from_str_radix(&hex[range], 16).ok();
Some((byte(1..3)?, byte(3..5)?, byte(5..7)?))
}
/// 相对亮度(WCAG 2.x,`0.0`~`1.0`):设置页「对比度检查提示」用它判断前景是否够读。
#[must_use]
pub fn relative_luminance(text: &str) -> Option<f64> {
let (r, g, b) = hex_to_rgb(text)?;
let channel = |value: u8| {
let value = f64::from(value) / 255.0;
if value <= 0.039_28 {
value / 12.92
} else {
((value + 0.055) / 1.055).powf(2.4)
}
};
Some(0.2126f64.mul_add(
channel(r),
0.7152f64.mul_add(channel(g), 0.0722 * channel(b)),
))
}
fn clamp(value: f64, min: f64, max: f64) -> f64 {
if value.is_nan() {
min
} else {
value.clamp(min, max)
}
}
+195
View File
@@ -0,0 +1,195 @@
//! 一份配置文件的键目录:[`SettingSchema`] 把 key → 元数据查表能力收拢在一处。
//!
//! 与 [`super::spec`] 的分工:`spec` 定义「一个键长什么样」,这里定义「一份文件的键集合」,
//! 并对外提供 AI 白名单 / 校验 / 工具 JSON Schema 投影(M16 `ToolSpec` 直接吃这份投影)。
use std::collections::BTreeSet;
use serde_json::{json, Map, Value};
use super::error::{SettingError, SettingResult};
use super::schema::{SchemaId, SchemaRef, SchemaVersion};
use super::spec::{Sensitivity, SettingSpec};
/// 一份配置文件里所有键的集合。
#[derive(Debug, Clone, PartialEq)]
pub struct SettingSchema {
id: SchemaId,
version: SchemaVersion,
specs: Vec<SettingSpec>,
}
impl SettingSchema {
/// 空 schema。
#[must_use]
pub fn new(id: SchemaId, version: SchemaVersion) -> Self {
Self {
id,
version,
specs: Vec::new(),
}
}
/// 链式加一个键(重复键直接 panic —— 这是编译期就该发现的表写错)。
#[must_use]
pub fn with(mut self, spec: SettingSpec) -> Self {
assert!(
!self.specs.iter().any(|existing| existing.key == spec.key),
"重复的配置键: {}",
spec.key
);
self.specs.push(spec);
self
}
/// 种类。
#[must_use]
pub const fn id(&self) -> SchemaId {
self.id
}
/// 版本。
#[must_use]
pub const fn version(&self) -> SchemaVersion {
self.version
}
/// `$schema` 标识。
#[must_use]
pub const fn schema_ref(&self) -> SchemaRef {
SchemaRef::new(self.id, self.version)
}
/// 全部键元数据。
#[must_use]
pub fn specs(&self) -> &[SettingSpec] {
&self.specs
}
/// 按键名取元数据。
#[must_use]
pub fn spec(&self, key: &str) -> Option<&SettingSpec> {
self.specs.iter().find(|spec| spec.key == key)
}
/// 是否定义了该键。
#[must_use]
pub fn is_known(&self, key: &str) -> bool {
self.spec(key).is_some()
}
/// 是否是某个已知键的**前缀路径**(用于区分「嵌套对象」与「陌生整棵子树」)。
#[must_use]
pub fn has_prefix(&self, segments: &[&str]) -> bool {
let mut prefix = segments.join(".");
prefix.push('.');
self.specs.iter().any(|spec| spec.key.starts_with(&prefix))
}
/// 默认值。
#[must_use]
pub fn default_value(&self, key: &str) -> Option<&Value> {
self.spec(key).map(|spec| &spec.default)
}
/// 校验一个键值对。
///
/// # Errors
/// 未定义的键 → [`SettingError::UnknownKey`];值不合法 → [`SettingError::InvalidValue`]。
pub fn validate(&self, key: &str, value: &Value) -> SettingResult<()> {
let spec = self.spec(key).ok_or_else(|| SettingError::UnknownKey {
key: key.to_owned(),
})?;
spec.kind
.check(value)
.map_err(|reason| SettingError::InvalidValue {
key: key.to_owned(),
reason,
})
}
/// AI 可写键白名单(供 M16 `set_config` 前置检查)。
#[must_use]
pub fn ai_writable_keys(&self) -> Vec<&'static str> {
self.specs
.iter()
.filter(|spec| spec.ai.writable && !spec.is_deprecated())
.map(|spec| spec.key)
.collect()
}
/// AI 是否允许写该键(未定义的键 = 不允许)。
#[must_use]
pub fn accepts_ai_write(&self, key: &str) -> bool {
self.spec(key)
.is_some_and(|spec| spec.ai.writable && !spec.is_deprecated())
}
/// 已废弃键清单(只读兼容,不再落盘)。
#[must_use]
pub fn deprecated_keys(&self) -> Vec<&'static str> {
self.specs
.iter()
.filter(|spec| spec.is_deprecated())
.map(|spec| spec.key)
.collect()
}
/// 某键的替代键(不是废弃键则 `None`)。
#[must_use]
pub fn replacement_of(&self, key: &str) -> Option<&'static str> {
self.spec(key)?.deprecated.map(|info| info.replaced_by)
}
/// 敏感分级。
#[must_use]
pub fn sensitivity(&self, key: &str) -> Option<Sensitivity> {
self.spec(key).map(|spec| spec.ai.sensitivity)
}
/// 顶层键名集合(`download.concurrency` → `download`)。
#[must_use]
pub fn top_level_keys(&self) -> BTreeSet<&'static str> {
self.specs
.iter()
.filter_map(|spec| spec.key.split('.').next())
.collect()
}
/// 生成给 AI 工具用的 JSON Schema(只含 AI 可写键)。
#[must_use]
pub fn to_json_schema(&self) -> Value {
let mut properties = Map::new();
for spec in self
.specs
.iter()
.filter(|spec| spec.ai.writable && !spec.is_deprecated())
{
let mut property = match spec.kind.to_json_schema() {
Value::Object(map) => map,
other => {
let mut map = Map::new();
map.insert("schema".to_owned(), other);
map
}
};
property.insert("description".to_owned(), Value::from(spec.doc));
property.insert(
"x-ai-sensitivity".to_owned(),
Value::from(spec.ai.sensitivity.as_str()),
);
if !spec.default.is_null() {
property.insert("default".to_owned(), spec.default.clone());
}
properties.insert(spec.key.to_owned(), Value::Object(property));
}
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": format!("ull.{}", self.id.as_str()),
"type": "object",
"additionalProperties": false,
"properties": properties,
})
}
}
+399
View File
@@ -0,0 +1,399 @@
//! 配置文档模型:嵌套点路径取值、脏字段追踪、未知字段保留、`$schema` 保留。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.2-§1.3(HMCL `DirtyTracker` +
//! `unknownFields` + `RawPreservingProperty`)与 `DevDocs/PlanDoc/04-data-and-config.md` §3。
//!
//! 三个关键取舍:
//! 1. **默认值不进文件**——只有「文件里读到的键」和「本次显式设过的键」会出现在落盘视图里,
//! 因此改默认值不需要写迁移代码;
//! 2. **未知字段整棵保留**——旧版本打开新版本写的文件不会丢配置;
//! 3. **坏值原样保留**——单个键解析/校验失败只记 `LoadProblem`,不丢数据、不整文件报废。
use std::collections::BTreeSet;
use std::fmt;
use serde_json::{Map, Value};
use super::catalog::SettingSchema;
use super::error::{SettingError, SettingResult};
use super::json_path::{collect_unknown, flatten, insert_at, lookup, merge_maps, segments};
use super::schema::{
SchemaCompat, SchemaRef, SchemaVersion, SCHEMA_KEY, SCHEMA_URL_PREFIX, SCHEMA_VERSION_KEY,
};
/// 落盘视图模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderMode {
/// 完整视图:`$schema` + `schemaVersion` + 已读/已设键 + 未知键(保存用,无损)。
Full,
/// 只含本次脏字段(AI 增量 / IPC 同步 / 审计用)。
DirtyOnly,
}
/// 加载时发现的问题(都不致命,配置照常用)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadProblem {
/// 文件里有、schema 不认识的键(整棵子树原样保留)。
UnknownKey {
/// 点分键;陌生子树按**最上层**节点报一次(不做逐叶展开)。
key: String,
},
/// 已知键但值不合法(原样保留,等用户/AI 修)。
InvalidValue {
/// 点分键。
key: String,
/// 原因。
reason: String,
},
/// 已知键的父路径类型不符(如 `download` 本该是对象却是字符串)。
InvalidShape {
/// 点分键。
key: String,
/// 原因。
reason: String,
},
/// `schemaVersion` 镜像与 `$schema` 尾部不一致(以 `$schema` 为准)。
SchemaVersionMismatch {
/// 文件里写的整数。
declared: u64,
/// `$schema` 推出的 major。
expected: u32,
},
/// 文件用的是已废弃键:值已按映射带到替代键,下次保存只写替代键。
DeprecatedKey {
/// 已废弃的键。
key: String,
/// 替代键。
replaced_by: &'static str,
},
/// `$schema` 本身的状态(缺失 / 非法 / 更高版本等)。
Schema(SchemaCompat),
/// 有 `$schema` 字符串但认不出前缀(M40:值照读、文件按只读,不再静默回落默认值)。
UnknownSchema {
/// 文件里写的原串。
url: String,
},
}
impl fmt::Display for LoadProblem {
/// 人读一行(CLI 的 `problems` 通道、日志与设置页共用一份文案)。
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownKey { key } => write!(f, "{key}:schema 不认识的键(已原样保留)"),
Self::InvalidValue { key, reason } => write!(f, "{key}:值不合法({reason}),已保留"),
Self::InvalidShape { key, reason } => write!(f, "{key}:结构不符({reason})"),
Self::SchemaVersionMismatch { declared, expected } => {
write!(
f,
"schemaVersion={declared} 不是 $schema 的 major {expected}"
)
}
Self::DeprecatedKey { key, replaced_by } => write!(f, "{key}:改用 {replaced_by}"),
Self::Schema(compat) => write!(f, "$schema 状态:{}", compat.as_str()),
Self::UnknownSchema { url } => {
write!(f, "$schema 认不出「{url}」,前缀需为 {SCHEMA_URL_PREFIX}")
}
}
}
}
/// 一份配置文件的内存视图。
#[derive(Debug, Clone)]
pub struct SettingDocument {
schema: SettingSchema,
version: SchemaVersion,
values: Map<String, Value>,
/// 已废弃键的原始值:只供诊断/UI 提示,**永远不落盘**。
legacy: Map<String, Value>,
/// 废弃键(点分)清单。
legacy_keys: BTreeSet<String>,
dirty: BTreeSet<String>,
unknown: Map<String, Value>,
unknown_keys: BTreeSet<String>,
preserve_schema: Option<String>,
}
impl SettingDocument {
/// 空文档(全部走默认值),版本取 schema 版本。
#[must_use]
pub fn new(schema: SettingSchema) -> Self {
let version = schema.version();
Self {
schema,
version,
values: Map::new(),
legacy: Map::new(),
legacy_keys: BTreeSet::new(),
dirty: BTreeSet::new(),
unknown: Map::new(),
unknown_keys: BTreeSet::new(),
preserve_schema: None,
}
}
/// 从文件内容加载;返回文档与问题清单(永不失败)。
#[must_use]
pub fn load(schema: &SettingSchema, raw: &Value) -> (Self, Vec<LoadProblem>) {
let mut problems = Vec::new();
let mut document = Self::new(schema.clone());
let original_schema = raw
.get(SCHEMA_KEY)
.and_then(Value::as_str)
.map(str::to_owned);
let compat = match &original_schema {
Some(url) => SchemaCompat::check_url(url, schema.schema_ref()),
None => SchemaCompat::Missing,
};
if compat.preserve_schema() {
document.preserve_schema = original_schema.clone();
}
// 认得 `$schema` 键、但前缀解析不出来:别静默按「没有声明」装默认值。
if let (SchemaCompat::Invalid, Some(url)) = (compat, original_schema.clone()) {
problems.push(LoadProblem::UnknownSchema { url });
}
if let Some(parsed) = original_schema.as_deref().and_then(SchemaRef::parse_url) {
document.version = parsed.version;
}
if let Some(declared) = raw.get(SCHEMA_VERSION_KEY).and_then(Value::as_u64) {
let expected = document.version.major;
if declared != u64::from(expected) {
problems.push(LoadProblem::SchemaVersionMismatch { declared, expected });
}
}
let Some(object) = raw.as_object() else {
if !raw.is_null() {
problems.push(LoadProblem::Schema(compat));
}
return (document, problems);
};
for spec in schema.specs() {
let path = segments(spec.key);
let Some(value) = lookup(object, &path) else {
continue;
};
if let Some(deprecation) = spec.deprecated {
// 废弃键:原文进 legacy(不落盘),值按映射带到替代键(新键已在则新键优先)。
insert_at(&mut document.legacy, &path, value.clone());
problems.push(LoadProblem::DeprecatedKey {
key: spec.key.to_owned(),
replaced_by: deprecation.replaced_by,
});
let replacement = segments(deprecation.replaced_by);
if lookup(object, &replacement).is_none() {
let translated = deprecation.translate(value);
if schema
.validate(deprecation.replaced_by, &translated)
.is_err()
{
problems.push(LoadProblem::InvalidValue {
key: deprecation.replaced_by.to_owned(),
reason: format!("旧键 {} 的值 {value} 无法映射到新键", spec.key),
});
}
insert_at(&mut document.values, &replacement, translated);
}
continue;
}
if let Err(err) = schema.validate(spec.key, value) {
problems.push(LoadProblem::InvalidValue {
key: spec.key.to_owned(),
reason: err.to_string(),
});
}
insert_at(&mut document.values, &path, value.clone());
}
document.legacy_keys = flatten(&document.legacy);
let unknown = collect_unknown(&[], object, schema, &mut problems);
document.unknown_keys = flatten(&unknown);
document.unknown = unknown;
(document, problems)
}
/// 键表。
#[must_use]
pub const fn schema(&self) -> &SettingSchema {
&self.schema
}
/// 文档版本(来自文件 `$schema`;迁移后用 [`Self::set_version`] 推进)。
#[must_use]
pub const fn version(&self) -> SchemaVersion {
self.version
}
/// 设置文档版本(迁移链推进后调用)。
pub const fn set_version(&mut self, version: SchemaVersion) {
self.version = version;
}
/// 文件里显式出现过的值(不含默认值)。
#[must_use]
pub fn get(&self, key: &str) -> Option<&Value> {
let path = segments(key);
lookup(&self.values, &path).or_else(|| lookup(&self.legacy, &path))
}
/// 已废弃键在文件里的原文(仅供诊断/UI 提示;不参与落盘)。
#[must_use]
pub fn legacy_value(&self, key: &str) -> Option<&Value> {
lookup(&self.legacy, &segments(key))
}
/// 废弃键清单(文件里真的出现过的)。
pub fn legacy_keys(&self) -> impl Iterator<Item = &str> {
self.legacy_keys.iter().map(String::as_str)
}
/// 生效值:文件值 → schema 默认值(默认值为 `null` 视为未设置)。
#[must_use]
pub fn effective(&self, key: &str) -> Option<&Value> {
self.get(key)
.or_else(|| self.schema.default_value(key))
.filter(|value| !value.is_null())
}
/// 生效布尔值。
#[must_use]
pub fn effective_bool(&self, key: &str) -> Option<bool> {
self.effective(key).and_then(Value::as_bool)
}
/// 生效整数。
#[must_use]
pub fn effective_i64(&self, key: &str) -> Option<i64> {
self.effective(key).and_then(Value::as_i64)
}
/// 生效浮点。
#[must_use]
pub fn effective_f64(&self, key: &str) -> Option<f64> {
self.effective(key).and_then(Value::as_f64)
}
/// 生效文本。
#[must_use]
pub fn effective_str(&self, key: &str) -> Option<&str> {
self.effective(key).and_then(Value::as_str)
}
/// 写一个键(走 schema 校验,并标脏)。
///
/// # Errors
/// 未定义的键或值不合法时返回 [`SettingError`]。
pub fn set(&mut self, key: &str, value: Value) -> SettingResult<()> {
if let Some(replaced_by) = self.schema.replacement_of(key) {
return Err(SettingError::DeprecatedKey {
key: key.to_owned(),
replaced_by,
});
}
self.schema.validate(key, &value)?;
insert_at(&mut self.values, &segments(key), value);
self.dirty.insert(key.to_owned());
Ok(())
}
/// 是否有未落盘的改动。
#[must_use]
pub fn is_dirty(&self) -> bool {
!self.dirty.is_empty()
}
/// 脏键清单。
pub fn dirty_keys(&self) -> impl Iterator<Item = &str> {
self.dirty.iter().map(String::as_str)
}
/// 把当前所有已读/已设键全部标脏(迁移后强制回写用)。
///
/// 废弃键跳过:它们只做读兼容、不落盘,不需要回写。
pub fn mark_all_dirty(&mut self) {
for spec in self.schema.specs() {
if spec.is_deprecated() {
continue;
}
if self.get(spec.key).is_some() {
self.dirty.insert(spec.key.to_owned());
}
}
}
/// 清脏(保存成功后调用)。
pub fn mark_clean(&mut self) {
self.dirty.clear();
}
/// 未知键(点分)清单。
pub fn unknown_keys(&self) -> impl Iterator<Item = &str> {
self.unknown_keys.iter().map(String::as_str)
}
/// 文件里原有的 `$schema`(仅 `preserve_schema` 状态下有值)。
#[must_use]
pub fn preserve_schema(&self) -> Option<&str> {
self.preserve_schema.as_deref()
}
/// 显式指定保存时沿用的 `$schema` 字符串。
pub fn set_preserve_schema(&mut self, url: Option<String>) {
self.preserve_schema = url;
}
/// 落盘视图。
#[must_use]
pub fn render(&self, mode: RenderMode) -> Value {
let mut root = Map::new();
root.insert(SCHEMA_KEY.to_owned(), Value::String(self.schema_url()));
root.insert(
SCHEMA_VERSION_KEY.to_owned(),
Value::from(self.version.major),
);
match mode {
RenderMode::Full => {
merge_maps(&mut root, &self.values);
merge_maps(&mut root, &self.unknown);
}
RenderMode::DirtyOnly => {
let patch = self.dirty_patch_map();
merge_maps(&mut root, &patch);
}
}
Value::Object(root)
}
/// 完整落盘视图(等价 `render(RenderMode::Full)`)。
#[must_use]
pub fn to_value(&self) -> Value {
self.render(RenderMode::Full)
}
/// 只含脏字段的嵌套对象。
#[must_use]
pub fn dirty_patch(&self) -> Value {
Value::Object(self.dirty_patch_map())
}
fn dirty_patch_map(&self) -> Map<String, Value> {
let mut patch = Map::new();
for key in &self.dirty {
if let Some(value) = lookup(&self.values, &segments(key)) {
insert_at(&mut patch, &segments(key), value.clone());
}
}
patch
}
fn schema_url(&self) -> String {
self.preserve_schema
.clone()
.unwrap_or_else(|| self.schema.schema_ref().url())
}
}
+121
View File
@@ -0,0 +1,121 @@
//! setting 层错误类型与结果别名。
use std::fmt;
use std::io;
use std::path::PathBuf;
use crate::platform::error::PlatformError;
use super::schema::{SchemaCompat, SchemaVersion};
/// setting 层的结果别名。
pub type SettingResult<T> = Result<T, SettingError>;
/// 配置读写与迁移可能出的问题。
#[derive(Debug)]
pub enum SettingError {
/// 文件系统操作失败。
Io {
/// 出错路径。
path: PathBuf,
/// 底层错误。
source: io::Error,
},
/// JSON 解析/序列化失败。
Json {
/// 出错路径。
path: PathBuf,
/// 底层错误。
source: serde_json::Error,
},
/// 文件是只读档(`READ_ONLY`),拒绝保存。
ReadOnly {
/// 文件路径。
path: PathBuf,
},
/// 文件不可读档(`UNREADABLE`),全程不写。
Unreadable {
/// 文件路径。
path: PathBuf,
/// 判定依据。
compat: SchemaCompat,
},
/// schema 里没有这个键(AI 白名单外的键也走这里)。
UnknownKey {
/// 键。
key: String,
},
/// 值不合法(类型/范围/枚举)。
InvalidValue {
/// 键。
key: String,
/// 原因。
reason: String,
},
/// 键已废弃,写必须走替代键。
DeprecatedKey {
/// 用户/调用方给的旧键。
key: String,
/// 替代键。
replaced_by: &'static str,
},
/// 迁移链断链/缺环/顺序错。
ChainBroken {
/// 原因。
reason: String,
},
/// 单步迁移失败。
Migration {
/// 起始版本。
from: SchemaVersion,
/// 目标版本。
to: SchemaVersion,
/// 原因。
reason: String,
},
/// platform 层错误透传。
Platform(PlatformError),
}
impl fmt::Display for SettingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => write!(f, "IO 失败 @ {}: {source}", path.display()),
Self::Json { path, source } => write!(f, "JSON 失败 @ {}: {source}", path.display()),
Self::ReadOnly { path } => write!(f, "文件只读,拒绝保存: {}", path.display()),
Self::Unreadable { path, compat } => write!(
f,
"文件不可读({}),全程不写: {}",
compat.as_str(),
path.display()
),
Self::UnknownKey { key } => write!(f, "schema 未定义该键: {key}"),
Self::InvalidValue { key, reason } => write!(f, "值不合法 [{key}]: {reason}"),
Self::DeprecatedKey { key, replaced_by } => {
write!(f, "键 {key} 已废弃,请改用 {replaced_by}")
}
Self::ChainBroken { reason } => write!(f, "迁移链不完整: {reason}"),
Self::Migration { from, to, reason } => {
write!(f, "迁移 {from} → {to} 失败: {reason}")
}
Self::Platform(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for SettingError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Json { source, .. } => Some(source),
Self::Platform(err) => Some(err),
_ => None,
}
}
}
impl From<PlatformError> for SettingError {
fn from(err: PlatformError) -> Self {
Self::Platform(err)
}
}
+155
View File
@@ -0,0 +1,155 @@
//! `config/profiles.json` 与 `config/instances/<id>.json` 的键表。
//!
//! 实例模型预告见 `DevDocs/PlanDoc/04-data-and-config.md` §4:
//! 每实例 = 一个 `.minecraft` 目录绑定 + 独立设置覆盖链(全局 → 实例 → 临时启动参数),
//! 实例 id 用稳定 UUID(不用路径——路径会变)。
//!
//! # 实例文件的形状(D20 定案,research/04 §5.1)
//!
//! 实例文件是**稀疏 camelCase 扁平结构**,与 `game::install::instance::InstanceConfig`
//! 的 serde 形状逐键一致(那份结构才是写入者,本文件是它的 schema 白名单视图):
//!
//! ```jsonc
//! {
//! "schemaVersion": 1,
//! "id": "<uuid>",
//! "versionId": "1.20.1",
//! "isolation": "custom", // 缺键 = inherit(跟随全局,不落盘)
//! "gameDir": "D:/mcs/farm", // 仅 custom 时有值
//! "overrides": ["maxMemory"],
//! "lastModeChange": { "at": "2026-10-30T12:00:00Z", "from": "shared", "to": "isolated",
//! "probe": { "isolatedSide": 0, "sharedSide": 12 }, "warned": true }
//! }
//! ```
//!
//! 三条不变量:
//! 1. `isolation` 默认 `inherit` 且**不落盘**——改全局模式对存量实例立即生效
//! (`document` 的「默认值不进文件」保证这一点,直接避开 PCL2 的默认值固化缺陷);
//! 显式写 `"isolation": "inherit"` 也合法:`InstanceConfig` 的反序列化把它归一成
//! 「键不存在」(等价语义,不会出现两层读写回环不一致);
//! 2. `isolation`/`lastModeChange` 由用户(前者)与启动器(后者)维护,**AI 不可写**;
//! 3. 派生的绝对路径不入档:`isolated` 只是「运行目录 = `versions/<versionId>`」的语法糖,
//! 派生实现归 `game::isolation`。
use super::catalog::SettingSchema;
use super::schema::{SchemaId, SchemaVersion};
use super::spec::{AiPolicy, SettingSpec};
/// 实例级 `isolation` 允许值(`inherit` = 键缺失,跟随全局 `instances.mode`)。
pub const ISOLATION_OVERRIDES: &[&str] = &["inherit", "isolated", "shared", "custom"];
/// 切换检测记录里 `from`/`to` 的允许值(`inherit` 不是生效档位,故不在表里)。
pub const ISOLATION_RESOLVED: &[&str] = &["isolated", "shared", "custom"];
/// 实例可覆盖的设置键(enum 白名单,拼错即校验失败而不是静默不生效)。
pub const OVERRIDE_KEYS: &[&str] = &["maxMemory", "javaType", "runningDirectory"];
/// profiles.json 当前 schema 版本。
#[must_use]
pub fn profiles_version() -> SchemaVersion {
SchemaVersion::new(1, 0, 0)
}
/// instances/<id>.json 当前 schema 版本。
///
/// `1.1.0` 登记 D20 的 `isolation` 档位(`inherit`/`isolated`/`shared`/`custom`)与
/// `lastModeChange` 切换检测键;老文件缺键即默认值,不需要迁移步骤(major 未变)。
#[must_use]
pub fn instance_version() -> SchemaVersion {
SchemaVersion::new(1, 1, 0)
}
/// `config/profiles.json` 的 schema。
#[must_use]
pub fn profiles_schema() -> SettingSchema {
SettingSchema::new(SchemaId::PROFILES, profiles_version())
.with(SettingSpec::text(
"selected_profile_id",
"",
64,
AiPolicy::audited(),
"上次选中的实例 id",
))
.with(SettingSpec::any(
"profiles",
AiPolicy::locked(),
"实例档案列表(数据而非配置,由 game 模块维护;AI 不得整体改写)",
))
}
/// `config/instances/<id>.json` 的 schema。
#[must_use]
pub fn instance_schema() -> SettingSchema {
SettingSchema::new(SchemaId::INSTANCE, instance_version())
.with(SettingSpec::text(
"id",
"",
64,
AiPolicy::locked(),
"实例稳定 id(UUID,与文件名一致;不允许改写身份)",
))
.with(SettingSpec::text(
"versionId",
"",
128,
AiPolicy::audited(),
"绑定的版本 id(versions/<id>)",
))
.with(SettingSpec::enumeration(
"isolation",
"inherit",
ISOLATION_OVERRIDES,
AiPolicy::sensitive(),
"实例隔离档位:inherit(跟随全局 instances.mode,不落盘)/ isolated(运行目录派生 versions/<versionId>)/ shared(共用 .minecraft 根)/ custom(用 gameDir)",
))
.with(SettingSpec::path(
"gameDir",
AiPolicy::sensitive(),
"自定义运行目录(仅 isolation=custom 生效)",
))
.with(SettingSpec::enum_list(
"overrides",
OVERRIDE_KEYS,
AiPolicy::locked(),
"该实例覆盖了全局哪些设置键(enum 白名单,拼错即校验失败)",
))
.with(SettingSpec::text(
"lastModeChange.at",
"",
40,
AiPolicy::locked(),
"上次切换隔离档位的时间(RFC-3339,启动器写入)",
))
.with(SettingSpec::enumeration(
"lastModeChange.from",
"isolated",
ISOLATION_RESOLVED,
AiPolicy::locked(),
"切换前的生效档位(启动器写入)",
))
.with(SettingSpec::enumeration(
"lastModeChange.to",
"isolated",
ISOLATION_RESOLVED,
AiPolicy::locked(),
"切换后的生效档位(启动器写入)",
))
.with(SettingSpec::int(
"lastModeChange.probe.isolatedSide",
0,
(0, 1_000_000_000),
AiPolicy::locked(),
"切换时隔离侧(versions/<versionId>)的内容条目数(只探测,不迁移)",
))
.with(SettingSpec::int(
"lastModeChange.probe.sharedSide",
0,
(0, 1_000_000_000),
AiPolicy::locked(),
"切换时共享侧(.minecraft 根)的内容条目数(只探测,不迁移)",
))
.with(SettingSpec::bool(
"lastModeChange.warned",
false,
AiPolicy::locked(),
"该次切换的提示是否已展示过(保证切换后首次启动只提示一次)",
))
}
+124
View File
@@ -0,0 +1,124 @@
//! 点分键(`download.concurrency`)与嵌套 JSON 之间的最小工具集。
//!
//! 单独成文件:这些函数是 [`super::document`] 的机械部件,和「配置语义」无关,
//! 也是容易出边界 bug(空路径、中间层不是对象)的地方。
use std::collections::BTreeSet;
use serde_json::{Map, Value};
use super::catalog::SettingSchema;
use super::document::LoadProblem;
use super::schema::{SCHEMA_KEY, SCHEMA_VERSION_KEY};
/// 点分键 → 路径段。
pub(crate) fn segments(key: &str) -> Vec<&str> {
key.split('.').filter(|part| !part.is_empty()).collect()
}
/// 沿路径取嵌套值。
pub(crate) fn lookup<'a>(root: &'a Map<String, Value>, path: &[&str]) -> Option<&'a Value> {
let (head, tail) = path.split_first()?;
let value = root.get(*head)?;
if tail.is_empty() {
Some(value)
} else {
lookup(value.as_object()?, tail)
}
}
/// 沿路径写嵌套值(中间层不是对象则重建)。
pub(crate) fn insert_at(root: &mut Map<String, Value>, path: &[&str], value: Value) {
let Some((head, tail)) = path.split_first() else {
return;
};
if tail.is_empty() {
root.insert((*head).to_owned(), value);
return;
}
let entry = root
.entry((*head).to_owned())
.or_insert_with(|| Value::Object(Map::new()));
if !entry.is_object() {
*entry = Value::Object(Map::new());
}
if let Some(child) = entry.as_object_mut() {
insert_at(child, tail, value);
}
}
/// 深合并(`extra` 覆盖 `target` 的同名叶子)。
pub(crate) fn merge_maps(target: &mut Map<String, Value>, extra: &Map<String, Value>) {
for (key, value) in extra {
if let Some(Value::Object(existing)) = target.get_mut(key) {
if let Some(incoming) = value.as_object() {
merge_maps(existing, incoming);
continue;
}
}
target.insert(key.clone(), value.clone());
}
}
/// 收集 schema 不认识的键(整棵保留),并把形状问题记进 `problems`。
pub(crate) fn collect_unknown(
prefix: &[&str],
node: &Map<String, Value>,
schema: &SettingSchema,
problems: &mut Vec<LoadProblem>,
) -> Map<String, Value> {
let mut out = Map::new();
for (key, value) in node {
if prefix.is_empty() && (key == SCHEMA_KEY || key == SCHEMA_VERSION_KEY) {
continue;
}
let mut path = prefix.to_vec();
path.push(key.as_str());
let dotted = path.join(".");
if schema.is_known(&dotted) {
continue;
}
if schema.has_prefix(&path) {
match value.as_object() {
Some(child) => {
let nested = collect_unknown(&path, child, schema, problems);
if !nested.is_empty() {
out.insert(key.clone(), Value::Object(nested));
}
}
None => problems.push(LoadProblem::InvalidShape {
key: path.join("."),
reason: "期望对象(其下还有已知键)".to_owned(),
}),
}
continue;
}
problems.push(LoadProblem::UnknownKey {
key: path.join("."),
});
out.insert(key.clone(), value.clone());
}
out
}
/// 把嵌套对象摊平成点分叶子集合。
pub(crate) fn flatten(map: &Map<String, Value>) -> BTreeSet<String> {
fn walk(prefix: &str, node: &Map<String, Value>, out: &mut BTreeSet<String>) {
for (key, value) in node {
let dotted = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match value.as_object() {
Some(child) if !child.is_empty() => walk(&dotted, child, out),
_ => {
out.insert(dotted);
}
}
}
}
let mut out = BTreeSet::new();
walk("", map, &mut out);
out
}
+352
View File
@@ -0,0 +1,352 @@
//! `config/launcher.json` 的键表(全局设置)。
//!
//! 键名与默认值对齐 `DevDocs/PlanDoc/04-data-and-config.md` §2-§3 与 `05/06/07/08` 各专题;
//! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 的边界落:**密钥类一律 locked**。
use super::catalog::SettingSchema;
use super::schema::{SchemaId, SchemaVersion};
use super::spec::{AiPolicy, SettingSpec};
/// `core.data_mode` 允许值。
pub const DATA_MODES: &[&str] = &["portable", "standard", "custom"];
/// `download.region` 允许值。
pub const REGIONS: &[&str] = &["auto", "cn", "global"];
/// `download.mirror_preference` 允许值。
pub const MIRROR_PREFERENCES: &[&str] = &["auto", "official", "mirror"];
/// `download.proxy_mode` 允许值。
pub const PROXY_MODES: &[&str] = &["none", "system", "manual"];
/// `ai.provider` 允许值。
pub const AI_PROVIDERS: &[&str] = &["openai_compatible", "anthropic", "ollama", "disabled"];
/// `ui.language` 允许值。
pub const LANGUAGES: &[&str] = &["zh_cn", "en_us"];
/// `appearance.theme_mode` 允许值(D17 定案:auto/light/dark)。
pub const THEME_MODES: &[&str] = &["auto", "light", "dark"];
/// `ui.theme` 的旧允许值(该键已废弃,仅读兼容,不再落盘)。
pub const LEGACY_THEMES: &[&str] = &["system", "metro_light", "metro_dark"];
/// 旧 `ui.theme` 值 → 新 `appearance.theme_mode` 值(tower 键冲突裁决的读兼容映射)。
pub const THEME_ALIASES: &[(&str, &str)] = &[
("system", "auto"),
("metro_light", "light"),
("metro_dark", "dark"),
];
/// `log.level` 允许值。
pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"];
/// `instances.mode` 允许值(D20 定案:默认隔离,用户可手动切共享)。
pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"];
/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。
pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。
///
/// 与 `crate::game::java::vendor::VENDOR_IDS` 必须是同一份集合——setting 层不依赖 game 层
/// (分层纪律),一致性由 `tests/setting/java_install_keys.rs` 的契约测试固定。
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu"];
/// launcher.json 当前 schema 版本。
///
/// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。
/// `1.2.0` 登记 D23 的 JDK 落点与发行版优先级键(`java.installRoot` / `java.vendorPriority`):
/// 同样只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认,
/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。
/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` /
/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。
#[must_use]
pub fn launcher_version() -> SchemaVersion {
SchemaVersion::new(1, 3, 0)
}
/// `config/launcher.json` 的 schema。
#[must_use]
pub fn launcher_schema() -> SettingSchema {
SettingSchema::new(SchemaId::LAUNCHER, launcher_version())
.with(SettingSpec::enumeration(
"core.data_mode",
"portable",
DATA_MODES,
AiPolicy::sensitive(),
"数据目录模式:portable(exe 旁)/ standard(系统标准目录)/ custom(自定义)",
))
.with(SettingSpec::path(
"core.data_custom_root",
AiPolicy::sensitive(),
"custom 模式的根目录(必须是绝对路径)",
))
.with(SettingSpec::path(
"core.launcher_home",
AiPolicy::sensitive(),
"显式覆盖启动器数据目录(.ULL 的等价物)",
))
.with(SettingSpec::path(
"core.game_home",
AiPolicy::sensitive(),
"游戏目录(.minecraft),与数据目录分离指定",
))
// D20(research/04 §5.1):只留一个全局默认档,实例级 override 在
// `instance_keys::instance_schema`(`isolation`),派生逻辑在 `game::isolation`。
.with(SettingSpec::enumeration(
"instances.mode",
"isolated",
INSTANCE_MODES,
AiPolicy::sensitive(),
"实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)",
))
.with(SettingSpec::int(
"download.concurrency",
8,
(1, 256),
AiPolicy::silent(),
"下载并发数",
))
.with(SettingSpec::int(
"download.retry_limit",
3,
(0, 10),
AiPolicy::silent(),
"单个文件重试次数",
))
.with(SettingSpec::enumeration(
"download.region",
"auto",
REGIONS,
AiPolicy::audited(),
"下载地区判定:auto(时区+语言+探测)/ cn / global",
))
.with(SettingSpec::enumeration(
"download.mirror_preference",
"auto",
MIRROR_PREFERENCES,
AiPolicy::audited(),
"镜像偏好:auto(健康度评分)/ official(官方源优先)/ mirror(镜像优先)",
))
.with(SettingSpec::enumeration(
"download.proxy_mode",
"none",
PROXY_MODES,
AiPolicy::sensitive(),
"代理模式:none / system / manual",
))
.with(SettingSpec::text(
"download.proxy_url",
"",
512,
AiPolicy::sensitive(),
"manual 代理地址(如 http://127.0.0.1:7890)",
))
.with(SettingSpec::path(
"java.selected_path",
AiPolicy::sensitive(),
"用户选定的 Java 可执行文件路径",
))
.with(SettingSpec::bool(
"java.auto_install",
true,
AiPolicy::audited(),
"缺 Java 时自动下载(D13 基准 JDK17)",
))
// D23:托管 JDK 的落点三模式。语义在 `game::java::location`:
// 未设置(空串)→ 按 `core.data_mode` 派生(Portable 数据根 / Standard 系统目录);
// 设置了 → 覆盖前两者,按 `<vendor>/<version>` 分层。
.with(SettingSpec::text(
"java.installRoot",
JAVA_INSTALL_ROOT_UNSET,
512,
AiPolicy::sensitive(),
"托管 JDK 的自定义安装根(留空 = 按数据目录模式:portable 进数据根、standard 进 %APPDATA%\\Java);设置后覆盖两者,JDK 落在 <root>/<发行方>/<版本>/",
))
// D23:发行版回退链的用户优先级。空数组 = 按地区默认序
// (国内 Temurin→Oracle→Microsoft→Zulu;国外 Temurin→Microsoft→Oracle→Zulu)。
.with(SettingSpec::enum_list(
"java.vendorPriority",
JDK_VENDORS,
AiPolicy::audited(),
"JDK 发行版优先级(temurin/oracle/microsoft/zulu);留空按地区默认序,只写几家则其余按默认序补齐",
))
.with(SettingSpec::int(
"java.default_memory_mb",
2048,
(512, 65536),
AiPolicy::silent(),
"默认分配给游戏的最大堆内存(MB)",
))
.with(SettingSpec::text(
"java.extra_args",
"",
1024,
AiPolicy::sensitive(),
"附加 JVM 参数(原样拼到命令行)",
))
.with(SettingSpec::bool(
"ai.enabled",
true,
AiPolicy::silent(),
"启用内置 AI agent(关闭后规则引擎诊断仍可用)",
))
.with(SettingSpec::enumeration(
"ai.provider",
"openai_compatible",
AI_PROVIDERS,
AiPolicy::sensitive(),
"LLM 提供方:openai_compatible(含本地 llama.cpp/DeepSeek/Qwen)/ anthropic / ollama / disabled",
))
.with(SettingSpec::text(
"ai.base_url",
"",
512,
AiPolicy::sensitive(),
"LLM 端点 base_url(OpenAI 兼容形态)",
))
.with(SettingSpec::text(
"ai.model",
"",
128,
AiPolicy::audited(),
"模型名(如 gpt-4o-mini / deepseek-chat)",
))
.with(SettingSpec::bool(
"ai.yolo",
false,
AiPolicy::sensitive(),
"自动批准全部工具调用(审计照记,白名单不变)",
))
.with(SettingSpec::new(
"ai.api_key",
super::spec::ValueKind::Text { max_len: 4096 },
serde_json::Value::Null,
AiPolicy::locked(),
"LLM API 密钥——AI 永不读写的凭据(D9)",
))
// M39(P3 MCP server,02 §0「传输定论」):对外 agent 工具服务。
// 三个开关默认「关 / 只读」——外部客户端拿到的是与内置客户端**同一注册表**的工具,
// 但写权限必须用户显式打开(02 §1「同一注册表、同一安全策略、同一审计日志」)。
.with(SettingSpec::bool(
"agent.mcp.enabled",
false,
AiPolicy::sensitive(),
"对外 MCP server 总开关(127.0.0.1 上的 Streamable HTTP;stdio 形态按需显式启动)",
))
.with(SettingSpec::int(
"agent.mcp.port",
47823,
(1024, 65535),
AiPolicy::sensitive(),
"MCP server 的 Streamable HTTP 端口(只绑 127.0.0.1)",
))
.with(SettingSpec::bool(
"agent.mcp.allow_write",
false,
AiPolicy::sensitive(),
"允许外部 MCP 客户端调用写工具(默认只读;敏感键仍受分级确认与 YOLO 规则约束)",
))
.with(SettingSpec::text(
"agent.mcp.token",
"",
256,
AiPolicy::locked(),
"MCP server 本地访问令牌(Authorization: Bearer;空 = 不校验,仅绑 127.0.0.1)",
))
.with(SettingSpec::enumeration(
"ui.language",
"zh_cn",
LANGUAGES,
AiPolicy::audited(),
"界面语言",
))
.with(SettingSpec::enumeration(
"appearance.theme_mode",
"auto",
THEME_MODES,
AiPolicy::silent(),
"主题模式:auto(跟随系统)/ light(Metro 浅色)/ dark(Metro 深色)",
))
// D17「外观(高级)」P1 四件(09 §101-104 / §111):**默认全部关闭**(出厂 Metro 纯色)。
// 分级一律 audited(Medium):全部是纯视觉、可一键复原的键,没有安全面(不碰凭据/网络/
// 数据根),谈不上 High 级确认;但 AI 改过什么要留审计痕迹,故不用 silent。
.with(SettingSpec::bool(
"appearance.bg_image_enabled",
false,
AiPolicy::audited(),
"自定义窗口背景图开关(默认关;开启时设置页给出对比度提示)",
))
.with(SettingSpec::text(
"appearance.bg_image",
"",
512,
AiPolicy::audited(),
"背景图路径(PNG;空 = 未设置。Slint 默认解码器只带 PNG)",
))
.with(SettingSpec::enumeration(
"appearance.bg_fill",
"cover",
super::appearance::BG_FILLS,
AiPolicy::audited(),
"背景图填充模式:cover(铺满裁切)/ contain(完整留底色)/ tile(平铺)",
))
.with(SettingSpec::float(
"appearance.bg_dim",
0.35,
(
super::appearance::BG_DIM_MIN,
super::appearance::BG_DIM_MAX,
),
AiPolicy::audited(),
"背景图叠加暗化层强度(越大前景越清晰;09 §101 的对比度保底手法)",
))
.with(SettingSpec::float(
"appearance.panel_alpha",
1.0,
(super::appearance::PANEL_ALPHA_MIN, 1.0),
AiPolicy::audited(),
"全局面板半透明度(卡片/导航/AI 栏统一透明度;1.0 = 出厂不透明)",
))
.with(SettingSpec::bool(
"appearance.glass",
false,
AiPolicy::audited(),
"玻璃拟态卡片(背景图预生成模糊层作卡片底;Slint 无 backdrop-filter 的替代)",
))
.with(SettingSpec::text(
"appearance.accent_color",
"",
9,
AiPolicy::audited(),
"自定义强调色 #RRGGBB(空 = 出厂 #0078D4;accent 令牌全域生效)",
))
// 已废弃:D17 定案 appearance.theme_mode 为准(旧值读兼容映射,写永远走新键)。
.with(
SettingSpec::new(
"ui.theme",
super::spec::ValueKind::Enum(LEGACY_THEMES),
serde_json::Value::Null,
AiPolicy::retired(),
"【已废弃】改用 appearance.theme_mode;旧值读兼容:system→auto / metro_light→light / metro_dark→dark",
)
.deprecated("appearance.theme_mode", THEME_ALIASES),
)
.with(SettingSpec::float(
"ui.scale",
1.0,
(0.75, 2.0),
AiPolicy::silent(),
"界面缩放系数",
))
.with(SettingSpec::int(
"ui.window.width",
1280,
(320, 16384),
AiPolicy::silent(),
"主窗口宽度(逻辑像素)",
))
.with(SettingSpec::int(
"ui.window.height",
800,
(200, 16384),
AiPolicy::silent(),
"主窗口高度(逻辑像素)",
))
.with(SettingSpec::enumeration(
"log.level",
"info",
LOG_LEVELS,
AiPolicy::audited(),
"启动器日志级别",
))
}
+222
View File
@@ -0,0 +1,222 @@
//! 迁移链:`schemaVersion N → N+1` 顺序迁移框架 + 收据闸门(防重放)。
//!
//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §3(迁移链 + 迁移收据)、
//! `DevDocs/analyse/07-settings-packaging-update.md` §1.8(收据存源文件 `sourceSHA256`)。
//!
//! 与 HMCL 的差别:HMCL 完全靠 `$schema` 字段做兼容、迁移只处理遗留格式;ULL 在这之上补一条
//! 「major 逐级迁移链」——每一步只跨一个 major,缺环/重复/逆序在 `push` 时就报错,
//! 避免出现「跳版迁移脚本」这种组合爆炸。
use std::path::Path;
use serde_json::{Map, Value};
use crate::platform::error::PlatformResult;
use crate::platform::receipt::{self, MigrationReceipt, ReplayDecision};
use super::error::{SettingError, SettingResult};
use super::schema::{SchemaVersion, SCHEMA_VERSION_KEY};
/// 一步迁移。实现者只管把 `root` 从 `from` 改造成 `to`。
pub trait SettingMigration: Send + Sync {
/// 起始版本。
fn from(&self) -> SchemaVersion;
/// 目标版本(必须等于 `from.next_major()`)。
fn to(&self) -> SchemaVersion;
/// 一句话说明(进日志与迁移报告)。
fn describe(&self) -> &'static str;
/// 就地改 JSON 对象;`Err(String)` 会被包成 [`SettingError::Migration`]。
///
/// # Errors
/// 结构不符合预期(该有的键没有/类型不对)时返回原因。
fn apply(&self, root: &mut Map<String, Value>) -> Result<(), String>;
}
/// 迁移报告(进日志 / 遥测 / 收据)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationReport {
/// 起始版本。
pub from: SchemaVersion,
/// 结束版本。
pub to: SchemaVersion,
/// 依次执行的步骤说明。
pub applied: Vec<&'static str>,
}
impl MigrationReport {
/// 是否真的做了迁移。
#[must_use]
pub fn is_noop(&self) -> bool {
self.applied.is_empty()
}
}
/// 有序迁移链。
#[derive(Default)]
pub struct MigrationChain {
steps: Vec<Box<dyn SettingMigration>>,
}
impl std::fmt::Debug for MigrationChain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_list()
.entries(self.steps.iter().map(|step| step.describe()))
.finish()
}
}
impl MigrationChain {
/// 空链。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 追加一步;必须与上一环首尾相接,且只跨一个 major。
///
/// # Errors
/// 跨多个 major、起始版本重复、顺序错乱时返回 [`SettingError::ChainBroken`]。
pub fn push(&mut self, step: Box<dyn SettingMigration>) -> SettingResult<()> {
let from = step.from();
let to = step.to();
if to != from.next_major() {
return Err(SettingError::ChainBroken {
reason: format!("一步迁移只能跨一个 major:{from} → {to}"),
});
}
if self.steps.iter().any(|existing| existing.from() == from) {
return Err(SettingError::ChainBroken {
reason: format!("{from} 已有迁移步骤"),
});
}
if let Some(last) = self.steps.last() {
if last.to() != from {
return Err(SettingError::ChainBroken {
reason: format!("顺序错误:上一环到 {},本环从 {from}", last.to()),
});
}
}
self.steps.push(step);
Ok(())
}
/// 步骤数。
#[must_use]
pub fn len(&self) -> usize {
self.steps.len()
}
/// 是否没有步骤。
#[must_use]
pub fn is_empty(&self) -> bool {
self.steps.is_empty()
}
/// 步骤说明清单(日志用)。
#[must_use]
pub fn describe(&self) -> Vec<&'static str> {
self.steps.iter().map(|step| step.describe()).collect()
}
/// 链能达到的最新版本。
#[must_use]
pub fn latest(&self) -> Option<SchemaVersion> {
self.steps.last().map(|step| step.to())
}
/// 从 `from` 起要执行的步骤(含 `from` 那一步)。
///
/// # Errors
/// 链里没有以 `from` 开头的步骤(版本不认识)时返回 [`SettingError::ChainBroken`]。
pub fn plan_from(&self, from: SchemaVersion) -> SettingResult<Vec<&dyn SettingMigration>> {
let start = self
.steps
.iter()
.position(|step| step.from() == from)
.ok_or_else(|| SettingError::ChainBroken {
reason: format!("链里没有以 {from} 起始的迁移步骤"),
})?;
Ok(self.steps[start..]
.iter()
.map(|step| step.as_ref())
.collect())
}
/// 顺序执行迁移,并把 `schemaVersion` 镜像更新成最终 major。
///
/// # Errors
/// 缺环 → [`SettingError::ChainBroken`];某步失败 → [`SettingError::Migration`]。
pub fn migrate(
&self,
from: SchemaVersion,
root: &mut Map<String, Value>,
) -> SettingResult<MigrationReport> {
let plan = self.plan_from(from)?;
let mut current = from;
let mut applied = Vec::with_capacity(plan.len());
for step in plan {
step.apply(root).map_err(|reason| SettingError::Migration {
from: current,
to: step.to(),
reason,
})?;
current = step.to();
applied.push(step.describe());
}
root.insert(SCHEMA_VERSION_KEY.to_owned(), Value::from(current.major));
Ok(MigrationReport {
from,
to: current,
applied,
})
}
}
/// 收据闸门:迁移前问一句「这次该不该跑」。
#[derive(Debug, Clone, Copy)]
pub struct MigrationGate<'a> {
receipt_path: &'a Path,
now_ms: u64,
}
impl<'a> MigrationGate<'a> {
/// 绑定收据路径与当前时间(毫秒 since UNIX_EPOCH,注入便于测试)。
#[must_use]
pub const fn new(receipt_path: &'a Path, now_ms: u64) -> Self {
Self {
receipt_path,
now_ms,
}
}
/// 当前时间。
#[must_use]
pub const fn now_ms(&self) -> u64 {
self.now_ms
}
/// 读收据并判断是否重放。
///
/// # Errors
/// 收据损坏或读失败时返回 [`crate::platform::error::PlatformError`]。
pub fn evaluate(&self, source_fingerprint: &str) -> PlatformResult<ReplayDecision> {
let existing = receipt::read(self.receipt_path)?;
Ok(receipt::decide(existing.as_ref(), source_fingerprint))
}
/// 是否该执行迁移(收据在且源指纹相同 → `false`)。
///
/// # Errors
/// 同 [`Self::evaluate`]。
pub fn should_run(&self, source_fingerprint: &str) -> PlatformResult<bool> {
Ok(self.evaluate(source_fingerprint)?.should_replay())
}
/// 记录一次迁移收据。
///
/// # Errors
/// 写盘失败时返回 [`crate::platform::error::PlatformError`]。
pub fn record(&self, receipt: &MigrationReceipt) -> PlatformResult<()> {
receipt::write(self.receipt_path, receipt)
}
}
+36
View File
@@ -0,0 +1,36 @@
//! 配置与数据目录:三模式数据根(Portable 默认 / Standard / Custom)、schema 化配置、原子写、迁移收据。
//!
//! 设计定稿:`DevDocs/PlanDoc/04-data-and-config.md`(决策 D6)。
//! 刻意把 setting 放在 core 而不是 UI 层——这是 HMCL 的架构债
//! (`DevDocs/analyse/01-architecture-overview.md` §3.5),我们反过来做。
//!
//! 与其它模块的契约:所有模块的配置读写统一走本模块的 schema API,
//! 模块自有配置键需先在 `DevDocs/SubAgentsTalk/` 报备(见该目录 README 的对齐点)。
//! 暴露给各模块的签名清单见 `DevDocs/SubAgentsTalk/setting-接口约定.md`。
//!
//! 公共面(M12 已落地):
//! - [`schema`]:`$schema` / `schemaVersion` 标识、8 态兼容判定 → 三档访问权;
//! - [`spec`] + [`catalog`]:键元数据(类型/范围、默认值)与键目录;
//! - [`policy`]:AI 可写白名单与敏感分级(`setting::spec::AiPolicy` 仍是兼容别名);
//! - [`keys`] / [`instance_keys`]:`launcher.json`、`profiles.json`、`instances/<id>.json` 的键表;
//! - [`appearance`]:D17「外观(高级)」键簇的取值快照与校验(主题三态 / 背景图 / 面板透明度 /
//! 玻璃拟态 / 强调色);
//! - [`document`]:内存文档(点分键、脏字段、未知字段保留、默认值不落盘);
//! - [`store`]:单文件加载降级 + 原子保存 + 坏文件备份;
//! - [`save`] / [`save_worker`]:200ms 去抖攒批与后台保存线程;
//! - [`migrate`]:`schemaVersion N → N+1` 迁移链 + 收据闸门。
pub mod appearance;
pub mod catalog;
pub mod document;
pub mod error;
pub mod instance_keys;
pub mod json_path;
pub mod keys;
pub mod migrate;
pub mod policy;
pub mod save;
pub mod save_worker;
pub mod schema;
pub mod spec;
pub mod store;
+92
View File
@@ -0,0 +1,92 @@
//! 键的 AI 边界:可写白名单与敏感分级(弹窗确认)。
//!
//! 出处:`DevDocs/PlanDoc/02-ai-agent-design.md` §3.1(AI 安全边界:白名单 + 类型/范围校验 +
//! 分级确认)、`DevDocs/PlanDoc/04-data-and-config.md` §3(schema 化)。
//!
//! 从 [`super::spec`] 拆出来的原因很实际:`spec.rs` 摸到 400 行硬上限(`guards.yml` 的
//! file-size 门禁),而「策略」与「键的形状」本来就是两件事。
/// 敏感分级:决定 AI 改这个键时要不要弹窗。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Sensitivity {
/// 普通键:静默生效。
Low,
/// 静默生效但审计标记(便于回看「AI 动过什么」)。
Medium,
/// 敏感键:必须 UI 弹窗确认。
High,
}
impl Sensitivity {
/// 稳定名。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
/// 是否必须人工确认。
#[must_use]
pub const fn requires_confirmation(&self) -> bool {
matches!(self, Self::High)
}
}
/// 一个键的 AI 可写策略。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AiPolicy {
/// AI 是否允许写这个键(白名单)。
pub writable: bool,
/// 敏感分级。
pub sensitivity: Sensitivity,
}
impl AiPolicy {
/// 普通键:AI 可写,静默生效。
#[must_use]
pub const fn silent() -> Self {
Self {
writable: true,
sensitivity: Sensitivity::Low,
}
}
/// 可写但要审计标记。
#[must_use]
pub const fn audited() -> Self {
Self {
writable: true,
sensitivity: Sensitivity::Medium,
}
}
/// 可写但必须弹窗确认(java 路径 / 数据目录 / 代理 / AI 端点 / 隔离档位)。
#[must_use]
pub const fn sensitive() -> Self {
Self {
writable: true,
sensitivity: Sensitivity::High,
}
}
/// 已废弃键:AI 不可写,读兼容映射到替代键后由替代键承担策略。
#[must_use]
pub const fn retired() -> Self {
Self {
writable: false,
sensitivity: Sensitivity::Low,
}
}
/// 锁死:AI 永远不能写(凭据/密钥,D9「AI 永不触凭据」)。
#[must_use]
pub const fn locked() -> Self {
Self {
writable: false,
sensitivity: Sensitivity::High,
}
}
}
+217
View File
@@ -0,0 +1,217 @@
//! 保存通道的**去抖攒批**部分(纯逻辑,可注入时钟,确定性单测)。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileSaver`):
//! 入队 → 消费线程先 `sleep(200ms)` 等更多改动 → 全量 drain 去重 → 逐条 `saveSync`(tmp + move)。
//! 效果:一次窗口拖拽只产生一次写盘,而不是 60 次。
//!
//! 分层:本文件只管「什么时候写、写什么」;真正落盘由 [`JobSink`] 决定
//! (生产用 [`AtomicSink`],测试用记录型 sink)。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::error::SettingResult;
/// 默认去抖窗口(analyse/07 §1.6 的 200ms)。
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(200);
/// 一条待写任务。
#[derive(Debug, Clone, PartialEq, Eq)]
struct Pending {
payload: Vec<u8>,
queued_at: Instant,
}
/// 按 key 去重的去抖队列:同一 key 的新内容覆盖旧内容,但**保留首次入队时间**
/// (窗口从第一次改动开始算,避免连续改动把写盘无限推迟)。
#[derive(Debug, Clone)]
pub struct DebounceQueue<K: Ord> {
debounce: Duration,
pending: BTreeMap<K, Pending>,
}
impl<K: Ord + Clone> DebounceQueue<K> {
/// 新建一个去抖队列。
#[must_use]
pub fn new(debounce: Duration) -> Self {
Self {
debounce,
pending: BTreeMap::new(),
}
}
/// 去抖窗口。
#[must_use]
pub const fn debounce(&self) -> Duration {
self.debounce
}
/// 入队(同 key 覆盖内容)。返回 `true` 表示是新增条目。
pub fn enqueue(&mut self, key: K, payload: impl Into<Vec<u8>>, now: Instant) -> bool {
match self.pending.get_mut(&key) {
Some(existing) => {
existing.payload = payload.into();
false
}
None => {
self.pending.insert(
key,
Pending {
payload: payload.into(),
queued_at: now,
},
);
true
}
}
}
/// 待写条目数。
#[must_use]
pub fn len(&self) -> usize {
self.pending.len()
}
/// 是否没有待写任务。
#[must_use]
pub fn is_empty(&self) -> bool {
self.pending.is_empty()
}
/// 某 key 是否在队列里。
#[must_use]
pub fn contains(&self, key: &K) -> bool {
self.pending.contains_key(key)
}
/// 最早该写盘的时刻。
#[must_use]
pub fn next_due(&self) -> Option<Instant> {
self.pending
.values()
.map(|pending| pending.queued_at + self.debounce)
.min()
}
/// 取出所有已过窗口的任务(按键排序,保证写盘顺序稳定)。
pub fn drain_due(&mut self, now: Instant) -> Vec<(K, Vec<u8>)> {
let due: Vec<K> = self
.pending
.iter()
.filter(|(_, pending)| pending.queued_at + self.debounce <= now)
.map(|(key, _)| key.clone())
.collect();
self.take(due)
}
/// 取出全部任务(关停兜底)。
pub fn drain_all(&mut self) -> Vec<(K, Vec<u8>)> {
let all: Vec<K> = self.pending.keys().cloned().collect();
self.take(all)
}
fn take(&mut self, keys: Vec<K>) -> Vec<(K, Vec<u8>)> {
keys.into_iter()
.filter_map(|key| {
self.pending
.remove(&key)
.map(|pending| (key, pending.payload))
})
.collect()
}
}
impl<K: Ord + Clone> Default for DebounceQueue<K> {
fn default() -> Self {
Self::new(DEFAULT_DEBOUNCE)
}
}
/// 真正落盘的执行者(测试可注入记录型实现)。
pub trait JobSink {
/// 写一个文件。
///
/// # Errors
/// 落盘失败时返回 [`super::error::SettingError`]。
fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()>;
}
/// 生产用 sink:tmp → fsync → rename。
#[derive(Debug, Clone, Copy, Default)]
pub struct AtomicSink;
impl JobSink for AtomicSink {
fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()> {
crate::platform::atomic::write_atomic(path, payload)?;
Ok(())
}
}
/// 去抖队列 + sink 的组合体(app 层持有它,或交给 [`super::save_worker::SaveWorker`] 托管)。
#[derive(Debug, Clone)]
pub struct CoalescingWriter<S: JobSink> {
queue: DebounceQueue<PathBuf>,
sink: S,
}
impl<S: JobSink> CoalescingWriter<S> {
/// 组合去抖队列与 sink。
#[must_use]
pub fn new(sink: S, debounce: Duration) -> Self {
Self {
queue: DebounceQueue::new(debounce),
sink,
}
}
/// 入队一次写盘。
pub fn queue(&mut self, path: impl Into<PathBuf>, payload: impl Into<Vec<u8>>, now: Instant) {
self.queue.enqueue(path.into(), payload, now);
}
/// 待写条目数。
#[must_use]
pub fn pending_len(&self) -> usize {
self.queue.len()
}
/// 是否没有待写任务。
#[must_use]
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
/// 最早该写盘的时刻。
#[must_use]
pub fn next_due(&self) -> Option<Instant> {
self.queue.next_due()
}
/// 写掉已过窗口的任务,返回写了几个文件。
///
/// # Errors
/// sink 落盘失败时返回 [`super::error::SettingError`]。
pub fn flush_due(&mut self, now: Instant) -> SettingResult<usize> {
let jobs = self.queue.drain_due(now);
self.run(jobs)
}
/// 写掉全部任务(关停兜底),返回写了几个文件。
///
/// # Errors
/// sink 落盘失败时返回 [`super::error::SettingError`]。
pub fn flush_all(&mut self) -> SettingResult<usize> {
let jobs = self.queue.drain_all();
self.run(jobs)
}
fn run(&mut self, jobs: Vec<(PathBuf, Vec<u8>)>) -> SettingResult<usize> {
let mut written = 0usize;
for (path, payload) in jobs {
self.sink.write(&path, &payload)?;
written += 1;
}
Ok(written)
}
}
+181
View File
@@ -0,0 +1,181 @@
//! 去抖保存的线程封装:app 层持有它,界面改配置只入队,后台线程负责攒批落盘。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileSaver` 消费线程 +
//! 关停钩子 `waitForAllSaves()`)。本实现的「攒批/去抖/落盘」逻辑在 [`super::save`],
//! 这里只做线程与命令通道,方便对时序逻辑保持确定性单测。
use std::io;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use super::error::{SettingError, SettingResult};
use super::save::{AtomicSink, CoalescingWriter, DEFAULT_DEBOUNCE};
/// 线程没起时的兜底路径标记(只为让 [`SettingError::Io`] 有出处)。
const WORKER_PATH: &str = "<save-worker>";
enum Command {
Queue { path: PathBuf, payload: Vec<u8> },
Flush(Sender<SettingResult<()>>),
Shutdown(Sender<SettingResult<()>>),
}
/// 后台保存线程句柄。
#[derive(Debug)]
pub struct SaveWorker {
tx: Option<Sender<Command>>,
handle: Option<JoinHandle<()>>,
}
impl SaveWorker {
/// 用默认 200ms 去抖窗口起线程。
///
/// # Errors
/// 线程创建失败时返回 [`SettingError::Io`]。
pub fn spawn_default() -> SettingResult<Self> {
Self::spawn(DEFAULT_DEBOUNCE)
}
/// 用指定去抖窗口起线程。
///
/// # Errors
/// 线程创建失败时返回 [`SettingError::Io`]。
pub fn spawn(debounce: Duration) -> SettingResult<Self> {
let (tx, rx) = mpsc::channel();
let handle = thread::Builder::new()
.name("ull-save".to_owned())
.spawn(move || run_loop(&rx, debounce))
.map_err(io_error)?;
Ok(Self {
tx: Some(tx),
handle: Some(handle),
})
}
/// 入队一次写盘(不阻塞)。
///
/// # Errors
/// 保存线程已停止时返回 [`SettingError::Io`]。
pub fn queue(
&self,
path: impl Into<PathBuf>,
payload: impl Into<Vec<u8>>,
) -> SettingResult<()> {
let command = Command::Queue {
path: path.into(),
payload: payload.into(),
};
self.send(command)
}
/// 同步落盘(返回时队列已空,且错误已带回当前线程)。
///
/// # Errors
/// 保存线程已停止,或后台落盘失败时返回 [`SettingError`]。
pub fn flush(&self) -> SettingResult<()> {
let (ack_tx, ack_rx) = mpsc::channel();
self.send(Command::Flush(ack_tx))?;
ack_rx.recv().map_err(|_| {
io_error(io::Error::new(
io::ErrorKind::BrokenPipe,
"保存线程未回传结果",
))
})?
}
/// drain 全部任务后停止线程。
///
/// # Errors
/// 落盘失败时返回 [`SettingError`]。
pub fn shutdown(&mut self) -> SettingResult<()> {
let Some(tx) = self.tx.take() else {
return Ok(());
};
let (ack_tx, ack_rx) = mpsc::channel();
let sent = tx.send(Command::Shutdown(ack_tx));
let result = match sent {
Ok(()) => ack_rx.recv().unwrap_or_else(|_| {
Err(io_error(io::Error::new(
io::ErrorKind::BrokenPipe,
"保存线程未回传结果",
)))
}),
Err(_) => Ok(()),
};
if let Some(handle) = self.handle.take() {
handle.join().ok();
}
result
}
fn send(&self, command: Command) -> SettingResult<()> {
let Some(tx) = self.tx.as_ref() else {
return Err(io_error(io::Error::new(
io::ErrorKind::BrokenPipe,
"保存线程已停止",
)));
};
tx.send(command)
.map_err(|_| io_error(io::Error::new(io::ErrorKind::BrokenPipe, "保存线程已停止")))
}
}
impl Drop for SaveWorker {
fn drop(&mut self) {
self.shutdown().ok();
}
}
fn run_loop(rx: &Receiver<Command>, debounce: Duration) {
let mut writer = CoalescingWriter::new(AtomicSink, debounce);
let mut last_error: Option<SettingError> = None;
loop {
let wait = writer
.next_due()
.map(|due| due.saturating_duration_since(Instant::now()))
.unwrap_or(Duration::from_secs(3600))
.max(Duration::from_millis(1));
match rx.recv_timeout(wait) {
Ok(Command::Queue { path, payload }) => {
writer.queue(path, payload, Instant::now());
}
Ok(Command::Flush(ack)) => {
let result = combine(last_error.take(), writer.flush_all());
ack.send(result).ok();
}
Ok(Command::Shutdown(ack)) => {
let result = combine(last_error.take(), writer.flush_all());
ack.send(result).ok();
break;
}
Err(RecvTimeoutError::Timeout) => {
if let Err(err) = writer.flush_due(Instant::now()) {
last_error = Some(err);
}
}
Err(RecvTimeoutError::Disconnected) => {
writer.flush_all().ok();
break;
}
}
}
}
/// 后台静默失败过的错误优先上报(不吞掉)。
fn combine(previous: Option<SettingError>, result: SettingResult<usize>) -> SettingResult<()> {
match previous {
Some(err) => Err(err),
None => result.map(|_| ()),
}
}
fn io_error(source: io::Error) -> SettingError {
SettingError::Io {
path: PathBuf::from(WORKER_PATH),
source,
}
}
+292
View File
@@ -0,0 +1,292 @@
//! `$schema` 与 `schemaVersion`:版本标识、8 态兼容判定、三档访问权。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.5(`JsonSchema.check` 8 态 +
//! `SettingFileAccess` 三档),`DevDocs/PlanDoc/04-data-and-config.md` §3。
//!
//! 我们抄的是「一个 `$schema` 字段决定可读/可写/是否保留原 schema」,不抄 HMCL 的
//! `major.minor.patch` 三级分支——ULL 的迁移链按 **major** 走:`1.0.0 → 2.0.0 → 3.0.0`。
use std::fmt;
use serde_json::Value;
/// `$schema` 键名。
pub const SCHEMA_KEY: &str = "$schema";
/// `schemaVersion` 键名(整数镜像 = major)。
pub const SCHEMA_VERSION_KEY: &str = "schemaVersion";
/// schema URL 前缀(逻辑标识,暂不对外发布)。
pub const SCHEMA_URL_PREFIX: &str = "https://schemas.unlucky-launcher.dev/ull/";
/// 配置文件的语义版本。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub struct SchemaVersion {
/// 主版本(迁移链单位)。
pub major: u32,
/// 次版本(新版本文件只读)。
pub minor: u32,
/// 修订号。
pub patch: u32,
}
impl SchemaVersion {
/// 构造。
#[must_use]
pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
/// 解析 `1.0.0` 形式。
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
let mut parts = text.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some(Self::new(major, minor, patch))
}
/// 下一个主版本(迁移链的一步)。
#[must_use]
pub const fn next_major(&self) -> Self {
Self::new(self.major + 1, 0, 0)
}
}
impl fmt::Display for SchemaVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
/// 配置文件种类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SchemaId(&'static str);
impl SchemaId {
/// `config/launcher.json`。
pub const LAUNCHER: Self = Self("launcher");
/// `config/profiles.json`。
pub const PROFILES: Self = Self("profiles");
/// `config/instances/<id>.json`。
pub const INSTANCE: Self = Self("instance");
/// 全部已知 id。
pub const ALL: [Self; 3] = [Self::LAUNCHER, Self::PROFILES, Self::INSTANCE];
/// 稳定名。
#[must_use]
pub const fn as_str(&self) -> &'static str {
self.0
}
/// 从字符串解析(只认已知 id)。
#[must_use]
pub fn parse(text: &str) -> Option<Self> {
Self::ALL.into_iter().find(|id| id.0 == text)
}
}
/// `$schema` 的完整标识:种类 + 版本。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SchemaRef {
/// 种类。
pub id: SchemaId,
/// 版本。
pub version: SchemaVersion,
}
impl SchemaRef {
/// 构造。
#[must_use]
pub const fn new(id: SchemaId, version: SchemaVersion) -> Self {
Self { id, version }
}
/// 写进文件的 `$schema` 字符串。
#[must_use]
pub fn url(&self) -> String {
format!(
"{SCHEMA_URL_PREFIX}{}/{}.{}.{}",
self.id.as_str(),
self.version.major,
self.version.minor,
self.version.patch
)
}
/// 反解 `$schema` 字符串。
#[must_use]
pub fn parse_url(text: &str) -> Option<Self> {
let tail = text.strip_prefix(SCHEMA_URL_PREFIX)?;
let (id, version) = tail.split_once('/')?;
Some(Self::new(
SchemaId::parse(id)?,
SchemaVersion::parse(version)?,
))
}
}
/// 文件与本实现期望 schema 的兼容状态(8 态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaCompat {
/// 同版本或更旧的版本:可读可写,正常升级。
ReadWrite,
/// 同 minor、更高 patch:可读可写,但保留原 `$schema`。
ReadWritePreserveSchema,
/// 同 major、更高 minor:可读不可写(新版写的文件),保留原 `$schema`。
ReadOnlyPreserveSchema,
/// 文件没有 `$schema`(首建 / 遗留格式)。
Missing,
/// `$schema` 不是我们能解析的 URL。
///
/// 手写配置最容易踩的一格:前缀写错(`https://unlucky-launcher.local/schema/...`)后整份
/// 文件的价值都不该被丢掉,所以 M40 起按**只读**处理——值照读、不静默回落默认值,
/// 同时由 [`super::document::LoadProblem::UnknownSchema`] 明确告警。
Invalid,
/// 文件连 JSON 都解析不了(由加载方给出)。
Unparseable,
/// `$schema` 指向别的配置文件种类。
UnexpectedId,
/// 文件 major 高于本实现——不认识,绝不能覆盖。
UnsupportedMajor,
}
impl SchemaCompat {
/// 判定文件里的 `$schema`(缺失时由调用方给 [`Self::Missing`])。
#[must_use]
pub fn check(value: &Value, expected: SchemaRef) -> Self {
match value.get(SCHEMA_KEY).and_then(Value::as_str) {
None => Self::Missing,
Some(url) => Self::check_url(url, expected),
}
}
/// 判定 `$schema` 字符串。
#[must_use]
pub fn check_url(url: &str, expected: SchemaRef) -> Self {
let Some(found) = SchemaRef::parse_url(url) else {
return Self::Invalid;
};
if found.id != expected.id {
return Self::UnexpectedId;
}
if found.version.major > expected.version.major {
return Self::UnsupportedMajor;
}
if found.version.major < expected.version.major {
// 旧文件:正常升级(迁移链负责搬运语义)。
return Self::ReadWrite;
}
if found.version.minor > expected.version.minor {
return Self::ReadOnlyPreserveSchema;
}
if found.version.minor == expected.version.minor
&& found.version.patch > expected.version.patch
{
return Self::ReadWritePreserveSchema;
}
Self::ReadWrite
}
/// 稳定名(日志 / 收据)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::ReadWrite => "read_write",
Self::ReadWritePreserveSchema => "read_write_preserve_schema",
Self::ReadOnlyPreserveSchema => "read_only_preserve_schema",
Self::Missing => "missing",
Self::Invalid => "invalid",
Self::Unparseable => "unparseable",
Self::UnexpectedId => "unexpected_id",
Self::UnsupportedMajor => "unsupported_major",
}
}
/// 是否可读。
#[must_use]
pub const fn readable(&self) -> bool {
matches!(
self,
Self::ReadWrite
| Self::ReadWritePreserveSchema
| Self::ReadOnlyPreserveSchema
| Self::Missing
| Self::Invalid
)
}
/// 保存时是否沿用文件里原有的 `$schema` 字符串(不降级覆盖新版本标记)。
#[must_use]
pub const fn preserve_schema(&self) -> bool {
matches!(
self,
Self::ReadWritePreserveSchema | Self::ReadOnlyPreserveSchema
)
}
/// 映射到运行时三档访问权。
///
/// [`Self::Invalid`] 走**只读**:认不出的 `$schema` 前缀不该让整份配置失效(值照读,
/// 由 [`super::document::LoadProblem::UnknownSchema`] 告警),但也不该被我们悄悄改写。
#[must_use]
pub const fn access(&self) -> FileAccess {
match self {
Self::ReadWrite | Self::ReadWritePreserveSchema | Self::Missing => {
FileAccess::ReadWrite
}
Self::ReadOnlyPreserveSchema | Self::Invalid => FileAccess::ReadOnly,
Self::Unparseable | Self::UnexpectedId | Self::UnsupportedMajor => {
FileAccess::Unreadable
}
}
}
}
/// 运行时访问权三档(锁文件/权限异常时降级运行而非崩溃)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileAccess {
/// 可读可写。
ReadWrite,
/// 可读不可写(新版文件 / 权限受限)。
ReadOnly,
/// 读不了:值不可信,全程不写(防止覆盖新版本配置)。
Unreadable,
}
impl FileAccess {
/// 能否使用文件里的值。
#[must_use]
pub const fn can_read(&self) -> bool {
!matches!(self, Self::Unreadable)
}
/// 能否保存。
#[must_use]
pub const fn can_save(&self) -> bool {
matches!(self, Self::ReadWrite)
}
/// UI 是否该禁用编辑。
#[must_use]
pub const fn blocks_editing(&self) -> bool {
!self.can_save()
}
/// 稳定名。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::ReadWrite => "read_write",
Self::ReadOnly => "read_only",
Self::Unreadable => "unreadable",
}
}
}
+331
View File
@@ -0,0 +1,331 @@
//! 配置键元数据:类型/范围、默认值、AI 可写白名单、敏感分级。
//!
//! 出处:`DevDocs/PlanDoc/04-data-and-config.md` §3(schema 化 + AI 可写键 = schema 子集)、
//! `DevDocs/PlanDoc/02-ai-agent-design.md` §3.1(AI 安全边界:白名单 + 类型/范围校验 + 分级确认)。
//!
//! 这份元数据是「唯一真相源」:UI 设置页、AI 的 `get_config`/`set_config`、审计日志都读它。
use serde_json::{json, Value};
/// 键的值类型与约束。
#[derive(Debug, Clone, PartialEq)]
pub enum ValueKind {
/// 布尔。
Bool,
/// 整数(含闭区间范围)。
Int {
/// 下界(含)。
min: i64,
/// 上界(含)。
max: i64,
},
/// 浮点(含闭区间范围)。
Float {
/// 下界(含)。
min: f64,
/// 上界(含)。
max: f64,
},
/// 文本(长度上限,按字符数)。
Text {
/// 最大字符数。
max_len: usize,
},
/// 枚举(只允许列出的字面量)。
Enum(&'static [&'static str]),
/// 路径(非空字符串;绝对/相对都由业务层决定)。
Path,
/// 字符串列表。
TextList,
/// 枚举字面量数组:每个元素都必须落在白名单里(`overrides` 这类键用)。
EnumList(&'static [&'static str]),
/// 任意 JSON(数据结构本身不参与校验,用于档案列表这类「数据而非配置」的键)。
Any,
}
impl ValueKind {
/// 校验;返回中文原因便于直接给用户看。
pub fn check(&self, value: &Value) -> Result<(), String> {
match self {
Self::Bool => value
.is_boolean()
.then_some(())
.ok_or_else(|| format!("期望布尔,实际是 {value}")),
Self::Int { min, max } => {
let Some(number) = value.as_i64() else {
return Err(format!("期望整数,实际是 {value}"));
};
if number < *min || number > *max {
return Err(format!("{number} 超出范围 [{min}, {max}]"));
}
Ok(())
}
Self::Float { min, max } => {
let Some(number) = value.as_f64() else {
return Err(format!("期望数字,实际是 {value}"));
};
if number < *min || number > *max {
return Err(format!("{number} 超出范围 [{min}, {max}]"));
}
Ok(())
}
Self::Text { max_len } => {
let Some(text) = value.as_str() else {
return Err(format!("期望文本,实际是 {value}"));
};
let len = text.chars().count();
if len > *max_len {
return Err(format!("文本 {len} 字符,超过上限 {max_len}"));
}
Ok(())
}
Self::Enum(allowed) => {
let Some(text) = value.as_str() else {
return Err(format!("期望枚举字面量,实际是 {value}"));
};
if allowed.contains(&text) {
Ok(())
} else {
Err(format!("{text:?} 不在 {allowed:?} 之中"))
}
}
Self::Path => match value.as_str() {
Some(text) if !text.trim().is_empty() => Ok(()),
_ => Err(format!("期望非空路径字符串,实际是 {value}")),
},
Self::TextList => {
let Some(items) = value.as_array() else {
return Err(format!("期望字符串数组,实际是 {value}"));
};
if items.iter().all(Value::is_string) {
Ok(())
} else {
Err("数组里含非字符串元素".to_owned())
}
}
Self::EnumList(allowed) => {
let Some(items) = value.as_array() else {
return Err(format!("期望枚举字面量数组,实际是 {value}"));
};
for item in items {
let Some(text) = item.as_str() else {
return Err(format!("数组里含非字符串元素: {item}"));
};
if !allowed.contains(&text) {
return Err(format!("{text:?} 不在 {allowed:?} 之中"));
}
}
Ok(())
}
Self::Any => Ok(()),
}
}
/// 转成 JSON Schema 片段(喂给 M16 的 `ToolSpec`)。
#[must_use]
pub(crate) fn to_json_schema(&self) -> Value {
match self {
Self::Bool => json!({ "type": "boolean" }),
Self::Int { min, max } => json!({ "type": "integer", "minimum": min, "maximum": max }),
Self::Float { min, max } => json!({ "type": "number", "minimum": min, "maximum": max }),
Self::Text { max_len } => json!({ "type": "string", "maxLength": max_len }),
Self::Enum(allowed) => json!({ "type": "string", "enum": allowed }),
Self::Path => json!({ "type": "string", "minLength": 1 }),
Self::TextList => json!({ "type": "array", "items": { "type": "string" } }),
Self::EnumList(allowed) => {
json!({ "type": "array", "items": { "type": "string", "enum": allowed } })
}
Self::Any => json!({}),
}
}
}
// AI 边界类型从 [`super::policy`] 拆出去(`spec.rs` 摸到 400 行硬上限),
// 路径保持不变:`setting::spec::AiPolicy` 仍是可用名。
pub use super::policy::{AiPolicy, Sensitivity};
/// 键废弃信息:替代键 + 旧值映射表。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Deprecation {
/// 替代键(写永远走这里)。
pub replaced_by: &'static str,
/// 旧值 → 新值映射(读兼容);表里没有的值原样带过去。
pub aliases: &'static [(&'static str, &'static str)],
}
impl Deprecation {
/// 把旧值翻译成新键能接受的值(非字符串或表里没有的值原样返回)。
#[must_use]
pub fn translate(&self, legacy: &Value) -> Value {
match legacy.as_str() {
Some(text) => self
.aliases
.iter()
.find(|(old, _)| *old == text)
.map_or_else(|| legacy.clone(), |(_, new)| Value::from(*new)),
None => legacy.clone(),
}
}
}
/// 一个配置键的完整元数据。
#[derive(Debug, Clone, PartialEq)]
pub struct SettingSpec {
/// 点分键名(如 `download.concurrency`)。
pub key: &'static str,
/// 类型与约束。
pub kind: ValueKind,
/// 默认值;`Value::Null` 表示「无默认」(如用户必须先选的路径)。
pub default: Value,
/// AI 策略。
pub ai: AiPolicy,
/// 一句话说明(UI 提示 + AI 工具描述)。
pub doc: &'static str,
/// 废弃信息:有值表示这个键只做**读兼容**,写必须走替代键。
pub deprecated: Option<Deprecation>,
}
impl SettingSpec {
/// 完全手工构造。
#[must_use]
pub const fn new(
key: &'static str,
kind: ValueKind,
default: Value,
ai: AiPolicy,
doc: &'static str,
) -> Self {
Self {
key,
kind,
default,
ai,
doc,
deprecated: None,
}
}
/// 标记为「已废弃,读兼容到 `replaced_by`」,并按 `aliases` 翻译旧值。
///
/// 用于键改名/语义变更(如 `ui.theme` → `appearance.theme_mode`,tower 键冲突裁决):
/// 旧文件照读、下次保存只写新键,AI 白名单与工具 JSON Schema 自动排除本键。
#[must_use]
pub const fn deprecated(
mut self,
replaced_by: &'static str,
aliases: &'static [(&'static str, &'static str)],
) -> Self {
self.deprecated = Some(Deprecation {
replaced_by,
aliases,
});
self
}
/// 该键是否已废弃。
#[must_use]
pub const fn is_deprecated(&self) -> bool {
self.deprecated.is_some()
}
/// 布尔键。
#[must_use]
pub fn bool(key: &'static str, default: bool, ai: AiPolicy, doc: &'static str) -> Self {
Self::new(key, ValueKind::Bool, Value::Bool(default), ai, doc)
}
/// 整数键(闭区间)。
#[must_use]
pub fn int(
key: &'static str,
default: i64,
(min, max): (i64, i64),
ai: AiPolicy,
doc: &'static str,
) -> Self {
Self::new(
key,
ValueKind::Int { min, max },
Value::from(default),
ai,
doc,
)
}
/// 浮点键(闭区间)。
#[must_use]
pub fn float(
key: &'static str,
default: f64,
(min, max): (f64, f64),
ai: AiPolicy,
doc: &'static str,
) -> Self {
Self::new(
key,
ValueKind::Float { min, max },
Value::from(default),
ai,
doc,
)
}
/// 文本键。
#[must_use]
pub fn text(
key: &'static str,
default: &str,
max_len: usize,
ai: AiPolicy,
doc: &'static str,
) -> Self {
Self::new(
key,
ValueKind::Text { max_len },
Value::from(default),
ai,
doc,
)
}
/// 枚举键。
#[must_use]
pub fn enumeration(
key: &'static str,
default: &'static str,
allowed: &'static [&'static str],
ai: AiPolicy,
doc: &'static str,
) -> Self {
Self::new(key, ValueKind::Enum(allowed), Value::from(default), ai, doc)
}
/// 路径键(无默认值,未设置时为 `null`)。
#[must_use]
pub fn path(key: &'static str, ai: AiPolicy, doc: &'static str) -> Self {
Self::new(key, ValueKind::Path, Value::Null, ai, doc)
}
/// 枚举字面量数组键(默认空数组;元素逐个校验)。
#[must_use]
pub fn enum_list(
key: &'static str,
allowed: &'static [&'static str],
ai: AiPolicy,
doc: &'static str,
) -> Self {
Self::new(
key,
ValueKind::EnumList(allowed),
Value::Array(Vec::new()),
ai,
doc,
)
}
/// 任意 JSON 键(数据结构不参与校验)。
#[must_use]
pub fn any(key: &'static str, ai: AiPolicy, doc: &'static str) -> Self {
Self::new(key, ValueKind::Any, Value::Null, ai, doc)
}
}
+301
View File
@@ -0,0 +1,301 @@
//! 一个配置文件 = 一个 [`SettingStore`]:加载降级 → 编辑 → 原子保存 → 坏文件备份。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.5-§1.6(8 态 schema / 三档访问权 /
//! 「文件损坏 → 默认值 + 下次保存前备份 `xxx.json.1`」),`DevDocs/PlanDoc/04-data-and-config.md` §3。
//!
//! 加载永不 panic:读不了/版本不认识就降级成只读或不可读,让启动器带着默认值继续跑
//! (analyse/07 §1「锁文件/权限异常时降级运行而非崩溃」)。
use std::io;
use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::platform::atomic;
use super::catalog::SettingSchema;
use super::document::{LoadProblem, SettingDocument};
use super::error::{SettingError, SettingResult};
use super::migrate::{MigrationChain, MigrationReport};
use super::schema::{FileAccess, SchemaCompat, SCHEMA_KEY, SCHEMA_VERSION_KEY};
/// 保存结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SaveOutcome {
/// 已原子落盘。
Saved(PathBuf),
/// 没有写盘,原因见 [`SkipReason`]。
Skipped(SkipReason),
}
impl SaveOutcome {
/// 是否真的写了盘。
#[must_use]
pub fn wrote(&self) -> bool {
matches!(self, Self::Saved(_))
}
}
/// 跳过保存的原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason {
/// 没有脏字段。
NotDirty,
/// 文件只读(`READ_ONLY`)。
ReadOnly,
/// 文件不可信(`UNREADABLE`),全程不写。
Unreadable,
}
impl SkipReason {
/// 稳定名。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::NotDirty => "not_dirty",
Self::ReadOnly => "read_only",
Self::Unreadable => "unreadable",
}
}
}
/// 单个配置文件的内存 + 磁盘视图。
#[derive(Debug, Clone)]
pub struct SettingStore {
path: PathBuf,
doc: SettingDocument,
access: FileAccess,
compat: SchemaCompat,
problems: Vec<LoadProblem>,
backup_on_next_save: bool,
}
impl SettingStore {
/// 加载(或首建)一个配置文件;永不失败,状态从 [`Self::access`] 读。
#[must_use]
pub fn load(path: impl Into<PathBuf>, schema: SettingSchema) -> Self {
let mut store = Self {
path: path.into(),
doc: SettingDocument::new(schema.clone()),
access: FileAccess::ReadWrite,
compat: SchemaCompat::Missing,
problems: Vec::new(),
backup_on_next_save: false,
};
store.reload_inner();
store
}
/// 文件路径。
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
/// 访问权三档。
#[must_use]
pub const fn access(&self) -> FileAccess {
self.access
}
/// `$schema` 兼容状态。
#[must_use]
pub const fn compat(&self) -> SchemaCompat {
self.compat
}
/// 加载时的问题清单(未知键 / 坏值 / 版本镜像不一致…)。
#[must_use]
pub fn problems(&self) -> &[LoadProblem] {
&self.problems
}
/// 只读视图。
#[must_use]
pub const fn doc(&self) -> &SettingDocument {
&self.doc
}
/// 可变视图(迁移 / 导入用;正常改配置请走 [`Self::set`],那里带访问权检查)。
pub const fn doc_mut(&mut self) -> &mut SettingDocument {
&mut self.doc
}
/// 生效值:文件值 → 默认值。
#[must_use]
pub fn effective(&self, key: &str) -> Option<&Value> {
self.doc.effective(key)
}
/// 是否有未落盘改动。
#[must_use]
pub fn is_dirty(&self) -> bool {
self.doc.is_dirty()
}
/// 改一个键(只读/不可读时直接拒绝,不静默丢改动)。
///
/// # Errors
/// 文件只读 → [`SettingError::ReadOnly`];不可读 → [`SettingError::Unreadable`];
/// 键值不合法 → [`SettingError::InvalidValue`] / [`SettingError::UnknownKey`]。
pub fn set(&mut self, key: &str, value: Value) -> SettingResult<()> {
match self.access {
FileAccess::ReadWrite => self.doc.set(key, value),
FileAccess::ReadOnly => Err(SettingError::ReadOnly {
path: self.path.clone(),
}),
FileAccess::Unreadable => Err(SettingError::Unreadable {
path: self.path.clone(),
compat: self.compat,
}),
}
}
/// 保存(只写脏字段、原子落盘;不满足条件时返回 [`SaveOutcome::Skipped`])。
///
/// # Errors
/// 序列化 / 备份 / 写盘失败时返回 [`SettingError`]。
pub fn save(&mut self) -> SettingResult<SaveOutcome> {
if !self.access.can_save() {
let reason = if matches!(self.access, FileAccess::ReadOnly) {
SkipReason::ReadOnly
} else {
SkipReason::Unreadable
};
return Ok(SaveOutcome::Skipped(reason));
}
if !self.doc.is_dirty() {
return Ok(SaveOutcome::Skipped(SkipReason::NotDirty));
}
if self.backup_on_next_save {
self.backup_existing()?;
}
self.write_document()?;
Ok(SaveOutcome::Saved(self.path.clone()))
}
/// 显式强写(用户在 UI 上确认「覆盖只读/损坏文件」后调用):先备份,再覆盖,恢复可写。
///
/// # Errors
/// 备份或写盘失败时返回 [`SettingError`]。
pub fn save_forced(&mut self) -> SettingResult<SaveOutcome> {
self.backup_existing()?;
self.write_document()?;
self.access = FileAccess::ReadWrite;
self.backup_on_next_save = false;
Ok(SaveOutcome::Saved(self.path.clone()))
}
/// 重新从磁盘加载(丢弃内存改动)。
pub fn reload(&mut self) {
self.reload_inner();
}
/// 按迁移链把文件从旧版本推进到 schema 版本(补丁后再落盘由调用方决定)。
///
/// # Errors
/// 链不完整或某步迁移失败时返回 [`SettingError`]。
pub fn apply_chain(
&mut self,
chain: &MigrationChain,
) -> SettingResult<Option<MigrationReport>> {
let schema = self.doc.schema().clone();
if !self.access.can_read() || self.doc.version() == schema.version() {
return Ok(None);
}
let Value::Object(mut root) = self.doc.to_value() else {
return Ok(None);
};
let report = chain.migrate(self.doc.version(), &mut root)?;
root.insert(
SCHEMA_KEY.to_owned(),
Value::String(schema.schema_ref().url()),
);
root.insert(
SCHEMA_VERSION_KEY.to_owned(),
Value::from(schema.version().major),
);
let (mut document, problems) = SettingDocument::load(&schema, &Value::Object(root));
document.set_version(schema.version());
document.mark_all_dirty();
self.doc = document;
self.problems = problems;
Ok(Some(report))
}
/// 下一个空位的备份路径(`launcher.json.1`、`.2`…)。
#[must_use]
pub fn next_backup_path(&self) -> PathBuf {
for index in 1..=999u32 {
let candidate = PathBuf::from(format!("{}.{index}", self.path.display()));
if !candidate.exists() {
return candidate;
}
}
PathBuf::from(format!("{}.1", self.path.display()))
}
fn reload_inner(&mut self) {
let schema = self.doc.schema().clone();
self.doc = SettingDocument::new(schema.clone());
self.problems.clear();
self.access = FileAccess::ReadWrite;
self.compat = SchemaCompat::Missing;
self.backup_on_next_save = false;
let bytes = match std::fs::read(&self.path) {
Ok(bytes) => bytes,
// 首建:可写 + 全默认值。
Err(err) if err.kind() == io::ErrorKind::NotFound => return,
// 读不了(权限/被占用):值不可信,全程不写。
Err(_) => {
self.access = FileAccess::Unreadable;
self.compat = SchemaCompat::Unparseable;
return;
}
};
match serde_json::from_slice::<Value>(&bytes) {
// 文件损坏:默认值 + 下次保存前先备份(不把坏文件当未知字段回写)。
Err(_) => {
self.compat = SchemaCompat::Unparseable;
self.backup_on_next_save = true;
}
Ok(value) => {
let compat = SchemaCompat::check(&value, schema.schema_ref());
self.compat = compat;
self.access = compat.access();
if !self.access.can_read() {
return;
}
let (document, problems) = SettingDocument::load(&schema, &value);
self.doc = document;
self.problems = problems;
}
}
}
fn write_document(&mut self) -> SettingResult<()> {
let value = self.doc.to_value();
let bytes = serde_json::to_vec_pretty(&value).map_err(|source| SettingError::Json {
path: self.path.clone(),
source,
})?;
atomic::write_atomic(&self.path, &bytes)?;
self.doc.mark_clean();
Ok(())
}
fn backup_existing(&mut self) -> SettingResult<Option<PathBuf>> {
if !self.path.exists() {
return Ok(None);
}
let backup = self.next_backup_path();
std::fs::rename(&self.path, &backup).map_err(|source| SettingError::Io {
path: self.path.clone(),
source,
})?;
Ok(Some(backup))
}
}