Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
首个 iced + Material 3 Expressive 版本的源码快照。 完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -0,0 +1,123 @@
|
||||
//! HTTP 取文本(内容库唯一的网络出口)。
|
||||
//!
|
||||
//! ## 两条路径,为什么不是一条
|
||||
//!
|
||||
//! | 请求 | 走哪 | 拿到什么 |
|
||||
//! |---|---|---|
|
||||
//! | 无自定义 header(Modrinth 的全部请求) | [`DownloadEngine::download_bytes`] | **信号量限流 + 源健康度 + 指数退避 + 分类错误**,全是 core 现成的 |
|
||||
//! | 带 header(只有 CurseForge 的 `X-API-KEY`) | `engine.client()` 直发 | 引擎的请求体没有 header 字段,只能自己发;代价是这条路径自己带一个 429/5xx 退避 |
|
||||
//!
|
||||
//! ## 密钥纪律
|
||||
//!
|
||||
//! 带 header 的那条路径**只把 header 值喂给请求**:不进日志、不进错误串。错误串里只有 URL
|
||||
//! (CF 的 URL 不含密钥)与状态码。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::{DownloadRequest, ResourceKind};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
|
||||
/// 站点要求的 User-Agent(Modrinth 明文要求带联系方式,否则限流)。
|
||||
pub(super) const USER_AGENT: &str =
|
||||
"Unlucky-Launcher/0.1 (+https://github.com/AskaEth/Unlucky-Launcher)";
|
||||
|
||||
/// 最多重试次数(含首次)。
|
||||
const ATTEMPTS: u32 = 3;
|
||||
|
||||
/// 取一段文本(JSON 原文)。
|
||||
pub(super) fn get(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result<String, String> {
|
||||
if headers.is_empty() {
|
||||
get_via_engine(env, url)
|
||||
} else {
|
||||
get_with_headers(env, url, headers)
|
||||
}
|
||||
}
|
||||
|
||||
/// core 的下载引擎:限流与退避都在里面,这里只负责「URL → 文本」。
|
||||
fn get_via_engine(env: &HostEnv, url: &str) -> Result<String, String> {
|
||||
let engine = super::super::engine::engine(env);
|
||||
let dir = env.layout.cache().join("content");
|
||||
std::fs::create_dir_all(&dir).map_err(|error| format!("建缓存目录失败:{error}"))?;
|
||||
let request = DownloadRequest::new(ResourceKind::Other, url, dir.join("response.json"));
|
||||
let bytes = block_on(engine.download_bytes(request))
|
||||
.map_err(|error| format!("请求 {url} 失败:{error}"))?;
|
||||
String::from_utf8(bytes).map_err(|error| format!("响应不是 UTF-8:{error}"))
|
||||
}
|
||||
|
||||
/// 带 header 的请求(只有 CF 走这条):429 / 5xx / 连接类失败按退避重试。
|
||||
fn get_with_headers(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result<String, String> {
|
||||
let engine = super::super::engine::engine(env);
|
||||
let owned: Vec<(String, String)> = headers
|
||||
.iter()
|
||||
.map(|(name, value)| ((*name).to_string(), (*value).to_string()))
|
||||
.collect();
|
||||
|
||||
for attempt in 0..ATTEMPTS {
|
||||
let result = block_on(async {
|
||||
let mut builder = engine.client().get(url).header("User-Agent", USER_AGENT);
|
||||
for (name, value) in &owned {
|
||||
builder = builder.header(name.as_str(), value.as_str());
|
||||
}
|
||||
let response = builder.send().await.map_err(|error| error.to_string())?;
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
return response.text().await.map_err(|error| error.to_string());
|
||||
}
|
||||
let retry = response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
Err(format!("HTTP {} (retry_after={retry:?})", status.as_u16()))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(body) => return Ok(body),
|
||||
Err(reason) if attempt + 1 < ATTEMPTS && retryable(&reason) => {
|
||||
std::thread::sleep(retry_after(&reason, attempt));
|
||||
}
|
||||
Err(reason) => return Err(format!("请求 {url} 失败:{reason}")),
|
||||
}
|
||||
}
|
||||
Err(format!("请求 {url} 失败:重试用尽"))
|
||||
}
|
||||
|
||||
/// 这个失败值得再试一次吗(429 / 5xx / 连接类;**401/403/404 一律不重试**,重试只会更糟)。
|
||||
pub(super) fn retryable(reason: &str) -> bool {
|
||||
reason.contains("HTTP 429")
|
||||
|| reason.contains("HTTP 500")
|
||||
|| reason.contains("HTTP 502")
|
||||
|| reason.contains("HTTP 503")
|
||||
|| reason.contains("HTTP 504")
|
||||
|| reason.contains("error sending request")
|
||||
|| reason.contains("timed out")
|
||||
}
|
||||
|
||||
/// 退避时长:优先吃服务端给的 `Retry-After`(封顶 30s),否则指数退避(1s、2s、4s)。
|
||||
pub(super) fn retry_after(reason: &str, attempt: u32) -> Duration {
|
||||
let parsed = reason
|
||||
.split("retry_after=Some(")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split(')').next())
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
match parsed {
|
||||
Some(secs) => Duration::from_secs(secs.min(30)),
|
||||
None => Duration::from_secs(1u64 << attempt.min(4)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 最小百分号编码(query 串用;不为一件小事引第三方 crate)。
|
||||
pub(super) fn encode(raw: &str) -> String {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
for byte in raw.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(char::from(byte));
|
||||
}
|
||||
_ => out.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! 「一键装入指定实例」(M109)。
|
||||
//!
|
||||
//! ## 它到底做了什么(诚实版)
|
||||
//!
|
||||
//! | 内容类型 | 做什么 | 状态 |
|
||||
//! |---|---|---|
|
||||
//! | Mod / 资源包 / 光影 | 解析「该项目下匹配目标实例(版本 + 加载器)的那个文件」→ core 下载引擎下载到实例运行目录的 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流(**复用下载页那块面板**) | 已接 |
|
||||
//! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) |
|
||||
//! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** |
|
||||
//!
|
||||
//! ## 为什么进度只有阶段级,没有字节条
|
||||
//!
|
||||
//! 字节级进度要走 `ProgressEmitter`(core 的 tokio 广播),而 presenter 的依赖表里没有
|
||||
//! `tokio` / `futures-util`(`Cargo.toml` 不在本 mission 的 scope),拿不到接收端的 `recv()`。
|
||||
//! 所以这里给的是**阶段 + 文件名**(`Planning` → `Downloading` → `Done`/`Failed`),
|
||||
//! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。
|
||||
//! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::download::{DownloadRequest, ResourceKind};
|
||||
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,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::http::{encode, get};
|
||||
use super::sources::MODRINTH;
|
||||
|
||||
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
|
||||
pub(super) fn install(
|
||||
env: &HostEnv,
|
||||
tx: &JobSender,
|
||||
request: &ContentInstallRequest,
|
||||
cancel: &AtomicBool,
|
||||
epoch: u64,
|
||||
) {
|
||||
let mut progress = InstallProgressView {
|
||||
version: request.title.clone(),
|
||||
instance: request.instance.clone(),
|
||||
loader: request.loader.label().to_string(),
|
||||
files_total: 1,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
|
||||
// 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的,
|
||||
// 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。
|
||||
if request.kind.dir().is_none() {
|
||||
fail(
|
||||
tx,
|
||||
progress,
|
||||
"整合包安装尚未开放;内容库对整合包只做检索",
|
||||
epoch,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
match run(env, request, cancel, &mut progress, tx, epoch) {
|
||||
Ok(done) => {
|
||||
progress.stage = InstallStage::Done;
|
||||
progress.files_done = 1;
|
||||
progress.message = done;
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Ok(request.title.clone()),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
Err(reason) => {
|
||||
progress.stage = if cancel.load(Ordering::SeqCst) {
|
||||
InstallStage::Cancelled
|
||||
} else {
|
||||
InstallStage::Failed
|
||||
};
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(reason),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(tx: &JobSender, mut progress: InstallProgressView, reason: &str, epoch: u64) {
|
||||
progress.stage = InstallStage::Failed;
|
||||
progress.message = reason.to_string();
|
||||
let _ = tx.send(HostTask::InstallDone {
|
||||
progress: Box::new(progress),
|
||||
result: Err(reason.to_string()),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
|
||||
/// 真正干活的那一段,返回一句人读的落盘描述。
|
||||
fn run(
|
||||
env: &HostEnv,
|
||||
request: &ContentInstallRequest,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut InstallProgressView,
|
||||
tx: &JobSender,
|
||||
epoch: u64,
|
||||
) -> Result<String, String> {
|
||||
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())?;
|
||||
let target_dir = game_dir.join(dir);
|
||||
std::fs::create_dir_all(&target_dir)
|
||||
.map_err(|error| format!("建目录 {} 失败:{error}", target_dir.display()))?;
|
||||
|
||||
let file = resolve_file(env, request)?;
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err("已取消(下载开始前)".to_string());
|
||||
}
|
||||
progress.stage = InstallStage::Downloading;
|
||||
progress.message = file.name.clone();
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
|
||||
let dest = target_dir.join(&file.name);
|
||||
let mut download = DownloadRequest::new(ResourceKind::ModFile, file.url, dest.clone());
|
||||
if let Some(sha1) = file.sha1.as_deref().filter(|hex| !hex.is_empty()) {
|
||||
download = download.with_sha1(sha1);
|
||||
}
|
||||
block_on(super::super::engine::engine(env).download(download))
|
||||
.map_err(|error| format!("下载失败:{error}"))?;
|
||||
Ok(format!("{}({})", file.name, dest.display()))
|
||||
}
|
||||
|
||||
fn find_instance(env: &HostEnv, id: &str) -> Result<InstanceSummary, String> {
|
||||
super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| format!("找不到实例 {id}"))
|
||||
}
|
||||
|
||||
/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。
|
||||
fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result<PathBuf, String> {
|
||||
let global = super::super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("读实例设置失败:{error}"))?;
|
||||
derive_game_dir(
|
||||
&env.game,
|
||||
&instance.version_id,
|
||||
global,
|
||||
config.as_ref(),
|
||||
None,
|
||||
)
|
||||
.map(|derivation| derivation.game_dir)
|
||||
.map_err(|error| format!("隔离目录派生失败:{error}"))
|
||||
}
|
||||
|
||||
/// 要下载的那个文件。
|
||||
struct RemoteFile {
|
||||
url: String,
|
||||
name: String,
|
||||
sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// 解析「这个项目下最适合目标实例的那个文件」。
|
||||
fn resolve_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
|
||||
match request.source {
|
||||
ContentSource::Modrinth => modrinth_file(env, request),
|
||||
ContentSource::Curseforge => Err(
|
||||
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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}\"]"))));
|
||||
}
|
||||
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 sha1 = file
|
||||
.get("hashes")
|
||||
.and_then(|hashes| hashes.get("sha1"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
Ok(RemoteFile {
|
||||
url: url.to_string(),
|
||||
name: safe_file_name(name),
|
||||
sha1,
|
||||
})
|
||||
}
|
||||
|
||||
/// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉
|
||||
/// (与 `core::addon::pack::path` 的穿越双检同一条纪律)。
|
||||
pub(super) fn safe_file_name(raw: &str) -> String {
|
||||
let name = raw
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(raw)
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
"download.bin".to_string()
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! 整合包**导入 / 导出**(M113 `PACKUI`):把 M111/M112 的管线接到内容库页上。
|
||||
//!
|
||||
//! ## 这里只做「解析目标 + 翻译 + 转发」
|
||||
//!
|
||||
//! 真正的活全在 core:`addon::import::import_pack`(三种格式 → 实例目录)与
|
||||
//! `addon::export::{scan_instance, export_instance}`(实例目录 → `.mrpack` / CF 清单)。
|
||||
//! 本模块不重新判定任何东西——三态、逐项原因、三分类全部**逐字段照搬** core 的报告
|
||||
//! ([`convert`]),于是「UI 说的」与「管线干的」是同一份事实。
|
||||
//!
|
||||
//! ## 文件分工(守 400 行)
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | 本文件 | `CoreHost::pack` 落点 + 取消标记 + 三条 spawn 共用的目标解析 |
|
||||
//! | [`import`] | 后台导入(探测 → 目标目录 → 管线 → 报告) |
|
||||
//! | [`export`] | 后台预检(不写盘)与后台写盘 |
|
||||
//! | [`convert`] | core 报告 → DTO(逐字段照搬)+ 小工具 |
|
||||
//! | [`diagnose`] | 探测诊断:「看起来像 X,但缺 Y」 |
|
||||
//!
|
||||
//! ## 导出预检与写盘用同一套判据
|
||||
//!
|
||||
//! core 的 `export_instance` 没有 dry-run 入口(已报 finding)。预检用 `scan_instance` +
|
||||
//! 「离线目录 ⇒ 一律内联」这条事实拼出来:`RemoteCatalog` 目前只有 `OfflineCatalog`,
|
||||
//! 所以每个可扫文件都证明不了可被远程替代 ⇒ 内联;`SkippedLocal` 就是「无法导出」。
|
||||
//! `tests::preview_counts_match_the_written_report` 用真管线钉住这个等式。
|
||||
//!
|
||||
//! ## 取消是**界面级**的
|
||||
//!
|
||||
//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1:
|
||||
//!
|
||||
//! 1. 面板立刻收回(不是等后台回一句话);
|
||||
//! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉;
|
||||
//! 3. **后台那次导入会继续跑到结束**——core 的 `ImportRequest` 没有取消令牌,进度回调也拿不到
|
||||
//! 控制流。所以「取消」的真实含义是「不再显示、结果作废」,不是「把它停手」。要真停必须给
|
||||
//! core 加 cancel token(已登记 finding,报告里同样划出来);
|
||||
//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):下载页的取消 toast 写着「实例未创建」,
|
||||
//! 而导入会把实例**登记上**。所以 `cancel_pack()` 回一个「刚才有没有导入在跑」,
|
||||
//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`],界面上换成
|
||||
//! 「导入会在后台继续跑完 —— 本次结果作废」这句。
|
||||
|
||||
mod convert;
|
||||
mod diagnose;
|
||||
mod export;
|
||||
mod import;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use unlucky_core::addon::export::{read_instance_snapshot, InstanceSnapshot};
|
||||
use unlucky_core::addon::pack::PackFormat;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::isolation::derive_game_dir;
|
||||
|
||||
use crate::dto::{
|
||||
CoreEvent, ExportRequestView, ImportProgressView, InstanceId, PackCommand, PackEvent,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
|
||||
/// pack 的**每 host** 状态:代次 + 在跑计数 + 本次运行的取消标记。
|
||||
///
|
||||
/// ## 为什么不是裸静态量(复审 r2 P3-6 要的那句回答)
|
||||
///
|
||||
/// 裸静态量是**跨 host 共享**的。生产路径只有一个 host 所以看不出来,但测试里两个 host 会互相
|
||||
/// 干扰 —— 本轮实测撞到过:`host/cancel_tests` 派一次 `CancelInstall` 就把我这条用例在飞的导入
|
||||
/// 事件按代次丢了。键取 `LauncherLayout::home()`(每个测试的临时根都不同),于是各自独立:
|
||||
///
|
||||
/// - **代次**:每个 host 一条,`handle` 只跟**自己这个 host** 的当前值比;
|
||||
/// - **在跑计数**:一张计数表(每个 host 一格),由 [`InFlight`] 的 `Drop` 维护;
|
||||
/// - **取消标记**:每个 host 一份 `Arc<AtomicBool>`,导入线程拿克隆,`cancel_pack` 置位。
|
||||
///
|
||||
/// 为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在本 mission 的 scope)。这张表等价,
|
||||
/// 只是把「每 host 的字段」放在模块里;`host/mod.rs` 一旦进 scope,搬过去只是把这张表删掉。
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct HostPackState {
|
||||
epoch: u64,
|
||||
in_flight: usize,
|
||||
}
|
||||
|
||||
static PACK_STATE: Mutex<Vec<(PathBuf, HostPackState)>> = Mutex::new(Vec::new());
|
||||
static PACK_CANCEL: Mutex<Vec<(PathBuf, Arc<AtomicBool>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// 这张表的键(数据根;`HostEnv::at_root` 的每个测试根都不同)。
|
||||
fn host_key(env: &HostEnv) -> PathBuf {
|
||||
env.layout.home().to_path_buf()
|
||||
}
|
||||
|
||||
fn with_state<T>(env: &HostEnv, body: impl FnOnce(&mut HostPackState) -> T) -> T {
|
||||
let key = host_key(env);
|
||||
let mut table = PACK_STATE
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
if let Some(index) = table.iter().position(|(name, _)| *name == key) {
|
||||
return body(&mut table[index].1);
|
||||
}
|
||||
table.push((key, HostPackState::default()));
|
||||
body(&mut table.last_mut().expect("刚插入").1)
|
||||
}
|
||||
|
||||
/// 这个 host 当前的 pack 代次(`handle` 用它判断事件是否已被取消/关面板作废)。
|
||||
pub(in crate::host) fn pack_epoch(env: &HostEnv) -> u64 {
|
||||
with_state(env, |state| state.epoch)
|
||||
}
|
||||
|
||||
/// 作废这个 host 在飞的一切 pack 事件(取消 / 关面板都走它)。
|
||||
pub(in crate::host) fn bump_pack_epoch(env: &HostEnv) -> u64 {
|
||||
with_state(env, |state| {
|
||||
state.epoch = state.epoch.wrapping_add(1);
|
||||
state.epoch
|
||||
})
|
||||
}
|
||||
|
||||
/// 这个 host 现在有几条导入在跑。
|
||||
pub(in crate::host) fn in_flight(env: &HostEnv) -> usize {
|
||||
with_state(env, |state| state.in_flight)
|
||||
}
|
||||
|
||||
/// 「这次导入还在跑」的 **RAII 句柄**。
|
||||
///
|
||||
/// 复审 r2 P2-1 的教训:手工 `finished()` 只在 `block_on(import_pack(..))` 之后调用,
|
||||
/// 而失败早退(探测失败 / 目标目录不可用)都在它之前 ⇒ 计数永远留在「在跑」,于是下一次
|
||||
/// **下载页**的「取消安装」不再发 `InstallCancelled`,还给内容库画一句「导入还在后台跑」的假话。
|
||||
///
|
||||
/// 现在计数由句柄的 `Drop` 负责:**任何**退出路径(早退、报错、`?`、panic 展开)都会减一。
|
||||
pub(super) struct InFlight {
|
||||
env: HostEnv,
|
||||
}
|
||||
|
||||
impl InFlight {
|
||||
/// 计一次「在跑」。句柄活多久,这次导入就算跑多久。
|
||||
pub(super) fn begin(env: &HostEnv) -> Self {
|
||||
with_state(env, |state| state.in_flight += 1);
|
||||
Self { env: env.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InFlight {
|
||||
fn drop(&mut self) {
|
||||
with_state(&self.env, |state| {
|
||||
state.in_flight = state.in_flight.saturating_sub(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消这个 host 正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。
|
||||
///
|
||||
/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语:**导入的取消不是 install 的取消**
|
||||
/// (后台会跑完并登记实例,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。
|
||||
pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool {
|
||||
let key = host_key(env);
|
||||
{
|
||||
let table = PACK_CANCEL
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
if let Some((_, cancel)) = table.iter().find(|(name, _)| *name == key) {
|
||||
cancel.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
bump_pack_epoch(env);
|
||||
in_flight(env) > 0
|
||||
}
|
||||
|
||||
/// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。
|
||||
fn fresh_cancel(env: &HostEnv) -> Arc<AtomicBool> {
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let key = host_key(env);
|
||||
let mut table = PACK_CANCEL
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
match table.iter_mut().find(|(name, _)| *name == key) {
|
||||
Some((_, slot)) => *slot = Arc::clone(&cancel),
|
||||
None => table.push((key, Arc::clone(&cancel))),
|
||||
}
|
||||
cancel
|
||||
}
|
||||
|
||||
/// `AppCommand::Pack` 的落点(M113)。
|
||||
impl super::super::CoreHost {
|
||||
pub(crate) fn pack(&mut self, command: PackCommand) {
|
||||
let epoch = pack_epoch(&self.env);
|
||||
match command {
|
||||
PackCommand::Import(request) => {
|
||||
let cancel = fresh_cancel(&self.env);
|
||||
// 句柄在**派发点**就计上,随线程一起结束(含早退与 panic)
|
||||
let guard = InFlight::begin(&self.env);
|
||||
// 先把面板点亮:用户点了就有反应,不等后台回第一条进度
|
||||
let name = request
|
||||
.instance_name
|
||||
.clone()
|
||||
.or_else(|| request.instance.clone())
|
||||
.unwrap_or_default();
|
||||
self.send(CoreEvent::Pack(Box::new(PackEvent::Progress(Some(
|
||||
Box::new(ImportProgressView::new(name)),
|
||||
)))));
|
||||
import::spawn_import(
|
||||
self.env.clone(),
|
||||
self.tx.clone(),
|
||||
*request,
|
||||
epoch,
|
||||
cancel,
|
||||
guard,
|
||||
);
|
||||
}
|
||||
PackCommand::Export(request) => {
|
||||
export::spawn_preview(self.env.clone(), self.tx.clone(), *request, epoch);
|
||||
}
|
||||
PackCommand::Confirm(request) => {
|
||||
export::spawn_write(self.env.clone(), self.tx.clone(), *request, epoch);
|
||||
}
|
||||
PackCommand::Dismiss => {
|
||||
// 只作废 pack 自己的代次:**不碰** `install_epoch`(那是下载页安装的命门)
|
||||
cancel_pack(&self.env);
|
||||
self.send(CoreEvent::Pack(Box::new(PackEvent::Cleared)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台任务 → host 的一跳(三个 spawn 共用)。
|
||||
pub(super) fn send(tx: &JobSender, epoch: u64, event: PackEvent) {
|
||||
let _ = tx.send(HostTask::Pack {
|
||||
event: Box::new(event),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
|
||||
/// 实例 → 运行目录(与 `addon/install.rs` 同一条派生:实例隔离档 > 全局档)。
|
||||
pub(super) fn instance_root(env: &HostEnv, id: &InstanceId) -> Result<PathBuf, String> {
|
||||
let instance = super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| &instance.id == id)
|
||||
.ok_or_else(|| format!("instance not found: {id}"))?;
|
||||
let global = super::super::game::global_mode(env);
|
||||
let config = read_instance_config(&env.layout, &instance.id)
|
||||
.map_err(|error| format!("read instance config failed: {error}"))?;
|
||||
derive_game_dir(
|
||||
&env.game,
|
||||
&instance.version_id,
|
||||
global,
|
||||
config.as_ref(),
|
||||
None,
|
||||
)
|
||||
.map(|derivation| derivation.game_dir)
|
||||
.map_err(|error| format!("derive game dir failed: {error}"))
|
||||
}
|
||||
|
||||
/// 实例快照(清单的 `game` + `dependencies` 从它来)。读不出来就返回 `None`,
|
||||
/// 由 core 自己按 `root/versions/` 再试一次(失败会给具名错误)。
|
||||
pub(super) fn snapshot_of(env: &HostEnv, id: &InstanceId) -> Option<InstanceSnapshot> {
|
||||
let instance = super::super::game::instances(env)
|
||||
.into_iter()
|
||||
.find(|instance| &instance.id == id)?;
|
||||
// 隔离实例的 `versions/` 在全局根下,所以试全局根
|
||||
read_instance_snapshot(env.game.root(), Some(&instance.version_id)).ok()
|
||||
}
|
||||
|
||||
/// 格式视图 → core 的静态格式(MultiMC 只读,不能当导出目标)。
|
||||
pub(super) fn format_of(format: PackFormatView) -> Result<&'static dyn PackFormat, String> {
|
||||
use unlucky_core::addon::pack::{curseforge, modrinth};
|
||||
match format {
|
||||
PackFormatView::Modrinth => Ok(&modrinth::MODRINTH),
|
||||
PackFormatView::Curseforge => Ok(&curseforge::CURSEFORGE),
|
||||
PackFormatView::Multimc => {
|
||||
Err("MultiMC / Prism can only be imported, not exported".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 缺省落盘路径:`<data>/exports/<name>-<version>.<ext>`。
|
||||
pub(super) fn default_export_path(env: &HostEnv, request: &ExportRequestView) -> PathBuf {
|
||||
let ext = match request.format {
|
||||
PackFormatView::Curseforge => "zip",
|
||||
_ => "mrpack",
|
||||
};
|
||||
let name = format!(
|
||||
"{}-{}",
|
||||
convert::slug(&request.name),
|
||||
convert::slug(&request.version)
|
||||
);
|
||||
env.layout
|
||||
.data()
|
||||
.join("exports")
|
||||
.join(format!("{name}.{ext}"))
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! core 报告 → DTO(M113):**逐字段照搬,不重算任何结论**。
|
||||
//!
|
||||
//! 这一层的唯一职责是翻译:三态、五阶段、四种处置、三种冲突成因、十一种导出原因,
|
||||
//! 全部一一对应。任何一个「顺手换算一下」都会让 UI 说的和管线干的不是一回事,
|
||||
//! 所以这里没有一处 re-derive(唯一例外是把 `&'static str` 的稳定码换成人读串)。
|
||||
//!
|
||||
//! 翻译用的是 core 的稳定 slug(`as_str()`)+ DTO 的 `from_slug()`,
|
||||
//! 于是「翻译表」本身就是一份可审计的清单:core 加了新变体,`from_slug` 返回 `None`
|
||||
//! 时这里会落到一个有名字的兜底,而不是悄悄画成别的意思。
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
use crate::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportDoneView,
|
||||
ExportReasonView, FileActionView, ImportErrorView, ImportProgressView, ImportReportView,
|
||||
ImportStageView, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
/// 一次导入结果 → DTO。
|
||||
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::from_slug(report.status.as_str())
|
||||
.unwrap_or(ImportStatusView::Failed),
|
||||
format: report.format.as_deref().and_then(PackFormatView::from_slug),
|
||||
pack_name: report.pack_name.clone(),
|
||||
pack_version: report.pack_version.clone(),
|
||||
game_version: report.game_version.clone(),
|
||||
loader: report.loader.as_ref().map(loader_label),
|
||||
version_id: report.version_id.clone(),
|
||||
target: target.to_path_buf(),
|
||||
files: report
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| ImportedFileView {
|
||||
path: file.path.clone(),
|
||||
action: action_view(file.action),
|
||||
bytes: file.bytes,
|
||||
})
|
||||
.collect(),
|
||||
conflicts: report.conflicts.iter().map(conflict_view).collect(),
|
||||
missing: report
|
||||
.missing
|
||||
.iter()
|
||||
.map(|file| MissingFileView {
|
||||
path: file.path.clone(),
|
||||
display: file.display.clone(),
|
||||
reason: missing_view(file.reason),
|
||||
detail: file.detail.clone(),
|
||||
})
|
||||
.collect(),
|
||||
rejected: report
|
||||
.rejected_components
|
||||
.iter()
|
||||
.map(|component| match &component.version {
|
||||
Some(version) => format!("{} {version}", component.uid),
|
||||
None => component.uid.clone(),
|
||||
})
|
||||
.collect(),
|
||||
warnings: report
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|issue| issue.message.clone())
|
||||
.collect(),
|
||||
error: report.error.as_ref().map(error_view),
|
||||
registered: report
|
||||
.registered
|
||||
.as_ref()
|
||||
.map(|registered| registered.name.clone()),
|
||||
bytes_written: report.bytes_written,
|
||||
rolled_back: report.rolled_back,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次导出结果 → DTO。
|
||||
pub(super) fn done_view(report: &ExportReport) -> ExportDoneView {
|
||||
ExportDoneView {
|
||||
path: report.path.clone(),
|
||||
bytes: report.bytes,
|
||||
remote: u32::try_from(report.remote_count()).unwrap_or(u32::MAX),
|
||||
inline: u32::try_from(report.inline_count()).unwrap_or(u32::MAX),
|
||||
skipped: u32::try_from(report.skipped_count()).unwrap_or(u32::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条导入进度 → DTO。
|
||||
pub(super) fn progress_view(progress: &ImportProgress, pack: &str) -> ImportProgressView {
|
||||
ImportProgressView {
|
||||
stage: ImportStageView::from_slug(progress.stage.as_str())
|
||||
.unwrap_or(ImportStageView::Probe),
|
||||
done: u32::try_from(progress.done).unwrap_or(u32::MAX),
|
||||
total: u32::try_from(progress.total).unwrap_or(u32::MAX),
|
||||
current: progress.current.clone(),
|
||||
pack: pack.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 致命失败 → DTO(稳定码原样带走,日志与 `--json` 用的是同一串)。
|
||||
fn error_view(error: &ImportError) -> ImportErrorView {
|
||||
ImportErrorView {
|
||||
code: error.kind().as_str().to_string(),
|
||||
label: error.kind().label().to_string(),
|
||||
message: error.message().to_string(),
|
||||
hint: error.hint().map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
pack_sha1: file.pack_sha1.clone(),
|
||||
base_sha1: file.base_sha1.clone(),
|
||||
disk_sha1: file.disk_sha1.clone(),
|
||||
pack_size: file.pack_size,
|
||||
}
|
||||
}
|
||||
|
||||
const fn action_view(action: FileAction) -> FileActionView {
|
||||
match action {
|
||||
FileAction::Installed => FileActionView::Installed,
|
||||
FileAction::Replaced => FileActionView::Replaced,
|
||||
FileAction::SkippedIdentical => FileActionView::SkippedIdentical,
|
||||
FileAction::KeptUser => FileActionView::KeptUser,
|
||||
}
|
||||
}
|
||||
|
||||
const fn missing_view(reason: MissingReason) -> MissingReasonView {
|
||||
match reason {
|
||||
MissingReason::Offline => MissingReasonView::Offline,
|
||||
MissingReason::NoSource => MissingReasonView::NoSource,
|
||||
MissingReason::FetchFailed => MissingReasonView::FetchFailed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reason_view(reason: FileReason) -> ExportReasonView {
|
||||
ExportReasonView::from_slug(reason.as_str()).unwrap_or(ExportReasonView::Unreadable)
|
||||
}
|
||||
|
||||
pub(super) fn class_view(class: FileClass) -> ExportClassView {
|
||||
match class {
|
||||
FileClass::Remote => ExportClassView::Remote,
|
||||
FileClass::Inline => ExportClassView::Inline,
|
||||
FileClass::Skipped => ExportClassView::Skipped,
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器 → 人读串(`fabric-loader 0.15.11` / `vanilla 1.20.1`)。
|
||||
fn loader_label(target: &LoaderTarget) -> String {
|
||||
format!("{} {}", target.kind.as_str(), target.loader_version)
|
||||
}
|
||||
|
||||
/// 「命令跑起来了,但目标实例不可用」这类**host 侧**失败的报告。
|
||||
///
|
||||
/// 仍然走三态里的 `Failed`:界面只有一种失败形状,用户不用学第二套。
|
||||
/// `target` 是**目标实例目录**(不知道就给空路径),不是包路径(复审 r1 P2-9 顺手纠的 DTO 语义)。
|
||||
pub(super) fn failed_report(
|
||||
target: PathBuf,
|
||||
pack: &Path,
|
||||
code: &str,
|
||||
label: &str,
|
||||
message: &str,
|
||||
) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::Failed,
|
||||
format: None,
|
||||
pack_name: Some(pack_stem(pack)),
|
||||
pack_version: None,
|
||||
game_version: None,
|
||||
loader: None,
|
||||
version_id: None,
|
||||
target,
|
||||
files: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
missing: Vec::new(),
|
||||
rejected: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
error: Some(ImportErrorView {
|
||||
code: code.to_string(),
|
||||
label: label.to_string(),
|
||||
message: message.to_string(),
|
||||
hint: None,
|
||||
}),
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 包文件名(`Better MC.mrpack` → `Better MC`)。
|
||||
pub(super) fn pack_stem(pack: &Path) -> String {
|
||||
pack.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "imported-pack".to_string())
|
||||
}
|
||||
|
||||
/// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。
|
||||
///
|
||||
/// 判据(复审 r2 P2-2 之后与注释对齐):名字里**只含** ASCII 字母数字、空格、`-`、`_` 时
|
||||
/// 保持原样(空格折成 `-`,`Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、
|
||||
/// 括号……)就补 `-<hash8>`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」
|
||||
/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。
|
||||
pub(super) fn dir_name(name: &str) -> String {
|
||||
let base = slug(name);
|
||||
let faithful = !name.is_empty()
|
||||
&& name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ' ');
|
||||
if faithful {
|
||||
base
|
||||
} else {
|
||||
format!("{base}-{}", hash8(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 64 → 8 位十六进制(只要稳定、短、不需要密码学强度)。
|
||||
fn hash8(text: &str) -> String {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in text.as_bytes() {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{:08x}", hash as u32)
|
||||
}
|
||||
|
||||
/// 目录/文件名用的 slug(只留 ASCII 字母数字与 `-` / `_`,其余折成 `-`)。
|
||||
pub(super) fn slug(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for ch in text.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
out.push(ch);
|
||||
} else if !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let trimmed = out.trim_matches('-').to_string();
|
||||
if trimmed.is_empty() {
|
||||
"pack".to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 探测诊断(M113):「这个包**看起来像** X,但**缺** Y」。
|
||||
//!
|
||||
//! ## 为什么由 host 判,不引 core 改动
|
||||
//!
|
||||
//! core 的 `PackRegistry::detect` 只回答「谁认领」,一个都不认领时只给一句
|
||||
//! `pack_unsupported` + 「试过哪些格式」。而用户手里那个包往往**差一点点**就是某个已知格式
|
||||
//! (CF 导出漏了 `manifest.json`、mrpack 被浏览器改名成 `.zip` 后解压丢了 index)。
|
||||
//! 判断「像哪个」要看包内到底有哪些标记文件,这是宿主层拿着 `PackArchive` 才看得见的事。
|
||||
//!
|
||||
//! ## 判据只有标记文件的有无(不看文件名)
|
||||
//!
|
||||
//! | 格式 | 标记 | 邻居(有它但没标记 ⇒ 「像它」) |
|
||||
//! |---|---|---|
|
||||
//! | CurseForge | `manifest.json` | `overrides/` · `mods/` |
|
||||
//! | Modrinth | `modrinth.index.json` | `overrides/` |
|
||||
//! | MultiMC / Prism | `mmc-pack.json` | `instance.cfg` · `.minecraft/` |
|
||||
//!
|
||||
//! `saw` 只留前 5 条有代表性的标记:长路径往界面里倒会把版面撑破(塔台复审提过)。
|
||||
//! 一个标记都没认出来时 `saw` 为空,由界面显示「nothing recognisable」而不是一行空白。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::addon::pack::{PackArchive, PackLimits, PackRegistry};
|
||||
|
||||
use crate::dto::{PackFormatView, ProbeView};
|
||||
|
||||
/// 诊断一个包(`None` = 有个格式认领它,正常走管线)。
|
||||
pub(super) fn of(pack: &Path) -> Option<ProbeView> {
|
||||
let archive = match PackArchive::open(pack, PackLimits::default()) {
|
||||
Ok(archive) => archive,
|
||||
Err(error) => {
|
||||
// 连 zip 都打不开(选错文件 / 下了一半):也要说清楚是哪一步失败的
|
||||
return Some(ProbeView {
|
||||
looks_like: None,
|
||||
missing: Vec::new(),
|
||||
saw: Vec::new(),
|
||||
detail: format!("could not open the archive: {error}"),
|
||||
});
|
||||
}
|
||||
};
|
||||
let registry = PackRegistry::builtin();
|
||||
if registry.detect(&archive).is_ok() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// (格式, 标记文件, 邻居标记)
|
||||
let rules: [(PackFormatView, &str, &[&str]); 3] = [
|
||||
(
|
||||
PackFormatView::Curseforge,
|
||||
"manifest.json",
|
||||
&["overrides/", "mods/"],
|
||||
),
|
||||
(
|
||||
PackFormatView::Modrinth,
|
||||
"modrinth.index.json",
|
||||
&["overrides/"],
|
||||
),
|
||||
(
|
||||
PackFormatView::Multimc,
|
||||
"mmc-pack.json",
|
||||
&["instance.cfg", ".minecraft/"],
|
||||
),
|
||||
];
|
||||
|
||||
// 目录输入的条目名**没有尾斜杠**(`overrides` 而不是 `overrides/`),zip 里目录条目也可能缺;
|
||||
// 所以匹配同时认「精确 / 前缀 / 去掉尾斜杠」三种写法,判据仍然只有标记文件的有无。
|
||||
let names: Vec<&str> = archive.entries().iter().map(|entry| entry.name()).collect();
|
||||
let has = |needle: &str| {
|
||||
names.iter().any(|name| {
|
||||
*name == needle || name.starts_with(needle) || *name == needle.trim_end_matches('/')
|
||||
})
|
||||
};
|
||||
|
||||
let mut saw: Vec<String> = Vec::new();
|
||||
let mut best: Option<(PackFormatView, String)> = None;
|
||||
for (format, marker, neighbours) in rules {
|
||||
let hits: Vec<&str> = neighbours
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|entry| has(entry))
|
||||
.collect();
|
||||
if hits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !has(marker) {
|
||||
// 有邻居、没标记 ⇒ 「像它,但缺那个标记」
|
||||
saw.extend(hits.iter().map(|entry| (*entry).to_string()));
|
||||
if best.is_none() {
|
||||
best = Some((format, marker.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
saw.sort();
|
||||
saw.dedup();
|
||||
saw.truncate(5);
|
||||
|
||||
let detail = format!("tried: {}", registry.ids().join(" / "));
|
||||
Some(ProbeView {
|
||||
looks_like: best.as_ref().map(|(format, _)| *format),
|
||||
missing: best.map(|(_, marker)| vec![marker]).unwrap_or_default(),
|
||||
saw,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! 导出(M113):**预检不写盘**,写盘是另一次调用,两次用同一套判据。
|
||||
//!
|
||||
//! 预检的全部依据就是 core 的 `scan_instance` + 「没有远程目录 ⇒ 一律内联」:
|
||||
//! `RemoteCatalog` 目前只有 `OfflineCatalog`,任何文件都证明不了可被远程替代。所以
|
||||
//! 「将内联 N / 无法导出 M」里的 N 是扫到的文件数、M 是 `SkippedLocal`(符号链接 / 路径不安全 /
|
||||
//! 读不出来)。写盘走 `export_instance`(同样注入离线目录),
|
||||
//! `tests::preview_counts_match_the_written_report` 用真管线钉住两边相等。
|
||||
|
||||
use unlucky_core::addon::export::{
|
||||
export_instance, scan_instance, ExportRequest, OfflineCatalog, DEFAULT_CONTENT_ROOTS,
|
||||
};
|
||||
|
||||
use crate::dto::{
|
||||
ExportClassView, ExportFileView, ExportPreviewView, ExportReasonView, ExportRequestView,
|
||||
PackEvent,
|
||||
};
|
||||
|
||||
use super::super::super::env::HostEnv;
|
||||
use super::super::super::job::{spawn, JobSender};
|
||||
use super::convert::{class_view, done_view, reason_view};
|
||||
use super::send;
|
||||
|
||||
/// 起一个后台预检线程(**不写盘**)。
|
||||
pub(super) fn spawn_preview(env: HostEnv, tx: JobSender, request: ExportRequestView, epoch: u64) {
|
||||
spawn("pack-export-preview", move || {
|
||||
let root = match super::instance_root(&env, &request.instance) {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
send(&tx, epoch, PackEvent::ExportFailed(reason));
|
||||
return;
|
||||
}
|
||||
};
|
||||
send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Preview(Box::new(preview_of(&root, &env, &request))),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 预检的**纯函数部分**(不起线程、不写盘):扫描 → 三分类计数。
|
||||
///
|
||||
/// 抽出来是为了能测:`tests::preview_counts_match_the_written_report` 用它和
|
||||
/// `export_instance` 的真实报告对数字,等式不成立就红。
|
||||
pub(super) fn preview_of(
|
||||
root: &std::path::Path,
|
||||
env: &HostEnv,
|
||||
request: &ExportRequestView,
|
||||
) -> ExportPreviewView {
|
||||
let target = request
|
||||
.target
|
||||
.clone()
|
||||
.unwrap_or_else(|| super::default_export_path(env, request));
|
||||
let roots: Vec<String> = DEFAULT_CONTENT_ROOTS
|
||||
.iter()
|
||||
.map(|entry| (*entry).to_string())
|
||||
.collect();
|
||||
let (files, skipped) = scan_instance(root, &roots);
|
||||
|
||||
let mut rows: Vec<ExportFileView> = files
|
||||
.iter()
|
||||
.map(|file| ExportFileView {
|
||||
path: file.path.clone(),
|
||||
class: ExportClassView::Inline,
|
||||
reason: ExportReasonView::NoRemoteMatch,
|
||||
bytes: file.bytes,
|
||||
disabled: file.disabled,
|
||||
})
|
||||
.collect();
|
||||
rows.extend(skipped.iter().map(|file| ExportFileView {
|
||||
path: file.path.clone(),
|
||||
class: class_view(file.reason.class()),
|
||||
reason: reason_view(file.reason),
|
||||
bytes: 0,
|
||||
disabled: false,
|
||||
}));
|
||||
// 与 `ExportReport::files` 同一条顺序(按路径)
|
||||
rows.sort_by(|left, right| left.path.cmp(&right.path));
|
||||
|
||||
ExportPreviewView {
|
||||
format: request.format,
|
||||
instance: request.instance.clone(),
|
||||
name: request.name.clone(),
|
||||
version: request.version.clone(),
|
||||
target,
|
||||
files: rows,
|
||||
notes: vec![
|
||||
"No remote catalogue yet: every file has to be inlined into overrides/".to_string(),
|
||||
"Unreadable files, symlinks and unsafe paths are listed below, one by one".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// 起一个后台写盘线程。
|
||||
pub(super) fn spawn_write(env: HostEnv, tx: JobSender, request: ExportRequestView, epoch: u64) {
|
||||
spawn("pack-export", move || {
|
||||
let root = match super::instance_root(&env, &request.instance) {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
send(&tx, epoch, PackEvent::ExportFailed(reason));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let format = match super::format_of(request.format) {
|
||||
Ok(format) => format,
|
||||
Err(reason) => {
|
||||
send(&tx, epoch, PackEvent::ExportFailed(reason));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let target = request
|
||||
.target
|
||||
.clone()
|
||||
.unwrap_or_else(|| super::default_export_path(&env, &request));
|
||||
let snapshot = super::snapshot_of(&env, &request.instance);
|
||||
let mut core_request = ExportRequest::new(&root, &target, format)
|
||||
.with_name(request.name.clone())
|
||||
.with_version(request.version.clone())
|
||||
.with_catalog(&OfflineCatalog);
|
||||
if let Some(snapshot) = &snapshot {
|
||||
core_request = core_request.with_snapshot(snapshot);
|
||||
}
|
||||
match export_instance(&core_request) {
|
||||
Ok(report) => send(
|
||||
&tx,
|
||||
epoch,
|
||||
PackEvent::Exported(Box::new(done_view(&report))),
|
||||
),
|
||||
Err(error) => send(&tx, epoch, PackEvent::ExportFailed(format!("{error}"))),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! 后台导入(M113):探测 → 目标目录 → core 的 `import_pack` → 报告。
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//!
|
||||
//! 1. **探测失败不空跑管线**:先做具名诊断([`super::diagnose`]),失败就只回 `Probe` 事件;
|
||||
//! 2. **默认档永远是 `KeepUser`**:只有 `request.overwrite == true` 才换成 `Overwrite`;
|
||||
//! 3. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
|
||||
|
||||
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,
|
||||
ImportRequest,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{ImportRequestView, PackEvent};
|
||||
use unlucky_core::addon::import::ImportStatus;
|
||||
|
||||
use super::super::super::env::HostEnv;
|
||||
use super::super::super::job::{spawn, JobSender};
|
||||
use super::convert;
|
||||
use super::send;
|
||||
|
||||
/// 起一个后台导入线程。
|
||||
pub(super) fn spawn_import(
|
||||
env: HostEnv,
|
||||
tx: JobSender,
|
||||
request: ImportRequestView,
|
||||
epoch: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
inflight: super::InFlight,
|
||||
) {
|
||||
spawn("pack-import", move || {
|
||||
// 句柄跟着这次运行走到**出口**:正常收尾、两条早退、panic 展开都会 drop ⇒ 计数减一。
|
||||
// 以前是手工 `finished()` 放在 `block_on()` 之后,两条早退路径直接漏掉(复审 r2 P2-1)。
|
||||
let _inflight = inflight;
|
||||
run(&env, &tx, &request, epoch, cancel);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn run(
|
||||
env: &HostEnv,
|
||||
tx: &JobSender,
|
||||
request: &ImportRequestView,
|
||||
epoch: u64,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) {
|
||||
// 探测先过一遍:失败就只给具名诊断(「看起来像 X,但缺 Y」),不空跑一遍管线
|
||||
if let Some(probe) = super::diagnose::of(&request.pack) {
|
||||
send(tx, epoch, PackEvent::Probe(Box::new(probe)));
|
||||
return;
|
||||
}
|
||||
let (target, register) = 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 pack_name = request
|
||||
.instance_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| convert::pack_stem(&request.pack));
|
||||
let progress_tx = tx.clone();
|
||||
let progress_name = pack_name.clone();
|
||||
let cancel_flag = Arc::clone(&cancel);
|
||||
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
|
||||
if cancel_flag.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
send(
|
||||
&progress_tx,
|
||||
epoch,
|
||||
PackEvent::Progress(Some(Box::new(convert::progress_view(
|
||||
progress,
|
||||
&progress_name,
|
||||
)))),
|
||||
);
|
||||
});
|
||||
|
||||
let engine = super::super::super::engine::engine(env);
|
||||
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);
|
||||
}
|
||||
if register {
|
||||
core_request = core_request.with_registration(&env.layout, Some(&pack_name));
|
||||
}
|
||||
|
||||
let report = block_on(import_pack(&core_request));
|
||||
if report.status == ImportStatus::Failed && register {
|
||||
// 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话
|
||||
// 下一次重试会撞「directory already exists」(复审 r1 P2-9)。
|
||||
remove_if_empty(&target);
|
||||
}
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
// 取消之后不再打扰界面(面板已经收回,代次也已经作废)。
|
||||
// 注意:后台这一步**已经跑完**(core 没有取消令牌),实例可能已被登记 ——
|
||||
// 所以取消的提示语必须说「还在后台跑」,不能说「实例未创建」。
|
||||
return;
|
||||
}
|
||||
send(
|
||||
tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::report_view(&report, &target))),
|
||||
);
|
||||
}
|
||||
|
||||
/// 目录里没有东西就删掉(失败回滚后的收尾;非空一律不动)。
|
||||
fn remove_if_empty(dir: &std::path::Path) {
|
||||
let empty = std::fs::read_dir(dir)
|
||||
.map(|mut entries| entries.next().is_none())
|
||||
.unwrap_or(false);
|
||||
if empty {
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入目标:`(目录, 是否登记新实例)`。
|
||||
///
|
||||
/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录);
|
||||
/// - 没选实例 ⇒ `<data>/instances/<dir_name>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
|
||||
/// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。
|
||||
pub(super) fn target_of(
|
||||
env: &HostEnv,
|
||||
request: &ImportRequestView,
|
||||
) -> Result<(PathBuf, bool), String> {
|
||||
match &request.instance {
|
||||
Some(id) => super::instance_root(env, id).map(|root| (root, false)),
|
||||
None => {
|
||||
let name = request
|
||||
.instance_name
|
||||
.clone()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| convert::pack_stem(&request.pack));
|
||||
let target = env
|
||||
.layout
|
||||
.data()
|
||||
.join("instances")
|
||||
.join(convert::dir_name(&name));
|
||||
if target.exists() {
|
||||
// 空目录(多半是上一次失败的残留)**可以复用**,否则重试永远报「已存在」
|
||||
let mut entries = std::fs::read_dir(&target)
|
||||
.map_err(|error| format!("read instance dir failed: {error}"))?;
|
||||
if entries.next().is_some() {
|
||||
return Err(format!(
|
||||
"directory already exists and is not empty: {}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
std::fs::create_dir_all(&target)
|
||||
.map_err(|error| format!("create dir failed: {error}"))?;
|
||||
}
|
||||
Ok((target, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
//! M113 的证据(都跑真管线 / 真 host,不用假数据):
|
||||
//!
|
||||
//! | 用例 | 钉住什么 |
|
||||
//! |---|---|
|
||||
//! | [`bad_packs_get_a_named_diagnosis`] | 坏包必须有具名失败,不是静默、不是 panic |
|
||||
//! | [`preview_counts_match_the_written_report`] | 预检的 N/M 与最终报告**逐项相等** |
|
||||
//! | [`a_real_pack_runs_through_the_pipeline_end_to_end`] | 正常包能走完管线并落盘 |
|
||||
//! | [`chinese_instance_names_get_distinct_directories`] | 中文实例名不会塌成同一个目录 |
|
||||
//! | [`a_failed_new_instance_directory_can_be_reused_for_retry`] | 失败残留的空目录不挡重试 |
|
||||
//! | [`pack_epoch_is_per_host_and_is_its_own_counter`] | 代次按 host 分开,不再跨 host 串扰 |
|
||||
//! | [`a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`] | 早退路径放回「在跑」计数,取消安装仍是安装的取消 |
|
||||
//!
|
||||
//! 夹具(临时目录、写文件、泵 host)在 [`harness`]。
|
||||
|
||||
mod harness;
|
||||
|
||||
use harness::{pack_events, pump, pump_until, temp_dir, write};
|
||||
|
||||
use unlucky_core::addon::export::{
|
||||
export_instance, scan_instance, ExportRequest, InstanceSnapshot, OfflineCatalog,
|
||||
DEFAULT_CONTENT_ROOTS,
|
||||
};
|
||||
|
||||
use crate::dto::{ExportClassView, ExportRequestView, PackFormatView};
|
||||
|
||||
#[test]
|
||||
fn bad_packs_get_a_named_diagnosis() {
|
||||
let dir = temp_dir("badpack");
|
||||
|
||||
// ① 把文本文件改名成 .mrpack:连 zip 都不是
|
||||
let fake = dir.join("fake.mrpack");
|
||||
write(&fake, b"this is not a zip");
|
||||
let probe = super::diagnose::of(&fake).expect("打不开的包必须有诊断");
|
||||
assert!(
|
||||
!probe.detail.is_empty(),
|
||||
"诊断要给一句具体原因(不是空字符串)"
|
||||
);
|
||||
assert!(
|
||||
probe.detail.to_lowercase().contains("archive") || probe.detail.contains("zip"),
|
||||
"诊断要说清是哪一步失败:{}",
|
||||
probe.detail
|
||||
);
|
||||
|
||||
// ② 目录形态:有 overrides/、没有 manifest.json ⇒ 「像 CF,缺 manifest.json」
|
||||
let dir_pack = dir.join("half");
|
||||
write(&dir_pack.join("overrides").join("options.txt"), b"fov=90\n");
|
||||
if let Some(probe) = super::diagnose::of(&dir_pack) {
|
||||
assert!(
|
||||
!probe.saw.is_empty(),
|
||||
"看到了 overrides/ 就该说出来(saw 不能为空)"
|
||||
);
|
||||
if probe.looks_like.is_some() {
|
||||
assert!(
|
||||
!probe.missing.is_empty(),
|
||||
"说「像某个格式」就必须说出缺哪个标记文件"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_counts_match_the_written_report() {
|
||||
let dir = temp_dir("export-eq");
|
||||
let root = dir.join("instance");
|
||||
write(&root.join("mods").join("sodium.jar"), &[0x11; 4096]);
|
||||
write(&root.join("mods").join("lithium.jar"), &[0x22; 2048]);
|
||||
write(&root.join("mods").join("old.jar.disabled"), &[0x33; 512]);
|
||||
write(&root.join("config").join("options.txt"), b"fov=90\n");
|
||||
// 白名单之外的目录不该被扫(否则「将内联 N」会虚高)
|
||||
write(&root.join("logs").join("latest.log"), b"noise\n");
|
||||
|
||||
let request = ExportRequestView {
|
||||
instance: "survival".to_string(),
|
||||
format: PackFormatView::Modrinth,
|
||||
name: "Survival".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
target: Some(dir.join("out.mrpack")),
|
||||
};
|
||||
let env_root = dir.join("data");
|
||||
std::fs::create_dir_all(&env_root).expect("env");
|
||||
let env = crate::host::HostEnv::at_root(&env_root);
|
||||
|
||||
// 预检(不写盘)
|
||||
let preview = super::export::preview_of(&root, &env, &request);
|
||||
|
||||
// 真写盘:用同一套判据(离线目录 + 默认内容目录 + 直接给的快照)
|
||||
let snapshot = InstanceSnapshot {
|
||||
game_version: "1.20.1".to_string(),
|
||||
loaders: Vec::new(),
|
||||
};
|
||||
let target = dir.join("out.mrpack");
|
||||
let core_request = ExportRequest::new(
|
||||
&root,
|
||||
&target,
|
||||
&unlucky_core::addon::pack::modrinth::MODRINTH,
|
||||
)
|
||||
.with_name("Survival")
|
||||
.with_version("1.0.0")
|
||||
.with_catalog(&OfflineCatalog)
|
||||
.with_snapshot(&snapshot);
|
||||
let report = export_instance(&core_request).expect("导出应当成功");
|
||||
|
||||
// 三个计数逐项相等(塔台要求:不是「大致对」)
|
||||
assert_eq!(
|
||||
preview.inline_count(),
|
||||
report.inline_count(),
|
||||
"内联条数不一致:预检 {} vs 报告 {}",
|
||||
preview.inline_count(),
|
||||
report.inline_count()
|
||||
);
|
||||
assert_eq!(
|
||||
preview.skipped_count(),
|
||||
report.skipped_count(),
|
||||
"无法导出条数不一致"
|
||||
);
|
||||
assert_eq!(
|
||||
preview.remote_count(),
|
||||
report.remote_count(),
|
||||
"远程引用条数不一致"
|
||||
);
|
||||
|
||||
// 逐条:同一个路径在两边必须是同一个类
|
||||
for file in &preview.files {
|
||||
let written = report
|
||||
.files
|
||||
.iter()
|
||||
.find(|candidate| candidate.path == file.path)
|
||||
.unwrap_or_else(|| panic!("预检多出一条:{}", file.path));
|
||||
let expected = match file.class {
|
||||
ExportClassView::Remote => unlucky_core::addon::export::FileClass::Remote,
|
||||
ExportClassView::Inline => unlucky_core::addon::export::FileClass::Inline,
|
||||
ExportClassView::Skipped => unlucky_core::addon::export::FileClass::Skipped,
|
||||
};
|
||||
assert_eq!(written.class, expected, "{} 的归类不一致", file.path);
|
||||
}
|
||||
|
||||
// 白名单外的 logs/ 不许进包(防「扫太宽」把预检数字弄虚)
|
||||
assert!(
|
||||
!report
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path.starts_with("logs/")),
|
||||
"logs/ 不该被扫进整合包"
|
||||
);
|
||||
|
||||
// 同时钉一条:`scan_instance` 的口径就是预览用的那一套
|
||||
let roots: Vec<String> = DEFAULT_CONTENT_ROOTS
|
||||
.iter()
|
||||
.map(|entry| (*entry).to_string())
|
||||
.collect();
|
||||
let (files, skipped) = scan_instance(&root, &roots);
|
||||
assert_eq!(files.len(), preview.inline_count());
|
||||
assert_eq!(skipped.len(), preview.skipped_count());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_pack_runs_through_the_pipeline_end_to_end() {
|
||||
// 真管线端到端:造一个 MultiMC 实例目录当「包」(目录形态不需要 zip 写入器),
|
||||
// 走 `import::run` 的**真**路径,看事件流里有没有「进度 + 结果」、文件有没有落地。
|
||||
let dir = temp_dir("import-e2e");
|
||||
let source = dir.join("Better MC");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"},
|
||||
{"uid": "net.fabricmc.fabric-loader", "version": "0.15.11"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 1024]);
|
||||
write(&source.join(".minecraft/config/options.txt"), b"fov=90\n");
|
||||
|
||||
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("Imported Pack".to_string()),
|
||||
overwrite: false,
|
||||
};
|
||||
super::import::run(
|
||||
&env,
|
||||
&tx,
|
||||
&request,
|
||||
7,
|
||||
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
);
|
||||
drop(tx);
|
||||
|
||||
let mut progress = 0usize;
|
||||
let mut report = None;
|
||||
for task in rx.iter() {
|
||||
match task {
|
||||
crate::host::job::HostTask::Pack { event, epoch } => {
|
||||
assert_eq!(epoch, 7, "代次必须原样带回(取消判定靠它)");
|
||||
match *event {
|
||||
crate::dto::PackEvent::Progress(Some(_)) => progress += 1,
|
||||
crate::dto::PackEvent::Report(view) => report = Some(*view),
|
||||
other => panic!("意料之外的事件:{other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("意料之外的任务:{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(progress > 0, "至少要有一条进度事件");
|
||||
let report = report.expect("必须有结果事件");
|
||||
assert_ne!(
|
||||
report.status,
|
||||
crate::dto::ImportStatusView::Failed,
|
||||
"正常包不该失败:{:?}",
|
||||
report.error
|
||||
);
|
||||
assert_eq!(
|
||||
report.format,
|
||||
Some(crate::dto::PackFormatView::Multimc),
|
||||
"应当认成 MultiMC 格式"
|
||||
);
|
||||
assert!(
|
||||
report.written() > 0,
|
||||
"报告里要至少有写盘的文件:{:?}",
|
||||
report.files
|
||||
);
|
||||
// 落盘位置以**报告里的 target** 为准(那是 host 真算出来的目录,界面显示的也是它)
|
||||
let landed = &report.target;
|
||||
assert!(
|
||||
landed.join("mods/sodium.jar").is_file(),
|
||||
"文件没有落到实例目录:{}",
|
||||
landed.display()
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_instance_names_get_distinct_directories() {
|
||||
// 复审 r1 P2-8:slug() 只留 ASCII,中文名会全塌成 `pack`,第二个就撞目录
|
||||
let first = super::convert::dir_name("我的整合包");
|
||||
let second = super::convert::dir_name("另一个整合包");
|
||||
assert_ne!(first, second, "两个不同的中文名必须得到不同目录");
|
||||
assert_eq!(
|
||||
first,
|
||||
super::convert::dir_name("我的整合包"),
|
||||
"同一个名字要稳定(不然重试会漂到新目录)"
|
||||
);
|
||||
assert!(first.starts_with("pack-"), "退化时要留可读前缀:{first}");
|
||||
|
||||
// 判据与注释对齐(复审 r2 P2-2):只含 ASCII 字母数字/空格/`-`/`_` ⇒ 保持原样
|
||||
assert_eq!(super::convert::dir_name("Better-MC"), "Better-MC");
|
||||
assert_eq!(
|
||||
super::convert::dir_name("Better MC"),
|
||||
"Better-MC",
|
||||
"空格折成 `-`、**不加**哈希"
|
||||
);
|
||||
assert_eq!(
|
||||
super::convert::dir_name("pack"),
|
||||
"pack",
|
||||
"名字就叫 pack 也保留"
|
||||
);
|
||||
|
||||
// 折过**别的**字符(这里是 `.`)就必须补哈希,否则不同名字会撞同一个目录
|
||||
let versioned = super::convert::dir_name("Survival 1.20.1");
|
||||
assert!(versioned.starts_with("Survival-1-20-1-"), "{versioned}");
|
||||
assert_ne!(versioned, "Survival-1-20-1", "折过 `.` 就要补哈希");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_new_instance_directory_can_be_reused_for_retry() {
|
||||
// 复审 r1 P2-9:失败回滚留下空目录时会挡住重试
|
||||
let dir = temp_dir("retry");
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
|
||||
let request = crate::dto::ImportRequestView {
|
||||
pack: dir.join("whatever.mrpack"),
|
||||
instance: None,
|
||||
instance_name: Some("Retry Me".to_string()),
|
||||
overwrite: false,
|
||||
};
|
||||
|
||||
// 第一次:目录不存在 ⇒ 建出来
|
||||
let (first, register) = super::import::target_of(&env, &request).expect("首次应当可以建目录");
|
||||
assert!(register);
|
||||
assert!(first.is_dir());
|
||||
|
||||
// 第二次(模拟上一次失败留下的空目录)⇒ 复用,而不是报「已存在」
|
||||
let (second, _) = super::import::target_of(&env, &request).expect("空目录应当可复用");
|
||||
assert_eq!(first, second);
|
||||
|
||||
// 目录里已经有东西 ⇒ 拒绝(不能往别人的实例里倒)
|
||||
std::fs::write(second.join("marker.txt"), b"x").expect("write");
|
||||
assert!(
|
||||
super::import::target_of(&env, &request).is_err(),
|
||||
"非空目录必须拒绝"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_epoch_is_per_host_and_is_its_own_counter() {
|
||||
// 复审 r2 P3-6:代次**按 host** 分开(不再是跨 host 共享的裸静态量)。
|
||||
// 两个 host(两个临时根)各 bump 一次,互不影响 —— 这正是并行测试串扰的根因。
|
||||
let one = temp_dir("epoch-one");
|
||||
let two = temp_dir("epoch-two");
|
||||
let env_one = crate::host::HostEnv::at_root(one.join("data"));
|
||||
let env_two = crate::host::HostEnv::at_root(two.join("data"));
|
||||
|
||||
let before_one = super::pack_epoch(&env_one);
|
||||
let before_two = super::pack_epoch(&env_two);
|
||||
super::bump_pack_epoch(&env_one);
|
||||
assert_eq!(
|
||||
super::pack_epoch(&env_one),
|
||||
before_one.wrapping_add(1),
|
||||
"自己这个 host 的代次 +1"
|
||||
);
|
||||
assert_eq!(
|
||||
super::pack_epoch(&env_two),
|
||||
before_two,
|
||||
"**另一个** host 的代次不受影响(P3-6 的根因)"
|
||||
);
|
||||
assert_eq!(super::in_flight(&env_two), 0, "在跑计数也是每 host 一份");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&one);
|
||||
let _ = std::fs::remove_dir_all(&two);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact() {
|
||||
// 复审 r2 P2-1 的护栏:早退路径必须把「在跑」计数放回去,否则之后**下载页**的
|
||||
// 「取消安装」会被当成导入取消(不发 `InstallCancelled`,还给内容库画一句假话)。
|
||||
use crate::dto::{
|
||||
AppCommand, AppEvent, Backend, CoreEvent, ImportRequestView, PackCommand, PackEvent,
|
||||
};
|
||||
|
||||
let dir = temp_dir("inflight-guard");
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let host_env = crate::host::HostEnv::at_root(&data);
|
||||
let mut host = crate::host::CoreHost::headless(host_env.clone());
|
||||
|
||||
// ① 派一次**必然早退**的导入:包文件不存在 ⇒ 探测当场失败,根本不进管线
|
||||
host.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new(
|
||||
ImportRequestView {
|
||||
pack: dir.join("missing.mrpack"),
|
||||
instance: None,
|
||||
instance_name: Some("Guess".to_string()),
|
||||
overwrite: false,
|
||||
},
|
||||
)))));
|
||||
let events = pump_until(&mut host, |all| {
|
||||
pack_events(all)
|
||||
.iter()
|
||||
.any(|event| matches!(event, PackEvent::Probe(_)))
|
||||
});
|
||||
assert!(
|
||||
pack_events(&events)
|
||||
.iter()
|
||||
.any(|event| matches!(event, PackEvent::Probe(_))),
|
||||
"坏包必须给出探测诊断(而不是静默);实际收到 {events:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
super::in_flight(&host_env),
|
||||
0,
|
||||
"早退之后「在跑」计数必须归零(RAII guard 负责,不靠手工 finished)"
|
||||
);
|
||||
|
||||
// ② 此后下载页的「取消安装」必须照常发 `InstallCancelled`,且**不能**冒出 pack 的取消
|
||||
host.dispatch(AppCommand::CancelInstall);
|
||||
let events = pump(&mut host);
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallCancelled))),
|
||||
"没有导入在跑时,取消安装必须照常给回执(否则用户点了取消毫无反应)"
|
||||
);
|
||||
assert!(
|
||||
!pack_events(&events)
|
||||
.iter()
|
||||
.any(|event| matches!(event, PackEvent::Cancelled)),
|
||||
"此刻没有导入在跑,不该给内容库发「导入还在后台跑」"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! pack 用例的共用夹具:临时目录、文件写入、host 泵。
|
||||
//!
|
||||
//! 拆出去只为一件事:`tests.rs` 已经 429 行(超 400 硬线),而这些 helper 与任何一条断言无关。
|
||||
//! `tests.rs` 里各条用例的「抓得住什么」写在用例自己的注释里,这里只放机械动作。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 每个用例一个独立临时目录(用完删)。
|
||||
pub(super) fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ull-m113-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
pub(super) fn write(path: &std::path::Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("parent");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("write");
|
||||
}
|
||||
|
||||
/// 泵一次 host:把后台任务收成事件并取走。
|
||||
pub(super) fn pump(host: &mut crate::host::CoreHost) -> Vec<crate::dto::AppEvent> {
|
||||
use crate::dto::Backend;
|
||||
host.tick(std::time::Duration::from_millis(1));
|
||||
host.drain_events()
|
||||
}
|
||||
|
||||
/// 泵到 `done` 为真(最多 ~3 s),返回期间收到的全部事件。
|
||||
pub(super) fn pump_until(
|
||||
host: &mut crate::host::CoreHost,
|
||||
done: impl Fn(&[crate::dto::AppEvent]) -> bool,
|
||||
) -> Vec<crate::dto::AppEvent> {
|
||||
let mut all = Vec::new();
|
||||
for _ in 0..600 {
|
||||
all.extend(pump(host));
|
||||
if done(&all) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
pub(super) fn pack_events(events: &[crate::dto::AppEvent]) -> Vec<crate::dto::PackEvent> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
crate::dto::AppEvent::Core(crate::dto::CoreEvent::Pack(inner)) => {
|
||||
Some((**inner).clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! 两个站点的检索方言(M109):把各自的 JSON 翻成 [`ContentHit`]。
|
||||
//!
|
||||
//! 两个站点在**同一件事上用的字段名完全不同**,所以这里每个站点一段、各自带注释:
|
||||
//!
|
||||
//! | 语义 | Modrinth `/v2/search` | CurseForge `/v1/mods/search` |
|
||||
//! |---|---|---|
|
||||
//! | 项目 id | `project_id`(base62) | `id`(数字) |
|
||||
//! | 标题 / 简介 | `title` / `description` | `name` / `summary` |
|
||||
//! | 下载量 | `downloads` | `downloadCount` |
|
||||
//! | 游戏版本 | `versions` | `latestFilesIndexes[].gameVersion` |
|
||||
//! | 加载器 | `categories` 里混着的 `fabric`/`forge`/… | `latestFilesIndexes[].modLoader`(数字枚举) |
|
||||
//!
|
||||
//! 加载器那一行是最容易抄错的地方:Modrinth 的 `categories` 同时装着 `optimization` 这种**分类标签**,
|
||||
//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(`sources/tests` 钉住了这一条)。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::{
|
||||
loader_facet, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource,
|
||||
};
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::curseforge_ready;
|
||||
use super::http::{encode, get};
|
||||
|
||||
/// Modrinth v2 的根(无需 key)。
|
||||
pub(super) const MODRINTH: &str = "https://api.modrinth.com/v2";
|
||||
/// CurseForge v1 的根(需要 `X-API-KEY`)。
|
||||
pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1";
|
||||
/// CurseForge 的「Minecraft」gameId(官方固定值)。
|
||||
const CF_MINECRAFT: u32 = 432;
|
||||
|
||||
/// 检索分派。
|
||||
pub(super) fn search(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, String> {
|
||||
match query.source {
|
||||
ContentSource::Modrinth => modrinth(env, query),
|
||||
ContentSource::Curseforge => curseforge(env, query),
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- Modrinth
|
||||
|
||||
fn modrinth(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, String> {
|
||||
let mut facets = vec![format!("[\"project_type:{}\"]", query.kind.modrinth_type())];
|
||||
if let Some(version) = &query.game_version {
|
||||
facets.push(format!("[\"versions:{version}\"]"));
|
||||
}
|
||||
if let Some(facet) = query.loader.and_then(loader_facet) {
|
||||
facets.push(format!("[\"categories:{facet}\"]"));
|
||||
}
|
||||
|
||||
let mut url = format!(
|
||||
"{MODRINTH}/search?limit={}&offset={}&index={}&facets={}",
|
||||
query.limit.min(100),
|
||||
query.offset,
|
||||
query.sort.modrinth_index(),
|
||||
encode(&format!("[{}]", facets.join(",")))
|
||||
);
|
||||
if !query.text.trim().is_empty() {
|
||||
url.push_str(&format!("&query={}", encode(query.text.trim())));
|
||||
}
|
||||
|
||||
let body = get(env, &url, &[])?;
|
||||
let json: Value = serde_json::from_str(&body)
|
||||
.map_err(|error| format!("Modrinth 返回的不是合法 JSON:{error}"))?;
|
||||
let hits = json
|
||||
.get("hits")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "Modrinth 返回里没有 hits 字段".to_string())?
|
||||
.iter()
|
||||
.map(modrinth_hit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(ContentResults {
|
||||
query: query.clone(),
|
||||
hits,
|
||||
total: u32_of(&json, "total_hits"),
|
||||
curseforge: curseforge_ready(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 一条 Modrinth 命中项 → DTO(缺字段一律给空/零,**不**编造)。
|
||||
fn modrinth_hit(value: &Value) -> ContentHit {
|
||||
let loaders = strings(value, "categories")
|
||||
.into_iter()
|
||||
// `categories` 混着加载器与分类标签:只留已知的四个加载器
|
||||
.filter(|tag| matches!(tag.as_str(), "fabric" | "forge" | "neoforge" | "quilt"))
|
||||
.collect();
|
||||
|
||||
ContentHit {
|
||||
id: text(value, "project_id"),
|
||||
slug: text(value, "slug"),
|
||||
title: text(value, "title"),
|
||||
summary: text(value, "description"),
|
||||
author: text(value, "author"),
|
||||
downloads: u64_of(value, "downloads"),
|
||||
updated: date_part(&text(value, "updated")),
|
||||
source: ContentSource::Modrinth,
|
||||
game_versions: strings(value, "versions"),
|
||||
loaders,
|
||||
icon: optional_text(value, "icon_url"),
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- CurseForge
|
||||
|
||||
fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, String> {
|
||||
let Some(key) = super::curseforge_key() else {
|
||||
return Err("CurseForge 需要 API key(ULL_CF_API_KEY 未设置)".to_string());
|
||||
};
|
||||
let mut url = format!(
|
||||
"{CURSEFORGE}/mods/search?gameId={CF_MINECRAFT}&classId={}&index={}&pageSize={}&sortField={}&sortOrder=desc",
|
||||
query.kind.curseforge_class(),
|
||||
query.offset,
|
||||
query.limit.min(50),
|
||||
query.sort.curseforge_field()
|
||||
);
|
||||
if !query.text.trim().is_empty() {
|
||||
url.push_str(&format!("&searchFilter={}", encode(query.text.trim())));
|
||||
}
|
||||
if let Some(version) = &query.game_version {
|
||||
url.push_str(&format!("&gameVersion={}", encode(version)));
|
||||
}
|
||||
|
||||
let body = get(env, &url, &[("x-api-key", key.as_str())])?;
|
||||
let json: Value = serde_json::from_str(&body)
|
||||
.map_err(|error| format!("CurseForge 返回的不是合法 JSON:{error}"))?;
|
||||
let hits = json
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "CurseForge 返回里没有 data 字段".to_string())?
|
||||
.iter()
|
||||
.map(curseforge_hit)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(ContentResults {
|
||||
query: query.clone(),
|
||||
hits,
|
||||
total: json
|
||||
.get("pagination")
|
||||
.map(|pagination| u64_of(pagination, "totalCount"))
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.unwrap_or(0),
|
||||
curseforge: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// 一条 CF 命中项 → DTO。加载器只能从 `latestFilesIndexes[].modLoader` 的数字枚举反推。
|
||||
fn curseforge_hit(value: &Value) -> ContentHit {
|
||||
let mut game_versions: Vec<String> = Vec::new();
|
||||
let mut loaders: Vec<String> = Vec::new();
|
||||
if let Some(indexes) = value.get("latestFilesIndexes").and_then(Value::as_array) {
|
||||
for entry in indexes {
|
||||
if let Some(version) = entry.get("gameVersion").and_then(Value::as_str) {
|
||||
if !game_versions.iter().any(|item| item == version) {
|
||||
game_versions.push(version.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(loader) = entry
|
||||
.get("modLoader")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(curseforge_loader)
|
||||
{
|
||||
if !loaders.iter().any(|item| item == loader) {
|
||||
loaders.push(loader.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let author = value
|
||||
.get("authors")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.map(|entry| text(entry, "name"))
|
||||
.unwrap_or_default();
|
||||
|
||||
ContentHit {
|
||||
id: value
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|id| id.to_string())
|
||||
.unwrap_or_default(),
|
||||
slug: text(value, "slug"),
|
||||
title: text(value, "name"),
|
||||
summary: text(value, "summary"),
|
||||
author,
|
||||
downloads: u64_of(value, "downloadCount"),
|
||||
updated: date_part(&text(value, "dateModified")),
|
||||
source: ContentSource::Curseforge,
|
||||
game_versions,
|
||||
loaders,
|
||||
icon: value
|
||||
.get("logo")
|
||||
.and_then(|logo| logo.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// CF 的 `modLoader` 枚举 → Modrinth 口径的 loader 名(1 Forge / 4 Fabric / 5 Quilt / 6 NeoForge)。
|
||||
pub(super) fn curseforge_loader(raw: u64) -> Option<&'static str> {
|
||||
match raw {
|
||||
1 => Some("forge"),
|
||||
4 => Some("fabric"),
|
||||
5 => Some("quilt"),
|
||||
6 => Some("neoforge"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 小工具
|
||||
|
||||
/// 取字符串字段(缺 / 类型不对 ⇒ 空串)。
|
||||
fn text(value: &Value, key: &str) -> String {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 取可选字符串字段(空串也当没有——站点会给 `""` 表示没图)。
|
||||
fn optional_text(value: &Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
/// 取字符串数组(缺 / 类型不对 ⇒ 空 vec)。
|
||||
fn strings(value: &Value, key: &str) -> Vec<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str().map(ToString::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn u64_of(value: &Value, key: &str) -> u64 {
|
||||
value.get(key).and_then(Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn u32_of(value: &Value, key: &str) -> u32 {
|
||||
u32::try_from(u64_of(value, key)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// ISO-8601 → 日期部分(`2024-05-01T…` → `2024-05-01`;短串原样返回)。
|
||||
pub(super) fn date_part(raw: &str) -> String {
|
||||
raw.get(..10).unwrap_or(raw).to_string()
|
||||
}
|
||||
|
||||
/// 类型名只用来钉住「四条侧栏都在」的常量没被改乱(检索本身按 `kind` 走 facet)。
|
||||
#[must_use]
|
||||
pub(super) fn all_kinds() -> [ContentKind; 4] {
|
||||
ContentKind::ALL
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 内容库宿主的守门测试(M109):编码、文件名穿越、退避判定、两个站点的方言映射。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::http::{encode, retry_after, retryable};
|
||||
use super::install::safe_file_name;
|
||||
use super::sources::{all_kinds, curseforge_loader, date_part};
|
||||
use crate::dto::ContentKind;
|
||||
|
||||
#[test]
|
||||
fn percent_encoding_keeps_the_query_readable_and_escapes_the_rest() {
|
||||
assert_eq!(encode("1.20.1"), "1.20.1");
|
||||
assert_eq!(
|
||||
encode("[\"versions:1.20.1\"]"),
|
||||
"%5B%22versions%3A1.20.1%22%5D"
|
||||
);
|
||||
assert_eq!(encode("中文"), "%E4%B8%AD%E6%96%87");
|
||||
assert_eq!(encode("a b"), "a%20b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_never_carry_a_path() {
|
||||
// 抓得住:站点给的 `../../evil.jar` 落到实例目录之外(穿越)。
|
||||
assert_eq!(safe_file_name("sodium.jar"), "sodium.jar");
|
||||
assert_eq!(safe_file_name("../../evil.jar"), "evil.jar");
|
||||
assert_eq!(safe_file_name("mods\\jei.jar"), "jei.jar");
|
||||
assert_eq!(safe_file_name(".."), "download.bin");
|
||||
assert_eq!(safe_file_name(""), "download.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_are_sliced_not_guessed() {
|
||||
assert_eq!(date_part("2024-05-01T12:00:00Z"), "2024-05-01");
|
||||
assert_eq!(date_part("2024-05"), "2024-05");
|
||||
assert_eq!(date_part(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_rate_limited_and_transient_failures_are_retried() {
|
||||
assert!(retryable("HTTP 429 (retry_after=Some(3))"));
|
||||
assert!(retryable("HTTP 503 (retry_after=None)"));
|
||||
assert!(!retryable("HTTP 404 (retry_after=None)"));
|
||||
assert!(
|
||||
!retryable("HTTP 401 (retry_after=None)"),
|
||||
"密钥错重试只会更糟"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_prefers_the_server_and_falls_back_to_exponential() {
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=Some(7))", 0),
|
||||
Duration::from_secs(7)
|
||||
);
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=None)", 0),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=None)", 2),
|
||||
Duration::from_secs(4)
|
||||
);
|
||||
// 服务端要得再多也不等超过 30s(交互与截图不能被一次退避挂死)
|
||||
assert_eq!(
|
||||
retry_after("HTTP 429 (retry_after=Some(900))", 0),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curseforge_loader_ids_follow_the_official_enum() {
|
||||
assert_eq!(curseforge_loader(1), Some("forge"));
|
||||
assert_eq!(curseforge_loader(4), Some("fabric"));
|
||||
assert_eq!(curseforge_loader(5), Some("quilt"));
|
||||
assert_eq!(curseforge_loader(6), Some("neoforge"));
|
||||
assert_eq!(curseforge_loader(99), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_four_content_kinds_are_the_four_side_entries() {
|
||||
assert_eq!(
|
||||
all_kinds().map(ContentKind::slug),
|
||||
["modpack", "mod", "resourcepack", "shader"]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user