- 阻塞项:下载循环从 `install::run` 抽到新文件 `addon/downloads.rs`
(`download_failure()` + `download_stage()`),假下载器就能驱动它;三条用例分别钉
①失败→Unreachable+叶子成因、②第 2 个失败不中断且已装不回滚、③解析完就取消一份都不下。
实测删掉 `record_failure` 那行会红(failures 0 vs 1),把 `cause` 丢掉也会红;
- nit① 补 core 用例:夹具里 `{"version_id":"vid-only","dependency_type":"required"}` ⇒
`plan.unresolved_required == [UnresolvedRequired{parent:"MIXED001",…}]`,同版本的
`project_id` required 仍被跟;删掉收集那段即红(left: [] vs 一条);
- nit② 取消检查加回解析之后(`download_stage` 入口),并用例钉「解析后取消 ⇒ 不下任何文件」;
- nit③ `fail()` 文档写明去重按**请求键**而非规范 id 的边界(先 slug 后 project_id 仍会两条),
不消掉但不再让人误以为全覆盖;
- `install.rs` 只留解析 + 收口(275 行),模块表登记 `downloads`。
125 lines
4.9 KiB
Rust
125 lines
4.9 KiB
Rust
//! 内容库的宿主实现(M109 `CONTENTDL`):**检索** + **装进指定实例**。
|
||
//!
|
||
//! ## 为什么这些代码在 presenter 而不是 core(临时落点,必须写清)
|
||
//!
|
||
//! `crates/core` 的 `addon` 模块今天只有两件事:加载器安装(`addon::loader`)与整合包**格式**
|
||
//! 解析(`addon::pack`,M110)。它**没有** CurseForge / Modrinth 的 API 客户端,也没有
|
||
//! 「远程整合包一键装进实例」的管线(M111/M112 才做)。而任务书的硬约束是「UI 层不许自己发
|
||
//! HTTP,新增能力一律经 presenter 的 host 转发」——所以本轮把客户端放在这里。
|
||
//!
|
||
//! **长期应下沉到 `core::addon`**(已报 finding):那时本模块只剩「DTO ↔ 领域类型」的翻译,
|
||
//! 与 `host/versions.rs` 现在的角色一样。
|
||
//!
|
||
//! ## 三条纪律
|
||
//!
|
||
//! | 纪律 | 落点 |
|
||
//! |---|---|
|
||
//! | 网络只在后台线程 | [`spawn_search`] / [`spawn_install`] 走 `job::spawn`,UI 线程只收事件 |
|
||
//! | Modrinth 走 core 的下载引擎 | `addon/http.rs` 的 `download_bytes` 路径带**信号量限流 + 源健康度 + 指数退避**,不另写重试循环 |
|
||
//! | 密钥不进仓库 / 日志 | CF 的 `X-API-KEY` 只从 `ULL_CF_API_KEY` 读,日志只打 `set`/`unset`,DTO 里只有一个 `bool` |
|
||
//!
|
||
//! ## 模块
|
||
//!
|
||
//! | 文件 | 职责 |
|
||
//! |---|---|
|
||
//! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] |
|
||
//! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 |
|
||
//! | [`install`] | 一键装入:解析文件(含 `required` 前置)→ 下载到实例目录 → 进度回流 |
|
||
//! | [`downloads`] | 下载阶段(可测):逐个下、失败进名单不中断、两处取消点 |
|
||
//! | [`plan`] | 依赖计划的宿主侧:扫目录、两档失败 → DTO 大类、人读文案 |
|
||
//!
|
||
//! ## 整合包与 CF 的边界(都写进报告)
|
||
//!
|
||
//! - 整合包**不做**「一键装入」:`ContentKind::Modpack.dir()` 是 `None`,UI 侧按钮禁用带原因,
|
||
//! host 侧再挡一次(就算绕过 UI 发指令也只回一条 `Failed`)。真流程是 M111 的导入管线。
|
||
//! - CurseForge 本轮只做**检索**(需要用户配 `ULL_CF_API_KEY`),文件解析未接通。
|
||
|
||
mod downloads;
|
||
mod http;
|
||
mod install;
|
||
mod plan;
|
||
// M113: import/export —— 整合包导入/导出(拆成子目录:父文件只留既有内容 + mod)
|
||
mod pack;
|
||
mod sources;
|
||
|
||
pub(super) use pack::{cancel_pack, pack_epoch};
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|
||
|
||
use std::sync::atomic::AtomicBool;
|
||
use std::sync::Arc;
|
||
|
||
use crate::dto::{
|
||
ContentDetail, ContentDetailRequest, ContentFailure, ContentQuery, ContentResults,
|
||
};
|
||
|
||
use super::env::HostEnv;
|
||
use super::job::{spawn, HostTask, JobSender};
|
||
|
||
/// 日志里只出现这两种状态,**永远不打密钥本身**。
|
||
const KEY_UNSET: &str = "ULL_CF_API_KEY unset(CurseForge 源不可用)";
|
||
const KEY_SET: &str = "ULL_CF_API_KEY set(CurseForge 源可用)";
|
||
|
||
/// CurseForge 的密钥(环境变量;空串 / 空白按「没配」处理)。
|
||
///
|
||
/// 为什么不放设置项:core 的 `setting` schema 不在本 mission 的 scope(已登记 finding)。
|
||
/// 环境变量这条路至少满足「不进仓库、不进日志」两条硬要求。
|
||
fn curseforge_key() -> Option<String> {
|
||
std::env::var("ULL_CF_API_KEY")
|
||
.ok()
|
||
.map(|key| key.trim().to_string())
|
||
.filter(|key| !key.is_empty())
|
||
}
|
||
|
||
/// CF 源此刻可用吗(页面据此禁用那条来源)。
|
||
#[must_use]
|
||
pub fn curseforge_ready() -> bool {
|
||
curseforge_key().is_some()
|
||
}
|
||
|
||
/// 打一行「配没配」的日志(**不含**密钥内容;host 装配时调一次)。
|
||
pub fn log_curseforge_state() {
|
||
eprintln!(
|
||
"[content] {}",
|
||
if curseforge_ready() {
|
||
KEY_SET
|
||
} else {
|
||
KEY_UNSET
|
||
}
|
||
);
|
||
}
|
||
|
||
/// 后台检索(网络在后台线程,UI 不卡)。
|
||
pub fn spawn_search(env: HostEnv, tx: JobSender, query: ContentQuery) {
|
||
spawn("content", move || {
|
||
let result: Result<ContentResults, ContentFailure> = sources::search(&env, &query);
|
||
let _ = tx.send(HostTask::Content(Box::new(result)));
|
||
});
|
||
}
|
||
|
||
/// 后台取项目详情 + 版本列表(M162;选中项目才发,一条独立通道)。
|
||
pub fn spawn_detail(env: HostEnv, tx: JobSender, request: ContentDetailRequest) {
|
||
spawn("content-detail", move || {
|
||
let project = request.project.clone();
|
||
let result: Result<ContentDetail, ContentFailure> = sources::detail(&env, &request);
|
||
let _ = tx.send(HostTask::ContentDetail {
|
||
project,
|
||
result: Box::new(result),
|
||
});
|
||
});
|
||
}
|
||
|
||
/// 后台「装进指定实例」。
|
||
pub fn spawn_install(
|
||
env: HostEnv,
|
||
tx: JobSender,
|
||
request: crate::dto::ContentInstallRequest,
|
||
cancel: Arc<AtomicBool>,
|
||
epoch: u64,
|
||
) {
|
||
spawn("content-install", move || {
|
||
install::install(&env, &tx, &request, &cancel, epoch);
|
||
});
|
||
}
|