From d4aa3b23f40768a1a0ff1d4b525673a9ff0ae8d5 Mon Sep 17 00:00:00 2001 From: tower-worker Date: Mon, 9 Nov 2026 11:12:59 +0800 Subject: [PATCH] =?UTF-8?q?M205:=20gh-proxy=20=E9=94=AE=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E3=80=81=E5=A4=96=E5=B1=82=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E5=88=A4=E6=8D=AE=E3=80=81UA=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7=E6=9D=A5=E6=BA=90=E3=80=81=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=96=87=E6=A1=A3=E6=9B=B4=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 已合入的事实 --- crates/core/src/addon/online/download.rs | 33 ++++++++++++++++- crates/core/src/setting/keys/proxy.rs | 19 ++++++++++ crates/presenter/src/host/addon/http.rs | 45 ++++++++++++++++++++++-- docs/download-settings.md | 38 +++++++++++++------- 4 files changed, 119 insertions(+), 16 deletions(-) diff --git a/crates/core/src/addon/online/download.rs b/crates/core/src/addon/online/download.rs index 5befc04..8c2716a 100644 --- a/crates/core/src/addon/online/download.rs +++ b/crates/core/src/addon/online/download.rs @@ -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> { diff --git a/crates/core/src/setting/keys/proxy.rs b/crates/core/src/setting/keys/proxy.rs index 9608c73..c6b4924 100644 --- a/crates/core/src/setting/keys/proxy.rs +++ b/crates/core/src/setting/keys/proxy.rs @@ -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); + } } diff --git a/crates/presenter/src/host/addon/http.rs b/crates/presenter/src/host/addon/http.rs index 9e508cb..00b93e5 100644 --- a/crates/presenter/src/host/addon/http.rs +++ b/crates/presenter/src/host/addon/http.rs @@ -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/ (+)` 这个形状、 +/// 以及那个 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/ (<联系方式>)`"); + 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 没网,也不该有)。 diff --git a/docs/download-settings.md b/docs/download-settings.md index ded21a9..5450447 100644 --- a/docs/download-settings.md +++ b/docs/download-settings.md @@ -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(, 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)。