Files
Unlucky-Launcher/crates/core/src/setting/store.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

302 lines
9.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 一个配置文件 = 一个 [`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))
}
}