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

+13 -46
View File
@@ -336,57 +336,24 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I
}
/// 包文件名(`Better MC.mrpack` → `Better MC`)。
pub(super) fn pack_stem(pack: &Path) -> String {
pack.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
.unwrap_or_else(|| "imported-pack".to_string())
}
/// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。
///
/// 判据(复审 r2 P2-2 之后与注释对齐):名字里**只含** ASCII 字母数字、空格、`-`、`_` 时
/// 保持原样(空格折成 `-`,`Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、
/// 括号……)就补 `-<hash8>`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」
/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。
/// **M216 ②**:函数体一行委托 core(`game::install::pack_dir`)——CLI 调的是同一个函数,
/// 于是「CLI 缺省落点」与「GUI 落点」不只目录相同,连目录名归一化也只有一份实现。
pub(super) fn pack_stem(pack: &Path) -> String {
unlucky_core::game::install::pack_dir::pack_stem(pack)
}
/// **实例目录名**(判据见 core 的 [`pack_dir::instance_dir_name`]):本函数现在只有委托,
/// 保留它是因为 presenter 侧五个调用点按 `convert::dir_name` 这个名字取。
///
/// [`pack_dir::instance_dir_name`]: unlucky_core::game::install::pack_dir::instance_dir_name
pub(super) fn dir_name(name: &str) -> String {
let base = slug(name);
let faithful = !name.is_empty()
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ' ');
if faithful {
base
} else {
format!("{base}-{}", hash8(name))
}
unlucky_core::game::install::pack_dir::instance_dir_name(name)
}
/// FNV-1a 64 → 8 位十六进制(只要稳定、短、不需要密码学强度)。
fn hash8(text: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in text.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{:08x}", hash as u32)
}
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
/// 目录/文件名用的 slug(导出用的文件名也走它;实现同样收在 core `pack_dir`)。
pub(super) fn slug(text: &str) -> String {
let mut out = String::new();
for ch in text.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-').to_string();
if trimmed.is_empty() {
"pack".to_string()
} else {
trimmed
}
unlucky_core::game::install::pack_dir::slug(text)
}
#[cfg(test)]