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

+7 -5
View File
@@ -54,11 +54,13 @@ pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSes
pub use notices::{NoticeLevel, NoticeView};
// M113: import/export
pub use pack::{
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView, ExportState,
FileActionView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
ImportStageView, ImportState, ImportStatusView, ImportedFileView, MissingFileView,
MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
ImportConflictView, ImportErrorView, ImportProgressView, ImportReportView, ImportRequestView,
ImportScanView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
MissingFileView, MissingReasonView, PackCommand, PackEvent, PackFormatView, PackState,
ProbeView,
};
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
// M159: 自更新
+15 -4
View File
@@ -26,10 +26,18 @@
//! | [`conflict`] | 冲突成因、处置、三方哈希 |
//! | [`export`] | 三分类 / 逐条原因 / 预检 |
//!
//! ## 冲突:默认一侧永远是用户那一侧
//! ## 冲突:预检清单 → 用户点头 → 事后账(M160)
//!
//! [`ImportRequestView::overwrite`] 缺省 `false` = `ConflictPolicy::KeepUser`。这是 M113 的硬线:
//! **绝不默认覆盖用户改过的文件**。
//! 三样东西在三处,别混:
//!
//! | 类型 | 时机 | 语义 |
//! |---|---|---|
//! | [`ImportScanView`] / [`ImportConflictView`] | 导入**前**([`PackEvent::Scan`]) | 待用户点头的冲突清单 |
//! | [`ConflictDecisionsView`] | 页面 → 宿主([`ImportRequestView::decisions`]) | 全部保留 / 全部覆盖 / 逐项 |
//! | [`ConflictFileView`] | 导入**后**([`PackEvent::Report`]) | 每条实际怎么处置的 |
//!
//! 两处缺省都是**保留**([`ConflictDecisionsView::default`] = `KeepAll`,core 的
//! `ConflictChoice::default()` = `KeepUser`):**绝不默认覆盖用户已有文件**。
mod conflict;
mod event;
@@ -37,7 +45,10 @@ mod export;
mod format;
mod import;
pub use conflict::{ConflictChoiceView, ConflictFileView, ConflictReasonView};
pub use conflict::{
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
ConflictReasonView, ConflictSourceView, ImportConflictView, ImportScanView,
};
pub use event::{PackCommand, PackEvent, PackState};
pub use export::{
ExportClassView, ExportDoneView, ExportFileView, ExportPreviewView, ExportReasonView,
+231 -4
View File
@@ -1,11 +1,238 @@
//! 冲突的 DTO:成因、本次处置、以及三方哈希的那一条账。
//! 冲突的 DTO:成因、本次处置、**预检的待决清单**、以及三方哈希的那一条账。
//!
//! 三条判据与 core 的 `ConflictReason` 一一对应;`choice` 是**事后记录**(本次实际怎么处置的),
//! 不是「逐文件决策通道」——core 今天没有那种通道(`ConflictPolicy` 是整次导入一个全局档)。
//! 「默认保留用户那一侧」这条硬线在 `ImportRequestView::overwrite` 缺省 `false` 上。
//! 三种东西分得清清楚楚(混起来就会把"要不要覆盖"变成"已经覆盖了"):
//!
//! | 类型 | 什么时候出现 | 语义 |
//! |---|---|---|
//! | [`ImportConflictView`] | **导入前**(`PackEvent::Scan`) | 待用户点头的一条:路径、来源、目标现有体积与哈希 |
//! | [`ConflictFileView`] | **导入后**(`PackEvent::Report`) | 事后账:这条实际怎么处置的(`choice`) |
//! | [`ConflictDecisionsView`] | 从页面发回去(`ImportRequestView::decisions`) | 用户点的「全部保留 / 全部覆盖 / 逐项」 |
//!
//! 「默认保留用户那一侧」这条硬线在 [`ConflictDecisionsView::default`] 上:缺省 = 全部保留,
//! 而 core 侧 [`ConflictChoiceView::KeepUser`] 也是缺省档——两侧都不许"没收到决策就覆盖"。
use serde::{Deserialize, Serialize};
/// 一条冲突**来源**(预检清单里那一列)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConflictSourceView {
/// 包内 `overrides/`(CF 清单点名的那个目录 / MultiMC 的 `.minecraft/` 也走这条)。
Overrides,
/// 包内 `client-overrides/`(叠层里胜出的那一侧)。
ClientOverrides,
/// CurseForge `files[]` 条目。
CurseForge,
/// Modrinth `files[]` 条目。
Modrinth,
}
impl ConflictSourceView {
/// 稳定 slug。
#[must_use]
pub const fn slug(self) -> &'static str {
match self {
Self::Overrides => "overrides",
Self::ClientOverrides => "client_overrides",
Self::CurseForge => "curseforge",
Self::Modrinth => "modrinth",
}
}
/// 从 core 的 id 反解。
#[must_use]
pub fn from_slug(slug: &str) -> Option<Self> {
match slug {
"overrides" => Some(Self::Overrides),
"client_overrides" => Some(Self::ClientOverrides),
"curseforge" => Some(Self::CurseForge),
"modrinth" => Some(Self::Modrinth),
_ => None,
}
}
/// 是不是"包内的覆盖目录"(相对于清单里的远端条目)。
#[must_use]
pub const fn is_override_dir(self) -> bool {
matches!(self, Self::Overrides | Self::ClientOverrides)
}
}
/// 一条**待用户点头**的冲突(导入前那张清单里的一行)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportConflictView {
/// 实例内相对路径。
pub path: String,
/// 来源(包内覆盖目录 / 清单远端条目)。
pub source: ConflictSourceView,
/// 成因。
pub reason: ConflictReasonView,
/// 包内那条是不是禁用态(`.disabled`)。
pub disabled: bool,
/// 包内声明的 SHA-1。
pub pack_sha1: String,
/// 包内声明的字节数。
pub pack_size: u64,
/// 目标现有文件的 SHA-1。
pub disk_sha1: String,
/// 目标现有文件的字节数(覆盖会丢掉的量)。
pub disk_size: u64,
/// 上次导入写下的 SHA-1(没有基线时 `None`)。
pub base_sha1: Option<String>,
}
/// 用户的冲突决策(三条入口共用一个类型)。
///
/// 三条入口与 core 的 `ConflictDecisions`(`default` + `entries`)是同一件事的两种说法:
///
/// | 变体 | core 形状 |
/// |---|---|
/// | [`ConflictDecisionsView::KeepAll`] | `default = KeepUser`, 空表 |
/// | [`ConflictDecisionsView::OverwriteAll`] | `default = Overwrite`, 空表 |
/// | `PerItem { default, items }` | `default` + 逐项表 |
///
/// `PerItem` 带一个 `default` 是为了让"全部覆盖之后我又单独放回两条"这种真实操作可表达:
/// 若只有"逐项覆盖 + 缺省保留",用户就得把剩下的几百条全点一遍。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConflictDecisionsView {
/// **全部保留**(缺省):用户已有文件一个都不动。
KeepAll,
/// **全部覆盖**:整合包说了算。
OverwriteAll,
/// **逐项**:点过的路径按 `items`,其余按 `default`。
PerItem {
/// 没点过的路径按它处置。
default: ConflictChoiceView,
/// 点过的路径。
items: Vec<ConflictDecisionView>,
},
}
impl Default for ConflictDecisionsView {
/// **缺省 = 全部保留**(绝不默认覆盖用户已有文件)。
fn default() -> Self {
Self::KeepAll
}
}
impl ConflictDecisionsView {
/// 逐项构造(缺省档 + 点过的那些)。
#[must_use]
pub fn per_item(default: ConflictChoiceView, items: Vec<ConflictDecisionView>) -> Self {
if items.is_empty() {
return match default {
ConflictChoiceView::KeepUser => Self::KeepAll,
ConflictChoiceView::Overwrite => Self::OverwriteAll,
};
}
Self::PerItem { default, items }
}
/// 逐项表里的缺省档(`KeepAll` / `OverwriteAll` 也答得出)。
#[must_use]
pub const fn default_choice(&self) -> ConflictChoiceView {
match self {
Self::KeepAll => ConflictChoiceView::KeepUser,
Self::OverwriteAll => ConflictChoiceView::Overwrite,
Self::PerItem { default, .. } => *default,
}
}
/// 用户显式要求"以包为准"吗(整批覆盖)。
#[must_use]
pub fn overwrites_everything(&self) -> bool {
matches!(self, Self::OverwriteAll)
}
/// 逐项表里"覆盖"的条数(不含缺省档)。
#[must_use]
pub fn overwrite_count(&self) -> usize {
self.items()
.iter()
.filter(|item| item.choice == ConflictChoiceView::Overwrite)
.count()
}
/// 逐项表里"保留"的条数。
#[must_use]
pub fn keep_count(&self) -> usize {
self.items()
.iter()
.filter(|item| item.choice == ConflictChoiceView::KeepUser)
.count()
}
/// 逐项表(`KeepAll` / `OverwriteAll` 是空表)。
#[must_use]
pub fn items(&self) -> &[ConflictDecisionView] {
match self {
Self::PerItem { items, .. } => items,
Self::KeepAll | Self::OverwriteAll => &[],
}
}
/// 逐项表的条数。
#[must_use]
pub fn len(&self) -> usize {
self.items().len()
}
/// 有没有逐项决策。
#[must_use]
pub fn is_empty(&self) -> bool {
self.items().is_empty()
}
}
/// 逐项决策的一条(路径 → 处置)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConflictDecisionView {
/// 实例内相对路径。
pub path: String,
/// 处置。
pub choice: ConflictChoiceView,
}
/// 一次导入预检的结果(`PackEvent::Scan` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImportScanView {
/// 目标实例目录(人要看到"装到哪")。
pub target: std::path::PathBuf,
/// 命中的格式。
pub format: Option<crate::dto::PackFormatView>,
/// 包名。
pub pack_name: Option<String>,
/// 包版本。
pub pack_version: Option<String>,
/// 游戏版本。
pub game_version: Option<String>,
/// 待用户点头的冲突(按路径排序)。
pub conflicts: Vec<ImportConflictView>,
/// 补全前没有路径、因而判不了的远端条目数。
pub unknown_remote: usize,
/// 覆盖会丢掉的字节总量。
pub lost_bytes: u64,
/// 包内那些条目声明了多大。
pub incoming_bytes: u64,
/// 注记(与导入报告同源的人读串)。
pub notes: Vec<String>,
/// 预检本身失败(探测不过 / 开包失败)时的原因。
pub error: Option<crate::dto::ImportErrorView>,
}
impl ImportScanView {
/// 需要用户点头吗(`false` = 可以直接导入,别打扰)。
#[must_use]
pub fn needs_decision(&self) -> bool {
!self.conflicts.is_empty()
}
/// 一条冲突都没有、预检也成功(可以直接导)。
#[must_use]
pub fn is_clean(&self) -> bool {
self.error.is_none() && self.conflicts.is_empty()
}
}
/// 冲突成因(core 的三条判据)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConflictReasonView {
+19 -3
View File
@@ -26,12 +26,17 @@ use serde::{Deserialize, Serialize};
use crate::dto::pack::{
ExportDoneView, ExportPreviewView, ExportRequestView, ExportState, ImportProgressView,
ImportReportView, ImportRequestView, ImportState, ProbeView,
ImportReportView, ImportRequestView, ImportScanView, ImportState, ProbeView,
};
/// app → host 的导入/导出意图。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PackCommand {
/// **预检**导入(只读:算出冲突清单,**不写一个字节**)
///
/// 页面先发它,拿到 [`PackEvent::Scan`] 之后:没有冲突就直接发 [`PackCommand::Import`]
/// (不打扰用户);有冲突就把清单画出来,用户点完再带着决策发 `Import`。
Scan(Box<ImportRequestView>),
/// 开始导入(目标实例已选好;`instance` 为 `None` = 按 `instance_name` 新建目录)
Import(Box<ImportRequestView>),
/// 跑**预检**(只扫描分类,**不写盘**)
@@ -47,6 +52,8 @@ pub enum PackCommand {
pub enum PackEvent {
/// 导入进度(`None` = 收回面板:取消,或流程已经给出结果)
Progress(Option<Box<ImportProgressView>>),
/// 导入**预检**结果:冲突清单(M160)。空清单 = 没有文件需要用户点头。
Scan(Box<ImportScanView>),
/// 导入结果(三态 + 逐项原因;致命失败也走它,`report.error` 里有原因)
Report(Box<ImportReportView>),
/// 探测失败:这个包看起来像什么、缺哪个标记文件
@@ -80,15 +87,24 @@ impl PackState {
PackEvent::Progress(progress) => {
self.import.progress = progress.map(|progress| *progress);
}
PackEvent::Report(report) => {
// 结果到了:进度面板让位,探测诊断作废(这一轮已经有结论了)
PackEvent::Scan(scan) => {
// 预检到了:进度让位,上一轮的结果/诊断作废(这一轮还没开始导)。
self.import.progress = None;
self.import.probe = None;
self.import.report = None;
self.import.scan = Some(*scan);
}
PackEvent::Report(report) => {
// 结果到了:进度面板让位,探测诊断与预检清单都作废(这一轮已经有结论了)
self.import.progress = None;
self.import.probe = None;
self.import.scan = None;
self.import.report = Some(*report);
}
PackEvent::Probe(probe) => {
self.import.progress = None;
self.import.report = None;
self.import.scan = None;
self.import.probe = Some(*probe);
}
PackEvent::Preview(preview) => {
+7 -4
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::dto::InstanceId;
use super::conflict::ConflictFileView;
use super::conflict::{ConflictDecisionsView, ConflictFileView, ImportScanView};
use super::format::PackFormatView;
/// 导入阶段(与 core 的 `ImportStage` 一一对应)。
@@ -144,10 +144,11 @@ pub struct ImportRequestView {
pub instance: Option<InstanceId>,
/// 新实例的展示名(`None` = 用包名)。
pub instance_name: Option<String>,
/// 冲突翻档:`false`(**默认**)= 保留用户那一侧;`true` = 以包为准覆盖。
/// 冲突决策(**缺省 [`ConflictDecisionsView::KeepAll`]** = 用户已有文件一个都不动)。
///
/// 页面上把 `true` 那条写成「以包为准重新导入」,并且**只在用户显式点过之后**才发。
pub overwrite: bool,
/// 页面上:`KeepAll` 是缺省与「全部保留」,`OverwriteAll` 是「全部覆盖」,
/// `PerItem` 是逐项勾选的结果。只有用户**显式点过**之后才会出现后两者。
pub decisions: ConflictDecisionsView,
}
/// 一条导入进度。
@@ -334,6 +335,8 @@ pub struct ImportState {
/// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上——
/// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。
pub cancelled: bool,
/// 最近一次的预检(M160:冲突清单;`None` = 还没预检过或已被新结果顶掉)。
pub scan: Option<ImportScanView>,
/// 最近一次的结果(`None` = 还没导过)。
pub report: Option<ImportReportView>,
/// 探测失败的具名诊断(有它就让用户看清「像 X 缺 Y」,而不是一句「无法识别」)。
+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);
}