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
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:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
"该次切换的提示是否已展示过(保证切换后首次启动只提示一次)",
|
||||
))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
"启动器日志级别",
|
||||
))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user