Files
Unlucky-Launcher/crates/core/src/download/progress.rs
T
AskaEth ce463cc9b5 feat(app-iced): Java 页「下载一个 JDK」+ 三个设置键(M197)
用户原话:「我们内置了那么多镜像源 甚至用户不能手动选择指定版本下载。」
「自动下载 jdk17 作为基准版本,后续在设置中提供多版本可供用户下载。」

- core:新增 `game::java::catalog`(8/11/17/21/25 五档 × 能承接的发行版 × 「该装哪档」),
  数据全部来自 `JdkVendor::supports` 与 `vendor::order`,不复制第二份真相。
- progress:`SourceFailed` 补一个结构化的 `kind`(在这之前只有中文 `reason`,双语界面
  要么放弃语言、要么放弃「哪个源为什么退避」这条用户可见信息)。
- app-iced:`downloads` 模块真下(后台线程 + `ProgressEmitter` + 快照),三态(选择列表 /
  下载中 / 失败)都能说出「命中的源 / 退避原因 / 试过哪些源」;成功后重扫并(在
  `java.auto_install` 开着且没有默认 JDK 时)把新装的那份设为默认。
- 三个设置键做成可改 UI:`java.installRoot` / `java.vendorPriority` / `java.auto_install`,
  各自带「现在生效什么、从哪一层来」(数据目录模式 / 地区默认序),改完回读落盘值。
- `State::satisfied` 换成带方向的 `Fit`:1.12.2 + Java 21 不再被说成「低于要求」。
- i18n:删掉 `SettingsJavaEmptyBody` / `SettingsJavaAddHint` 的 markdown 反引号;
  `SettingsJavaNeverScannedBody` 改成不与首屏自动扫矛盾的表述。
- 出图三张(选择列表 / 下载中 / 失败)在 DevDocs/artifacts/m197/。

顺带拆文件守 400 行:`golden/tail.rs` 的 M174 段搬到 `golden/tail_ai.rs`,
`pages/settings/java/{context,prefs,prefs_store,downloads,download_view}` 各自成模块。
2026-11-08 20:49:56 +08:00

203 lines
5.7 KiB
Rust

//! 进度事件与发射器。
//!
//! 事件是「字节级 + 文件级」双层的载体(设计文档 §5):文件级由 `Started/Completed/Failed`
//! 表达,字节级由节流后的 `Progress` 表达。`total_bytes` 未知时给 `None`——不做假进度。
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use super::error::DownloadErrorKind;
use super::types::{DownloadId, HashAlgo, ResourceKind};
/// 一条下载进度事件。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum ProgressEvent {
/// 开始一次下载(含候选数量与总字节预算)。
Started {
/// 关联 id。
id: DownloadId,
/// 资源类型。
kind: ResourceKind,
/// 逻辑 URL。
url: String,
/// 落盘路径。
dest: String,
/// 候选源数量。
candidates: usize,
/// 总字节数(未知为 `None`)。
total_bytes: Option<u64>,
},
/// 字节级进度(节流后)。
Progress {
/// 关联 id。
id: DownloadId,
/// 已下载字节。
downloaded: u64,
/// 总字节数(未知为 `None`)。
total_bytes: Option<u64>,
},
/// 某个源失败(单次尝试)。
SourceFailed {
/// 关联 id。
id: DownloadId,
/// 源标识。
source: String,
/// 失败分类(**结构化**:文案层按这个类型选 i18n key,`reason` 只进日志)。
///
/// M197 补的:在这之前只有 `reason`(core 的中文句子),GUI 要么放弃双语、
/// 要么放弃「命中哪个源 / 为什么退避」这一条用户可见信息。
kind: DownloadErrorKind,
/// 失败原因(短描述)。
reason: String,
/// 第几次尝试(从 0 起)。
attempt: u32,
/// 是否还会在同源重试。
will_retry: bool,
/// 下次重试的等待毫秒数。
retry_in_ms: u64,
},
/// 切换到下一个候选源。
SourceSwitched {
/// 关联 id。
id: DownloadId,
/// 原源。
from: String,
/// 新源。
to: String,
},
/// 进入校验阶段。
Verifying {
/// 关联 id。
id: DownloadId,
/// 算法。
algo: HashAlgo,
/// 期望摘要前缀(8 位,够定位又不太啰嗦)。
expected_prefix: String,
},
/// 下载成功。
Completed {
/// 关联 id。
id: DownloadId,
/// 字节数。
bytes: u64,
/// 是否命中内容寻址缓存。
from_cache: bool,
/// 成功的源。
source: String,
/// 耗时毫秒。
elapsed_ms: u64,
},
/// 下载失败(所有候选源都失败或不可重试)。
Failed {
/// 关联 id。
id: DownloadId,
/// 错误分类。
kind: DownloadErrorKind,
/// 错误描述。
message: String,
},
}
impl ProgressEvent {
/// 事件归属的下载 id。
#[must_use]
pub const fn id(&self) -> DownloadId {
match self {
Self::Started { id, .. }
| Self::Progress { id, .. }
| Self::SourceFailed { id, .. }
| Self::SourceSwitched { id, .. }
| Self::Verifying { id, .. }
| Self::Completed { id, .. }
| Self::Failed { id, .. } => *id,
}
}
}
/// 进度发射器:没有接收端时退化为空操作(core 不关心谁在听)。
#[derive(Debug, Clone, Default)]
pub struct ProgressEmitter {
tx: Option<UnboundedSender<ProgressEvent>>,
}
impl ProgressEmitter {
/// 静默发射器(测试、后台静默下载用)。
#[must_use]
pub const fn silent() -> Self {
Self { tx: None }
}
/// 建一对(发射器, 接收端);接收端由 app 层转成 IPC DTO。
#[must_use]
pub fn channel() -> (Self, UnboundedReceiver<ProgressEvent>) {
let (tx, rx) = mpsc::unbounded_channel();
(Self { tx: Some(tx) }, rx)
}
/// 发一条事件;接收端已关闭则静默丢弃。
pub fn emit(&self, event: ProgressEvent) {
if let Some(tx) = &self.tx {
let _ = tx.send(event);
}
}
/// 是否没有接收端。
#[must_use]
pub const fn is_silent(&self) -> bool {
self.tx.is_none()
}
}
/// 进度节流器:距上次发射不足 `interval` 且未完成时压掉本次事件。
#[derive(Debug, Clone)]
pub struct ProgressThrottle {
interval: Duration,
last_emit: Option<Instant>,
}
impl ProgressThrottle {
/// 构造节流器。
#[must_use]
pub const fn new(interval: Duration) -> Self {
Self {
interval,
last_emit: None,
}
}
/// 节流间隔。
#[must_use]
pub const fn interval(&self) -> Duration {
self.interval
}
/// 是否应该发射本次进度(`finished = true` 时无条件放行)。
pub fn should_emit(&mut self, now: Instant, finished: bool) -> bool {
if finished {
self.last_emit = Some(now);
return true;
}
match self.last_emit {
Some(last) if now.duration_since(last) < self.interval => false,
_ => {
self.last_emit = Some(now);
true
}
}
}
/// 复位(换源/重试时重新计时)。
pub fn reset(&mut self) {
self.last_emit = None;
}
}
impl Default for ProgressThrottle {
fn default() -> Self {
Self::new(Duration::from_millis(250))
}
}