Files
Unlucky-Launcher/docs/download-settings.md
T
worker-mirrorwire 69e3e198bf fix(download): M294 r1 —— JDK 下载页与 CLI 也接上源选择,文档同步
复审 p1:发布版 GUI 的「下载 JDK」页是第二处 DownloadEngine::new(要 with_progress
拿走引擎),此前只读了 gh_proxy,region / mirror_preference 到不了那里;报告却把 GUI
写成「已接」。复审 p2:CLI 四条命令同样不认这两个键 —— 同一份 launcher.json 在 GUI 与
CLI 上表现不同,等于把「设置项说假话」挪到 CLI 侧。

- app-iced downloads/engine.rs:新增 SourceSelection(只读键、不翻译)+
  engine_with(.., &SourceSelection),在 gh-proxy 三态之前叠
  DownloadTuning::with_source_selection(..).apply_source_selection(..)
- Request 加 source_selection 字段,构造点(app/update/settings/java/downloads.rs)
  用 SourceSelection::of_config(&config) 显式传值(不从缓存根反推配置路径:
  反推失败会静默按 auto 收场,正是本 mission 要消灭的形状)
- cli download.rs 的 tuning():链 .with_source_selection(..),四条命令共用这一处装配
- core 的 summary():把「地区 / 镜像偏好」两档印进 CLI 那行生效值(CLI 既然读了,
  少印两项就是新的半真话)
- shared.rs 注释说清「共享引擎」与本文件的第二处装配点;app-iced 模块头补对照表
- 新判据:JDK 下载页与 CLI 各一条断候选源顺序的用例,均配变异验证(换回裸
  EngineConfig::default() / 裸 tuning 即红);补一条 region=cn + gh_proxy=off 的
  叠加次序守卫(复审 P3-2)
- docs/download-settings.md:§1 表格两行、§5 拆「已接 / 未接」、新增 §3.1.1 覆盖面
  表、§6 补 M14-M16 三条变异、§7 补「镜像可达性没验」

连带(不加就编不过,已报备 tower):downloads/tests.rs 的 Request 字面量 +1 行、
downloads/tests/gh_proxy.rs 两处 engine_with 调用点补第四参。
2026-11-13 09:00:00 +08:00

21 KiB
Raw Blame History

下载设置到底改了谁(M193)

一句话:download.concurrency / download.retry_limit / download.gh_proxy 真的进引擎; download.region / download.mirror_preference 自 M294 起也真的进引擎(GUI 主路径、发布版 JDK 下载页、CLI 四条命令都认,见 §5);只剩 proxy_mode / proxy_url 仍是引擎侧零消费者—— 这一页把它们逐条标出来,别再让「设置项说假话」。

出处: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 无(http::build_client 从不调 .proxy()) 只走环境变量代理(reqwest 默认),system/manual 都不生效 ❌ 未接(见 §5)
download.proxy_url "" 无 空(从不使用) ❌ 未接(见 §5)
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. 唯一来源:默认值谁说了算

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. 接线链(宿主 → 引擎)

config/launcher.json
  download.concurrency ─┐
  download.retry_limit ─┼→ presenter::host::settings::download::tuning_of
  download.gh_proxy ────┤        │  DownloadTuning::from_settings(默认值/夹取/回落都在 core)
  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 }
                     + RegionProfile { mode(源选择叠出来的), gh_proxy }
                                 ↓
                     Semaphore(容量) / attempts_for() / MirrorTable::expand() 的候选顺序 + gh-proxy 候选

同步点是显式的,两处,共用同一个函数 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 「源选择」两键(region / mirror_preference)的覆盖面(M294)

路径 吃吗 装配点
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

三个宿主读的是同一个翻译层 DownloadTuning::with_source_selection(auto 回落、优先级、 未知值怎么退都只写在 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);只剩代理两键没接

5.1 已接:download.region / download.mirror_preference(M294)

两个键都落成同一个开关上的覆盖——RegionProfile.mode(引擎里「官方在前还是镜像在前」只有它, mirrors.rs::expand 与 gh_proxy_candidates 都只认它)。优先级是一条可读规则:

显式 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

引擎侧零消费者: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)与自更新要不要走同一个代理,是另一个决定,得先定。

已开 finding:download.proxy_mode / download.proxy_url 零消费者(代理问的是「怎么连」, 与「连哪里」不是同一个决定面,混在一个 diff 里审不动)。setting/keys/proxy.rs 的模块头那句 「download.proxy_mode → 引擎的 HTTP 客户端(http::build_client)」目前是假的,接线时一并改。

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 描述里的「164」改成「199」(第三处字面量) 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)

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 是否会因此放宽,全部未知。