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

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+123
View File
@@ -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
}
+252
View File
@@ -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
}
}
+290
View File
@@ -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()
}
+262
View File
@@ -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
}
+85
View File
@@ -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"]
);
}