M205: gh-proxy 键绑定断言、外层重试预算判据、UA 版本号来源、下载设置文档更正

- setting/keys/proxy: 新增断言把 KEY_GH_PROXY 常量与 schema 登记绑起来(键名 / Text{max_len:256} / 默认空串)
- addon/online/download: RETRY_ATTEMPTS 与引擎每源次数是两件事(层级相乘、管的失败类型不同),写清判据并用两条编译期断言钉住 3 与 3x3=9 的边界
- presenter/host/addon/http: UA 版本号改为从 CARGO_PKG_VERSION 拼出,补断言钉住版本 == CARGO_PKG_VERSION 且联系方式段不丢
- docs/download-settings: §3.1 四条 CLI 命令都吃设置(update 是自更新路径),§6 补 M11-M13 三条 CLI 变异,§1/§7 里 gh_proxy 已合入的事实
This commit is contained in:
tower-worker committed 2026-11-09 11:12:59 +08:00
1 parent 4c29cbac3e
commit d4aa3b23f4
4 files changed
+119 -16

No files matched your search

+32 -1
View File
@@ -25,14 +25,45 @@ use std::path::{Path, PathBuf};
use sha1::Digest as _;
use sha2::Sha512;
use crate::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE;
use crate::download::{DownloadEngine, DownloadRequest, Integrity, ResourceKind};
use super::modrinth::HashAlgorithm;
use super::transport::{OnlineError, OnlineErrorKind, OnlineFuture};
/// 最多尝试几次(含首次)。3 次 = 镜像/健康度换源之外还给一次真重试。
/// 本模块外层「整条下载」的预算(含首次):最多取几遍文件。
///
/// # 它**不是**引擎的每源尝试次数,所以不引用 `download::config` 的常量(M205 判定)
///
/// 数值恰好都是 3,但数的不是同一件事。三条判据,任一条都足够说明「合并成一处」会把语义拍平:
///
/// 1. **层级不同,而且是相乘**:引擎那层在 [`ArtifactFetcher`] 里面,按**源**数(镜像展开后每个候选
/// 各有一份预算,见 `DownloadEngine::attempts_for`;源在冷却期时还会缩成 1 次恢复探测)。本模块这
/// 层只数「取一遍 + 复核一遍」,每数一次都要重新走一遍引擎 —— 最坏情况下的真实请求数 ≈
/// [`RETRY_ATTEMPTS`] × 引擎每源预算,合并成一个数等于把最坏请求数变成平方级。
/// 2. **管的失败类型不同**:sha512 不符与字节数不符只有本模块知道(引擎只认 sha1,`Integrity` 没有
/// Sha512 档),这类失败引擎**不会**重试,必须由这一层重取 —— 这一层的预算里有一档是引擎看不见的。
/// 3. **改它的性质不同**:`download.retry_limit` 是用户可见的设置(0~10、进 JSON schema,也就是说引擎
/// 那个默认值会因为**用户的**配置而变);这里的 3 是内容库元数据请求的产品口径,没有设置键,也不该
/// 被某个设置项的出厂默认牵着走。
///
/// **边界**:最坏情况 = [`RETRY_ATTEMPTS`](外层)× [`DEFAULT_MAX_ATTEMPTS_PER_SOURCE`](引擎每个源
/// 一次请求内的尝试)= 9 次真实网络请求。下面两条注解级断言(编译期求值)钉住这个乘积:任一侧想改,
/// 都得先读到这段判据,而不是被一次「统一常量」的顺带改动悄悄翻倍。
pub const RETRY_ATTEMPTS: u32 = 3;
/// 外层预算被改了吗(改之前先读 [`RETRY_ATTEMPTS`] 的 doc:它不是引擎的每源次数)。
const _: () = assert!(
RETRY_ATTEMPTS == 3,
"外层下载预算变了:它不是 download::config 的每源尝试次数,改它要单独一个决定"
);
/// 最坏情况下的真实请求数(3 × 3)——外层与引擎每源这两个「3」不共享来源,乘积才是事实。
const _: () = assert!(
RETRY_ATTEMPTS as u64 * DEFAULT_MAX_ATTEMPTS_PER_SOURCE as u64 == 9,
"最坏真实请求数变了(外层 × 引擎每源):先读 RETRY_ATTEMPTS 的 doc 再改"
);
/// 一次下载请求(只含「引擎需要什么」,不含任何站点字段)。
#[derive(Debug, Clone, Copy)]
pub struct FetchArgs<'a> {
+19
View File
@@ -74,4 +74,23 @@ mod tests {
assert!(gh.doc.contains("留空 = 跟随内置默认"), "{}", gh.doc);
assert!(gh.doc.contains("填 off 关闭"), "{}", gh.doc);
}
/// **`KEY_GH_PROXY` 与 schema 登记的绑定**(M205):`download/config.rs` 那个常量是宿主读数用的
/// 唯一键名,本文件 `specs()` 里是 schema 的登记——两侧各写一个字符串,此前没有任何断言把它们
/// 绑在一起,常量被改名/写错时全绿,行为只在宿主侧静默退化成 `Builtin`(用户填的 `off` 被忽略)。
///
/// 抓得住:常量值写错(如 `download.ghproxy`)⇒ 找不到登记项,第一句红;登记的 `max_len` 从 256
/// 改掉、或默认值从空串改成别的 ⇒ 各自红(类型/默认值是宿主的读侧契约)。
/// 抓不住:两侧**同时**改名(那是一次公开改名,本用例判断不了它对不对,只能保证两侧一致)。
#[test]
fn the_gh_proxy_key_is_bound_to_the_schema_registration() {
use crate::download::config::KEY_GH_PROXY;
use crate::setting::spec::ValueKind;
let Some(gh) = specs().into_iter().find(|spec| spec.key == KEY_GH_PROXY) else {
panic!("下载键常量 {KEY_GH_PROXY:?} 在 schema 里没有登记项(常量值与 specs() 的键名不一致)");
};
assert_eq!(gh.kind, ValueKind::Text { max_len: 256 }, "{}", gh.key);
assert_eq!(gh.default, serde_json::json!(""), "{}", gh.key);
}
}
+43 -2
View File
@@ -26,8 +26,26 @@ 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)";
///
/// ## 为什么这条 UA 要单独看
///
/// 它**只**出现在带 header 的那条路径(`engine.client()` 直发,今天是 CurseForge 的 `X-API-KEY`),
/// 而 Modrinth 的请求走 [`get_via_engine`] —— 那条用的是引擎自己的 UA(`EngineConfig::user_agent`)。
/// 也就是说进程里同时存在两个 UA 字符串,改一处不会带动另一处。
///
/// ## 版本号来自 `CARGO_PKG_VERSION`(M205)
///
/// 此前写死 `Unlucky-Launcher/0.1`,而当时的版本已经是 0.2.3:写死的版本号只会一年一年地落后,而
/// Modrinth 按 UA 做限流与「出问题怎么联系你」的登记 —— UA 里说错版本,就是给了错信息。
///
/// **产品名与联系方式的写法不跟着版本动**:`Unlucky-Launcher/<version> (+<repo URL>)` 这个形状、
/// 以及那个 GitHub 地址,是站点侧识别我们、以及知道去哪儿联系的地方;改它是单独的产品决定,不在
/// 「版本号来源」这条改动里顺手改(断言 `the_user_agent_carries_the_package_version` 钉住了现状)。
pub(super) const USER_AGENT: &str = concat!(
"Unlucky-Launcher/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/AskaEth/Unlucky-Launcher)"
);
/// 最多重试次数(含首次)。
///
@@ -149,6 +167,29 @@ mod tests {
use super::get;
use crate::host::env::HostEnv;
/// UA 里的版本号必须等于这个 crate 的 `CARGO_PKG_VERSION`(M205)。
///
/// 为什么不是「编译过就行」:`concat!` 只保证拼得起来,**写死**一个版本号照样编得过 —— 上一版正是
/// 这样从 0.1 一路落后到 0.2.3(UA 会发到站点,版本号是站点侧看到的事实)。
///
/// 抓得住:版本号又写回任何字面量(`0.1` / `0.2` / 与 `CARGO_PKG_VERSION` 不等的任何值)⇒ 红;
/// UA 形状被拼歪(少了 `Unlucky-Launcher/` 前缀、`(+<联系方式>)` 那一段被删)⇒ 也红。
/// 抓不住:真实 Modrinth 请求下这条 UA 会被怎么限流(单测打不了公网);也抓不住「产品名与联系
/// 方式写得对不对」——这里只是钉住现状不许顺手改,不是对站点口径的验证。
#[test]
fn the_user_agent_carries_the_package_version() {
let ua = super::USER_AGENT;
let version = ua
.strip_prefix("Unlucky-Launcher/")
.and_then(|rest| rest.split(' ').next())
.expect("UA 形状应为 `Unlucky-Launcher/<version> (<联系方式>)`");
assert_eq!(version, env!("CARGO_PKG_VERSION"), "UA: {ua}");
assert!(
ua.contains("(+https://github.com/AskaEth/Unlucky-Launcher)"),
"联系方式那一段是站点要求,不许顺手删:{ua}"
);
}
/// 离线回环 HTTP 服务:只答固定 JSON,数「处理了几条请求 / 接了几条连接」。
///
/// 用 127.0.0.1 而不是真站点:**单测不许打公网**(CI 没网,也不该有)。
+25 -13
View File
@@ -15,7 +15,7 @@
|---|---|---|---|---|
| `download.concurrency` | 8 / 1–256 | **无**(恒用 `EngineConfig::default()`) | **64**(`Semaphore` 容量) | ✅ 接进引擎,范围收成 1–64 |
| `download.retry_limit` | 3 / 0–10 | **无** | 3(碰巧与 schema 相同,但改它无效) | ✅ 接进引擎,范围 0–10(0 与 1 都 = 只试一次) |
| `download.gh_proxy` | (M197 登记,dev 上尚无此键) | JDK 作业那条(M197 自建引擎时覆盖档案) | 地区档案里的内置值(镜像优先时 `gh-proxy.com`) | ✅ 下沉到共享装配点,所有下载路径一致 |
| `download.gh_proxy` | 空串 / ≤256 字符(M197 登记) | 共享引擎装配点(`shared::build` 的 gh-proxy 候选链)+ CLI 的 `download::tuning` | 地区档案里的内置值(镜像优先时 `gh-proxy.com`) | ✅ 下沉到共享装配点,GUI 与四条 CLI 命令一致 |
| `download.region` | auto / auto·cn·global | **无**(`RegionProfile::pinned` 无生产调用点) | 永远按「时区+语言」自动判定 | ❌ 未接(见 §5) |
| `download.mirror_preference` | auto / auto·official·mirror | **无** | 永远按地区模式(镜像优先/直连) | ❌ 未接(见 §5) |
| `download.proxy_mode` | none / none·system·manual | **无**(`http::build_client` 从不调 `.proxy()`) | 只走环境变量代理(reqwest 默认),`system`/`manual` 都不生效 | ❌ 未接(见 §5) |
@@ -68,20 +68,27 @@ config/launcher.json
调优按**数据根**存(与引擎登记处同键),不是进程级:同一进程里多个数据根时互不串味。
## 3.1 哪些路径吃这套设置(哪些不吃)
## 3.1 哪些路径吃这套设置
**四条 CLI 命令与 GUI 都吃**(M203 起。M193 时期这张表把三条 CLI 记成「自建裸引擎、零消费者」,那已不成立——教训见本节末)。
| 路径 | 入口 | 吃设置吗 |
|---|---|---|
| GUI 的**全部**下载:内容库(Modrinth/CurseForge)、版本清单、资源/库/日志文件、字体、JDK 安装、自更新 | `presenter::host::engine::engine` → `core::download::shared::engine_for` | ✅ 吃(并发/尝试次数/gh-proxy 都吃) |
| CLI `ull install` | `crates/cli/src/commands/install/mod.rs:95` 自建 `DownloadEngine::new(EngineConfig::default())` | ❌ **不吃**:并发与尝试次数是**出厂值**(8 / 3),不是用户设置值 |
| CLI `ull java` | `crates/cli/src/commands/java.rs:326` 同上 | ❌ 不吃 |
| CLI `ull pack` | `crates/cli/src/commands/pack.rs:286` 同上 | ❌ 不吃 |
| CLI `ull install` | `crates/cli/src/commands/install/mod.rs:78` 的 `build_engine` → `crates/cli/src/download.rs::engine` | ✅ 吃(并发 / 尝试次数 / gh-proxy) |
| CLI `ull java` | `crates/cli/src/commands/java.rs:312` 同上 | ✅ 吃(并发 + 尝试次数 + gh-proxy) |
| CLI `ull pack` | `crates/cli/src/commands/pack.rs:286` 同上 | ✅ 吃(并发 / 尝试次数 / gh-proxy) |
| CLI `ull update`(**自更新路径**) | `crates/cli/src/commands/update.rs:64` 复用 `install::build_engine` | ✅ 吃(**并发这条路径上不可观测**:自更新只下**一个** artifact,闸门开 1 还是 8 都只有一条连接;真正随设置变的是每源尝试次数与 gh-proxy 前缀) |
也就是说 `shared::build` 是 **presenter 侧**唯一一处宿主装配,但**不是全仓唯一**:CLI 有三处自建引擎,
它们在 M193 之前也不读设置(一直是裸 `EngineConfig::default()`),而 M193 把默认并发从 64 改成 8 之后,
`ull install` / `ull java` / `ull pack` 的并发**跟着从 64 变成 8**(出厂值变化,不是读到了设置)。
要让它们也吃设置,得改成走 `shared::engine_for` + 从 CLI 自己的 store 灌 tuning —— 已立 finding,
不在 M193 的 scope(`crates/cli`)。
四条 CLI 路径共用同一次装配:`crates/cli/src/download.rs` 从 **CLI 自己的** `launcher.json`(与
`ull config set` 同一份文件)读三个 `download.*` 键 → `DownloadTuning::from_settings`(默认值/夹取/回落
都在 core)→ `shared::engine_with_tuning(<cache 根>, tuning)`。所以 `presenter` 的 `shared::build` 不再是
**全仓唯一**的装配点,但装配规则仍只有 core 那一份。
回落仍是静默的:`SettingStore::load` 永不失败(坏值只进 `problems()`),类型不对 → `None`,越界 → 出厂
值,不报错、不中断安装。历史教训保留在此:M193 把出厂并发从 64 改成 8 之后,当时还在用裸
`EngineConfig::default()` 的那几条命令**静默**跟着降了——「用出厂值」和「读到了设置」在输出上一模一样,
这正是 §6 里三条 CLI 变异要抓的东西。
## 4. 三态语义:`download.gh_proxy`
@@ -128,6 +135,9 @@ config/launcher.json
| M8 | 下载设置页的 gh-proxy 行改回 `("Preferred source", "Automatic (region)")` | `the_downloads_rows_state_semantics…` | 红 |
| M9 | `from_settings` 的尝试次数去掉 `.max(1)`(0 原样交给引擎) | `download::config::tests::missing_or_illegal_values_fall_back_to_the_defaults` | 红(`left: 0 / right: 1`) |
| M10 | schema 描述里的「1~64」改成「1~99」(第三处字面量) | `download::config::tests::the_key_docs_quote_the_real_limits` | 红 |
| M11 | `commands::pack::build_engine` 换回裸 `EngineConfig::default()`(其余三条不动) | `cli::download::tests::pack_engine_takes_the_concurrency_from_the_setting_store` | 红(探针 8 ≠ 2),另四条 CLI 用例绿 |
| M12 | `commands::update::engine` 换回裸 `EngineConfig::default()` | `cli::download::tests::update_engine_takes_the_concurrency_from_the_setting_store` | 红(8 ≠ 4),另四条绿 |
| M13 | `download::tuning` 不读 `KEY_GH_PROXY`(第三参传 `None`) | `cli::download::tests::the_gh_proxy_setting_reaches_the_candidate_chain` | 红(`off` 档仍有 gh-proxy 候选) |
## 6.1 已知的观感问题(P3-1,归属下一条 mission)
@@ -158,8 +168,10 @@ iced 侧的 `rows` 是 `&'static str`,仍是**已知的第二真相源**,等
配置读点;「2 条真连接同时下载时服务器只看到 2 条」和「某个源真的被试 3 次」没有在真网络下跑过
(离线回环能验连接复用,验不了限流的端到端时序)。
* **macOS / Linux 未验**:本轮只在 Windows 上编译与跑测试。
* **`download.gh_proxy` 的端到端未验**:那个键由 M197 登记,尚未合入 dev;`setting::document` 只认
已登记的键,所以「写文件 ⇒ 读出来 ⇒ 进候选链」这一段在 dev 上跑不通(core 侧「调优 → 候选链」
已守)。M197 合入后应补一条宿主侧端到端用例。
* **`download.gh_proxy` 的端到端只到「候选生成」为止**(键已合入,M197/M203 之后不再是「登记了但读不到」):
三处各有守卫——core 的「调优 → 候选链」(`gh_proxy_override_reaches_the_profile_and_the_chain`)、
CLI 的「文件 → 候选链」(`the_gh_proxy_setting_reaches_the_candidate_chain`)、GUI 的「文件 → 宿主 →
core 三态」(`the_gh_proxy_key_travels_from_the_file_to_the_core_tri_state`)。**没验的是真网络下那个前缀
能不能通**:离线只验候选链的生成,没发过一次真的 `gh-proxy.com` 请求。
* **iced 设置页仍然不是可改控件**:它画的是语义行(默认值/区间/三态),真值不显示——因为 iced 侧
还没有「presenter 设置快照 → app → `Groups.download`」的数据通路(另立 mission,见 finding)。