From 4e1323212b1bcd3e5dc49cd6b1d43e80d4925668 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 19:46:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(coexist):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E8=BF=94=E5=B7=A5=E2=80=94=E2=80=94=E5=B8=AE=E5=8A=A9=E6=96=87?= =?UTF-8?q?=E6=A1=88=E8=B7=9F=E4=B8=8A=E6=96=B0=E8=90=BD=E7=82=B9=20+=20?= =?UTF-8?q?=E3=80=8C=E6=9B=BF=E6=8D=A2=E4=BA=86=E6=B8=85=E5=8D=95=E3=80=8D?= =?UTF-8?q?=E8=BF=9B=E4=BA=BA=E8=AF=BB=E9=80=9A=E9=81=93=20+=20=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=BB=BA=E5=AE=9E=E4=BE=8B=E7=BB=91=E5=88=B0=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E7=9B=AE=E5=BD=95=EF=BC=88M216=20P2/P3/=C2=A75?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1 ② 改了缺省落点,CLI 自己的文档还在写旧行为 - `cli/help.rs`「目标目录缺省 = 数据根的共享游戏目录(.minecraft/)」→ 新实例目录 `<数据根>/instances/<实例名>`(并登记实例); - `cli/model.rs` 的 `PackImportArgs::target` 字段文档同步。 - 用例 `help_describes_the_new_default_target`(断言帮助含新落点、不含旧落点)。 P2-2 ③ 的「显式说明」进不了任何人读通道 - CLI:`human_lines` 原只遍历 `plan.warnings`,而运行期告警(含「已替换版本清单 …旧的那份 备份在 …」)在 `report.warnings` 里 ⇒ `ull install` 人读输出从没说过这句话。现在有 report 就 报 report.warnings(它开头就是 plan 告警的副本),`manifest=Replaced` 再单独打一行备份路径。 - GUI:`install.rs` 新增 `Job::journal_report`(把 `report.warnings` 与 `manifest` 的备份路径写进 安装日志),`install/routes.rs` 两处 `ensure_complete` 之前各加一行调用(tower 批的两行)。 此前 `report.manifest` 全仓零消费者。 - 用例 `a_replaced_manifest_is_said_out_loud_in_the_human_output`(走真 `InstallPlan` 排版)。 P3-1 缺省目录被占的 hint 删掉不成立的 `--overwrite` 那半句(它绕过不了这条预检)。 P3-2 GUI 落点改成委托 core 的 `pack_dir::instance_dir`(此前 presenter 自己拼 `data()/instances/…`); `convert::dir_name` 缩成 `#[cfg(test)]` 的委托入口(生产侧已无调用点,留着继续钉归一化判据)。 P3-3 补 `the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it`:不带 `--target` 跑通整条 CLI,断言落点、内容、登记、以及实例设置里的 `isolation`/`gameDir`。 §5 导入建出来的实例原先「导入成功但内容永远不会被启动用到」 - 新增 `pack_dir::bind_instance_dir`(`InstanceConfig.isolation = Custom` + `game_dir = 导入目录`) 与 `pack_dir::bind_created_instance`(判据+文案,CLI 与 GUI 同一份); - CLI 在缺省建实例后、GUI 在 `import.rs::run` 里各调一次;只在「我们新建了实例目录且档案是新建」 时绑(显式 `--target` / `--no-register` / 复用已有档案都不动); - 运行目录 = 导入目录(saves/mods/logs/config),**versions/libraries/assets 仍按 D20 恒共享**; - 未改 `derive_game_dir`。 - 用例:正向 `binding_an_imported_instance_points_it_at_the_import_dir`、反向 `a_freshly_registered_instance_still_inherits_the_global_mode`。 --- crates/cli/src/cli/help.rs | 3 +- crates/cli/src/cli/model.rs | 3 +- crates/cli/src/commands/install/report.rs | 74 ++++++++++- crates/cli/src/commands/pack.rs | 69 +++++----- crates/cli/tests/pack_test.rs | 87 +++++++++++++ crates/core/src/game/install/pack_dir.rs | 121 ++++++++++++++++++ .../presenter/src/host/addon/pack/convert.rs | 8 +- .../presenter/src/host/addon/pack/import.rs | 20 ++- crates/presenter/src/host/install.rs | 17 +++ crates/presenter/src/host/install/routes.rs | 2 + 10 files changed, 359 insertions(+), 45 deletions(-) diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index aa1c720..e893105 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -104,7 +104,8 @@ pub fn topic(name: &str) -> Option<&'static str> { MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/ (Prism 实例;非加载器 component 会逐项列进报告,不静默丢) -目标目录缺省 = 数据根的共享游戏目录(.minecraft/);`--target` 可钉到实例目录。 +目标目录缺省 = 新实例目录 `<数据根>/instances/<实例名>`(并登记实例,M216 起不再落共享 + .minecraft 根);`--target` 可钉到别处(例如某个实例的运行目录)。 两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。 · 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖 · 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」 diff --git a/crates/cli/src/cli/model.rs b/crates/cli/src/cli/model.rs index c37e7de..db5ce86 100644 --- a/crates/cli/src/cli/model.rs +++ b/crates/cli/src/cli/model.rs @@ -157,7 +157,8 @@ pub struct InstallArgs { pub struct PackImportArgs { /// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。 pub path: PathBuf, - /// 目标实例目录(缺省 = 数据根的共享游戏目录 `game_home`)。 + /// 目标实例目录(缺省 = 新实例目录 `<数据根>/instances/<实例名>`,M216 前是共享游戏目录 + /// `game_home`)。 pub target: Option, /// 实例显示名(缺省 = 包名)。 pub name: Option, diff --git a/crates/cli/src/commands/install/report.rs b/crates/cli/src/commands/install/report.rs index 750204b..a5b47bc 100644 --- a/crates/cli/src/commands/install/report.rs +++ b/crates/cli/src/commands/install/report.rs @@ -130,9 +130,25 @@ fn human_lines( sources.join(" → ") )); } - for warning in &plan.warnings { + // **M216 P2-2**:有 report 就报**运行期**告警——它开头就是 `plan.warnings` 的副本,加上执行期 + // 才知道的事(「已替换版本清单 … 旧的那份备份在 …」)。此前这里只遍历 `plan.warnings`, + // 于是「我们动了别人 `.minecraft` 里那份清单」这句话在 `ull install` 的人读输出里**从没出现过** + // (只有 `--json` 的 `result.warnings` 有)——「不静默破坏」就落不了地。 + // 没有 report(`--dry-run`)时退回计划期告警。 + let warnings = report.map_or(plan.warnings.as_slice(), |report| { + report.warnings.as_slice() + }); + for warning in warnings { human.push(format!("告警: {warning}")); } + if let Some(manifest) = report.and_then(|report| report.manifest.as_ref()) { + if let Some(backup) = manifest.backup() { + human.push(format!( + "版本清单已替换(旧的那份备份在 {})", + backup.display() + )); + } + } if let Some(report) = report { human.push(format!( "下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})", @@ -181,6 +197,62 @@ pub fn human_bytes(bytes: u64) -> String { #[cfg(test)] mod tests { use super::human_bytes; + use super::*; + use unlucky_core::game::arguments::Environment; + use unlucky_core::game::install::{ManifestWrite, PlanInput}; + use unlucky_core::game::layout::GameLayout; + use unlucky_core::game::manifest::VersionJson; + + /// **M216 P2-2**:清单被替换这件事必须出现在**人读**行里(以前只进 `--json`, + /// 复审 r1 抓的就是「对玩家仍然静默」)。 + /// + /// 抓得住:`human_lines` 退回只遍历 `plan.warnings`(那时找不到「已替换版本清单」⇒ 红, + /// 正是修前状态);`manifest` 的备份路径没渲出来。抓不住:真实终端排版与颜色、 + /// 以及 `ull install` 的整条命令链(本用例直接调排版函数,不落盘、不联网)。 + #[test] + fn a_replaced_manifest_is_said_out_loud_in_the_human_output() { + let root = std::env::temp_dir().join(format!("ull216-report-{}", std::process::id())); + let layout = GameLayout::new(&root); + let resolved = VersionJson::from_json_str( + r#"{"id":"1.20.1","mainClass":"net.minecraft.client.main.Main", + "downloads":{"client":{"sha1":"aa","size":1,"url":"https://example.invalid/c.jar"}}}"#, + ) + .expect("清单应可解析"); + let plan = InstallPlan::build(PlanInput { + layout: &layout, + version_id: "1.20.1", + resolved: &resolved, + asset_index: None, + environment: &Environment::host(), + }) + .expect("组计划应成功"); + + let backup = root.join("versions/1.20.1/1.20.1.json.ullbak-4242"); + let mut warnings = plan.warnings.clone(); + warnings.push("已替换版本清单 (旧的那份备份在 )".to_string()); + let report = InstallReport { + files: Vec::new(), + version_json: Some(plan.version_json_path.clone()), + manifest: Some(ManifestWrite::Replaced { + backup: backup.clone(), + }), + warnings, + }; + + let text = human_lines(false, &plan, Some(&report), None, &[], "ignored", "1.20.1").join( + " +", + ); + assert!( + text.contains("已替换版本清单"), + "运行期告警没进人读输出:{text}" + ); + assert!( + text.contains(&backup.display().to_string()), + "备份路径没进人读输出:{text}" + ); + std::fs::remove_dir_all(&root).ok(); + } #[test] fn formats_sizes_without_fake_precision() { diff --git a/crates/cli/src/commands/pack.rs b/crates/cli/src/commands/pack.rs index 4d17d93..9e96ab0 100644 --- a/crates/cli/src/commands/pack.rs +++ b/crates/cli/src/commands/pack.rs @@ -1,8 +1,7 @@ //! `pack import `:整合包导入(M111)。 //! -//! 逻辑**全在** `core::addon::import`:本文件只做三件事——参数落地、下载引擎接线、两种输出。 -//! 之所以坚持这一层很薄,是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`, -//! 不是这里的打印逻辑。 +//! 逻辑**全在** `core::addon::import`:本文件只做参数落地、引擎接线、两种输出。坚持这一层很薄, +//! 是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,不是这里的打印逻辑。 //! //! # 三态退出码(脚本契约) //! @@ -13,15 +12,13 @@ //! | `failed` | `1` | 致命失败,**已整包回滚** | //! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) | //! -//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(一次性命令, -//! Ctrl-C 直接杀进程),但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时 -//! 必须能把它与 `failed` 分开——所以退出码与文案在这里就位,接中断信号是下一步的事。 +//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(Ctrl-C 直接杀进程), +//! 但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时要能把它与 `failed` 分开。 //! //! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看 -//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、 -//! 数据根不可用),与其它命令一致。 +//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、数据根不可用)。 -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::{Arc, Mutex}; use serde_json::json; @@ -32,7 +29,6 @@ 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; @@ -53,7 +49,19 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result { .with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录")); } let layout = ctx.layout(); - let (target, instance_name) = import_target(&layout, args)?; + // **M216 ②**:缺省落点 = `/instances/<实例名>`(与 GUI 同源,判据在 core 的 + // `pack_dir::import_target`),不再是共享的 `.minecraft` 根。 + let (target, instance_name) = 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 只管文件冲突,绕过不了这条预检)", + ) + })?; let stages: Arc>> = Arc::new(Mutex::new(Vec::new())); let recorder = Arc::clone(&stages); @@ -80,30 +88,19 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result { } let report = block_on(import_pack(&request)); + // **§5**:缺省建出来的实例要绑到它的内容目录(判据/文案在 core,与 GUI 同一份),否则 + // 「导入成功」而那批 mods/config 永远不会被那条实例的启动用到。 + let auto = args.target.is_none() && !args.no_register; + let binding = pack_dir::bind_created_instance(&layout, &target, auto, &report); let seen = stages.lock().map(|seen| seen.clone()).unwrap_or_default(); - 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") - }) + Ok(outcome( + ctx, + &report, + &target, + &seen, + args, + binding.as_deref(), + )) } /// 组装 `Outcome`(三态 → 退出码 + 两种输出)。 @@ -113,6 +110,7 @@ fn outcome( target: &Path, stages: &[&'static str], args: &PackImportArgs, + binding: Option<&str>, ) -> Outcome { let mut data = serde_json::to_value(report).unwrap_or_else(|_| { json!({ @@ -146,6 +144,9 @@ fn outcome( if !stages.is_empty() { human.push(format!("阶段:{}", stages.join(" → "))); } + if let Some(note) = binding { + human.push(note.to_string()); + } human.push(format!( "结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}", status_label(report), diff --git a/crates/cli/tests/pack_test.rs b/crates/cli/tests/pack_test.rs index 196b8b1..5c77801 100644 --- a/crates/cli/tests/pack_test.rs +++ b/crates/cli/tests/pack_test.rs @@ -197,3 +197,90 @@ fn help_lists_the_pack_command() { assert_eq!(code, 0); assert!(stdout.contains("pack import"), "总帮助里也要有:{stdout}"); } + +/// **M216 P3-3 + §5**:不带 `--target` 时落点是**新实例目录**(不是共享 `.minecraft` 根), +/// 而且登记出来的实例被绑到那个目录(`isolation=custom` + `gameDir`),否则导入内容永远不会 +/// 被启动用到。 +/// +/// 抓得住:CLI 自己兜底回 `ctx.game().root()`(第一条断言会变成 `.minecraft`);不建实例目录而 +/// 直接铺共享根;不做绑定(`isolation` 仍是 `null`);绑定写错目录。抓不住:真实启动那一条 +/// `derive_game_dir` 的派生(core 的 `isolation.rs` 用例钉,这里只断言设置文件的内容)。 +#[test] +fn the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it() { + let data = TempDir::new("pack-default-target"); + let source = multimc_instance(data.path()); + let (code, stdout, stderr) = run_cli(&[ + "pack", + "import", + &source.display().to_string(), + "--name", + "My Pack", + "--data-dir", + &data.arg(), + "--json", + ]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + + let instance_dir = data.path().join(".ULL/data/instances/My-Pack"); + assert!( + instance_dir.join("mods/a.jar").is_file(), + "缺省落点应当是 /instances/My-Pack:{stdout}" + ); + assert!( + !data.path().join(".minecraft/mods/a.jar").exists(), + "不许再往共享 .minecraft 根铺:{stdout}" + ); + + // 登记的实例被绑到这个目录(§5)。 + let profiles: Value = serde_json::from_str( + &std::fs::read_to_string(data.path().join(".ULL/config/profiles.json")) + .expect("profiles.json"), + ) + .expect("profiles.json 是 JSON"); + assert_eq!( + profiles["profiles"][0]["name"], + Value::String("My Pack".into()) + ); + let id = profiles["profiles"][0]["id"] + .as_str() + .expect("登记出来的实例有 id") + .to_string(); + let config: Value = serde_json::from_str( + &std::fs::read_to_string( + data.path() + .join(".ULL/config/instances") + .join(format!("{id}.json")), + ) + .expect("实例设置文件"), + ) + .expect("实例设置是 JSON"); + assert_eq!( + config["isolation"], + Value::String("custom".into()), + "isolation 应当是 custom:{config}" + ); + assert_eq!( + config["gameDir"].as_str().map(Path::new), + Some(instance_dir.as_path()), + "gameDir 应当指向导入目录:{config}" + ); +} + +/// **M216 P2-1**:`--help` 里的缺省落点必须与新行为一致(写旧行为 = 用户照读会以为文件进了 +/// `.minecraft`)。 +/// +/// 抓得住:文案退回「共享游戏目录」;新落点没写进去。抓不住:`model.rs` 的字段文档 +/// (那是编译期文档,测试读不到)——所以断言落在这里只覆盖用户能看见的那一份。 +#[test] +fn help_describes_the_new_default_target() { + let (code, stdout, _) = run_cli(&["help", "pack"]); + assert_eq!(code, 0); + assert!( + stdout.contains("instances/<实例名>"), + "帮助里没写新缺省落点:{stdout}" + ); + assert!( + !stdout.contains("缺省 = 数据根的共享游戏目录"), + "帮助里还留着旧缺省落点:{stdout}" + ); +} diff --git a/crates/core/src/game/install/pack_dir.rs b/crates/core/src/game/install/pack_dir.rs index 54d92e8..0ce4887 100644 --- a/crates/core/src/game/install/pack_dir.rs +++ b/crates/core/src/game/install/pack_dir.rs @@ -11,6 +11,8 @@ use std::path::{Path, PathBuf}; +use super::instance::{self, InstanceConfig, Isolation}; +use crate::game::error::GameError; use crate::platform::layout::LauncherLayout; /// 包文件名 → 实例候选名(`Better MC.mrpack` → `Better MC`)。 @@ -89,6 +91,82 @@ pub fn import_target( Ok((target.to_path_buf(), name)) } +/// 把「导入建出来的实例」绑到它的内容目录(`isolation=custom` + `game_dir`)。 +/// +/// # 为什么必须绑 +/// +/// 运行目录不是「导入目录」,而是从 `config/instances/.json` 的 `isolation` 派生的 +/// ([`derive_game_dir`](crate::game::isolation::derive_game_dir)):缺省档 +/// `instances.mode=isolated` 给成 `/versions/`,`shared` 给成游戏根。 +/// 导入目标 `/instances/<名>` **不在这条链上** ⇒ 不绑的话「导入成功」而那批 +/// mods/config 永远不会被那条实例的启动用到(M113 起 GUI 就有这个洞)。 +/// +/// # 绑成什么、不绑成什么 +/// +/// - 运行目录 = `dir`:`saves/` `mods/` `logs/` `config/` 落在这里(与别的启动器隔离); +/// - **`versions/` `libraries/` `assets/` 仍按 D20 恒共享**在游戏根 —— 不是「完全隔离」, +/// 版本本体只装一份,别人 `.minecraft` 里已下好的库也照旧复用。 +/// +/// # 回滚 +/// +/// 把 `config/instances/.json` 的 `isolation` 删掉(或写成 `"inherit"`)即回到「跟随全局 +/// 档」;`gameDir` 随之失效。也可以整份删掉这一份实例设置文件(`register` 会重新补最小那份)。 +/// **不会**搬动任何已落盘的内容。 +/// +/// # Errors +/// +/// 读/写实例设置失败(JSON 坏、id 不合法、原子写失败)。 +pub fn bind_instance_dir( + layout: &LauncherLayout, + instance_id: &str, + version_id: &str, + dir: &Path, +) -> Result<(), GameError> { + let mut config = instance::read_instance_config(layout, instance_id)? + .unwrap_or_else(|| InstanceConfig::new(instance_id, version_id)); + config.isolation = Some(Isolation::Custom); + config.game_dir = Some(dir.to_path_buf()); + instance::write_instance_config(layout, &config)?; + Ok(()) +} + +/// 导入收尾:把「我们新建出来的实例」绑到它的内容目录,返回一句给人读的结论。 +/// +/// 判据(CLI 与 GUI 共用一份,别自己再写一遍): +/// - `auto_bound == false` ⇒ 不绑(用户显式给了目标目录 / `--no-register`:那目录可能是别人的 +/// 运行目录,也可能被用户自己指过); +/// - 没登记、或**复用了已有档案**(`registered.created == false`)⇒ 不绑; +/// - 其余 ⇒ [`bind_instance_dir`]。 +/// +/// 参数直接吃 `addon::import::ImportReport`:`addon` 早就在调 `game::install::register`, +/// 两者同属 core 内部;再投影一层 `struct` 只会让两侧各多写六行构造。 +#[must_use] +pub fn bind_created_instance( + layout: &LauncherLayout, + target: &Path, + auto_bound: bool, + report: &crate::addon::import::ImportReport, +) -> Option { + if !auto_bound { + return None; + } + let registered = report.registered.as_ref().filter(|item| item.created)?; + Some( + match bind_instance_dir(layout, ®istered.id, ®istered.version_id, target) { + Ok(()) => format!( + "运行目录已指向 {}(实例 {})", + target.display(), + registered.name + ), + // 内容与档案都已就位,只是目录没指过来 ⇒ 不把整次导入判失败,但必须说出来。 + Err(error) => format!( + "实例已登记但没能绑到导入目录({error}):启动前请把该实例的运行目录指到 {}", + target.display() + ), + }, + ) +} + /// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。 #[must_use] pub fn slug(text: &str) -> String { @@ -189,4 +267,47 @@ mod tests { assert_eq!((dir, name.as_str()), (explicit, "x")); std::fs::remove_dir_all(&root).ok(); } + + /// **§5 正向**:导入建出来的实例必须绑到内容目录,否则「导入成功」而内容永远不会被启动用到。 + /// + /// 抓得住:`bind_instance_dir` 没写 `isolation=custom` / `game_dir` 落空 / 写错了实例(读回来 + /// 仍是 `None`)。抓不住:`derive_game_dir` 真的把运行目录算成那个目录——那条由 + /// `isolation.rs` 的既有用例钉住,这里不重算(tower 要求不动 `derive_game_dir`)。 + #[test] + fn binding_an_imported_instance_points_it_at_the_import_dir() { + let root = std::env::temp_dir().join(format!("ull216-bind-{}", std::process::id())); + let layout = LauncherLayout::new(root.join(".ULL")); + let registration = + crate::game::install::instance::register(&layout, "pack", "1.20.1", None) + .expect("登记"); + let id = registration.profile.id.as_str().to_string(); + let dir = instance_dir(&layout, "pack"); + + bind_instance_dir(&layout, &id, "1.20.1", &dir).expect("绑定"); + let config = crate::game::install::instance::read_instance_config(&layout, &id) + .expect("读设置") + .expect("有设置文件"); + assert_eq!(config.isolation, Some(Isolation::Custom)); + assert_eq!(config.game_dir.as_deref(), Some(dir.as_path())); + std::fs::remove_dir_all(&root).ok(); + } + + /// **§5 反向**:没走绑定那条路的实例不许自己冒出 `custom`(否则上一条抓不住东西)。 + #[test] + fn a_freshly_registered_instance_still_inherits_the_global_mode() { + let root = std::env::temp_dir().join(format!("ull216-unbound-{}", std::process::id())); + let layout = LauncherLayout::new(root.join(".ULL")); + let registration = + crate::game::install::instance::register(&layout, "plain", "1.20.1", None) + .expect("登记"); + let config = crate::game::install::instance::read_instance_config( + &layout, + registration.profile.id.as_str(), + ) + .expect("读设置") + .expect("有设置文件"); + assert_eq!(config.isolation, None, "缺省必须跟随全局档"); + assert_eq!(config.game_dir, None); + std::fs::remove_dir_all(&root).ok(); + } } diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index fe83921..f490945 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -343,10 +343,14 @@ 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` 这个名字取。 +/// **实例目录名**(判据见 core 的 [`pack_dir::instance_dir_name`]):本函数只有一行委托。 +/// +/// **只剩 presenter 自己的用例在调它**(生产侧落点已改成 `pack_dir::instance_dir`,见 M216 +/// P3-2),所以挂 `cfg(test)`:它是那组既有判据(空格折 `-`、中文补 `-`、两个中文名 +/// 不塌成同一个目录)继续钉住归一化规则的入口,生产构建里不需要它。 /// /// [`pack_dir::instance_dir_name`]: unlucky_core::game::install::pack_dir::instance_dir_name +#[cfg(test)] pub(super) fn dir_name(name: &str) -> String { unlucky_core::game::install::pack_dir::instance_dir_name(name) } diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index eac39ec..58f75ce 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -16,11 +16,13 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use unlucky_core::addon::import::report::ImportIssue; use unlucky_core::addon::import::{ import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress, ImportProgressSink, ImportRequest, }; use unlucky_core::download::blocking::block_on; +use unlucky_core::game::install::pack_dir; use crate::dto::{ImportRequestView, PackEvent}; use unlucky_core::addon::import::ImportStatus; @@ -196,7 +198,15 @@ pub(super) fn run( core_request = core_request.with_registration(&env.layout, Some(&pack_name)); } - let report = block_on(import_pack(&core_request)); + let mut report = block_on(import_pack(&core_request)); + // **§5**:导入建出来的实例要绑到它的内容目录(`isolation=custom` + `gameDir`),否则运行 + // 目录由全局档派生成 `/versions/`,这批 mods/config 永远不会被启动用到。 + // 判据与文案在 core(与 CLI 同一份);失败不把整次导入判失败,但要让用户看见。 + if let Some(note) = pack_dir::bind_created_instance(&env.layout, &target, register, &report) { + report + .warnings + .push(ImportIssue::new("instance_binding", note)); + } // 取消标记要**在管线返回之后**再读一次:取消可能来得太晚(管线已经跑完),这一读就是 // "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。 let cancel_requested = cancel.load(Ordering::SeqCst); @@ -270,11 +280,9 @@ pub(super) fn target_of( .clone() .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| convert::pack_stem(&request.pack)); - let target = env - .layout - .data() - .join("instances") - .join(convert::dir_name(&name)); + // **M216 P3-2**:落点也委托 core(`pack_dir::instance_dir`)——此前这里自己拼 + // `/instances/`,core 改了目录语义时 GUI 不会跟着走。 + let target = pack_dir::instance_dir(&env.layout, &name); if target.exists() { // 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」 let mut entries = std::fs::read_dir(&target) diff --git a/crates/presenter/src/host/install.rs b/crates/presenter/src/host/install.rs index 20a6ee9..346998e 100644 --- a/crates/presenter/src/host/install.rs +++ b/crates/presenter/src/host/install.rs @@ -51,6 +51,23 @@ struct Job<'a> { } impl Job<'_> { + /// 把 core 的运行期告警与「清单被替换」写进安装日志(**M216 P2-2**)。 + /// + /// 面板只有一行、Toast 4 秒就消失,安装日志是用户事后能自己回看的唯一渠道:动了共享 + /// `.minecraft` 里那份 `versions//.json` 这件事必须先落在这里(issue #M216 复审 + /// P2-2:`report.manifest` 以前零消费者,GUI 侧对用户完全静默)。 + fn journal_report(&self, report: &InstallReport) { + for warning in &report.warnings { + self.journal.line(warning); + } + if let Some(backup) = report.manifest.as_ref().and_then(|write| write.backup()) { + self.journal.line(&format!( + "版本清单已替换,旧的那份备份在 {}", + backup.display() + )); + } + } + /// processor 段的进度出口(同一套 channel/meter/journal)。 fn reporter(&self) -> install_processors::Reporter<'_> { install_processors::Reporter { diff --git a/crates/presenter/src/host/install/routes.rs b/crates/presenter/src/host/install/routes.rs index 22f00bf..59fd171 100644 --- a/crates/presenter/src/host/install/routes.rs +++ b/crates/presenter/src/host/install/routes.rs @@ -86,6 +86,7 @@ pub(super) fn install_vanilla( ) .await .map_err(|error| job_error(job, error))?; + job.journal_report(&report); ensure_complete(&report)?; Ok::(version.to_string()) }) @@ -178,6 +179,7 @@ pub(super) fn install_loader( .await }) .map_err(|error| job_error(job, error))?; + job.journal_report(&report); ensure_complete(&report)?; Ok(release.version_id) }