core:新增 addon::online —— 按 Modrinth v2 真实形状建模(search facets / project /
project versions / version_file by hash),网络出口只有 Transport 一个 trait,生产实现
EngineTransport 走既有 DownloadEngine::download_bytes(零新依赖);错误分大类,网络类
失败映射成 NotConnected(= 未联网),不再用空列表冒充「没有结果」。
core:online::download::download_verified —— sha1 交给引擎流式校验、sha512 落盘后复核
(download::HashAlgo 没有 Sha512,改它超出 scope,已登记 finding),不符/失败删文件重试,
最多 3 次尝试且不留半截文件。
core:远端条目补全 —— PackFetcher::modrinth_file_by_sha512(默认 Unsupported = 补不出),
import 管线与新增的 scan_pack_online 都会在「有 path 但缺 hashes.sha1」时按 sha512 反查;
补出的 sha1 走 M160 同一套 judge 与合并判定,补不出仍进 unknown_remote 计数(口径未改)。
DownloadEngineFetcher 改用 download_verified,FetchRequest 增 sha512。
presenter:Modrinth 检索/详情改由 core 的 online 客户端提供(不再自己拼 URL、不再读错
date_modified);ContentFailure{kind} 取代裸字符串,无网 = Offline;新增
ContentDetailRequest/ContentDetail + LoadContentDetail 命令与 CoreEvent::ContentDetail 通道;
预检改走 scan_pack_online;内容库安装也走 download_verified(sha1+sha512)。
app-iced:Status::Offline 独立一态(未联网 ≠ 空列表 ≠ 普通失败),详情面板消费新 DTO
显示可用版本;选中项目即拉详情。i18n 新增 6 个键并同步金表。
测试:online 模块 24 个离线用例(录制 JSON + 假字节:搜索/详情/版本/按摘要反查/校验失败
即删/重试/离线分类/补全),import 侧 6 个(离线计数不变、补全后判冲突、补错摘要 ⇒ Failed),
presenter/app-iced 各补失败分类与详情状态机用例。
236 lines
10 KiB
Rust
236 lines
10 KiB
Rust
//! 整合包**导入**(M111):CurseForge `manifest.json` / Modrinth `.mrpack` / MultiMC·Prism 实例
|
||
//! → 一个可启动的实例目录。
|
||
//!
|
||
//! 设计依据:`DevDocs/PlanDoc/08-mod-and-recommend.md` §2(D10)、
|
||
//! `DevDocs/PlanDoc/research/18-modpack-formats*.md`(字段清单,A/B 级来源)、
|
||
//! `DevDocs/SubAgentsTalk/addon-整合包导入导出对接要点.md`(施工口径)。
|
||
//! 格式模型与安全闸是 M110 的 [`crate::addon::pack`]:本模块**只消费,不重写**。
|
||
//!
|
||
//! # 逐文件冲突决策(M160):预检 → 用户点头 → 导入
|
||
//!
|
||
//! | 步骤 | 入口 | 写盘吗 |
|
||
//! |------|------|--------|
|
||
//! | 预检:算出"哪几个文件会盖掉用户的" | [`scan_pack`] | **不写**(只读盘算摘要) |
|
||
//! | 用户点「全部保留 / 全部覆盖 / 逐项」 | [`ConflictDecisions`] | — |
|
||
//! | 导入 | [`import_pack`] | 写(按决策处置冲突) |
|
||
//!
|
||
//! 三条硬口径(改它们之前先看 [`scan`] 与 [`decision`] 的模块文档):
|
||
//!
|
||
//! 1. 进冲突清单的**只有**"目标里已存在同名普通文件且内容(SHA-1)不同"的路径;内容相同、
|
||
//! 目标不存在、以及用户删过的文件都不打扰用户;
|
||
//! 2. 决策按**路径**给,逐项优先于整批档,整批档缺省 = 保留;
|
||
//! 3. 缺省**绝不覆盖**:没收到决策的冲突一律保留用户那一侧([`ConflictChoice::default`])。
|
||
//!
|
||
//! # 三条硬要求落在哪
|
||
//!
|
||
//! | 要求 | 落点 |
|
||
//! |------|------|
|
||
//! | overrides 哈希**三方合并**(不静默覆盖用户改动) | [`merge`] 的判据 + [`baseline`] 的 base 侧 |
|
||
//! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 |
|
||
//! | 路径穿越**双检** | 开包时 M110 [`crate::addon::pack::PackArchive::open`],落地时 [`stage`] 的 `normalize_file` + `starts_with` 复核 |
|
||
//!
|
||
//! # 三种格式的语义(tower 定案,与 HMCL 相反的两条要记住)
|
||
//!
|
||
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
|
||
//! HMCL 恰好相反);`server-overrides/` 客户端**不解压**;其余同名路径无官方优先级 ⇒ 按侧
|
||
//! 优先级落地,不自创排序。**同一实例路径被本包多份声明命中时**(跨侧或同侧重复),
|
||
//! 逐条算清楚:胜者按上面这条规则定,被遮蔽的那条不读字节、只留一条
|
||
//! `pack.import.note.override_shadowed` 注记说明「谁赢了 / 两份内容是否逐字节相同」
|
||
//! (见 [`plan::Shadowed`])。一路径一行,不再出现"两条 Installed、前一条没落地"。
|
||
//! - **CF**:溢出目录名取自清单 `overrides` 字段;`files[]` 只有 `projectID/fileID`,要 CF API
|
||
//! 才能补出文件名/地址——仓库里还没有 CF 客户端(D10 §5),因此这部分条目的**当前正确行为**
|
||
//! 是记成 `missing`(`Partial`),**绝不猜 `edge.forgecdn.net` 那类 URL**。
|
||
//! - **MultiMC / Prism**:`mmc-pack.json` 的 `formatVersion` 必须恰为 `1`(M110 的读者会拒绝
|
||
//! 其余值);`.minecraft/` 直拷;非加载器 component(`org.lwjgl3` 等)**逐项**进
|
||
//! [`report::RejectedComponent`],不静默丢;只有导入,没有导出。
|
||
//!
|
||
//! # `.disabled` 只按路径后缀
|
||
//!
|
||
//! `x.jar` / `x.jar.disabled` / `x.jar.old` / `x.jar.disabled.old` 四种变体都识别,后缀**原样
|
||
//! 保留**;`.old` 只识别与保留(不清除、不改名、不当回滚语义)。`env.client=optional` **不是**
|
||
//! 禁用——"可选"的语义是"安装时可取消勾选",装回来默认仍然启用,两者不是一回事。
|
||
//!
|
||
//! # 用法
|
||
//!
|
||
//! ```ignore
|
||
//! let engine = /* core::download::DownloadEngine */;
|
||
//! let fetcher = DownloadEngineFetcher::new(&engine);
|
||
//! let request = ImportRequest::new(pack_path, instance_dir)
|
||
//! .with_fetcher(&fetcher)
|
||
//! .with_registration(&launcher_layout, None);
|
||
//! let report = download::blocking::block_on(import_pack(&request));
|
||
//! match report.status { /* Success / Partial / Failed */ }
|
||
//! ```
|
||
//!
|
||
//! # 刻意不做的事(分工边界)
|
||
//!
|
||
//! - **不删除**整合包升级后不再包含的文件:磁盘上的 `mods/` 里可能有用户自己装的模组,
|
||
//! 删旧文件是"清理"而不是"导入"(要做得先有"这个文件是整合包装的"的完整账本 + 用户确认);
|
||
//! - **不装加载器**:只把清单翻译成 [`report::LoaderTarget`](`kind` + 原版版本 + 加载器版本
|
||
//! + 版本 id),真正的元数据解析/库下载仍是 `addon::loader` + `game::install` 的既有路径;
|
||
//! - **不碰 UI 类型**:core 零 UI 依赖(`core-no-ui` 门禁),报告只出现 `String` / `&'static str`;
|
||
//! - **不绕过下载引擎**:所有网络只经 [`fetch::PackFetcher`],生产实现只有
|
||
//! [`fetch::DownloadEngineFetcher`](走 `core::download`)。
|
||
//!
|
||
//! # 没验的部分(真实世界的欠账,报告里同样登记)
|
||
//!
|
||
//! 真实 CF / MultiMC 导出物(夹具是手造的)、超大包(>2G)、Windows 之外的平台、断网中途、
|
||
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
|
||
|
||
pub mod baseline;
|
||
pub(crate) mod complete;
|
||
pub mod decision;
|
||
pub mod error;
|
||
pub mod fetch;
|
||
pub mod merge;
|
||
pub mod phase;
|
||
pub mod pipeline;
|
||
pub mod plan;
|
||
pub mod report;
|
||
pub mod scan;
|
||
pub mod stage;
|
||
|
||
pub use baseline::{PackBaseline, BASELINE_FILE};
|
||
pub use decision::ConflictDecisions;
|
||
pub use error::{ImportError, ImportErrorKind};
|
||
pub use fetch::{
|
||
CurseFileMeta, DownloadEngineFetcher, FetchError, FetchErrorKind, FetchFuture, FetchRequest,
|
||
FetchedFile, ModrinthFileMeta, PackFetcher,
|
||
};
|
||
pub use merge::{MergeAction, MergeDecision};
|
||
pub use pipeline::baseline_relative;
|
||
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
|
||
pub use report::{
|
||
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
|
||
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
|
||
LoaderTarget, MissingFile, MissingReason, RegisteredInstance, RejectedComponent,
|
||
};
|
||
pub use scan::{scan_pack, scan_pack_online, ImportConflict, ImportScan};
|
||
pub use stage::{RollbackOutcome, Staging};
|
||
|
||
use std::path::Path;
|
||
|
||
use crate::addon::pack::PackLimits;
|
||
use crate::platform::layout::LauncherLayout;
|
||
|
||
/// 整次导入的**缺省**冲突档(逐项决策见 [`ConflictDecisions`])。
|
||
///
|
||
/// 保留这个名字是因为 CLI 已经按它调用(`pack import --overwrite`);语义上它就是
|
||
/// [`ConflictDecisions::default_choice`] 的构造器:
|
||
///
|
||
/// - [`ConflictPolicy::KeepUser`](缺省)= 逐项决策的缺省档是"保留";
|
||
/// - [`ConflictPolicy::Overwrite`] = 逐项决策的缺省档是"覆盖"。
|
||
///
|
||
/// **逐项决策优先于它**,所以"整批覆盖 + 某几条保留"是可表达的。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum ConflictPolicy {
|
||
/// 保留用户那一侧(不覆盖、不补回被删的文件),冲突逐条进报告。
|
||
#[default]
|
||
KeepUser,
|
||
/// 用包内内容覆盖(用户明确要求"整合包说了算")。
|
||
Overwrite,
|
||
}
|
||
|
||
impl ConflictPolicy {
|
||
/// 对应的逐项决策缺省档。
|
||
#[must_use]
|
||
const fn choice(self) -> ConflictChoice {
|
||
match self {
|
||
Self::KeepUser => ConflictChoice::KeepUser,
|
||
Self::Overwrite => ConflictChoice::Overwrite,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一次导入请求。
|
||
pub struct ImportRequest<'a> {
|
||
/// 包路径:zip(`.zip` / `.mrpack`)或 MultiMC 实例目录。
|
||
pub pack: &'a Path,
|
||
/// 目标实例的游戏目录(`mods/` 直接在其下)。
|
||
pub target: &'a Path,
|
||
/// 远端补全 + 下载;`None` = 离线导入(远端条目全部记成 `missing`)。
|
||
pub fetcher: Option<&'a dyn PackFetcher>,
|
||
/// 数据根布局;给了才登记实例(`profiles.json`)。
|
||
pub register: Option<&'a LauncherLayout>,
|
||
/// 实例展示名(缺省用包名)。
|
||
pub instance_name: Option<&'a str>,
|
||
/// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。
|
||
pub decisions: ConflictDecisions,
|
||
/// 包读取限额(条数 / 单条体积 / 解压总量)。
|
||
pub limits: PackLimits,
|
||
/// 进度回调。
|
||
pub progress: Option<ImportProgressSink>,
|
||
}
|
||
|
||
impl<'a> ImportRequest<'a> {
|
||
/// 最小请求(离线、不登记、保留用户文件、默认限额)。
|
||
#[must_use]
|
||
pub fn new(pack: &'a Path, target: &'a Path) -> Self {
|
||
Self {
|
||
pack,
|
||
target,
|
||
fetcher: None,
|
||
register: None,
|
||
instance_name: None,
|
||
decisions: ConflictDecisions::default(),
|
||
limits: PackLimits::default(),
|
||
progress: None,
|
||
}
|
||
}
|
||
|
||
/// 挂上远端补全 / 下载。
|
||
#[must_use]
|
||
pub fn with_fetcher(mut self, fetcher: &'a dyn PackFetcher) -> Self {
|
||
self.fetcher = Some(fetcher);
|
||
self
|
||
}
|
||
|
||
/// 挂上实例登记(`name` 为 `None` 时用包名)。
|
||
#[must_use]
|
||
pub fn with_registration(mut self, layout: &'a LauncherLayout, name: Option<&'a str>) -> Self {
|
||
self.register = Some(layout);
|
||
self.instance_name = name;
|
||
self
|
||
}
|
||
|
||
/// 整批缺省档(逐项决策仍然优先)。
|
||
#[must_use]
|
||
pub fn with_conflict_policy(mut self, policy: ConflictPolicy) -> Self {
|
||
self.decisions = std::mem::take(&mut self.decisions).with_default(policy.choice());
|
||
self
|
||
}
|
||
|
||
/// 整表逐文件决策(「全部保留 / 全部覆盖 / 逐项」三条入口的产物)。
|
||
#[must_use]
|
||
pub fn with_decisions(mut self, decisions: ConflictDecisions) -> Self {
|
||
self.decisions = decisions;
|
||
self
|
||
}
|
||
|
||
/// 挂一条逐项决策(链式,便于测试与命令行逐条给)。
|
||
#[must_use]
|
||
pub fn with_decision(mut self, path: impl Into<String>, choice: ConflictChoice) -> Self {
|
||
self.decisions.set(path, choice);
|
||
self
|
||
}
|
||
|
||
/// 改读取限额。
|
||
#[must_use]
|
||
pub fn with_limits(mut self, limits: PackLimits) -> Self {
|
||
self.limits = limits;
|
||
self
|
||
}
|
||
|
||
/// 挂进度回调。
|
||
#[must_use]
|
||
pub fn with_progress(mut self, sink: ImportProgressSink) -> Self {
|
||
self.progress = Some(sink);
|
||
self
|
||
}
|
||
}
|
||
|
||
/// 导入一个整合包。**永不返回 `Err`**:三态与逐项原因都在 [`ImportReport`] 里。
|
||
pub async fn import_pack(request: &ImportRequest<'_>) -> ImportReport {
|
||
pipeline::run(request).await
|
||
}
|