From e3ae71ac37c334be4da2656fe88e0beabb540ef1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 12:27:59 +0800 Subject: [PATCH] =?UTF-8?q?M207:=20UA=20=E6=94=B6=E6=88=90=E4=B8=80?= =?UTF-8?q?=E4=BB=BD=E5=B8=A6=E8=81=94=E7=B3=BB=E6=96=B9=E5=BC=8F=20+=20?= =?UTF-8?q?=E4=B8=A4=E5=A4=84=E5=AD=97=E7=AC=A6=E4=B8=B2=E5=89=AF=E6=9C=AC?= =?UTF-8?q?=EF=BC=88gh=5Fproxy=20=E9=94=AE=E3=80=81=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) UA 只有一份定义:core 的 `download::http::USER_AGENT` (`Unlucky-Launcher/ (+仓库 URL)`,不用邮箱)。 - 引擎侧:`EngineConfig::default().user_agent` 直接取它(config.rs 等量替换,仍是 400 行) - 带 header 侧:presenter 不再持有 UA 字面量,`get_with_headers` 发 `engine.config().user_agent` M205 复审核实过的缺口:Modrinth 收到的是引擎那份 `UnluckyLauncher/`(无连字符、无联系方式), 带联系方式的那份只发给 CurseForge 搜索 —— Modrinth 的 UA 政策在真正会被限流的路径上没被满足。 断言:core 一条钉形状(版本 == CARGO_PKG_VERSION、联系方式段 == 仓库 URL、不含 `@`); presenter 一条在回环上抓**发出去的字节**(两条路径的 UA 逐字节相同且 == EngineConfig::default())。 2) `app-iced/.../java/downloads/engine.rs`:`"download.gh_proxy"` 字面量改成 `KEY_GH_PROXY`。 3) `presenter/src/host/addon/http.rs` 的用例注释改成事实(Modrinth 走 core 的 EngineTransport, `get` 的无 header 分支生产零调用者),模块说明表格同步。 4) `docs/download-settings.md`:§6.1 / §6.1.1 / §7 按 M201(iced 真控件 + 快照通路)之后的现状重写。 --- .../pages/settings/java/downloads/engine.rs | 12 +- crates/core/src/download/config.rs | 4 +- crates/core/src/download/http.rs | 47 ++++- crates/presenter/src/host/addon/http.rs | 166 ++++++++++-------- docs/download-settings.md | 46 +++-- 5 files changed, 184 insertions(+), 91 deletions(-) diff --git a/crates/app-iced/src/pages/settings/java/downloads/engine.rs b/crates/app-iced/src/pages/settings/java/downloads/engine.rs index 1d77c5b..900a36b 100644 --- a/crates/app-iced/src/pages/settings/java/downloads/engine.rs +++ b/crates/app-iced/src/pages/settings/java/downloads/engine.rs @@ -24,6 +24,7 @@ use std::path::Path; use std::sync::Arc; use unlucky_core::download::cache::now_ms; +use unlucky_core::download::config::KEY_GH_PROXY; use unlucky_core::download::region::{decide_from, load_cached}; use unlucky_core::download::region_system::detect_signals; use unlucky_core::download::{ @@ -65,15 +66,14 @@ pub fn gh_proxy_from(raw: &str) -> GhProxy { } /// 从 `launcher.json` 读 `download.gh_proxy`(读不到 / 没有文件 = 跟随内置默认)。 +/// +/// 键名走 core 的 [`KEY_GH_PROXY`],不写字面量:同一件事的第二个字符串就是下一次「改名只改一处」的种子 +/// (M207:重试次数、UA 之后又一处副本)。绑定关系由 core 的 +/// `setting::keys::proxy::the_gh_proxy_key_is_bound_to_the_schema_registration` 钉住。 #[must_use] pub fn gh_proxy_of_config(config: &Path) -> GhProxy { let store = SettingStore::load(config, launcher_schema()); - gh_proxy_from( - store - .doc() - .effective_str("download.gh_proxy") - .unwrap_or_default(), - ) + gh_proxy_from(store.doc().effective_str(KEY_GH_PROXY).unwrap_or_default()) } /// 带进度发射器的引擎(装配口径与 `download::shared::build` 一致,只多挂一个 emitter + gh-proxy 覆盖)。 diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs index 832d2ac..20929a1 100644 --- a/crates/core/src/download/config.rs +++ b/crates/core/src/download/config.rs @@ -51,7 +51,7 @@ pub struct EngineConfig { pub connect_timeout: Duration, /// 单次读超时(读空闲判定,不是整体超时),默认 30s。 pub read_timeout: Duration, - /// User-Agent。 + /// User-Agent(唯一定义在 `http::USER_AGENT`;两条 HTTP 路径发的都是这一份,别在这里另拼)。 pub user_agent: String, /// 进度事件节流间隔,默认 250ms。 pub progress_throttle: Duration, @@ -70,7 +70,7 @@ impl Default for EngineConfig { max_attempts_per_source: DEFAULT_MAX_ATTEMPTS_PER_SOURCE, connect_timeout: Duration::from_secs(10), read_timeout: Duration::from_secs(30), - user_agent: concat!("UnluckyLauncher/", env!("CARGO_PKG_VERSION")).to_string(), + user_agent: super::http::USER_AGENT.to_string(), progress_throttle: Duration::from_millis(250), backoff: BackoffPolicy::default(), chunked: ChunkedConfig::default(), diff --git a/crates/core/src/download/http.rs b/crates/core/src/download/http.rs index 6e6fd66..9db7b1e 100644 --- a/crates/core/src/download/http.rs +++ b/crates/core/src/download/http.rs @@ -18,6 +18,24 @@ use super::error::{DownloadError, DownloadErrorKind}; /// 重定向上限(承 HMCL 的 20 跳)。 pub const MAX_REDIRECTS: usize = 20; +/// 站点可见的 `User-Agent`:`<产品名>/<版本> (+<仓库 URL>)`。**全仓唯一一份**(M207)。 +/// +/// 三段各自有理由: +/// * `Unlucky-Launcher` —— 连字符写法(与仓库名一致)。此前引擎那一份是 `UnluckyLauncher`,于是同一个 +/// 产品对 Modrinth 与 CurseForge 报两个名字; +/// * 版本取 `CARGO_PKG_VERSION` —— 写死的话只会一年一年落后(M205 之前写死成 `0.1`,那时版本已是 0.2.3); +/// * `(+https://github.com/AskaEth/Unlucky-Launcher)` —— Modrinth 的 UA 政策要求带联系方式,否则限流。 +/// 联系方式用**仓库 URL**、不用邮箱:这个字符串会出现在公开流量里。 +/// +/// 消费者两处,都是引用、不是副本:`EngineConfig::default().user_agent`([`super::config`] 取它), +/// 与 [`build_client`] 的 `.user_agent(…)`(reqwest 客户端的默认 UA)。于是引擎的每一条请求——包括 +/// presenter 里 `engine.client()` 直发的那条——发的都是这一份。 +pub const USER_AGENT: &str = concat!( + "Unlucky-Launcher/", + env!("CARGO_PKG_VERSION"), + " (+https://github.com/AskaEth/Unlucky-Launcher)" +); + /// 构造共享的 `reqwest::Client`(连接池、超时、重定向策略一次定好)。 pub fn build_client(cfg: &EngineConfig) -> Result { let mut headers = HeaderMap::new(); @@ -132,7 +150,7 @@ pub fn html_mirror_reason( mod tests { use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH, CONTENT_TYPE}; - use super::{html_mirror_reason, MAX_REDIRECTS}; + use super::{html_mirror_reason, MAX_REDIRECTS, USER_AGENT}; fn headers(content_type: Option<&str>, length: Option) -> HeaderMap { let mut map = HeaderMap::new(); @@ -180,4 +198,31 @@ mod tests { fn redirect_cap_is_still_twenty() { assert_eq!(MAX_REDIRECTS, 20); } + + /// UA 的三段都得对:产品名连字符写法、版本 == `CARGO_PKG_VERSION`、联系方式是仓库 URL 且不是邮箱。 + /// + /// 为什么不是「编译过就行」:`concat!` 只保证拼得起来——版本写回字面量、`(+…)` 那一段被顺手删掉、 + /// 产品名改回无连字符写法,都照样编得过。而这条 UA 会真的发到 Modrinth(M207 之前发的不是它)。 + /// + /// 抓得住:版本写死(`0.1` / `0.2` / 与 `CARGO_PKG_VERSION` 不等的任何值)⇒ 红;产品名回到 + /// `UnluckyLauncher`(无连字符)⇒ 红;联系方式段被删、被改或换成邮箱 ⇒ 红。 + /// 抓不住:真实 Modrinth / CurseForge 收到这条 UA 之后怎么限流(单测不出网),也抓不住 + /// 「仓库 URL 那个仓库还在不在」——这里钉的是字符串形状,不是站点的接受度。 + #[test] + fn the_user_agent_carries_the_package_version_and_the_repo_url() { + let ua = USER_AGENT; + let rest = ua + .strip_prefix("Unlucky-Launcher/") + .expect("产品名是连字符写法(与仓库名一致)"); + let (version, contact) = rest.split_once(' ').expect("版本之后是联系方式段"); + assert_eq!(version, env!("CARGO_PKG_VERSION"), "UA: {ua}"); + assert_eq!( + contact, "(+https://github.com/AskaEth/Unlucky-Launcher)", + "联系方式那一段是 Modrinth 的 UA 政策要求,不许顺手删:{ua}" + ); + assert!( + !ua.contains('@'), + "联系方式用仓库 URL、不用邮箱(公开流量里不放个人联系方式):{ua}" + ); + } } diff --git a/crates/presenter/src/host/addon/http.rs b/crates/presenter/src/host/addon/http.rs index 677c36a..60ff41f 100644 --- a/crates/presenter/src/host/addon/http.rs +++ b/crates/presenter/src/host/addon/http.rs @@ -4,7 +4,7 @@ //! //! | 请求 | 走哪 | 拿到什么 | //! |---|---|---| -//! | 无自定义 header(Modrinth 的全部请求) | [`DownloadEngine::download_bytes`] | **信号量限流 + 源健康度 + 指数退避 + 分类错误**,全是 core 现成的 | +//! | 无自定义 header([`get_via_engine`],**生产零调用者**:内容库的 Modrinth 走 core 的 `EngineTransport`,同一条 `download_bytes`) | [`DownloadEngine::download_bytes`] | **信号量限流 + 源健康度 + 指数退避 + 分类错误**,全是 core 现成的 | //! | 带 header(只有 CurseForge 的 `X-API-KEY`) | `engine.client()` 直发 | 引擎的请求体没有 header 字段,只能自己发;代价是这条路径自己带一个 429/5xx 退避 | //! //! 两条路径用的是**同一个引擎**,而引擎是进程级共享的(M165:`host::engine::engine` → @@ -12,6 +12,21 @@ //! 以及它们与真正的文件下载,共用同一条连接池与同一套限流;`block_on` 也跑在同一个常驻运行时上, //! 池里的空闲连接不会随着某次调用结束而变成死连接。 //! +//! ## UA 只有一份定义(M207) +//! +//! `User-Agent` 定义在 core 的 `download::http::USER_AGENT`:`Unlucky-Launcher/ +//! (+https://github.com/AskaEth/Unlucky-Launcher)`。本文件**不拼** UA——带 header 的路径发的是 +//! `engine.config().user_agent`,无 header 的路径发的是同一个客户端的默认 UA,所以两条路径、以及真正的 +//! 文件下载,站点看到的都是同一个字符串。 +//! +//! M205 复审核实过的旧状:`api.modrinth.com` 收到的是引擎那份 `UnluckyLauncher/`(无连字符、 +//! 无联系方式),带联系方式的 `Unlucky-Launcher/ (+repo)` 只发给 CurseForge 搜索——而 Modrinth 的 +//! UA 政策正是「带联系方式,否则限流」,也就是说那条政策在**真正会被限流的那条路上**没被满足。两份 UA +//! 同时活着、改一处不带另一处,所以 M207 把定义收进 core 一处。 +//! +//! 钉住它的是回环上的字节级用例 `both_download_paths_send_the_same_user_agent`:两条路径发出去的 +//! `User-Agent` 必须逐字节相同,且等于 `EngineConfig::default().user_agent`。 +//! //! ## 密钥纪律 //! //! 带 header 的那条路径**只把 header 值喂给请求**:不进日志、不进错误串。错误串里只有 URL @@ -25,30 +40,6 @@ use unlucky_core::download::{DownloadRequest, ResourceKind}; use super::super::env::HostEnv; -/// 站点要求的 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)" -); - /// 最多重试次数(含首次)。 /// /// **不在这里写数字**:取值就是下载引擎的出厂默认 @@ -85,6 +76,8 @@ fn get_via_engine(env: &HostEnv, url: &str) -> Result { /// 带 header 的请求(只有 CF 走这条):429 / 5xx / 连接类失败按退避重试。 fn get_with_headers(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result { let engine = super::super::engine::engine(env); + // UA 取自引擎配置(**不在这里拼**):定义只有 core 那一份,这条路径与引擎路径发的必须是同一个字符串。 + let user_agent = engine.config().user_agent.clone(); let owned: Vec<(String, String)> = headers .iter() .map(|(name, value)| ((*name).to_string(), (*value).to_string())) @@ -92,7 +85,10 @@ fn get_with_headers(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Resul for attempt in 0..ATTEMPTS { let result = block_on(async { - let mut builder = engine.client().get(url).header("User-Agent", USER_AGENT); + let mut builder = engine + .client() + .get(url) + .header("User-Agent", user_agent.as_str()); for (name, value) in &owned { builder = builder.header(name.as_str(), value.as_str()); } @@ -169,44 +165,20 @@ mod tests { use super::get; use crate::host::env::HostEnv; - /// UA 里的版本号必须等于这个 crate 的 `CARGO_PKG_VERSION`(M205)。 + /// 离线回环 HTTP 服务:只答固定 JSON,把每条请求的**原始请求头**送回主线程。 /// - /// 为什么不是「编译过就行」:`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 没网,也不该有)。 - fn loopback(expected_requests: usize) -> (String, std::sync::mpsc::Receiver<(usize, usize)>) { + /// 用 127.0.0.1 而不是真站点:**单测不许打公网**(CI 没网,也不该有)。回传原始头部是为了抓 + /// 「站点实际收到的 `User-Agent`」——那是唯一能证明两条路径发同一个字符串的地方。 + fn loopback(expected_requests: usize) -> (String, std::sync::mpsc::Receiver>) { let listener = TcpListener::bind("127.0.0.1:0").expect("绑定回环端口"); let addr = listener.local_addr().expect("取端口"); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let mut conns = 0usize; - let mut requests = 0usize; - while requests < expected_requests { + let mut heads: Vec = Vec::new(); + while heads.len() < expected_requests { let Ok((mut socket, _)) = listener.accept() else { break; }; - conns += 1; let _ = socket.set_read_timeout(Some(Duration::from_secs(5))); let mut seen = Vec::new(); loop { @@ -218,28 +190,39 @@ mod tests { if !seen.windows(4).any(|window| window == b"\r\n\r\n") { continue; } - requests += 1; + heads.push(String::from_utf8_lossy(&seen).into_owned()); let body = br#"{"ok":true}"#; - let head = format!( + let reply = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len() ); - if socket.write_all(head.as_bytes()).is_err() || socket.write_all(body).is_err() + if socket.write_all(reply.as_bytes()).is_err() + || socket.write_all(body).is_err() { break; } seen.clear(); - if requests >= expected_requests { - let _ = tx.send((conns, requests)); + if heads.len() >= expected_requests { + let _ = tx.send(heads); return; } } } - let _ = tx.send((conns, requests)); + let _ = tx.send(heads); }); (format!("http://{addr}/v2/search"), rx) } + /// 一条原始请求头里 `User-Agent` 的值(大小写不敏感:reqwest 发的是小写字段名)。 + fn user_agent_of(head: &str) -> Option { + head.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.trim() + .eq_ignore_ascii_case("user-agent") + .then(|| value.trim().to_string()) + }) + } + /// 一个只用来放缓存根的临时数据根(`/.ULL/cache`)。 fn data_root(tag: &str) -> std::path::PathBuf { static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); @@ -250,6 +233,49 @@ mod tests { dir } + /// 两条下载路径发出去的 `User-Agent` 必须是**同一个字符串**,且就是引擎配置里那一个(M207)。 + /// + /// 为什么读回环上的字节、而不是断言常量相等:`header("User-Agent", …)` 到底传了什么,只有抓包才算 + /// 「站点看到的事实」;断言两个常量相等只说明「借了同一个名字」,谁在发送处又拼了一份照旧全绿。 + /// + /// 三段内容的正面断言在 core(`download::http` 的用例 + /// `the_user_agent_carries_the_package_version_and_the_repo_url`:版本 == `CARGO_PKG_VERSION`、 + /// 联系方式段 == 仓库 URL、不含邮箱)。这里钉的是**两条路径同源**:无 header 那条发引擎 reqwest + /// 客户端的默认 UA,带 header 那条发 `engine.config().user_agent`,两者都必须对 + /// `EngineConfig::default()` 逐字节相等。 + /// + /// 抓得住:presenter 又自己拼一份 UA(哪怕只差产品名的连字符)⇒ 红;发送处把 header 删掉或写成空串 ⇒ 红; + /// 客户端的默认 UA 与配置字段分叉(`build_client` 不再读 `cfg.user_agent`)⇒ 红。 + /// 抓不住:真实 Modrinth / CurseForge 收到这条 UA 之后怎么限流(单测不出网);也抓不住「联系方式 + /// 那一串是不是站点认可的格式」——那要真发一次才知道。 + #[test] + fn both_download_paths_send_the_same_user_agent() { + let dir = data_root("ua"); + let env = HostEnv::at_root(&dir); + let (url, seen) = loopback(2); + + get(&env, &url, &[]).expect("无 header 路径(引擎 `download_bytes`)"); + get(&env, &url, &[("x-api-key", "test-key")]) + .expect("带 header 路径(`engine.client()` 直发)"); + + let heads = seen + .recv_timeout(Duration::from_secs(10)) + .expect("回环服务应回传请求头"); + let sent: Vec = heads + .iter() + .filter_map(|head| user_agent_of(head)) + .collect(); + assert_eq!(sent.len(), 2, "两条请求都要带 User-Agent:{heads:?}"); + assert_eq!(sent[0], sent[1], "两条路径的 UA 必须是同一个字符串"); + + let defined = unlucky_core::download::config::EngineConfig::default().user_agent; + assert_eq!( + sent[0], defined, + "发出去的 UA 必须就是引擎配置里那一个(定义只有 core 一份)" + ); + std::fs::remove_dir_all(&dir).ok(); + } + /// **端到端守卫(M165 复审备注 2)**:连走三条真实宿主请求路径,只该构造一个引擎。 /// /// 为什么不满足于「再断言一次 `engine()` 复用」:那种断言只钉漏斗本身——将来谁在调用点 @@ -257,7 +283,8 @@ mod tests { /// `DownloadEngine::new` 这个卡口上(与谁造的无关),所以只要有一条路径另造引擎就会红。 /// /// 三条路径: - /// 1. [`get`] 无 header = 内容库的 Modrinth 元数据 GET(走 `engine.download_bytes`); + /// 1. [`get`] 无 header = [`get_via_engine`](走 `engine.download_bytes`)。**生产零调用者**:内容库的 + /// Modrinth 元数据 GET 走 core 的 `EngineTransport`(同一条 `download_bytes`),不经本文件; /// 2. [`get`] 带 header = CurseForge 那条(走 `engine.client()`,另一条机制); /// 3. `host::java::vendors` = Java 页渲染(走 `domestic()`,非 HTTP 的漏斗消费者)。 /// @@ -268,10 +295,11 @@ mod tests { let dir = data_root("paths"); let env = HostEnv::at_root(&dir); let cache_root = env.layout.cache(); - let (url, counts) = loopback(2); + let (url, seen) = loopback(2); - let without_headers = get(&env, &url, &[]).expect("Modrinth 元数据 GET"); - let with_headers = get(&env, &url, &[("x-api-key", "test-key")]).expect("CF 带 header GET"); + let without_headers = get(&env, &url, &[]).expect("无 header 路径(`get_via_engine`)"); + let with_headers = + get(&env, &url, &[("x-api-key", "test-key")]).expect("带 header 路径(CF)"); let vendors = crate::host::java::vendors(&env, &[]); assert_eq!( @@ -279,10 +307,10 @@ mod tests { "两条路径读的是同一份回环响应" ); assert!(!vendors.is_empty(), "Java 页应有发行版行"); - let (_, requests) = counts + let heads = seen .recv_timeout(Duration::from_secs(10)) - .expect("回环服务应报告计数"); - assert_eq!(requests, 2, "两条 HTTP 路径各发一条请求"); + .expect("回环服务应回传请求头"); + assert_eq!(heads.len(), 2, "两条 HTTP 路径各发一条请求"); assert_eq!( shared::constructions(&cache_root), 1, diff --git a/docs/download-settings.md b/docs/download-settings.md index 5450447..15654a3 100644 --- a/docs/download-settings.md +++ b/docs/download-settings.md @@ -41,7 +41,7 @@ crates/core/src/download/config.rs 守卫 `the_schema_contract_is_single_sourced`(`download/config.rs`,它合并了原先的「默认值相等」与 「区间同源」两条)让「改一处另一个不变」直接变红——这是防 8 vs 64 复发的唯一手段。 -另有 `the_key_docs_quote_the_real_limits` 钉住 schema 描述里的数字(见 §6.1 的第三处字面量)。 +另有 `the_key_docs_quote_the_real_limits` 钉住 schema 描述里的数字(见 §6.1.1 表格的第三行)。 ## 3. 接线链(宿主 → 引擎) @@ -139,26 +139,37 @@ config/launcher.json | 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) +## 6.1 gh-proxy 那一行的形状(P3-1):M201 之后已解决 -下载设置页把「GitHub 加速前缀」的三态画成了三行(`GitHub 加速前缀 / 留空 = 跟随内置默认`、 +M193 时期这一组把「GitHub 加速前缀」的三态画成三行(`GitHub 加速前缀 / 留空 = 跟随内置默认`、 `关掉它 / 填 off = 关闭`、`换掉它 / 填 URL 前缀 = 覆盖`)。这是**已知的观感问题**: ①`关掉它` / `换掉它` 读起来像按钮而不是取值说明;②同一个设置画成三行比三态本身更难懂。 -不改的理由不是不重要,而是渲染器不支持「一行 + 副说明」:`pages/settings/frame.rs` 的 -`setting_row` 只画「左标签 + 右单行值」,没有副说明位;要改得动 `frame.rs`(M196 正在改它)。 -正确的家是**下一条「iced 设置数据通路」mission**——那三行会变成真控件,形状问题自然消失。 -(同一条 finding 里还记着 presenter 侧描述里的 `1~64` / 出厂值:presenter 已改成用常量 `format!`, -iced 侧的 `rows` 是 `&'static str`,仍是**已知的第二真相源**,等真控件一并解决。) +当时的结论是「正确的家是下一条『iced 设置数据通路』mission」——**那一条已落地(M201)**,现状: -### 6.1.1 三处「数字的第二来源」现状(P3-2 登记) +| 项 | M193 时期 | M201 之后 | +|---|---|---| +| 控件 | 三行语义行(`&'static str`) | **一行真控件**:标题 + 说明 + 输入框 + 「应用」按钮(`pages/settings/download.rs::text_row`) | +| 三态怎么说 | 拆成三行、每行一句 | 一句话说完(留空 / off / 前缀),末尾接「当前生效:…」(presenter 的 `gh_proxy_state`,读的是**引擎生效档位**,不是文件原文,见 P3-5) | +| 当前值 | 不显示 | 输入框里是快照里那个值(`States::settings` ← `CoreEvent::SettingsChanged`) | +| 改它 | 改不了 | 输入 → 本地草稿 → 点「应用」→ `AppCommand::SetSetting` → host 写盘 + 重发快照 | + +那三行**还在** `GROUP.rows` 里,但只在**没有快照**时画(harness / `pages_demo` / 首帧,走 `frame::demo_card`): +它们说的是语义(「出厂 8 · 可调 1~64」)而不是当前值,所以不会再被当成界面上的真值(M193 用户投诉的形状)。 +有快照时这一组画的是真控件,`GROUP.rows` 一行都不画。 + +### 6.1.1 「数字的第二来源」现状(P3-2 登记,M207 复核) | 位置 | 能不能引用常量 | 守卫 | |---|---|---| -| `presenter/src/host/settings/download.rs` 的行描述 | 能(`format!`) | 已改 | -| `app-iced/src/pages/settings/download.rs` 的 `rows` | 不能:`&'static [(&'static str, &'static str)]` 是编译期字面量,要改运行时生成得动 `Group` 的类型 | 无(`the_downloads_rows_state_semantics…` 只断言 `"1~64"` 这个字符串在,改常量不会红) | +| `presenter/src/host/settings/download.rs` 的行描述 | 能(`format!` + `DEFAULT_MAX_CONCURRENCY` / `MAX_CONCURRENCY`) | 已改;**有快照时界面显示的就是这一份文案** | +| `app-iced/src/pages/settings/download.rs` 的 `GROUP.rows` | 不能:`&'static [(&'static str, &'static str)]` 是编译期字面量,要改运行时生成得动 `Group` 的类型 | 只有「字符串还在」的探针(`the_downloads_rows_state_semantics_and_never_fake_a_current_value` 断言 `"1~64"` 在):改常量不会红 | | `setting/keys.rs` 的 `SettingSpec.doc` | 不能:`doc: &'static str`(`spec.rs:183`),`concat!` 只吃字面量、插不进常量 | ✅ `the_key_docs_quote_the_real_limits`(`config.rs`):把「1~64」「出厂 8」「出厂 3」与常量比对,改常量不改文案就红 | +第二行的**地位变了(M201)**:它不再是产品界面上的数字来源——有快照时界面画的是第一行那份(用常量 +`format!` 出来的),`GROUP.rows` 只在没有快照的兜底屏出现,而且那里的量词是「出厂 / 可调」而不是「当前」。 +于是它现在的风险是「兜底演示行与常量不一致(演示屏说谎)」,不是「设置页骗人」。 + `SettingSpec.doc` 那一处**不是注释**:`catalog.rs` 会把它当 JSON schema 的 `description` 发出去 (AI 工具与文档可见),所以它必须有守卫——这也是三处里唯一补上了守卫的一处。 @@ -173,5 +184,14 @@ iced 侧的 `rows` 是 `&'static str`,仍是**已知的第二真相源**,等 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)。 +* **真机上的手点交互没验**(设置页自 M201 起已是真控件;M193 时期文档里那句「这一组只能看、改不了」已作废): + Downloads 组的行来自 presenter 快照(`presenter::host::settings::groups` → `CoreEvent::SettingsChanged` → + `pages::Model.settings`),画的是 choice chip / 文本输入 + 「应用」/ 动作按钮,写回走 + `AppCommand::SetSetting`;没有快照时(harness / `pages_demo` / 首帧)才退回 `GROUP.rows` 那几行语义演示行。 + 本次(M207)只读了代码与单测,没在 GPU 窗口里点过 chip / 输入 / 应用,也没量过「点了以后界面何时变」 + (设计是**不做乐观回显**,只认 host 发回的快照——这一点同样没在真机上确认过)。 +* **UA 里的联系方式在真站点上的后果未知**(M207):`User-Agent` 现在只有一份定义 + (`core::download::http::USER_AGENT` = `Unlucky-Launcher/ (+仓库 URL)`), + 两条 HTTP 路径发的是同一个字符串。证据只在**离线回环**上(发出去的字节 == + `EngineConfig::default().user_agent`);**没有向 Modrinth / CurseForge 发过一条带新 UA 的真实请求**, + 限流阈值、CF 会不会因为多了 `(+URL)` 而拒、Modrinth 是否会因此放宽,全部未知。