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

+15 -3
View File
@@ -31,7 +31,8 @@ use std::sync::Arc;
use unlucky_core::download::config::{
DownloadTuning, RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY,
KEY_MIRROR_PREFERENCE, KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR,
KEY_MIRROR_PREFERENCE, KEY_PROXY_MODE, KEY_PROXY_URL, KEY_REGION, KEY_RESPONSE_TIMEOUT,
KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR,
};
use unlucky_core::download::{shared, DownloadEngine};
use unlucky_core::setting::keys::launcher_schema;
@@ -39,7 +40,7 @@ use unlucky_core::setting::store::SettingStore;
use crate::context::Ctx;
/// 从 CLI 自己的 setting store 读下载调优(`launcher.json` 的八个 `download.*` 键)。
/// 从 CLI 自己的 setting store 读下载调优(`launcher.json` 的十个 `download.*` 键)。
///
/// 读的是 `ctx.launcher_config()`——与 `ull config set download.concurrency` 的落点同一份文件
/// (设置键的落点分流见 [`Ctx::setting_path_for`])。gh-proxy 空串 = 跟随内置默认,**不是**关。
@@ -52,6 +53,9 @@ use crate::context::Ctx;
/// [`RawTuning`]**——那两个是枚举,翻译层是 core 的 [`DownloadTuning::with_source_selection`]
/// (未知值回落 `auto`)。CLI 与 presenter 走的是同一个入口,别在宿主里再写一份字符串匹配:
/// 两份口径就是「GUI 认、CLI 不认」的下一次复发。
///
/// M295:代理两键(`download.proxy_mode` / `proxy_url`)读法与 presenter 的 `tuning_of` 逐行相同
/// ——同一个 `RawTuning`、同一个 `ProxySetting::from_raw`,CLI 侧**零**字符串匹配。
#[must_use]
pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
@@ -67,6 +71,8 @@ pub(crate) fn tuning(ctx: &Ctx) -> 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),
@@ -99,7 +105,13 @@ pub(crate) fn engine(ctx: &Ctx) -> Arc<DownloadEngine> {
if let Some(line) = announce(&tuning) {
eprintln!("{line}");
}
shared::engine_with_tuning(ctx.layout().cache(), tuning)
let engine = shared::engine_with_tuning(ctx.layout().cache(), tuning);
// 代理那一档没生效时(`manual` 的地址解析不了 ⇒ 引擎回落直连)必须说出来:静默回落等于把
// 「设置项说假话」换成「设置项不说实话」,用户只会觉得「填了代理还是连不上」。
if let Some(problem) = engine.proxy_problem() {
eprintln!("下载代理未生效,本次按直连:{problem}");
}
engine
}
#[cfg(test)]
+4
View File
@@ -389,3 +389,7 @@ fn the_source_selection_settings_reach_the_cli_engine() {
assert!(line.contains("地区=cn"), "{line}");
assert!(line.contains("镜像偏好=auto"), "{line}");
}
/// 代理两键的 CLI 侧守门(M295):单开一个文件——本文件已贴着 400 行硬线。
#[path = "tests/proxy.rs"]
mod proxy;
+78
View File
@@ -0,0 +1,78 @@
//! **代理两键的 CLI 侧守门(M295)**:`download.proxy_mode` / `download.proxy_url` 从 CLI 自己的
//! `launcher.json` 进四条命令的生产装配点,终端那行生效值也含代理档。
//!
//! 从 `tests.rs` 拆出只为守 400 行硬线(与 core 的 `engine_mock/*.rs` 同一手法)。
//!
//! 抓得住:① `download::tuning` 不读 `KEY_PROXY_MODE` / `KEY_PROXY_URL` ⇒ 三档全退回 `Direct`,
//! 前两条红;② `summary()` 少印代理 ⇒ 那行断言红;③ 非法地址让装配 panic ⇒ 最后一段红;
//! ④ 键名常量与 schema 登记脱钩 ⇒ 末尾那句红。
//!
//! 抓不住:请求是不是**真的**经由代理(core 的 `tests/download/engine_mock/proxy.rs`:本地假代理 +
//! `.invalid` 目标 + 绝对 URI 请求行);也抓不住真实的系统代理 / PAC / 需要认证的代理
//! (见 `docs/download-settings.md` §7)。
use super::*;
#[test]
fn the_proxy_settings_reach_the_cli_engine_and_the_console_line() {
use unlucky_core::download::config::{KEY_PROXY_MODE, KEY_PROXY_URL};
use unlucky_core::download::proxy::ProxySetting;
let dir = TempDir::new("proxy");
let ctx = ctx_at(&dir);
// manual:地址进引擎配置,终端那行写的是同一档。
write_launcher_config(
&ctx,
json!({ "download": { "proxy_mode": "manual", "proxy_url": " http://127.0.0.1:7890 " } }),
);
let engine = crate::commands::install::build_engine(&ctx);
assert_eq!(
engine.config().proxy,
ProxySetting::Manual {
url: "http://127.0.0.1:7890".to_string()
},
"manual 档必须落到引擎配置上(去空白)"
);
let line =
crate::download::announce(&crate::download::tuning(&ctx)).expect("改过设置 ⇒ 该有一行");
assert!(
line.contains("代理=manual(http://127.0.0.1:7890)"),
"{line}"
);
// system + 一个残留地址:地址被忽略,档位是 system(终端那行也要说清是「跟随环境变量」)。
write_launcher_config(
&ctx,
json!({ "download": { "proxy_mode": "system", "proxy_url": "http://127.0.0.1:7890" } }),
);
assert_eq!(
crate::commands::install::build_engine(&ctx).config().proxy,
ProxySetting::System
);
let line = crate::download::announce(&crate::download::tuning(&ctx)).expect("该有一行");
assert!(line.contains("代理=system(跟随环境变量)"), "{line}");
// 非法地址:不 panic;引擎给出可读原因,配置字段仍是用户写的值(回落是运行时行为)。
write_launcher_config(
&ctx,
json!({ "download": { "proxy_mode": "manual", "proxy_url": "http://127.0.0.1:99999" } }),
);
let engine = crate::commands::install::build_engine(&ctx);
let problem = engine
.proxy_problem()
.expect("地址解析不了要留下原因(CLI 的那行 stderr 靠它)");
assert!(problem.contains("http://127.0.0.1:99999"), "{problem}");
assert_eq!(
engine.config().proxy,
ProxySetting::Manual {
url: "http://127.0.0.1:99999".to_string()
}
);
// 两个键名常量必须真的登记在 schema 里(写错 = 「用户设了没生效」的静默退化)。
let schema = launcher_schema();
for key in [KEY_PROXY_MODE, KEY_PROXY_URL] {
assert!(schema.spec(key).is_some(), "{key} 没登记");
}
}