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:
1 parent
ab5b593708
commit
96f15c79fb
14 files changed
+672
-118
No files matched your search
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user