diff --git a/src-tauri/src/backend.rs b/src-tauri/src/backend.rs index 24a1165..c4714aa 100644 --- a/src-tauri/src/backend.rs +++ b/src-tauri/src/backend.rs @@ -2457,6 +2457,8 @@ struct ModTaskInfo { dependencies: Vec, cancel_key: String, status: DownloadStatus, + /// `data` 里的百分比是「制作镜像」阶段的服务端进度,而不是下载进度。 + mirror_preparing: bool, data: String, downloaded_bytes: u64, total_bytes: u64, @@ -2468,6 +2470,8 @@ enum DownloadWorkerMessage { index: usize, progress: DownloadCallbackInfo, }, + /// 下载前的镜像制作进度(weg.fan 镜像才有)。 + Preparing { index: usize, progress: f32 }, Finished { index: usize, result: Result<(), String>, @@ -2510,6 +2514,7 @@ fn emit_download_failure( dependencies: Vec::new(), cancel_key: name.to_string(), status: DownloadStatus::Failed, + mirror_preparing: false, data: error, downloaded_bytes: 0, total_bytes: 0, @@ -2593,6 +2598,7 @@ fn expand_dependency_graph( dependencies: Vec::new(), cancel_key: cancel_key.clone(), status: DownloadStatus::Waiting, + mirror_preparing: false, data: "0".to_string(), downloaded_bytes: 0, total_bytes: 0, @@ -2648,8 +2654,10 @@ fn start_waiting_mod_downloads( handles.push(std::thread::spawn(move || { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let progress_sender = sender.clone(); + let download_url = + resolve_download_url(&task_url, index, &progress_sender, &cancel_flag)?; download_mod_file( - &task_url, + &download_url, &task_dest, &mut |progress| { let _ = progress_sender @@ -2733,14 +2741,24 @@ fn download_mod_queue( }; match message { DownloadWorkerMessage::Progress { index, progress } => { + tasks[index].mirror_preparing = false; tasks[index].data = format!("{:.2}", progress.progress); tasks[index].downloaded_bytes = progress.downloaded_bytes; tasks[index].total_bytes = progress.total_bytes; tasks[index].speed_bytes_per_sec = progress.speed_bytes_per_sec; emit_download_tasks(tasks, on_event, "pending"); } + DownloadWorkerMessage::Preparing { index, progress } => { + tasks[index].mirror_preparing = true; + tasks[index].data = format!("{progress:.2}"); + tasks[index].downloaded_bytes = 0; + tasks[index].total_bytes = 0; + tasks[index].speed_bytes_per_sec = 0.0; + emit_download_tasks(tasks, on_event, "pending"); + } DownloadWorkerMessage::Finished { index, result } => { active -= 1; + tasks[index].mirror_preparing = false; match result { Ok(()) => { tasks[index].status = DownloadStatus::Finished; @@ -2791,6 +2809,79 @@ fn download_mod_queue( failed || cancel_flag.load(Ordering::Relaxed) } +/// 下载前的镜像制作(只有 weg.fan 镜像的下载地址会走到里面): +/// 先让镜像开始制作文件、轮询服务端进度并把进度发给下载管理,最后返回可以下载 +/// 的地址。不是镜像地址时原样返回 URL。 +fn prepare_mirror_before_download( + url: &str, + index: usize, + sender: &std::sync::mpsc::Sender, + cancel_flag: &Arc, +) -> anyhow::Result { + let prepared = wegfan::prepare_mirror(url, cancel_flag, &mut |current, total| { + let progress = if total == 0 { + 0.0 + } else { + 100.0 * current as f32 / total as f32 + }; + let _ = sender.send(DownloadWorkerMessage::Preparing { index, progress }); + })?; + Ok(prepared.unwrap_or_else(|| url.to_string())) +} + +/// 制作镜像失败时显示在下载管理里的原因。后面的原始错误来自 wegfan 模块: +/// `/start` 请求失败、`/status` 查不到(接口不可用)、文件未收录、轮询超时等。 +fn mirror_prepare_failure(error: &anyhow::Error) -> String { + format!("制作镜像失败:{error:#}") +} + +/// 决定这次任务用哪个 URL 下载。不是 weg.fan 镜像地址(其它镜像、直连)时原样 +/// 返回;镜像制作失败时返回失败原因,由调用方把任务置为失败——不再悄悄退回直连。 +fn resolve_download_url( + url: &str, + index: usize, + sender: &std::sync::mpsc::Sender, + cancel_flag: &Arc, +) -> Result { + match prepare_mirror_before_download(url, index, sender, cancel_flag) { + Ok(url) => Ok(url), + // 取消依旧保持现有的取消语义(任务进入已取消,而不是失败)。 + Err(error) if cancel_flag.load(Ordering::Relaxed) => Err(error.to_string()), + Err(error) => Err(mirror_prepare_failure(&error)), + } +} + +#[cfg(test)] +mod download_mirror_tests { + use super::*; + + /// 其它镜像、直连地址不经过镜像制作,按原样下载。 + #[test] + fn non_mirror_urls_are_downloaded_directly() { + let (sender, _receiver) = std::sync::mpsc::channel(); + let cancel_flag = Arc::new(AtomicBool::new(false)); + for url in [ + "https://gamebanana.com/dl/844056", + "https://celestemodupdater.0x0a.de/banana-mirror/844056.zip", + ] { + assert_eq!( + resolve_download_url(url, 0, &sender, &cancel_flag), + Ok(url.to_string()) + ); + } + } + + /// 制作镜像失败时把原因交给下载任务(界面显示「制作镜像失败:…」), + /// 不再回退到直连下载。 + #[test] + fn mirror_failure_reports_the_reason() { + let error = anyhow::anyhow!("Failed to query mirror status of 123: connection refused"); + let message = mirror_prepare_failure(&error); + assert!(message.starts_with("制作镜像失败:"), "{message}"); + assert!(message.contains("connection refused"), "{message}"); + } +} + fn make_path_compatible_name(name: &str) -> String { name.replace([' ', ':', '/', '\\', '?', '*', '\"', '<', '>', '|'], "_") } @@ -3765,6 +3856,7 @@ mod local_package_tests { dependencies: Vec::new(), cancel_key: "A".to_string(), status: DownloadStatus::Waiting, + mirror_preparing: false, data: "0".to_string(), downloaded_bytes: 0, total_bytes: 0, @@ -3778,6 +3870,7 @@ mod local_package_tests { dependencies: Vec::new(), cancel_key: "A".to_string(), status: DownloadStatus::Waiting, + mirror_preparing: false, data: "0".to_string(), downloaded_bytes: 0, total_bytes: 0, @@ -5900,6 +5993,7 @@ fn download_mod_with_roots( dependencies: Vec::new(), cancel_key: name.clone(), status: DownloadStatus::Waiting, + mirror_preparing: false, data: String::new(), downloaded_bytes: 0, total_bytes: 0, diff --git a/src-tauri/src/wegfan.rs b/src-tauri/src/wegfan.rs index d2c6aa4..801b61a 100644 --- a/src-tauri/src/wegfan.rs +++ b/src-tauri/src/wegfan.rs @@ -1,5 +1,220 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, bail}; use serde::{Deserialize, Serialize}; +/// weg.fan 镜像的下载接口。只有这些 URL 才走「先制作镜像、轮询进度、再下载」 +/// 的流程,其它镜像(0x0a.de、GameBanana 直连等)保持原样直连下载。 +pub const MIRROR_GAME_BANANA_FILES_API: &str = + "https://celeste.weg.fan/api/v2/download/gamebanana-files"; +pub const MIRROR_FILES_API: &str = "https://celeste.weg.fan/api/v2/download/files"; +const MIRROR_START_API: &str = "https://celeste.weg.fan/api/v2/mirror/start"; +const MIRROR_STATUS_API: &str = "https://celeste.weg.fan/api/v2/mirror/status"; + +/// 轮询间隔与镜像官方 Mod(ChinaMirror)保持一致。 +const POLL_INTERVAL: Duration = Duration::from_millis(200); +/// 服务器还没报出任何进度(total 仍为 0)时的等待上限。 +const FIRST_PROGRESS_TIMEOUT: Duration = Duration::from_secs(30); +/// 已经有进度、但进度停滞时的上限。 +const STALL_TIMEOUT: Duration = Duration::from_secs(10); +/// 整个制作阶段的兜底上限,避免轮询卡死在这里。 +const PREPARE_TIMEOUT: Duration = Duration::from_secs(600); +/// 镜像回复「文件未找到」,此时没必要再轮询,直接回退直连下载。 +const FILE_NOT_FOUND_CODE: i64 = 10002; + +#[derive(Deserialize)] +struct MirrorApiResponse { + data: Option, + code: Option, + message: Option, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct MirrorPrepareStatus { + download_progress: FileProgress, + upload_progress: FileProgress, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct FileProgress { + current: u64, + total: u64, +} + +fn mirror_agent() -> ureq::Agent { + // 和下载用 agent 一样的超时约定(#84):连接/读/写各自 30s,服务端不回数据 + // 时必须报错而不是一直挂着。这里额外关掉重定向跟随:`gamebanana-files/{id}` + // 的 302 就是要读的东西,不是要跟过去的东西。 + ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(30)) + .timeout_read(Duration::from_secs(30)) + .timeout_write(Duration::from_secs(30)) + .redirects(0) + .try_proxy_from_env(true) + .build() +} + +fn mirror_user_agent() -> String { + format!("CeleMod/{}", env!("VERSION")) +} + +fn parse_mirror_response( + url: &str, + response: ureq::Response, +) -> anyhow::Result> { + let parsed = response + .into_json::>() + .with_context(|| format!("Failed to parse mirror response from {url}"))?; + match parsed.code { + Some(200) => Ok(parsed), + Some(FILE_NOT_FOUND_CODE) => bail!("Mirror does not know this file: {url}"), + code => bail!( + "Mirror request failed ({}, code {:?}): {}", + url, + code, + parsed.message.as_deref().unwrap_or("") + ), + } +} + +/// 取出 URL 里挂在 `prefix/` 后面的那个 id。 +fn mirror_url_suffix(url: &str, prefix: &str) -> Option { + let rest = url + .split(['?', '#']) + .next()? + .strip_prefix(prefix)? + .strip_prefix('/')?; + let id = rest.trim_end_matches('/'); + (!id.is_empty() && !id.contains('/')).then(|| id.to_string()) +} + +/// `.../download/files/{fileId}` 直接带文件 id;`.../download/gamebanana-files/{source}` +/// 需要 HEAD 一次读 302 的 `Location` 才能拿到 id。都不是就说明不用制作镜像。 +fn mirror_file_id(url: &str) -> anyhow::Result> { + if let Some(file_id) = mirror_url_suffix(url, MIRROR_FILES_API) { + return Ok(Some(file_id)); + } + let Some(source) = mirror_url_suffix(url, MIRROR_GAME_BANANA_FILES_API) else { + return Ok(None); + }; + resolve_mirror_file_id(&source).map(Some) +} + +fn resolve_mirror_file_id(source: &str) -> anyhow::Result { + let url = format!("{MIRROR_GAME_BANANA_FILES_API}/{source}"); + let response = mirror_agent() + .head(&url) + .set("User-Agent", &mirror_user_agent()) + .call() + .with_context(|| format!("Failed to resolve mirror file id: {url}"))?; + let location = response.header("Location").unwrap_or_default(); + mirror_url_suffix(location, MIRROR_FILES_API).with_context(|| { + format!("Mirror did not redirect {url} to a file id (Location: {location})") + }) +} + +fn start_mirror(file_id: &str) -> anyhow::Result<()> { + let url = format!("{MIRROR_START_API}/{file_id}"); + let response = mirror_agent() + .post(&url) + .set("User-Agent", &mirror_user_agent()) + .call() + .with_context(|| format!("Failed to ask the mirror to prepare {file_id}"))?; + parse_mirror_response::(&url, response).map(|_| ()) +} + +/// 服务器已经开始上传缓存时,下载进度计数可能还没更新,按镜像 Mod 的做法直接 +/// 认为下载阶段已完成,避免卡在最后一段上。 +fn combined_progress(download: FileProgress, upload: FileProgress) -> (u64, u64) { + let mut download = download; + if upload.current > 0 { + download.current = download.total; + } + ( + download.current + upload.current, + download.total + upload.total, + ) +} + +/// 判断该不该放弃轮询:还没拿到任何进度(total 为 0)时看总耗时,已经有进度 +/// 之后看停滞时间。 +fn mirror_prepare_stalled(total: u64, waited: Duration, since_change: Duration) -> bool { + if total == 0 { + waited >= FIRST_PROGRESS_TIMEOUT + } else { + since_change >= STALL_TIMEOUT + } +} + +fn mirror_status(file_id: &str) -> anyhow::Result<(u64, u64)> { + let url = format!("{MIRROR_STATUS_API}/{file_id}"); + let response = mirror_agent() + .get(&url) + .set("User-Agent", &mirror_user_agent()) + .call() + .with_context(|| format!("Failed to query mirror status of {file_id}"))?; + let status = parse_mirror_response::(&url, response)? + .data + .unwrap_or_default(); + Ok(combined_progress( + status.download_progress, + status.upload_progress, + )) +} + +fn wait_for_mirror( + file_id: &str, + cancel_flag: &AtomicBool, + on_progress: &mut dyn FnMut(u64, u64), +) -> anyhow::Result<()> { + let started = Instant::now(); + let mut last_change = started; + let mut last_current = 0; + loop { + if cancel_flag.load(Ordering::Relaxed) { + bail!("Download canceled"); + } + let (current, total) = mirror_status(file_id)?; + on_progress(current, total); + if total > 0 && current >= total { + return Ok(()); + } + if current != last_current { + last_current = current; + last_change = Instant::now(); + } + let elapsed = started.elapsed(); + if mirror_prepare_stalled(total, elapsed, last_change.elapsed()) + || elapsed >= PREPARE_TIMEOUT + { + bail!("Mirror did not finish preparing {file_id} in time"); + } + std::thread::sleep(POLL_INTERVAL); + } +} + +/// 让镜像服务器开始制作文件,并轮询到进度满,最后返回可以直接下载的镜像地址。 +/// +/// - `Ok(None)`:这个 URL 不是 weg.fan 镜像下载,不需要制作镜像; +/// - `Ok(Some(url))`:镜像已就绪,用返回的地址下载; +/// - `Err(..)`:制作失败(网络、超时、文件不存在…),调用方应当回退到原来的 +/// 直连下载,不能让镜像的新接口坏掉就下不了东西。 +pub fn prepare_mirror( + url: &str, + cancel_flag: &AtomicBool, + on_progress: &mut dyn FnMut(u64, u64), +) -> anyhow::Result> { + let Some(file_id) = mirror_file_id(url)? else { + return Ok(None); + }; + start_mirror(&file_id)?; + wait_for_mirror(&file_id, cancel_flag, on_progress)?; + Ok(Some(format!("{MIRROR_FILES_API}/{file_id}"))) +} + #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Mod { @@ -47,3 +262,107 @@ pub struct Submission { pub sub_category_name: Option, pub latest_update_added_time: String, } + +#[cfg(test)] +mod tests { + use super::*; + + /// 只有 weg.fan 镜像的下载地址才走制作镜像,其它镜像保持原样直连下载。 + #[test] + fn only_weg_fan_download_urls_are_prepared() { + let cancel_flag = AtomicBool::new(false); + let mut on_progress = |_: u64, _: u64| {}; + for url in [ + "https://gamebanana.com/dl/844056", + "https://celestemodupdater.0x0a.de/banana-mirror/844056.zip", + "https://celeste.weg.fan/api/v2/mod/list", + "https://celeste.weg.fan/api/v2/download/mod_dependency_graph.yaml", + "http://127.0.0.1:8080/api/v2/download/gamebanana-files/1", + ] { + let prepared = prepare_mirror(url, &cancel_flag, &mut on_progress) + .expect("non-mirror URLs must not do any request"); + assert!(prepared.is_none(), "{url} should not be prepared"); + } + } + + #[test] + fn file_id_comes_from_the_mirror_url() { + assert_eq!( + mirror_file_id("https://celeste.weg.fan/api/v2/download/files/257995518157984676") + .unwrap() + .as_deref(), + Some("257995518157984676") + ); + // 已经是 files/{id} 的地址不用再解析重定向,也不会走网络。 + assert_eq!( + mirror_url_suffix( + "https://celeste.weg.fan/api/v2/download/files/1?type=mod", + MIRROR_FILES_API + ) + .as_deref(), + Some("1") + ); + assert_eq!( + mirror_url_suffix( + "https://celeste.weg.fan/api/v2/download/files/", + MIRROR_FILES_API + ), + None + ); + assert_eq!( + mirror_url_suffix( + "https://celeste.weg.fan/api/v2/download/files/1/extra", + MIRROR_FILES_API + ), + None + ); + } + + /// 真实 /status 响应:下载 0/13856、上传 13856/13856,视为制作完成。 + #[test] + fn upload_started_means_download_finished() { + let status: MirrorPrepareStatus = serde_json::from_str( + r#"{"downloadProgress":{"current":0,"total":13856},"uploadProgress":{"current":13856,"total":13856}}"#, + ) + .unwrap(); + assert_eq!( + combined_progress(status.download_progress, status.upload_progress), + (27712, 27712) + ); + + let status: MirrorPrepareStatus = serde_json::from_str( + r#"{"downloadProgress":{"current":55695,"total":10246734},"uploadProgress":{"current":0,"total":10246734}}"#, + ) + .unwrap(); + assert_eq!( + combined_progress(status.download_progress, status.upload_progress), + (55695, 20493468) + ); + } + + #[test] + fn prepare_gives_up_on_timeout_but_not_while_progressing() { + // 还没有任何进度:30s 才算超时。 + assert!(!mirror_prepare_stalled( + 0, + Duration::from_secs(29), + Duration::from_secs(29) + )); + assert!(mirror_prepare_stalled( + 0, + Duration::from_secs(30), + Duration::from_secs(30) + )); + // 有进度之后:停滞 10s 算超时。 + assert!(!mirror_prepare_stalled( + 1024, + Duration::from_secs(60), + Duration::from_secs(9) + )); + assert!(mirror_prepare_stalled( + 1024, + Duration::from_secs(60), + Duration::from_secs(10) + )); + } +} diff --git a/src/celemod-ui/locales/en-US.json b/src/celemod-ui/locales/en-US.json index eab511a..e01ae8a 100644 --- a/src/celemod-ui/locales/en-US.json +++ b/src/celemod-ui/locales/en-US.json @@ -604,6 +604,7 @@ "全不选": "Select none", "不删除已启用的 Mod": "Do not delete enabled mods", "已暂停": "Paused", + "制作镜像中": "Preparing mirror", "继续": "Resume", "项": "items", "画布竞赛": "Canvas Contest", diff --git a/src/celemod-ui/locales/zh-CN.json b/src/celemod-ui/locales/zh-CN.json index b81c48f..cc1b40d 100644 --- a/src/celemod-ui/locales/zh-CN.json +++ b/src/celemod-ui/locales/zh-CN.json @@ -606,6 +606,7 @@ "全不选": "全不选", "不删除已启用的 Mod": "不删除已启用的 Mod", "已暂停": "已暂停", + "制作镜像中": "制作镜像中", "继续": "继续", "项": "项", "画布竞赛": "画布竞赛", diff --git a/src/celemod-ui/src/components/DownloadList.scss b/src/celemod-ui/src/components/DownloadList.scss index ed98f6b..98d3d6b 100644 --- a/src/celemod-ui/src/components/DownloadList.scss +++ b/src/celemod-ui/src/components/DownloadList.scss @@ -224,6 +224,12 @@ background: color.change(theme.$fg3, $alpha: 0.13); } +// 制作镜像阶段:进度来自镜像服务器,用另一种颜色和普通下载区分开。 +.download-task-preparing .download-task-status-icon { + color: #e0b56b; + background: color.change(#e0b56b, $alpha: 0.13); +} + .download-task-title { min-width: 0; overflow: hidden; diff --git a/src/celemod-ui/src/components/DownloadList.tsx b/src/celemod-ui/src/components/DownloadList.tsx index aa917fe..ece9cc8 100644 --- a/src/celemod-ui/src/components/DownloadList.tsx +++ b/src/celemod-ui/src/components/DownloadList.tsx @@ -91,6 +91,8 @@ const stateLabel = (task: Download.TaskInfo) => { return { label: _i18n.t("已完成"), icon: "i-tick", tone: "finished" }; if (task.paused) return { label: _i18n.t("已暂停"), icon: "pause", tone: "paused" }; + if (task.mirrorPreparing) + return { label: _i18n.t("制作镜像中"), icon: "download", tone: "preparing" }; return { label: _i18n.t("下载中"), icon: "download", tone: "active" }; }; diff --git a/src/celemod-ui/src/stores/download.ts b/src/celemod-ui/src/stores/download.ts index 13efdbd..7bd1580 100644 --- a/src/celemod-ui/src/stores/download.ts +++ b/src/celemod-ui/src/stores/download.ts @@ -18,6 +18,8 @@ export namespace Download { state: "finished" | "failed" | "pending"; error?: string; progress: number; + /** weg.fan 镜像:正在让镜像服务器制作文件,progress 是制作进度而非下载进度。 */ + mirrorPreparing?: boolean; downloadedBytes: number; totalBytes: number; speedBytesPerSec: number; @@ -36,6 +38,7 @@ interface BackendModTaskInfo { cancel_key: string; status: "Waiting" | "Downloading" | "Finished" | "Failed"; data: string; + mirror_preparing?: boolean; downloaded_bytes: number; total_bytes: number; speed_bytes_per_sec: number; @@ -219,6 +222,7 @@ export const useDownloadStore = create((set, get) => ({ ? 100 : Number.parseFloat(task.data) || 0, error: task.status === "Failed" ? task.data : undefined, + mirrorPreparing: Boolean(task.mirror_preparing), downloadedBytes: task.downloaded_bytes || 0, totalBytes: task.total_bytes || 0, speedBytesPerSec: task.speed_bytes_per_sec || 0, @@ -401,6 +405,7 @@ export const useDownloadStore = create((set, get) => ({ ? 100 : Number.parseFloat(task.data) || 0, error: task.status === "Failed" ? task.data : undefined, + mirrorPreparing: Boolean(task.mirror_preparing), downloadedBytes: task.downloaded_bytes || 0, totalBytes: task.total_bytes || 0, speedBytesPerSec: task.speed_bytes_per_sec || 0,