feat(download): 把 download.proxy_mode / download.proxy_url 接进代理三态(M295)

台账 P26:两个键引擎侧零消费者——`http::build_client` 只设 UA / 连接超时 / 重定向 / 默认头,
从不 `.proxy()`。设置页画着三档 chip 与一个地址框,改它对任何一次下载都不生效(`system` 那一
档「碰巧能用」只是因为 reqwest 默认读环境变量,不是这段代码做的)。

- core::download::proxy(新):`ProxySetting::from_raw` 是**唯一**翻译层,三档落到 client 上:
  `none` ⇒ `.no_proxy()`(真直连,连 `HTTP_PROXY` 也不读)/ `system` ⇒ 不调任何代理 API
  (跟随环境变量 = M295 之前的行为)/ `manual` ⇒ `.proxy(Proxy::all(url))`;未知值 ⇒ `none`。
- 两条决定写进模块头:`proxy_url` 在非 manual 档**忽略**(不校验、不进结构,值原样留在文件
  里);`manual` + 空地址 ⇒ `system`(设置页那行文案一直这么承诺)。
- 非法地址不穿透:`Proxy::all` 解析失败 ⇒ 有界错误(`InvalidRequest` + 带原串的文案),不
  panic;`DownloadEngine::new` 回落**直连**重建(不用 `reqwest::Client::new()`——那会跟随环境
  变量代理,回落就不确定),原因留在 `proxy_problem()`,CLI 的 `download::engine` 打一行
  stderr;`config().proxy` 不撒谎(仍是用户写的 `manual`)。
- `EngineConfig.proxy` 默认 `System`(=「没读设置」的档:auth 登录 / JDK 下载页 / 地区探测都
  直接用它,保持老行为),`DownloadTuning.proxy` 默认 `Direct`(与 schema 的 `none` 一致)。
- 两键进 `RawTuning`(与 `gh_proxy` 同形),presenter 的 `tuning_of` 与 CLI 的 `tuning` 各加
  两行;两个宿主都是完整字段字面量 ⇒ 「忘了读」是编译错误。`summary()` 加 `代理=…` 一档。
- 判据是「请求真的经过代理」:新夹具 `FakeProxy`(本地 TcpListener)+ 目标 `.invalid`(解析不
  了)⇒ 拿到正文只可能来自代理,且代理记到的请求行是**绝对 URI**。三条用例:manual 真走代理
  / direct 无视环境变量而 system 跟随(同一份 `HTTP_PROXY`)/ 非法地址回落直连不 panic。
  变异 M17-M22 记在 docs/download-settings.md §6。
- 行为变化(doc §5.2 写明):`none` 从「碰巧跟随环境变量」变成真直连,靠 `HTTP_PROXY` 上网的
  用户要选一次 `system`。发布版 GUI 的「下载 JDK」页仍不读这两个键(finding 已开)。
- 门禁:四 crate `check --all-targets` / 测试(3201 passed, 0 failed)/ `clippy -D warnings` /
  `fmt --check`;另跑了 `check --workspace --all-targets`。判据用 `$?` 与 `test result:` 行验。
This commit is contained in:
AskaEth committed 2026-11-13 10:43:09 +08:00
1 parent c731d3fa54
commit d584a2c42e
16 files changed
+1000 -56

No files matched your search

