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 开发中。
302 lines
9.7 KiB
Rust
302 lines
9.7 KiB
Rust
//! 一个配置文件 = 一个 [`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))
|
||
}
|
||
}
|