Merge branch 'feat/m205-debtsweep2-gh-proxy-assertion-user'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-09 11:36:57 +08:00
commit a48d055118
5 files changed
+128 -20

No files matched your search

+35 -1
View File
@@ -25,14 +25,48 @@ 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`](外层)× 引擎每源预算(`download.retry_limit`,出厂
/// [`DEFAULT_MAX_ATTEMPTS_PER_SOURCE`])。**出厂档**是 3 × 3 = 9;用户把 `retry_limit` 调到 N 时是
/// 3 × N(schema 上限 `MAX_ATTEMPTS_PER_SOURCE` = 10 ⇒ 30)——**9 只是出厂档的数,不是事实上限**,
/// 这正是本仓反复吃亏的「把出厂值说成上限」。下面两条注解级断言(编译期求值)钉的也只是**出厂档**:
/// 出厂默认想改,就得先读到这段判据,而不是被一次「统一常量」的顺带改动悄悄翻倍。
pub const RETRY_ATTEMPTS: u32 = 3;
/// 外层预算被改了吗(改之前先读 [`RETRY_ATTEMPTS`] 的 doc:它不是引擎的每源次数)。
const _: () = assert!(
RETRY_ATTEMPTS == 3,
"外层下载预算(出厂档 3)变了:它不是 download::config 的每源尝试次数,改它要单独一个决定"
);
/// **出厂档**最坏请求数(外层 3 × 引擎每源出厂 3)——两个「3」不共享来源,乘积才是事实。
/// 用户把 `download.retry_limit` 调大后真实最坏是 3 × N,与本断言无关(那头是运行时配置,编译期钉不了)。
const _: () = assert!(
RETRY_ATTEMPTS as u64 * DEFAULT_MAX_ATTEMPTS_PER_SOURCE as u64 == 9,
"出厂档最坏真实请求数变了(外层 3 × 引擎每源出厂 3 = 9;用户把 retry_limit 调到 N 时是 3 × N):先读 doc"
);
/// 一次下载请求(只含「引擎需要什么」,不含任何站点字段)。
#[derive(Debug, Clone, Copy)]
pub struct FetchArgs<'a> {
+3 -3
View File
@@ -35,9 +35,9 @@ pub const KEY_CONCURRENCY: &str = "download.concurrency";
pub const KEY_RETRY_LIMIT: &str = "download.retry_limit";
/// gh-proxy 前缀覆盖键(三态语义见 [`gh_proxy_override`])。**已登记**:`setting::keys::proxy` 的
/// `specs()` 里有它(文本 / 出厂空串),presenter 与 CLI 的宿主都按这个常量读,读不到 ⇒
/// [`GhProxyOverride::Builtin`]。**弱在哪(别当死键删)**:本文件没有断言把它与 schema 登记绑起来,
/// 常量值被改名或写错时这里不红,行为只会在宿主侧静默退化成 `Builtin`(用户填的 `off` 被忽略);
/// 拦住行为退化的是 `empty_gh_proxy_means_follow_builtin_not_off` 与 `shared` 的「调优 → 候选链」。
/// [`GhProxyOverride::Builtin`]。**绑定在哪(别当死键删)**:常量与 schema 登记的对应关系由
/// `setting::keys::proxy` 的 `the_gh_proxy_key_is_bound_to_the_schema_registration`(M205)钉住:写错就红;
/// 它抓不住两侧同时改名(公开改名)。行为退化由 `empty_gh_proxy_means_follow_builtin_not_off` 与 `shared` 的候选链拦。
pub const KEY_GH_PROXY: &str = "download.gh_proxy";
/// 引擎配置。
+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);
}
}
+46 -3
View File
@@ -25,9 +25,29 @@ 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)";
/// 站点要求的 User-Agent(`(+联系方式)` 这一段来自 Modrinth 的 UA 政策:带联系方式,否则限流)。
///
/// ## 为什么这条 UA 要单独看
///
/// 它**只**出现在带 header 的那条路径(`engine.client()` 直发),而今天走这条路径的只有 CurseForge 的
/// 搜索(`x-api-key`)。**Modrinth 看到的是另一个 UA**:内容库的 Modrinth 请求经
/// `core::addon::online::EngineTransport` → `DownloadEngine::download_bytes`,带的是引擎自己的
/// `EngineConfig::user_agent`(`UnluckyLauncher/<ver>`,无联系方式)。两条 UA 同时活着,改一处不带另一处;
/// [`get_via_engine`] 那条无 header 的分支今天只有单测在用(生产里 Modrinth 不走它)。
///
/// ## 版本号来自 `CARGO_PKG_VERSION`(M205)
///
/// 此前写死 `Unlucky-Launcher/0.1`,而当时的版本已经是 0.2.3:写死的版本号只会一年一年地落后,而
/// 站点按 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 +169,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)。