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)]
@@ -162,6 +162,7 @@ impl Backend for Fake {
InstallReport {
files: outcomes,
version_json: None,
manifest: None,
warnings: Vec::new(),
}
}
@@ -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);
+80 -9
View File
@@ -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<PathBuf> {
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;
+44 -42
View File
@@ -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` 那一份)→ 便携那份 `<exe>/.ULL/config/launcher.json`,启动解析读的就是它;
/// 其余键 → `env_config`(解析后数据根那份)。
///
/// **写侧与读侧同源就是这一个函数**(M216 ①):老代码在 Standard 模式下把 `core.data_mode`
/// 写进 `%APPDATA%/ULL/config/launcher.json`,而下次启动只读 `<exe>/.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<PathBuf> {
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
}
/// `<exe 目录>/.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<SystemTime> {
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");