Merge branch 'feat/m216-coexistcore-shared-minecraft-no-sil'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
7521c47944
23 files changed
+1168
-139
No files matched your search
@@ -104,7 +104,9 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/
|
||||
(Prism 实例;非加载器 component 会逐项列进报告,不静默丢)
|
||||
|
||||
目标目录缺省 = 数据根的共享游戏目录(.minecraft/);`--target` 可钉到实例目录。
|
||||
目标目录缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`(并登记实例,M216 起
|
||||
不再落共享 .minecraft 根;目录名是包名/`--name` 经 ASCII 归一化后的结果,如 `Better-MC`);
|
||||
`--target` 可钉到别处(例如某个实例的运行目录)。
|
||||
两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。
|
||||
· 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖
|
||||
· 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」
|
||||
|
||||
@@ -157,7 +157,8 @@ pub struct InstallArgs {
|
||||
pub struct PackImportArgs {
|
||||
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
|
||||
pub path: PathBuf,
|
||||
/// 目标实例目录(缺省 = 数据根的共享游戏目录 `game_home`)。
|
||||
/// 目标实例目录(缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`,M216 前是
|
||||
/// 共享游戏目录 `game_home`)。
|
||||
pub target: Option<PathBuf>,
|
||||
/// 实例显示名(缺省 = 包名)。
|
||||
pub name: Option<String>,
|
||||
|
||||
@@ -85,12 +85,13 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
|
||||
skipped: false,
|
||||
});
|
||||
return Ok(report_loader::build(
|
||||
ctx, &engine, now, true, &plan, None, None, &name, section,
|
||||
ctx, &engine, now, true, &plan, None, None, None, &name, section,
|
||||
));
|
||||
}
|
||||
|
||||
let mut vanilla_report = None;
|
||||
if plan.vanilla_install_needed {
|
||||
let (_, vanilla_report) = vanilla::plan_and_install(
|
||||
let (_, report) = vanilla::plan_and_install(
|
||||
ctx,
|
||||
&engine,
|
||||
&version,
|
||||
@@ -98,11 +99,14 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
|
||||
args.progress,
|
||||
args.json_progress,
|
||||
)?;
|
||||
if let Some(report) = &vanilla_report {
|
||||
if let Some(report) = &report {
|
||||
if !report.is_complete() {
|
||||
return Err(failure_error(report, ctx));
|
||||
}
|
||||
}
|
||||
// **M216 r2 P2-2**:原版那段 report 不能在这里丢掉——它的 `warnings` / `manifest`
|
||||
// (可能替换了共享 `.minecraft` 里 `versions/<原版>/<原版>.json`)必须进人读与 `--json`。
|
||||
vanilla_report = report;
|
||||
}
|
||||
|
||||
// installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。
|
||||
@@ -143,6 +147,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
|
||||
false,
|
||||
&plan,
|
||||
Some(&install_report),
|
||||
vanilla_report.as_ref(),
|
||||
Some(®istration),
|
||||
&name,
|
||||
section,
|
||||
|
||||
@@ -130,9 +130,25 @@ fn human_lines(
|
||||
sources.join(" → ")
|
||||
));
|
||||
}
|
||||
for warning in &plan.warnings {
|
||||
// **M216 P2-2**:有 report 就报**运行期**告警——它开头就是 `plan.warnings` 的副本,加上执行期
|
||||
// 才知道的事(「已替换版本清单 … 旧的那份备份在 …」)。此前这里只遍历 `plan.warnings`,
|
||||
// 于是「我们动了别人 `.minecraft` 里那份清单」这句话在 `ull install` 的人读输出里**从没出现过**
|
||||
// (只有 `--json` 的 `result.warnings` 有)——「不静默破坏」就落不了地。
|
||||
// 没有 report(`--dry-run`)时退回计划期告警。
|
||||
let warnings = report.map_or(plan.warnings.as_slice(), |report| {
|
||||
report.warnings.as_slice()
|
||||
});
|
||||
for warning in warnings {
|
||||
human.push(format!("告警: {warning}"));
|
||||
}
|
||||
if let Some(manifest) = report.and_then(|report| report.manifest.as_ref()) {
|
||||
if let Some(backup) = manifest.backup() {
|
||||
human.push(format!(
|
||||
"版本清单已替换(旧的那份备份在 {})",
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(report) = report {
|
||||
human.push(format!(
|
||||
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
|
||||
@@ -181,6 +197,62 @@ pub fn human_bytes(bytes: u64) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::human_bytes;
|
||||
use super::*;
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::{ManifestWrite, PlanInput};
|
||||
use unlucky_core::game::layout::GameLayout;
|
||||
use unlucky_core::game::manifest::VersionJson;
|
||||
|
||||
/// **M216 P2-2**:清单被替换这件事必须出现在**人读**行里(以前只进 `--json`,
|
||||
/// 复审 r1 抓的就是「对玩家仍然静默」)。
|
||||
///
|
||||
/// 抓得住:`human_lines` 退回只遍历 `plan.warnings`(那时找不到「已替换版本清单」⇒ 红,
|
||||
/// 正是修前状态);`manifest` 的备份路径没渲出来。抓不住:真实终端排版与颜色、
|
||||
/// 以及 `ull install` 的整条命令链(本用例直接调排版函数,不落盘、不联网)。
|
||||
#[test]
|
||||
fn a_replaced_manifest_is_said_out_loud_in_the_human_output() {
|
||||
let root = std::env::temp_dir().join(format!("ull216-report-{}", std::process::id()));
|
||||
let layout = GameLayout::new(&root);
|
||||
let resolved = VersionJson::from_json_str(
|
||||
r#"{"id":"1.20.1","mainClass":"net.minecraft.client.main.Main",
|
||||
"downloads":{"client":{"sha1":"aa","size":1,"url":"https://example.invalid/c.jar"}}}"#,
|
||||
)
|
||||
.expect("清单应可解析");
|
||||
let plan = InstallPlan::build(PlanInput {
|
||||
layout: &layout,
|
||||
version_id: "1.20.1",
|
||||
resolved: &resolved,
|
||||
asset_index: None,
|
||||
environment: &Environment::host(),
|
||||
})
|
||||
.expect("组计划应成功");
|
||||
|
||||
let backup = root.join("versions/1.20.1/1.20.1.json.ullbak-4242");
|
||||
let mut warnings = plan.warnings.clone();
|
||||
warnings.push("已替换版本清单 <dest>(旧的那份备份在 <backup>)".to_string());
|
||||
let report = InstallReport {
|
||||
files: Vec::new(),
|
||||
version_json: Some(plan.version_json_path.clone()),
|
||||
manifest: Some(ManifestWrite::Replaced {
|
||||
backup: backup.clone(),
|
||||
}),
|
||||
warnings,
|
||||
};
|
||||
|
||||
let text = human_lines(false, &plan, Some(&report), None, &[], "ignored", "1.20.1").join(
|
||||
"
|
||||
",
|
||||
);
|
||||
assert!(
|
||||
text.contains("已替换版本清单"),
|
||||
"运行期告警没进人读输出:{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&backup.display().to_string()),
|
||||
"备份路径没进人读输出:{text}"
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_sizes_without_fake_precision() {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是
|
||||
//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。
|
||||
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun};
|
||||
use unlucky_core::addon::loader::LoaderInstallPlan;
|
||||
use unlucky_core::download::DownloadEngine;
|
||||
@@ -39,6 +39,7 @@ pub fn build(
|
||||
dry_run: bool,
|
||||
plan: &LoaderInstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
vanilla: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
name: &str,
|
||||
processors: Option<ProcessorSection<'_>>,
|
||||
@@ -78,6 +79,9 @@ pub fn build(
|
||||
if let Some(section) = &processors {
|
||||
data["processors"] = processor_json(section);
|
||||
}
|
||||
if let Some(block) = vanilla_json(vanilla) {
|
||||
data["vanilla"] = block;
|
||||
}
|
||||
|
||||
Report::new(
|
||||
data,
|
||||
@@ -85,6 +89,7 @@ pub fn build(
|
||||
dry_run,
|
||||
plan,
|
||||
report,
|
||||
vanilla,
|
||||
registration,
|
||||
&samples,
|
||||
name,
|
||||
@@ -115,10 +120,47 @@ fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value {
|
||||
node
|
||||
}
|
||||
|
||||
/// `--json` 的 `vanilla` 段:先装原版那一步的告警 + 清单结局(`None` = 这次没先装原版)。
|
||||
///
|
||||
/// **M216 r2 P2-2**:原版 report 在 `is_complete()` 之后就被丢了 ⇒ 「替换了共享 `.minecraft`
|
||||
/// 里的原版清单」在加载器安装的 JSON 里没有痕迹。
|
||||
fn vanilla_json(vanilla: Option<&InstallReport>) -> Option<Value> {
|
||||
let report = vanilla?;
|
||||
Some(json!({
|
||||
"warnings": report.warnings,
|
||||
"version_json": report.version_json.as_ref().map(|path| path.display().to_string()),
|
||||
"manifest": report.manifest.as_ref().map(|write| json!({
|
||||
"kind": write.as_str(),
|
||||
"backup": write.backup().map(|path| path.display().to_string()),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
/// 人读行里「先装原版」那一段(**M216 r2 P2-2**,与 [`vanilla_json`] 同源):以前
|
||||
/// `human_lines` 只遍历 `plan.warnings()`,原版那份 report 的告警与「清单被替换」都没说。
|
||||
fn vanilla_lines(vanilla: Option<&InstallReport>) -> Vec<String> {
|
||||
let Some(report) = vanilla else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut lines: Vec<String> = report
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|warning| format!("告警(原版): {warning}"))
|
||||
.collect();
|
||||
if let Some(backup) = report.manifest.as_ref().and_then(|write| write.backup()) {
|
||||
lines.push(format!(
|
||||
"原版版本清单已替换(旧的那份备份在 {})",
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn human_lines(
|
||||
dry_run: bool,
|
||||
plan: &LoaderInstallPlan,
|
||||
report: Option<&InstallReport>,
|
||||
vanilla: Option<&InstallReport>,
|
||||
registration: Option<&Registration>,
|
||||
samples: &[serde_json::Value],
|
||||
name: &str,
|
||||
@@ -181,6 +223,7 @@ fn human_lines(
|
||||
for warning in plan.warnings() {
|
||||
human.push(format!("告警: {warning}"));
|
||||
}
|
||||
human.extend(vanilla_lines(vanilla));
|
||||
if let Some(section) = processors {
|
||||
human.extend(processor_lines(section));
|
||||
}
|
||||
@@ -272,3 +315,85 @@ fn processor_lines(section: &ProcessorSection<'_>) -> Vec<String> {
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use unlucky_core::addon::loader::{
|
||||
LoaderInstallPlan, LoaderKind, LoaderPlanInput, LoaderRelease, LoaderSupport,
|
||||
};
|
||||
use unlucky_core::game::arguments::Environment;
|
||||
use unlucky_core::game::install::ManifestWrite;
|
||||
use unlucky_core::game::layout::GameLayout;
|
||||
use unlucky_core::game::manifest::VersionJson;
|
||||
|
||||
/// 一份最小的 Fabric 加载器计划(零网络:只算路径,不落盘)。
|
||||
fn loader_plan(root: &std::path::Path) -> LoaderInstallPlan {
|
||||
let layout = GameLayout::new(root);
|
||||
let release = LoaderRelease {
|
||||
kind: LoaderKind::Fabric,
|
||||
mc_version: "1.20.1".to_string(),
|
||||
loader_version: "0.15.11".to_string(),
|
||||
version_id: "1.20.1-fabric-0.15.11".to_string(),
|
||||
version_json: VersionJson::from_json_str(
|
||||
r#"{"id":"1.20.1-fabric-0.15.11","mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient","inheritsFrom":"1.20.1","libraries":[]}"#,
|
||||
)
|
||||
.expect("loader 清单应可解析"),
|
||||
support: LoaderSupport::JsonRoute,
|
||||
installer: None,
|
||||
install_profile: None,
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
LoaderInstallPlan::build(LoaderPlanInput {
|
||||
layout: &layout,
|
||||
release: &release,
|
||||
environment: &Environment::host(),
|
||||
vanilla_installed: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// **M216 r2 P2-2**:装加载器时「先装原版」那一步替换了共享 `.minecraft` 里的原版清单,
|
||||
/// 这句话必须出现在**加载器安装**的人读输出里(此前只出现在纯原版安装的输出里)。
|
||||
///
|
||||
/// 抓得住:`human_lines` 只遍历 `plan.warnings()`(= r1 状态);备份路径没渲出来;
|
||||
/// `--json` 的 `vanilla` 段没了。抓不住:真实 `ull install --loader …` 整条链路(要网络),
|
||||
/// 以及「原版 report 是否真被 `loader.rs` 传进来」那步接线(见报告)。
|
||||
#[test]
|
||||
fn a_replaced_vanilla_manifest_is_said_out_loud_in_the_loader_output() {
|
||||
let root = std::env::temp_dir().join(format!("ull216-loader-{}", std::process::id()));
|
||||
let plan = loader_plan(&root);
|
||||
let backup = PathBuf::from("/g/.minecraft/versions/1.20.1/1.20.1.json.ullbak-99");
|
||||
let vanilla = InstallReport {
|
||||
files: Vec::new(),
|
||||
version_json: Some(PathBuf::from("/g/.minecraft/versions/1.20.1/1.20.1.json")),
|
||||
manifest: Some(ManifestWrite::Replaced {
|
||||
backup: backup.clone(),
|
||||
}),
|
||||
warnings: vec!["已替换版本清单 /g/.minecraft/versions/1.20.1/1.20.1.json".to_string()],
|
||||
};
|
||||
|
||||
let text = human_lines(false, &plan, None, Some(&vanilla), None, &[], "n", None).join("\n");
|
||||
assert!(
|
||||
text.contains("已替换版本清单"),
|
||||
"原版那段的告警没进人读输出:{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&backup.display().to_string()),
|
||||
"原版清单的备份路径没进人读输出:{text}"
|
||||
);
|
||||
// 没先装原版时不许凭空多说话。
|
||||
let plain = human_lines(false, &plan, None, None, None, &[], "n", None).join("\n");
|
||||
assert!(!plain.contains("原版版本清单已替换"), "{plain}");
|
||||
|
||||
// `--json` 的 `vanilla` 段(结构化,脚本可读)。
|
||||
let block = vanilla_json(Some(&vanilla)).expect("应当有 vanilla 段");
|
||||
assert_eq!(block["manifest"]["kind"], Value::String("replaced".into()));
|
||||
assert_eq!(
|
||||
block["manifest"]["backup"].as_str(),
|
||||
Some(backup.to_string_lossy().as_ref())
|
||||
);
|
||||
assert!(vanilla_json(None).is_none());
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
//! `pack import <file>`:整合包导入(M111)。
|
||||
//!
|
||||
//! 逻辑**全在** `core::addon::import`:本文件只做三件事——参数落地、下载引擎接线、两种输出。
|
||||
//! 之所以坚持这一层很薄,是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,
|
||||
//! 不是这里的打印逻辑。
|
||||
//! 逻辑**全在** `core::addon::import`:本文件只做参数落地、引擎接线、两种输出。坚持这一层很薄,
|
||||
//! 是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,不是这里的打印逻辑。
|
||||
//!
|
||||
//! # 三态退出码(脚本契约)
|
||||
//!
|
||||
@@ -13,13 +12,11 @@
|
||||
//! | `failed` | `1` | 致命失败,**已整包回滚** |
|
||||
//! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) |
|
||||
//!
|
||||
//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(一次性命令,
|
||||
//! Ctrl-C 直接杀进程),但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时
|
||||
//! 必须能把它与 `failed` 分开——所以退出码与文案在这里就位,接中断信号是下一步的事。
|
||||
//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(Ctrl-C 直接杀进程),
|
||||
//! 但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时要能把它与 `failed` 分开。
|
||||
//!
|
||||
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
|
||||
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、
|
||||
//! 数据根不可用),与其它命令一致。
|
||||
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、数据根不可用)。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -31,6 +28,7 @@ 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 crate::cli::PackImportArgs;
|
||||
use crate::context::Ctx;
|
||||
@@ -50,10 +48,20 @@ 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();
|
||||
// **M216 ②**:缺省落点 = `<data>/instances/<实例名>`(与 GUI 同源,判据在 core 的
|
||||
// `pack_dir::import_target`),不再是共享的 `.minecraft` 根。
|
||||
let (target, instance_name) = 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 只管文件冲突,绕过不了这条预检)",
|
||||
)
|
||||
})?;
|
||||
|
||||
let stages: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorder = Arc::clone(&stages);
|
||||
@@ -68,7 +76,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,12 +83,24 @@ 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));
|
||||
// **§5**:缺省建出来的实例要绑到它的内容目录(判据/文案在 core,与 GUI 同一份),否则
|
||||
// 「导入成功」而那批 mods/config 永远不会被那条实例的启动用到。
|
||||
let auto = args.target.is_none() && !args.no_register;
|
||||
let binding = pack_dir::bind_created_instance(&layout, &target, auto, &report);
|
||||
let seen = stages.lock().map(|seen| seen.clone()).unwrap_or_default();
|
||||
Ok(outcome(ctx, &report, &target, &seen, args))
|
||||
Ok(outcome(
|
||||
ctx,
|
||||
&report,
|
||||
&target,
|
||||
&seen,
|
||||
args,
|
||||
binding.as_ref(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 组装 `Outcome`(三态 → 退出码 + 两种输出)。
|
||||
@@ -91,6 +110,7 @@ fn outcome(
|
||||
target: &Path,
|
||||
stages: &[&'static str],
|
||||
args: &PackImportArgs,
|
||||
binding: Option<&Result<String, String>>,
|
||||
) -> Outcome {
|
||||
let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
|
||||
json!({
|
||||
@@ -116,7 +136,7 @@ fn outcome(
|
||||
report.format.as_deref().unwrap_or("-"),
|
||||
target.display(),
|
||||
if args.target.is_none() {
|
||||
"(缺省 = 数据根的共享游戏目录)"
|
||||
"(缺省 = 新实例目录 <数据根>/instances/)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
@@ -124,6 +144,15 @@ fn outcome(
|
||||
if !stages.is_empty() {
|
||||
human.push(format!("阶段:{}", stages.join(" → ")));
|
||||
}
|
||||
if let Some(binding) = binding {
|
||||
// **M216 r2**:`--json` 里给**结构化成败**(r1 只印人读行,脚本看不到成败)。
|
||||
if let Some(object) = data.as_object_mut() {
|
||||
object.insert("instance_bound".to_string(), json!(binding.is_ok()));
|
||||
}
|
||||
human.push(match binding {
|
||||
Ok(note) | Err(note) => note.clone(),
|
||||
});
|
||||
}
|
||||
human.push(format!(
|
||||
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
|
||||
status_label(report),
|
||||
|
||||
@@ -197,3 +197,107 @@ fn help_lists_the_pack_command() {
|
||||
assert_eq!(code, 0);
|
||||
assert!(stdout.contains("pack import"), "总帮助里也要有:{stdout}");
|
||||
}
|
||||
|
||||
/// **M216 P3-3 + §5**:不带 `--target` 时落点是**新实例目录**(不是共享 `.minecraft` 根),
|
||||
/// 而且登记出来的实例被绑到那个目录(`isolation=custom` + `gameDir`),否则导入内容永远不会
|
||||
/// 被启动用到。
|
||||
///
|
||||
/// 抓得住:CLI 自己兜底回 `ctx.game().root()`(第一条断言会变成 `.minecraft`);不建实例目录而
|
||||
/// 直接铺共享根;不做绑定(`isolation` 仍是 `null`);绑定写错目录。抓不住:真实启动那一条
|
||||
/// `derive_game_dir` 的派生(core 的 `isolation.rs` 用例钉,这里只断言设置文件的内容)。
|
||||
#[test]
|
||||
fn the_default_target_is_a_new_instance_dir_and_the_instance_is_bound_to_it() {
|
||||
let data = TempDir::new("pack-default-target");
|
||||
let source = multimc_instance(data.path());
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"pack",
|
||||
"import",
|
||||
&source.display().to_string(),
|
||||
"--name",
|
||||
"My Pack",
|
||||
"--data-dir",
|
||||
&data.arg(),
|
||||
"--json",
|
||||
]);
|
||||
assert_eq!(code, 0, "stdout={stdout} stderr={stderr}");
|
||||
|
||||
// **M216 r2**:绑定成败也进 `--json`(结构化,不必猜文案)。
|
||||
let value = parse_json(&stdout);
|
||||
assert_eq!(
|
||||
value["data"]["instance_bound"],
|
||||
Value::Bool(true),
|
||||
"缺省导入应当把实例绑到内容目录并报 true:{stdout}"
|
||||
);
|
||||
|
||||
let instance_dir = data.path().join(".ULL/data/instances/My-Pack");
|
||||
assert!(
|
||||
instance_dir.join("mods/a.jar").is_file(),
|
||||
"缺省落点应当是 <data>/instances/My-Pack:{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!data.path().join(".minecraft/mods/a.jar").exists(),
|
||||
"不许再往共享 .minecraft 根铺:{stdout}"
|
||||
);
|
||||
|
||||
// 登记的实例被绑到这个目录(§5)。
|
||||
let profiles: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(data.path().join(".ULL/config/profiles.json"))
|
||||
.expect("profiles.json"),
|
||||
)
|
||||
.expect("profiles.json 是 JSON");
|
||||
assert_eq!(
|
||||
profiles["profiles"][0]["name"],
|
||||
Value::String("My Pack".into())
|
||||
);
|
||||
let id = profiles["profiles"][0]["id"]
|
||||
.as_str()
|
||||
.expect("登记出来的实例有 id")
|
||||
.to_string();
|
||||
let config: Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(
|
||||
data.path()
|
||||
.join(".ULL/config/instances")
|
||||
.join(format!("{id}.json")),
|
||||
)
|
||||
.expect("实例设置文件"),
|
||||
)
|
||||
.expect("实例设置是 JSON");
|
||||
assert_eq!(
|
||||
config["isolation"],
|
||||
Value::String("custom".into()),
|
||||
"isolation 应当是 custom:{config}"
|
||||
);
|
||||
assert_eq!(
|
||||
config["gameDir"].as_str().map(Path::new),
|
||||
Some(instance_dir.as_path()),
|
||||
"gameDir 应当指向导入目录:{config}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **M216 P2-1 / r2**:`--help` 里的缺省落点必须是**真路径**。
|
||||
///
|
||||
/// 「数据根」在这份帮助自己的定义里是「含 `.ULL/` 与 `.minecraft/` 的父目录」(`--data-dir` 那行),
|
||||
/// 所以实例目录的真路径是 `<数据根>/.ULL/data/instances/<目录名>`。r1 写成了
|
||||
/// `<数据根>/instances/<实例名>` —— 少一层 `.ULL/data`,照读会去一个不存在的位置找包内容。
|
||||
///
|
||||
/// 抓得住:缺 `.ULL/data` 这一层(下面第二条断言就是抓它的——少写时该子串恰好出现);
|
||||
/// 文案退回「共享游戏目录」;目录名归一化这件事没提。抓不住:`model.rs` 的字段文档
|
||||
/// (编译期文档,测试读不到),以及真实 `--data-dir` 下的绝对路径拼接(那由上面那条端到端
|
||||
/// 用例按真目录断言)。
|
||||
#[test]
|
||||
fn help_describes_the_new_default_target_with_the_real_path() {
|
||||
let (code, stdout, _) = run_cli(&["help", "pack"]);
|
||||
assert_eq!(code, 0);
|
||||
assert!(
|
||||
stdout.contains("<数据根>/.ULL/data/instances/<实例目录名>"),
|
||||
"帮助里没写真路径 <数据根>/.ULL/data/instances/<实例目录名>:{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("<数据根>/instances/"),
|
||||
"帮助里的落点少了 `.ULL/data` 这一层(r2 抓的就是这个错法):{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("缺省 = 数据根的共享游戏目录"),
|
||||
"帮助里还留着旧缺省落点:{stdout}"
|
||||
);
|
||||
}
|
||||
@@ -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,316 @@
|
||||
//! 整合包导入的**落点派生**(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 super::instance::{self, InstanceConfig, Isolation};
|
||||
use crate::game::error::GameError;
|
||||
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))
|
||||
}
|
||||
|
||||
/// 把「导入建出来的实例」绑到它的内容目录(`isolation=custom` + `game_dir`)。
|
||||
///
|
||||
/// # 为什么必须绑
|
||||
///
|
||||
/// 运行目录不是「导入目录」,而是从 `config/instances/<id>.json` 的 `isolation` 派生的
|
||||
/// ([`derive_game_dir`](crate::game::isolation::derive_game_dir)):缺省档
|
||||
/// `instances.mode=isolated` 给成 `<game>/versions/<versionId>`,`shared` 给成游戏根。
|
||||
/// 导入目标 `<data>/instances/<名>` **不在这条链上** ⇒ 不绑的话「导入成功」而那批
|
||||
/// mods/config 永远不会被那条实例的启动用到(M113 起 GUI 就有这个洞)。
|
||||
///
|
||||
/// # 绑成什么、不绑成什么
|
||||
///
|
||||
/// - 运行目录 = `dir`:`saves/` `mods/` `logs/` `config/` 落在这里(与别的启动器隔离);
|
||||
/// - **`versions/` `libraries/` `assets/` 仍按 D20 恒共享**在游戏根 —— 不是「完全隔离」,
|
||||
/// 版本本体只装一份,别人 `.minecraft` 里已下好的库也照旧复用。
|
||||
///
|
||||
/// # 回滚
|
||||
///
|
||||
/// 把 `config/instances/<id>.json` 的 `isolation` 删掉(或写成 `"inherit"`)即回到「跟随全局
|
||||
/// 档」;`gameDir` 随之失效。也可以整份删掉这一份实例设置文件(`register` 会重新补最小那份)。
|
||||
/// **不会**搬动任何已落盘的内容。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 读/写实例设置失败(JSON 坏、id 不合法、原子写失败)。
|
||||
pub fn bind_instance_dir(
|
||||
layout: &LauncherLayout,
|
||||
instance_id: &str,
|
||||
version_id: &str,
|
||||
dir: &Path,
|
||||
) -> Result<(), GameError> {
|
||||
let mut config = instance::read_instance_config(layout, instance_id)?
|
||||
.unwrap_or_else(|| InstanceConfig::new(instance_id, version_id));
|
||||
config.isolation = Some(Isolation::Custom);
|
||||
config.game_dir = Some(dir.to_path_buf());
|
||||
instance::write_instance_config(layout, &config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导入收尾:把「我们新建出来的实例」绑到它的内容目录,返回一句给人读的结论。
|
||||
///
|
||||
/// 返回 `None` = 不需要绑;`Some(Ok(说明))` = 已绑;`Some(Err(说明))` = 绑失败(档案在、内容在,
|
||||
/// 只是运行目录没指过来——调用方**别**把它当整次导入失败,但要让人看见)。
|
||||
///
|
||||
/// 判据(CLI 与 GUI 共用一份,别自己再写一遍):
|
||||
/// - `auto_bound == false` ⇒ 不绑(用户显式给了目标目录 / `--no-register`:那目录可能是别人的
|
||||
/// 运行目录,也可能被用户自己指过);
|
||||
/// - 没登记、或**复用了已有档案**(`registered.created == false`)⇒ 不绑;
|
||||
/// - 其余 ⇒ [`bind_instance_dir`]。
|
||||
///
|
||||
/// 参数直接吃 `addon::import::ImportReport`:`addon` 早就在调 `game::install::register`,
|
||||
/// 两者同属 core 内部;再投影一层 `struct` 只会让两侧各多写六行构造。
|
||||
#[must_use]
|
||||
pub fn bind_created_instance(
|
||||
layout: &LauncherLayout,
|
||||
target: &Path,
|
||||
auto_bound: bool,
|
||||
report: &crate::addon::import::ImportReport,
|
||||
) -> Option<Result<String, String>> {
|
||||
if !auto_bound {
|
||||
return None;
|
||||
}
|
||||
let registered = report.registered.as_ref().filter(|item| item.created)?;
|
||||
Some(
|
||||
match bind_instance_dir(layout, ®istered.id, ®istered.version_id, target) {
|
||||
Ok(()) => Ok(format!(
|
||||
"运行目录已指向 {}(实例 {})",
|
||||
target.display(),
|
||||
registered.name
|
||||
)),
|
||||
// 内容与档案都已就位,只是目录没指过来 ⇒ 不把整次导入判失败,但必须说出来。
|
||||
Err(error) => Err(format!(
|
||||
"实例已登记但没能绑到导入目录({error}):启动前请把该实例的运行目录指到 {}",
|
||||
target.display()
|
||||
)),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 目录/文件名用的 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();
|
||||
}
|
||||
|
||||
/// **§5 正向**:导入建出来的实例必须绑到内容目录,否则「导入成功」而内容永远不会被启动用到。
|
||||
///
|
||||
/// 抓得住:`bind_instance_dir` 没写 `isolation=custom` / `game_dir` 落空 / 写错了实例(读回来
|
||||
/// 仍是 `None`)。抓不住:`derive_game_dir` 真的把运行目录算成那个目录——那条由
|
||||
/// `isolation.rs` 的既有用例钉住,这里不重算(tower 要求不动 `derive_game_dir`)。
|
||||
#[test]
|
||||
fn binding_an_imported_instance_points_it_at_the_import_dir() {
|
||||
let root = std::env::temp_dir().join(format!("ull216-bind-{}", std::process::id()));
|
||||
let layout = LauncherLayout::new(root.join(".ULL"));
|
||||
let registration =
|
||||
crate::game::install::instance::register(&layout, "pack", "1.20.1", None)
|
||||
.expect("登记");
|
||||
let id = registration.profile.id.as_str().to_string();
|
||||
let dir = instance_dir(&layout, "pack");
|
||||
|
||||
bind_instance_dir(&layout, &id, "1.20.1", &dir).expect("绑定");
|
||||
let config = crate::game::install::instance::read_instance_config(&layout, &id)
|
||||
.expect("读设置")
|
||||
.expect("有设置文件");
|
||||
assert_eq!(config.isolation, Some(Isolation::Custom));
|
||||
assert_eq!(config.game_dir.as_deref(), Some(dir.as_path()));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// **§5 反向**:没走绑定那条路的实例不许自己冒出 `custom`(否则上一条抓不住东西)。
|
||||
#[test]
|
||||
fn a_freshly_registered_instance_still_inherits_the_global_mode() {
|
||||
let root = std::env::temp_dir().join(format!("ull216-unbound-{}", std::process::id()));
|
||||
let layout = LauncherLayout::new(root.join(".ULL"));
|
||||
let registration =
|
||||
crate::game::install::instance::register(&layout, "plain", "1.20.1", None)
|
||||
.expect("登记");
|
||||
let config = crate::game::install::instance::read_instance_config(
|
||||
&layout,
|
||||
registration.profile.id.as_str(),
|
||||
)
|
||||
.expect("读设置")
|
||||
.expect("有设置文件");
|
||||
assert_eq!(config.isolation, None, "缺省必须跟随全局档");
|
||||
assert_eq!(config.game_dir, None);
|
||||
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,28 @@ 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 自己的用例在调它**(生产侧落点已改成 `pack_dir::instance_dir`,见 M216
|
||||
/// P3-2),所以挂 `cfg(test)`:它是那组既有判据(空格折 `-`、中文补 `-<hash8>`、两个中文名
|
||||
/// 不塌成同一个目录)继续钉住归一化规则的入口,生产构建里不需要它。
|
||||
///
|
||||
/// [`pack_dir::instance_dir_name`]: unlucky_core::game::install::pack_dir::instance_dir_name
|
||||
#[cfg(test)]
|
||||
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)]
|
||||
|
||||
@@ -16,11 +16,13 @@ use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::import::report::ImportIssue;
|
||||
use unlucky_core::addon::import::{
|
||||
import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress,
|
||||
ImportProgressSink, ImportRequest,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::install::pack_dir;
|
||||
|
||||
use crate::dto::{ImportRequestView, PackEvent};
|
||||
use unlucky_core::addon::import::ImportStatus;
|
||||
@@ -196,7 +198,19 @@ pub(super) fn run(
|
||||
core_request = core_request.with_registration(&env.layout, Some(&pack_name));
|
||||
}
|
||||
|
||||
let report = block_on(import_pack(&core_request));
|
||||
let mut report = block_on(import_pack(&core_request));
|
||||
// **§5**:导入建出来的实例要绑到它的内容目录(`isolation=custom` + `gameDir`),否则运行
|
||||
// 目录由全局档派生成 `<game>/versions/<version_id>`,这批 mods/config 永远不会被启动用到。
|
||||
// 判据与文案在 core(与 CLI 同一份);失败不把整次导入判失败,但要让用户看见。
|
||||
match pack_dir::bind_created_instance(&env.layout, &target, register, &report) {
|
||||
Some(Ok(note)) => report
|
||||
.warnings
|
||||
.push(ImportIssue::new("instance_bound", note)),
|
||||
Some(Err(note)) => report
|
||||
.warnings
|
||||
.push(ImportIssue::new("instance_binding_failed", note)),
|
||||
None => {}
|
||||
}
|
||||
// 取消标记要**在管线返回之后**再读一次:取消可能来得太晚(管线已经跑完),这一读就是
|
||||
// "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。
|
||||
let cancel_requested = cancel.load(Ordering::SeqCst);
|
||||
@@ -270,11 +284,9 @@ pub(super) fn target_of(
|
||||
.clone()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| convert::pack_stem(&request.pack));
|
||||
let target = env
|
||||
.layout
|
||||
.data()
|
||||
.join("instances")
|
||||
.join(convert::dir_name(&name));
|
||||
// **M216 P3-2**:落点也委托 core(`pack_dir::instance_dir`)——此前这里自己拼
|
||||
// `<data>/instances/<dir_name>`,core 改了目录语义时 GUI 不会跟着走。
|
||||
let target = pack_dir::instance_dir(&env.layout, &name);
|
||||
if target.exists() {
|
||||
// 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」
|
||||
let mut entries = std::fs::read_dir(&target)
|
||||
|
||||
@@ -51,6 +51,23 @@ struct Job<'a> {
|
||||
}
|
||||
|
||||
impl Job<'_> {
|
||||
/// 把 core 的运行期告警与「清单被替换」写进安装日志(**M216 P2-2**)。
|
||||
///
|
||||
/// 面板只有一行、Toast 4 秒就消失,安装日志是用户事后能自己回看的唯一渠道:动了共享
|
||||
/// `.minecraft` 里那份 `versions/<id>/<id>.json` 这件事必须先落在这里(issue #M216 复审
|
||||
/// P2-2:`report.manifest` 以前零消费者,GUI 侧对用户完全静默)。
|
||||
fn journal_report(&self, report: &InstallReport) {
|
||||
for warning in &report.warnings {
|
||||
self.journal.line(warning);
|
||||
}
|
||||
if let Some(backup) = report.manifest.as_ref().and_then(|write| write.backup()) {
|
||||
self.journal.line(&format!(
|
||||
"版本清单已替换,旧的那份备份在 {}",
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// processor 段的进度出口(同一套 channel/meter/journal)。
|
||||
fn reporter(&self) -> install_processors::Reporter<'_> {
|
||||
install_processors::Reporter {
|
||||
|
||||
@@ -86,6 +86,7 @@ pub(super) fn install_vanilla(
|
||||
)
|
||||
.await
|
||||
.map_err(|error| job_error(job, error))?;
|
||||
job.journal_report(&report);
|
||||
ensure_complete(&report)?;
|
||||
Ok::<String, String>(version.to_string())
|
||||
})
|
||||
@@ -178,6 +179,7 @@ pub(super) fn install_loader(
|
||||
.await
|
||||
})
|
||||
.map_err(|error| job_error(job, error))?;
|
||||
job.journal_report(&report);
|
||||
ensure_complete(&report)?;
|
||||
Ok(release.version_id)
|
||||
}
|
||||
@@ -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