fix(coexist): 多启动器共存——存储模式写回同源 + 整合包缺省落点对齐 GUI + 版本清单不再静默覆盖(M216)

三条 M215 判定的共存债,一次修完:

① 存储模式切不回来(high)
- 写侧:`host::settings::set` 老实现一律写 `env.store()`(= 解析后数据根那份),而启动解析
  只读 `<exe>/.ULL/config/launcher.json` ⇒ Standard 下把模式改成 portable 写进了 `%APPDATA%`,
  下次启动看不见,切不回来。
- 现在 `core.*` 四个数据根键一律落便携那份(判据收在 `settings_bridge::setting_path` 一处,
  与 CLI `Ctx::setting_path_for` 同一契约),其余键照旧。
- 读侧:`settings_bridge` 删掉自己那份 `request_from_portable`/`DATA_ROOT_KEYS` 拷贝,改用
  `host::env` 的那份(presenter 侧两份逐字拷贝合成一处,不新增真相源)。
- 设置页 `core.data_custom_root` 的显示值也读便携那份,否则改完输入看起来凭空消失。

② `ull pack import` 缺省落点与 GUI 不一致(high)
- CLI 老缺省 = `ctx.game().root()`(共享 `.minecraft` 根);GUI 导入页落在
  `<data>/instances/<实例目录名>` 并登记。
- 新增 `core::game::install::pack_dir`(`pack_stem` / `instance_dir_name` / `slug` /
  `instance_dir` / `import_target`):命名与落点只有一份实现;CLI 调它,presenter 的
  `convert::{dir_name,pack_stem,slug}` 改成一行委托 core(对外签名不变)。
- CLI 缺省现在与 GUI 同源,并复用 GUI 那条「非空目录不复用」预检。

③ 装同版本静默覆盖 `versions/<id>/<id>.json`(medium)
- `write_version_json` 三档:不存在 → `Fresh`;逐字节相同 → `Identical`(一个字节都不写,
  重装同版本照旧正常);不同 → 先 copy 到 `<dest>.ullbak-<pid>` 再原子写 → `Replaced{backup}`。
- `InstallReport` 新增结构化字段 `manifest: Option<ManifestWrite>`,并把「已替换版本清单 +
  备份路径」推进 `report.warnings`(进 `--json` 的 `result.warnings`)。

用例(全部零网络,走真的 `execute`):三档各一条,各写抓得住/抓不住。
三个变异各跑一次(只改实现侧)都红:写回落点改回旧的、缺省改回 `.minecraft` 根、去掉备份只覆盖。
This commit is contained in:
AskaEth committed 2026-11-09 18:42:41 +08:00
1 parent ab5b593708
commit 96f15c79fb
14 files changed
+672 -118

No files matched your search

