Merge branch 'feat/m294-mirrorwire-wire-mirror-preference-a'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-13 09:08:19 +08:00
commit c731d3fa54
17 files changed
+846 -38

No files matched your search

@@ -37,7 +37,9 @@ impl App {
/// ① 目录里有这个大版本;② 没有别的作业在跑;③ 有可写的设置文件与能解析的落点; /// ① 目录里有这个大版本;② 没有别的作业在跑;③ 有可写的设置文件与能解析的落点;
/// ④ 这一档在这台机器上真有一家发行版承接(纯判据,不联网)。 /// ④ 这一档在这台机器上真有一家发行版承接(纯判据,不联网)。
pub(super) fn start_java_download(&mut self, major: u32) { 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.download_notice = None;
self.settings.java.notice = None; self.settings.java.notice = None;
@@ -80,6 +82,9 @@ impl App {
// `download.gh_proxy`(M197 的任务书那句「默认值可改」):空 = 跟随内置默认, // `download.gh_proxy`(M197 的任务书那句「默认值可改」):空 = 跟随内置默认,
// `off` = 关,其余 = 覆盖前缀。Java 页不画这一项的 UI(归 M193 的下载设置页)。 // `off` = 关,其余 = 覆盖前缀。Java 页不画这一项的 UI(归 M193 的下载设置页)。
gh_proxy: gh_proxy_of_config(&config), gh_proxy: gh_proxy_of_config(&config),
// M294:`download.region` / `download.mirror_preference` 从**同一份** config 读出来
// 显式传下去(装配点不猜配置在哪;翻译在 core,这里只搬原文)。
source_selection: SourceSelection::of_config(&config),
}); });
if !started { if !started {
self.settings.java.download_notice = Some(Refusal::Busy); self.settings.java.download_notice = Some(Refusal::Busy);
@@ -54,7 +54,7 @@ mod engine;
mod recommend; mod recommend;
mod refusal; 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 recommend::recommend;
pub use refusal::Refusal; pub use refusal::Refusal;
@@ -211,6 +211,11 @@ pub struct Request {
pub priority: Vec<JdkVendor>, pub priority: Vec<JdkVendor>,
/// `download.gh_proxy`(`Builtin` = 跟随内置默认;`Off` = 关;`Prefix` = 覆盖前缀) /// `download.gh_proxy`(`Builtin` = 跟随内置默认;`Off` = 关;`Prefix` = 覆盖前缀)
pub gh_proxy: GhProxy, 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); 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 install_request = InstallRequest::new(request.major).with_priority(request.priority);
let result = block_on(install(&engine, &request.install_root, install_request)); let result = block_on(install(&engine, &request.install_root, install_request));
// 引擎丢掉 ⇒ 发射器丢掉 ⇒ 抽干线程读到 `None` 自然退出。 // 引擎丢掉 ⇒ 发射器丢掉 ⇒ 抽干线程读到 `None` 自然退出。
@@ -1,7 +1,25 @@
//! 带进度发射器的下载引擎(M197):一次 JDK 安装一个实例 + `download.gh_proxy` 的真消费者。 //! 带进度发射器的下载引擎(M197):一次 JDK 安装一个实例 + `download.*` 的真消费者。
//! //!
//! 从 `downloads.rs` 拆出来(行数 + 口径独立:这里只说「为什么不用共享引擎」)。 //! 从 `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 的取值链(任务书那句「(默认值可改)」的落点) //! ## gh-proxy 的取值链(任务书那句「(默认值可改)」的落点)
//! //!
@@ -24,8 +42,10 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use unlucky_core::download::cache::now_ms; use unlucky_core::download::cache::now_ms;
use unlucky_core::download::config::KEY_GH_PROXY; use unlucky_core::download::config::{
use unlucky_core::download::region::{decide_from, load_cached}; 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::region_system::detect_signals;
use unlucky_core::download::{ use unlucky_core::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable, ProgressEmitter, 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()) 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<String>,
/// `download.mirror_preference` 原文(同上)。
pub mirror_preference: Option<String>,
}
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<DownloadEngine>`,而挂进度要 /// 为什么不再走 `shared::engine_for`:那个登记处交出的是 `Arc<DownloadEngine>`,而挂进度要
/// 拿走引擎(`with_progress(self)`)。要让共享引擎带「按作业」的观察者,得改引擎内部的 /// 拿走引擎(`with_progress(self)`)。要让共享引擎带「按作业」的观察者,得改引擎内部的
@@ -87,6 +146,7 @@ pub(super) fn engine_with(
cache_root: &Path, cache_root: &Path,
progress: ProgressEmitter, progress: ProgressEmitter,
gh_proxy: &GhProxy, gh_proxy: &GhProxy,
selection: &SourceSelection,
) -> DownloadEngine { ) -> DownloadEngine {
let now = now_ms(); let now = now_ms();
let profile = load_cached(&cache_root.join("region.json"), now).unwrap_or_else(|| { 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); let _ = std::fs::create_dir_all(cache_root);
profile profile
}); });
// 显式地区 / 镜像偏好先叠(M294):`DownloadTuning::default()` 的 gh_proxy 是 `Builtin`
// (恒等),所以这一步只动「谁在前」;前缀仍由下面那三态决定——那是 JDK 页 `off` 档的唯一
// 真相源,不在这里再翻译一次。
let profile = selection.apply(profile);
let profile = match gh_proxy { let profile = match gh_proxy {
GhProxy::Builtin => profile, GhProxy::Builtin => profile,
GhProxy::Off => profile.with_gh_proxy(None), GhProxy::Off => profile.with_gh_proxy(None),
@@ -108,3 +172,128 @@ pub(super) fn engine_with(
) )
.with_progress(progress) .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 下载的临时数据根(`<dir>/cache` + `<dir>/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` 的布局一致:`<home>/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<String> {
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();
}
}
@@ -174,6 +174,7 @@ fn the_real_download_installs_a_usable_jdk() {
install_root: JavaInstallRoot::custom(dir.join("jdks")), install_root: JavaInstallRoot::custom(dir.join("jdks")),
priority: Vec::new(), priority: Vec::new(),
gh_proxy: super::GhProxy::Builtin, gh_proxy: super::GhProxy::Builtin,
source_selection: super::SourceSelection::default(),
})); }));
// 失败/panic 也要清理:这条用例会真下 ~180 MB,留在系统 temp 里是最贵的垃圾。 // 失败/panic 也要清理:这条用例会真下 ~180 MB,留在系统 temp 里是最贵的垃圾。
struct Cleanup(std::path::PathBuf); struct Cleanup(std::path::PathBuf);
@@ -39,7 +39,7 @@ fn the_gh_proxy_setting_maps_to_three_states() {
#[test] #[test]
fn the_gh_proxy_setting_reaches_the_candidate_chain() { fn the_gh_proxy_setting_reaches_the_candidate_chain() {
use crate::pages::settings::java::downloads::engine::engine_with; 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::cache::now_ms;
use unlucky_core::download::region::{store_cached, RegionProfile}; use unlucky_core::download::region::{store_cached, RegionProfile};
use unlucky_core::download::types::{DownloadRequest, ResourceKind}; use unlucky_core::download::types::{DownloadRequest, ResourceKind};
@@ -79,6 +79,8 @@ fn the_gh_proxy_setting_reaches_the_candidate_chain() {
&cache, &cache,
ProgressEmitter::silent(), ProgressEmitter::silent(),
&gh_proxy_of_config(&config), &gh_proxy_of_config(&config),
// M294:这条用例只守 gh-proxy,两个源选择键留空(= 跟随 region.json 的判定)。
&SourceSelection::default(),
); );
// ② 档案被覆盖 // ② 档案被覆盖
assert_eq!( assert_eq!(
@@ -104,7 +106,12 @@ fn the_gh_proxy_setting_reaches_the_candidate_chain() {
); );
// 关掉那一档:一个代理候选都不生成(同一份 region.json,只换设置值)。 // 关掉那一档:一个代理候选都不生成(同一份 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 必须把前缀清掉"); assert_eq!(off.region().gh_proxy, None, "off 必须把前缀清掉");
let plan = off.plan(&request, 0); let plan = off.plan(&request, 0);
assert!( assert!(
+16 -2
View File
@@ -14,6 +14,11 @@
//! → shared::engine_with_tuning(<cache 根>, tuning) → 引擎的 Semaphore / attempts_for() / 三段超时 //! → shared::engine_with_tuning(<cache 根>, 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.*` 从三个键扩到六个(多了吞吐下限 / 无进展窗口 / 单次请求上界),装配链没变, //! M292:`download.*` 从三个键扩到六个(多了吞吐下限 / 无进展窗口 / 单次请求上界),装配链没变,
//! 变的是 [`tuning`] 取值的宽度与 [`engine`] 里那一行**仅在改过设置时**才打的 stderr 说明——用户在 //! 变的是 [`tuning`] 取值的宽度与 [`engine`] 里那一行**仅在改过设置时**才打的 stderr 说明——用户在
//! 慢链路上改完设置,得能在终端里看见引擎实际拿到了什么(P21 的验收判据;为什么不是写 //! 慢链路上改完设置,得能在终端里看见引擎实际拿到了什么(P21 的验收判据;为什么不是写
@@ -26,7 +31,7 @@ use std::sync::Arc;
use unlucky_core::download::config::{ use unlucky_core::download::config::{
DownloadTuning, RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, 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::download::{shared, DownloadEngine};
use unlucky_core::setting::keys::launcher_schema; use unlucky_core::setting::keys::launcher_schema;
@@ -34,7 +39,7 @@ use unlucky_core::setting::store::SettingStore;
use crate::context::Ctx; 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.launcher_config()`——与 `ull config set download.concurrency` 的落点同一份文件
/// (设置键的落点分流见 [`Ctx::setting_path_for`])。gh-proxy 空串 = 跟随内置默认,**不是**关。 /// (设置键的落点分流见 [`Ctx::setting_path_for`])。gh-proxy 空串 = 跟随内置默认,**不是**关。
@@ -42,6 +47,11 @@ use crate::context::Ctx;
/// **本函数只做「取裸值」**:默认值、区间、回落、KiB→字节的换算全在 core 的 /// **本函数只做「取裸值」**:默认值、区间、回落、KiB→字节的换算全在 core 的
/// [`DownloadTuning::from_raw`](M292 起宿主不再逐个判断,也不再自己决定缺省值——那是「UI 说 8、 /// [`DownloadTuning::from_raw`](M292 起宿主不再逐个判断,也不再自己决定缺省值——那是「UI 说 8、
/// 引擎跑 64」长回来的地方)。 /// 引擎跑 64」长回来的地方)。
///
/// M294 r1:`download.region` / `download.mirror_preference` 也在这里读,但**不经过
/// [`RawTuning`]**——那两个是枚举,翻译层是 core 的 [`DownloadTuning::with_source_selection`]
/// (未知值回落 `auto`)。CLI 与 presenter 走的是同一个入口,别在宿主里再写一份字符串匹配:
/// 两份口径就是「GUI 认、CLI 不认」的下一次复发。
#[must_use] #[must_use]
pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning { pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema()); 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), stall_timeout_secs: doc.effective_i64(KEY_CHUNK_STALL),
response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT), response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT),
}) })
.with_source_selection(
doc.effective_str(KEY_REGION),
doc.effective_str(KEY_MIRROR_PREFERENCE),
)
} }
/// 生效值那一行说明,**只在改过设置时**给:`None` = 全是出厂值,没什么可说的。 /// 生效值那一行说明,**只在改过设置时**给:`None` = 全是出厂值,没什么可说的。
+46
View File
@@ -343,3 +343,49 @@ fn the_timeout_settings_reach_the_engine_and_the_console_line() {
assert_eq!(super::tuning(&ctx), DownloadTuning::default()); assert_eq!(super::tuning(&ctx), DownloadTuning::default());
assert!(super::announce(&super::tuning(&ctx)).is_none()); assert!(super::announce(&super::tuning(&ctx)).is_none());
} }
/// 一个 URL 在 CLI 装配出来的引擎里的**源名顺序**(`plan()` 是引擎真正的选源决策点)。
fn plan_sources(engine: &DownloadEngine) -> Vec<String> {
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(&region_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(&region_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}");
}
+5
View File
@@ -91,6 +91,11 @@ pub const KEY_RESPONSE_TIMEOUT: &str = "download.response_timeout_secs";
/// `setting::keys::proxy` 的 `the_gh_proxy_key_is_bound_to_the_schema_registration`(M205)钉住:写错就红; /// `setting::keys::proxy` 的 `the_gh_proxy_key_is_bound_to_the_schema_registration`(M205)钉住:写错就红;
/// 它抓不住两侧同时改名(公开改名)。行为退化由 `empty_gh_proxy_means_follow_builtin_not_off` 与 `shared` 的候选链拦。 /// 它抓不住两侧同时改名(公开改名)。行为退化由 `empty_gh_proxy_means_follow_builtin_not_off` 与 `shared` 的候选链拦。
pub const KEY_GH_PROXY: &str = "download.gh_proxy"; pub const KEY_GH_PROXY: &str = "download.gh_proxy";
/// 「地区判定」键(`auto` / `cn` / `global`)。消费方与回落规则见 `download::preference` 模块头;
/// 读它的是宿主(presenter 的 `tuning_of`),翻译成 [`DownloadTuning::with_source_selection`]。
pub const KEY_REGION: &str = "download.region";
/// 「镜像偏好」键(`auto` / `official` / `mirror`)。读法与优先级同 [`KEY_REGION`]。
pub const KEY_MIRROR_PREFERENCE: &str = "download.mirror_preference";
/// 引擎配置。 /// 引擎配置。
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
+51 -1
View File
@@ -10,6 +10,7 @@
use std::time::Duration; use std::time::Duration;
use super::super::preference::{self, MirrorPreference, RegionPreference};
use super::super::region::RegionProfile; use super::super::region::RegionProfile;
use super::super::stall; use super::super::stall;
use super::{ use super::{
@@ -41,6 +42,10 @@ pub struct DownloadTuning {
/// 分块作业的平均吞吐下限(**字节/秒**,已夹在 `MIN_THROUGHPUT_FLOOR_KIB..=MAX_THROUGHPUT_FLOOR_KIB` /// 分块作业的平均吞吐下限(**字节/秒**,已夹在 `MIN_THROUGHPUT_FLOOR_KIB..=MAX_THROUGHPUT_FLOOR_KIB`
/// 换算后的区间)。 /// 换算后的区间)。
pub min_avg_throughput: u64, pub min_avg_throughput: u64,
/// 显式的「地区」覆盖(`download.region`;`Auto` = 用自动探测/缓存,即 M294 之前的行为)。
pub region: RegionPreference,
/// 显式的「镜像偏好」覆盖(`download.mirror_preference`;`Auto` = 跟着 `region` 走)。
pub mirror_preference: MirrorPreference,
} }
impl Default for DownloadTuning { impl Default for DownloadTuning {
@@ -52,6 +57,8 @@ impl Default for DownloadTuning {
response_timeout: DEFAULT_RESPONSE_TIMEOUT, response_timeout: DEFAULT_RESPONSE_TIMEOUT,
chunk_stall_timeout: DEFAULT_CHUNK_STALL, chunk_stall_timeout: DEFAULT_CHUNK_STALL,
min_avg_throughput: stall::MIN_AVG_THROUGHPUT, min_avg_throughput: stall::MIN_AVG_THROUGHPUT,
region: RegionPreference::Auto,
mirror_preference: MirrorPreference::Auto,
} }
} }
} }
@@ -130,6 +137,10 @@ impl DownloadTuning {
.and_then(|value| u32::try_from(value).ok()) .and_then(|value| u32::try_from(value).ok())
.filter(|kib| (MIN_THROUGHPUT_FLOOR_KIB..=MAX_THROUGHPUT_FLOOR_KIB).contains(kib)) .filter(|kib| (MIN_THROUGHPUT_FLOOR_KIB..=MAX_THROUGHPUT_FLOOR_KIB).contains(kib))
.map_or(default.min_avg_throughput, |kib| u64::from(kib) * 1024), .map_or(default.min_avg_throughput, |kib| u64::from(kib) * 1024),
// M294 的两个「源选择」键**不走这里**,见 [`Self::with_source_selection`]:
// `from_raw` 恒给 `Auto`,即「不发言」。
region: RegionPreference::Auto,
mirror_preference: MirrorPreference::Auto,
} }
} }
@@ -165,6 +176,12 @@ impl DownloadTuning {
/// ///
/// 存在的理由就是 P21 的验收判据:用户改完设置之后,得在 `logs/launcher.log` 里**看见**生效值, /// 存在的理由就是 P21 的验收判据:用户改完设置之后,得在 `logs/launcher.log` 里**看见**生效值,
/// 而不是只有「我以为我改了」。字段顺序与 `EngineConfig` 一致,单位写在名字里。 /// 而不是只有「我以为我改了」。字段顺序与 `EngineConfig` 一致,单位写在名字里。
///
/// M294 r1:两个「源选择」档位(`地区` / `镜像偏好`)也在这里——CLI 的
/// `download::tuning` 已经读了它们,这一行若不含就等于「打了一行说生效值、却少说两项」。两个值都用
/// [`RegionPreference::as_str`] / [`MirrorPreference::as_str`],与 schema 的取值逐字相同。
///
/// (`crates/cli/src/download.rs::tuning` 是唯一读者。)
#[must_use] #[must_use]
pub fn summary(&self) -> String { pub fn summary(&self) -> String {
let gh_proxy = match &self.gh_proxy { let gh_proxy = match &self.gh_proxy {
@@ -173,12 +190,14 @@ impl DownloadTuning {
GhProxyOverride::Prefix(prefix) => prefix.clone(), GhProxyOverride::Prefix(prefix) => prefix.clone(),
}; };
format!( format!(
"并发={} 单源尝试={} 吞吐下限={}KiB/s 无进展窗口={}s 单次请求上界={}s gh_proxy={gh_proxy}", "并发={} 单源尝试={} 吞吐下限={}KiB/s 无进展窗口={}s 单次请求上界={}s 地区={} 镜像偏好={} gh_proxy={gh_proxy}",
self.max_concurrency, self.max_concurrency,
self.max_attempts_per_source, self.max_attempts_per_source,
self.min_avg_throughput / 1024, self.min_avg_throughput / 1024,
self.chunk_stall_timeout.as_secs(), self.chunk_stall_timeout.as_secs(),
self.response_timeout.as_secs(), self.response_timeout.as_secs(),
self.region.as_str(),
self.mirror_preference.as_str(),
) )
} }
@@ -191,4 +210,35 @@ impl DownloadTuning {
GhProxyOverride::Prefix(prefix) => profile.with_gh_proxy(Some(prefix)), GhProxyOverride::Prefix(prefix) => profile.with_gh_proxy(Some(prefix)),
} }
} }
/// 叠上「源选择」两个显式键的**原文**(`download.region` / `download.mirror_preference`)。
///
/// 为什么不进 [`RawTuning`]:CLI 的 `tuning()` 用**完整字段字面量**构造 `RawTuning`,给它加字段
/// 会让 `crates/cli` 编译不过——而本轮不改那个宿主。于是这两个键走**同一个翻译层、另一个入口**:
/// 默认 [`RegionPreference::Auto`] / [`MirrorPreference::Auto`](= 与今天逐值相同),
/// 谁读设置谁调这里。未知值/空串的回落规则见 `preference` 模块头。
#[must_use]
pub fn with_source_selection(
mut self,
region: Option<&str>,
mirror_preference: Option<&str>,
) -> Self {
self.region = region.map_or(RegionPreference::Auto, RegionPreference::from_raw);
self.mirror_preference =
mirror_preference.map_or(MirrorPreference::Auto, MirrorPreference::from_raw);
self
}
/// 把「源选择」这一族设置盖到地区档案上(装配引擎时的唯一入口)。
///
/// 顺序即优先级:先 `region` / `mirror_preference` 的显式覆盖(细则见 `preference` 模块头),
/// 再叠 gh-proxy 三态——它只管**前缀**、不管顺序,所以放在最后。
#[must_use]
pub fn apply_source_selection(&self, profile: RegionProfile) -> RegionProfile {
self.apply_gh_proxy(preference::apply(
profile,
self.region,
self.mirror_preference,
))
}
} }
+2
View File
@@ -59,6 +59,7 @@ pub mod font;
pub mod health; pub mod health;
pub mod jdk_sources; pub mod jdk_sources;
pub mod mirrors; pub mod mirrors;
pub mod preference;
pub mod progress; pub mod progress;
pub mod region; pub mod region;
pub mod region_system; pub mod region_system;
@@ -92,6 +93,7 @@ pub use self::font::{
pub use self::health::{HealthKey, HealthRegistry, SourceHealth}; pub use self::health::{HealthKey, HealthRegistry, SourceHealth};
pub use self::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable}; pub use self::jdk_sources::{MsOpenJdkTable, OracleMirror, ZuluTable};
pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable, PrefixRule}; pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable, PrefixRule};
pub use self::preference::{MirrorPreference, RegionPreference};
pub use self::progress::{ProgressEmitter, ProgressEvent, ProgressThrottle}; pub use self::progress::{ProgressEmitter, ProgressEvent, ProgressThrottle};
pub use self::region::{RegionMode, RegionProfile, RegionTrigger, Signals}; pub use self::region::{RegionMode, RegionProfile, RegionTrigger, Signals};
pub use self::region_system::detect_signals; pub use self::region_system::detect_signals;
+147
View File
@@ -0,0 +1,147 @@
//! 「源选择」的两个显式设置键 → [`RegionProfile`] 之上的覆盖层(M294)。
//!
//! 两个键:`download.region`(`auto` / `cn` / `global`)与 `download.mirror_preference`
//! (`auto` / `official` / `mirror`)。在本轮之前它们**一个引擎侧消费者都没有**——
//! `grep -rn "mirror_preference\|Region::" crates/core/src | grep -v setting/keys` 是零命中,
//! 设置页画着三档 chip,改它不改任何一次下载。本模块就是那个消费者。
//!
//! # 为什么两个键都落到同一个开关上
//!
//! 引擎里「官方在前还是镜像在前」**只有一个**开关:[`RegionProfile::mode`](`mirrors.rs` 的
//! `expand()` 与 `gh_proxy_candidates()` 都只认它)。两个键问的是同一件事的两个侧面——
//! 「我在哪个地区」(决定默认值)与「我要谁在前」(更具体的显式选择)——所以两者都落成
//! 「给 `mode` 叠一层显式结论」,**不另造第二套排序开关**。再造一层的下场是「改 A 被 B 盖住、
//! 界面上看不出为什么」,正是本任务要治的那类病。
//!
//! 两者的分工也据此定死:`region` 回答「我在不在大中华区」(`greater_china`,并给出该地区的
//! 默认模式),`mirror_preference` 只回答「谁在前」(`mode`),**不动地区判定**。
//!
//! # 优先级(唯一一条可读规则)
//!
//! ```text
//! 显式 mirror_preference(official / mirror) ← 最具体,永远优先
//! > 显式 region(cn / global)
//! > 自动探测(时区 + 语言 + 系统信号)与 region.json 的 24h 缓存 ← 今天的行为
//! ```
//!
//! `auto` 等于「这一层不发言」,退到下一层。两个键都不设、或都写 `auto` ⇒ 覆盖层是恒等映射,
//! 与 M294 之前**逐值相同**(守卫:`preference/tests.rs` 的 `not_set_changes_nothing`)。
//!
//! # 回落规则(手改文件 / 老配置 / 未知值)
//!
//! 只认 schema 登记的那几个词(`setting::keys::REGIONS` / `MIRROR_PREFERENCES`),大小写与前后
//! 空白不敏感。其余一切(`""` / `"cn2"` / `"bogus"`,以及非字符串值——宿主侧 `effective_str`
//! 给出 `None`)**一律当 `auto`**,静默退到下一层:下载不能因为一个设置字符串就停下。
//! 这与 `gh_proxy_override` 的「空串≠关」是同一条纪律的两面。
//!
//! # 不落盘
//!
//! 覆盖只发生在**读时**(`shared::build` 装配引擎那一刻,与 `apply_gh_proxy` 同一个位置)。
//! `region.json` 里仍然只有自动判定的结论——把显式值写回去会让 24h 重判永久失效。
use super::region::{RegionMode, RegionProfile};
/// `download.region` 的三档。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RegionPreference {
/// 不发言:用自动探测(或 `region.json` 的 24h 缓存)的结论。
#[default]
Auto,
/// `cn`:人在大中华区 ⇒ 默认镜像优先。
GreaterChina,
/// `global`:人在大中华区以外 ⇒ 默认直连。
Global,
}
impl RegionPreference {
/// 稳定字符串 id(与 schema 的取值逐字相同;日志/诊断用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::GreaterChina => "cn",
Self::Global => "global",
}
}
/// 设置原文 → 档位(未知值 = [`Self::Auto`],见模块头的回落规则)。
#[must_use]
pub fn from_raw(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"cn" => Self::GreaterChina,
"global" => Self::Global,
_ => Self::Auto,
}
}
/// 叠到已有判定上:`cn` ⇒ 大中华区 + 镜像优先;`global` ⇒ 非大中华区 + 直连;
/// `auto` ⇒ **原样返回**(一个字段都不动)。
#[must_use]
pub fn apply(self, profile: RegionProfile) -> RegionProfile {
match self {
Self::Auto => profile,
Self::GreaterChina => profile.overridden(RegionMode::MirrorFirst, true),
Self::Global => profile.overridden(RegionMode::Direct, false),
}
}
}
/// `download.mirror_preference` 的三档。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MirrorPreference {
/// 不发言:跟着 [`RegionPreference`] 的结论走(镜像侧再按健康度排序)。
#[default]
Auto,
/// `official`:官方源永远第一条。
Official,
/// `mirror`:镜像永远第一条(官方兜底)。
Mirror,
}
impl MirrorPreference {
/// 稳定字符串 id(与 schema 的取值逐字相同;日志/诊断用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Official => "official",
Self::Mirror => "mirror",
}
}
/// 设置原文 → 档位(未知值 = [`Self::Auto`])。
#[must_use]
pub fn from_raw(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"official" => Self::Official,
"mirror" => Self::Mirror,
_ => Self::Auto,
}
}
/// 叠到已有判定上:只改「谁在前」(`mode`),**不动** `greater_china`——那是
/// [`RegionPreference`] 的事。`auto` ⇒ 原样返回。
#[must_use]
pub fn apply(self, profile: RegionProfile) -> RegionProfile {
let greater_china = profile.greater_china;
match self {
Self::Auto => profile,
Self::Official => profile.overridden(RegionMode::Direct, greater_china),
Self::Mirror => profile.overridden(RegionMode::MirrorFirst, greater_china),
}
}
}
/// 两层覆盖按**固定顺序**叠:`region` 先、`mirror_preference` 后 ⇒ 后者更具体、永远优先。
#[must_use]
pub fn apply(
profile: RegionProfile,
region: RegionPreference,
mirror: MirrorPreference,
) -> RegionProfile {
mirror.apply(region.apply(profile))
}
#[cfg(test)]
#[path = "preference/tests.rs"]
mod tests;
@@ -0,0 +1,227 @@
//! 「源选择」两个键的行为守卫(M294)。
//!
//! 判据一律断**具体的源名序列**,不断「非空」:今天不改设置也能选到镜像,「非空」抓不住
//! 「设置没被读」——本任务要证明的恰好是「改了真的变、不改逐值不变」。
use std::path::{Path, PathBuf};
use super::{apply, MirrorPreference, RegionPreference};
use crate::download::cache::now_ms;
use crate::download::config::DownloadTuning;
use crate::download::mirrors::MirrorTable;
use crate::download::region::{store_cached, RegionMode, RegionProfile, RegionTrigger};
use crate::download::shared::{engine_for, set_tuning};
use crate::download::types::{DownloadRequest, ResourceKind};
const LIBRARIES_URL: &str =
"https://libraries.minecraft.net/com/google/guava/guava/32.0.0/guava-32.0.0.jar";
/// 一个 URL 展开后的**源名序列**(`expand()` 就是「按顺序候选源」那一步)。
fn sources(profile: &RegionProfile) -> Vec<String> {
MirrorTable::builtin()
.expand(LIBRARIES_URL, ResourceKind::Library, profile)
.into_iter()
.map(|candidate| candidate.source)
.collect()
}
/// 一个独立数据根(共享引擎登记处是进程级、按根存,键撞了会互相干扰)。
fn root(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ull-pref-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建缓存根");
dir
}
/// **默认判据**:两个键都不设 / 写 `auto` / 写不认识的值 ⇒ 候选序列与不做覆盖**逐项相同**。
///
/// 这里的期望值不是「非空」而是 M294 之前 `expand()` 对同一 URL 的真实结论(国内档案
/// `["bmclapi", "official"]`、国外档案 `["official", "bmclapi"]`),所以覆盖层一旦偷偷改了顺序,
/// 断言立刻红。变异证明:把 `RegionPreference::Auto` 分支改成 `Global` ⇒ domestic 那两行红。
#[test]
fn not_set_changes_nothing() {
let domestic = RegionProfile::mirror_first(0);
let abroad = RegionProfile::direct(0);
assert_eq!(sources(&domestic), ["bmclapi", "official"]);
assert_eq!(sources(&abroad), ["official", "bmclapi"]);
for (region, mirror) in [
(None, None),
(Some(""), Some("")),
(Some("auto"), Some("auto")),
(Some("AUTO"), Some(" Auto ")),
(Some("mars"), Some("prefer-mirror")),
] {
let tuned = DownloadTuning::default()
.with_source_selection(region, mirror)
.apply_source_selection(domestic.clone());
assert_eq!(
tuned, domestic,
"region={region:?} mirror={mirror:?} 不该动档案"
);
assert_eq!(
sources(&tuned),
["bmclapi", "official"],
"region={region:?} mirror={mirror:?} 不该动候选顺序"
);
}
}
/// **主判据(mirror_preference)**:设成 `official` / `mirror` ⇒ 候选链第一条真的跟着换。
///
/// 与探测结论**故意取反**(国内档案 + official、国外档案 + mirror),这样「覆盖没生效」时
/// 结论仍是探测值、两条断言各自红。变异证明:`MirrorPreference::apply` 的 `Official` 分支
/// 返回原样 ⇒ 第一条还是 `bmclapi`,红。
#[test]
fn mirror_preference_decides_who_comes_first() {
let domestic = RegionProfile::mirror_first(0);
let abroad = RegionProfile::direct(0);
let official = DownloadTuning::default()
.with_source_selection(None, Some("official"))
.apply_source_selection(domestic.clone());
assert_eq!(official.mode, RegionMode::Direct);
assert_eq!(
sources(&official),
["official", "bmclapi"],
"官方必须第一条"
);
assert_eq!(
official.greater_china, domestic.greater_china,
"镜像偏好只定「谁在前」,地区判定不归它管"
);
let mirror = DownloadTuning::default()
.with_source_selection(None, Some("mirror"))
.apply_source_selection(abroad.clone());
assert_eq!(mirror.mode, RegionMode::MirrorFirst);
assert_eq!(sources(&mirror), ["bmclapi", "official"], "镜像必须第一条");
assert_eq!(
mirror.gh_proxy.as_deref(),
Some(crate::download::region::DEFAULT_GH_PROXY),
"「镜像优先」被显式要求时补齐内置 gh-proxy 前缀(否则 GitHub 资源等于没加速)"
);
assert_eq!(mirror.trigger, RegionTrigger::UserPinned);
}
/// **主判据(region)**:`cn` / `global` 在探测结论之上叠一层,并改到「直连还是镜像」。
///
/// `greater_china` 也一起变(这是 `is_domestic()` 的输入 → JDK 发行版默认序),所以两个键
/// 的分工在这里可见:region 定地区,mirror_preference 定顺序。
#[test]
fn region_decides_direct_or_mirror_first() {
let cn = DownloadTuning::default()
.with_source_selection(Some("cn"), None)
.apply_source_selection(RegionProfile::direct(0));
assert_eq!(cn.mode, RegionMode::MirrorFirst);
assert!(cn.greater_china);
assert!(cn.is_domestic());
assert_eq!(sources(&cn), ["bmclapi", "official"]);
let global = DownloadTuning::default()
.with_source_selection(Some("global"), None)
.apply_source_selection(RegionProfile::mirror_first(0));
assert_eq!(global.mode, RegionMode::Direct);
assert!(!global.greater_china);
assert!(!global.is_domestic());
assert_eq!(global.gh_proxy, None, "直连不需要前缀");
assert_eq!(sources(&global), ["official", "bmclapi"]);
}
/// **优先级**:两个键都显式写时,更具体的 `mirror_preference` 胜。
///
/// 模块头那条规则的守卫。变异证明:`apply()` 里把两层顺序对调 ⇒ 第一条变回 `bmclapi`,红。
#[test]
fn an_explicit_mirror_preference_wins_over_an_explicit_region() {
let tuned = apply(
RegionProfile::direct(0),
RegionPreference::GreaterChina,
MirrorPreference::Official,
);
assert_eq!(tuned.mode, RegionMode::Direct, "mirror_preference 优先");
assert!(tuned.greater_china, "region 的「我在国内」仍然有效");
assert_eq!(sources(&tuned), ["official", "bmclapi"]);
}
/// **端到端**:设置原文 → `DownloadTuning` → 进程级共享引擎 → `plan()` 的候选顺序。
///
/// 这一条才是「改了设置下次下载真的变」的判据:`plan()` 是引擎选源的决策点,`set_tuning` 是宿主
/// (presenter 的 `apply_tuning` / CLI 的 `engine_with_tuning`)唯一的灌入口。
/// 变异证明:`shared::build` 里换回 `apply_gh_proxy(region)` ⇒ 第一条仍是探测值 `bmclapi`,红。
#[test]
fn the_settings_reach_the_engines_candidate_plan() {
let dir = root("plan");
store_cached(
&dir.join("region.json"),
&RegionProfile::mirror_first(now_ms()),
)
.expect("写地区缓存");
let request = DownloadRequest::new(
ResourceKind::Library,
LIBRARIES_URL.to_string(),
dir.join("guava.jar"),
);
let plan = |dir: &Path| -> Vec<String> {
engine_for(dir)
.plan(&request, 0)
.into_iter()
.map(|candidate| candidate.source)
.collect()
};
// 探测结论 = 镜像优先(不改设置时计划的第一条就是它)。
assert_eq!(plan(&dir), ["bmclapi", "official"]);
assert!(
set_tuning(
&dir,
DownloadTuning::default().with_source_selection(None, Some("official"))
),
"改了源选择必须报变更(否则不会重建引擎)"
);
assert_eq!(plan(&dir), ["official", "bmclapi"]);
set_tuning(
&dir,
DownloadTuning::default().with_source_selection(Some("cn"), Some("mirror")),
);
assert_eq!(plan(&dir), ["bmclapi", "official"]);
// 回到「不设」⇒ 回到探测结论(不是停在最后一次覆盖上)。
set_tuning(&dir, DownloadTuning::default());
assert_eq!(plan(&dir), ["bmclapi", "official"]);
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<String> {
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"]);
}
+30
View File
@@ -174,6 +174,36 @@ impl RegionProfile {
self self
} }
/// 在**已有判定**之上叠一层显式模式覆盖(M294;探测逻辑不在这里,也不重写)。
///
/// 只换「谁在前」这一族字段:`mode`、`greater_china`、以及由模式派生的 `gh_proxy`
/// ——镜像优先且原来没有前缀时补 [`DEFAULT_GH_PROXY`](否则「镜像优先」在 GitHub 资源上
/// 等于没加速);直连时置 `None`(`gh_proxy_candidates()` 只在镜像优先下读它)。
/// `decided_at_ms` / `probes_enabled` 原样保留:那是「探测做过什么」的记录,不是「谁在前」。
/// `trigger` 记为 [`RegionTrigger::UserPinned`]。
///
/// **调用方负责不落盘**:这是读时的覆盖层(同一位置还有 `DownloadTuning::apply_gh_proxy`),
/// `region.json` 里必须留自动判定的结论,否则 24h 重判永久失效。
///
/// 「显式设置 vs 自动探测」谁优先由 [`super::preference`] 按固定顺序决定,本函数只提供
/// 「叠一层」这个原语。
#[must_use]
pub fn overridden(self, mode: RegionMode, greater_china: bool) -> Self {
let gh_proxy = if mode == RegionMode::MirrorFirst {
self.gh_proxy.or_else(|| Some(DEFAULT_GH_PROXY.to_string()))
} else {
None
};
Self {
mode,
trigger: RegionTrigger::UserPinned,
greater_china,
gh_proxy,
probes_enabled: self.probes_enabled,
decided_at_ms: self.decided_at_ms,
}
}
/// 自动判定是否已过期(用户钉死永不过期)。 /// 自动判定是否已过期(用户钉死永不过期)。
#[must_use] #[must_use]
pub fn is_stale(&self, now_ms: u64) -> bool { pub fn is_stale(&self, now_ms: u64) -> bool {
+4 -3
View File
@@ -139,9 +139,10 @@ pub fn constructions(cache_root: impl AsRef<Path>) -> usize {
.unwrap_or(0) .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` /// 装配参数从该数据根的 [`wanted`] 取,不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore`
/// 容量、尝试次数 → `attempts_for()` 上限、gh_proxy → 地区档案(`expand()` 只认档案里那个前缀)。 /// 容量、尝试次数 → `attempts_for()` 上限、gh_proxy/地区/镜像偏好 → 地区档案(`expand()` 只认它)。
fn build(cache_root: &Path) -> DownloadEngine { fn build(cache_root: &Path) -> DownloadEngine {
let now = now_ms(); let now = now_ms();
let tune = wanted(cache_root); let tune = wanted(cache_root);
@@ -154,7 +155,7 @@ fn build(cache_root: &Path) -> DownloadEngine {
DownloadEngine::new( DownloadEngine::new(
tune.apply_to(EngineConfig::default()), tune.apply_to(EngineConfig::default()),
Arc::new(MirrorTable::builtin()), Arc::new(MirrorTable::builtin()),
Arc::new(tune.apply_gh_proxy(region)), Arc::new(tune.apply_source_selection(region)),
Arc::new(HealthRegistry::open_in(cache_root, now)), Arc::new(HealthRegistry::open_in(cache_root, now)),
Arc::new(ContentCache::open(cache_root)), Arc::new(ContentCache::open(cache_root)),
) )
+13 -3
View File
@@ -17,7 +17,9 @@
//! download.gh_proxy ────┼─→ DownloadTuning(core 决定默认值与夹取)─→ shared::set_tuning //! download.gh_proxy ────┼─→ DownloadTuning(core 决定默认值与夹取)─→ shared::set_tuning
//! download.throughput_floor_kib ─┤ (引擎重建时生效) //! download.throughput_floor_kib ─┤ (引擎重建时生效)
//! download.stall_timeout_secs ───┤ //! download.stall_timeout_secs ───┤
//! download.response_timeout_secs ┘ //! download.response_timeout_secs ┤
//! download.region ───────────────┤ M294:两个枚举键,走
//! download.mirror_preference ────┘ `with_source_selection`(回落 = auto)
//! ``` //! ```
//! //!
//! M292:后三个键(吞吐下限 / 无进展窗口 / 单次请求上界)只经 [`tuning_of`] 进引擎,**没有**对应的 //! M292:后三个键(吞吐下限 / 无进展窗口 / 单次请求上界)只经 [`tuning_of`] 进引擎,**没有**对应的
@@ -250,11 +252,15 @@ fn gh_proxy_state(state: &GhProxyOverride) -> String {
/// ///
/// M292:六个键一起读(并发 / 单源尝试 / gh-proxy / 吞吐下限 / 无进展窗口 / 单次请求上界), /// M292:六个键一起读(并发 / 单源尝试 / gh-proxy / 吞吐下限 / 无进展窗口 / 单次请求上界),
/// 缺省与越界一律由 core 的 [`DownloadTuning::from_raw`] 回落——本函数不判断任何区间。 /// 缺省与越界一律由 core 的 [`DownloadTuning::from_raw`] 回落——本函数不判断任何区间。
///
/// M294:另加两个「源选择」键(`download.region` / `download.mirror_preference`),它们是**枚举**
/// 而不是数字,走 [`DownloadTuning::with_source_selection`](同一个翻译层,回落 = `auto`)。
/// 少了这一步,那两行 chip 就只是画着好看——本轮之前正是如此。
#[must_use] #[must_use]
pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning { pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
use unlucky_core::download::config::{ use unlucky_core::download::config::{
RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, KEY_RESPONSE_TIMEOUT, RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, KEY_MIRROR_PREFERENCE,
KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR, KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR,
}; };
let store = env.store(); let store = env.store();
let doc = store.doc(); let doc = store.doc();
@@ -270,6 +276,10 @@ pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
stall_timeout_secs: doc.effective_i64(KEY_CHUNK_STALL), stall_timeout_secs: doc.effective_i64(KEY_CHUNK_STALL),
response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT), response_timeout_secs: doc.effective_i64(KEY_RESPONSE_TIMEOUT),
}) })
.with_source_selection(
doc.effective_str(KEY_REGION),
doc.effective_str(KEY_MIRROR_PREFERENCE),
)
} }
/// 把当前设置灌进 core 的下载调优(按数据根),返回**是否真的变了**(变了就失效该根的引擎)。 /// 把当前设置灌进 core 的下载调优(按数据根),返回**是否真的变了**(变了就失效该根的引擎)。
@@ -382,3 +382,17 @@ fn the_three_timeout_keys_travel_from_the_file_to_the_engine_config() {
assert_eq!(tuning_of(&env), DownloadTuning::default()); assert_eq!(tuning_of(&env), DownloadTuning::default());
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
/// **M294**:两个「源选择」键真的进了 `DownloadTuning`(删掉 `with_source_selection` ⇒ 两断言红)。
#[test]
fn the_two_source_selection_keys_reach_the_tuning() {
use unlucky_core::download::{MirrorPreference, RegionPreference};
let (dir, env) = env_with(
"source-selection",
serde_json::json!({ "download": { "region": "global", "mirror_preference": "official" } }),
);
let tuning = tuning_of(&env);
assert_eq!(tuning.region, RegionPreference::Global);
assert_eq!(tuning.mirror_preference, MirrorPreference::Official);
std::fs::remove_dir_all(&dir).ok();
}
+69 -19
View File
@@ -1,8 +1,9 @@
# 下载设置到底改了谁(M193) # 下载设置到底改了谁(M193)
一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 现在真的进引擎**; 一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 真的进引擎**;
其余四个 `download.*` 键(`region` / `mirror_preference` / `proxy_mode` / `proxy_url`)**引擎侧零消费者**, `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)挖出的 出处:M190 的 204 条 finding 复核(`DevDocs/PlanDoc/research/191-finding-triage.md` §3 A-3)挖出的
「用户能改、改了没用」缺口:schema 说并发 8,引擎恒跑 64,两处各写一个数。 「用户能改、改了没用」缺口:schema 说并发 8,引擎恒跑 64,两处各写一个数。
@@ -16,8 +17,8 @@
| `download.concurrency` | 8 / 1–256 | **无**(恒用 `EngineConfig::default()`) | **64**(`Semaphore` 容量) | ✅ 接进引擎,范围收成 1–64 | | `download.concurrency` | 8 / 1–256 | **无**(恒用 `EngineConfig::default()`) | **64**(`Semaphore` 容量) | ✅ 接进引擎,范围收成 1–64 |
| `download.retry_limit` | 3 / 0–10 | **无** | 3(碰巧与 schema 相同,但改它无效) | ✅ 接进引擎,范围 0–10(0 与 1 都 = 只试一次) | | `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.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.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 | **无** | 永远按地区模式(镜像优先/直连) | ❌ 未接(见 §5) | | `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_mode` | none / none·system·manual | **无**(`http::build_client` 从不调 `.proxy()`) | 只走环境变量代理(reqwest 默认),`system`/`manual` 都不生效 | ❌ 未接(见 §5) |
| `download.proxy_url` | "" | **无** | 空(从不使用) | ❌ 未接(见 §5) | | `download.proxy_url` | "" | **无** | 空(从不使用) | ❌ 未接(见 §5) |
| `download.action.detect_region` | (动作键,不是设置) | presenter 的 `detect_region` | 清 `region.json` + 失效共享引擎 | ✅ 本来就真 | | `download.action.detect_region` | (动作键,不是设置) | presenter 的 `detect_region` | 清 `region.json` + 失效共享引擎 | ✅ 本来就真 |
@@ -49,15 +50,18 @@ crates/core/src/download/config.rs
config/launcher.json config/launcher.json
download.concurrency ─┐ download.concurrency ─┐
download.retry_limit ─┼→ presenter::host::settings::download::tuning_of 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(<cache 根>, tuning) 变了吗?没变就什么都不做 shared::set_tuning(<cache 根>, tuning) 变了吗?没变就什么都不做
↓ 变了 → 淘汰该根的登记项 ↓ 变了 → 淘汰该根的登记项
shared::engine_for(<cache 根>) → shared::build shared::engine_for(<cache 根>) → 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`: **同步点是显式的**,两处,共用同一个函数 `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 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 java` | `crates/cli/src/commands/java.rs:312` 同上 | ✅ 吃(并发 + 尝试次数 + gh-proxy) |
| CLI `ull pack` | `crates/cli/src/commands/pack.rs:286` 同上 | ✅ 吃(并发 / 尝试次数 / 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 `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`(与 四条 CLI 路径共用同一次装配:`crates/cli/src/download.rs` 从 **CLI 自己的** `launcher.json`(与
`ull config set` 同一份文件)读三个 `download.*` 键 → `DownloadTuning::from_settings`(默认值/夹取/回落 `ull config set` 同一份文件)读八个 `download.*` 键(六个数字/三态 + M294 的两个源选择枚举)→
都在 core)→ `shared::engine_with_tuning(<cache 根>, tuning)`。所以 `presenter` 的 `shared::build` 不再是 `DownloadTuning::from_raw(..).with_source_selection(..)`(默认值/夹取/回落都在 core)→
`shared::engine_with_tuning(<cache 根>, tuning)`。所以 `presenter` 的 `shared::build` 不再是
**全仓唯一**的装配点,但装配规则仍只有 core 那一份。 **全仓唯一**的装配点,但装配规则仍只有 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`,越界 → 出厂 回落仍是静默的:`SettingStore::load` 永不失败(坏值只进 `problems()`),类型不对 → `None`,越界 → 出厂
值,不报错、不中断安装。历史教训保留在此:M193 把出厂并发从 64 改成 8 之后,当时还在用裸 值,不报错、不中断安装。历史教训保留在此:M193 把出厂并发从 64 改成 8 之后,当时还在用裸
`EngineConfig::default()` 的那几条命令**静默**跟着降了——「用出厂值」和「读到了设置」在输出上一模一样, `EngineConfig::default()` 的那几条命令**静默**跟着降了——「用出厂值」和「读到了设置」在输出上一模一样,
@@ -109,17 +127,43 @@ config/launcher.json
这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现 这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现
「界面显示 0、引擎按 3 跑」。 「界面显示 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`, 两个键都落成**同一个开关**上的覆盖——`RegionProfile.mode`(引擎里「官方在前还是镜像在前」只有它,
并把 `trigger` 置成 `UserPinned` 让 24h 重判不覆盖它); `mirrors.rs::expand` 与 `gh_proxy_candidates` 都只认它)。优先级是一条可读规则:
* `mirror_preference` → 引擎候选排序里插一档「强制官方优先 / 强制镜像优先」;
* `proxy_mode` / `proxy_url` → `http::build_client` 里加 `.proxy(reqwest::Proxy::all(url)?)`。
三条都在 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. 守卫与变异证明(逐条只改实现侧,跑完即还原) ## 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 用例绿 | | 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),另四条绿 | | 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 候选) | | 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 之后已解决 ## 6.1 gh-proxy 那一行的形状(P3-1):M201 之后已解决
@@ -190,6 +237,9 @@ M193 时期这一组把「GitHub 加速前缀」的三态画成三行(`GitHub
`AppCommand::SetSetting`;没有快照时(harness / `pages_demo` / 首帧)才退回 `GROUP.rows` 那几行语义演示行。 `AppCommand::SetSetting`;没有快照时(harness / `pages_demo` / 首帧)才退回 `GROUP.rows` 那几行语义演示行。
本次(M207)只读了代码与单测,没在 GPU 窗口里点过 chip / 输入 / 应用,也没量过「点了以后界面何时变」 本次(M207)只读了代码与单测,没在 GPU 窗口里点过 chip / 输入 / 应用,也没量过「点了以后界面何时变」
(设计是**不做乐观回显**,只认 host 发回的快照——这一点同样没在真机上确认过)。 (设计是**不做乐观回显**,只认 host 发回的快照——这一点同样没在真机上确认过)。
* **镜像可达性没验**(M294):源选择两键的用例全部离线(`region.json` 预置 + `expand()`/`plan()` 断源名
顺序),**没有向 BMCLAPI / TUNA / gh-proxy.com 发过一条真实请求**。所以「设成 `cn` 之后用户真的更快」
这件事本页不做承诺——只承诺「候选顺序按设置变了」。真机侧同样没点过 GUI 的 JDK 下载按钮观察命中源。
* **UA 里的联系方式在真站点上的后果未知**(M207):`User-Agent` 现在只有一份定义 * **UA 里的联系方式在真站点上的后果未知**(M207):`User-Agent` 现在只有一份定义
(`core::download::http::USER_AGENT` = `Unlucky-Launcher/<CARGO_PKG_VERSION> (+仓库 URL)`), (`core::download::http::USER_AGENT` = `Unlucky-Launcher/<CARGO_PKG_VERSION> (+仓库 URL)`),
两条 HTTP 路径发的是同一个字符串。证据只在**离线回环**上(发出去的字节 == 两条 HTTP 路径发的是同一个字符串。证据只在**离线回环**上(发出去的字节 ==