+14 -3
View File
@@ -18,6 +18,8 @@
//! download.throughput_floor_kib ─┤ (引擎重建时生效)
//! download.stall_timeout_secs ───┤
//! download.response_timeout_secs ┤
//! download.proxy_mode ───────────┤ M295:代理两键进 RawTuning,
//! download.proxy_url ────────────┤ 三档翻译在 core 的 ProxySetting
//! download.region ───────────────┤ M294:两个枚举键,走
//! download.mirror_preference ────┘ `with_source_selection`(回落 = auto)
//! ```
@@ -134,7 +136,7 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
.with(SettingRowView::choice(
"download.proxy_mode",
"代理模式",
"none / system(跟随系统)/ manual(下面填地址)",
"none = 不走代理(连环境变量里的 HTTP_PROXY 也不读)/ system = 跟随系统环境变量代理 / manual = 用下面填的地址",
&proxies,
selected(
&proxies,
@@ -145,10 +147,12 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
},
),
))
// M295:这一行只在 manual 档生效;别的档下地址被**忽略**(值仍留在文件里),地址解析不了
// 时引擎回落直连并说明原因——三件事都写进文案,否则「填了没生效」又得靠用户猜。
.with(SettingRowView::text(
"download.proxy_url",
"HTTP 代理",
"manual 模式生效;留空则用系统代理",
"只在 manual 模式生效(其它模式忽略这段地址);manual 留空则用系统代理;地址写法不对时按直连处理,原值保留",
&text(env, "download.proxy_url"),
))
// M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把
@@ -256,11 +260,16 @@ fn gh_proxy_state(state: &GhProxyOverride) -> String {
/// M294:另加两个「源选择」键(`download.region` / `download.mirror_preference`),它们是**枚举**
/// 而不是数字,走 [`DownloadTuning::with_source_selection`](同一个翻译层,回落 = `auto`)。
/// 少了这一步,那两行 chip 就只是画着好看——本轮之前正是如此。
///
/// M295:代理两键(`download.proxy_mode` / `download.proxy_url`)进 [`RawTuning`](它们是字符串
/// 原文 + 一个开关,与 `gh_proxy` 同形),三档翻译与地址校验都在 core 的
/// `DownloadTuning::from_raw` / `ProxySetting`,**本函数不解释任何一个字**。
#[must_use]
pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
use unlucky_core::download::config::{
RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, KEY_MIRROR_PREFERENCE,
KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR,
KEY_PROXY_MODE, KEY_PROXY_URL, KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT,
KEY_THROUGHPUT_FLOOR,
};
let store = env.store();
let doc = store.doc();
@@ -275,6 +284,8 @@ pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
throughput_floor_kib: doc.effective_i64(KEY_THROUGHPUT_FLOOR),
stall_timeout_secs: doc.effective_i64(KEY_CHUNK_STALL),
response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT),
proxy_mode: doc.effective_str(KEY_PROXY_MODE),
proxy_url: doc.effective_str(KEY_PROXY_URL),
})
.with_source_selection(
doc.effective_str(KEY_REGION),
@@ -383,16 +383,5 @@ fn the_three_timeout_keys_travel_from_the_file_to_the_engine_config() {
std::fs::remove_dir_all(&dir).ok();
}
/// **M294**:两个「源选择」键真的进了 `DownloadTuning`(删掉 `with_source_selection` ⇒ 两断言红)。
#[test]
fn the_two_source_selection_keys_reach_the_tuning() {
use unlucky_core::download::{MirrorPreference, RegionPreference};
let (dir, env) = env_with(
"source-selection",
serde_json::json!({ "download": { "region": "global", "mirror_preference": "official" } }),
);
let tuning = tuning_of(&env);
assert_eq!(tuning.region, RegionPreference::Global);
assert_eq!(tuning.mirror_preference, MirrorPreference::Official);
std::fs::remove_dir_all(&dir).ok();
}
#[path = "download_tests/selection_and_proxy.rs"]
mod selection_and_proxy;
@@ -0,0 +1,133 @@
//! **最近两轮接进来的两个键族**的守门:源选择(M294)与代理(M295)。
//!
//! 从 `download_tests.rs` 搬出只为守 400 行硬线(与 `support/server.rs` 拆出同一理由):那条文件
//! 里这一族是最后两段,搬过来正文一字未改,只是多了一句「搬家原因」。
//!
//! ## 代理两键(M295):`download.proxy_mode` / `download.proxy_url`
//!
//! 从文件走到**引擎配置**,走的是 GUI 的生产装配点 `host::engine::engine`(内部 `apply_tuning` →
//! `shared::set_tuning` → 按数据根重建)。
//!
//! 抓得住:① `tuning_of` 少读一个键(`proxy_mode` 或 `proxy_url`)⇒ 第一、三条红;② 非 manual 档
//! 也把地址塞进 `Manual` ⇒ 第一条红;③ `manual` + 空地址落成 `Direct`(设置页承诺的是「用系统代理」)
//! ⇒ 第四条红;④ 非法地址让引擎构造 panic ⇒ 最后一段红;⑤ 两行文案与三档语义脱钩 ⇒ 文案断言红。
//!
//! 抓不住:请求是不是**真的**经由代理(那是 core 的 `tests/download/engine_mock/proxy.rs`:本地假
//! 代理 + `.invalid` 目标 + 绝对 URI 请求行);也抓不住发布版 GUI 的「下载 JDK」页——那条路径仍用
//! `EngineConfig::default()`(见 `docs/download-settings.md` §3.1.1 的 ❌ 一行)。
use super::*;
use unlucky_core::download::proxy::ProxySetting;
/// 写文件 → 走生产装配点 → 返回「引擎拿到的代理档 + 引擎自报的问题」。
fn applied(tag: &str, document: serde_json::Value) -> (ProxySetting, Option<String>) {
let (dir, env) = env_with(tag, document);
std::fs::create_dir_all(env.layout.cache()).expect("cache 目录");
let engine = crate::host::engine::engine(&env);
let seen = (
engine.config().proxy.clone(),
engine.proxy_problem().map(str::to_string),
);
std::fs::remove_dir_all(&dir).ok();
seen
}
#[test]
fn the_proxy_keys_travel_from_the_file_to_the_engine_config() {
use unlucky_core::download::config::{EngineConfig, KEY_PROXY_MODE, KEY_PROXY_URL};
// ① none(也是 schema 出厂值):直连;**非 manual 档的地址被忽略**。
assert_eq!(
applied(
"proxy-none",
serde_json::json!({ "download": { "proxy_mode": "none", "proxy_url": "http://127.0.0.1:7890" } })
),
(ProxySetting::Direct, None),
"none 档必须是真直连,且地址不参与"
);
// ② system:跟随环境变量(同样是「不调代理 API」那一档,地址仍被忽略)。
assert_eq!(
applied(
"proxy-system",
serde_json::json!({ "download": { "proxy_mode": "system", "proxy_url": "http://127.0.0.1:7890" } })
),
(ProxySetting::System, None)
);
// ③ manual:地址进结构(去空白)。
assert_eq!(
applied(
"proxy-manual",
serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": " http://127.0.0.1:7890 " } })
),
(
ProxySetting::Manual {
url: "http://127.0.0.1:7890".to_string()
},
None
)
);
// ④ manual + 空地址 ⇒ 设置页那行文案承诺的「用系统代理」。
assert_eq!(
applied(
"proxy-manual-empty",
serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": "" } })
),
(ProxySetting::System, None)
);
// ⑤ 非法地址:不 panic、有可读原因、配置不撒谎(仍是用户写的 manual)。
let (proxy, problem) = applied(
"proxy-broken",
serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": "http://127.0.0.1:99999" } }),
);
assert_eq!(
proxy,
ProxySetting::Manual {
url: "http://127.0.0.1:99999".to_string()
}
);
let problem = problem.expect("解析不了必须留下原因(否则用户只会觉得「填了没用」)");
assert!(problem.contains("http://127.0.0.1:99999"), "{problem}");
// 两行文案必须与新语义一致(否则界面又回到「填了不知道算不算数」)。
let (dir, env) = env_with("proxy-rows", serde_json::json!({}));
let group_view = group(&env);
let mode_row = row(&group_view, KEY_PROXY_MODE);
assert!(
mode_row
.description
.contains("system = 跟随系统环境变量代理")
&& mode_row.description.contains("manual"),
"代理模式那行必须把三档说清:{}",
mode_row.description
);
let url_row = row(&group_view, KEY_PROXY_URL);
assert!(
url_row.description.contains("只在 manual 模式生效"),
"地址那行必须说清它在别的档被忽略:{}",
url_row.description
);
assert_eq!(
EngineConfig::default().proxy,
ProxySetting::System,
"「没读设置」的档仍是跟随环境变量(auth / JDK 页走它),与本文件的三档翻译是两件事"
);
std::fs::remove_dir_all(&dir).ok();
}
/// **M294**:两个「源选择」键真的进了 `DownloadTuning`(删掉 `with_source_selection` ⇒ 两断言红)。
#[test]
fn the_two_source_selection_keys_reach_the_tuning() {
use unlucky_core::download::{MirrorPreference, RegionPreference};
let (dir, env) = env_with(
"source-selection",
serde_json::json!({ "download": { "region": "global", "mirror_preference": "official" } }),
);
let tuning = tuning_of(&env);
assert_eq!(tuning.region, RegionPreference::Global);
assert_eq!(tuning.mirror_preference, MirrorPreference::Official);
std::fs::remove_dir_all(&dir).ok();
}