feat(m160): 整合包逐文件覆盖/保留决策 + 导入前冲突清单 + 限额口径统一

主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
  逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
  缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
  全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
  spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
  预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。

限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
  (20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
  不等式在 pack/tests/limits.rs 里用 const 断言钉死。

测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
  under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
  multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。

测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
  以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。

证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
This commit is contained in:
worker-modpackpick authored and AskaEth committed 2026-11-07 13:52:43 +08:00
1 parent a4f096eea3
commit 4defc41e8d
64 files changed
+3819 -442

No files matched your search

+5
View File
@@ -185,6 +185,11 @@ impl super::super::CoreHost {
pub(crate) fn pack(&mut self, command: PackCommand) {
let epoch = pack_epoch(&self.env);
match command {
PackCommand::Scan(request) => {
// 预检只读:不点亮导入面板(那会让用户以为已经在装了),也不计"在跑导入"。
let cancel = fresh_cancel(&self.env);
import::spawn_scan(self.env.clone(), self.tx.clone(), *request, epoch, cancel);
}
PackCommand::Import(request) => {
let cancel = fresh_cancel(&self.env);
// 句柄在**派发点**就计上,随线程一起结束(含早退与 panic)
+89 -13
View File
@@ -12,17 +12,92 @@ use std::path::{Path, PathBuf};
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
use unlucky_core::addon::import::{
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportError, ImportProgress,
ImportReport, LoaderTarget, MissingReason,
ConflictChoice, ConflictDecisions, ConflictFile, ConflictReason, FileAction, FileSource,
ImportError, ImportProgress, ImportReport, ImportScan, LoaderTarget, MissingReason,
};
use crate::dto::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
ExportReasonView, FileActionView, ImportErrorView, ImportProgressView, ImportReportView,
ConflictChoiceView, ConflictDecisionsView, ConflictFileView, ConflictReasonView,
ConflictSourceView, ExportClassView, ExportDoneView, ExportReasonView, FileActionView,
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportScanView,
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
PackFormatView,
};
/// 一次导入**预检** → DTO(M160:冲突清单)。
pub(super) fn scan_view(scan: &ImportScan) -> ImportScanView {
ImportScanView {
target: scan.target.clone(),
format: scan.format.as_deref().and_then(PackFormatView::from_slug),
pack_name: scan.pack_name.clone(),
pack_version: scan.pack_version.clone(),
game_version: scan.game_version.clone(),
conflicts: scan
.conflicts
.iter()
.map(|conflict| ImportConflictView {
path: conflict.path.clone(),
source: source_view(conflict.source),
reason: reason_of(conflict.reason),
disabled: conflict.disabled,
pack_sha1: conflict.pack_sha1.clone(),
pack_size: conflict.pack_size,
disk_sha1: conflict.disk_sha1.clone(),
disk_size: conflict.disk_size,
base_sha1: conflict.base_sha1.clone(),
})
.collect(),
unknown_remote: scan.unknown_remote,
lost_bytes: scan.lost_bytes(),
incoming_bytes: scan.incoming_bytes(),
notes: scan
.notes
.iter()
.map(|issue| issue.message.clone())
.collect(),
error: scan.error.as_ref().map(error_view),
}
}
/// DTO 的决策 → core 的决策表(**形状一换,语义不换**)。
///
/// 三条入口一一对应;没点名的路径在 core 侧落到"保留"(`ConflictDecisions` 的缺省档),
/// 所以"页面少传一条"永远不会变成"覆盖"。
pub(super) fn decisions_of(view: &ConflictDecisionsView) -> ConflictDecisions {
let mut decisions = match view.default_choice() {
ConflictChoiceView::KeepUser => ConflictDecisions::keep_all(),
ConflictChoiceView::Overwrite => ConflictDecisions::overwrite_all(),
};
for item in view.items() {
decisions.set(item.path.clone(), choice_of(item.choice));
}
decisions
}
const fn choice_of(choice: ConflictChoiceView) -> ConflictChoice {
match choice {
ConflictChoiceView::KeepUser => ConflictChoice::KeepUser,
ConflictChoiceView::Overwrite => ConflictChoice::Overwrite,
}
}
const fn source_view(source: FileSource) -> ConflictSourceView {
match source {
FileSource::Overrides => ConflictSourceView::Overrides,
FileSource::ClientOverrides => ConflictSourceView::ClientOverrides,
FileSource::CurseForge => ConflictSourceView::CurseForge,
FileSource::Modrinth => ConflictSourceView::Modrinth,
}
}
const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
match reason {
ConflictReason::UserModified => ConflictReasonView::UserModified,
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
}
}
/// 一次导入结果 → DTO。
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
ImportReportView {
@@ -114,15 +189,8 @@ fn error_view(error: &ImportError) -> ImportErrorView {
fn conflict_view(file: &ConflictFile) -> ConflictFileView {
ConflictFileView {
path: file.path.clone(),
reason: match file.reason {
ConflictReason::UserModified => ConflictReasonView::UserModified,
ConflictReason::UserDeleted => ConflictReasonView::UserDeleted,
ConflictReason::PreExisting => ConflictReasonView::PreExisting,
},
choice: match file.choice {
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
},
reason: reason_of(file.reason),
choice: choice_view(file.choice),
pack_sha1: file.pack_sha1.clone(),
base_sha1: file.base_sha1.clone(),
disk_sha1: file.disk_sha1.clone(),
@@ -130,6 +198,14 @@ fn conflict_view(file: &ConflictFile) -> ConflictFileView {
}
}
/// 一条 `ConflictChoice` → DTO(预检与事后账共用一张翻译表)。
const fn choice_view(choice: ConflictChoice) -> ConflictChoiceView {
match choice {
ConflictChoice::KeepUser => ConflictChoiceView::KeepUser,
ConflictChoice::Overwrite => ConflictChoiceView::Overwrite,
}
}
const fn action_view(action: FileAction) -> FileActionView {
match action {
FileAction::Installed => FileActionView::Installed,
+79 -13
View File
@@ -1,17 +1,20 @@
//! 后台导入(M113):探测 → 目标目录 → core 的 `import_pack` → 报告。
//! 后台导入(M113/M160):探测 → **预检(冲突清单)** → 目标目录 → core 的 `import_pack` → 报告。
//!
//! 三条纪律:
//! 四条纪律:
//!
//! 1. **探测失败不空跑管线**:先做具名诊断([`super::diagnose`]),失败就只回 `Probe` 事件;
//! 2. **默认档永远是 `KeepUser`**:只有 `request.overwrite == true` 才换成 `Overwrite`;
//! 3. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
//! 2. **先预检再导入**(M160):预检只读,把"会盖掉用户哪几个文件"算清楚。没有冲突就直接
//! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导;
//! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的
//! 路径在 core 侧落到"保留";
//! 4. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use unlucky_core::addon::import::{
import_pack, ConflictPolicy, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
import_pack, scan_pack, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
ImportRequest,
};
use unlucky_core::download::blocking::block_on;
@@ -41,6 +44,52 @@ pub(super) fn spawn_import(
});
}
/// 起一个后台**预检**线程(M160:只读,不写盘)。
pub(super) fn spawn_scan(
env: HostEnv,
tx: JobSender,
request: ImportRequestView,
epoch: u64,
cancel: Arc<AtomicBool>,
) {
spawn("pack-scan", move || {
// 探测先过一遍:失败就只给具名诊断,不空跑一遍预检。
if let Some(probe) = super::diagnose::of(&request.pack) {
send(&tx, epoch, PackEvent::Probe(Box::new(probe)));
return;
}
let target = match target_of(&env, &request) {
Ok((target, _)) => target,
Err(reason) => {
// 目标实例不可用:与导入路径同一形状的失败报告(界面不用学第二套)。
send(
&tx,
epoch,
PackEvent::Report(Box::new(convert::failed_report(
PathBuf::new(),
&request.pack,
"import_host",
"Target instance is not usable",
&reason,
))),
);
return;
}
};
let core_request = ImportRequest::new(&request.pack, &target);
// 预检是纯读的同步函数(不写盘、不联网),所以直接调用,不必占一个 async 任务。
let scan = scan_pack(&core_request);
if cancel.load(Ordering::SeqCst) {
return;
}
send(
&tx,
epoch,
PackEvent::Scan(Box::new(convert::scan_view(&scan))),
);
});
}
pub(super) fn run(
env: &HostEnv,
tx: &JobSender,
@@ -71,6 +120,22 @@ pub(super) fn run(
return;
}
};
if register {
if let Err(reason) = prepare_target(&target) {
send(
tx,
epoch,
PackEvent::Report(Box::new(convert::failed_report(
target,
&request.pack,
"import_host",
"Target instance is not usable",
&reason,
))),
);
return;
}
}
let pack_name = request
.instance_name
@@ -97,10 +162,9 @@ pub(super) fn run(
let fetcher = DownloadEngineFetcher::new(&engine);
let mut core_request = ImportRequest::new(&request.pack, &target)
.with_fetcher(&fetcher)
.with_progress(sink);
if request.overwrite {
core_request = core_request.with_conflict_policy(ConflictPolicy::Overwrite);
}
.with_progress(sink)
// 决策逐字段搬过来:`KeepAll`(缺省)/ `OverwriteAll` / 逐项,语义在 core 那一侧落地。
.with_decisions(convert::decisions_of(&request.decisions));
if register {
core_request = core_request.with_registration(&env.layout, Some(&pack_name));
}
@@ -134,7 +198,7 @@ fn remove_if_empty(dir: &std::path::Path) {
}
}
/// 导入目标:`(目录, 是否登记新实例)`。
/// 导入目标:`(目录, 是否登记新实例)` —— **只算路径,不建目录**(预检要能只读地用它)。
///
/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录);
/// - 没选实例 ⇒ `<data>/instances/<dir_name>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
@@ -166,11 +230,13 @@ pub(super) fn target_of(
target.display()
));
}
} else {
std::fs::create_dir_all(&target)
.map_err(|error| format!("create dir failed: {error}"))?;
}
Ok((target, true))
}
}
}
/// 真要写盘之前建目录(**预检不调它**:预检是只读的,不建任何东西)。
pub(super) fn prepare_target(target: &std::path::Path) -> Result<(), String> {
std::fs::create_dir_all(target).map_err(|error| format!("create dir failed: {error}"))
}
+12 -6
View File
@@ -13,6 +13,7 @@
//! 夹具(临时目录、写文件、泵 host)在 [`harness`]。
mod harness;
mod scan;
use harness::{pack_events, pump, pump_until, temp_dir, write};
@@ -21,7 +22,7 @@ use unlucky_core::addon::export::{
DEFAULT_CONTENT_ROOTS,
};
use crate::dto::{ExportClassView, ExportRequestView, PackFormatView};
use crate::dto::{ConflictDecisionsView, ExportClassView, ExportRequestView, PackFormatView};
#[test]
fn bad_packs_get_a_named_diagnosis() {
@@ -179,7 +180,7 @@ fn a_real_pack_runs_through_the_pipeline_end_to_end() {
pack: source,
instance: None,
instance_name: Some("Imported Pack".to_string()),
overwrite: false,
decisions: ConflictDecisionsView::default(),
};
super::import::run(
&env,
@@ -279,12 +280,17 @@ fn a_failed_new_instance_directory_can_be_reused_for_retry() {
pack: dir.join("whatever.mrpack"),
instance: None,
instance_name: Some("Retry Me".to_string()),
overwrite: false,
decisions: ConflictDecisionsView::default(),
};
// 第一次:目录不存在 ⇒ 建出来
let (first, register) = super::import::target_of(&env, &request).expect("首次应当可以建目录");
// 第一次:`target_of` 只算路径(预检要能只读地用它),真要写盘前才 `prepare_target`。
let (first, register) = super::import::target_of(&env, &request).expect("首次应当算得出目录");
assert!(register);
assert!(
!first.exists(),
"`target_of` 不许建目录(M160:预检是只读的)"
);
super::import::prepare_target(&first).expect("写盘前建目录");
assert!(first.is_dir());
// 第二次(模拟上一次失败留下的空目录)⇒ 复用,而不是报「已存在」
@@ -349,7 +355,7 @@ fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact()
pack: dir.join("missing.mrpack"),
instance: None,
instance_name: Some("Guess".to_string()),
overwrite: false,
decisions: ConflictDecisionsView::default(),
},
)))));
let events = pump_until(&mut host, |all| {
@@ -0,0 +1,185 @@
//! 预检与决策通道(M160):DTO ↔ core 的翻译、以及"缺省不覆盖"在 presenter 这一侧的表现。
//!
//! 这里**不重算**任何判定(判定在 core):只钉两件事——
//!
//! 1. 三条入口(全部保留 / 全部覆盖 / 逐项)翻成 core 的 `ConflictDecisions` 之后语义不变,
//! 尤其"没点名的路径 = 保留";
//! 2. 预检清单逐字段搬进 DTO(来源、成因、两侧体积与哈希、计数摘要)。
use std::path::PathBuf;
use super::harness::{temp_dir, write};
use crate::dto::{
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictReasonView,
ConflictSourceView,
};
use unlucky_core::addon::import::{
ConflictChoice, ConflictReason, FileSource, ImportConflict, ImportScan,
};
/// 造一份 core 侧的预检结果(只填这条用例要看的字段)。
fn scan_fixture() -> ImportScan {
ImportScan {
target: PathBuf::from("instances/survival/.minecraft"),
format: Some("modrinth".to_string()),
pack_name: Some("Better MC".to_string()),
pack_version: Some("1.2.0".to_string()),
game_version: Some("1.20.1".to_string()),
loader: None,
conflicts: vec![ImportConflict {
path: "config/sodium-options.json".to_string(),
source: FileSource::ClientOverrides,
reason: ConflictReason::UserModified,
disabled: false,
pack_sha1: "aa".repeat(20),
pack_size: 4_200,
disk_sha1: "bb".repeat(20),
disk_size: 5_100,
base_sha1: Some("cc".repeat(20)),
}],
unknown_remote: 2,
notes: Vec::new(),
error: None,
}
}
#[test]
fn scan_view_carries_every_field_and_the_counts() {
let scan = scan_fixture();
let view = super::super::convert::scan_view(&scan);
assert_eq!(view.format, Some(crate::dto::PackFormatView::Modrinth));
assert_eq!(view.pack_name.as_deref(), Some("Better MC"));
assert_eq!(view.unknown_remote, 2, "判不了的远端条目要如实计数");
assert!(view.needs_decision(), "有一条冲突就要问用户");
let conflict = &view.conflicts[0];
assert_eq!(conflict.path, "config/sodium-options.json");
assert_eq!(
conflict.source,
ConflictSourceView::ClientOverrides,
"叠层胜者那一侧的来源"
);
assert_eq!(conflict.reason, ConflictReasonView::UserModified);
assert_eq!(conflict.pack_size, 4_200);
assert_eq!(conflict.disk_size, 5_100, "目标现有体积要出现在清单里");
assert_eq!(conflict.disk_sha1.len(), 40, "目标现有哈希也要有");
assert_eq!(view.lost_bytes, 5_100, "覆盖会丢掉的字节");
assert_eq!(view.incoming_bytes, 4_200);
}
#[test]
fn a_clean_scan_is_not_a_reason_to_bother_the_user() {
let mut scan = scan_fixture();
scan.conflicts.clear();
let view = super::super::convert::scan_view(&scan);
assert!(!view.needs_decision());
assert!(view.is_clean(), "没有冲突也没有错误 ⇒ 可以直接导入");
}
#[test]
fn decisions_are_keep_all_by_default() {
// 缺省 = 全部保留:页面不发决策、或发默认值,结果都必须是"一个文件都不动"。
let view = ConflictDecisionsView::default();
assert_eq!(view, ConflictDecisionsView::KeepAll);
assert!(!view.overwrites_everything());
let core = super::super::convert::decisions_of(&view);
assert_eq!(core.choice_for("config/a.toml"), ConflictChoice::KeepUser);
assert_eq!(core.default_choice(), ConflictChoice::KeepUser);
assert!(core.is_empty());
}
#[test]
fn the_three_channels_map_one_to_one() {
// 全部覆盖
let all = super::super::convert::decisions_of(&ConflictDecisionsView::OverwriteAll);
assert_eq!(all.choice_for("anything.toml"), ConflictChoice::Overwrite);
assert_eq!(all.default_choice(), ConflictChoice::Overwrite);
// 逐项:点名的照点名走,没点名的落到"保留"。
let view = ConflictDecisionsView::per_item(
ConflictChoiceView::KeepUser,
vec![
ConflictDecisionView {
path: "mods/pin.jar".to_string(),
choice: ConflictChoiceView::Overwrite,
},
ConflictDecisionView {
path: "config/keep.toml".to_string(),
choice: ConflictChoiceView::KeepUser,
},
],
);
assert_eq!(view.overwrite_count(), 1);
assert_eq!(view.keep_count(), 1);
assert_eq!(view.len(), 2);
assert!(!view.overwrites_everything(), "逐项不等于整批覆盖");
let core = super::super::convert::decisions_of(&view);
assert_eq!(core.choice_for("mods/pin.jar"), ConflictChoice::Overwrite);
assert_eq!(
core.choice_for("config/keep.toml"),
ConflictChoice::KeepUser
);
assert_eq!(
core.choice_for("mods/unlisted.jar"),
ConflictChoice::KeepUser,
"没点名的路径不许被逐项表顺手覆盖"
);
assert_eq!(core.overwrite_count(), 1);
}
#[test]
fn the_scan_runs_without_writing_and_says_nothing_when_there_is_no_conflict() {
// 真跑一次后台预检:全新目标(什么都没有)⇒ 没有冲突,且**目标目录没被建出来**。
let dir = temp_dir("scan-fresh");
let source = dir.join("Better MC");
write(
&source.join("mmc-pack.json"),
br#"{"formatVersion": 1, "components": [
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
);
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 64]);
let data = dir.join("data");
std::fs::create_dir_all(&data).expect("data root");
let env = crate::host::HostEnv::at_root(&data);
let (tx, rx) = crate::host::job::channel();
let request = crate::dto::ImportRequestView {
pack: source,
instance: None,
instance_name: Some("Scan Only".to_string()),
decisions: ConflictDecisionsView::default(),
};
super::super::import::spawn_scan(
env,
tx,
request,
9,
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
);
let mut scan = None;
for task in rx.iter() {
if let crate::host::job::HostTask::Pack { event, epoch } = task {
assert_eq!(epoch, 9, "代次必须原样带回");
if let crate::dto::PackEvent::Scan(view) = *event {
scan = Some(*view);
}
}
}
let scan = scan.expect("必须回一条预检事件");
assert!(scan.error.is_none(), "{:?}", scan.error);
assert!(
!scan.needs_decision(),
"空目标上不该有冲突:{:?}",
scan.conflicts
);
assert_eq!(scan.format, Some(crate::dto::PackFormatView::Multimc));
assert!(
!scan.target.exists(),
"预检只读:目标目录不许被建出来({})",
scan.target.display()
);
let _ = std::fs::remove_dir_all(&dir);
}