fix(m162-r1): 叶子成因穿出引擎 + 安装路径切 core 在线源 + 详情迟到结果闸门
P1-1(p1-1items 的根因): - download/error.rs:`DownloadError` 增 `leaf: Option<DownloadErrorKind>` 与 `effective_kind()`;`leaf_of()` 汇总规则只此一处:全 404 ⇒ NotFound、有 Network/Timeout ⇒ Network、全 5xx ⇒ ServerError、混合按「网络错 > 5xx > 404」 取最可诊断的那个(**不从 suppressed 文本猜状态码**)。 - download/bytes.rs:候选源循环收集逐源 kind,收尾时把叶子挂到 AllSourcesFailed 上。 - online/transport.rs:`From<DownloadError>` 改用 `effective_kind()`,于是 NotFound/Server/Integrity 在生产路径可达(项目被删/id 拼错/限速不再画成「未联网」)。 - import/fetch.rs:新增 `FetchErrorKind::Offline`(真正的「没联网」)并与 Network 分开; `From<DownloadError>`/`From<OnlineError>` 都按叶子/大类映射。 - import/complete.rs:补全只在 `Offline` 时早停;404(真实 mrpack 大多托管在 CF CDN, Modrinth 必然 404)与 5xx 只影响那一条,不再掐掉整轮;注记「因未联网提前停止」因此 只会在真断网时出现。 P2-1:presenter 安装路径不再自己拼 Modrinth URL / 自己解析 JSON——改用 `core::addon::online::Modrinth::versions` + `primary_file()` + `VersionFile::sha1/sha512`; `HostTask::InstallDone.result` 改成 `Result<String, ContentFailure>`, `InstallProgressView` 增 `failure_kind`(离线 ≠ 查无此项)。 P2-2:`app/update/content.rs` 把被详情方法抢走的「装入闸门」注释还回 `content_install_request`,详情方法只留自己那一行。 P2-3:`ContentState::apply_detail/fail_detail` 带上 project(事件与 HostTask 都携带请求时的 项目 id),迟到结果一律丢弃;`request_detail` 记下在飞项目,`detail_pending` 不再被迟到 结果清掉。 顺手项:`usable_hash` 让形状非法的那一档摘要被**丢弃**(sha512 仍校验,不再整条跳过; 两档都不可用才拒绝);`digest_file` 的 IO 失败先删落盘文件再返回。i18n 的 ContentOfflineBody 改成不暗示「有缓存」的措辞(golden 同步)。 用例:download/error.rs 叶子四档 + effective_kind;online/tests/classify.rs 5 条 (AllSourcesFailed+leaf 404 ⇒ NotFound、无叶子 ⇒ NotConnected 保守、叶子一一映射、 单源自身 kind、导入侧 404≠Offline);import_online 增「404 不停」「真离线才停」 (共 7 条);download 增「sha1 形状非法仍用 sha512 校验成功」;presenter 增详情迟到闸门。
This commit is contained in:
1 parent
81278f55af
commit
b394316df4
30 files changed
+658
-172
No files matched your search
@@ -1,5 +1,11 @@
|
||||
//! 「一键装入指定实例」(M109)。
|
||||
//!
|
||||
//! ## 网络只走 code 的在线内容源(M162 r1)
|
||||
//!
|
||||
//! 解析「哪个文件」用 `core::addon::online::Modrinth::versions`(同一个引擎传输层 ⇒ 信号量、
|
||||
//! 源健康度、退避都在引擎里),下载用 `online::download_verified`(sha1 + sha512 双检)。
|
||||
//! 本文件**不再**自己拼 `api.modrinth.com` 的 URL,也不再自己 `serde_json::from_str`。
|
||||
//!
|
||||
//! ## 它到底做了什么(诚实版)
|
||||
//!
|
||||
//! | 内容类型 | 做什么 | 状态 |
|
||||
@@ -19,20 +25,21 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::addon::online::{download_verified, EngineArtifactFetcher};
|
||||
use unlucky_core::addon::online::{
|
||||
download_verified, EngineArtifactFetcher, EngineTransport, Modrinth, VersionQuery,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::isolation::derive_game_dir;
|
||||
|
||||
use crate::dto::{
|
||||
loader_facet, ContentInstallRequest, ContentSource, InstallProgressView, InstallStage,
|
||||
InstanceSummary,
|
||||
loader_facet, ContentFailure, ContentFailureKind, ContentInstallRequest, ContentSource,
|
||||
InstallProgressView, InstallStage, InstanceSummary,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::http::{encode, get};
|
||||
use super::sources::MODRINTH;
|
||||
use super::sources::failure_of;
|
||||
|
||||
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
|
||||
pub(super) fn install(
|
||||
@@ -56,7 +63,7 @@ pub(super) fn install(
|
||||
fail(
|
||||
tx,
|
||||
progress,
|
||||
"整合包安装尚未开放;内容库对整合包只做检索",
|
||||
ContentFailure::unsupported("整合包安装尚未开放;内容库对整合包只做检索"),
|
||||
epoch,
|
||||
);
|
||||
return;
|
||||
@@ -77,32 +84,35 @@ pub(super) fn install(
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
Err(reason) => {
|
||||
Err(failure) => {
|
||||
progress.stage = if cancel.load(Ordering::SeqCst) {
|
||||
InstallStage::Cancelled
|
||||
} else {
|
||||
InstallStage::Failed
|
||||
};
|
||||
progress.message = failure.detail.clone();
|
||||
progress.failure_kind = Some(failure.kind);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(reason),
|
||||
result: Err(failure),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(tx: &JobSender, mut progress: InstallProgressView, reason: &str, epoch: u64) {
|
||||
fn fail(tx: &JobSender, mut progress: InstallProgressView, failure: ContentFailure, epoch: u64) {
|
||||
progress.stage = InstallStage::Failed;
|
||||
progress.message = reason.to_string();
|
||||
progress.message = failure.detail.clone();
|
||||
progress.failure_kind = Some(failure.kind);
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(reason.to_string()),
|
||||
result: Err(failure),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
|
||||
/// 真正干活的那一段,返回一句人读的落盘描述。
|
||||
/// 真正干活的那一段,返回一句人读的落盘描述(失败带**大类**,离线不等同于「查无此项」)。
|
||||
fn run(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
@@ -110,20 +120,20 @@ fn run(
|
||||
progress: &mut InstallProgressView,
|
||||
tx: &JobSender,
|
||||
epoch: u64,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, ContentFailure> {
|
||||
let instance = find_instance(env, &request.instance)?;
|
||||
let game_dir = game_dir_of(env, &instance)?;
|
||||
let dir = request
|
||||
.kind
|
||||
.dir()
|
||||
.ok_or_else(|| "该内容类型不支持单文件装入".to_string())?;
|
||||
.ok_or_else(|| local("该内容类型不支持单文件装入"))?;
|
||||
let target_dir = game_dir.join(dir);
|
||||
std::fs::create_dir_all(&target_dir)
|
||||
.map_err(|error| format!("建目录 {} 失败:{error}", target_dir.display()))?;
|
||||
.map_err(|error| local(format!("建目录 {} 失败:{error}", target_dir.display())))?;
|
||||
|
||||
let file = resolve_file(env, request)?;
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err("已取消(下载开始前)".to_string());
|
||||
return Err(local("已取消(下载开始前)"));
|
||||
}
|
||||
progress.stage = InstallStage::Downloading;
|
||||
progress.message = file.name.clone();
|
||||
@@ -145,22 +155,27 @@ fn run(
|
||||
file.size,
|
||||
&dest,
|
||||
))
|
||||
.map_err(|error| format!("下载失败:{error}"))?;
|
||||
.map_err(failure_of)?;
|
||||
Ok(format!("{}({})", file.name, dest.display()))
|
||||
}
|
||||
|
||||
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, String> {
|
||||
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
|
||||
fn local(reason: impl Into<String>) -> ContentFailure {
|
||||
ContentFailure::new(ContentFailureKind::Other, reason)
|
||||
}
|
||||
|
||||
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, ContentFailure> {
|
||||
super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| format!("找不到实例 {id}"))
|
||||
.ok_or_else(|| local(format!("找不到实例 {id}")))
|
||||
}
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
|
||||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, ContentFailure> {
|
||||
let global = super::super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
.map_err(|error| local(format!("读实例设置失败:{error}")))?;
|
||||
derive_game_dir(
|
||||
&env.game,
|
||||
&instance.version_id,
|
||||
@@ -169,7 +184,7 @@ fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, Str
|
||||
None,
|
||||
)
|
||||
.map(|derivation| derivation.game_dir)
|
||||
.map_err(|error| format!("隔离目录派生失败:{error}"))
|
||||
.map_err(|error| local(format!("隔离目录派生失败:{error}")))
|
||||
}
|
||||
|
||||
/// 要下载的那个文件(摘要两档都带着:mrpack 的必填摘要是 sha512)。
|
||||
@@ -182,72 +197,56 @@ struct RemoteFile {
|
||||
}
|
||||
|
||||
/// 解析「这个项目下最适合目标实例的那个文件」。
|
||||
fn resolve_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
|
||||
fn resolve_file(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
) -> Result<RemoteFile, ContentFailure> {
|
||||
match request.source {
|
||||
ContentSource::Modrinth => modrinth_file(env, request),
|
||||
ContentSource::Curseforge => Err(
|
||||
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)".to_string(),
|
||||
),
|
||||
ContentSource::Curseforge => Err(ContentFailure::unsupported(
|
||||
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
|
||||
let mut url = format!(
|
||||
"{MODRINTH}/project/{}/version?game_versions={}",
|
||||
request.project,
|
||||
encode(&format!("[\"{}\"]", request.game_version))
|
||||
);
|
||||
if let Some(facet) = loader_facet(request.loader) {
|
||||
url.push_str(&format!("&loaders={}", encode(&format!("[\"{facet}\"]"))));
|
||||
/// 版本列表 → 目标文件:走 core 的在线内容源(**本文件不再拼 URL、不再自己解析 JSON**)。
|
||||
///
|
||||
/// 选 `primary: true` 的那个(Modrinth 官方口径):直接取第一个会拿到 `sources.jar` 这类坑,
|
||||
/// `Version::primary_file()` 就是为这件事存在的。
|
||||
fn modrinth_file(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
) -> Result<RemoteFile, ContentFailure> {
|
||||
let core_engine = super::super::engine::engine(env);
|
||||
let transport = EngineTransport::new(&core_engine);
|
||||
let client = Modrinth::new(&transport);
|
||||
let versions = block_on(client.versions(&VersionQuery {
|
||||
project: request.project.clone(),
|
||||
game_version: Some(request.game_version.clone()),
|
||||
loader: loader_facet(request.loader).map(str::to_string),
|
||||
}))
|
||||
.map_err(failure_of)?;
|
||||
let version = versions.first().ok_or_else(|| {
|
||||
ContentFailure::new(
|
||||
ContentFailureKind::NotFound,
|
||||
"这个项目没有匹配目标实例(版本 / 加载器)的文件",
|
||||
)
|
||||
})?;
|
||||
let file = version
|
||||
.primary_file()
|
||||
.ok_or_else(|| ContentFailure::new(ContentFailureKind::NotFound, "版本里没有可用文件"))?;
|
||||
if file.url.trim().is_empty() {
|
||||
return Err(ContentFailure::new(
|
||||
ContentFailureKind::Unsupported,
|
||||
"版本里的主文件没有下载地址",
|
||||
));
|
||||
}
|
||||
let body = get(env, &url, &[])?;
|
||||
let json: serde_json::Value = serde_json::from_str(&body)
|
||||
.map_err(|error| format!("Modrinth 版本列表不是合法 JSON:{error}"))?;
|
||||
let versions = json
|
||||
.as_array()
|
||||
.ok_or_else(|| "Modrinth 版本列表不是数组".to_string())?;
|
||||
let first = versions
|
||||
.first()
|
||||
.ok_or_else(|| "这个项目没有匹配目标实例(版本 / 加载器)的文件".to_string())?;
|
||||
let files = first
|
||||
.get("files")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "版本里没有 files".to_string())?;
|
||||
// 显式选 `primary: true`(Modrinth 官方口径):取第一个会拿到 sources jar 这类坑
|
||||
let file = files
|
||||
.iter()
|
||||
.find(|file| {
|
||||
file.get("primary")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.or_else(|| files.first())
|
||||
.ok_or_else(|| "版本里没有可用文件".to_string())?;
|
||||
|
||||
let url = file
|
||||
.get("url")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| "文件没有 url".to_string())?;
|
||||
let name = file
|
||||
.get("filename")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| "文件没有 filename".to_string())?;
|
||||
let digest = |algorithm: &str| {
|
||||
file.get("hashes")
|
||||
.and_then(|hashes| hashes.get(algorithm))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|hex| !hex.trim().is_empty())
|
||||
.map(ToString::to_string)
|
||||
};
|
||||
Ok(RemoteFile {
|
||||
url: url.to_string(),
|
||||
name: safe_file_name(name),
|
||||
sha1: digest("sha1"),
|
||||
sha512: digest("sha512"),
|
||||
size: file
|
||||
.get("size")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.filter(|size| *size > 0),
|
||||
url: file.url.clone(),
|
||||
name: safe_file_name(&file.filename),
|
||||
sha1: file.sha1().map(ToString::to_string),
|
||||
sha512: file.sha512().map(ToString::to_string),
|
||||
size: (file.size > 0).then_some(file.size),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ use super::http::{encode, get};
|
||||
use unlucky_core::addon::online::EngineTransport;
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
/// Modrinth v2 的根(无需 key)。
|
||||
pub(super) const MODRINTH: &str = "https://api.modrinth.com/v2";
|
||||
// Modrinth 的根不再在本文件:URL 拼装与解析都在 `core::addon::online`,这里只留 CF 的
|
||||
// (它要 `X-API-KEY`,是 core 那一侧**故意**没接的那半)。
|
||||
/// CurseForge v1 的根(需要 `X-API-KEY`)。
|
||||
pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1";
|
||||
/// CurseForge 的「Minecraft」gameId(官方固定值)。
|
||||
|
||||
Reference in new issue
Block a user