复审 r1(p2-2items / fix-then-merge)两条 p2 都是本轮新开的「用户被骗」面,三条 p3 一并收。 p2-1(凭据泄漏面):`proxy_url` 允许 userinfo,而这个键在本仓就是凭据(schema 是 `AiPolicy::sensitive()`、`config get` 打 `<redacted>`、`config_secret_test.rs` 专门守它)。第一版把 manual 的原串整条写进 `summary()`(→ CLI stderr → CI 日志 / 截图)与解析失败文案,**不需要用户犯错**。 - `redact_proxy_url` 是唯一实现:`user:password@` → `user:***@`(用户名保留、口令打掉;只有用户名 没有口令 ⇒ 原样;路径/查询里的 `@` `:` 一律不碰,也不做任何 URL 归一)。 - 四个出口全过它:`describe()`(`summary()`/stderr)、`apply()` 的错误文案(`proxy_problem()`/ GUI 提示)、手写的 `Debug`(配置与引擎被打进日志时)、`problem()`。 - 刻意例外(写进 docs §5.3):设置页那个输入框仍显示文件原值——它是编辑面,回填掩码会在用户点 「应用」时把真口令冲掉。 p2-2(GUI 对「非法地址 ⇒ 回落直连」完全静默):`proxy_problem()` 的消费者只有 CLI。 - `ProxySetting::problem()` 是新增的**纯判定**(与 `apply` 共用 `manual_proxy`):宿主用它给提示, 不必造 client。两个宿主都调它,优先级写清:GUI 常驻(设置页「HTTP 代理」行第二段 `\n已回落到直连:<原因>`)、CLI 每次装配引擎一行 stderr。 - 取舍写进 docs §5.3:回落直连(而不是拒绝下载)不挡用户,代价是「以为走代理」,所以回落**必须** 可见;fail-closed 是产品决定,不在本轮。 p3-1:`socks5://` 在 `Proxy::all` 里返回 `Ok`,但本构建没开 socks feature ⇒ 以前静默变成「所有候选 源都失败了」。现在 `manual_proxy` 先自己 `Url::parse` 拿 scheme 做白名单,不支持的直接有界错误 (「本构建不支持 socks5 代理(只支持 http / https)」)⇒ 走已有的回落 + 告知路径。 p3-3:错因不再只有 `builder error`:`reason()` 沿 `source()` 链拼一句;宽口径只补到「少写 scheme」 为止(已带 `://` 不再套一层 `http://`,否则真错因会被掩盖成「解析成功」)。实测文案: `代理地址无法解析(http://127.0.0.1:99999):invalid port number`。 p3-2:两个测试文件已在 scope 里登记,本轮未动。 判据(都落在最终字符串 / 真正可见面上): - `a_password_in_the_proxy_url_never_reaches_a_user_visible_string`:生效值行 / 错误文案 / `Debug` 四处都不含口令子串且保留 `user:***@`;`a_proxy_url_without_a_password_is_left_byte_identical` 守「没口令的地址逐字不变」。 - `a_broken_proxy_url_is_shown_as_falling_back_to_direct`(presenter):设置页那一行含 `\n已回落到直连:`、带(脱敏后的)地址、不含口令,且与 `engine.proxy_problem()` 是同一串; 合规地址没有这条提示。 - `the_console_line_never_carries_the_proxy_password`(CLI):`announce` 那一行与回落提示都不含口令。 - `unsupported_schemes_and_bad_ports_explain_themselves`(core)+ `an_unsupported_proxy_scheme_is_ refused_with_a_readable_reason`(engine)。 - 变异 M23-M26 记在 docs §6(redact 返回原文 ⇒ 3 条红;删 `proxy_note` ⇒ GUI 提示红;白名单写 `if false` ⇒ socks 红;错因换回 `builder error` ⇒ 红)。 - 端到端(真二进制 `ull install 1.20.1 --dry-run`,`CARGO_TARGET_DIR` 同前):口令样本 `PWMUSTNOTLEAK` 在 stdout+stderr 里 **0 次**,两行都是 `user:***@`;socks5 那档打出 「本构建不支持 socks5 代理(只支持 http / https)」。 - 门禁:四 crate `--all-targets` check / 测试(3206 passed, 0 failed, 45 binaries)/ `clippy --all-targets -- -D warnings` / `fmt --all --check`,另 `check --workspace --all-targets`。
371 lines
35 KiB
Markdown
371 lines
35 KiB
Markdown
# 下载设置到底改了谁(M193)
|
||
|
||
一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 真的进引擎**;
|
||
`download.region` / `download.mirror_preference` 自 **M294** 起也真的进引擎(GUI 主路径、发布版 JDK
|
||
下载页、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,两处各写一个数。
|
||
|
||
## 1. 取证:八个下载类键的真实生效值
|
||
|
||
「真实生效值」是**改造前**(M193 之前)代码里实际会用的值,不是界面上写的值。
|
||
|
||
| 键 | schema 默认 / 范围 | 引擎侧消费者 | 真实生效值(改造前) | 本轮 |
|
||
|---|---|---|---|---|
|
||
| `download.concurrency` | 8 / 1–256 | **无**(恒用 `EngineConfig::default()`) | **64**(`Semaphore` 容量) | ✅ 接进引擎,范围收成 1–64 |
|
||
| `download.retry_limit` | 3 / 0–10 | **无** | 3(碰巧与 schema 相同,但改它无效) | ✅ 接进引擎,范围 0–10(0 与 1 都 = 只试一次) |
|
||
| `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 | **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` 的定义与
|
||
agent 的测试;同理 `download.proxy_mode` / `proxy_url` / `mirror_preference` 只有 presenter 的显示行。
|
||
|
||
## 2. 唯一来源:默认值谁说了算
|
||
|
||
```text
|
||
crates/core/src/download/config.rs
|
||
DEFAULT_MAX_CONCURRENCY = 8 ← 唯一写死数字的地方
|
||
MAX_CONCURRENCY = 64 ← 硬上限
|
||
DEFAULT_MAX_ATTEMPTS_PER_SOURCE = 3
|
||
MAX_ATTEMPTS_PER_SOURCE = 10
|
||
│
|
||
├─→ EngineConfig::default() (引擎默认)
|
||
├─→ DownloadTuning::default() (宿主没灌任何值时)
|
||
└─→ setting::keys 的 schema 默认值与范围 (界面/AI/CLI 看到的契约)
|
||
```
|
||
|
||
守卫 `the_schema_contract_is_single_sourced`(`download/config.rs`,它合并了原先的「默认值相等」与
|
||
「区间同源」两条)让「改一处另一个不变」直接变红——这是防 8 vs 64 复发的唯一手段。
|
||
另有 `the_key_docs_quote_the_real_limits` 钉住 schema 描述里的数字(见 §6.1.1 表格的第三行)。
|
||
|
||
## 3. 接线链(宿主 → 引擎)
|
||
|
||
```text
|
||
config/launcher.json
|
||
download.concurrency ─┐
|
||
download.retry_limit ─┼→ presenter::host::settings::download::tuning_of
|
||
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 页共用的唯一入口)
|
||
↓
|
||
shared::set_tuning(<cache 根>, tuning) 变了吗?没变就什么都不做
|
||
↓ 变了 → 淘汰该根的登记项
|
||
shared::engine_for(<cache 根>) → shared::build
|
||
↓
|
||
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`:
|
||
|
||
* `presenter/src/host/engine.rs::engine()` —— 每次取引擎前(幂等,值没变就一行不动);
|
||
* `presenter/src/host/dispatch.rs` 的 `SetSetting` 分支 —— 用户改完设置立刻生效,并让常驻持有
|
||
引擎的自更新 `Factory` 换一份(`refresh_update_factory`)。
|
||
|
||
调优按**数据根**存(与引擎登记处同键),不是进程级:同一进程里多个数据根时互不串味。
|
||
|
||
## 3.1 哪些路径吃这套设置
|
||
|
||
**四条 CLI 命令与 GUI 都吃**(M203 起。M193 时期这张表把三条 CLI 记成「自建裸引擎、零消费者」,那已不成立——教训见本节末)。
|
||
|
||
| 路径 | 入口 | 吃设置吗 |
|
||
|---|---|---|
|
||
| GUI 主路径的下载:内容库(Modrinth/CurseForge)、版本清单、资源/库/日志文件、字体、自更新(**不含**「下载 JDK」页,那条见 §3.1.1) | `presenter::host::engine::engine` → `core::download::shared::engine_for` | ✅ 吃(并发/尝试次数/gh-proxy 都吃) |
|
||
| CLI `ull install` | `crates/cli/src/commands/install/mod.rs:78` 的 `build_engine` → `crates/cli/src/download.rs::engine` | ✅ 吃(并发 / 尝试次数 / gh-proxy) |
|
||
| CLI `ull java` | `crates/cli/src/commands/java.rs:312` 同上 | ✅ 吃(并发 + 尝试次数 + gh-proxy) |
|
||
| CLI `ull pack` | `crates/cli/src/commands/pack.rs:286` 同上 | ✅ 吃(并发 / 尝试次数 / gh-proxy) |
|
||
| CLI `ull update`(**自更新路径**) | `crates/cli/src/commands/update.rs:64` 复用 `install::build_engine` | ✅ 吃(**并发这条路径上不可观测**:自更新只下**一个** artifact,闸门开 1 还是 8 都只有一条连接;真正随设置变的是每源尝试次数与 gh-proxy 前缀) |
|
||
|
||
四条 CLI 路径共用同一次装配:`crates/cli/src/download.rs` 从 **CLI 自己的** `launcher.json`(与
|
||
`ull config set` 同一份文件)读八个 `download.*` 键(六个数字/三态 + M294 的两个源选择枚举)→
|
||
`DownloadTuning::from_raw(..).with_source_selection(..)`(默认值/夹取/回落都在 core)→
|
||
`shared::engine_with_tuning(<cache 根>, tuning)`。所以 `presenter` 的 `shared::build` 不再是
|
||
**全仓唯一**的装配点,但装配规则仍只有 core 那一份。
|
||
|
||
### 3.1.1 「源选择」两键(M294)与代理两键(M295)的覆盖面
|
||
|
||
| 键族 | 路径 | 吃吗 | 装配点 |
|
||
|---|---|---|---|
|
||
| `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 一处);代理两键的翻译层是 `ProxySetting::from_raw`(三档 + 那两句「非 manual
|
||
忽略地址」「manual 留空 = 系统代理」,同样只写在 core 一处)。两边都是:**宿主只负责把
|
||
`launcher.json` 里的键读出来并显式传下去**。
|
||
|
||
|
||
回落仍是静默的:`SettingStore::load` 永不失败(坏值只进 `problems()`),类型不对 → `None`,越界 → 出厂
|
||
值,不报错、不中断安装。历史教训保留在此:M193 把出厂并发从 64 改成 8 之后,当时还在用裸
|
||
`EngineConfig::default()` 的那几条命令**静默**跟着降了——「用出厂值」和「读到了设置」在输出上一模一样,
|
||
这正是 §6 里三条 CLI 变异要抓的东西。
|
||
|
||
## 4. 三态语义:`download.gh_proxy`
|
||
|
||
| 值 | 含义 | 效果 |
|
||
|---|---|---|
|
||
| 空串(出厂) | **没有覆盖**,跟随内置默认 | 镜像优先/国内判定时套 `mirrors.json` 里的 `gh-proxy.com` |
|
||
| `off`(也认 `none`/`direct`/`disable`/`disabled`/`no`) | 明确关掉 | 一个 gh-proxy 候选都不生成 |
|
||
| 其它非空 | 覆盖前缀 | 用它替换内置前缀(类别仍只在镜像优先/国内判定时启用) |
|
||
|
||
**空串 ≠ 关**:「我还没决定」和「我明确不要」是两件事。设置页的说明文案(presenter 行 + iced 行)
|
||
两档必须都写出来——这是最容易让用户以为「关掉了、其实还在套前缀」的地方。
|
||
|
||
边界:非法值(负数、超上限、手改坏值)**一律回落出厂默认**,不报错、不阻断下载;schema 在写入口
|
||
就拒绝越界值,引擎侧再夹一次是纵深防御(老文件里可能躺着旧 schema 允许的大数)。
|
||
|
||
**`download.retry_limit = 0` 是合法值**(P2-1 复审的结论):旧版 GUI 的档位表里就有 `0`,所以
|
||
范围保留 `0~10`,语义定为 **`0` 与 `1` 都 = 只试一次、不重试**(`attempts = max(1, value)`)。
|
||
这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现
|
||
「界面显示 0、引擎按 3 跑」。
|
||
|
||
## 5. 源选择两键(M294)与代理两键(M295)都已接
|
||
|
||
### 5.1 已接:`download.region` / `download.mirror_preference`(M294)
|
||
|
||
两个键都落成**同一个开关**上的覆盖——`RegionProfile.mode`(引擎里「官方在前还是镜像在前」只有它,
|
||
`mirrors.rs::expand` 与 `gh_proxy_candidates` 都只认它)。优先级是一条可读规则:
|
||
|
||
```text
|
||
显式 mirror_preference(official / mirror) ← 最具体,永远优先
|
||
> 显式 region(cn / global)
|
||
> 自动探测(时区 + 语言 + 系统信号)与 region.json 的 24h 缓存
|
||
```
|
||
|
||
* 分工:`region` 定「我在不在大中华区」(`greater_china` → `is_domestic()` → JDK 发行版默认序)
|
||
与该地区默认模式;`mirror_preference` 只定「谁在前」,不动地区判定。
|
||
* 叠加原语是 `RegionProfile::overridden(mode, greater_china)`(**不是** `pinned`:`pinned` 会把
|
||
`greater_china` 一起改掉,而显式 `official` 不该抹掉「我在国内」这个事实)。探测逻辑一行没改,
|
||
也没有第二套探测。
|
||
* **读时叠加、不落盘**:覆盖发生在装配引擎那一刻(`shared::build` / `engine_with`),`region.json`
|
||
里永远只有自动判定的结论,所以 24h 重判不会被覆盖层永久冻住。
|
||
* 回落:只认 schema 的那几个词(大小写/空白不敏感),其余一切(空串、`mars`、非字符串)当 `auto`,
|
||
静默退到下一层、不报错、不中断下载。
|
||
* 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`(M295)
|
||
|
||
**M295 之前引擎侧零消费者**:`http::build_client` 只有 UA / 连接超时 / 重定向 / 默认头,**从不调
|
||
`.proxy()`**。于是 `manual`(指定地址)与 `none`(强制直连)都不生效;`system` 那一档「碰巧能用」
|
||
只是因为 reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是那段代码做的。
|
||
|
||
现在两个键翻译成 `EngineConfig.proxy`(`core::download::proxy::ProxySetting`),唯一消费点是
|
||
`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`)、方括号不闭合这一类——用例挑的是前者。
|
||
|
||
### 5.3 口令脱敏与「回落必须被看见」(M295 r1 复审 p2-1 / p2-2)
|
||
|
||
**p2-1:口令不许出现在任何用户可见 / 会落盘的字符串里。** `proxy_url` 允许带 userinfo
|
||
(`http://user:password@host:port`),而这个键在本仓就是凭据(schema 是 `AiPolicy::sensitive()`,
|
||
`ull config get` 打 `<redacted>`,`crates/cli/tests/config_secret_test.rs` 专门守它)。M295 第一版
|
||
把 `manual` 的原串整条写进 `summary()`(→ CLI stderr,进而 CI 日志 / 用户的截图)与解析失败文案,
|
||
等于把口令还原成明文,而且**不需要用户犯错**。现在:
|
||
|
||
* **唯一实现**:`core::download::proxy::redact_proxy_url`——`user:password@` → `user:***@`(用户名保留、
|
||
口令打掉;userinfo 里没有 `:` ⇒ **逐字原样**;路径/查询里的 `@` `:` 一律不碰,也不做任何 URL 归一);
|
||
* **四个出口都过它**:`ProxySetting::describe()`(`summary()` / CLI stderr)、`apply()` 的错误文案
|
||
(`proxy_problem()` / GUI 提示)、手写的 `Debug`(配置与引擎被打进日志时)、`problem()`;
|
||
宿主**不许**自己再拼一遍地址;
|
||
* 例外(刻意留着):设置页那个**输入框**显示文件原值——它是编辑面,把 `user:***@host` 回填进去、
|
||
用户再点「应用」就会把掩码写回配置、把真口令冲掉。文件本身也是明文,掩码的价值在输出面不在编辑面。
|
||
|
||
**p2-2:回落必须被看见。** 引擎对「解析不了 / 本构建用不了」的处置是**回落直连**(见下),M295 第一版
|
||
只有 CLI 说了,GUI 一个字都没有——用户在「直连能通、但他要求走代理(公司策略 / 隐私)」的场景下会以为
|
||
流量在代理里,实际裸奔。现在两个宿主都调**同一个判定** `ProxySetting::problem()`(与 `apply` 共用
|
||
`manual_proxy`,所以不会各说一套):
|
||
|
||
| 面 | 形态 | 何时出现 |
|
||
|---|---|---|
|
||
| CLI | 装配引擎时一行 stderr:`下载代理未生效,本次按直连:<原因>` | 每条命令装配引擎时(`download::engine`) |
|
||
| GUI 设置页 | 「HTTP 代理」这一行的第二段(`\n已回落到直连:<原因>`) | 打开设置页即见(`host::settings::download::group`) |
|
||
|
||
**取舍(显式写下来)**:为什么是「回落直连」而不是「拒绝下载」?拒绝对用户更不挡路——下载一个坏掉的
|
||
代理地址不该让整个安装停摆,而设置字符串又很容易写错(少个端口、多了个 scheme)。代价是「用户以为走
|
||
代理」,所以配一条硬要求:**回落必须有可见提示**,两处都要有(GUI 常驻、CLI 每次装配一行)。
|
||
要改成 fail-closed 只需让 `build_client` 的错误直接冒泡,但那是产品决定,不在 M295 的范围。
|
||
|
||
**p3-1 / p3-3:`Proxy::all` 会骗人,原因串也不好读。** 两处补丁:
|
||
|
||
* `socks5://…`(以及任何非 http/https)在 `Proxy::all` 里返回 **`Ok`**,但本构建 reqwest 没开 `socks`
|
||
feature ⇒ 以前表现为「没有任何提示,然后所有候选源都失败了」,把原因推给源。现在 `manual_proxy`
|
||
先用 `Url::parse` 拿 scheme 做白名单,不支持的直接给有界错误「本构建不支持 socks5 代理(只支持
|
||
http / https):…」,于是它走已有的「回落直连 + 告知」路径;
|
||
* 原因串不再只有 `builder error`:`reason()` 沿 `source()` 链拼成一句(`builder error:invalid port
|
||
number` → 现在先自己解析,通常只剩 `invalid port number`);宽口径只补到「少写 scheme」为止,
|
||
已经带 `://` 的串**不再**套一层 `http://`(那会把真错因掩盖成「解析成功」)。
|
||
|
||
## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原)
|
||
|
||
| # | 变异(只改实现侧) | 守卫用例 | 结果 |
|
||
|---|---|---|---|
|
||
| M1 | `shared::build` 的 `tune.apply_to(...)` 换回裸 `EngineConfig::default()` | `download::shared::tests::the_setting_and_the_gh_proxy…` | 红(探针 8,期望 2) |
|
||
| M2 | `shared::build` 的 `tune.apply_gh_proxy(region)` 换回 `region` | 同上 | 红(`off` 档仍有 gh-proxy 候选) |
|
||
| M3 | 删掉 `set_tuning` 里的 `registry().remove` | 同上 | 红(换参数后交回旧引擎) |
|
||
| M4 | 删掉构造点的 `clamp(1, MAX_CONCURRENCY)` | 同上 | 红(9999 张许可真开了) |
|
||
| M5 | `EngineConfig::default()` 的并发写回字面量 64 | `download::config::tests::the_schema_contract_is_single_sourced` | 红 |
|
||
| M6 | schema 的并发默认值写成字面量 16 | 同上 | 红(两处各写一个数) |
|
||
| M7 | 删掉 `host/engine.rs::engine` 里的 `apply_tuning` | `host::settings::tests::the_concurrency_setting…` | 红(探针 8,期望 2) |
|
||
| M8 | 下载设置页的 gh-proxy 行改回 `("Preferred source", "Automatic (region)")` | `the_downloads_rows_state_semantics…` | 红 |
|
||
| M9 | `from_settings` 的尝试次数去掉 `.max(1)`(0 原样交给引擎) | `download::config::tests::missing_or_illegal_values_fall_back_to_the_defaults` | 红(`left: 0 / right: 1`) |
|
||
| M10 | schema 描述里的「1~64」改成「1~99」(第三处字面量) | `download::config::tests::the_key_docs_quote_the_real_limits` | 红 |
|
||
| M11 | `commands::pack::build_engine` 换回裸 `EngineConfig::default()`(其余三条不动) | `cli::download::tests::pack_engine_takes_the_concurrency_from_the_setting_store` | 红(探针 8 ≠ 2),另四条 CLI 用例绿 |
|
||
| 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 候选) |
|
||
| 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 单测 `proxy::tests::unsupported_schemes_and_bad_ports_explain_themselves`) | 红(两处都 panic:`代理地址: … InvalidPort`) |
|
||
| M23(M295 r1) | `redact_proxy_url` 开头直接 `return raw.to_string()` | `download::proxy::tests::a_password_in_the_proxy_url_never_reaches_a_user_visible_string`(另两条同族一起红) | 红(`test result: FAILED. 9 passed; 3 failed`)。红原文:`本构建不支持 socks5 代理(只支持 http / https):socks5://user:PWMUSTNOTLEAK@127.0.0.1:1080` |
|
||
| M24(M295 r1) | presenter `group()` 里 `proxy_note` 换成 `None`(= GUI 不再说「已回落到直连」) | `host::settings::download::tests::selection_and_proxy::a_broken_proxy_url_is_shown_as_falling_back_to_direct` | 红(`FAILED. 3 passed; 1 failed`):`用不了的代理地址必须在设置页说出来(独占一行):"只在 manual 模式生效…原值保留"` |
|
||
| M25(M295 r1) | `manual_proxy` 的 scheme 白名单写成 `if false`(= socks5 又变成「解析成功」) | `download::proxy::tests::unsupported_schemes_and_bad_ports_explain_themselves`(+ engine_mock 的 socks 用例) | 红(`FAILED. 11 passed; 1 failed`):`本构建用不了的 scheme 必须报错` |
|
||
| M26(M295 r1) | 错误文案的错因换回 `builder error`(= p3-3 报的那个形态) | 同上 | 红(`FAILED. 11 passed; 1 failed`):`原因要能读(「builder error」等于没说):代理地址无法解析(http://127.0.0.1:99999):builder error` |
|
||
|
||
**M20 / M21 的一条附注**:直接把那两个字段**删掉**根本编译不过——两个宿主的 `RawTuning` 是完整字段
|
||
字面量(没有 `..RawTuning::default()`),编译器会报 `E0063 missing fields proxy_mode and proxy_url`。
|
||
所以「宿主忘了读这两个键」这一档是**编译期**拦住的;M20/M21 验的是「读到了但没用」(传 `None`),
|
||
那是编译器管不着的下一半。
|
||
|
||
## 6.1 gh-proxy 那一行的形状(P3-1):M201 之后已解决
|
||
|
||
M193 时期这一组把「GitHub 加速前缀」的三态画成三行(`GitHub 加速前缀 / 留空 = 跟随内置默认`、
|
||
`关掉它 / 填 off = 关闭`、`换掉它 / 填 URL 前缀 = 覆盖`)。这是**已知的观感问题**:
|
||
①`关掉它` / `换掉它` 读起来像按钮而不是取值说明;②同一个设置画成三行比三态本身更难懂。
|
||
|
||
当时的结论是「正确的家是下一条『iced 设置数据通路』mission」——**那一条已落地(M201)**,现状:
|
||
|
||
| 项 | 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!` + `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 工具与文档可见),所以它必须有守卫——这也是三处里唯一补上了守卫的一处。
|
||
|
||
## 7. 没验的部分
|
||
|
||
* **真实网络下的并发/重试行为未验**:守卫读的是 `Semaphore` 的真实许可数与 `attempts_for()` 的
|
||
配置读点;「2 条真连接同时下载时服务器只看到 2 条」和「某个源真的被试 3 次」没有在真网络下跑过
|
||
(离线回环能验连接复用,验不了限流的端到端时序)。
|
||
* **macOS / Linux 未验**:本轮只在 Windows 上编译与跑测试。
|
||
* **`download.gh_proxy` 的端到端只到「候选生成」为止**(键已合入,M197/M203 之后不再是「登记了但读不到」):
|
||
三处各有守卫——core 的「调优 → 候选链」(`gh_proxy_override_reaches_the_profile_and_the_chain`)、
|
||
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` 请求。
|
||
* **真机上的手点交互没验**(设置页自 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 发回的快照——这一点同样没在真机上确认过)。
|
||
* **镜像可达性没验**(M294):源选择两键的用例全部离线(`region.json` 预置 + `expand()`/`plan()` 断源名
|
||
顺序),**没有向 BMCLAPI / TUNA / gh-proxy.com 发过一条真实请求**。所以「设成 `cn` 之后用户真的更快」
|
||
这件事本页不做承诺——只承诺「候选顺序按设置变了」。真机侧同样没点过 GUI 的 JDK 下载按钮观察命中源。
|
||
* **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 是否会因此放宽,全部未知。
|
||
* **代理只验到「明文 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`)没测;userinfo 里的账号口令
|
||
是否被 reqwest 转成 `Proxy-Authorization` 也没验(我们只保证**文案**里没有口令,见 §5.3);
|
||
* SOCKS 代理**不支持**(reqwest 的 `socks` feature 没开)。M295 r1 起它不再是「静默直连」:
|
||
`socks5://` 会被 scheme 白名单拦下有界错误并提示「本构建不支持 socks5 代理」(§5.3),但「能用
|
||
SOCKS」这件事仍然没有;
|
||
* HTTPS 目标的 `CONNECT` 隧道没测:夹具只讲明文 HTTP,`manual` 档对 https 目标的行为只在
|
||
「`Proxy::all` 会拦下 https」这一层有依据;
|
||
* 代理自身连不上/半死时被归到 `Network` 还是 `Timeout`(以及会不会被 `attempts_for` 重试)没测;
|
||
* **没在真的代理软件上点过**(Clash / v2rayN / 公司代理),也没在「只靠 `HTTP_PROXY` 上网」的
|
||
环境里回归过 `none` 由「碰巧跟随环境变量」变成真直连这件事——那是一次真实的行为变化(见 §5.2)。
|
||
* 「回落提示」只在 CLI 的真实二进制上端到端验过(`ull install --dry-run` 两行都不含口令);
|
||
**GUI 那一行只在单元层面验**(`group()` 产出的 `description` 文本),没在真窗口里看过它渲染、
|
||
折行、以及它与 424 列表单宽度的相容性(设置行的渲染在 `app-iced`,不在本页 scope)。
|
||
* **发布版 GUI 的「下载 JDK」页仍不认代理两键**(M295):那一处是独立装配点,直接用
|
||
`EngineConfig::default()`(= `system`,跟随环境变量)。它与「并发 / 三段时间」是同一条已知缺口
|
||
(`app-iced/…/java/downloads/engine.rs` 模块头的对照表),要接就得把那一处并进 `wanted(cache_root)`。
|