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

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

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

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

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

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

No files matched your search

+30 -8
View File
@@ -21,7 +21,7 @@
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、
//! 数据根不可用),与其它命令一致。
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde_json::json;
@@ -31,6 +31,8 @@ use unlucky_core::addon::import::{
};
use unlucky_core::download::blocking::block_on;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::install::pack_dir;
use unlucky_core::platform::layout::LauncherLayout;
use crate::cli::PackImportArgs;
use crate::context::Ctx;
@@ -50,10 +52,8 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
)
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
}
let target = args
.target
.clone()
.unwrap_or_else(|| ctx.game().root().to_path_buf());
let layout = ctx.layout();
let (target, instance_name) = import_target(&layout, args)?;
let stages: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&stages);
@@ -68,7 +68,6 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
let engine = build_engine(ctx);
let fetcher = DownloadEngineFetcher::new(&engine);
let layout = ctx.layout();
let mut request = ImportRequest::new(&args.path, &target)
.with_fetcher(&fetcher)
.with_progress(sink);
@@ -76,7 +75,8 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
request = request.with_conflict_policy(ConflictPolicy::Overwrite);
}
if !args.no_register {
request = request.with_registration(&layout, args.name.as_deref());
// 登记名与目录名同源(GUI 也是这样:`instance_name` 缺省用包名)。
request = request.with_registration(&layout, Some(&instance_name));
}
let report = block_on(import_pack(&request));
@@ -84,6 +84,28 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
Ok(outcome(ctx, &report, &target, &seen, args))
}
/// 导入落点与登记名:**M216 ②** 起与 GUI 同源(`<data>/instances/<实例目录名>`,不是共享的
/// `.minecraft` 根),判据收在 core 的 [`pack_dir::import_target`],本函数只做错误码翻译。
///
/// # Errors
///
/// 缺省目录已存在且非空(`target_exists`)。
fn import_target(
layout: &LauncherLayout,
args: &PackImportArgs,
) -> Result<(PathBuf, String), CliError> {
pack_dir::import_target(
layout,
&args.path,
args.target.as_deref(),
args.name.as_deref(),
)
.map_err(|reason| {
CliError::user("target_exists", reason)
.with_hint("加 --name 换个实例名,或显式给 --target / --overwrite")
})
}
/// 组装 `Outcome`(三态 → 退出码 + 两种输出)。
fn outcome(
ctx: &Ctx,
@@ -116,7 +138,7 @@ fn outcome(
report.format.as_deref().unwrap_or("-"),
target.display(),
if args.target.is_none() {
"(缺省 = 数据根的共享游戏目录)"
"(缺省 = 新实例目录 <数据根>/instances/)"
} else {
""
}