fix(coexist): 复审 r1 返工——帮助文案跟上新落点 + 「替换了清单」进人读通道 + 导入建实例绑到内容目录(M216 P2/P3/§5)
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`。
This commit is contained in:
1 parent
96f15c79fb
commit
4e1323212b
10 files changed
+359
-45
No files matched your search
@@ -104,7 +104,8 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
|||||||
MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/
|
MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/
|
||||||
(Prism 实例;非加载器 component 会逐项列进报告,不静默丢)
|
(Prism 实例;非加载器 component 会逐项列进报告,不静默丢)
|
||||||
|
|
||||||
目标目录缺省 = 数据根的共享游戏目录(.minecraft/);`--target` 可钉到实例目录。
|
目标目录缺省 = 新实例目录 `<数据根>/instances/<实例名>`(并登记实例,M216 起不再落共享
|
||||||
|
.minecraft 根);`--target` 可钉到别处(例如某个实例的运行目录)。
|
||||||
两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。
|
两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。
|
||||||
· 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖
|
· 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖
|
||||||
· 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」
|
· 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ pub struct InstallArgs {
|
|||||||
pub struct PackImportArgs {
|
pub struct PackImportArgs {
|
||||||
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
|
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
/// 目标实例目录(缺省 = 数据根的共享游戏目录 `game_home`)。
|
/// 目标实例目录(缺省 = 新实例目录 `<数据根>/instances/<实例名>`,M216 前是共享游戏目录
|
||||||
|
/// `game_home`)。
|
||||||
pub target: Option<PathBuf>,
|
pub target: Option<PathBuf>,
|
||||||
/// 实例显示名(缺省 = 包名)。
|
/// 实例显示名(缺省 = 包名)。
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
|||||||
@@ -130,9 +130,25 @@ fn human_lines(
|
|||||||
sources.join(" → ")
|
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}"));
|
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 {
|
if let Some(report) = report {
|
||||||
human.push(format!(
|
human.push(format!(
|
||||||
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
|
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
|
||||||
@@ -181,6 +197,62 @@ pub fn human_bytes(bytes: u64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::human_bytes;
|
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("已替换版本清单 <dest>(旧的那份备份在 <backup>)".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]
|
#[test]
|
||||||
fn formats_sizes_without_fake_precision() {
|
fn formats_sizes_without_fake_precision() {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
//! `pack import <file>`:整合包导入(M111)。
|
//! `pack import <file>`:整合包导入(M111)。
|
||||||
//!
|
//!
|
||||||
//! 逻辑**全在** `core::addon::import`:本文件只做三件事——参数落地、下载引擎接线、两种输出。
|
//! 逻辑**全在** `core::addon::import`:本文件只做参数落地、引擎接线、两种输出。坚持这一层很薄,
|
||||||
//! 之所以坚持这一层很薄,是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,
|
//! 是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,不是这里的打印逻辑。
|
||||||
//! 不是这里的打印逻辑。
|
|
||||||
//!
|
//!
|
||||||
//! # 三态退出码(脚本契约)
|
//! # 三态退出码(脚本契约)
|
||||||
//!
|
//!
|
||||||
@@ -13,15 +12,13 @@
|
|||||||
//! | `failed` | `1` | 致命失败,**已整包回滚** |
|
//! | `failed` | `1` | 致命失败,**已整包回滚** |
|
||||||
//! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) |
|
//! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) |
|
||||||
//!
|
//!
|
||||||
//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(一次性命令,
|
//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(Ctrl-C 直接杀进程),
|
||||||
//! Ctrl-C 直接杀进程),但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时
|
//! 但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时要能把它与 `failed` 分开。
|
||||||
//! 必须能把它与 `failed` 分开——所以退出码与文案在这里就位,接中断信号是下一步的事。
|
|
||||||
//!
|
//!
|
||||||
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
|
//! `--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 std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -32,7 +29,6 @@ use unlucky_core::addon::import::{
|
|||||||
use unlucky_core::download::blocking::block_on;
|
use unlucky_core::download::blocking::block_on;
|
||||||
use unlucky_core::download::DownloadEngine;
|
use unlucky_core::download::DownloadEngine;
|
||||||
use unlucky_core::game::install::pack_dir;
|
use unlucky_core::game::install::pack_dir;
|
||||||
use unlucky_core::platform::layout::LauncherLayout;
|
|
||||||
|
|
||||||
use crate::cli::PackImportArgs;
|
use crate::cli::PackImportArgs;
|
||||||
use crate::context::Ctx;
|
use crate::context::Ctx;
|
||||||
@@ -53,7 +49,19 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
|
|||||||
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
|
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
|
||||||
}
|
}
|
||||||
let layout = ctx.layout();
|
let layout = ctx.layout();
|
||||||
let (target, instance_name) = import_target(&layout, args)?;
|
// **M216 ②**:缺省落点 = `<data>/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<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
|
let stages: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let recorder = Arc::clone(&stages);
|
let recorder = Arc::clone(&stages);
|
||||||
@@ -80,30 +88,19 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let report = block_on(import_pack(&request));
|
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();
|
let seen = stages.lock().map(|seen| seen.clone()).unwrap_or_default();
|
||||||
Ok(outcome(ctx, &report, &target, &seen, args))
|
Ok(outcome(
|
||||||
}
|
ctx,
|
||||||
|
&report,
|
||||||
/// 导入落点与登记名:**M216 ②** 起与 GUI 同源(`<data>/instances/<实例目录名>`,不是共享的
|
&target,
|
||||||
/// `.minecraft` 根),判据收在 core 的 [`pack_dir::import_target`],本函数只做错误码翻译。
|
&seen,
|
||||||
///
|
args,
|
||||||
/// # Errors
|
binding.as_deref(),
|
||||||
///
|
))
|
||||||
/// 缺省目录已存在且非空(`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`(三态 → 退出码 + 两种输出)。
|
/// 组装 `Outcome`(三态 → 退出码 + 两种输出)。
|
||||||
@@ -113,6 +110,7 @@ fn outcome(
|
|||||||
target: &Path,
|
target: &Path,
|
||||||
stages: &[&'static str],
|
stages: &[&'static str],
|
||||||
args: &PackImportArgs,
|
args: &PackImportArgs,
|
||||||
|
binding: Option<&str>,
|
||||||
) -> Outcome {
|
) -> Outcome {
|
||||||
let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
|
let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
|
||||||
json!({
|
json!({
|
||||||
@@ -146,6 +144,9 @@ fn outcome(
|
|||||||
if !stages.is_empty() {
|
if !stages.is_empty() {
|
||||||
human.push(format!("阶段:{}", stages.join(" → ")));
|
human.push(format!("阶段:{}", stages.join(" → ")));
|
||||||
}
|
}
|
||||||
|
if let Some(note) = binding {
|
||||||
|
human.push(note.to_string());
|
||||||
|
}
|
||||||
human.push(format!(
|
human.push(format!(
|
||||||
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
|
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
|
||||||
status_label(report),
|
status_label(report),
|
||||||
|
|||||||
@@ -197,3 +197,90 @@ fn help_lists_the_pack_command() {
|
|||||||
assert_eq!(code, 0);
|
assert_eq!(code, 0);
|
||||||
assert!(stdout.contains("pack import"), "总帮助里也要有:{stdout}");
|
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(),
|
||||||
|
"缺省落点应当是 <data>/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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use super::instance::{self, InstanceConfig, Isolation};
|
||||||
|
use crate::game::error::GameError;
|
||||||
use crate::platform::layout::LauncherLayout;
|
use crate::platform::layout::LauncherLayout;
|
||||||
|
|
||||||
/// 包文件名 → 实例候选名(`Better MC.mrpack` → `Better MC`)。
|
/// 包文件名 → 实例候选名(`Better MC.mrpack` → `Better MC`)。
|
||||||
@@ -89,6 +91,82 @@ pub fn import_target(
|
|||||||
Ok((target.to_path_buf(), name))
|
Ok((target.to_path_buf(), name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 把「导入建出来的实例」绑到它的内容目录(`isolation=custom` + `game_dir`)。
|
||||||
|
///
|
||||||
|
/// # 为什么必须绑
|
||||||
|
///
|
||||||
|
/// 运行目录不是「导入目录」,而是从 `config/instances/<id>.json` 的 `isolation` 派生的
|
||||||
|
/// ([`derive_game_dir`](crate::game::isolation::derive_game_dir)):缺省档
|
||||||
|
/// `instances.mode=isolated` 给成 `<game>/versions/<versionId>`,`shared` 给成游戏根。
|
||||||
|
/// 导入目标 `<data>/instances/<名>` **不在这条链上** ⇒ 不绑的话「导入成功」而那批
|
||||||
|
/// mods/config 永远不会被那条实例的启动用到(M113 起 GUI 就有这个洞)。
|
||||||
|
///
|
||||||
|
/// # 绑成什么、不绑成什么
|
||||||
|
///
|
||||||
|
/// - 运行目录 = `dir`:`saves/` `mods/` `logs/` `config/` 落在这里(与别的启动器隔离);
|
||||||
|
/// - **`versions/` `libraries/` `assets/` 仍按 D20 恒共享**在游戏根 —— 不是「完全隔离」,
|
||||||
|
/// 版本本体只装一份,别人 `.minecraft` 里已下好的库也照旧复用。
|
||||||
|
///
|
||||||
|
/// # 回滚
|
||||||
|
///
|
||||||
|
/// 把 `config/instances/<id>.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<String> {
|
||||||
|
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 字母数字与 `-` / `_`,其余折成 `-`)。
|
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn slug(text: &str) -> String {
|
pub fn slug(text: &str) -> String {
|
||||||
@@ -189,4 +267,47 @@ mod tests {
|
|||||||
assert_eq!((dir, name.as_str()), (explicit, "x"));
|
assert_eq!((dir, name.as_str()), (explicit, "x"));
|
||||||
std::fs::remove_dir_all(&root).ok();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -343,10 +343,14 @@ pub(super) fn pack_stem(pack: &Path) -> String {
|
|||||||
unlucky_core::game::install::pack_dir::pack_stem(pack)
|
unlucky_core::game::install::pack_dir::pack_stem(pack)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **实例目录名**(判据见 core 的 [`pack_dir::instance_dir_name`]):本函数现在只有委托,
|
/// **实例目录名**(判据见 core 的 [`pack_dir::instance_dir_name`]):本函数只有一行委托。
|
||||||
/// 保留它是因为 presenter 侧五个调用点按 `convert::dir_name` 这个名字取。
|
///
|
||||||
|
/// **只剩 presenter 自己的用例在调它**(生产侧落点已改成 `pack_dir::instance_dir`,见 M216
|
||||||
|
/// P3-2),所以挂 `cfg(test)`:它是那组既有判据(空格折 `-`、中文补 `-<hash8>`、两个中文名
|
||||||
|
/// 不塌成同一个目录)继续钉住归一化规则的入口,生产构建里不需要它。
|
||||||
///
|
///
|
||||||
/// [`pack_dir::instance_dir_name`]: unlucky_core::game::install::pack_dir::instance_dir_name
|
/// [`pack_dir::instance_dir_name`]: unlucky_core::game::install::pack_dir::instance_dir_name
|
||||||
|
#[cfg(test)]
|
||||||
pub(super) fn dir_name(name: &str) -> String {
|
pub(super) fn dir_name(name: &str) -> String {
|
||||||
unlucky_core::game::install::pack_dir::instance_dir_name(name)
|
unlucky_core::game::install::pack_dir::instance_dir_name(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ use std::path::PathBuf;
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use unlucky_core::addon::import::report::ImportIssue;
|
||||||
use unlucky_core::addon::import::{
|
use unlucky_core::addon::import::{
|
||||||
import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress,
|
import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress,
|
||||||
ImportProgressSink, ImportRequest,
|
ImportProgressSink, ImportRequest,
|
||||||
};
|
};
|
||||||
use unlucky_core::download::blocking::block_on;
|
use unlucky_core::download::blocking::block_on;
|
||||||
|
use unlucky_core::game::install::pack_dir;
|
||||||
|
|
||||||
use crate::dto::{ImportRequestView, PackEvent};
|
use crate::dto::{ImportRequestView, PackEvent};
|
||||||
use unlucky_core::addon::import::ImportStatus;
|
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));
|
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`),否则运行
|
||||||
|
// 目录由全局档派生成 `<game>/versions/<version_id>`,这批 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`)。
|
// "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。
|
||||||
let cancel_requested = cancel.load(Ordering::SeqCst);
|
let cancel_requested = cancel.load(Ordering::SeqCst);
|
||||||
@@ -270,11 +280,9 @@ pub(super) fn target_of(
|
|||||||
.clone()
|
.clone()
|
||||||
.filter(|name| !name.trim().is_empty())
|
.filter(|name| !name.trim().is_empty())
|
||||||
.unwrap_or_else(|| convert::pack_stem(&request.pack));
|
.unwrap_or_else(|| convert::pack_stem(&request.pack));
|
||||||
let target = env
|
// **M216 P3-2**:落点也委托 core(`pack_dir::instance_dir`)——此前这里自己拼
|
||||||
.layout
|
// `<data>/instances/<dir_name>`,core 改了目录语义时 GUI 不会跟着走。
|
||||||
.data()
|
let target = pack_dir::instance_dir(&env.layout, &name);
|
||||||
.join("instances")
|
|
||||||
.join(convert::dir_name(&name));
|
|
||||||
if target.exists() {
|
if target.exists() {
|
||||||
// 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」
|
// 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」
|
||||||
let mut entries = std::fs::read_dir(&target)
|
let mut entries = std::fs::read_dir(&target)
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ struct Job<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Job<'_> {
|
impl Job<'_> {
|
||||||
|
/// 把 core 的运行期告警与「清单被替换」写进安装日志(**M216 P2-2**)。
|
||||||
|
///
|
||||||
|
/// 面板只有一行、Toast 4 秒就消失,安装日志是用户事后能自己回看的唯一渠道:动了共享
|
||||||
|
/// `.minecraft` 里那份 `versions/<id>/<id>.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)。
|
/// processor 段的进度出口(同一套 channel/meter/journal)。
|
||||||
fn reporter(&self) -> install_processors::Reporter<'_> {
|
fn reporter(&self) -> install_processors::Reporter<'_> {
|
||||||
install_processors::Reporter {
|
install_processors::Reporter {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ pub(super) fn install_vanilla(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| job_error(job, error))?;
|
.map_err(|error| job_error(job, error))?;
|
||||||
|
job.journal_report(&report);
|
||||||
ensure_complete(&report)?;
|
ensure_complete(&report)?;
|
||||||
Ok::<String, String>(version.to_string())
|
Ok::<String, String>(version.to_string())
|
||||||
})
|
})
|
||||||
@@ -178,6 +179,7 @@ pub(super) fn install_loader(
|
|||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
.map_err(|error| job_error(job, error))?;
|
.map_err(|error| job_error(job, error))?;
|
||||||
|
job.journal_report(&report);
|
||||||
ensure_complete(&report)?;
|
ensure_complete(&report)?;
|
||||||
Ok(release.version_id)
|
Ok(release.version_id)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user