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

+97 -25
View File
@@ -2,8 +2,10 @@
一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 真的进引擎**;
`download.region` / `download.mirror_preference` 自 **M294** 起也真的进引擎(GUI 主路径、发布版 JDK
下载页、CLI 四条命令都认,见 §5);只剩 `proxy_mode` / `proxy_url` 仍是**引擎侧零消费者**——
这一页把它们逐条标出来,别再让「设置项说假话」。
下载页、CLI 四条命令都认,见 §5);`download.proxy_mode` / `download.proxy_url` 自 **M295** 起接进
HTTP 客户端的代理三态(GUI 主路径、CLI 四条命令都认,**发布版 GUI 的「下载 JDK」页仍不认**,见
§3.1.1)。还剩的缺口只有 §3.1.1 表里那几个 ❌——这一页把八个下载类键逐条标出来,别再让
「设置项说假话」。
出处:M190 的 204 条 finding 复核(`DevDocs/PlanDoc/research/191-finding-triage.md` §3 A-3)挖出的
「用户能改、改了没用」缺口:schema 说并发 8,引擎恒跑 64,两处各写一个数。
@@ -19,8 +21,8 @@
| `download.gh_proxy` | 空串 / ≤256 字符(M197 登记) | 共享引擎装配点(`shared::build` 的 gh-proxy 候选链)+ CLI 的 `download::tuning` | 地区档案里的内置值(镜像优先时 `gh-proxy.com`) | ✅ 下沉到共享装配点,GUI 与四条 CLI 命令一致 |
| `download.region` | auto / auto·cn·global | **M294 起有**:`DownloadTuning::with_source_selection` → `RegionProfile::overridden`,装配点是 `shared::build` 与 `app-iced` 的 JDK 下载页 | 永远按「时区+语言」自动判定 | ✅ 接进引擎(M294):显式 `cn`/`global` 压过探测 |
| `download.mirror_preference` | auto / auto·official·mirror | 同上(同一个入口) | 永远按地区模式(镜像优先/直连) | ✅ 接进引擎(M294):优先级 `mirror_preference` > `region` > 探测 |
| `download.proxy_mode` | none / none·system·manual | **无**(`http::build_client` 从不调 `.proxy()`) | 只走环境变量代理(reqwest 默认),`system`/`manual` 都不生效 | ❌ 未接(见 §5) |
| `download.proxy_url` | "" | **无** | 空(从不使用) | ❌ 未接(见 §5) |
| `download.proxy_mode` | none / none·system·manual | **M295 起有**:`DownloadTuning::from_raw` → `ProxySetting`,唯一消费点是 `http::build_client` | 只走环境变量代理(reqwest 默认):`none` / `manual` 都不生效,`system` 只是「碰巧」 | ✅ 接进引擎(M295):`none` = `.no_proxy()`、`system` = 跟随环境变量、`manual` = `Proxy::all(url)` |
| `download.proxy_url` | "" | 同上(**只在 manual 档被读**) | 空(从不使用) | ✅ 接进引擎(M295);非 manual 档**忽略**(值原样留在文件里) |
| `download.action.detect_region` | (动作键,不是设置) | presenter 的 `detect_region` | 清 `region.json` + 失效共享引擎 | ✅ 本来就真 |
复现方法(改造前):`grep -rn "download.concurrency" crates/` 只命中 `setting/keys.rs` 的定义与
@@ -50,7 +52,9 @@ crates/core/src/download/config.rs
config/launcher.json
download.concurrency ─┐
download.retry_limit ─┼→ presenter::host::settings::download::tuning_of
download.gh_proxy ────┤ │ DownloadTuning::from_settings(默认值/夹取/回落都在 core)
download.gh_proxy ────┤ │ DownloadTuning::from_raw(默认值/夹取/回落都在 core)
download.proxy_mode ──┤ │ ← M295:两键进 RawTuning,三档翻译在 ProxySetting
download.proxy_url ───┤ │
download.region ──────┤ │
download.mirror_preference ─┐ │ ← M294:with_source_selection(GUI/CLI/JDK 页共用的唯一入口)
↓
@@ -58,10 +62,11 @@ config/launcher.json
↓ 变了 → 淘汰该根的登记项
shared::engine_for(<cache 根>) → shared::build
↓
EngineConfig { max_concurrency, max_attempts_per_source }
EngineConfig { max_concurrency, max_attempts_per_source, proxy }
+ RegionProfile { mode(源选择叠出来的), gh_proxy }
↓
Semaphore(容量) / attempts_for() / MirrorTable::expand() 的候选顺序 + gh-proxy 候选
/ http::build_client 的 .no_proxy() | 环境变量 | .proxy(Proxy::all(url))
```
**同步点是显式的**,两处,共用同一个函数 `apply_tuning`:
@@ -90,17 +95,21 @@ config/launcher.json
`shared::engine_with_tuning(<cache 根>, tuning)`。所以 `presenter` 的 `shared::build` 不再是
**全仓唯一**的装配点,但装配规则仍只有 core 那一份。
### 3.1.1 「源选择」两键(`region` / `mirror_preference`)的覆盖面(M294)
### 3.1.1 「源选择」两键(M294)与代理两键(M295)的覆盖面
| 路径 | 吃吗 | 装配点 |
|---|---|---|
| GUI 主路径(内容库、版本清单、资源、字体、自更新…) | ✅ | `shared::engine_for` → `shared::build`(`tune.apply_source_selection`) |
| 发布版 GUI 的「下载 JDK」页 | ✅(M294 r1 补) | `app-iced` 的 `pages/settings/java/downloads/engine.rs::engine_with`(独立装配点:它要 `with_progress` 拿走引擎) |
| CLI `ull install` / `java` / `pack` / `update` | ✅(M294 r1 补) | 四条共用 `cli/src/download.rs::tuning`(`.with_source_selection`) |
| `proxy_mode` / `proxy_url` | ❌ | 仍是零消费者,见 §5 |
| 键族 | 路径 | 吃吗 | 装配点 |
|---|---|---|---|
| `region` / `mirror_preference` | GUI 主路径(内容库、版本清单、资源、字体、自更新…) | ✅ | `shared::engine_for` → `shared::build`(`tune.apply_source_selection`) |
| | 发布版 GUI 的「下载 JDK」页 | ✅(M294 r1 补) | `app-iced` 的 `pages/settings/java/downloads/engine.rs::engine_with`(独立装配点:它要 `with_progress` 拿走引擎) |
| | CLI `ull install` / `java` / `pack` / `update` | ✅(M294 r1 补) | 四条共用 `cli/src/download.rs::tuning`(`.with_source_selection`) |
| `proxy_mode` / `proxy_url` | GUI 主路径 | ✅(M295) | `shared::build`:`tune.apply_to(EngineConfig::default())` 把 `proxy` 盖上去 → `http::build_client` |
| | 发布版 GUI 的「下载 JDK」页 | ❌(M295 未接) | 那一处直接用 `EngineConfig::default()`(= `System`,跟随环境变量代理),**不读**这两个键 |
| | CLI `ull install` / `java` / `pack` / `update` | ✅(M295) | 同一个 `cli/src/download.rs::tuning`(进 `RawTuning`,宿主零字符串匹配) |
三个宿主读的是**同一个**翻译层 `DownloadTuning::with_source_selection`(`auto` 回落、优先级、
未知值怎么退都只写在 core 一处);宿主只负责「把 `launcher.json` 里那两个键读出来并显式传下去」。
「源选择」两个键的翻译层是 `DownloadTuning::with_source_selection`(`auto` 回落、优先级、未知值
怎么退都只写在 core 一处);代理两键的翻译层是 `ProxySetting::from_raw`(三档 + 那两句「非 manual
忽略地址」「manual 留空 = 系统代理」,同样只写在 core 一处)。两边都是:**宿主只负责把
`launcher.json` 里的键读出来并显式传下去**。
回落仍是静默的:`SettingStore::load` 永不失败(坏值只进 `problems()`),类型不对 → `None`,越界 → 出厂
@@ -127,7 +136,7 @@ config/launcher.json
这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现
「界面显示 0、引擎按 3 跑」。
## 5. 源选择两键已接(M294);只剩代理两键没接
## 5. 源选择两键(M294)与代理两键(M295)都已接
### 5.1 已接:`download.region` / `download.mirror_preference`(M294)
@@ -152,18 +161,52 @@ config/launcher.json
* gh-proxy 三态在源选择**之后**叠(`apply_source_selection` 的次序),所以
「`region=cn` + `gh_proxy=off`」⇒ 一个前缀都不生成(守卫:`an_explicit_gh_proxy_off_wins_over_a_mirror_first_region`)。
### 5.2 未接:`download.proxy_mode` / `download.proxy_url`
### 5.2 已接:`download.proxy_mode` / `download.proxy_url`(M295)
**引擎侧零消费者**:`http::build_client` 只有 UA / 连接超时 / 重定向 / 默认头,**从不调 `.proxy()`**。
于是 `manual`(指定地址)与 `none`(强制直连)都不生效;`system` 那一档「碰巧能用」只是因为
reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是这段代码做的。要接需要:
**M295 之前引擎侧零消费者**:`http::build_client` 只有 UA / 连接超时 / 重定向 / 默认头,**从不调
`.proxy()`**。于是 `manual`(指定地址)与 `none`(强制直连)都不生效;`system` 那一档「碰巧能用」
只是因为 reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是那段代码做的。
* `EngineConfig` 加代理三态 → `http::build_client` 里 `.proxy(reqwest::Proxy::all(url)?)` / `.no_proxy()`;
* 探测路径(`region::probe_signals`)与自更新要不要走同一个代理,是**另一个决定**,得先定。
现在两个键翻译成 `EngineConfig.proxy`(`core::download::proxy::ProxySetting`),唯一消费点是
`http::build_client`:
已开 finding:`download.proxy_mode` / `download.proxy_url` 零消费者(代理问的是「怎么连」,
与「连哪里」不是同一个决定面,混在一个 diff 里审不动)。`setting/keys/proxy.rs` 的模块头那句
「`download.proxy_mode` → 引擎的 HTTP 客户端(`http::build_client`)」**目前是假的**,接线时一并改。
| `proxy_mode` | `ProxySetting` | 落到 client 上 | 含义 |
|---|---|---|---|
| `none`(schema 出厂值,键不存在也走这档) | `Direct` | `.no_proxy()` | **真直连**:连进程环境里的 `HTTP_PROXY` 也不读 |
| `system` | `System` | **不调任何代理 API** | 跟随进程环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`)= M295 之前所有下载的实际行为 |
| `manual` | `Manual { url }` | `.proxy(reqwest::Proxy::all(url)?)` | 整条流量走 `proxy_url`(http 与 https 目标都经它;`Proxy::all` 顺手关掉「自动读系统代理」) |
三条决定写在这里,省得下一个人再猜:
* **`proxy_url` 在非 manual 模式下被忽略**:`none` / `system` 下它既不校验、也不进 `ProxySetting`,
值原样留在 `launcher.json` 里(切回 `manual` 立刻生效)。模式是唯一开关,不存在「填了地址、模式
没选对、半生效」的中间态。
* **`manual` + 空地址 = 用系统代理**:设置页那行文案一直这么承诺(「manual 留空则用系统代理」),
核心按文案兑现,而不是落成第三种没人写过的语义。
* **非法地址不穿透**:`proxy_url` 写成解析不了的串时,`ProxySetting::apply` 返回有界错误
(`InvalidRequest` + 带原串的可读文案),**不 panic**;`DownloadEngine::new` 据此回落**直连**重建
(不是 `reqwest::Client::new()`——那会跟随环境变量代理,回落就不确定了),原因留在
`DownloadEngine::proxy_problem()` 上,CLI 的 `download::engine` 打一行 stderr。配置字段**不撒谎**:
`config().proxy` 仍是用户写的 `manual`。
**这是一次真实的行为变化**:`none` 从「碰巧跟随环境变量」变成「真直连」。所以
* 靠 `HTTP_PROXY` 上网的用户(公司代理 / CI)要在设置页选一次 `system`,否则 M295 之后下载会直连;
* 反过来,之前选过「直连」却仍在被环境变量代理的人,现在拿到的是他选的那一档。
* `region::probe_signals`(地区探测)、`auth` 的登录与发布版 GUI 的「下载 JDK」页**都不读**这两个键,
它们仍用 `EngineConfig::default()`(= `System`,跟随环境变量):这些路径的默认值故意与
`download.proxy_mode` 的出厂值(`none`)不同,才没被这次接线静默改了网络路径。
`setting/keys/proxy.rs` 模块头那句「`download.proxy_mode` → 引擎的 HTTP 客户端
(`http::build_client`)」**从 M295 起是真的**(此前是空头支票)。
两条实现注记(改这段代码前先读):
* **宿主忘了读 = 编译不过**:两个键是 `RawTuning` 的字段,两个宿主的构造都是**完整字段字面量**
(没有 `..RawTuning::default()`),漏一个就是 `E0063`。编译器能拦「忘了读」,拦不住「读到了但
传空」——后者由 §6 的 M20 / M21 咬。
* **`reqwest` 会替用户补 `http://`**:`Proxy::all("127.0.0.1:7890")` 是合法写法(少写 scheme 不算
「解析不了」)。真正解析不了的只剩端口越界(`…:99999`)、方括号不闭合这一类——用例挑的是前者。
## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原)
@@ -185,6 +228,17 @@ reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是这
| M14(M294) | `shared::build` 的 `tune.apply_source_selection(region)` 换回 `apply_gh_proxy(region)` | `download::preference::tests::the_settings_reach_the_engines_candidate_plan` | 红(`left: ["bmclapi","official"] / right: ["official","bmclapi"]`,4 passed 1 failed) |
| M15(M294) | `cli::download::tuning` 删掉 `.with_source_selection(..)` | `cli::download::tests::the_source_selection_settings_reach_the_cli_engine` | 红(`official 必须第一条`:`left: ["bmclapi","official"]`,0 passed 1 failed) |
| M16(M294) | `app-iced` 的 `engine_with` 删掉 `selection.apply(profile)`(换回裸档案) | `…::downloads::engine::tests::the_source_selection_settings_reach_the_jdk_download_engine` | 红(`left: ["bmclapi","official"]`,0 passed 1 failed) |
| M17(M295) | `ProxySetting::apply` 的 manual 档删掉 `.proxy(proxy)` | `…::engine_mock::proxy::manual_mode_really_sends_the_request_through_the_proxy` | 红(`AllSourcesFailed`:`.invalid` 解析不了 ⇒ 请求根本没到假代理;2 passed 1 failed) |
| M18(M295) | `ProxySetting::apply` 的 `Direct` 档删掉 `.no_proxy()` | `…::engine_mock::proxy::direct_ignores_the_env_proxy_and_system_follows_it` | 红(direct 档在教育环境变量的假代理上拿到了正文:`[112, 114, …]` = `proxied payload`) |
| M19(M295) | `ProxySetting::apply` 的 `System` 档改成 `.no_proxy()` | 同上 | 红(system 档不再跟随环境变量 ⇒ `AllSourcesFailed`) |
| M20(M295) | presenter 的 `tuning_of` 把 `proxy_mode` / `proxy_url` 传 `None`(= 没读这两个键) | `host::settings::download::tests::selection_and_proxy::the_proxy_keys_travel_from_the_file_to_the_engine_config` | 红(`left: (Direct, None)` vs `right: (System, None)`;2 passed 1 failed) |
| M21(M295) | CLI 的 `download::tuning` 同上 | `cli::download::tests::proxy::the_proxy_settings_reach_the_cli_engine_and_the_console_line` | 红(`left: Direct` vs `right: Manual { url: … }`;1 passed 1 failed) |
| M22(M295) | `ProxySetting::apply` 的 `Proxy::all(url)?` 改成 `expect(...)` | `…::engine_mock::proxy::an_unparseable_proxy_url_falls_back_to_direct_and_says_why`(+ core 单测 `a_broken_proxy_url_is_a_bounded_error_not_a_panic`) | 红(两处都 panic:`代理地址: … InvalidPort`) |
**M20 / M21 的一条附注**:直接把那两个字段**删掉**根本编译不过——两个宿主的 `RawTuning` 是完整字段
字面量(没有 `..RawTuning::default()`),编译器会报 `E0063 missing fields proxy_mode and proxy_url`。
所以「宿主忘了读这两个键」这一档是**编译期**拦住的;M20/M21 验的是「读到了但没用」(传 `None`),
那是编译器管不着的下一半。
## 6.1 gh-proxy 那一行的形状(P3-1):M201 之后已解决
@@ -245,3 +299,21 @@ M193 时期这一组把「GitHub 加速前缀」的三态画成三行(`GitHub
两条 HTTP 路径发的是同一个字符串。证据只在**离线回环**上(发出去的字节 ==
`EngineConfig::default().user_agent`);**没有向 Modrinth / CurseForge 发过一条带新 UA 的真实请求**,
限流阈值、CF 会不会因为多了 `(+URL)` 而拒、Modrinth 是否会因此放宽,全部未知。
* **代理只验到「明文 HTTP 真的经由代理」这一层**(M295):判据是本地假代理(`127.0.0.1` 的
`TcpListener`)+ RFC 2606 的 `.invalid` 目标——目标域名自己解析不了,能拿到正文就只可能是因为
请求走了代理;代理记到的请求行是**绝对 URI**(`GET http://…/path`),那是「经由代理」的直接证据。
**没验**:
* 真机上的「系统代理」:Windows 注册表 / macOS 网络偏好设置里的代理**没有被读**——本仓 reqwest
没开 `client-proxy-system` feature ⇒ `system` 档只认环境变量(`HTTP_PROXY` 等)。所以「我在
Windows 设置里开了系统代理」这件事对 `system` 档**无效**,这是已知限制,不是本页的承诺;
* PAC 脚本(`AutoConfigURL` / `PROXY.pac`)没测,代码里也没有任何 PAC 支持;
* 需要认证的代理(`Proxy-Authorization` / `Proxy::basic_auth`)没测;
* SOCKS 代理没测(reqwest 的 `socks` feature 本仓没开);
* HTTPS 目标的 `CONNECT` 隧道没测:夹具只讲明文 HTTP,`manual` 档对 https 目标的行为只在
「`Proxy::all` 会拦下 https」这一层有依据;
* 代理自身连不上/半死时被归到 `Network` 还是 `Timeout`(以及会不会被 `attempts_for` 重试)没测;
* **没在真的代理软件上点过**(Clash / v2rayN / 公司代理),也没在「只靠 `HTTP_PROXY` 上网」的
环境里回归过 `none` 由「碰巧跟随环境变量」变成真直连这件事——那是一次真实的行为变化(见 §5.2)。
* **发布版 GUI 的「下载 JDK」页仍不认代理两键**(M295):那一处是独立装配点,直接用
`EngineConfig::default()`(= `system`,跟随环境变量)。它与「并发 / 三段时间」是同一条已知缺口
(`app-iced/…/java/downloads/engine.rs` 模块头的对照表),要接就得把那一处并进 `wanted(cache_root)`。