M207: UA 收成一份带联系方式 + 两处字符串副本(gh_proxy 键、用例注释)
1) UA 只有一份定义:core 的 `download::http::USER_AGENT` (`Unlucky-Launcher/<CARGO_PKG_VERSION> (+仓库 URL)`,不用邮箱)。 - 引擎侧:`EngineConfig::default().user_agent` 直接取它(config.rs 等量替换,仍是 400 行) - 带 header 侧:presenter 不再持有 UA 字面量,`get_with_headers` 发 `engine.config().user_agent` M205 复审核实过的缺口:Modrinth 收到的是引擎那份 `UnluckyLauncher/<ver>`(无连字符、无联系方式), 带联系方式的那份只发给 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 真控件 + 快照通路)之后的现状重写。
This commit is contained in:
1 parent
a853986283
commit
e3ae71ac37
5 files changed
+184
-91
No files matched your search
@@ -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 覆盖)。
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<reqwest::Client, DownloadError> {
|
||||
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<u64>) -> 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/<CARGO_PKG_VERSION>
|
||||
//! (+https://github.com/AskaEth/Unlucky-Launcher)`。本文件**不拼** UA——带 header 的路径发的是
|
||||
//! `engine.config().user_agent`,无 header 的路径发的是同一个客户端的默认 UA,所以两条路径、以及真正的
|
||||
//! 文件下载,站点看到的都是同一个字符串。
|
||||
//!
|
||||
//! M205 复审核实过的旧状:`api.modrinth.com` 收到的是引擎那份 `UnluckyLauncher/<ver>`(无连字符、
|
||||
//! 无联系方式),带联系方式的 `Unlucky-Launcher/<ver> (+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/<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)"
|
||||
);
|
||||
|
||||
/// 最多重试次数(含首次)。
|
||||
///
|
||||
/// **不在这里写数字**:取值就是下载引擎的出厂默认
|
||||
@@ -85,6 +76,8 @@ fn get_via_engine(env: &HostEnv, url: &str) -> Result<String, String> {
|
||||
/// 带 header 的请求(只有 CF 走这条):429 / 5xx / 连接类失败按退避重试。
|
||||
fn get_with_headers(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result<String, String> {
|
||||
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/<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 没网,也不该有)。
|
||||
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<Vec<String>>) {
|
||||
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<String> = 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<String> {
|
||||
head.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.trim()
|
||||
.eq_ignore_ascii_case("user-agent")
|
||||
.then(|| value.trim().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// 一个只用来放缓存根的临时数据根(`<root>/.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<String> = 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,
|
||||
|
||||
+33
-13
@@ -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/<CARGO_PKG_VERSION> (+仓库 URL)`),
|
||||
两条 HTTP 路径发的是同一个字符串。证据只在**离线回环**上(发出去的字节 ==
|
||||
`EngineConfig::default().user_agent`);**没有向 Modrinth / CurseForge 发过一条带新 UA 的真实请求**,
|
||||
限流阈值、CF 会不会因为多了 `(+URL)` 而拒、Modrinth 是否会因此放宽,全部未知。
|
||||
Reference in new issue
Block a user