+30 -8
View File
@@ -21,7 +21,7 @@
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、
//! 数据根不可用),与其它命令一致。
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde_json::json;
@@ -31,6 +31,8 @@ use unlucky_core::addon::import::{
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::install::pack_dir;
use unlucky_core::platform::layout::LauncherLayout;
use crate::cli::PackImportArgs;
use crate::context::Ctx;
@@ -50,10 +52,8 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
)
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
}
let target = args
.target
.clone()
.unwrap_or_else(|| ctx.game().root().to_path_buf());
let layout = ctx.layout();
let (target, instance_name) = import_target(&layout, args)?;
let stages: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&stages);
@@ -68,7 +68,6 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
let engine = build_engine(ctx);
let fetcher = DownloadEngineFetcher::new(&engine);
let layout = ctx.layout();
let mut request = ImportRequest::new(&args.path, &target)
.with_fetcher(&fetcher)
.with_progress(sink);
@@ -76,7 +75,8 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
request = request.with_conflict_policy(ConflictPolicy::Overwrite);
}
if !args.no_register {
request = request.with_registration(&layout, args.name.as_deref());
// 登记名与目录名同源(GUI 也是这样:`instance_name` 缺省用包名)。
request = request.with_registration(&layout, Some(&instance_name));
}
let report = block_on(import_pack(&request));
@@ -84,6 +84,28 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
Ok(outcome(ctx, &report, &target, &seen, args))
}
/// 导入落点与登记名:**M216 ②** 起与 GUI 同源(`<data>/instances/<实例目录名>`,不是共享的
/// `.minecraft` 根),判据收在 core 的 [`pack_dir::import_target`],本函数只做错误码翻译。
///
/// # Errors
///
/// 缺省目录已存在且非空(`target_exists`)。
fn import_target(
layout: &LauncherLayout,
args: &PackImportArgs,
) -> Result<(PathBuf, String), CliError> {
pack_dir::import_target(
layout,
&args.path,
args.target.as_deref(),
args.name.as_deref(),
)
.map_err(|reason| {
CliError::user("target_exists", reason)
.with_hint("加 --name 换个实例名,或显式给 --target / --overwrite")
})
}
/// 组装 `Outcome`(三态 → 退出码 + 两种输出)。
fn outcome(
ctx: &Ctx,
@@ -116,7 +138,7 @@ fn outcome(
report.format.as_deref().unwrap_or("-"),
target.display(),
if args.target.is_none() {
"(缺省 = 数据根的共享游戏目录)"
"(缺省 = 新实例目录 <数据根>/instances/)"
} else {
""
}
@@ -40,6 +40,7 @@ pub async fn download_libraries(
InstallReport {
files: outcomes,
version_json: None,
manifest: None,
warnings: Vec::new(),
}
}
+70 -10
View File
@@ -51,7 +51,7 @@
//! `buffer_unordered` 支持「取消即清空队列」,本轮不做(不在 M105 的验收口径里)。
use std::future::poll_fn;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::pin::pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::task::Poll;
@@ -60,6 +60,7 @@ use futures_util::future::{select, Either};
use futures_util::stream::{self, StreamExt};
use super::plan::PlanFile;
use super::report::ManifestWrite;
use super::{InstallError, InstallErrorKind, InstallPlan};
use crate::download::verify;
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind, HashAlgo};
@@ -221,17 +222,28 @@ pub async fn execute_with_cancel(
// 版本清单只在**全量就绪**时落盘:`is_version_installed()` 以清单存在为判据,
// 半成品写进去会让 `instances list` 把"缺 jar 的版本"报成已安装。
let complete = outcomes.iter().all(|outcome| !outcome.failed());
let version_json = if complete {
write_version_json(&plan.version_json_path, plan)?;
Some(plan.version_json_path.clone())
let mut warnings = plan.warnings.clone();
let manifest = if complete {
let written = write_version_json(&plan.version_json_path, plan)?;
// **M216 ③**:替换了别人的清单要大声说一句(`report.warnings` 会进 CLI 的 `--json`,
// GUI 侧另有结构化的 `manifest` 字段可读)。内容相同(重装)不在此列。
if let Some(backup) = written.backup() {
warnings.push(format!(
"已替换版本清单 {}(旧的那份备份在 {}):这个 .minecraft 是共享的,别的启动器可能也用它",
plan.version_json_path.display(),
backup.display()
));
}
Some(written)
} else {
None
};
Ok(InstallReport {
files: outcomes,
version_json,
warnings: plan.warnings.clone(),
version_json: complete.then(|| plan.version_json_path.clone()),
manifest,
warnings,
})
}
@@ -322,13 +334,61 @@ pub fn current_bytes(file: &PlanFile) -> Option<u64> {
}
}
/// 写解析后的版本清单(原子写)。
fn write_version_json(path: &Path, plan: &InstallPlan) -> Result<(), super::InstallError> {
/// 写解析后的版本清单(原子写),并如实交代「原来那份怎么办」(**M216 ③**)。
///
/// 三条判据:
/// - 目标不存在 → [`ManifestWrite::Fresh`],正常写;
/// - 存在且内容**逐字节相同** → [`ManifestWrite::Identical`],**一个字节都不写**(重装同版本
/// 是正常操作:既不是冲突,也不该改别人的 mtime);
/// - 存在且内容不同(含「存在但读不出来」——读不了 ≠ 相同)→ 旧的那份先 **copy** 到
/// `<dest>.ullbak-<pid>`,再原子写 ⇒ [`ManifestWrite::Replaced`]。
///
/// 备份用 `copy` 而不是 `rename`:整合包导入的合并态用 rename 是因为它紧接着覆盖同一路径、
/// 失败还会回滚;这里若 rename 之后原子写失败(目标正被别的启动器占着),盘上会**一份清单
/// 都不剩**。copy 之下最坏是「原件完好 + 多一份备份」。
fn write_version_json(
path: &Path,
plan: &InstallPlan,
) -> Result<ManifestWrite, super::InstallError> {
let text = plan.version_json.to_json_string().map_err(|error| {
super::InstallError::new(super::InstallErrorKind::Metadata, error.to_string())
})?;
write_atomic(path, text.as_bytes())
.map_err(|error| super::InstallError::new(super::InstallErrorKind::Io, error.to_string()))
let bytes = text.as_bytes();
if std::fs::read(path).ok().as_deref() == Some(bytes) {
return Ok(ManifestWrite::Identical);
}
let Some(backup) = path.is_file().then(|| backup_manifest(path)) else {
write_atomic(path, bytes).map_err(io_error)?;
return Ok(ManifestWrite::Fresh);
};
std::fs::copy(path, &backup).map_err(io_error)?;
write_atomic(path, bytes).map_err(io_error)?;
Ok(ManifestWrite::Replaced { backup })
}
/// IO 失败 → 安装错误(`std::io::Error` 与 `PlatformError` 都吃,取 `Display` 原文)。
fn io_error(error: impl std::fmt::Display) -> super::InstallError {
super::InstallError::new(super::InstallErrorKind::Io, error.to_string())
}
/// `<dest>.ullbak-<pid>`(与整合包导入同一命名习惯,见 `addon/import/stage/commit.rs`)。
///
/// 同一进程里再替换一次时补 `-<n>`:否则第二份备份会盖掉第一份,而第一份很可能才是别人
/// 启动器铺的那份原文。
fn backup_manifest(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map_or_else(|| "version.json".into(), std::ffi::OsStr::to_os_string);
name.push(format!(".ullbak-{}", std::process::id()));
let mut candidate = path.with_file_name(&name);
let mut seq = 1_u32;
while candidate.exists() {
let mut next = name.clone();
next.push(format!("-{seq}"));
candidate = path.with_file_name(next);
seq += 1;
}
candidate
}
fn describe(error: &DownloadError) -> FileFailure {
+4 -1
View File
@@ -30,6 +30,7 @@ pub mod assets;
pub mod execute;
pub mod instance;
pub mod libraries;
pub mod pack_dir;
pub mod plan;
pub mod plan_view;
mod report;
@@ -41,7 +42,9 @@ pub use assets::{AssetIndex, AssetObject, ASSET_OBJECT_BASE};
pub use execute::{execute, execute_with_cancel};
pub use instance::{register, InstanceConfig, Registration};
pub use plan::{FileRole, InstallPlan, PlanFile, PlanInput};
pub use report::{FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
pub use report::{
FileFailure, FileOutcome, FileStatus, InstallReport, ManifestWrite, ProgressSink,
};
pub use source::{ManifestFreshness, RemoteSource, MANIFEST_TTL_MS};
use crate::download::{DownloadError, DownloadErrorKind};
+192
View File
@@ -0,0 +1,192 @@
//! 整合包导入的**落点派生**(M216 ②):`<data>/instances/<实例目录名>` + 名字归一化。
//!
//! 为什么在 core:`ull pack import` 与 GUI 导入页必须落到**同一个**目录、用**同一份**名字
//! 归一化。M215 survey 的债②就是两边不同源——CLI 缺省指向共享 `.minecraft` 根
//! (`cli/commands/pack.rs`),GUI 指向 `<data>/instances/<dir_name(包名)>`
//! (`presenter/host/addon/pack/import.rs::target_of`)。归一化留在 presenter 就只有 GUI 能看,
//! CLI 复制一份就是第三处真相源;放这里,两侧各一行委托。
//!
//! 归 GUI 侧独有的部分**不在**这里:选中的实例 → 其运行目录(要读隔离档与实例设置,
//! `pack.rs::instance_root`),那是「用已有实例」而不是「建新实例目录」。
use std::path::{Path, PathBuf};
use crate::platform::layout::LauncherLayout;
/// 包文件名 → 实例候选名(`Better MC.mrpack` → `Better MC`)。
///
/// 取不到 `file_stem`(路径以 `..` 结尾之类)时给 `imported-pack`:宁可名字难看,
/// 也不要一个空目录名。
#[must_use]
pub fn pack_stem(pack: &Path) -> String {
pack.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
.unwrap_or_else(|| "imported-pack".to_string())
}
/// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。
///
/// 判据:名字里**只含** ASCII 字母数字、空格、`-`、`_` 时保持原样(空格折成 `-`,
/// `Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、括号……)就补 `-<hash8>`,
/// 用原名的哈希保住区分度——否则「我的整合包」「另一个整合包」都会得到 `pack`,
/// 第二个中文名导入直接撞「目录已存在」。
#[must_use]
pub fn instance_dir_name(name: &str) -> String {
let base = slug(name);
let faithful = !name.is_empty()
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ' ');
if faithful {
base
} else {
format!("{base}-{}", hash8(name))
}
}
/// 新实例的内容目录:`<data>/instances/<实例目录名>`(**只算路径,不建目录**)。
///
/// 调用方自己决定要不要建(CLI 的预检与 GUI 的 `prepare_target` 都要先看路径)。
#[must_use]
pub fn instance_dir(layout: &LauncherLayout, name: &str) -> PathBuf {
layout
.data()
.join("instances")
.join(instance_dir_name(name))
}
/// 导入一次的**落点与登记名**:显式目标优先,否则 `<data>/instances/<实例目录名>`。
///
/// 名字 = `name`(去空格,空串不算)或包文件名;**目录名与登记名用同一个值**(GUI 同)。
/// `Err` = 缺省目录已存在且非空的人读原因:空目录(上次失败的残留)可以复用,否则一次
/// 失败之后永远重试不了;非空也不猜「你想合并进已有实例」,让用户显式给目标。
///
/// GUI 导入页(`import.rs::target_of`)是同一判据的另一份实现,外加一条「用已有实例」分支
/// (要读隔离档与实例设置)——那条分支只有 GUI 有,所以落点判定留了这道公共函数给 CLI。
///
/// # Errors
///
/// 缺省目录非空时返回原因字符串。
pub fn import_target(
layout: &LauncherLayout,
pack: &Path,
target: Option<&Path>,
name: Option<&str>,
) -> Result<(PathBuf, String), String> {
let name = name
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| pack_stem(pack));
let Some(target) = target else {
let dir = instance_dir(layout, &name);
let occupied = std::fs::read_dir(&dir).is_ok_and(|mut entries| entries.next().is_some());
if occupied {
return Err(format!("目标实例目录已存在且非空:{}", dir.display()));
}
return Ok((dir, name));
};
Ok((target.to_path_buf(), name))
}
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
#[must_use]
pub fn slug(text: &str) -> String {
let mut out = String::new();
for ch in text.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
"pack".to_string()
} else {
trimmed
}
}
/// FNV-1a 64 → 8 位十六进制(只要稳定、短、不需要密码学强度)。
fn hash8(text: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in text.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{:08x}", hash as u32)
}
#[cfg(test)]
mod tests {
use super::*;
/// 抓得住:目录名归一化的四条既有判据(ASCII 保真、空格折 `-`、中文补哈希、点号补哈希),
/// 以及 `<data>/instances/<名>` 这个落点本身。
/// 抓不住:GUI 是否真的改调这个函数(那条由 presenter 侧的既有用例继续钉着)。
#[test]
fn the_instance_dir_is_derived_the_same_way_the_gui_used_to() {
assert_eq!(
pack_stem(Path::new("C:/packs/Better MC.mrpack")),
"Better MC"
);
assert_eq!(pack_stem(Path::new("packs/unnamed")), "unnamed");
assert_eq!(instance_dir_name("Better MC"), "Better-MC");
assert_eq!(instance_dir_name("Better-MC"), "Better-MC");
assert_eq!(instance_dir_name("pack"), "pack");
// 折过字符 ⇒ 补哈希保住区分度(两个中文名不许塌成同一个目录)。
let a = instance_dir_name("我的整合包");
let b = instance_dir_name("另一个整合包");
assert_ne!(a, b);
assert!(
a.starts_with("pack-") && a.len() == "pack-".len() + 8,
"{a}"
);
assert!(instance_dir_name("Survival 1.20.1").starts_with("Survival-1-20-1-"));
// 落点:`<data>/instances/<目录名>`,不是共享的 `.minecraft` 根。
let layout = LauncherLayout::new(PathBuf::from("/data/.ULL"));
assert_eq!(
instance_dir(&layout, "Better MC"),
PathBuf::from("/data/.ULL/data/instances/Better-MC")
);
}
/// **M216 ②**:缺省落点与 GUI 同源(`<data>/instances/<名>`),**不是共享的 `.minecraft` 根**;
/// 显式目标照给、空目录可复用、非空才报错。
///
/// 抓得住:缺省改回 `game_home` 根(第一条断言会变成 `…/ .minecraft`)、目录名不归一化、
/// 非空目录不复检、登记名与目录名分叉。抓不住:CLI/GUI 是否真调用本函数(编译期无约束,
/// 靠两侧各自的调用点用例),以及真实 `Ctx::layout()` / `HostEnv` 的取值。
#[test]
fn the_default_target_is_an_instance_dir_not_the_shared_minecraft_root() {
let root = std::env::temp_dir().join(format!("ull-m216-pack-{}", std::process::id()));
let layout = LauncherLayout::new(root.join(".ULL"));
let pack = Path::new("C:/packs/Better MC.mrpack");
let target =
|target: Option<&Path>, name: Option<&str>| import_target(&layout, pack, target, name);
let (dir, name) = target(None, None).expect("缺省可用");
assert_eq!(dir, root.join(".ULL/data/instances/Better-MC"));
assert!(
!dir.starts_with(root.join(".minecraft")),
"不许落到共享游戏根"
);
assert_eq!(name, "Better MC", "登记名与目录名同源");
std::fs::create_dir_all(&dir).expect("建目录");
assert_eq!(
target(None, None).expect("空目录可复用").0,
dir,
"空目录可复用"
);
std::fs::write(dir.join("mmc-pack.json"), b"{}").expect("写文件");
let reason = target(None, None).expect_err("非空要报错");
assert!(reason.contains("已存在且非空"), "{reason}");
let explicit = root.join("my-target");
let (dir, name) = target(Some(&explicit), Some("x")).expect("显式目标照给");
assert_eq!((dir, name.as_str()), (explicit, "x"));
std::fs::remove_dir_all(&root).ok();
}
}
+45 -2
View File
@@ -3,7 +3,7 @@
//! 纯数据 + 纯方法:`execute.rs`(执行)与 CLI(汇总打印)都读它,谁也不改它。
//! 搬移是**逐字**的,只有 `use` 断了回来(原来靠 `execute.rs` 的 import 活着)。
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::download::DownloadErrorKind;
@@ -74,6 +74,47 @@ impl FileOutcome {
/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
pub type ProgressSink = Arc<dyn Fn(&FileOutcome) + Send + Sync>;
/// 版本清单(`versions/<id>/<id>.json`)这一处落盘的结局(**M216 ③**)。
///
/// 这一份清单是「版本已安装」的判据,也是**别人的 `.minecraft` 里同一个文件**:装在同一个
/// 上级目录下的另一个启动器(官方 / PCL / HMCL)铺的就是它。M215 survey 判为债③:老实现
/// `write_atomic` 无条件 rename 覆盖,不检测、不备份、不提示。现在三种结局都如实记下来。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestWrite {
/// 目标原本没有清单:正常新建。
Fresh,
/// 目标已有**完全相同**的内容:**一个字节都没写**(别人的 mtime / ACL 保持原样)。
///
/// 重装同版本走这一档:正常操作,不该被当成冲突,也不该留一堆备份。
Identical,
/// 目标已有**不同**的内容:旧清单先复制到 `backup`,再写新的。
Replaced {
/// 旧清单的备份路径(`<dest>.ullbak-<pid>`,与整合包导入同一命名习惯)。
backup: PathBuf,
},
}
impl ManifestWrite {
/// 稳定字符串 id(日志 / `--json` 用)。
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Fresh => "fresh",
Self::Identical => "identical",
Self::Replaced { .. } => "replaced",
}
}
/// 备份路径(只有 [`Self::Replaced`] 有)。
#[must_use]
pub fn backup(&self) -> Option<&Path> {
match self {
Self::Replaced { backup } => Some(backup),
Self::Fresh | Self::Identical => None,
}
}
}
/// 一次安装的执行结果。
#[derive(Debug, Clone)]
pub struct InstallReport {
@@ -81,7 +122,9 @@ pub struct InstallReport {
pub files: Vec<FileOutcome>,
/// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 `execute` 的说明)。
pub version_json: Option<PathBuf>,
/// 计划阶段带来的告警(缺件降级说明)。
/// 版本清单这一处怎么落的盘;`None` = 本次根本没写清单(有失败 / 不写清单的那条路径)。
pub manifest: Option<ManifestWrite>,
/// 计划阶段带来的告警(缺件降级说明)+ 运行期告警(清单被替换等)。
pub warnings: Vec<String>,
}
@@ -302,6 +302,7 @@ fn failure_kind_prefers_integrity() {
}),
};
let report = crate::game::install::InstallReport {
manifest: None,
files: vec![
failed(DownloadErrorKind::AllSourcesFailed),
failed(DownloadErrorKind::Integrity),
@@ -0,0 +1,189 @@
//! M216 ③:装同一版本时**不再静默覆盖** `versions/<id>/<id>.json`。
//!
//! 三条判据各一条用例(都走真的 `execute`,所以报告字段是端到端验过的,不是只测私有函数):
//!
//! | 盘上状态 | 期望 |
//! |---|---|
//! | 没有清单 | `ManifestWrite::Fresh`,无备份、无告警 |
//! | 有、且内容**逐字节相同** | `Identical`,不写、不备份、不告警(重装同版本是正常操作)|
//! | 有、且内容**不同** | `Replaced{backup}`,旧的那份进 `<dest>.ullbak-<pid>`,告警里点名 |
//!
//! 全部零网络:客户端 jar 事先按 sha1 放好,跳过判定第一级直接命中。
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::download::verify;
use crate::download::{
ContentCache, DownloadEngine, EngineConfig, HashAlgo, HealthRegistry, MirrorTable,
RegionProfile,
};
use crate::game::arguments::Environment;
use crate::game::install::execute::execute;
use crate::game::install::plan::{InstallPlan, PlanInput};
use crate::game::install::{InstallReport, ManifestWrite};
use crate::game::layout::GameLayout;
use crate::game::manifest::VersionJson;
fn temp_root(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("ull-m216-manifest-{}-{tag}", std::process::id()))
}
fn engine(root: &Path) -> DownloadEngine {
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(root.join("cache"))),
)
}
/// 客户端 jar 已在盘上的一份计划(于是整次安装零下载,只有清单这一步会写盘)。
fn plan_with_existing_jar(root: &Path) -> InstallPlan {
let payload = b"already installed client jar".to_vec();
let layout = GameLayout::new(root);
let sha1 = verify::digest_bytes(HashAlgo::Sha1, &payload);
std::fs::create_dir_all(root.join("versions/1.20.1")).expect("建版本目录");
std::fs::write(layout.version_jar("1.20.1"), &payload).expect("放客户端 jar");
let resolved = VersionJson::from_json_str(&format!(
r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
"downloads": {{"client": {{"sha1": "{sha1}", "size": {}, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#,
payload.len()
))
.expect("清单应可解析");
InstallPlan::build(PlanInput {
layout: &layout,
version_id: "1.20.1",
resolved: &resolved,
asset_index: None,
environment: &Environment::host(),
})
.expect("组计划应成功")
}
/// 版本目录下的备份文件(`<dest>.ullbak-<pid>[-n]`)。
fn backups(plan: &InstallPlan) -> Vec<PathBuf> {
let Some(dir) = plan.version_json_path.parent() else {
return Vec::new();
};
std::fs::read_dir(dir)
.map(|entries| {
entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.to_string_lossy().contains(".ullbak-"))
.collect()
})
.unwrap_or_default()
}
fn says_replaced(report: &InstallReport) -> bool {
report
.warnings
.iter()
.any(|line| line.contains("已替换版本清单"))
}
/// **不存在 ⇒ 正常写**(不能因为加了检测就把「第一次装」也变成需要备份的路径)。
///
/// 抓得住:Fresh 被误标成 Replaced/Identical(那会让报告对第一次安装说谎)、凭空多出备份、
/// 以及「检测逻辑把没有清单的路径判成失败」。抓不住:写完之后别的启动器能不能读(实机才验)。
#[tokio::test]
async fn a_version_without_a_manifest_gets_one_written() {
let root = temp_root("fresh");
let plan = plan_with_existing_jar(&root);
let report = execute(&engine(&root), &plan, 4, None)
.await
.expect("执行应成功");
assert_eq!(report.manifest, Some(ManifestWrite::Fresh));
assert!(plan.version_json_path.is_file(), "清单该落盘");
assert!(backups(&plan).is_empty(), "没覆盖就没什么可备份");
assert!(!says_replaced(&report), "第一次装不该说「替换了」");
std::fs::remove_dir_all(&root).ok();
}
/// **存在且相同 ⇒ 不写、不备份、不提示**(重装同版本是正常操作,不是冲突)。
///
/// 抓得住:内容相同也走备份/告警(`backups` 非空或 `says_replaced` 为真就红)、清单内容被改。
/// 抓不住:是不是真的没调 `write_atomic`——内容本来就一样,盘上看不出来;这一条靠
/// `Identical` 早退那段代码 review 兜住(Windows 上另加一半证据:只读属性下 rename 会失败,
/// 所以「只读 + 仍然成功」在 Windows 上等于没写)。
#[tokio::test]
async fn reinstalling_the_same_version_leaves_the_manifest_alone() {
let root = temp_root("identical");
let plan = plan_with_existing_jar(&root);
execute(&engine(&root), &plan, 4, None)
.await
.expect("第一次执行应成功");
let before = std::fs::read(&plan.version_json_path).expect("读清单");
// Windows 上把这枚文件设成只读:写它的唯一途径是 rename 盖过去,那会失败。
// (只读这条证据只在 Windows 上成立;Unix 下目录权限才管 rename,所以那里它不证明什么。)
let original = std::fs::metadata(&plan.version_json_path)
.expect("读元数据")
.permissions();
let mut readonly = original.clone();
readonly.set_readonly(true);
std::fs::set_permissions(&plan.version_json_path, readonly).expect("设只读");
let again = execute(&engine(&root), &plan, 4, None).await;
// 复原**原来的**权限(不是 `set_readonly(false)`:那会把 Unix 的位改成 world-writable)。
std::fs::set_permissions(&plan.version_json_path, original).expect("恢复权限");
let report = again.expect("内容相同 ⇒ 不该尝试写盘(只读也不该失败)");
assert_eq!(report.manifest, Some(ManifestWrite::Identical));
assert_eq!(
std::fs::read(&plan.version_json_path).expect("读清单"),
before,
"内容相同 ⇒ 清单一个字节都不该变"
);
assert!(backups(&plan).is_empty(), "内容相同不该备份");
assert!(!says_replaced(&report), "内容相同不该提示替换");
std::fs::remove_dir_all(&root).ok();
}
/// **存在且不同 ⇒ 备份 + 报告里可见**(这是「不静默破坏别人」的核心一条)。
///
/// 抓得住:去掉备份只覆盖(`backup` 为空、`.ullbak-*` 不存在 ⇒ 红)、旧内容丢失(备份内容
/// 不等于别人的原文 ⇒ 红)、报告不说(`ManifestWrite::Replaced` 与告警都缺 ⇒ 红)。
/// 抓不住:真实另一个启动器**正开着**这枚文件时的失败落点(Windows 共享冲突),那要实机。
#[tokio::test]
async fn a_different_manifest_is_backed_up_before_being_replaced() {
let root = temp_root("replaced");
let plan = plan_with_existing_jar(&root);
// 「别人铺的那份」:同路径、不同内容(这里用另一份 id/mainClass 模拟)。
let foreign = br#"{"id":"1.20.1","mainClass":"org.pcl.Launcher","note":"not ours"}"#.to_vec();
std::fs::write(&plan.version_json_path, &foreign).expect("放别人的清单");
let report = execute(&engine(&root), &plan, 4, None)
.await
.expect("执行应成功");
let Some(ManifestWrite::Replaced { backup }) = report.manifest.clone() else {
panic!("应当报 Replaced,实际 {:?}", report.manifest);
};
assert_eq!(
std::fs::read(&backup).expect("读备份"),
foreign,
"备份=别人原文"
);
assert_eq!(
backups(&plan),
vec![backup.clone()],
"备份只有一份、在版本目录里"
);
assert_ne!(
std::fs::read(&plan.version_json_path).expect("读清单"),
foreign,
"目标应当是刚解析出来的那份"
);
let written = std::fs::read_to_string(&plan.version_json_path).expect("读清单");
assert!(
VersionJson::from_json_str(&written).is_ok_and(|json| json.id.as_deref() == Some("1.20.1")),
"{written}"
);
assert!(says_replaced(&report), "报告里必须点名「替换了已有清单」");
std::fs::remove_dir_all(&root).ok();
}
@@ -5,4 +5,5 @@
mod execute;
mod instance;
mod manifest_write;
mod plan;
+13 -46
View File
@@ -336,57 +336,24 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I
}
/// 包文件名(`Better MC.mrpack` → `Better MC`)。
pub(super) fn pack_stem(pack: &Path) -> String {
pack.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
.unwrap_or_else(|| "imported-pack".to_string())
}
/// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。
///
/// 判据(复审 r2 P2-2 之后与注释对齐):名字里**只含** ASCII 字母数字、空格、`-`、`_` 时
/// 保持原样(空格折成 `-`,`Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、
/// 括号……)就补 `-<hash8>`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」
/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。
/// **M216 ②**:函数体一行委托 core(`game::install::pack_dir`)——CLI 调的是同一个函数,
/// 于是「CLI 缺省落点」与「GUI 落点」不只目录相同,连目录名归一化也只有一份实现。
pub(super) fn pack_stem(pack: &Path) -> String {
unlucky_core::game::install::pack_dir::pack_stem(pack)
}
/// **实例目录名**(判据见 core 的 [`pack_dir::instance_dir_name`]):本函数现在只有委托,
/// 保留它是因为 presenter 侧五个调用点按 `convert::dir_name` 这个名字取。
///
/// [`pack_dir::instance_dir_name`]: unlucky_core::game::install::pack_dir::instance_dir_name
pub(super) fn dir_name(name: &str) -> String {
let base = slug(name);
let faithful = !name.is_empty()
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ' ');
if faithful {
base
} else {
format!("{base}-{}", hash8(name))
}
unlucky_core::game::install::pack_dir::instance_dir_name(name)
}
/// FNV-1a 64 → 8 位十六进制(只要稳定、短、不需要密码学强度)。
fn hash8(text: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in text.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{:08x}", hash as u32)
}
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
/// 目录/文件名用的 slug(导出用的文件名也走它;实现同样收在 core `pack_dir`)。
pub(super) fn slug(text: &str) -> String {
let mut out = String::new();
for ch in text.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
"pack".to_string()
} else {
trimmed
}
unlucky_core::game::install::pack_dir::slug(text)
}
#[cfg(test)]
@@ -162,6 +162,7 @@ impl Backend for Fake {
InstallReport {
files: outcomes,
version_json: None,
manifest: None,
warnings: Vec::new(),
}
}
@@ -69,6 +69,7 @@ fn libraries_failed_lists_at_most_three() {
let report = InstallReport {
files,
version_json: None,
manifest: None,
warnings: Vec::new(),
};
let text = libraries_failed(&report);
+80 -9
View File
@@ -6,8 +6,12 @@
//!
//! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。
use std::path::{Path, PathBuf};
use serde_json::Value;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::ValueKind;
use unlucky_core::setting::store::SettingStore;
use crate::dto::{SettingGroupView, SettingRowView};
@@ -130,10 +134,21 @@ fn java_group(env: &HostEnv) -> SettingGroupView {
))
}
/// 数据目录(D6 三模式)。
/// 数据目录(D6 三模式)。**M216 ①**:`core.data_custom_root` 读**写回的那份**(便携配置),否则
/// 改完自定义根页面读「解析后」那份、输入凭空消失;`core.data_mode` 仍显示生效模式。
fn data_group(env: &HostEnv) -> SettingGroupView {
data_group_at(env, exe_dir().as_deref())
}
/// [`data_group`] 的参数化入口(`exe_dir` 决定自定义根那行读哪份配置;单测注入用)。
fn data_group_at(env: &HostEnv, exe_dir: Option<&Path>) -> SettingGroupView {
let modes = ["portable", "standard", "custom"];
let mode = env.mode().as_str().to_string();
let custom_root = exe_dir
.map(super::env::request_from_portable)
.and_then(|request| request.custom_root)
.map(|path| path.display().to_string())
.unwrap_or_default();
SettingGroupView::new("data", "数据目录(D6 三模式)")
.with(SettingRowView::choice(
"core.data_mode",
@@ -146,7 +161,7 @@ fn data_group(env: &HostEnv) -> SettingGroupView {
"core.data_custom_root",
"自定义根目录",
"custom 模式生效(绝对路径)",
&text(env, "core.data_custom_root"),
&custom_root,
))
.with(SettingRowView::read_only(
"启动器数据目录",
@@ -244,22 +259,26 @@ fn mcp_group(env: &HostEnv) -> SettingGroupView {
/// 写一条设置。`value` 是字符串形态,按 schema 键的类型转换后交 `store.set`。
///
/// # Errors
///
/// 未知键、值类型不符、越界、落盘失败时返回用户可读文案。
pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
set_at(env, exe_dir().as_deref(), key, value)
}
/// [`set`] 的参数化入口(`exe_dir` 决定数据根四键写哪份;单测注入假 exe 目录)。**M216 ①**:
/// 老实现写 `env.store()`(解析后那份),而启动只读便携那份 ⇒ 改「存储模式」下次启动看不见。
fn set_at(env: &HostEnv, exe_dir: Option<&Path>, key: &str, value: &str) -> Result<(), String> {
if key == super::engine::DETECT_REGION_ACTION {
return detect_region(env);
}
// M204:设置页 Java 组的「写回完成,重发快照」动作键。**不写盘**——页面那一侧已经把三键
// 一次原子写进 `launcher.json` 了(`pages/settings/java/store.rs::save`),这里只要返回
// `Ok(())`,`dispatch.rs` 的 `SetSetting` 分支就会重发 `CoreEvent::SettingsChanged`,
// `AppState::settings_groups` 里 java 组那几行随之变成刚写进去的真值。
// 键的来历与取舍见 `crate::dto::JAVA_REFRESH_ACTION` 的文档。
// M204:Java 组的「写回完成,重发快照」动作键。**不写盘**——页面那一侧已把三键一次原子写进
// `launcher.json`(`pages/settings/java/store.rs::save`),这里返回 `Ok(())` 就能让
// `dispatch.rs` 重发 `SettingsChanged`。键的取舍见 `crate::dto::JAVA_REFRESH_ACTION`。
if key == crate::dto::JAVA_REFRESH_ACTION {
return Ok(());
}
let parsed = parse_value(key, value)?;
let mut store = env.store();
let path = crate::settings_bridge::setting_path(&env.launcher_config(), exe_dir, key);
let mut store = SettingStore::load(&path, launcher_schema());
store
.set(key, parsed)
.map_err(|error| format!("设置 {key} 失败:{error}"))?;
@@ -269,6 +288,12 @@ pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
Ok(())
}
/// 本进程的 exe 目录(拿不到就退回 `None`:落在解析后那份,见 `setting_path`)。
fn exe_dir() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
exe.parent().map(Path::to_path_buf)
}
/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。
///
/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):硬编码会让 Float 键被当 String
@@ -324,6 +349,52 @@ pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> {
Ok(())
}
/// **M216 ① 的回合用例**(不并进 `settings_tests.rs`:那是另一个文件,不在本条的文字范围里)。
#[cfg(test)]
mod data_mode_tests {
use unlucky_core::platform::data_dir::{DataMode, ResolvedData};
use super::*;
/// 写回与启动解析同源:切到 standard 再切回 portable,**两次都真的生效**;页面显示同源。
/// 抓得住:四键的落点(写进「解析后」那份 ⇒ 启动读到旧值 ⇒ 红)、不写副本、其余键不改道。
/// 抓不住:真实 `current_exe()`、`redirect.json` 跟随、GUI 派发。
#[test]
fn data_mode_written_back_is_the_one_the_next_start_reads() {
let root = std::env::temp_dir().join(format!("ull216-{}-{}", std::process::id(), line!()));
let exe_dir = root.join("exe");
std::fs::create_dir_all(exe_dir.join(".ULL/config")).expect("建目录");
let portable = crate::host::env::portable_config_path(&exe_dir);
std::fs::write(&portable, r#"{"core":{"data_mode":"portable"}}"#).expect("写便携配置");
// 「以为自己在 Standard 模式」:解析后的数据根在别处。
let running = HostEnv::from_data(ResolvedData {
mode: DataMode::Standard,
launcher_home: root.join("appdata/ULL"),
game_home: root.join("appdata/.minecraft"),
redirected_from: None,
});
let write = |mode: &str| set_at(&running, Some(&exe_dir), "core.data_mode", mode);
let write_key = |key: &str, value: &str| set_at(&running, Some(&exe_dir), key, value);
let at_startup = || crate::host::env::request_from_portable(&exe_dir).mode;
assert_eq!(at_startup(), DataMode::Portable, "起点");
assert_eq!(write("standard"), Ok(()));
assert_eq!(at_startup(), DataMode::Standard, "切出后启动读得到");
assert_eq!(write("portable"), Ok(()));
assert_eq!(at_startup(), DataMode::Portable, "切回也一样");
assert!(!running.launcher_config().is_file(), "四键不写副本");
assert_eq!(write_key("java.default_memory_mb", "4096"), Ok(()));
let text = std::fs::read_to_string(running.launcher_config()).expect("读配置");
assert!(text.contains("4096"), "非数据根键仍落解析后那份:{text}");
let shared = root.join("shared").display().to_string();
assert_eq!(write_key("core.data_custom_root", &shared), Ok(()));
let group = data_group_at(&running, Some(&exe_dir));
let row = group.rows.iter().find(|r| r.key == "core.data_custom_root");
assert_eq!(row.expect("有这一行").value_text, shared, "显示同源");
std::fs::remove_dir_all(&root).ok();
}
}
#[cfg(test)]
#[path = "settings_tests.rs"]
mod tests;
+44 -42
View File
@@ -12,8 +12,7 @@
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, Resolver};
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::data_dir::Resolver;
use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::appearance::Appearance;
use unlucky_core::setting::keys::launcher_schema;
@@ -26,13 +25,23 @@ mod write;
pub(crate) use write::Edit;
/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
"core.data_custom_root",
"core.launcher_home",
"core.game_home",
];
/// 一次设置写回该落到哪份 `launcher.json`:数据根四键(`DATA_ROOT_KEYS`,判据只留
/// `host::env` 那一份)→ 便携那份 `<exe>/.ULL/config/launcher.json`,启动解析读的就是它;
/// 其余键 → `env_config`(解析后数据根那份)。
///
/// **写侧与读侧同源就是这一个函数**(M216 ①):老代码在 Standard 模式下把 `core.data_mode`
/// 写进 `%APPDATA%/ULL/config/launcher.json`,而下次启动只读 `<exe>/.ULL/…` 里的旧值 ⇒
/// 切到 portable 后切不回来。CLI 的 `Ctx::setting_path_for` 早就是这个契约;`exe_dir = None`
/// 时退回 `env_config`(不 panic、不丢写)。
#[must_use]
pub(crate) fn setting_path(env_config: &Path, exe_dir: Option<&Path>, key: &str) -> PathBuf {
match exe_dir {
Some(dir) if crate::host::env::DATA_ROOT_KEYS.contains(&key) => {
crate::host::env::portable_config_path(dir)
}
_ => env_config.to_path_buf(),
}
}
/// 设置文件桥(一个进程一份)。
#[derive(Debug)]
@@ -168,49 +177,23 @@ impl SettingsBridge {
///
/// 与 CLI 同一套语义:先读便携目录那份 `launcher.json` 的 `core.*`(数据根决定键),
/// 再交 `Resolver` 解析(含 `redirect.json` 跟随)。任何一步失败都退回内存态。
///
/// **M216 ①**:读这一步不再自带一份实现,直接用 [`crate::host::env::request_from_portable`]
/// ——`HostEnv::resolve` 与 [`setting_path`] 读的也是它,于是「启动读到的」与「设置页写回的」
/// 是同一份文件、同一段解析(presenter 侧原先两份逐字拷贝,现合一处)。
fn resolve_config_path() -> Option<PathBuf> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf));
let exe_dir = exe_dir?;
let request = request_from_portable(&exe_dir);
.and_then(|path| path.parent().map(Path::to_path_buf))?;
let request = crate::host::env::request_from_portable(&exe_dir);
let resolver = Resolver::new(Platform::current(), Some(exe_dir.clone()));
match resolver.resolve_default(&request) {
Ok(resolved) => Some(resolved.layout().launcher_config()),
// 连 exe 目录都解析不了(极端情况):仍旧用便携落点,至少设置能存下来
Err(_) => Some(LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()),
Err(_) => Some(crate::host::env::portable_config_path(&exe_dir)),
}
}
/// 便携目录那份 `launcher.json` → 三模式请求(只读 `core.*` 四个键)。
fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
let mut request = DataDirRequest::default();
let path = portable_config_path(exe_dir);
if !path.is_file() {
return request;
}
let store = SettingStore::load(&path, launcher_schema());
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) {
request.mode = mode;
}
if let Some(root) = text(DATA_ROOT_KEYS[1]) {
request.custom_root = Some(PathBuf::from(root));
}
if let Some(home) = text(DATA_ROOT_KEYS[2]) {
request.launcher_home = Some(PathBuf::from(home));
}
if let Some(home) = text(DATA_ROOT_KEYS[3]) {
request.game_home = Some(PathBuf::from(home));
}
request
}
/// `<exe 目录>/.ULL/config/launcher.json`。
fn portable_config_path(exe_dir: &Path) -> PathBuf {
LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()
}
fn mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
@@ -233,6 +216,25 @@ mod tests {
path
}
/// **M216 ①**:数据根四键的写回落点 = 启动解析读的那份文件。
///
/// 抓得住:四键 → 便携那份、其余键 → 解析后那份、拿不到 exe 目录时退回。抓不住:真实
/// `current_exe()` 的取值与 `Resolver` 的 `redirect.json` 跟随(那两条靠 `set` 的回合用例)。
#[test]
fn data_root_keys_are_written_where_startup_reads_them() {
let exe = PathBuf::from("/games/ULL");
let portable = crate::host::env::portable_config_path(&exe);
let resolved = PathBuf::from("/appdata/ULL/config/launcher.json");
for key in crate::host::env::DATA_ROOT_KEYS {
assert_eq!(setting_path(&resolved, Some(&exe), key), portable, "{key}");
}
for key in ["java.default_memory_mb", "ai.model", "core.unknown_suffix"] {
assert_eq!(setting_path(&resolved, Some(&exe), key), resolved, "{key}");
}
// 拿不到 exe 目录:退回解析后那份(宁可写「没人读的那份」也不丢写、不 panic)。
assert_eq!(setting_path(&resolved, None, "core.data_mode"), resolved);
}
#[test]
fn factory_defaults_when_file_missing() {
let path = temp_config("factory");