From 96f15c79fb5efe625f1f7a0ca5905457d4ca4902 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 18:42:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(coexist):=20=E5=A4=9A=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=99=A8=E5=85=B1=E5=AD=98=E2=80=94=E2=80=94=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=86=99=E5=9B=9E=E5=90=8C=E6=BA=90=20+=20?= =?UTF-8?q?=E6=95=B4=E5=90=88=E5=8C=85=E7=BC=BA=E7=9C=81=E8=90=BD=E7=82=B9?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20GUI=20+=20=E7=89=88=E6=9C=AC=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98=E8=A6=86=E7=9B=96?= =?UTF-8?q?=EF=BC=88M216=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三条 M215 判定的共存债,一次修完: ① 存储模式切不回来(high) - 写侧:`host::settings::set` 老实现一律写 `env.store()`(= 解析后数据根那份),而启动解析 只读 `/.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 导入页落在 `/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//.json`(medium) - `write_version_json` 三档:不存在 → `Fresh`;逐字节相同 → `Identical`(一个字节都不写, 重装同版本照旧正常);不同 → 先 copy 到 `.ullbak-` 再原子写 → `Replaced{backup}`。 - `InstallReport` 新增结构化字段 `manifest: Option`,并把「已替换版本清单 + 备份路径」推进 `report.warnings`(进 `--json` 的 `result.warnings`)。 用例(全部零网络,走真的 `execute`):三档各一条,各写抓得住/抓不住。 三个变异各跑一次(只改实现侧)都红:写回落点改回旧的、缺省改回 `.minecraft` 根、去掉备份只覆盖。 --- crates/cli/src/commands/pack.rs | 38 +++- .../src/addon/loader/processors/download.rs | 1 + crates/core/src/game/install/execute.rs | 80 +++++++- crates/core/src/game/install/mod.rs | 5 +- crates/core/src/game/install/pack_dir.rs | 192 ++++++++++++++++++ crates/core/src/game/install/report.rs | 47 ++++- crates/core/src/game/install/tests/execute.rs | 1 + .../src/game/install/tests/manifest_write.rs | 189 +++++++++++++++++ crates/core/src/game/install/tests/mod.rs | 1 + .../presenter/src/host/addon/pack/convert.rs | 59 ++---- .../src/host/install_processors_fixture.rs | 1 + .../src/host/install_processors_tests.rs | 1 + crates/presenter/src/host/settings.rs | 89 +++++++- crates/presenter/src/settings_bridge.rs | 86 ++++---- 14 files changed, 672 insertions(+), 118 deletions(-) create mode 100644 crates/core/src/game/install/pack_dir.rs create mode 100644 crates/core/src/game/install/tests/manifest_write.rs diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs index 68713d1..4d17d93 100644 --- a/crates/cli/src/commands/pack.rs +++ b/crates/cli/src/commands/pack.rs @@ -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 { ) .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>> = Arc::new(Mutex::new(Vec::new())); let recorder = Arc::clone(&stages); @@ -68,7 +68,6 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result { 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 { 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 { Ok(outcome(ctx, &report, &target, &seen, args)) } +/// 导入落点与登记名:**M216 ②** 起与 GUI 同源(`/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 { "" } diff --git a/crates/core/src/addon/loader/processors/download.rs b/crates/core/src/addon/loader/processors/download.rs index a9e39ba..d9ca5a0 100644 --- a/crates/core/src/addon/loader/processors/download.rs +++ b/crates/core/src/addon/loader/processors/download.rs @@ -40,6 +40,7 @@ pub async fn download_libraries( InstallReport { files: outcomes, version_json: None, + manifest: None, warnings: Vec::new(), } } diff --git a/crates/core/src/game/install/execute.rs b/crates/core/src/game/install/execute.rs index f5c380a..8a3c16f 100644 --- a/crates/core/src/game/install/execute.rs +++ b/crates/core/src/game/install/execute.rs @@ -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 { } } -/// 写解析后的版本清单(原子写)。 -fn write_version_json(path: &Path, plan: &InstallPlan) -> Result<(), super::InstallError> { +/// 写解析后的版本清单(原子写),并如实交代「原来那份怎么办」(**M216 ③**)。 +/// +/// 三条判据: +/// - 目标不存在 → [`ManifestWrite::Fresh`],正常写; +/// - 存在且内容**逐字节相同** → [`ManifestWrite::Identical`],**一个字节都不写**(重装同版本 +/// 是正常操作:既不是冲突,也不该改别人的 mtime); +/// - 存在且内容不同(含「存在但读不出来」——读不了 ≠ 相同)→ 旧的那份先 **copy** 到 +/// `.ullbak-`,再原子写 ⇒ [`ManifestWrite::Replaced`]。 +/// +/// 备份用 `copy` 而不是 `rename`:整合包导入的合并态用 rename 是因为它紧接着覆盖同一路径、 +/// 失败还会回滚;这里若 rename 之后原子写失败(目标正被别的启动器占着),盘上会**一份清单 +/// 都不剩**。copy 之下最坏是「原件完好 + 多一份备份」。 +fn write_version_json( + path: &Path, + plan: &InstallPlan, +) -> Result { 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()) +} + +/// `.ullbak-`(与整合包导入同一命名习惯,见 `addon/import/stage/commit.rs`)。 +/// +/// 同一进程里再替换一次时补 `-`:否则第二份备份会盖掉第一份,而第一份很可能才是别人 +/// 启动器铺的那份原文。 +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 { diff --git a/crates/core/src/game/install/mod.rs b/crates/core/src/game/install/mod.rs index 63a3358..b045e5a 100644 --- a/crates/core/src/game/install/mod.rs +++ b/crates/core/src/game/install/mod.rs @@ -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}; diff --git a/crates/core/src/game/install/pack_dir.rs b/crates/core/src/game/install/pack_dir.rs new file mode 100644 index 0000000..54d92e8 --- /dev/null +++ b/crates/core/src/game/install/pack_dir.rs @@ -0,0 +1,192 @@ +//! 整合包导入的**落点派生**(M216 ②):`/instances/<实例目录名>` + 名字归一化。 +//! +//! 为什么在 core:`ull pack import` 与 GUI 导入页必须落到**同一个**目录、用**同一份**名字 +//! 归一化。M215 survey 的债②就是两边不同源——CLI 缺省指向共享 `.minecraft` 根 +//! (`cli/commands/pack.rs`),GUI 指向 `/instances/` +//! (`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`);**一旦折过别的字符**(中文、`.`、括号……)就补 `-`, +/// 用原名的哈希保住区分度——否则「我的整合包」「另一个整合包」都会得到 `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)) + } +} + +/// 新实例的内容目录:`/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)) +} + +/// 导入一次的**落点与登记名**:显式目标优先,否则 `/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 保真、空格折 `-`、中文补哈希、点号补哈希), + /// 以及 `/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-")); + // 落点:`/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 同源(`/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(); + } +} diff --git a/crates/core/src/game/install/report.rs b/crates/core/src/game/install/report.rs index 576b4ad..4431f6c 100644 --- a/crates/core/src/game/install/report.rs +++ b/crates/core/src/game/install/report.rs @@ -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; +/// 版本清单(`versions//.json`)这一处落盘的结局(**M216 ③**)。 +/// +/// 这一份清单是「版本已安装」的判据,也是**别人的 `.minecraft` 里同一个文件**:装在同一个 +/// 上级目录下的另一个启动器(官方 / PCL / HMCL)铺的就是它。M215 survey 判为债③:老实现 +/// `write_atomic` 无条件 rename 覆盖,不检测、不备份、不提示。现在三种结局都如实记下来。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ManifestWrite { + /// 目标原本没有清单:正常新建。 + Fresh, + /// 目标已有**完全相同**的内容:**一个字节都没写**(别人的 mtime / ACL 保持原样)。 + /// + /// 重装同版本走这一档:正常操作,不该被当成冲突,也不该留一堆备份。 + Identical, + /// 目标已有**不同**的内容:旧清单先复制到 `backup`,再写新的。 + Replaced { + /// 旧清单的备份路径(`.ullbak-`,与整合包导入同一命名习惯)。 + 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, /// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 `execute` 的说明)。 pub version_json: Option, - /// 计划阶段带来的告警(缺件降级说明)。 + /// 版本清单这一处怎么落的盘;`None` = 本次根本没写清单(有失败 / 不写清单的那条路径)。 + pub manifest: Option, + /// 计划阶段带来的告警(缺件降级说明)+ 运行期告警(清单被替换等)。 pub warnings: Vec, } diff --git a/crates/core/src/game/install/tests/execute.rs b/crates/core/src/game/install/tests/execute.rs index 881ce3d..28474c1 100644 --- a/crates/core/src/game/install/tests/execute.rs +++ b/crates/core/src/game/install/tests/execute.rs @@ -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), diff --git a/crates/core/src/game/install/tests/manifest_write.rs b/crates/core/src/game/install/tests/manifest_write.rs new file mode 100644 index 0000000..194f698 --- /dev/null +++ b/crates/core/src/game/install/tests/manifest_write.rs @@ -0,0 +1,189 @@ +//! M216 ③:装同一版本时**不再静默覆盖** `versions//.json`。 +//! +//! 三条判据各一条用例(都走真的 `execute`,所以报告字段是端到端验过的,不是只测私有函数): +//! +//! | 盘上状态 | 期望 | +//! |---|---| +//! | 没有清单 | `ManifestWrite::Fresh`,无备份、无告警 | +//! | 有、且内容**逐字节相同** | `Identical`,不写、不备份、不告警(重装同版本是正常操作)| +//! | 有、且内容**不同** | `Replaced{backup}`,旧的那份进 `.ullbak-`,告警里点名 | +//! +//! 全部零网络:客户端 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("组计划应成功") +} + +/// 版本目录下的备份文件(`.ullbak-[-n]`)。 +fn backups(plan: &InstallPlan) -> Vec { + 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(); +} diff --git a/crates/core/src/game/install/tests/mod.rs b/crates/core/src/game/install/tests/mod.rs index 21cecbe..4b80e9f 100644 --- a/crates/core/src/game/install/tests/mod.rs +++ b/crates/core/src/game/install/tests/mod.rs @@ -5,4 +5,5 @@ mod execute; mod instance; +mod manifest_write; mod plan; diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index 640f89f..fe83921 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -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`);**一旦折过别的字符**(中文、`.`、 -/// 括号……)就补 `-`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」 -/// 都会得到 `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)] diff --git a/crates/presenter/src/host/install_processors_fixture.rs b/crates/presenter/src/host/install_processors_fixture.rs index 8a47247..9cc4a06 100644 --- a/crates/presenter/src/host/install_processors_fixture.rs +++ b/crates/presenter/src/host/install_processors_fixture.rs @@ -162,6 +162,7 @@ impl Backend for Fake { InstallReport { files: outcomes, version_json: None, + manifest: None, warnings: Vec::new(), } } diff --git a/crates/presenter/src/host/install_processors_tests.rs b/crates/presenter/src/host/install_processors_tests.rs index dc22eed..9e3b0d7 100644 --- a/crates/presenter/src/host/install_processors_tests.rs +++ b/crates/presenter/src/host/install_processors_tests.rs @@ -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); diff --git a/crates/presenter/src/host/settings.rs b/crates/presenter/src/host/settings.rs index 4a6f7ba..2c0f528 100644 --- a/crates/presenter/src/host/settings.rs +++ b/crates/presenter/src/host/settings.rs @@ -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 { + 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; diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index 31a6196..27ee8cb 100644 --- a/crates/presenter/src/settings_bridge.rs +++ b/crates/presenter/src/settings_bridge.rs @@ -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` 那一份)→ 便携那份 `/.ULL/config/launcher.json`,启动解析读的就是它; +/// 其余键 → `env_config`(解析后数据根那份)。 +/// +/// **写侧与读侧同源就是这一个函数**(M216 ①):老代码在 Standard 模式下把 `core.data_mode` +/// 写进 `%APPDATA%/ULL/config/launcher.json`,而下次启动只读 `/.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 { 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 -} - -/// `/.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 { 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");