Files
Unlucky-Launcher/crates/presenter/src/host/addon.rs
T
AskaEth ff2f229b80 fix(addon): 复审 r2 —— 下载阶段抽出可测边界并补守门
- 阻塞项:下载循环从 `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`。
2026-11-08 20:23:57 +08:00

125 lines
4.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 内容库的宿主实现(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);
});
}