From 69e3e198bf9a72d755109749eebc6384c54b40ae Mon Sep 17 00:00:00 2001 From: worker-mirrorwire Date: Fri, 13 Nov 2026 08:59:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(download):=20M294=20r1=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20JDK=20=E4=B8=8B=E8=BD=BD=E9=A1=B5=E4=B8=8E=20CLI=20=E4=B9=9F?= =?UTF-8?q?=E6=8E=A5=E4=B8=8A=E6=BA=90=E9=80=89=E6=8B=A9=EF=BC=8C=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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 调用点补第四参。 --- .../src/app/update/settings/java/downloads.rs | 7 +- .../src/pages/settings/java/downloads.rs | 14 +- .../pages/settings/java/downloads/engine.rs | 199 +++++++++++++++++- .../pages/settings/java/downloads/tests.rs | 1 + .../settings/java/downloads/tests/gh_proxy.rs | 11 +- crates/cli/src/download.rs | 18 +- crates/cli/src/download/tests.rs | 46 ++++ crates/core/src/download/config/tuning.rs | 12 +- crates/core/src/download/preference/tests.rs | 32 +++ crates/core/src/download/shared.rs | 3 +- docs/download-settings.md | 88 ++++++-- 11 files changed, 395 insertions(+), 36 deletions(-) diff --git a/crates/app-iced/src/app/update/settings/java/downloads.rs b/crates/app-iced/src/app/update/settings/java/downloads.rs index 2583b91..f2c6b8b 100644 --- a/crates/app-iced/src/app/update/settings/java/downloads.rs +++ b/crates/app-iced/src/app/update/settings/java/downloads.rs @@ -37,7 +37,9 @@ impl App { /// ① 目录里有这个大版本;② 没有别的作业在跑;③ 有可写的设置文件与能解析的落点; /// ④ 这一档在这台机器上真有一家发行版承接(纯判据,不联网)。 pub(super) fn start_java_download(&mut self, major: u32) { - use pages::settings::java::downloads::{gh_proxy_of_config, Refusal, Request}; + use pages::settings::java::downloads::{ + gh_proxy_of_config, Refusal, Request, SourceSelection, + }; self.settings.java.download_notice = None; self.settings.java.notice = None; @@ -80,6 +82,9 @@ impl App { // `download.gh_proxy`(M197 的任务书那句「默认值可改」):空 = 跟随内置默认, // `off` = 关,其余 = 覆盖前缀。Java 页不画这一项的 UI(归 M193 的下载设置页)。 gh_proxy: gh_proxy_of_config(&config), + // M294:`download.region` / `download.mirror_preference` 从**同一份** config 读出来 + // 显式传下去(装配点不猜配置在哪;翻译在 core,这里只搬原文)。 + source_selection: SourceSelection::of_config(&config), }); if !started { self.settings.java.download_notice = Some(Refusal::Busy); diff --git a/crates/app-iced/src/pages/settings/java/downloads.rs b/crates/app-iced/src/pages/settings/java/downloads.rs index 0535e2f..a8f28da 100644 --- a/crates/app-iced/src/pages/settings/java/downloads.rs +++ b/crates/app-iced/src/pages/settings/java/downloads.rs @@ -54,7 +54,7 @@ mod engine; mod recommend; mod refusal; -pub use engine::{gh_proxy_of_config, GhProxy}; +pub use engine::{gh_proxy_of_config, GhProxy, SourceSelection}; pub use recommend::recommend; pub use refusal::Refusal; @@ -211,6 +211,11 @@ pub struct Request { pub priority: Vec, /// `download.gh_proxy`(`Builtin` = 跟随内置默认;`Off` = 关;`Prefix` = 覆盖前缀) pub gh_proxy: GhProxy, + /// `download.region` / `download.mirror_preference` 的原文(M294;两个 `None` = 都没设 = 按探测) + /// + /// 由调用方从**同一份** `launcher.json` 读出后显式传进来([`SourceSelection::of_config`]): + /// 装配点不再自己去猜配置在哪。翻译(`auto` 回落/优先级)仍只有 core 那一处。 + pub source_selection: SourceSelection, } /// 作业槽:快照 + 「线程还活着」。 @@ -272,7 +277,12 @@ impl Slot { feed.apply(event); } }); - let engine = engine::engine_with(&request.cache_root, emitter, &request.gh_proxy); + let engine = engine::engine_with( + &request.cache_root, + emitter, + &request.gh_proxy, + &request.source_selection, + ); let install_request = InstallRequest::new(request.major).with_priority(request.priority); let result = block_on(install(&engine, &request.install_root, install_request)); // 引擎丢掉 ⇒ 发射器丢掉 ⇒ 抽干线程读到 `None` 自然退出。 diff --git a/crates/app-iced/src/pages/settings/java/downloads/engine.rs b/crates/app-iced/src/pages/settings/java/downloads/engine.rs index 900a36b..7046704 100644 --- a/crates/app-iced/src/pages/settings/java/downloads/engine.rs +++ b/crates/app-iced/src/pages/settings/java/downloads/engine.rs @@ -1,7 +1,25 @@ -//! 带进度发射器的下载引擎(M197):一次 JDK 安装一个实例 + `download.gh_proxy` 的真消费者。 +//! 带进度发射器的下载引擎(M197):一次 JDK 安装一个实例 + `download.*` 的真消费者。 //! //! 从 `downloads.rs` 拆出来(行数 + 口径独立:这里只说「为什么不用共享引擎」)。 -//! 装配口径与 `core::download::shared::build` 一致,只多挂一个 `ProgressEmitter`。 +//! +//! ## 与 `core::download::shared::build` 的关系(别读成「一样」) +//! +//! 一个数据根上**有两处** `DownloadEngine::new`:共享登记处(`shared::build`,GUI 主路径与 CLI +//! 都走它)和这里(JDK 下载页,因为 `with_progress` 要**拿走**引擎)。差别是实打实的: +//! +//! | | `shared::build` | 这里 | +//! |---|---|---| +//! | 缓存 / 健康度 | 该数据根 | 同一份(盘上共用) | +//! | 设置调优(并发 / 重试 / 三段超时) | `wanted(cache_root)`(M193/M292/M289) | **`EngineConfig::default()`** —— 未接,已知差 | +//! | 源选择(`region` / `mirror_preference`) | `apply_source_selection`(M294) | 同一入口(M294 r1) | +//! | gh-proxy 三态 | `DownloadTuning::apply_gh_proxy` | 本文件的 [`GhProxy`](M197) | +//! | 进度事件 | `ProgressEmitter::silent()` | 这次作业的 emitter | +//! +//! 第一行「调优」是**没接**的差:JDK 页不理会 `download.concurrency` / 超时键。要接就把它并进 +//! `DownloadTuning` 一起用 `wanted(cache_root)`——那是另一个决定面,不在 M294 里顺手做。 +//! 「源选择」那一行本轮接上了(r1 复审 p1:设置页改「地区/镜像偏好」在这条路径上没生效), +//! 并且走的是**与共享引擎同一个翻译层**(`DownloadTuning::with_source_selection`),宿主里没有 +//! 第二份字符串匹配。 //! //! ## gh-proxy 的取值链(任务书那句「(默认值可改)」的落点) //! @@ -24,8 +42,10 @@ use std::path::Path; use std::sync::Arc; use unlucky_core::download::cache::now_ms; -use unlucky_core::download::config::KEY_GH_PROXY; -use unlucky_core::download::region::{decide_from, load_cached}; +use unlucky_core::download::config::{ + DownloadTuning, KEY_GH_PROXY, KEY_MIRROR_PREFERENCE, KEY_REGION, +}; +use unlucky_core::download::region::{decide_from, load_cached, RegionProfile}; use unlucky_core::download::region_system::detect_signals; use unlucky_core::download::{ ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable, ProgressEmitter, @@ -76,7 +96,46 @@ pub fn gh_proxy_of_config(config: &Path) -> GhProxy { gh_proxy_from(store.doc().effective_str(KEY_GH_PROXY).unwrap_or_default()) } -/// 带进度发射器的引擎(装配口径与 `download::shared::build` 一致,只多挂一个 emitter + gh-proxy 覆盖)。 +/// 一次 JDK 下载要用的两个「源选择」键的**原文**(`download.region` / `download.mirror_preference`)。 +/// +/// **显式传值**:由按钮回调(`app/update/settings/java/downloads.rs`)从同一份 `launcher.json` 读出、 +/// 挂进 [`Request`](crate::pages::settings::java::downloads::Request) 随作业传到这里,「设置从哪来」 +/// 在调用处一眼可读。不从缓存根反推配置路径——反推失败就静默按 `auto` 收场,正是这条 mission 要 +/// 消灭的那种「设置被无声忽略」。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SourceSelection { + /// `download.region` 原文(`None` = 键不存在 / 不是字符串)。 + pub region: Option, + /// `download.mirror_preference` 原文(同上)。 + pub mirror_preference: Option, +} + +impl SourceSelection { + /// 从 `launcher.json` 把两个键**读出来**(文件不存在 / 键没写 ⇒ `effective_str` 给 schema 默认 + /// 值 `"auto"`;非字符串值才是 `None`。两者在 core 那里同义,都落成 `Auto`)。 + /// + /// **这里不做默认值回落、也不做取值校验**:`auto` 的语义、大小写与空白的归一、未知值怎么退, + /// 全部由 [`DownloadTuning::with_source_selection`] 负责(全仓唯一一处)。在这个函数里再加一层 + /// 判断,就是「两份口径」的种子——GUI 认一套、CLI 认另一套。 + #[must_use] + pub fn of_config(config: &Path) -> Self { + let store = SettingStore::load(config, launcher_schema()); + let doc = store.doc(); + Self { + region: doc.effective_str(KEY_REGION).map(str::to_string), + mirror_preference: doc.effective_str(KEY_MIRROR_PREFERENCE).map(str::to_string), + } + } + + /// 叠到地区档案上:显式设置压过探测结论(`gh_proxy` 留 `Builtin`,前缀仍由调用点那三态决定)。 + fn apply(&self, profile: RegionProfile) -> RegionProfile { + DownloadTuning::default() + .with_source_selection(self.region.as_deref(), self.mirror_preference.as_deref()) + .apply_source_selection(profile) + } +} + +/// 带进度发射器的引擎(装配口径见模块头的对照表,**不等于** `shared::build`)。 /// /// 为什么不再走 `shared::engine_for`:那个登记处交出的是 `Arc`,而挂进度要 /// 拿走引擎(`with_progress(self)`)。要让共享引擎带「按作业」的观察者,得改引擎内部的 @@ -87,6 +146,7 @@ pub(super) fn engine_with( cache_root: &Path, progress: ProgressEmitter, gh_proxy: &GhProxy, + selection: &SourceSelection, ) -> DownloadEngine { let now = now_ms(); let profile = load_cached(&cache_root.join("region.json"), now).unwrap_or_else(|| { @@ -94,6 +154,10 @@ pub(super) fn engine_with( let _ = std::fs::create_dir_all(cache_root); profile }); + // 显式地区 / 镜像偏好先叠(M294):`DownloadTuning::default()` 的 gh_proxy 是 `Builtin` + // (恒等),所以这一步只动「谁在前」;前缀仍由下面那三态决定——那是 JDK 页 `off` 档的唯一 + // 真相源,不在这里再翻译一次。 + let profile = selection.apply(profile); let profile = match gh_proxy { GhProxy::Builtin => profile, GhProxy::Off => profile.with_gh_proxy(None), @@ -108,3 +172,128 @@ pub(super) fn engine_with( ) .with_progress(progress) } + +#[cfg(test)] +mod tests { + //! JDK 下载页这个**独立装配点**的守门(M294 r1 复审 P1-1)。 + //! + //! 不进 `downloads/tests.rs`:那一族守的是作业/快照,而这里守的是装配点本身,测试就在被守的 + //! 函数旁边(`engine_with` 是 `pub(super)`,同文件内可见)。 + + use super::*; + use std::path::PathBuf; + use unlucky_core::download::region::{store_cached, RegionProfile}; + use unlucky_core::download::types::{DownloadRequest, ResourceKind}; + + const LIBRARIES_URL: &str = + "https://libraries.minecraft.net/com/google/guava/guava/32.0.0/guava-32.0.0.jar"; + + /// 一次 JDK 下载的临时数据根(`/cache` + `/config`,与 `prefs_store` 的布局一致)。 + fn root(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ull-jdk-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("cache")).expect("cache"); + std::fs::create_dir_all(dir.join("config")).expect("config"); + dir + } + + /// 这个数据根的 `launcher.json`(与 `prefs_store` 的布局一致:`/config/launcher.json`)。 + fn config_of(dir: &Path) -> PathBuf { + dir.join("config").join("launcher.json") + } + + fn write_config(dir: &Path, key: &str, value: &str) { + let mut store = SettingStore::load(config_of(dir), launcher_schema()); + store + .set(key, value.to_string().into()) + .unwrap_or_else(|err| panic!("schema 认识 {key}:{err}")); + store.save().expect("写 launcher.json"); + } + + fn clear_config(dir: &Path) { + std::fs::write(config_of(dir), b"{}").expect("清空 launcher.json"); + } + + fn plan_sources(engine: &DownloadEngine, dest: &Path) -> Vec { + let request = DownloadRequest::new( + ResourceKind::Library, + LIBRARIES_URL.to_string(), + dest.to_path_buf(), + ); + engine + .plan(&request, 0) + .into_iter() + .map(|candidate| candidate.source) + .collect() + } + + /// **P1-1 判据**:`download.region` / `download.mirror_preference` 改得动 JDK 下载页的候选顺序。 + /// + /// 两段都让设置与 `region.json` 的探测结论**相反**,所以「装配点没读设置」时断言必红 + /// (未接线时两段的第一条都还是探测值 `bmclapi` / `official`)。 + /// + /// 变异证明:删掉 `engine_with` 里那句 `apply_source_selection`(换回裸档案)⇒ 两段顺序各自红。 + /// 抓不住:真机上下载 JDK 时实际命中哪个镜像(离线,没有网络证据)。 + #[test] + fn the_source_selection_settings_reach_the_jdk_download_engine() { + let dir = root("source-selection"); + let cache = dir.join("cache"); + let now = now_ms(); + + // 探测=镜像优先,设置 official ⇒ 官方第一条。 + store_cached( + &cache.join("region.json"), + &RegionProfile::mirror_first(now), + ) + .expect("档案"); + write_config(&dir, KEY_MIRROR_PREFERENCE, "official"); + let selection = SourceSelection::of_config(&config_of(&dir)); + let engine = engine_with( + &cache, + ProgressEmitter::silent(), + &GhProxy::Builtin, + &selection, + ); + assert_eq!( + plan_sources(&engine, &dir.join("a.jar")), + ["official", "bmclapi"], + "JDK 下载页也必须认 mirror_preference" + ); + + // 探测=直连,设置 cn ⇒ 镜像第一条(先清掉上一段的 official:它优先级更高)。 + store_cached(&cache.join("region.json"), &RegionProfile::direct(now)).expect("档案"); + clear_config(&dir); + write_config(&dir, KEY_REGION, "cn"); + let selection = SourceSelection::of_config(&config_of(&dir)); + let engine = engine_with( + &cache, + ProgressEmitter::silent(), + &GhProxy::Builtin, + &selection, + ); + assert_eq!( + plan_sources(&engine, &dir.join("a.jar")), + ["bmclapi", "official"], + "JDK 下载页也必须认 region" + ); + + // 不设 ⇒ 回到探测结论(这一条与 M294 之前逐值相同)。清空后 `effective_str` 给的是 + // schema 默认值 `auto`(不是 `None`)——两者在 core 那里同义,都落成 `Auto`。 + clear_config(&dir); + let selection = SourceSelection::of_config(&config_of(&dir)); + assert_eq!(selection.region.as_deref(), Some("auto"), "{selection:?}"); + let engine = engine_with( + &cache, + ProgressEmitter::silent(), + &GhProxy::Builtin, + &selection, + ); + assert_eq!( + plan_sources(&engine, &dir.join("a.jar")), + ["official", "bmclapi"], + "不设 = 探测结论(直连档案)" + ); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/app-iced/src/pages/settings/java/downloads/tests.rs b/crates/app-iced/src/pages/settings/java/downloads/tests.rs index f7b6a59..8823233 100644 --- a/crates/app-iced/src/pages/settings/java/downloads/tests.rs +++ b/crates/app-iced/src/pages/settings/java/downloads/tests.rs @@ -174,6 +174,7 @@ fn the_real_download_installs_a_usable_jdk() { install_root: JavaInstallRoot::custom(dir.join("jdks")), priority: Vec::new(), gh_proxy: super::GhProxy::Builtin, + source_selection: super::SourceSelection::default(), })); // 失败/panic 也要清理:这条用例会真下 ~180 MB,留在系统 temp 里是最贵的垃圾。 struct Cleanup(std::path::PathBuf); diff --git a/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs b/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs index 5684af7..21fa826 100644 --- a/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs +++ b/crates/app-iced/src/pages/settings/java/downloads/tests/gh_proxy.rs @@ -39,7 +39,7 @@ fn the_gh_proxy_setting_maps_to_three_states() { #[test] fn the_gh_proxy_setting_reaches_the_candidate_chain() { use crate::pages::settings::java::downloads::engine::engine_with; - use crate::pages::settings::java::downloads::{gh_proxy_of_config, GhProxy}; + use crate::pages::settings::java::downloads::{gh_proxy_of_config, GhProxy, SourceSelection}; use unlucky_core::download::cache::now_ms; use unlucky_core::download::region::{store_cached, RegionProfile}; use unlucky_core::download::types::{DownloadRequest, ResourceKind}; @@ -79,6 +79,8 @@ fn the_gh_proxy_setting_reaches_the_candidate_chain() { &cache, ProgressEmitter::silent(), &gh_proxy_of_config(&config), + // M294:这条用例只守 gh-proxy,两个源选择键留空(= 跟随 region.json 的判定)。 + &SourceSelection::default(), ); // ② 档案被覆盖 assert_eq!( @@ -104,7 +106,12 @@ fn the_gh_proxy_setting_reaches_the_candidate_chain() { ); // 关掉那一档:一个代理候选都不生成(同一份 region.json,只换设置值)。 - let off = engine_with(&cache, ProgressEmitter::silent(), &GhProxy::Off); + let off = engine_with( + &cache, + ProgressEmitter::silent(), + &GhProxy::Off, + &SourceSelection::default(), + ); assert_eq!(off.region().gh_proxy, None, "off 必须把前缀清掉"); let plan = off.plan(&request, 0); assert!( diff --git a/crates/cli/src/download.rs b/crates/cli/src/download.rs index 30c0cbc..7c22fcd 100644 --- a/crates/cli/src/download.rs +++ b/crates/cli/src/download.rs @@ -14,6 +14,11 @@ //! → shared::engine_with_tuning(, tuning) → 引擎的 Semaphore / attempts_for() / 三段超时 //! ``` //! +//! M294 r1:`download.region` / `download.mirror_preference` 走**同一条链**(入口是 +//! [`DownloadTuning::with_source_selection`],与 presenter 的 `tuning_of` 同一个),落成引擎的 +//! `RegionProfile`(候选源的先后)。在此之前 CLI 的四条命令认不到这两个键,同一份 +//! `launcher.json` 在 GUI 与 CLI 上表现不同——「设置项说假话」只是从 GUI 挪到了 CLI。 +//! //! M292:`download.*` 从三个键扩到六个(多了吞吐下限 / 无进展窗口 / 单次请求上界),装配链没变, //! 变的是 [`tuning`] 取值的宽度与 [`engine`] 里那一行**仅在改过设置时**才打的 stderr 说明——用户在 //! 慢链路上改完设置,得能在终端里看见引擎实际拿到了什么(P21 的验收判据;为什么不是写 @@ -26,7 +31,7 @@ use std::sync::Arc; use unlucky_core::download::config::{ DownloadTuning, RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, - KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR, + KEY_MIRROR_PREFERENCE, KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR, }; use unlucky_core::download::{shared, DownloadEngine}; use unlucky_core::setting::keys::launcher_schema; @@ -34,7 +39,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 空串 = 跟随内置默认,**不是**关。 @@ -42,6 +47,11 @@ use crate::context::Ctx; /// **本函数只做「取裸值」**:默认值、区间、回落、KiB→字节的换算全在 core 的 /// [`DownloadTuning::from_raw`](M292 起宿主不再逐个判断,也不再自己决定缺省值——那是「UI 说 8、 /// 引擎跑 64」长回来的地方)。 +/// +/// M294 r1:`download.region` / `download.mirror_preference` 也在这里读,但**不经过 +/// [`RawTuning`]**——那两个是枚举,翻译层是 core 的 [`DownloadTuning::with_source_selection`] +/// (未知值回落 `auto`)。CLI 与 presenter 走的是同一个入口,别在宿主里再写一份字符串匹配: +/// 两份口径就是「GUI 认、CLI 不认」的下一次复发。 #[must_use] pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning { let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); @@ -58,6 +68,10 @@ pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning { stall_timeout_secs: doc.effective_i64(KEY_CHUNK_STALL), response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT), }) + .with_source_selection( + doc.effective_str(KEY_REGION), + doc.effective_str(KEY_MIRROR_PREFERENCE), + ) } /// 生效值那一行说明,**只在改过设置时**给:`None` = 全是出厂值,没什么可说的。 diff --git a/crates/cli/src/download/tests.rs b/crates/cli/src/download/tests.rs index 3460394..33bd0a6 100644 --- a/crates/cli/src/download/tests.rs +++ b/crates/cli/src/download/tests.rs @@ -343,3 +343,49 @@ fn the_timeout_settings_reach_the_engine_and_the_console_line() { assert_eq!(super::tuning(&ctx), DownloadTuning::default()); assert!(super::announce(&super::tuning(&ctx)).is_none()); } + +/// 一个 URL 在 CLI 装配出来的引擎里的**源名顺序**(`plan()` 是引擎真正的选源决策点)。 +fn plan_sources(engine: &DownloadEngine) -> Vec { + let url = "https://libraries.minecraft.net/com/google/guava/guava/32.0.0/guava-32.0.0.jar"; + let dest = std::env::temp_dir().join("ull-cli-plan-guava.jar"); + let request = DownloadRequest::new(ResourceKind::Library, url.to_string(), dest); + engine + .plan(&request, 0) + .into_iter() + .map(|c| c.source) + .collect() +} + +/// **M294 r1(P2-1)**:两键进 CLI 四条命令的生产装配点,终端那行说明也含它们。 +/// +/// 判据是**候选顺序**:两段都让设置与探测结论相反,没接上时必红(变异:`download-settings.md` M15)。 +#[test] +fn the_source_selection_settings_reach_the_cli_engine() { + use unlucky_core::download::region::{store_cached, RegionProfile}; + + let dir = TempDir::new("source-selection"); + let ctx = ctx_at(&dir); + let cache = ctx.layout().cache(); + std::fs::create_dir_all(&cache).expect("建缓存根"); + let now = unlucky_core::download::cache::now_ms(); + let region_json = cache.join("region.json"); + + // 探测=镜像优先 + 设置 official ⇒ 官方第一条(未接线时是 bmclapi)。 + store_cached(®ion_json, &RegionProfile::mirror_first(now)).expect("档案"); + let mirror = json!({ "download": { "mirror_preference": "official" } }); + write_launcher_config(&ctx, mirror); + let engine = crate::commands::install::build_engine(&ctx); + assert_eq!(plan_sources(&engine), ["official", "bmclapi"], "官方第一条"); + let line = super::announce(&super::tuning(&ctx)).expect("该有一行"); + assert!(line.contains("镜像偏好=official"), "{line}"); + assert!(line.contains("地区=auto"), "{line}"); + + // 探测=直连 + 设置 cn ⇒ 镜像第一条(未接线时是 official)。 + store_cached(®ion_json, &RegionProfile::direct(now)).expect("档案"); + write_launcher_config(&ctx, json!({ "download": { "region": "cn" } })); + let engine = crate::commands::install::build_engine(&ctx); + assert_eq!(plan_sources(&engine), ["bmclapi", "official"], "镜像第一条"); + let line = super::announce(&super::tuning(&ctx)).expect("该有一行"); + assert!(line.contains("地区=cn"), "{line}"); + assert!(line.contains("镜像偏好=auto"), "{line}"); +} diff --git a/crates/core/src/download/config/tuning.rs b/crates/core/src/download/config/tuning.rs index 5eb8ae8..1c9c65e 100644 --- a/crates/core/src/download/config/tuning.rs +++ b/crates/core/src/download/config/tuning.rs @@ -177,9 +177,11 @@ impl DownloadTuning { /// 存在的理由就是 P21 的验收判据:用户改完设置之后,得在 `logs/launcher.log` 里**看见**生效值, /// 而不是只有「我以为我改了」。字段顺序与 `EngineConfig` 一致,单位写在名字里。 /// - /// M294 的两个「源选择」键**故意不在这一行里**:这一行唯一的读者是 CLI,而 CLI 的 `tuning()` - /// 还没读那两个键——印一句 `地区=auto` 对文件里写着 `cn` 的用户就是新的假话。等 CLI 接线 - /// (见 `with_source_selection` 的说明)再一起加。 + /// M294 r1:两个「源选择」档位(`地区` / `镜像偏好`)也在这里——CLI 的 + /// `download::tuning` 已经读了它们,这一行若不含就等于「打了一行说生效值、却少说两项」。两个值都用 + /// [`RegionPreference::as_str`] / [`MirrorPreference::as_str`],与 schema 的取值逐字相同。 + /// + /// (`crates/cli/src/download.rs::tuning` 是唯一读者。) #[must_use] pub fn summary(&self) -> String { let gh_proxy = match &self.gh_proxy { @@ -188,12 +190,14 @@ impl DownloadTuning { GhProxyOverride::Prefix(prefix) => prefix.clone(), }; format!( - "并发={} 单源尝试={} 吞吐下限={}KiB/s 无进展窗口={}s 单次请求上界={}s gh_proxy={gh_proxy}", + "并发={} 单源尝试={} 吞吐下限={}KiB/s 无进展窗口={}s 单次请求上界={}s 地区={} 镜像偏好={} gh_proxy={gh_proxy}", self.max_concurrency, self.max_attempts_per_source, self.min_avg_throughput / 1024, self.chunk_stall_timeout.as_secs(), self.response_timeout.as_secs(), + self.region.as_str(), + self.mirror_preference.as_str(), ) } diff --git a/crates/core/src/download/preference/tests.rs b/crates/core/src/download/preference/tests.rs index 8ede6b1..862a3fc 100644 --- a/crates/core/src/download/preference/tests.rs +++ b/crates/core/src/download/preference/tests.rs @@ -193,3 +193,35 @@ fn the_settings_reach_the_engines_candidate_plan() { std::fs::remove_dir_all(&dir).ok(); } + +/// **叠加次序**(r1 复审 P3-2):`gh_proxy` 三态在源选择**之后**叠,所以 +/// 「`region=cn`(⇒ 镜像优先)+ `gh_proxy=off`」⇒ 一个 gh-proxy 候选都不生成。 +/// +/// 这条此前只靠读 `apply_source_selection` 的实现,没有断言。变异证明:把 +/// `apply_source_selection` 写成 `preference::apply(self.apply_gh_proxy(p), …)`(先 gh_proxy 后 +/// 源选择)⇒ 前缀被重判成内置默认,`off` 那一段红。 +#[test] +fn an_explicit_gh_proxy_off_wins_over_a_mirror_first_region() { + use crate::download::config::GhProxyOverride; + + let url = "https://github.com/o/r/releases/download/v1/a.jar"; + let sources = |tune: &DownloadTuning| -> Vec { + let profile = tune.apply_source_selection(RegionProfile::direct(0)); + MirrorTable::builtin() + .expand(url, ResourceKind::GitHubRelease, &profile) + .into_iter() + .map(|candidate| candidate.source) + .collect() + }; + + // 基线:`region=cn` 让档案变镜像优先,GitHub 资源因此套上内置前缀。 + let mut tune = DownloadTuning::default().with_source_selection(Some("cn"), None); + assert_eq!(sources(&tune), ["gh_proxy", "official"]); + + // 同一次设置里再写 `off`:它最后叠 ⇒ 前缀被清掉,候选链只剩官方。 + tune.gh_proxy = GhProxyOverride::Off; + let off = tune.apply_source_selection(RegionProfile::direct(0)); + assert_eq!(off.mode, RegionMode::MirrorFirst, "off 不该改「谁在前」"); + assert_eq!(off.gh_proxy, None, "off 必须清掉前缀"); + assert_eq!(sources(&tune), ["official"]); +} diff --git a/crates/core/src/download/shared.rs b/crates/core/src/download/shared.rs index 113e169..4ab5301 100644 --- a/crates/core/src/download/shared.rs +++ b/crates/core/src/download/shared.rs @@ -139,7 +139,8 @@ pub fn constructions(cache_root: impl AsRef) -> usize { .unwrap_or(0) } -/// 真实构造(**presenter 侧**唯一一处 `DownloadEngine::new` 的宿主装配;CLI 走 [`engine_with_tuning`])。 +/// 真实构造(**共享引擎**这一处的宿主装配:presenter 主路径与 CLI 都走它,CLI 经 [`engine_with_tuning`])。 +/// 发布版 GUI 的 JDK 下载页另有一处装配(`app-iced` 的 `…/java/downloads/engine.rs`,要 `with_progress` 拿走引擎);两处共用同一个源选择翻译层。 /// 装配参数从该数据根的 [`wanted`] 取,不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore` /// 容量、尝试次数 → `attempts_for()` 上限、gh_proxy/地区/镜像偏好 → 地区档案(`expand()` 只认它)。 fn build(cache_root: &Path) -> DownloadEngine { diff --git a/docs/download-settings.md b/docs/download-settings.md index 15654a3..1019123 100644 --- a/docs/download-settings.md +++ b/docs/download-settings.md @@ -1,8 +1,9 @@ # 下载设置到底改了谁(M193) -一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 现在真的进引擎**; -其余四个 `download.*` 键(`region` / `mirror_preference` / `proxy_mode` / `proxy_url`)**引擎侧零消费者**, -界面上改了不会改变任何下载行为——这一页把它们逐条标出来,别再让「设置项说假话」。 +一句话:**`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,两处各写一个数。 @@ -16,8 +17,8 @@ | `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 | **无**(`RegionProfile::pinned` 无生产调用点) | 永远按「时区+语言」自动判定 | ❌ 未接(见 §5) | -| `download.mirror_preference` | auto / auto·official·mirror | **无** | 永远按地区模式(镜像优先/直连) | ❌ 未接(见 §5) | +| `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` + 失效共享引擎 | ✅ 本来就真 | @@ -49,15 +50,18 @@ 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_settings(默认值/夹取/回落都在 core) + download.region ──────┤ │ + download.mirror_preference ─┐ │ ← M294:with_source_selection(GUI/CLI/JDK 页共用的唯一入口) ↓ shared::set_tuning(, tuning) 变了吗?没变就什么都不做 ↓ 变了 → 淘汰该根的登记项 shared::engine_for() → shared::build ↓ - EngineConfig { max_concurrency, max_attempts_per_source } + RegionProfile.gh_proxy + EngineConfig { max_concurrency, max_attempts_per_source } + + RegionProfile { mode(源选择叠出来的), gh_proxy } ↓ - Semaphore(容量) / attempts_for() / MirrorTable::expand() 的 gh-proxy 候选 + Semaphore(容量) / attempts_for() / MirrorTable::expand() 的候选顺序 + gh-proxy 候选 ``` **同步点是显式的**,两处,共用同一个函数 `apply_tuning`: @@ -74,17 +78,31 @@ config/launcher.json | 路径 | 入口 | 吃设置吗 | |---|---|---| -| GUI 的**全部**下载:内容库(Modrinth/CurseForge)、版本清单、资源/库/日志文件、字体、JDK 安装、自更新 | `presenter::host::engine::engine` → `core::download::shared::engine_for` | ✅ 吃(并发/尝试次数/gh-proxy 都吃) | +| 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.*` 键 → `DownloadTuning::from_settings`(默认值/夹取/回落 -都在 core)→ `shared::engine_with_tuning(, tuning)`。所以 `presenter` 的 `shared::build` 不再是 +`ull config set` 同一份文件)读八个 `download.*` 键(六个数字/三态 + M294 的两个源选择枚举)→ +`DownloadTuning::from_raw(..).with_source_selection(..)`(默认值/夹取/回落都在 core)→ +`shared::engine_with_tuning(, 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()` 的那几条命令**静默**跟着降了——「用出厂值」和「读到了设置」在输出上一模一样, @@ -109,17 +127,43 @@ config/launcher.json 这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现 「界面显示 0、引擎按 3 跑」。 -## 5. 没接的四个键(已登记为 finding) +## 5. 源选择两键已接(M294);只剩代理两键没接 -`download.region` / `download.mirror_preference` / `download.proxy_mode` / `download.proxy_url` -**引擎侧零消费者**:界面上改了不改变任何下载行为。要接它们分别需要: +### 5.1 已接:`download.region` / `download.mirror_preference`(M294) -* `region` → `RegionProfile::pinned(mode, now)`(值 `cn`/`global` 映射到 `MirrorFirst`/`Direct`, - 并把 `trigger` 置成 `UserPinned` 让 24h 重判不覆盖它); -* `mirror_preference` → 引擎候选排序里插一档「强制官方优先 / 强制镜像优先」; -* `proxy_mode` / `proxy_url` → `http::build_client` 里加 `.proxy(reqwest::Proxy::all(url)?)`。 +两个键都落成**同一个开关**上的覆盖——`RegionProfile.mode`(引擎里「官方在前还是镜像在前」只有它, +`mirrors.rs::expand` 与 `gh_proxy_candidates` 都只认它)。优先级是一条可读规则: -三条都在 M193 的 scope 外(`http.rs` / 引擎候选排序 / 一处新装配),已单独立 finding。 +```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` + +**引擎侧零消费者**:`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. 守卫与变异证明(逐条只改实现侧,跑完即还原) @@ -138,6 +182,9 @@ config/launcher.json | 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 之后已解决 @@ -190,6 +237,9 @@ M193 时期这一组把「GitHub 加速前缀」的三态画成三行(`GitHub `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/ (+仓库 URL)`), 两条 HTTP 路径发的是同一个字符串。证据只在**离线回环**上(发出去的字节 ==