diff --git a/crates/core/src/addon/online/download.rs b/crates/core/src/addon/online/download.rs index 5befc04..9213a4c 100644 --- a/crates/core/src/addon/online/download.rs +++ b/crates/core/src/addon/online/download.rs @@ -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> { diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs index 331dbf4..832d2ac 100644 --- a/crates/core/src/download/config.rs +++ b/crates/core/src/download/config.rs @@ -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"; /// 引擎配置。 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..677c36a 100644 --- a/crates/presenter/src/host/addon/http.rs +++ b/crates/presenter/src/host/addon/http.rs @@ -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/`,无联系方式)。两条 UA 同时活着,改一处不带另一处; +/// [`get_via_engine`] 那条无 header 的分支今天只有单测在用(生产里 Modrinth 不走它)。 +/// +/// ## 版本号来自 `CARGO_PKG_VERSION`(M205) +/// +/// 此前写死 `Unlucky-Launcher/0.1`,而当时的版本已经是 0.2.3:写死的版本号只会一年一年地落后,而 +/// 站点按 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 +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/ (<联系方式>)`"); + 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)。