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
@@ -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 {
|
||||
""
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ pub async fn download_libraries(
|
||||
InstallReport {
|
||||
files: outcomes,
|
||||
version_json: None,
|
||||
manifest: None,
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
//! `buffer_unordered` 支持「取消即清空队列」,本轮不做(不在 M105 的验收口径里)。
|
||||
|
||||
use std::future::poll_fn;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::pin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::task::Poll;
|
||||
@@ -60,6 +60,7 @@ use futures_util::future::{select, Either};
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
|
||||
use super::plan::PlanFile;
|
||||
use super::report::ManifestWrite;
|
||||
use super::{InstallError, InstallErrorKind, InstallPlan};
|
||||
use crate::download::verify;
|
||||
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind, HashAlgo};
|
||||
@@ -221,17 +222,28 @@ pub async fn execute_with_cancel(
|
||||
// 版本清单只在**全量就绪**时落盘:`is_version_installed()` 以清单存在为判据,
|
||||
// 半成品写进去会让 `instances list` 把"缺 jar 的版本"报成已安装。
|
||||
let complete = outcomes.iter().all(|outcome| !outcome.failed());
|
||||
let version_json = if complete {
|
||||
write_version_json(&plan.version_json_path, plan)?;
|
||||
Some(plan.version_json_path.clone())
|
||||
let mut warnings = plan.warnings.clone();
|
||||
let manifest = if complete {
|
||||
let written = write_version_json(&plan.version_json_path, plan)?;
|
||||
// **M216 ③**:替换了别人的清单要大声说一句(`report.warnings` 会进 CLI 的 `--json`,
|
||||
// GUI 侧另有结构化的 `manifest` 字段可读)。内容相同(重装)不在此列。
|
||||
if let Some(backup) = written.backup() {
|
||||
warnings.push(format!(
|
||||
"已替换版本清单 {}(旧的那份备份在 {}):这个 .minecraft 是共享的,别的启动器可能也用它",
|
||||
plan.version_json_path.display(),
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
Some(written)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(InstallReport {
|
||||
files: outcomes,
|
||||
version_json,
|
||||
warnings: plan.warnings.clone(),
|
||||
version_json: complete.then(|| plan.version_json_path.clone()),
|
||||
manifest,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -322,13 +334,61 @@ pub fn current_bytes(file: &PlanFile) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 写解析后的版本清单(原子写)。
|
||||
fn write_version_json(path: &Path, plan: &InstallPlan) -> Result<(), super::InstallError> {
|
||||
/// 写解析后的版本清单(原子写),并如实交代「原来那份怎么办」(**M216 ③**)。
|
||||
///
|
||||
/// 三条判据:
|
||||
/// - 目标不存在 → [`ManifestWrite::Fresh`],正常写;
|
||||
/// - 存在且内容**逐字节相同** → [`ManifestWrite::Identical`],**一个字节都不写**(重装同版本
|
||||
/// 是正常操作:既不是冲突,也不该改别人的 mtime);
|
||||
/// - 存在且内容不同(含「存在但读不出来」——读不了 ≠ 相同)→ 旧的那份先 **copy** 到
|
||||
/// `<dest>.ullbak-<pid>`,再原子写 ⇒ [`ManifestWrite::Replaced`]。
|
||||
///
|
||||
/// 备份用 `copy` 而不是 `rename`:整合包导入的合并态用 rename 是因为它紧接着覆盖同一路径、
|
||||
/// 失败还会回滚;这里若 rename 之后原子写失败(目标正被别的启动器占着),盘上会**一份清单
|
||||
/// 都不剩**。copy 之下最坏是「原件完好 + 多一份备份」。
|
||||
fn write_version_json(
|
||||
path: &Path,
|
||||
plan: &InstallPlan,
|
||||
) -> Result<ManifestWrite, super::InstallError> {
|
||||
let text = plan.version_json.to_json_string().map_err(|error| {
|
||||
super::InstallError::new(super::InstallErrorKind::Metadata, error.to_string())
|
||||
})?;
|
||||
write_atomic(path, text.as_bytes())
|
||||
.map_err(|error| super::InstallError::new(super::InstallErrorKind::Io, error.to_string()))
|
||||
let bytes = text.as_bytes();
|
||||
if std::fs::read(path).ok().as_deref() == Some(bytes) {
|
||||
return Ok(ManifestWrite::Identical);
|
||||
}
|
||||
let Some(backup) = path.is_file().then(|| backup_manifest(path)) else {
|
||||
write_atomic(path, bytes).map_err(io_error)?;
|
||||
return Ok(ManifestWrite::Fresh);
|
||||
};
|
||||
std::fs::copy(path, &backup).map_err(io_error)?;
|
||||
write_atomic(path, bytes).map_err(io_error)?;
|
||||
Ok(ManifestWrite::Replaced { backup })
|
||||
}
|
||||
|
||||
/// IO 失败 → 安装错误(`std::io::Error` 与 `PlatformError` 都吃,取 `Display` 原文)。
|
||||
fn io_error(error: impl std::fmt::Display) -> super::InstallError {
|
||||
super::InstallError::new(super::InstallErrorKind::Io, error.to_string())
|
||||
}
|
||||
|
||||
/// `<dest>.ullbak-<pid>`(与整合包导入同一命名习惯,见 `addon/import/stage/commit.rs`)。
|
||||
///
|
||||
/// 同一进程里再替换一次时补 `-<n>`:否则第二份备份会盖掉第一份,而第一份很可能才是别人
|
||||
/// 启动器铺的那份原文。
|
||||
fn backup_manifest(path: &Path) -> PathBuf {
|
||||
let mut name = path
|
||||
.file_name()
|
||||
.map_or_else(|| "version.json".into(), std::ffi::OsStr::to_os_string);
|
||||
name.push(format!(".ullbak-{}", std::process::id()));
|
||||
let mut candidate = path.with_file_name(&name);
|
||||
let mut seq = 1_u32;
|
||||
while candidate.exists() {
|
||||
let mut next = name.clone();
|
||||
next.push(format!("-{seq}"));
|
||||
candidate = path.with_file_name(next);
|
||||
seq += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
fn describe(error: &DownloadError) -> FileFailure {
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod assets;
|
||||
pub mod execute;
|
||||
pub mod instance;
|
||||
pub mod libraries;
|
||||
pub mod pack_dir;
|
||||
pub mod plan;
|
||||
pub mod plan_view;
|
||||
mod report;
|
||||
@@ -41,7 +42,9 @@ pub use assets::{AssetIndex, AssetObject, ASSET_OBJECT_BASE};
|
||||
pub use execute::{execute, execute_with_cancel};
|
||||
pub use instance::{register, InstanceConfig, Registration};
|
||||
pub use plan::{FileRole, InstallPlan, PlanFile, PlanInput};
|
||||
pub use report::{FileFailure, FileOutcome, FileStatus, InstallReport, ProgressSink};
|
||||
pub use report::{
|
||||
FileFailure, FileOutcome, FileStatus, InstallReport, ManifestWrite, ProgressSink,
|
||||
};
|
||||
pub use source::{ManifestFreshness, RemoteSource, MANIFEST_TTL_MS};
|
||||
|
||||
use crate::download::{DownloadError, DownloadErrorKind};
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! 整合包导入的**落点派生**(M216 ②):`<data>/instances/<实例目录名>` + 名字归一化。
|
||||
//!
|
||||
//! 为什么在 core:`ull pack import` 与 GUI 导入页必须落到**同一个**目录、用**同一份**名字
|
||||
//! 归一化。M215 survey 的债②就是两边不同源——CLI 缺省指向共享 `.minecraft` 根
|
||||
//! (`cli/commands/pack.rs`),GUI 指向 `<data>/instances/<dir_name(包名)>`
|
||||
//! (`presenter/host/addon/pack/import.rs::target_of`)。归一化留在 presenter 就只有 GUI 能看,
|
||||
//! CLI 复制一份就是第三处真相源;放这里,两侧各一行委托。
|
||||
//!
|
||||
//! 归 GUI 侧独有的部分**不在**这里:选中的实例 → 其运行目录(要读隔离档与实例设置,
|
||||
//! `pack.rs::instance_root`),那是「用已有实例」而不是「建新实例目录」。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
/// 包文件名 → 实例候选名(`Better MC.mrpack` → `Better MC`)。
|
||||
///
|
||||
/// 取不到 `file_stem`(路径以 `..` 结尾之类)时给 `imported-pack`:宁可名字难看,
|
||||
/// 也不要一个空目录名。
|
||||
#[must_use]
|
||||
pub 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 化 + 必要时补一段内容哈希。
|
||||
///
|
||||
/// 判据:名字里**只含** ASCII 字母数字、空格、`-`、`_` 时保持原样(空格折成 `-`,
|
||||
/// `Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、括号……)就补 `-<hash8>`,
|
||||
/// 用原名的哈希保住区分度——否则「我的整合包」「另一个整合包」都会得到 `pack`,
|
||||
/// 第二个中文名导入直接撞「目录已存在」。
|
||||
#[must_use]
|
||||
pub fn instance_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))
|
||||
}
|
||||
}
|
||||
|
||||
/// 新实例的内容目录:`<data>/instances/<实例目录名>`(**只算路径,不建目录**)。
|
||||
///
|
||||
/// 调用方自己决定要不要建(CLI 的预检与 GUI 的 `prepare_target` 都要先看路径)。
|
||||
#[must_use]
|
||||
pub fn instance_dir(layout: &LauncherLayout, name: &str) -> PathBuf {
|
||||
layout
|
||||
.data()
|
||||
.join("instances")
|
||||
.join(instance_dir_name(name))
|
||||
}
|
||||
|
||||
/// 导入一次的**落点与登记名**:显式目标优先,否则 `<data>/instances/<实例目录名>`。
|
||||
///
|
||||
/// 名字 = `name`(去空格,空串不算)或包文件名;**目录名与登记名用同一个值**(GUI 同)。
|
||||
/// `Err` = 缺省目录已存在且非空的人读原因:空目录(上次失败的残留)可以复用,否则一次
|
||||
/// 失败之后永远重试不了;非空也不猜「你想合并进已有实例」,让用户显式给目标。
|
||||
///
|
||||
/// GUI 导入页(`import.rs::target_of`)是同一判据的另一份实现,外加一条「用已有实例」分支
|
||||
/// (要读隔离档与实例设置)——那条分支只有 GUI 有,所以落点判定留了这道公共函数给 CLI。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 缺省目录非空时返回原因字符串。
|
||||
pub fn import_target(
|
||||
layout: &LauncherLayout,
|
||||
pack: &Path,
|
||||
target: Option<&Path>,
|
||||
name: Option<&str>,
|
||||
) -> Result<(PathBuf, String), String> {
|
||||
let name = name
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| pack_stem(pack));
|
||||
let Some(target) = target else {
|
||||
let dir = instance_dir(layout, &name);
|
||||
let occupied = std::fs::read_dir(&dir).is_ok_and(|mut entries| entries.next().is_some());
|
||||
if occupied {
|
||||
return Err(format!("目标实例目录已存在且非空:{}", dir.display()));
|
||||
}
|
||||
return Ok((dir, name));
|
||||
};
|
||||
Ok((target.to_path_buf(), name))
|
||||
}
|
||||
|
||||
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
|
||||
#[must_use]
|
||||
pub 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 抓得住:目录名归一化的四条既有判据(ASCII 保真、空格折 `-`、中文补哈希、点号补哈希),
|
||||
/// 以及 `<data>/instances/<名>` 这个落点本身。
|
||||
/// 抓不住:GUI 是否真的改调这个函数(那条由 presenter 侧的既有用例继续钉着)。
|
||||
#[test]
|
||||
fn the_instance_dir_is_derived_the_same_way_the_gui_used_to() {
|
||||
assert_eq!(
|
||||
pack_stem(Path::new("C:/packs/Better MC.mrpack")),
|
||||
"Better MC"
|
||||
);
|
||||
assert_eq!(pack_stem(Path::new("packs/unnamed")), "unnamed");
|
||||
assert_eq!(instance_dir_name("Better MC"), "Better-MC");
|
||||
assert_eq!(instance_dir_name("Better-MC"), "Better-MC");
|
||||
assert_eq!(instance_dir_name("pack"), "pack");
|
||||
// 折过字符 ⇒ 补哈希保住区分度(两个中文名不许塌成同一个目录)。
|
||||
let a = instance_dir_name("我的整合包");
|
||||
let b = instance_dir_name("另一个整合包");
|
||||
assert_ne!(a, b);
|
||||
assert!(
|
||||
a.starts_with("pack-") && a.len() == "pack-".len() + 8,
|
||||
"{a}"
|
||||
);
|
||||
assert!(instance_dir_name("Survival 1.20.1").starts_with("Survival-1-20-1-"));
|
||||
// 落点:`<data>/instances/<目录名>`,不是共享的 `.minecraft` 根。
|
||||
let layout = LauncherLayout::new(PathBuf::from("/data/.ULL"));
|
||||
assert_eq!(
|
||||
instance_dir(&layout, "Better MC"),
|
||||
PathBuf::from("/data/.ULL/data/instances/Better-MC")
|
||||
);
|
||||
}
|
||||
|
||||
/// **M216 ②**:缺省落点与 GUI 同源(`<data>/instances/<名>`),**不是共享的 `.minecraft` 根**;
|
||||
/// 显式目标照给、空目录可复用、非空才报错。
|
||||
///
|
||||
/// 抓得住:缺省改回 `game_home` 根(第一条断言会变成 `…/ .minecraft`)、目录名不归一化、
|
||||
/// 非空目录不复检、登记名与目录名分叉。抓不住:CLI/GUI 是否真调用本函数(编译期无约束,
|
||||
/// 靠两侧各自的调用点用例),以及真实 `Ctx::layout()` / `HostEnv` 的取值。
|
||||
#[test]
|
||||
fn the_default_target_is_an_instance_dir_not_the_shared_minecraft_root() {
|
||||
let root = std::env::temp_dir().join(format!("ull-m216-pack-{}", std::process::id()));
|
||||
let layout = LauncherLayout::new(root.join(".ULL"));
|
||||
let pack = Path::new("C:/packs/Better MC.mrpack");
|
||||
let target =
|
||||
|target: Option<&Path>, name: Option<&str>| import_target(&layout, pack, target, name);
|
||||
|
||||
let (dir, name) = target(None, None).expect("缺省可用");
|
||||
assert_eq!(dir, root.join(".ULL/data/instances/Better-MC"));
|
||||
assert!(
|
||||
!dir.starts_with(root.join(".minecraft")),
|
||||
"不许落到共享游戏根"
|
||||
);
|
||||
assert_eq!(name, "Better MC", "登记名与目录名同源");
|
||||
|
||||
std::fs::create_dir_all(&dir).expect("建目录");
|
||||
assert_eq!(
|
||||
target(None, None).expect("空目录可复用").0,
|
||||
dir,
|
||||
"空目录可复用"
|
||||
);
|
||||
std::fs::write(dir.join("mmc-pack.json"), b"{}").expect("写文件");
|
||||
let reason = target(None, None).expect_err("非空要报错");
|
||||
assert!(reason.contains("已存在且非空"), "{reason}");
|
||||
|
||||
let explicit = root.join("my-target");
|
||||
let (dir, name) = target(Some(&explicit), Some("x")).expect("显式目标照给");
|
||||
assert_eq!((dir, name.as_str()), (explicit, "x"));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 纯数据 + 纯方法:`execute.rs`(执行)与 CLI(汇总打印)都读它,谁也不改它。
|
||||
//! 搬移是**逐字**的,只有 `use` 断了回来(原来靠 `execute.rs` 的 import 活着)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::download::DownloadErrorKind;
|
||||
@@ -74,6 +74,47 @@ impl FileOutcome {
|
||||
/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
|
||||
pub type ProgressSink = Arc<dyn Fn(&FileOutcome) + Send + Sync>;
|
||||
|
||||
/// 版本清单(`versions/<id>/<id>.json`)这一处落盘的结局(**M216 ③**)。
|
||||
///
|
||||
/// 这一份清单是「版本已安装」的判据,也是**别人的 `.minecraft` 里同一个文件**:装在同一个
|
||||
/// 上级目录下的另一个启动器(官方 / PCL / HMCL)铺的就是它。M215 survey 判为债③:老实现
|
||||
/// `write_atomic` 无条件 rename 覆盖,不检测、不备份、不提示。现在三种结局都如实记下来。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ManifestWrite {
|
||||
/// 目标原本没有清单:正常新建。
|
||||
Fresh,
|
||||
/// 目标已有**完全相同**的内容:**一个字节都没写**(别人的 mtime / ACL 保持原样)。
|
||||
///
|
||||
/// 重装同版本走这一档:正常操作,不该被当成冲突,也不该留一堆备份。
|
||||
Identical,
|
||||
/// 目标已有**不同**的内容:旧清单先复制到 `backup`,再写新的。
|
||||
Replaced {
|
||||
/// 旧清单的备份路径(`<dest>.ullbak-<pid>`,与整合包导入同一命名习惯)。
|
||||
backup: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl ManifestWrite {
|
||||
/// 稳定字符串 id(日志 / `--json` 用)。
|
||||
#[must_use]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Fresh => "fresh",
|
||||
Self::Identical => "identical",
|
||||
Self::Replaced { .. } => "replaced",
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份路径(只有 [`Self::Replaced`] 有)。
|
||||
#[must_use]
|
||||
pub fn backup(&self) -> Option<&Path> {
|
||||
match self {
|
||||
Self::Replaced { backup } => Some(backup),
|
||||
Self::Fresh | Self::Identical => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次安装的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InstallReport {
|
||||
@@ -81,7 +122,9 @@ pub struct InstallReport {
|
||||
pub files: Vec<FileOutcome>,
|
||||
/// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 `execute` 的说明)。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// 计划阶段带来的告警(缺件降级说明)。
|
||||
/// 版本清单这一处怎么落的盘;`None` = 本次根本没写清单(有失败 / 不写清单的那条路径)。
|
||||
pub manifest: Option<ManifestWrite>,
|
||||
/// 计划阶段带来的告警(缺件降级说明)+ 运行期告警(清单被替换等)。
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -302,6 +302,7 @@ fn failure_kind_prefers_integrity() {
|
||||
}),
|
||||
};
|
||||
let report = crate::game::install::InstallReport {
|
||||
manifest: None,
|
||||
files: vec![
|
||||
failed(DownloadErrorKind::AllSourcesFailed),
|
||||
failed(DownloadErrorKind::Integrity),
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! M216 ③:装同一版本时**不再静默覆盖** `versions/<id>/<id>.json`。
|
||||
//!
|
||||
//! 三条判据各一条用例(都走真的 `execute`,所以报告字段是端到端验过的,不是只测私有函数):
|
||||
//!
|
||||
//! | 盘上状态 | 期望 |
|
||||
//! |---|---|
|
||||
//! | 没有清单 | `ManifestWrite::Fresh`,无备份、无告警 |
|
||||
//! | 有、且内容**逐字节相同** | `Identical`,不写、不备份、不告警(重装同版本是正常操作)|
|
||||
//! | 有、且内容**不同** | `Replaced{backup}`,旧的那份进 `<dest>.ullbak-<pid>`,告警里点名 |
|
||||
//!
|
||||
//! 全部零网络:客户端 jar 事先按 sha1 放好,跳过判定第一级直接命中。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::download::verify;
|
||||
use crate::download::{
|
||||
ContentCache, DownloadEngine, EngineConfig, HashAlgo, HealthRegistry, MirrorTable,
|
||||
RegionProfile,
|
||||
};
|
||||
use crate::game::arguments::Environment;
|
||||
use crate::game::install::execute::execute;
|
||||
use crate::game::install::plan::{InstallPlan, PlanInput};
|
||||
use crate::game::install::{InstallReport, ManifestWrite};
|
||||
use crate::game::layout::GameLayout;
|
||||
use crate::game::manifest::VersionJson;
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("ull-m216-manifest-{}-{tag}", std::process::id()))
|
||||
}
|
||||
|
||||
fn engine(root: &Path) -> DownloadEngine {
|
||||
DownloadEngine::new(
|
||||
EngineConfig::default(),
|
||||
Arc::new(MirrorTable::builtin()),
|
||||
Arc::new(RegionProfile::direct(0)),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
Arc::new(ContentCache::open(root.join("cache"))),
|
||||
)
|
||||
}
|
||||
|
||||
/// 客户端 jar 已在盘上的一份计划(于是整次安装零下载,只有清单这一步会写盘)。
|
||||
fn plan_with_existing_jar(root: &Path) -> InstallPlan {
|
||||
let payload = b"already installed client jar".to_vec();
|
||||
let layout = GameLayout::new(root);
|
||||
let sha1 = verify::digest_bytes(HashAlgo::Sha1, &payload);
|
||||
std::fs::create_dir_all(root.join("versions/1.20.1")).expect("建版本目录");
|
||||
std::fs::write(layout.version_jar("1.20.1"), &payload).expect("放客户端 jar");
|
||||
let resolved = VersionJson::from_json_str(&format!(
|
||||
r#"{{"id": "1.20.1", "mainClass": "net.minecraft.client.main.Main",
|
||||
"downloads": {{"client": {{"sha1": "{sha1}", "size": {}, "url": "https://piston-data.mojang.com/v1/objects/{sha1}/client.jar"}}}}}}"#,
|
||||
payload.len()
|
||||
))
|
||||
.expect("清单应可解析");
|
||||
InstallPlan::build(PlanInput {
|
||||
layout: &layout,
|
||||
version_id: "1.20.1",
|
||||
resolved: &resolved,
|
||||
asset_index: None,
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("组计划应成功")
|
||||
}
|
||||
|
||||
/// 版本目录下的备份文件(`<dest>.ullbak-<pid>[-n]`)。
|
||||
fn backups(plan: &InstallPlan) -> Vec<PathBuf> {
|
||||
let Some(dir) = plan.version_json_path.parent() else {
|
||||
return Vec::new();
|
||||
};
|
||||
std::fs::read_dir(dir)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.to_string_lossy().contains(".ullbak-"))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn says_replaced(report: &InstallReport) -> bool {
|
||||
report
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|line| line.contains("已替换版本清单"))
|
||||
}
|
||||
|
||||
/// **不存在 ⇒ 正常写**(不能因为加了检测就把「第一次装」也变成需要备份的路径)。
|
||||
///
|
||||
/// 抓得住:Fresh 被误标成 Replaced/Identical(那会让报告对第一次安装说谎)、凭空多出备份、
|
||||
/// 以及「检测逻辑把没有清单的路径判成失败」。抓不住:写完之后别的启动器能不能读(实机才验)。
|
||||
#[tokio::test]
|
||||
async fn a_version_without_a_manifest_gets_one_written() {
|
||||
let root = temp_root("fresh");
|
||||
let plan = plan_with_existing_jar(&root);
|
||||
let report = execute(&engine(&root), &plan, 4, None)
|
||||
.await
|
||||
.expect("执行应成功");
|
||||
|
||||
assert_eq!(report.manifest, Some(ManifestWrite::Fresh));
|
||||
assert!(plan.version_json_path.is_file(), "清单该落盘");
|
||||
assert!(backups(&plan).is_empty(), "没覆盖就没什么可备份");
|
||||
assert!(!says_replaced(&report), "第一次装不该说「替换了」");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// **存在且相同 ⇒ 不写、不备份、不提示**(重装同版本是正常操作,不是冲突)。
|
||||
///
|
||||
/// 抓得住:内容相同也走备份/告警(`backups` 非空或 `says_replaced` 为真就红)、清单内容被改。
|
||||
/// 抓不住:是不是真的没调 `write_atomic`——内容本来就一样,盘上看不出来;这一条靠
|
||||
/// `Identical` 早退那段代码 review 兜住(Windows 上另加一半证据:只读属性下 rename 会失败,
|
||||
/// 所以「只读 + 仍然成功」在 Windows 上等于没写)。
|
||||
#[tokio::test]
|
||||
async fn reinstalling_the_same_version_leaves_the_manifest_alone() {
|
||||
let root = temp_root("identical");
|
||||
let plan = plan_with_existing_jar(&root);
|
||||
execute(&engine(&root), &plan, 4, None)
|
||||
.await
|
||||
.expect("第一次执行应成功");
|
||||
let before = std::fs::read(&plan.version_json_path).expect("读清单");
|
||||
|
||||
// Windows 上把这枚文件设成只读:写它的唯一途径是 rename 盖过去,那会失败。
|
||||
// (只读这条证据只在 Windows 上成立;Unix 下目录权限才管 rename,所以那里它不证明什么。)
|
||||
let original = std::fs::metadata(&plan.version_json_path)
|
||||
.expect("读元数据")
|
||||
.permissions();
|
||||
let mut readonly = original.clone();
|
||||
readonly.set_readonly(true);
|
||||
std::fs::set_permissions(&plan.version_json_path, readonly).expect("设只读");
|
||||
|
||||
let again = execute(&engine(&root), &plan, 4, None).await;
|
||||
// 复原**原来的**权限(不是 `set_readonly(false)`:那会把 Unix 的位改成 world-writable)。
|
||||
std::fs::set_permissions(&plan.version_json_path, original).expect("恢复权限");
|
||||
|
||||
let report = again.expect("内容相同 ⇒ 不该尝试写盘(只读也不该失败)");
|
||||
assert_eq!(report.manifest, Some(ManifestWrite::Identical));
|
||||
assert_eq!(
|
||||
std::fs::read(&plan.version_json_path).expect("读清单"),
|
||||
before,
|
||||
"内容相同 ⇒ 清单一个字节都不该变"
|
||||
);
|
||||
assert!(backups(&plan).is_empty(), "内容相同不该备份");
|
||||
assert!(!says_replaced(&report), "内容相同不该提示替换");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// **存在且不同 ⇒ 备份 + 报告里可见**(这是「不静默破坏别人」的核心一条)。
|
||||
///
|
||||
/// 抓得住:去掉备份只覆盖(`backup` 为空、`.ullbak-*` 不存在 ⇒ 红)、旧内容丢失(备份内容
|
||||
/// 不等于别人的原文 ⇒ 红)、报告不说(`ManifestWrite::Replaced` 与告警都缺 ⇒ 红)。
|
||||
/// 抓不住:真实另一个启动器**正开着**这枚文件时的失败落点(Windows 共享冲突),那要实机。
|
||||
#[tokio::test]
|
||||
async fn a_different_manifest_is_backed_up_before_being_replaced() {
|
||||
let root = temp_root("replaced");
|
||||
let plan = plan_with_existing_jar(&root);
|
||||
// 「别人铺的那份」:同路径、不同内容(这里用另一份 id/mainClass 模拟)。
|
||||
let foreign = br#"{"id":"1.20.1","mainClass":"org.pcl.Launcher","note":"not ours"}"#.to_vec();
|
||||
std::fs::write(&plan.version_json_path, &foreign).expect("放别人的清单");
|
||||
|
||||
let report = execute(&engine(&root), &plan, 4, None)
|
||||
.await
|
||||
.expect("执行应成功");
|
||||
let Some(ManifestWrite::Replaced { backup }) = report.manifest.clone() else {
|
||||
panic!("应当报 Replaced,实际 {:?}", report.manifest);
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(&backup).expect("读备份"),
|
||||
foreign,
|
||||
"备份=别人原文"
|
||||
);
|
||||
assert_eq!(
|
||||
backups(&plan),
|
||||
vec![backup.clone()],
|
||||
"备份只有一份、在版本目录里"
|
||||
);
|
||||
assert_ne!(
|
||||
std::fs::read(&plan.version_json_path).expect("读清单"),
|
||||
foreign,
|
||||
"目标应当是刚解析出来的那份"
|
||||
);
|
||||
let written = std::fs::read_to_string(&plan.version_json_path).expect("读清单");
|
||||
assert!(
|
||||
VersionJson::from_json_str(&written).is_ok_and(|json| json.id.as_deref() == Some("1.20.1")),
|
||||
"{written}"
|
||||
);
|
||||
assert!(says_replaced(&report), "报告里必须点名「替换了已有清单」");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
@@ -5,4 +5,5 @@
|
||||
|
||||
mod execute;
|
||||
mod instance;
|
||||
mod manifest_write;
|
||||
mod plan;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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