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/
|
MultiMC mmc-pack.json(formatVersion 必须为 1)+ instance.cfg + .minecraft/
|
||||||
(Prism 实例;非加载器 component 会逐项列进报告,不静默丢)
|
(Prism 实例;非加载器 component 会逐项列进报告,不静默丢)
|
||||||
|
|
||||||
目标目录缺省 = 数据根的共享游戏目录(.minecraft/);`--target` 可钉到实例目录。
|
目标目录缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`(并登记实例,M216 起
|
||||||
|
不再落共享 .minecraft 根;目录名是包名/`--name` 经 ASCII 归一化后的结果,如 `Better-MC`);
|
||||||
|
`--target` 可钉到别处(例如某个实例的运行目录)。
|
||||||
两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。
|
两阶段落盘:先 overrides(三方合并:包内原值 / 磁盘现状 / 上次基线),再补远端文件。
|
||||||
· 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖
|
· 用户改过的文件不会被静默覆盖(默认保留,逐条进冲突清单);`--overwrite` 才用包覆盖
|
||||||
· 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」
|
· 缺文件(断网 / CurseForge files[] 补不出来)可容忍,但结果降级为「部分成功」
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ pub struct InstallArgs {
|
|||||||
pub struct PackImportArgs {
|
pub struct PackImportArgs {
|
||||||
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
|
/// 整合包路径:`.zip` / `.mrpack` / MultiMC 实例目录。
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
/// 目标实例目录(缺省 = 数据根的共享游戏目录 `game_home`)。
|
/// 目标实例目录(缺省 = 新实例目录 `<数据根>/.ULL/data/instances/<实例目录名>`,M216 前是
|
||||||
|
/// 共享游戏目录 `game_home`)。
|
||||||
pub target: Option<PathBuf>,
|
pub target: Option<PathBuf>,
|
||||||
/// 实例显示名(缺省 = 包名)。
|
/// 实例显示名(缺省 = 包名)。
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
|||||||
@@ -85,12 +85,13 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
|
|||||||
skipped: false,
|
skipped: false,
|
||||||
});
|
});
|
||||||
return Ok(report_loader::build(
|
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 {
|
if plan.vanilla_install_needed {
|
||||||
let (_, vanilla_report) = vanilla::plan_and_install(
|
let (_, report) = vanilla::plan_and_install(
|
||||||
ctx,
|
ctx,
|
||||||
&engine,
|
&engine,
|
||||||
&version,
|
&version,
|
||||||
@@ -98,11 +99,14 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
|
|||||||
args.progress,
|
args.progress,
|
||||||
args.json_progress,
|
args.json_progress,
|
||||||
)?;
|
)?;
|
||||||
if let Some(report) = &vanilla_report {
|
if let Some(report) = &report {
|
||||||
if !report.is_complete() {
|
if !report.is_complete() {
|
||||||
return Err(failure_error(report, ctx));
|
return Err(failure_error(report, ctx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **M216 r2 P2-2**:原版那段 report 不能在这里丢掉——它的 `warnings` / `manifest`
|
||||||
|
// (可能替换了共享 `.minecraft` 里 `versions/<原版>/<原版>.json`)必须进人读与 `--json`。
|
||||||
|
vanilla_report = report;
|
||||||
}
|
}
|
||||||
|
|
||||||
// installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。
|
// installer processors 段:版本 JSON 已在盘上 = 上一轮早已跑完,整段跳过。
|
||||||
@@ -143,6 +147,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
|
|||||||
false,
|
false,
|
||||||
&plan,
|
&plan,
|
||||||
Some(&install_report),
|
Some(&install_report),
|
||||||
|
vanilla_report.as_ref(),
|
||||||
Some(®istration),
|
Some(®istration),
|
||||||
&name,
|
&name,
|
||||||
section,
|
section,
|
||||||
|
|||||||
@@ -130,9 +130,25 @@ fn human_lines(
|
|||||||
sources.join(" → ")
|
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}"));
|
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 {
|
if let Some(report) = report {
|
||||||
human.push(format!(
|
human.push(format!(
|
||||||
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
|
"下载 {} 个 / 跳过 {} 个已就绪 / 失败 {} 个(本次 {})",
|
||||||
@@ -181,6 +197,62 @@ pub fn human_bytes(bytes: u64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::human_bytes;
|
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]
|
#[test]
|
||||||
fn formats_sizes_without_fake_precision() {
|
fn formats_sizes_without_fake_precision() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是
|
//! 与 [`super::report`] 的分工:那边是"原版安装"(客户端 jar + 资源 + 库),这边是
|
||||||
//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。
|
//! "叠在原版上的 loader 层"(只有库)。共用同一套进度回调与字节格式化。
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun};
|
use unlucky_core::addon::loader::processors::{ProcessorPlan, ProcessorRun};
|
||||||
use unlucky_core::addon::loader::LoaderInstallPlan;
|
use unlucky_core::addon::loader::LoaderInstallPlan;
|
||||||
use unlucky_core::download::DownloadEngine;
|
use unlucky_core::download::DownloadEngine;
|
||||||
@@ -39,6 +39,7 @@ pub fn build(
|
|||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
plan: &LoaderInstallPlan,
|
plan: &LoaderInstallPlan,
|
||||||
report: Option<&InstallReport>,
|
report: Option<&InstallReport>,
|
||||||
|
vanilla: Option<&InstallReport>,
|
||||||
registration: Option<&Registration>,
|
registration: Option<&Registration>,
|
||||||
name: &str,
|
name: &str,
|
||||||
processors: Option<ProcessorSection<'_>>,
|
processors: Option<ProcessorSection<'_>>,
|
||||||
@@ -78,6 +79,9 @@ pub fn build(
|
|||||||
if let Some(section) = &processors {
|
if let Some(section) = &processors {
|
||||||
data["processors"] = processor_json(section);
|
data["processors"] = processor_json(section);
|
||||||
}
|
}
|
||||||
|
if let Some(block) = vanilla_json(vanilla) {
|
||||||
|
data["vanilla"] = block;
|
||||||
|
}
|
||||||
|
|
||||||
Report::new(
|
Report::new(
|
||||||
data,
|
data,
|
||||||
@@ -85,6 +89,7 @@ pub fn build(
|
|||||||
dry_run,
|
dry_run,
|
||||||
plan,
|
plan,
|
||||||
report,
|
report,
|
||||||
|
vanilla,
|
||||||
registration,
|
registration,
|
||||||
&samples,
|
&samples,
|
||||||
name,
|
name,
|
||||||
@@ -115,10 +120,47 @@ fn processor_json(section: &ProcessorSection<'_>) -> serde_json::Value {
|
|||||||
node
|
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(
|
fn human_lines(
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
plan: &LoaderInstallPlan,
|
plan: &LoaderInstallPlan,
|
||||||
report: Option<&InstallReport>,
|
report: Option<&InstallReport>,
|
||||||
|
vanilla: Option<&InstallReport>,
|
||||||
registration: Option<&Registration>,
|
registration: Option<&Registration>,
|
||||||
samples: &[serde_json::Value],
|
samples: &[serde_json::Value],
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -181,6 +223,7 @@ fn human_lines(
|
|||||||
for warning in plan.warnings() {
|
for warning in plan.warnings() {
|
||||||
human.push(format!("告警: {warning}"));
|
human.push(format!("告警: {warning}"));
|
||||||
}
|
}
|
||||||
|
human.extend(vanilla_lines(vanilla));
|
||||||
if let Some(section) = processors {
|
if let Some(section) = processors {
|
||||||
human.extend(processor_lines(section));
|
human.extend(processor_lines(section));
|
||||||
}
|
}
|
||||||
@@ -272,3 +315,85 @@ fn processor_lines(section: &ProcessorSection<'_>) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
lines
|
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)。
|
//! `pack import <file>`:整合包导入(M111)。
|
||||||
//!
|
//!
|
||||||
//! 逻辑**全在** `core::addon::import`:本文件只做三件事——参数落地、下载引擎接线、两种输出。
|
//! 逻辑**全在** `core::addon::import`:本文件只做参数落地、引擎接线、两种输出。坚持这一层很薄,
|
||||||
//! 之所以坚持这一层很薄,是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,
|
//! 是因为 M113 的导入页要复用的是 core 的 `import_pack` + `ImportReport`,不是这里的打印逻辑。
|
||||||
//! 不是这里的打印逻辑。
|
|
||||||
//!
|
//!
|
||||||
//! # 三态退出码(脚本契约)
|
//! # 三态退出码(脚本契约)
|
||||||
//!
|
//!
|
||||||
@@ -13,13 +12,11 @@
|
|||||||
//! | `failed` | `1` | 致命失败,**已整包回滚** |
|
//! | `failed` | `1` | 致命失败,**已整包回滚** |
|
||||||
//! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) |
|
//! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) |
|
||||||
//!
|
//!
|
||||||
//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(一次性命令,
|
//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(Ctrl-C 直接杀进程),
|
||||||
//! Ctrl-C 直接杀进程),但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时
|
//! 但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时要能把它与 `failed` 分开。
|
||||||
//! 必须能把它与 `failed` 分开——所以退出码与文案在这里就位,接中断信号是下一步的事。
|
|
||||||
//!
|
//!
|
||||||
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
|
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
|
||||||
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、
|
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、数据根不可用)。
|
||||||
//! 数据根不可用),与其它命令一致。
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{Arc, Mutex};
|
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::blocking::block_on;
|
||||||
use unlucky_core::download::DownloadEngine;
|
use unlucky_core::download::DownloadEngine;
|
||||||
|
use unlucky_core::game::install::pack_dir;
|
||||||
|
|
||||||
use crate::cli::PackImportArgs;
|
use crate::cli::PackImportArgs;
|
||||||
use crate::context::Ctx;
|
use crate::context::Ctx;
|
||||||
@@ -50,10 +48,20 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
|
|||||||
)
|
)
|
||||||
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
|
.with_hint("给 .zip / .mrpack 文件,或 MultiMC·Prism 的实例目录"));
|
||||||
}
|
}
|
||||||
let target = args
|
let layout = ctx.layout();
|
||||||
.target
|
// **M216 ②**:缺省落点 = `<data>/instances/<实例名>`(与 GUI 同源,判据在 core 的
|
||||||
.clone()
|
// `pack_dir::import_target`),不再是共享的 `.minecraft` 根。
|
||||||
.unwrap_or_else(|| ctx.game().root().to_path_buf());
|
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 stages: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let recorder = Arc::clone(&stages);
|
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 engine = build_engine(ctx);
|
||||||
let fetcher = DownloadEngineFetcher::new(&engine);
|
let fetcher = DownloadEngineFetcher::new(&engine);
|
||||||
let layout = ctx.layout();
|
|
||||||
let mut request = ImportRequest::new(&args.path, &target)
|
let mut request = ImportRequest::new(&args.path, &target)
|
||||||
.with_fetcher(&fetcher)
|
.with_fetcher(&fetcher)
|
||||||
.with_progress(sink);
|
.with_progress(sink);
|
||||||
@@ -76,12 +83,24 @@ pub fn run(ctx: &Ctx, args: &PackImportArgs) -> Result<Outcome, CliError> {
|
|||||||
request = request.with_conflict_policy(ConflictPolicy::Overwrite);
|
request = request.with_conflict_policy(ConflictPolicy::Overwrite);
|
||||||
}
|
}
|
||||||
if !args.no_register {
|
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));
|
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();
|
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`(三态 → 退出码 + 两种输出)。
|
/// 组装 `Outcome`(三态 → 退出码 + 两种输出)。
|
||||||
@@ -91,6 +110,7 @@ fn outcome(
|
|||||||
target: &Path,
|
target: &Path,
|
||||||
stages: &[&'static str],
|
stages: &[&'static str],
|
||||||
args: &PackImportArgs,
|
args: &PackImportArgs,
|
||||||
|
binding: Option<&Result<String, String>>,
|
||||||
) -> Outcome {
|
) -> Outcome {
|
||||||
let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
|
let mut data = serde_json::to_value(report).unwrap_or_else(|_| {
|
||||||
json!({
|
json!({
|
||||||
@@ -116,7 +136,7 @@ fn outcome(
|
|||||||
report.format.as_deref().unwrap_or("-"),
|
report.format.as_deref().unwrap_or("-"),
|
||||||
target.display(),
|
target.display(),
|
||||||
if args.target.is_none() {
|
if args.target.is_none() {
|
||||||
"(缺省 = 数据根的共享游戏目录)"
|
"(缺省 = 新实例目录 <数据根>/instances/)"
|
||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
@@ -124,6 +144,15 @@ fn outcome(
|
|||||||
if !stages.is_empty() {
|
if !stages.is_empty() {
|
||||||
human.push(format!("阶段:{}", stages.join(" → ")));
|
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!(
|
human.push(format!(
|
||||||
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
|
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
|
||||||
status_label(report),
|
status_label(report),
|
||||||
|
|||||||
@@ -197,3 +197,107 @@ fn help_lists_the_pack_command() {
|
|||||||
assert_eq!(code, 0);
|
assert_eq!(code, 0);
|
||||||
assert!(stdout.contains("pack import"), "总帮助里也要有:{stdout}");
|
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 {
|
InstallReport {
|
||||||
files: outcomes,
|
files: outcomes,
|
||||||
version_json: None,
|
version_json: None,
|
||||||
|
manifest: None,
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
//! `buffer_unordered` 支持「取消即清空队列」,本轮不做(不在 M105 的验收口径里)。
|
//! `buffer_unordered` 支持「取消即清空队列」,本轮不做(不在 M105 的验收口径里)。
|
||||||
|
|
||||||
use std::future::poll_fn;
|
use std::future::poll_fn;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::pin::pin;
|
use std::pin::pin;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::task::Poll;
|
use std::task::Poll;
|
||||||
@@ -60,6 +60,7 @@ use futures_util::future::{select, Either};
|
|||||||
use futures_util::stream::{self, StreamExt};
|
use futures_util::stream::{self, StreamExt};
|
||||||
|
|
||||||
use super::plan::PlanFile;
|
use super::plan::PlanFile;
|
||||||
|
use super::report::ManifestWrite;
|
||||||
use super::{InstallError, InstallErrorKind, InstallPlan};
|
use super::{InstallError, InstallErrorKind, InstallPlan};
|
||||||
use crate::download::verify;
|
use crate::download::verify;
|
||||||
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind, HashAlgo};
|
use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind, HashAlgo};
|
||||||
@@ -221,17 +222,28 @@ pub async fn execute_with_cancel(
|
|||||||
// 版本清单只在**全量就绪**时落盘:`is_version_installed()` 以清单存在为判据,
|
// 版本清单只在**全量就绪**时落盘:`is_version_installed()` 以清单存在为判据,
|
||||||
// 半成品写进去会让 `instances list` 把"缺 jar 的版本"报成已安装。
|
// 半成品写进去会让 `instances list` 把"缺 jar 的版本"报成已安装。
|
||||||
let complete = outcomes.iter().all(|outcome| !outcome.failed());
|
let complete = outcomes.iter().all(|outcome| !outcome.failed());
|
||||||
let version_json = if complete {
|
let mut warnings = plan.warnings.clone();
|
||||||
write_version_json(&plan.version_json_path, plan)?;
|
let manifest = if complete {
|
||||||
Some(plan.version_json_path.clone())
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(InstallReport {
|
Ok(InstallReport {
|
||||||
files: outcomes,
|
files: outcomes,
|
||||||
version_json,
|
version_json: complete.then(|| plan.version_json_path.clone()),
|
||||||
warnings: plan.warnings.clone(),
|
manifest,
|
||||||
|
warnings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,13 +334,61 @@ pub fn current_bytes(file: &PlanFile) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 写解析后的版本清单(原子写)。
|
/// 写解析后的版本清单(原子写),并如实交代「原来那份怎么办」(**M216 ③**)。
|
||||||
fn write_version_json(path: &Path, plan: &InstallPlan) -> Result<(), super::InstallError> {
|
///
|
||||||
|
/// 三条判据:
|
||||||
|
/// - 目标不存在 → [`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| {
|
let text = plan.version_json.to_json_string().map_err(|error| {
|
||||||
super::InstallError::new(super::InstallErrorKind::Metadata, error.to_string())
|
super::InstallError::new(super::InstallErrorKind::Metadata, error.to_string())
|
||||||
})?;
|
})?;
|
||||||
write_atomic(path, text.as_bytes())
|
let bytes = text.as_bytes();
|
||||||
.map_err(|error| super::InstallError::new(super::InstallErrorKind::Io, error.to_string()))
|
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 {
|
fn describe(error: &DownloadError) -> FileFailure {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub mod assets;
|
|||||||
pub mod execute;
|
pub mod execute;
|
||||||
pub mod instance;
|
pub mod instance;
|
||||||
pub mod libraries;
|
pub mod libraries;
|
||||||
|
pub mod pack_dir;
|
||||||
pub mod plan;
|
pub mod plan;
|
||||||
pub mod plan_view;
|
pub mod plan_view;
|
||||||
mod report;
|
mod report;
|
||||||
@@ -41,7 +42,9 @@ pub use assets::{AssetIndex, AssetObject, ASSET_OBJECT_BASE};
|
|||||||
pub use execute::{execute, execute_with_cancel};
|
pub use execute::{execute, execute_with_cancel};
|
||||||
pub use instance::{register, InstanceConfig, Registration};
|
pub use instance::{register, InstanceConfig, Registration};
|
||||||
pub use plan::{FileRole, InstallPlan, PlanFile, PlanInput};
|
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};
|
pub use source::{ManifestFreshness, RemoteSource, MANIFEST_TTL_MS};
|
||||||
|
|
||||||
use crate::download::{DownloadError, DownloadErrorKind};
|
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(汇总打印)都读它,谁也不改它。
|
//! 纯数据 + 纯方法:`execute.rs`(执行)与 CLI(汇总打印)都读它,谁也不改它。
|
||||||
//! 搬移是**逐字**的,只有 `use` 断了回来(原来靠 `execute.rs` 的 import 活着)。
|
//! 搬移是**逐字**的,只有 `use` 断了回来(原来靠 `execute.rs` 的 import 活着)。
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::download::DownloadErrorKind;
|
use crate::download::DownloadErrorKind;
|
||||||
@@ -74,6 +74,47 @@ impl FileOutcome {
|
|||||||
/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
|
/// CLI 用它往 stderr 打实时进度;GUI 侧直接消费 `ProgressEvent` 事件流即可,不必用这个。
|
||||||
pub type ProgressSink = Arc<dyn Fn(&FileOutcome) + Send + Sync>;
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct InstallReport {
|
pub struct InstallReport {
|
||||||
@@ -81,7 +122,9 @@ pub struct InstallReport {
|
|||||||
pub files: Vec<FileOutcome>,
|
pub files: Vec<FileOutcome>,
|
||||||
/// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 `execute` 的说明)。
|
/// 版本清单落盘路径;`None` = 有文件失败,故意没写(见 `execute` 的说明)。
|
||||||
pub version_json: Option<PathBuf>,
|
pub version_json: Option<PathBuf>,
|
||||||
/// 计划阶段带来的告警(缺件降级说明)。
|
/// 版本清单这一处怎么落的盘;`None` = 本次根本没写清单(有失败 / 不写清单的那条路径)。
|
||||||
|
pub manifest: Option<ManifestWrite>,
|
||||||
|
/// 计划阶段带来的告警(缺件降级说明)+ 运行期告警(清单被替换等)。
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ fn failure_kind_prefers_integrity() {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let report = crate::game::install::InstallReport {
|
let report = crate::game::install::InstallReport {
|
||||||
|
manifest: None,
|
||||||
files: vec![
|
files: vec![
|
||||||
failed(DownloadErrorKind::AllSourcesFailed),
|
failed(DownloadErrorKind::AllSourcesFailed),
|
||||||
failed(DownloadErrorKind::Integrity),
|
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 execute;
|
||||||
mod instance;
|
mod instance;
|
||||||
|
mod manifest_write;
|
||||||
mod plan;
|
mod plan;
|
||||||
@@ -336,57 +336,28 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 包文件名(`Better MC.mrpack` → `Better MC`)。
|
/// 包文件名(`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 字母数字、空格、`-`、`_` 时
|
/// **M216 ②**:函数体一行委托 core(`game::install::pack_dir`)——CLI 调的是同一个函数,
|
||||||
/// 保持原样(空格折成 `-`,`Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、
|
/// 于是「CLI 缺省落点」与「GUI 落点」不只目录相同,连目录名归一化也只有一份实现。
|
||||||
/// 括号……)就补 `-<hash8>`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」
|
pub(super) fn pack_stem(pack: &Path) -> String {
|
||||||
/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。
|
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 {
|
pub(super) fn dir_name(name: &str) -> String {
|
||||||
let base = slug(name);
|
unlucky_core::game::install::pack_dir::instance_dir_name(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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FNV-1a 64 → 8 位十六进制(只要稳定、短、不需要密码学强度)。
|
/// 目录/文件名用的 slug(导出用的文件名也走它;实现同样收在 core `pack_dir`)。
|
||||||
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 字母数字与 `-` / `_`,其余折成 `-`)。
|
|
||||||
pub(super) fn slug(text: &str) -> String {
|
pub(super) fn slug(text: &str) -> String {
|
||||||
let mut out = String::new();
|
unlucky_core::game::install::pack_dir::slug(text)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ use std::path::PathBuf;
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use unlucky_core::addon::import::report::ImportIssue;
|
||||||
use unlucky_core::addon::import::{
|
use unlucky_core::addon::import::{
|
||||||
import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress,
|
import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress,
|
||||||
ImportProgressSink, ImportRequest,
|
ImportProgressSink, ImportRequest,
|
||||||
};
|
};
|
||||||
use unlucky_core::download::blocking::block_on;
|
use unlucky_core::download::blocking::block_on;
|
||||||
|
use unlucky_core::game::install::pack_dir;
|
||||||
|
|
||||||
use crate::dto::{ImportRequestView, PackEvent};
|
use crate::dto::{ImportRequestView, PackEvent};
|
||||||
use unlucky_core::addon::import::ImportStatus;
|
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));
|
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`)。
|
// "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。
|
||||||
let cancel_requested = cancel.load(Ordering::SeqCst);
|
let cancel_requested = cancel.load(Ordering::SeqCst);
|
||||||
@@ -270,11 +284,9 @@ pub(super) fn target_of(
|
|||||||
.clone()
|
.clone()
|
||||||
.filter(|name| !name.trim().is_empty())
|
.filter(|name| !name.trim().is_empty())
|
||||||
.unwrap_or_else(|| convert::pack_stem(&request.pack));
|
.unwrap_or_else(|| convert::pack_stem(&request.pack));
|
||||||
let target = env
|
// **M216 P3-2**:落点也委托 core(`pack_dir::instance_dir`)——此前这里自己拼
|
||||||
.layout
|
// `<data>/instances/<dir_name>`,core 改了目录语义时 GUI 不会跟着走。
|
||||||
.data()
|
let target = pack_dir::instance_dir(&env.layout, &name);
|
||||||
.join("instances")
|
|
||||||
.join(convert::dir_name(&name));
|
|
||||||
if target.exists() {
|
if target.exists() {
|
||||||
// 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」
|
// 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」
|
||||||
let mut entries = std::fs::read_dir(&target)
|
let mut entries = std::fs::read_dir(&target)
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ struct Job<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Job<'_> {
|
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)。
|
/// processor 段的进度出口(同一套 channel/meter/journal)。
|
||||||
fn reporter(&self) -> install_processors::Reporter<'_> {
|
fn reporter(&self) -> install_processors::Reporter<'_> {
|
||||||
install_processors::Reporter {
|
install_processors::Reporter {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ pub(super) fn install_vanilla(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| job_error(job, error))?;
|
.map_err(|error| job_error(job, error))?;
|
||||||
|
job.journal_report(&report);
|
||||||
ensure_complete(&report)?;
|
ensure_complete(&report)?;
|
||||||
Ok::<String, String>(version.to_string())
|
Ok::<String, String>(version.to_string())
|
||||||
})
|
})
|
||||||
@@ -178,6 +179,7 @@ pub(super) fn install_loader(
|
|||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
.map_err(|error| job_error(job, error))?;
|
.map_err(|error| job_error(job, error))?;
|
||||||
|
job.journal_report(&report);
|
||||||
ensure_complete(&report)?;
|
ensure_complete(&report)?;
|
||||||
Ok(release.version_id)
|
Ok(release.version_id)
|
||||||
}
|
}
|
||||||
@@ -162,6 +162,7 @@ impl Backend for Fake {
|
|||||||
InstallReport {
|
InstallReport {
|
||||||
files: outcomes,
|
files: outcomes,
|
||||||
version_json: None,
|
version_json: None,
|
||||||
|
manifest: None,
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ fn libraries_failed_lists_at_most_three() {
|
|||||||
let report = InstallReport {
|
let report = InstallReport {
|
||||||
files,
|
files,
|
||||||
version_json: None,
|
version_json: None,
|
||||||
|
manifest: None,
|
||||||
warnings: Vec::new(),
|
warnings: Vec::new(),
|
||||||
};
|
};
|
||||||
let text = libraries_failed(&report);
|
let text = libraries_failed(&report);
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
//!
|
//!
|
||||||
//! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。
|
//! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use unlucky_core::setting::keys::launcher_schema;
|
||||||
use unlucky_core::setting::spec::ValueKind;
|
use unlucky_core::setting::spec::ValueKind;
|
||||||
|
use unlucky_core::setting::store::SettingStore;
|
||||||
|
|
||||||
use crate::dto::{SettingGroupView, SettingRowView};
|
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 {
|
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 modes = ["portable", "standard", "custom"];
|
||||||
let mode = env.mode().as_str().to_string();
|
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 三模式)")
|
SettingGroupView::new("data", "数据目录(D6 三模式)")
|
||||||
.with(SettingRowView::choice(
|
.with(SettingRowView::choice(
|
||||||
"core.data_mode",
|
"core.data_mode",
|
||||||
@@ -146,7 +161,7 @@ fn data_group(env: &HostEnv) -> SettingGroupView {
|
|||||||
"core.data_custom_root",
|
"core.data_custom_root",
|
||||||
"自定义根目录",
|
"自定义根目录",
|
||||||
"custom 模式生效(绝对路径)",
|
"custom 模式生效(绝对路径)",
|
||||||
&text(env, "core.data_custom_root"),
|
&custom_root,
|
||||||
))
|
))
|
||||||
.with(SettingRowView::read_only(
|
.with(SettingRowView::read_only(
|
||||||
"启动器数据目录",
|
"启动器数据目录",
|
||||||
@@ -244,22 +259,26 @@ fn mcp_group(env: &HostEnv) -> SettingGroupView {
|
|||||||
/// 写一条设置。`value` 是字符串形态,按 schema 键的类型转换后交 `store.set`。
|
/// 写一条设置。`value` 是字符串形态,按 schema 键的类型转换后交 `store.set`。
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
|
||||||
/// 未知键、值类型不符、越界、落盘失败时返回用户可读文案。
|
/// 未知键、值类型不符、越界、落盘失败时返回用户可读文案。
|
||||||
pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
|
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 {
|
if key == super::engine::DETECT_REGION_ACTION {
|
||||||
return detect_region(env);
|
return detect_region(env);
|
||||||
}
|
}
|
||||||
// M204:设置页 Java 组的「写回完成,重发快照」动作键。**不写盘**——页面那一侧已经把三键
|
// M204:Java 组的「写回完成,重发快照」动作键。**不写盘**——页面那一侧已把三键一次原子写进
|
||||||
// 一次原子写进 `launcher.json` 了(`pages/settings/java/store.rs::save`),这里只要返回
|
// `launcher.json`(`pages/settings/java/store.rs::save`),这里返回 `Ok(())` 就能让
|
||||||
// `Ok(())`,`dispatch.rs` 的 `SetSetting` 分支就会重发 `CoreEvent::SettingsChanged`,
|
// `dispatch.rs` 重发 `SettingsChanged`。键的取舍见 `crate::dto::JAVA_REFRESH_ACTION`。
|
||||||
// `AppState::settings_groups` 里 java 组那几行随之变成刚写进去的真值。
|
|
||||||
// 键的来历与取舍见 `crate::dto::JAVA_REFRESH_ACTION` 的文档。
|
|
||||||
if key == crate::dto::JAVA_REFRESH_ACTION {
|
if key == crate::dto::JAVA_REFRESH_ACTION {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let parsed = parse_value(key, value)?;
|
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
|
store
|
||||||
.set(key, parsed)
|
.set(key, parsed)
|
||||||
.map_err(|error| format!("设置 {key} 失败:{error}"))?;
|
.map_err(|error| format!("设置 {key} 失败:{error}"))?;
|
||||||
@@ -269,6 +288,12 @@ pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
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 会再校验范围/枚举)。
|
/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。
|
||||||
///
|
///
|
||||||
/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):硬编码会让 Float 键被当 String
|
/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):硬编码会让 Float 键被当 String
|
||||||
@@ -324,6 +349,52 @@ pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> {
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
#[path = "settings_tests.rs"]
|
#[path = "settings_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -12,8 +12,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, Resolver};
|
use unlucky_core::platform::data_dir::Resolver;
|
||||||
use unlucky_core::platform::layout::LauncherLayout;
|
|
||||||
use unlucky_core::platform::paths::Platform;
|
use unlucky_core::platform::paths::Platform;
|
||||||
use unlucky_core::setting::appearance::Appearance;
|
use unlucky_core::setting::appearance::Appearance;
|
||||||
use unlucky_core::setting::keys::launcher_schema;
|
use unlucky_core::setting::keys::launcher_schema;
|
||||||
@@ -26,13 +25,23 @@ mod write;
|
|||||||
|
|
||||||
pub(crate) use write::Edit;
|
pub(crate) use write::Edit;
|
||||||
|
|
||||||
/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
|
/// 一次设置写回该落到哪份 `launcher.json`:数据根四键(`DATA_ROOT_KEYS`,判据只留
|
||||||
const DATA_ROOT_KEYS: [&str; 4] = [
|
/// `host::env` 那一份)→ 便携那份 `<exe>/.ULL/config/launcher.json`,启动解析读的就是它;
|
||||||
"core.data_mode",
|
/// 其余键 → `env_config`(解析后数据根那份)。
|
||||||
"core.data_custom_root",
|
///
|
||||||
"core.launcher_home",
|
/// **写侧与读侧同源就是这一个函数**(M216 ①):老代码在 Standard 模式下把 `core.data_mode`
|
||||||
"core.game_home",
|
/// 写进 `%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)]
|
#[derive(Debug)]
|
||||||
@@ -168,49 +177,23 @@ impl SettingsBridge {
|
|||||||
///
|
///
|
||||||
/// 与 CLI 同一套语义:先读便携目录那份 `launcher.json` 的 `core.*`(数据根决定键),
|
/// 与 CLI 同一套语义:先读便携目录那份 `launcher.json` 的 `core.*`(数据根决定键),
|
||||||
/// 再交 `Resolver` 解析(含 `redirect.json` 跟随)。任何一步失败都退回内存态。
|
/// 再交 `Resolver` 解析(含 `redirect.json` 跟随)。任何一步失败都退回内存态。
|
||||||
|
///
|
||||||
|
/// **M216 ①**:读这一步不再自带一份实现,直接用 [`crate::host::env::request_from_portable`]
|
||||||
|
/// ——`HostEnv::resolve` 与 [`setting_path`] 读的也是它,于是「启动读到的」与「设置页写回的」
|
||||||
|
/// 是同一份文件、同一段解析(presenter 侧原先两份逐字拷贝,现合一处)。
|
||||||
fn resolve_config_path() -> Option<PathBuf> {
|
fn resolve_config_path() -> Option<PathBuf> {
|
||||||
let exe_dir = std::env::current_exe()
|
let exe_dir = std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|path| path.parent().map(Path::to_path_buf));
|
.and_then(|path| path.parent().map(Path::to_path_buf))?;
|
||||||
let exe_dir = exe_dir?;
|
let request = crate::host::env::request_from_portable(&exe_dir);
|
||||||
let request = request_from_portable(&exe_dir);
|
|
||||||
let resolver = Resolver::new(Platform::current(), Some(exe_dir.clone()));
|
let resolver = Resolver::new(Platform::current(), Some(exe_dir.clone()));
|
||||||
match resolver.resolve_default(&request) {
|
match resolver.resolve_default(&request) {
|
||||||
Ok(resolved) => Some(resolved.layout().launcher_config()),
|
Ok(resolved) => Some(resolved.layout().launcher_config()),
|
||||||
// 连 exe 目录都解析不了(极端情况):仍旧用便携落点,至少设置能存下来
|
// 连 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> {
|
fn mtime(path: &Path) -> Option<SystemTime> {
|
||||||
std::fs::metadata(path).ok()?.modified().ok()
|
std::fs::metadata(path).ok()?.modified().ok()
|
||||||
}
|
}
|
||||||
@@ -233,6 +216,25 @@ mod tests {
|
|||||||
path
|
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]
|
#[test]
|
||||||
fn factory_defaults_when_file_missing() {
|
fn factory_defaults_when_file_missing() {
|
||||||
let path = temp_config("factory");
|
let path = temp_config("factory");
|
||||||
|
|||||||
Reference in new issue
Block a user