feat(download): 把 download.proxy_mode / download.proxy_url 接进代理三态(M295)
台账 P26:两个键引擎侧零消费者——`http::build_client` 只设 UA / 连接超时 / 重定向 / 默认头, 从不 `.proxy()`。设置页画着三档 chip 与一个地址框,改它对任何一次下载都不生效(`system` 那一 档「碰巧能用」只是因为 reqwest 默认读环境变量,不是这段代码做的)。 - core::download::proxy(新):`ProxySetting::from_raw` 是**唯一**翻译层,三档落到 client 上: `none` ⇒ `.no_proxy()`(真直连,连 `HTTP_PROXY` 也不读)/ `system` ⇒ 不调任何代理 API (跟随环境变量 = M295 之前的行为)/ `manual` ⇒ `.proxy(Proxy::all(url))`;未知值 ⇒ `none`。 - 两条决定写进模块头:`proxy_url` 在非 manual 档**忽略**(不校验、不进结构,值原样留在文件 里);`manual` + 空地址 ⇒ `system`(设置页那行文案一直这么承诺)。 - 非法地址不穿透:`Proxy::all` 解析失败 ⇒ 有界错误(`InvalidRequest` + 带原串的文案),不 panic;`DownloadEngine::new` 回落**直连**重建(不用 `reqwest::Client::new()`——那会跟随环境 变量代理,回落就不确定),原因留在 `proxy_problem()`,CLI 的 `download::engine` 打一行 stderr;`config().proxy` 不撒谎(仍是用户写的 `manual`)。 - `EngineConfig.proxy` 默认 `System`(=「没读设置」的档:auth 登录 / JDK 下载页 / 地区探测都 直接用它,保持老行为),`DownloadTuning.proxy` 默认 `Direct`(与 schema 的 `none` 一致)。 - 两键进 `RawTuning`(与 `gh_proxy` 同形),presenter 的 `tuning_of` 与 CLI 的 `tuning` 各加 两行;两个宿主都是完整字段字面量 ⇒ 「忘了读」是编译错误。`summary()` 加 `代理=…` 一档。 - 判据是「请求真的经过代理」:新夹具 `FakeProxy`(本地 TcpListener)+ 目标 `.invalid`(解析不 了)⇒ 拿到正文只可能来自代理,且代理记到的请求行是**绝对 URI**。三条用例:manual 真走代理 / direct 无视环境变量而 system 跟随(同一份 `HTTP_PROXY`)/ 非法地址回落直连不 panic。 变异 M17-M22 记在 docs/download-settings.md §6。 - 行为变化(doc §5.2 写明):`none` 从「碰巧跟随环境变量」变成真直连,靠 `HTTP_PROXY` 上网的 用户要选一次 `system`。发布版 GUI 的「下载 JDK」页仍不读这两个键(finding 已开)。 - 门禁:四 crate `check --all-targets` / 测试(3201 passed, 0 failed)/ `clippy -D warnings` / `fmt --check`;另跑了 `check --workspace --all-targets`。判据用 `$?` 与 `test result:` 行验。
This commit is contained in:
1 parent
c731d3fa54
commit
d584a2c42e
16 files changed
+995
-51
No files matched your search
@@ -31,7 +31,8 @@ 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_MIRROR_PREFERENCE, KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR,
|
KEY_MIRROR_PREFERENCE, KEY_PROXY_MODE, KEY_PROXY_URL, 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;
|
||||||
@@ -39,7 +40,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 空串 = 跟随内置默认,**不是**关。
|
||||||
@@ -52,6 +53,9 @@ use crate::context::Ctx;
|
|||||||
/// [`RawTuning`]**——那两个是枚举,翻译层是 core 的 [`DownloadTuning::with_source_selection`]
|
/// [`RawTuning`]**——那两个是枚举,翻译层是 core 的 [`DownloadTuning::with_source_selection`]
|
||||||
/// (未知值回落 `auto`)。CLI 与 presenter 走的是同一个入口,别在宿主里再写一份字符串匹配:
|
/// (未知值回落 `auto`)。CLI 与 presenter 走的是同一个入口,别在宿主里再写一份字符串匹配:
|
||||||
/// 两份口径就是「GUI 认、CLI 不认」的下一次复发。
|
/// 两份口径就是「GUI 认、CLI 不认」的下一次复发。
|
||||||
|
///
|
||||||
|
/// M295:代理两键(`download.proxy_mode` / `proxy_url`)读法与 presenter 的 `tuning_of` 逐行相同
|
||||||
|
/// ——同一个 `RawTuning`、同一个 `ProxySetting::from_raw`,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());
|
||||||
@@ -67,6 +71,8 @@ pub(crate) fn tuning(ctx: &Ctx) -> DownloadTuning {
|
|||||||
throughput_floor_kib: doc.effective_i64(KEY_THROUGHPUT_FLOOR),
|
throughput_floor_kib: doc.effective_i64(KEY_THROUGHPUT_FLOOR),
|
||||||
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),
|
||||||
|
proxy_mode: doc.effective_str(KEY_PROXY_MODE),
|
||||||
|
proxy_url: doc.effective_str(KEY_PROXY_URL),
|
||||||
})
|
})
|
||||||
.with_source_selection(
|
.with_source_selection(
|
||||||
doc.effective_str(KEY_REGION),
|
doc.effective_str(KEY_REGION),
|
||||||
@@ -99,7 +105,13 @@ pub(crate) fn engine(ctx: &Ctx) -> Arc<DownloadEngine> {
|
|||||||
if let Some(line) = announce(&tuning) {
|
if let Some(line) = announce(&tuning) {
|
||||||
eprintln!("{line}");
|
eprintln!("{line}");
|
||||||
}
|
}
|
||||||
shared::engine_with_tuning(ctx.layout().cache(), tuning)
|
let engine = shared::engine_with_tuning(ctx.layout().cache(), tuning);
|
||||||
|
// 代理那一档没生效时(`manual` 的地址解析不了 ⇒ 引擎回落直连)必须说出来:静默回落等于把
|
||||||
|
// 「设置项说假话」换成「设置项不说实话」,用户只会觉得「填了代理还是连不上」。
|
||||||
|
if let Some(problem) = engine.proxy_problem() {
|
||||||
|
eprintln!("下载代理未生效,本次按直连:{problem}");
|
||||||
|
}
|
||||||
|
engine
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -389,3 +389,7 @@ fn the_source_selection_settings_reach_the_cli_engine() {
|
|||||||
assert!(line.contains("地区=cn"), "{line}");
|
assert!(line.contains("地区=cn"), "{line}");
|
||||||
assert!(line.contains("镜像偏好=auto"), "{line}");
|
assert!(line.contains("镜像偏好=auto"), "{line}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 代理两键的 CLI 侧守门(M295):单开一个文件——本文件已贴着 400 行硬线。
|
||||||
|
#[path = "tests/proxy.rs"]
|
||||||
|
mod proxy;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! **代理两键的 CLI 侧守门(M295)**:`download.proxy_mode` / `download.proxy_url` 从 CLI 自己的
|
||||||
|
//! `launcher.json` 进四条命令的生产装配点,终端那行生效值也含代理档。
|
||||||
|
//!
|
||||||
|
//! 从 `tests.rs` 拆出只为守 400 行硬线(与 core 的 `engine_mock/*.rs` 同一手法)。
|
||||||
|
//!
|
||||||
|
//! 抓得住:① `download::tuning` 不读 `KEY_PROXY_MODE` / `KEY_PROXY_URL` ⇒ 三档全退回 `Direct`,
|
||||||
|
//! 前两条红;② `summary()` 少印代理 ⇒ 那行断言红;③ 非法地址让装配 panic ⇒ 最后一段红;
|
||||||
|
//! ④ 键名常量与 schema 登记脱钩 ⇒ 末尾那句红。
|
||||||
|
//!
|
||||||
|
//! 抓不住:请求是不是**真的**经由代理(core 的 `tests/download/engine_mock/proxy.rs`:本地假代理 +
|
||||||
|
//! `.invalid` 目标 + 绝对 URI 请求行);也抓不住真实的系统代理 / PAC / 需要认证的代理
|
||||||
|
//! (见 `docs/download-settings.md` §7)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_proxy_settings_reach_the_cli_engine_and_the_console_line() {
|
||||||
|
use unlucky_core::download::config::{KEY_PROXY_MODE, KEY_PROXY_URL};
|
||||||
|
use unlucky_core::download::proxy::ProxySetting;
|
||||||
|
|
||||||
|
let dir = TempDir::new("proxy");
|
||||||
|
let ctx = ctx_at(&dir);
|
||||||
|
|
||||||
|
// manual:地址进引擎配置,终端那行写的是同一档。
|
||||||
|
write_launcher_config(
|
||||||
|
&ctx,
|
||||||
|
json!({ "download": { "proxy_mode": "manual", "proxy_url": " http://127.0.0.1:7890 " } }),
|
||||||
|
);
|
||||||
|
let engine = crate::commands::install::build_engine(&ctx);
|
||||||
|
assert_eq!(
|
||||||
|
engine.config().proxy,
|
||||||
|
ProxySetting::Manual {
|
||||||
|
url: "http://127.0.0.1:7890".to_string()
|
||||||
|
},
|
||||||
|
"manual 档必须落到引擎配置上(去空白)"
|
||||||
|
);
|
||||||
|
let line =
|
||||||
|
crate::download::announce(&crate::download::tuning(&ctx)).expect("改过设置 ⇒ 该有一行");
|
||||||
|
assert!(
|
||||||
|
line.contains("代理=manual(http://127.0.0.1:7890)"),
|
||||||
|
"{line}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// system + 一个残留地址:地址被忽略,档位是 system(终端那行也要说清是「跟随环境变量」)。
|
||||||
|
write_launcher_config(
|
||||||
|
&ctx,
|
||||||
|
json!({ "download": { "proxy_mode": "system", "proxy_url": "http://127.0.0.1:7890" } }),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crate::commands::install::build_engine(&ctx).config().proxy,
|
||||||
|
ProxySetting::System
|
||||||
|
);
|
||||||
|
let line = crate::download::announce(&crate::download::tuning(&ctx)).expect("该有一行");
|
||||||
|
assert!(line.contains("代理=system(跟随环境变量)"), "{line}");
|
||||||
|
|
||||||
|
// 非法地址:不 panic;引擎给出可读原因,配置字段仍是用户写的值(回落是运行时行为)。
|
||||||
|
write_launcher_config(
|
||||||
|
&ctx,
|
||||||
|
json!({ "download": { "proxy_mode": "manual", "proxy_url": "http://127.0.0.1:99999" } }),
|
||||||
|
);
|
||||||
|
let engine = crate::commands::install::build_engine(&ctx);
|
||||||
|
let problem = engine
|
||||||
|
.proxy_problem()
|
||||||
|
.expect("地址解析不了要留下原因(CLI 的那行 stderr 靠它)");
|
||||||
|
assert!(problem.contains("http://127.0.0.1:99999"), "{problem}");
|
||||||
|
assert_eq!(
|
||||||
|
engine.config().proxy,
|
||||||
|
ProxySetting::Manual {
|
||||||
|
url: "http://127.0.0.1:99999".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 两个键名常量必须真的登记在 schema 里(写错 = 「用户设了没生效」的静默退化)。
|
||||||
|
let schema = launcher_schema();
|
||||||
|
for key in [KEY_PROXY_MODE, KEY_PROXY_URL] {
|
||||||
|
assert!(schema.spec(key).is_some(), "{key} 没登记");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use super::backoff::BackoffPolicy;
|
use super::backoff::BackoffPolicy;
|
||||||
use super::chunked::ChunkedConfig;
|
use super::chunked::ChunkedConfig;
|
||||||
|
use super::proxy::ProxySetting;
|
||||||
use super::stall::{self, Deadlines};
|
use super::stall::{self, Deadlines};
|
||||||
use super::types::ResumePolicy;
|
use super::types::ResumePolicy;
|
||||||
|
|
||||||
@@ -96,6 +97,12 @@ pub const KEY_GH_PROXY: &str = "download.gh_proxy";
|
|||||||
pub const KEY_REGION: &str = "download.region";
|
pub const KEY_REGION: &str = "download.region";
|
||||||
/// 「镜像偏好」键(`auto` / `official` / `mirror`)。读法与优先级同 [`KEY_REGION`]。
|
/// 「镜像偏好」键(`auto` / `official` / `mirror`)。读法与优先级同 [`KEY_REGION`]。
|
||||||
pub const KEY_MIRROR_PREFERENCE: &str = "download.mirror_preference";
|
pub const KEY_MIRROR_PREFERENCE: &str = "download.mirror_preference";
|
||||||
|
/// 「代理模式」键(`none` / `system` / `manual`)。读它的是宿主,翻译成 [`ProxySetting`]
|
||||||
|
/// (三档语义、`proxy_url` 在非 manual 档的处置、非法地址怎么回落,**全写在** [`super::proxy`] 的
|
||||||
|
/// 模块头——别在宿主里再解释一次字符串)。
|
||||||
|
pub const KEY_PROXY_MODE: &str = "download.proxy_mode";
|
||||||
|
/// 「代理地址」键(**只在 `manual` 档生效**,其它档忽略,见 [`KEY_PROXY_MODE`])。
|
||||||
|
pub const KEY_PROXY_URL: &str = "download.proxy_url";
|
||||||
|
|
||||||
/// 引擎配置。
|
/// 引擎配置。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -146,6 +153,16 @@ pub struct EngineConfig {
|
|||||||
pub chunked: ChunkedConfig,
|
pub chunked: ChunkedConfig,
|
||||||
/// 默认续传策略(请求级 `Never` 优先级更高)。
|
/// 默认续传策略(请求级 `Never` 优先级更高)。
|
||||||
pub resume: ResumePolicy,
|
pub resume: ResumePolicy,
|
||||||
|
/// 代理三态(`download.proxy_mode` / `download.proxy_url` 的翻译结论,唯一翻译层在
|
||||||
|
/// [`super::proxy`])。
|
||||||
|
///
|
||||||
|
/// **默认 [`ProxySetting::System`],与 `download.proxy_mode` 的 schema 出厂值(`none`)故意不同**:
|
||||||
|
/// 这一档是「这个子系统**没读**设置」时的行为(`auth` 的登录、发布版 GUI 的 JDK 下载页、地区探测
|
||||||
|
/// 都直接 `EngineConfig::default()`),保持 M295 之前「跟随环境变量代理」才不会静默改掉它们的
|
||||||
|
/// 网络路径。读了设置的装配点(`shared::build` / `engine_with`)由 `DownloadTuning` 显式盖上去,
|
||||||
|
/// 那一路的出厂值是 `none`(真直连,与设置页画的那颗 chip 一致)。两处默认为什么必须不同,
|
||||||
|
/// 见 [`super::proxy`] 模块头。
|
||||||
|
pub proxy: ProxySetting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for EngineConfig {
|
impl Default for EngineConfig {
|
||||||
@@ -166,6 +183,7 @@ impl Default for EngineConfig {
|
|||||||
backoff: BackoffPolicy::default(),
|
backoff: BackoffPolicy::default(),
|
||||||
chunked: ChunkedConfig::default(),
|
chunked: ChunkedConfig::default(),
|
||||||
resume: ResumePolicy::Conservative,
|
resume: ResumePolicy::Conservative,
|
||||||
|
proxy: ProxySetting::System,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::super::preference::{self, MirrorPreference, RegionPreference};
|
use super::super::preference::{self, MirrorPreference, RegionPreference};
|
||||||
|
use super::super::proxy::ProxySetting;
|
||||||
use super::super::region::RegionProfile;
|
use super::super::region::RegionProfile;
|
||||||
use super::super::stall;
|
use super::super::stall;
|
||||||
use super::{
|
use super::{
|
||||||
@@ -46,6 +47,9 @@ pub struct DownloadTuning {
|
|||||||
pub region: RegionPreference,
|
pub region: RegionPreference,
|
||||||
/// 显式的「镜像偏好」覆盖(`download.mirror_preference`;`Auto` = 跟着 `region` 走)。
|
/// 显式的「镜像偏好」覆盖(`download.mirror_preference`;`Auto` = 跟着 `region` 走)。
|
||||||
pub mirror_preference: MirrorPreference,
|
pub mirror_preference: MirrorPreference,
|
||||||
|
/// 代理三态(`download.proxy_mode` + `download.proxy_url`)。出厂 [`ProxySetting::Direct`]:
|
||||||
|
/// 与 schema 的 `none` 同值(三档语义与回落见 `core::download::proxy` 模块头)。
|
||||||
|
pub proxy: ProxySetting,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DownloadTuning {
|
impl Default for DownloadTuning {
|
||||||
@@ -59,6 +63,7 @@ impl Default for DownloadTuning {
|
|||||||
min_avg_throughput: stall::MIN_AVG_THROUGHPUT,
|
min_avg_throughput: stall::MIN_AVG_THROUGHPUT,
|
||||||
region: RegionPreference::Auto,
|
region: RegionPreference::Auto,
|
||||||
mirror_preference: MirrorPreference::Auto,
|
mirror_preference: MirrorPreference::Auto,
|
||||||
|
proxy: ProxySetting::Direct,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +87,14 @@ pub struct RawTuning<'a> {
|
|||||||
pub stall_timeout_secs: Option<i64>,
|
pub stall_timeout_secs: Option<i64>,
|
||||||
/// [`KEY_RESPONSE_TIMEOUT`](super::KEY_RESPONSE_TIMEOUT)(秒)。
|
/// [`KEY_RESPONSE_TIMEOUT`](super::KEY_RESPONSE_TIMEOUT)(秒)。
|
||||||
pub response_timeout_secs: Option<i64>,
|
pub response_timeout_secs: Option<i64>,
|
||||||
|
/// [`KEY_PROXY_MODE`](super::KEY_PROXY_MODE)(`none` / `system` / `manual`)。
|
||||||
|
///
|
||||||
|
/// 代理两键**进这个结构体**(不像 `region` / `mirror_preference` 那样另开一个 `with_*` 入口):
|
||||||
|
/// 它们是「字符串原文 + 一个客户端开关」,与 `gh_proxy` 同形;而 `region` 那一对当初另开入口
|
||||||
|
/// 是因为给本结构体加字段会波及当时不在 scope 的 CLI(M294 的权宜),不是为了风格。
|
||||||
|
pub proxy_mode: Option<&'a str>,
|
||||||
|
/// [`KEY_PROXY_URL`](super::KEY_PROXY_URL)(**只在 `manual` 档被读**,其它档忽略)。
|
||||||
|
pub proxy_url: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `min..=max` 之间的秒数 → `Duration`(负数 / `0` / 越界一律 `None`,由调用方回落出厂值)。
|
/// `min..=max` 之间的秒数 → `Duration`(负数 / `0` / 越界一律 `None`,由调用方回落出厂值)。
|
||||||
@@ -101,6 +114,10 @@ impl DownloadTuning {
|
|||||||
///
|
///
|
||||||
/// 三个超时键的 `0` 也走「回落默认」而不是「夹到 1」:`0` 秒的窗口等于「立刻判失速」、
|
/// 三个超时键的 `0` 也走「回落默认」而不是「夹到 1」:`0` 秒的窗口等于「立刻判失速」、
|
||||||
/// `0` 字节/秒的吞吐下限是除零——两种都不是用户想要的东西,静默回落到出厂值是唯一安全的读法。
|
/// `0` 字节/秒的吞吐下限是除零——两种都不是用户想要的东西,静默回落到出厂值是唯一安全的读法。
|
||||||
|
///
|
||||||
|
/// 代理两键在这里翻译([`ProxySetting::from_raw`]):未知模式 ⇒ `none`、`manual` + 空地址 ⇒
|
||||||
|
/// `system`、地址**不在这里校验**(解析失败由 `build_client` 报有界错误,见
|
||||||
|
/// `core::download::proxy` 模块头)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn from_raw(raw: RawTuning<'_>) -> Self {
|
pub fn from_raw(raw: RawTuning<'_>) -> Self {
|
||||||
let default = Self::default();
|
let default = Self::default();
|
||||||
@@ -141,6 +158,7 @@ impl DownloadTuning {
|
|||||||
// `from_raw` 恒给 `Auto`,即「不发言」。
|
// `from_raw` 恒给 `Auto`,即「不发言」。
|
||||||
region: RegionPreference::Auto,
|
region: RegionPreference::Auto,
|
||||||
mirror_preference: MirrorPreference::Auto,
|
mirror_preference: MirrorPreference::Auto,
|
||||||
|
proxy: ProxySetting::from_raw(raw.proxy_mode, raw.proxy_url),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +187,10 @@ impl DownloadTuning {
|
|||||||
cfg.response_timeout = self.response_timeout;
|
cfg.response_timeout = self.response_timeout;
|
||||||
cfg.chunk_stall_timeout = self.chunk_stall_timeout;
|
cfg.chunk_stall_timeout = self.chunk_stall_timeout;
|
||||||
cfg.min_avg_throughput = self.min_avg_throughput;
|
cfg.min_avg_throughput = self.min_avg_throughput;
|
||||||
|
// 代理必须在这里盖:`EngineConfig::default()` 是 `System`(「没读设置」的档),而
|
||||||
|
// `DownloadTuning::default()` 是 `Direct`(settings 的 `none`)——不盖就是「设置页说 none、
|
||||||
|
// 引擎偷偷跟随环境变量」。
|
||||||
|
cfg.proxy = self.proxy.clone();
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +203,9 @@ impl DownloadTuning {
|
|||||||
/// `download::tuning` 已经读了它们,这一行若不含就等于「打了一行说生效值、却少说两项」。两个值都用
|
/// `download::tuning` 已经读了它们,这一行若不含就等于「打了一行说生效值、却少说两项」。两个值都用
|
||||||
/// [`RegionPreference::as_str`] / [`MirrorPreference::as_str`],与 schema 的取值逐字相同。
|
/// [`RegionPreference::as_str`] / [`MirrorPreference::as_str`],与 schema 的取值逐字相同。
|
||||||
///
|
///
|
||||||
|
/// M295:`代理` 这一档用 [`ProxySetting::describe`](不是 `as_str`):`none` 与 `system` 行为
|
||||||
|
/// 相反,只印一个词分不出「跟随环境变量」与「某个具体地址」。
|
||||||
|
///
|
||||||
/// (`crates/cli/src/download.rs::tuning` 是唯一读者。)
|
/// (`crates/cli/src/download.rs::tuning` 是唯一读者。)
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn summary(&self) -> String {
|
pub fn summary(&self) -> String {
|
||||||
@@ -190,7 +215,7 @@ 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,
|
||||||
@@ -198,6 +223,7 @@ impl DownloadTuning {
|
|||||||
self.response_timeout.as_secs(),
|
self.response_timeout.as_secs(),
|
||||||
self.region.as_str(),
|
self.region.as_str(),
|
||||||
self.mirror_preference.as_str(),
|
self.mirror_preference.as_str(),
|
||||||
|
self.proxy.describe(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use super::health::HealthRegistry;
|
|||||||
use super::http;
|
use super::http;
|
||||||
use super::mirrors::MirrorTable;
|
use super::mirrors::MirrorTable;
|
||||||
use super::progress::{ProgressEmitter, ProgressEvent};
|
use super::progress::{ProgressEmitter, ProgressEvent};
|
||||||
|
use super::proxy::ProxySetting;
|
||||||
use super::region::RegionProfile;
|
use super::region::RegionProfile;
|
||||||
use super::stall::{StallReport, StallStage};
|
use super::stall::{StallReport, StallStage};
|
||||||
mod finish;
|
mod finish;
|
||||||
@@ -53,6 +54,8 @@ pub(crate) struct AttemptOk {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct DownloadEngine {
|
pub struct DownloadEngine {
|
||||||
pub(crate) http: reqwest::Client,
|
pub(crate) http: reqwest::Client,
|
||||||
|
/// 代理解析失败的原因(`None` = `cfg.proxy` 那一档原样生效)。见 [`Self::proxy_problem`]。
|
||||||
|
pub(crate) proxy_problem: Option<String>,
|
||||||
pub(crate) cfg: EngineConfig,
|
pub(crate) cfg: EngineConfig,
|
||||||
pub(crate) progress: ProgressEmitter,
|
pub(crate) progress: ProgressEmitter,
|
||||||
pub(crate) health: Arc<HealthRegistry>,
|
pub(crate) health: Arc<HealthRegistry>,
|
||||||
@@ -73,7 +76,18 @@ impl DownloadEngine {
|
|||||||
health: Arc<HealthRegistry>,
|
health: Arc<HealthRegistry>,
|
||||||
cache: Arc<ContentCache>,
|
cache: Arc<ContentCache>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let http = http::build_client(&cfg).unwrap_or_else(|_| reqwest::Client::new());
|
// 代理地址解析不了(手改文件写坏了 `proxy_url`)⇒ 回落到**直连**重建:不能用
|
||||||
|
// `reqwest::Client::new()` 兜(那会跟随环境变量代理,「回落」就成了不确定行为,还会顺手
|
||||||
|
// 丢掉 UA / 超时 / 重定向策略)。原因留在引擎上,宿主打印出来([`Self::proxy_problem`])。
|
||||||
|
let (http, proxy_problem) = match http::build_client(&cfg) {
|
||||||
|
Ok(client) => (client, None),
|
||||||
|
Err(err) => {
|
||||||
|
let mut direct = cfg.clone();
|
||||||
|
direct.proxy = ProxySetting::Direct;
|
||||||
|
let client = http::build_client(&direct).unwrap_or_else(|_| reqwest::Client::new());
|
||||||
|
(client, Some(err.message))
|
||||||
|
}
|
||||||
|
};
|
||||||
// 硬上限夹取(M193):手改的 launcher.json / CLI 自拼的 cfg 都可能写着 9999,在唯一构造点再夹一次。
|
// 硬上限夹取(M193):手改的 launcher.json / CLI 自拼的 cfg 都可能写着 9999,在唯一构造点再夹一次。
|
||||||
let permits = cfg.max_concurrency.clamp(1, MAX_CONCURRENCY);
|
let permits = cfg.max_concurrency.clamp(1, MAX_CONCURRENCY);
|
||||||
let sem = Arc::new(Semaphore::new(permits));
|
let sem = Arc::new(Semaphore::new(permits));
|
||||||
@@ -82,6 +96,7 @@ impl DownloadEngine {
|
|||||||
super::shared::record_build(cache.root());
|
super::shared::record_build(cache.root());
|
||||||
Self {
|
Self {
|
||||||
http,
|
http,
|
||||||
|
proxy_problem,
|
||||||
cfg,
|
cfg,
|
||||||
progress: ProgressEmitter::silent(),
|
progress: ProgressEmitter::silent(),
|
||||||
health,
|
health,
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ impl DownloadEngine {
|
|||||||
&self.http
|
&self.http
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 代理这一档**没生效**的原因(`None` = 按 `config().proxy` 生效)。
|
||||||
|
///
|
||||||
|
/// 只有一种情况会给 `Some`:`manual` 的地址解析不出来 ⇒ 客户端回落**直连**(见
|
||||||
|
/// [`DownloadEngine::new`]),而 `config().proxy` 仍是用户写的 `manual` —— 配置不撒谎,实际行为
|
||||||
|
/// 由这个字段说明。宿主该把它打给用户(CLI 的 `download::engine` 打一行 stderr),否则「填了代理
|
||||||
|
/// 但没走代理」又成了没法解释的半真话。
|
||||||
|
#[must_use]
|
||||||
|
pub fn proxy_problem(&self) -> Option<&str> {
|
||||||
|
self.proxy_problem.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
/// 当前地区档案(设置页展示)。
|
/// 当前地区档案(设置页展示)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn region(&self) -> &RegionProfile {
|
pub fn region(&self) -> &RegionProfile {
|
||||||
|
|||||||
@@ -40,20 +40,24 @@ pub const USER_AGENT: &str = concat!(
|
|||||||
" (+https://github.com/AskaEth/Unlucky-Launcher)"
|
" (+https://github.com/AskaEth/Unlucky-Launcher)"
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 构造共享的 `reqwest::Client`(连接池、超时、重定向策略一次定好)。
|
/// 构造共享的 `reqwest::Client`(连接池、超时、重定向策略、代理一次定好)。
|
||||||
|
///
|
||||||
|
/// **代理由配置决定,不在这里解释字符串**:`cfg.proxy` 是 `download.proxy_mode` / `proxy_url` 的
|
||||||
|
/// 翻译结论(唯一翻译层是 [`super::proxy`]),三档各自落到 `.no_proxy()` / 什么都不做 /
|
||||||
|
/// `.proxy(Proxy::all(url))`。地址解析不了时返回**有界错误**(不 panic)——装配点
|
||||||
|
/// (`DownloadEngine::new`)据此回落直连并把原因留在引擎上,见 [`super::proxy`] 模块头。
|
||||||
pub fn build_client(cfg: &EngineConfig) -> Result<reqwest::Client, DownloadError> {
|
pub fn build_client(cfg: &EngineConfig) -> Result<reqwest::Client, DownloadError> {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
ACCEPT_ENCODING,
|
ACCEPT_ENCODING,
|
||||||
reqwest::header::HeaderValue::from_static("identity"),
|
reqwest::header::HeaderValue::from_static("identity"),
|
||||||
);
|
);
|
||||||
reqwest::Client::builder()
|
let builder = reqwest::Client::builder()
|
||||||
.user_agent(cfg.user_agent.clone())
|
.user_agent(cfg.user_agent.clone())
|
||||||
.connect_timeout(cfg.connect_timeout)
|
.connect_timeout(cfg.connect_timeout)
|
||||||
.redirect(Policy::limited(MAX_REDIRECTS))
|
.redirect(Policy::limited(MAX_REDIRECTS))
|
||||||
.default_headers(headers)
|
.default_headers(headers);
|
||||||
.build()
|
cfg.proxy.apply(builder)?.build().map_err(|err| {
|
||||||
.map_err(|err| {
|
|
||||||
DownloadError::new(
|
DownloadError::new(
|
||||||
DownloadErrorKind::Network,
|
DownloadErrorKind::Network,
|
||||||
format!("构造 HTTP 客户端失败:{err}"),
|
format!("构造 HTTP 客户端失败:{err}"),
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub mod jdk_sources;
|
|||||||
pub mod mirrors;
|
pub mod mirrors;
|
||||||
pub mod preference;
|
pub mod preference;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
|
pub mod proxy;
|
||||||
pub mod region;
|
pub mod region;
|
||||||
pub mod region_system;
|
pub mod region_system;
|
||||||
pub mod resume;
|
pub mod resume;
|
||||||
@@ -95,6 +96,7 @@ 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::preference::{MirrorPreference, RegionPreference};
|
||||||
pub use self::progress::{ProgressEmitter, ProgressEvent, ProgressThrottle};
|
pub use self::progress::{ProgressEmitter, ProgressEvent, ProgressThrottle};
|
||||||
|
pub use self::proxy::ProxySetting;
|
||||||
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;
|
||||||
pub use self::resume::{ChunkRecord, RestartReason, ResumeDecision, ResumeMeta};
|
pub use self::resume::{ChunkRecord, RestartReason, ResumeDecision, ResumeMeta};
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//! `download.proxy_mode` / `download.proxy_url` → reqwest 客户端的代理三态(M295)。
|
||||||
|
//!
|
||||||
|
//! # 现状(M295 之前)
|
||||||
|
//!
|
||||||
|
//! `grep -rn "proxy_mode\|proxy_url\|ProxyMode" crates/core/src --include=*.rs | grep -v setting/keys`
|
||||||
|
//! **零命中**:两个键在引擎侧零消费者。`http::build_client` 当时只设 UA / 连接超时 / 重定向 /
|
||||||
|
//! 默认头,**从不 `.proxy()`**,于是设置页画着的「直连 / 系统代理 / 手动代理」三档 chip 与那个
|
||||||
|
//! 地址文本框,对任何一次下载都不生效。`system` 那一档「碰巧能用」只是因为 reqwest 自己会读进程
|
||||||
|
//! 环境变量(`HTTP_PROXY` 等),不是这段代码做的——这也是本模块必须把它写成**显式一档**的理由:
|
||||||
|
//! 三档都要是这段代码的判决,而不是「碰巧」。
|
||||||
|
//!
|
||||||
|
//! # 三档语义(全仓唯一一处翻译)
|
||||||
|
//!
|
||||||
|
//! | `download.proxy_mode` | 翻译成 | 落到 client 上 | 含义 |
|
||||||
|
//! |---|---|---|---|
|
||||||
|
//! | `none`(schema 出厂值,键不存在也走这档) | [`ProxySetting::Direct`] | `.no_proxy()` | 什么都不走:连进程环境里的 `HTTP_PROXY` 也不读 |
|
||||||
|
//! | `system` | [`ProxySetting::System`] | **不调任何代理 API** | 跟随进程环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`)= reqwest 的默认行为,也是 M295 之前所有下载的实际行为 |
|
||||||
|
//! | `manual` | [`ProxySetting::Manual`] | `.proxy(reqwest::Proxy::all(url))` | 整条流量走 `proxy_url`(http 与 https 目标都经它;`Proxy::all` 会顺手关掉「自动读系统代理」) |
|
||||||
|
//!
|
||||||
|
//! 未知值 / 空串 ⇒ `none`(与 schema 出厂值一致:静默回落、不报错、不中断下载,口径与
|
||||||
|
//! `preference` 那两个枚举键相同)。
|
||||||
|
//!
|
||||||
|
//! **默认值为什么要分两处**(`EngineConfig::default()` 是 `System`、`DownloadTuning::default()`
|
||||||
|
//! 是 `Direct`):`EngineConfig::default()` 是「这个子系统**没读**设置」的档(`auth` 的登录、
|
||||||
|
//! 发布版 GUI 的 JDK 下载页、地区探测都直接用它),保持 M295 之前的行为(跟随环境变量)才不算
|
||||||
|
//! 静默改网络路径;而读了设置的装配点(`shared::build` / `engine_with`)一律由
|
||||||
|
//! `DownloadTuning` 显式盖上去,那一路的出厂值是 `none` —— 与设置页画的那颗 chip 一致,
|
||||||
|
//! 「界面说 none、实际偷偷走系统代理」正是本任务要治的病。
|
||||||
|
//!
|
||||||
|
//! ## `proxy_url` 在非 manual 模式下:**忽略**
|
||||||
|
//!
|
||||||
|
//! 一句话决定:`proxy_url` 只在 `manual` 档被读;`none` / `system` 下它**既不校验、也不进入**
|
||||||
|
//! [`ProxySetting`],值原样留在 `launcher.json` 里(模式切回 `manual` 就立刻生效)。模式是唯一
|
||||||
|
//! 开关,所以不存在「填了地址、模式没选对、半生效」这种中间态。
|
||||||
|
//!
|
||||||
|
//! `manual` + **空地址** ⇒ [`ProxySetting::System`]:设置页那行文案承诺过「manual 模式生效;留空则
|
||||||
|
//! 用系统代理」,这里按文案兑现。不选 `Direct` —— 那会让「留空」变成第三种没人写过的语义。
|
||||||
|
//!
|
||||||
|
//! # 非法地址不得穿透
|
||||||
|
//!
|
||||||
|
//! `proxy_url` 写成解析不了的串时,[`ProxySetting::apply`] 返回**有界错误**
|
||||||
|
//! ([`DownloadErrorKind::InvalidRequest`](super::error::DownloadErrorKind::InvalidRequest) +
|
||||||
|
//! 带原串的可读文案),**不 panic**。装配点([`super::engine::DownloadEngine::new`])的处置是
|
||||||
|
//! 「回落直连 + 把原因留在引擎上」(`DownloadEngine::proxy_problem`):一个设置字符串不该让用户
|
||||||
|
//! 连文件都下不了,但也不许它悄悄变成「用了某个不知道是什么的代理」。
|
||||||
|
//!
|
||||||
|
//! 一条实测(别把它当 bug 修掉):reqwest 的 `Proxy::all` 对**没有 scheme** 的串会自作主张补
|
||||||
|
//! `http://` —— `127.0.0.1:7890` 是合法写法。所以用户少写 `http://` 不算「解析不了」;真正会失败的
|
||||||
|
//! 是端口越界(`…:99999`)、方括号不闭合这一类。
|
||||||
|
//!
|
||||||
|
//! # 不落盘
|
||||||
|
//!
|
||||||
|
//! 与 `preference` 同一条纪律:翻译只发生在装配那一刻,`launcher.json` 里存的永远是用户写的原文。
|
||||||
|
//!
|
||||||
|
//! # 没验的部分
|
||||||
|
//!
|
||||||
|
//! 真机上的「系统代理」在 Windows/macOS 上的注册表/偏好设置路径**没有**被读(本仓 reqwest 没开
|
||||||
|
//! `client-proxy-system` feature ⇒ `system` 只认环境变量),PAC 脚本与需要认证(`Proxy-Authorization`)
|
||||||
|
//! 的代理同样没测。
|
||||||
|
|
||||||
|
use reqwest::ClientBuilder;
|
||||||
|
|
||||||
|
use super::error::{DownloadError, DownloadErrorKind};
|
||||||
|
|
||||||
|
/// `download.proxy_mode` + `download.proxy_url` 的翻译结论(**全仓唯一**一份)。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub enum ProxySetting {
|
||||||
|
/// `none`(schema 出厂值):**真直连**,连环境变量里的代理也不读。
|
||||||
|
#[default]
|
||||||
|
Direct,
|
||||||
|
/// `system`:跟随进程环境变量(reqwest 的默认行为,本档不调任何代理 API)。
|
||||||
|
System,
|
||||||
|
/// `manual`:整条流量走这个地址。
|
||||||
|
Manual {
|
||||||
|
/// `download.proxy_url` 的原串(已去前后空白;**未**校验,校验在 [`Self::apply`])。
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProxySetting {
|
||||||
|
/// 设置原文 → 代理三态(**唯一**翻译入口:两个键一起读,规则见模块头)。
|
||||||
|
///
|
||||||
|
/// `mode` / `url` 的 `None` 是「键不存在 / 类型不对」(宿主侧 `effective_str` 的两种失败),
|
||||||
|
/// 与空串一样按「没填」处理:`mode` 没填 ⇒ `none`,`url` 没填 + `manual` ⇒ `system`。
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_raw(mode: Option<&str>, url: Option<&str>) -> Self {
|
||||||
|
let url = url.map(str::trim).unwrap_or_default();
|
||||||
|
match mode
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"system" => Self::System,
|
||||||
|
"manual" if url.is_empty() => Self::System,
|
||||||
|
"manual" => Self::Manual {
|
||||||
|
url: url.to_string(),
|
||||||
|
},
|
||||||
|
// `none` / 空串 / 未知值(手改文件写出来的 `mars`)都落这一档:与 schema 出厂值一致。
|
||||||
|
_ => Self::Direct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 稳定字符串 id(与 schema 的取值逐字相同;日志/诊断用)。
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Direct => "none",
|
||||||
|
Self::System => "system",
|
||||||
|
Self::Manual { .. } => "manual",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一行「引擎实际拿到了什么」(`DownloadTuning::summary` 用:三档语义不同,只印 `as_str`
|
||||||
|
/// 会把「跟随环境变量」与「某个具体地址」印成同一句话)。
|
||||||
|
#[must_use]
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Direct => "none(直连)".to_string(),
|
||||||
|
Self::System => "system(跟随环境变量)".to_string(),
|
||||||
|
Self::Manual { url } => format!("manual({url})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把这一档盖到 client builder 上(**唯一**应用点:`http::build_client`)。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 只在 `manual` 档、且地址解析不出来时:`kind = InvalidRequest`,`message` 带上那个原串
|
||||||
|
/// (用户要能照着改)+ reqwest 的解析原因。**不 panic、不静默改成别的档**——回落由装配点决定,
|
||||||
|
/// 见模块头「非法地址不得穿透」。
|
||||||
|
pub fn apply(&self, builder: ClientBuilder) -> Result<ClientBuilder, DownloadError> {
|
||||||
|
match self {
|
||||||
|
Self::Direct => Ok(builder.no_proxy()),
|
||||||
|
// 故意的空操作:reqwest 的默认行为就是「有显式代理用显式的,没有就读环境变量」,
|
||||||
|
// 这一档要的正是后者。写成 `.proxy(Proxy::system())` 会更像「显式」,但 reqwest 0.12
|
||||||
|
// 没有这个公开构造函数(`system` 只在 `auto_sys_proxy` 为真时内部生效)。
|
||||||
|
Self::System => Ok(builder),
|
||||||
|
Self::Manual { url } => {
|
||||||
|
let proxy = reqwest::Proxy::all(url).map_err(|err| {
|
||||||
|
DownloadError::new(
|
||||||
|
DownloadErrorKind::InvalidRequest,
|
||||||
|
format!("代理地址无法解析({url}):{err}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(builder.proxy(proxy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::config::{DownloadTuning, EngineConfig};
|
||||||
|
use super::super::error::DownloadErrorKind;
|
||||||
|
use super::ProxySetting;
|
||||||
|
|
||||||
|
/// **两键 → 三档**的唯一翻译规则(表驱动,逐档一条)。
|
||||||
|
///
|
||||||
|
/// 抓得住:① `manual` / `system` 拼错或大小写敏感 ⇒ 对应档红;② 非 manual 档还在读 `proxy_url`
|
||||||
|
/// (`none` + 一个地址仍进 `Manual`)⇒ 第 2、3、4 条红;③ `manual` + 空地址落成 `Direct`
|
||||||
|
/// (而不是设置页承诺的「用系统代理」)⇒ 第 5、6 条红;④ 地址没 trim、模式没小写归一 ⇒ 第 8 条红。
|
||||||
|
/// 抓不住:这些档在真客户端上的行为(那是 `tests/download/engine_mock/proxy.rs` 的活)。
|
||||||
|
#[test]
|
||||||
|
fn the_two_keys_translate_to_the_three_modes() {
|
||||||
|
let cases: [(Option<&str>, Option<&str>, ProxySetting); 8] = [
|
||||||
|
// 键不存在 / 空串 ⇒ none(schema 出厂值)。
|
||||||
|
(None, None, ProxySetting::Direct),
|
||||||
|
(
|
||||||
|
Some(""),
|
||||||
|
Some("http://127.0.0.1:7890"),
|
||||||
|
ProxySetting::Direct,
|
||||||
|
),
|
||||||
|
// 显式 none:地址**忽略**(不进结构)。
|
||||||
|
(
|
||||||
|
Some("none"),
|
||||||
|
Some("http://127.0.0.1:7890"),
|
||||||
|
ProxySetting::Direct,
|
||||||
|
),
|
||||||
|
// system:地址同样忽略。
|
||||||
|
(
|
||||||
|
Some("system"),
|
||||||
|
Some("http://127.0.0.1:7890"),
|
||||||
|
ProxySetting::System,
|
||||||
|
),
|
||||||
|
// manual + 空地址 ⇒ 用系统代理(设置页那行文案的承诺)。
|
||||||
|
(Some("manual"), Some(" "), ProxySetting::System),
|
||||||
|
(Some("manual"), None, ProxySetting::System),
|
||||||
|
// 未知值(手改文件)⇒ 回落 none。
|
||||||
|
(Some("mars"), None, ProxySetting::Direct),
|
||||||
|
// 正常档:大小写/空白不敏感,地址去空白后原样保留。
|
||||||
|
(
|
||||||
|
Some(" MANUAL "),
|
||||||
|
Some(" http://127.0.0.1:7890 "),
|
||||||
|
ProxySetting::Manual {
|
||||||
|
url: "http://127.0.0.1:7890".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (mode, url, expected) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
ProxySetting::from_raw(mode, url),
|
||||||
|
expected,
|
||||||
|
"mode={mode:?} url={url:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// `as_str` 与 schema 的三个取值逐字相同(诊断行/日志里印的就是它)。
|
||||||
|
assert_eq!(ProxySetting::Direct.as_str(), "none");
|
||||||
|
assert_eq!(ProxySetting::System.as_str(), "system");
|
||||||
|
assert_eq!(
|
||||||
|
ProxySetting::Manual {
|
||||||
|
url: "http://p:1".to_string()
|
||||||
|
}
|
||||||
|
.as_str(),
|
||||||
|
"manual"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **非法地址 = 有界错误,不是 panic**(任务书第 4 条)。
|
||||||
|
///
|
||||||
|
/// 断言的是 `apply()` 的返回,而不是「调用方记得检查」:装配点 `build_client` 直接 `?` 它。
|
||||||
|
/// 抓得住:把 `Proxy::all(url)?` 写成 `expect` ⇒ 本用例 panic(红);把解析失败吞成 `Ok` ⇒
|
||||||
|
/// `Err` 那条红(用户会以为「用了代理」,实际谁也不知道用了什么)。
|
||||||
|
/// 抓不住:真机上这个错串好不好懂(那是文案评审的事)。
|
||||||
|
///
|
||||||
|
/// 地址取**端口越界**那个:`reqwest` 对「没有 scheme」的串会自作主张补 `http://`
|
||||||
|
/// (`Proxy::all("127.0.0.1:7890")` 是合法的、能解析),所以「解析不了」的样本必须挑
|
||||||
|
/// 补了前缀也救不回来的那种——端口 99999 超出 1–65535。
|
||||||
|
#[test]
|
||||||
|
fn a_broken_proxy_url_is_a_bounded_error_not_a_panic() {
|
||||||
|
let broken = ProxySetting::Manual {
|
||||||
|
url: "http://127.0.0.1:99999".to_string(),
|
||||||
|
};
|
||||||
|
let error = broken
|
||||||
|
.apply(reqwest::Client::builder())
|
||||||
|
.expect_err("解析不了的代理地址必须是有界错误");
|
||||||
|
assert_eq!(error.kind, DownloadErrorKind::InvalidRequest);
|
||||||
|
assert!(
|
||||||
|
error.message.contains("http://127.0.0.1:99999"),
|
||||||
|
"文案必须带上用户写的那串(否则他改不动):{}",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
error.message.contains("代理地址无法解析"),
|
||||||
|
"{}",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
|
||||||
|
// 另外两档正常:`none` 与 `system` 都不该因为「没有地址」而失败。
|
||||||
|
for ok in [ProxySetting::Direct, ProxySetting::System] {
|
||||||
|
assert!(
|
||||||
|
ok.apply(reqwest::Client::builder()).is_ok(),
|
||||||
|
"{ok:?} 不该失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **两个默认值故意不同**(模块头那段的守卫):没读设置的子系统保持 M295 之前的行为,
|
||||||
|
/// 读了设置的装配点与设置页的 `none` chip 对齐。
|
||||||
|
///
|
||||||
|
/// 抓得住:有人把 `EngineConfig::default().proxy` 一并改成 `Direct` ⇒ 第一条红(`auth` 与 JDK
|
||||||
|
/// 下载页会静默失去环境变量代理,那些路径在本仓读不到 `download.*`,改不回来);把它改成
|
||||||
|
/// `Manual` 之类 ⇒ 第一条也红。`DownloadTuning::default()` 改成 `System` ⇒ 第二条红
|
||||||
|
/// (设置页画着 `none`,引擎却在跟随环境变量——本任务要治的原病)。
|
||||||
|
#[test]
|
||||||
|
fn the_two_defaults_are_deliberately_different() {
|
||||||
|
assert_eq!(
|
||||||
|
EngineConfig::default().proxy,
|
||||||
|
ProxySetting::System,
|
||||||
|
"EngineConfig::default() 是「没读设置」的档:保持 M295 之前的跟随环境变量"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DownloadTuning::default().proxy,
|
||||||
|
ProxySetting::Direct,
|
||||||
|
"DownloadTuning::default() 是「读了设置、都是出厂值」的档:与 schema 的 none 对齐"
|
||||||
|
);
|
||||||
|
// 一行生效值里必须能看出是哪一档(`none` 与 `system` 行为相反,印成同一句话就是新谎)。
|
||||||
|
let line = DownloadTuning::default().summary();
|
||||||
|
assert!(line.contains("代理=none(直连)"), "{line}");
|
||||||
|
let manual = DownloadTuning {
|
||||||
|
proxy: ProxySetting::Manual {
|
||||||
|
url: "http://127.0.0.1:7890".to_string(),
|
||||||
|
},
|
||||||
|
..DownloadTuning::default()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
manual
|
||||||
|
.summary()
|
||||||
|
.contains("代理=manual(http://127.0.0.1:7890)"),
|
||||||
|
"{}",
|
||||||
|
manual.summary()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
//! **代理真的接上了吗**(M295):本地假代理 + 引擎的**生产装配路径**。
|
||||||
|
//!
|
||||||
|
//! 判据是「请求真的经由代理」而不是「配置对象里存了个地址」——后者正是这条债原本的形态
|
||||||
|
//! (`settings` 里存着 `manual` + 地址,`build_client` 从不 `.proxy()`,全绿)。
|
||||||
|
//!
|
||||||
|
//! 三档各一条:
|
||||||
|
//!
|
||||||
|
//! | 用例 | 环境 | 期望 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | [`manual_mode_really_sends_the_request_through_the_proxy`] | 无 | 目标域名**不可能解析**,仍拿到正文 ⇒ 只可能来自假代理;且代理记到的请求行是**绝对 URI** |
|
||||||
|
//! | [`direct_ignores_the_env_proxy_and_system_follows_it`] | `HTTP_PROXY` 指向假代理 | `system` 拿到正文(经由代理);**同一个环境**下 `direct` 拿不到(`.no_proxy()`),且代理一条新请求都没收到 |
|
||||||
|
//! | [`an_unparseable_proxy_url_falls_back_to_direct_and_says_why`] | 无 | 不 panic;`proxy_problem()` 有可读原因;配置字段仍是用户写的 `manual`(不撒谎) |
|
||||||
|
//!
|
||||||
|
//! 变异(只改实现侧,跑完即还原):
|
||||||
|
//! * 删掉 `ProxySetting::apply` 里的 `.proxy(proxy)` ⇒ 第 1 条红(其余两条绿);
|
||||||
|
//! * 删掉 `ProxySetting::apply` 里的 `.no_proxy()` ⇒ 第 2 条红(`direct` 也会经代理成功);
|
||||||
|
//! * 把 `ProxySetting::System` 改成 `.no_proxy()` ⇒ 第 2 条红(`system` 拿不到正文)。
|
||||||
|
//!
|
||||||
|
//! 抓不住:真机上的「系统代理」(Windows 注册表 / macOS 偏好设置,本仓 reqwest 没开
|
||||||
|
//! `client-proxy-system`,`system` 只认环境变量)、PAC 脚本、需要认证的代理、HTTPS 目标的
|
||||||
|
//! `CONNECT` 隧道(本夹具只讲明文 HTTP)。
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use unlucky_core::download::proxy::ProxySetting;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// 本文件的用例**串行**跑:`direct_ignores_the_env_proxy_and_system_follows_it` 会改进程环境变量
|
||||||
|
/// (全局状态),并发跑时邻座的客户端会跟着读到它——那会让失败原因变成「别人的环境」,而不是
|
||||||
|
/// 自己被变异的那一行。三个用例都先拿这把锁(同 `core::update` 的环境变量用例手法)。
|
||||||
|
static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
|
||||||
|
/// 用**异步**互斥锁而不是 `std::sync::Mutex`:三个用例都是 `#[tokio::test]`,持着标准锁跨越
|
||||||
|
/// `await` 会被 `clippy::await_holding_lock` 拦下(本仓 clippy 是 `-D warnings`)。
|
||||||
|
async fn serial() -> tokio::sync::MutexGuard<'static, ()> {
|
||||||
|
ENV_LOCK.lock().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 假代理:只回一份固定正文,并把每条请求的**第一行**记下来。
|
||||||
|
///
|
||||||
|
/// 为什么记请求行就够:明文 HTTP 走代理时请求行是**绝对 URI**(`GET http://host/path HTTP/1.1`),
|
||||||
|
/// 直连永远只有 `/path`。所以「记到绝对 URI」等价于「这条请求是代理发出的」。
|
||||||
|
struct FakeProxy {
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
lines: Arc<Mutex<Vec<String>>>,
|
||||||
|
handle: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeProxy {
|
||||||
|
async fn start(body: &'static [u8]) -> Self {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("绑定假代理端口");
|
||||||
|
let addr = listener.local_addr().expect("取假代理地址");
|
||||||
|
let lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let seen = Arc::clone(&lines);
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((mut socket, _)) = listener.accept().await else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let seen = Arc::clone(&seen);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut head = Vec::new();
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
match socket.read(&mut chunk).await {
|
||||||
|
Ok(0) | Err(_) => return,
|
||||||
|
Ok(read) => head.extend_from_slice(&chunk[..read]),
|
||||||
|
}
|
||||||
|
if head.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&head);
|
||||||
|
let first = text.lines().next().unwrap_or_default().to_string();
|
||||||
|
seen.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.push(first);
|
||||||
|
let header = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = socket.write_all(header.as_bytes()).await;
|
||||||
|
let _ = socket.write_all(body).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
addr,
|
||||||
|
lines,
|
||||||
|
handle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self) -> String {
|
||||||
|
format!("http://{}", self.addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_lines(&self) -> Vec<String> {
|
||||||
|
self.lines
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for FakeProxy {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一个引擎 + 一份缓存根(与 `engine_mock_test.rs` 的 `harness` 同一形状,只多了一个代理档)。
|
||||||
|
fn engine_with_proxy(proxy: ProxySetting, tag: &str) -> Harness {
|
||||||
|
harness(
|
||||||
|
EngineConfig {
|
||||||
|
proxy,
|
||||||
|
..fast_config(1)
|
||||||
|
},
|
||||||
|
MirrorTable::builtin(),
|
||||||
|
direct_region(),
|
||||||
|
tag,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 目标 URL 用 RFC 2606 的保留域名 `.invalid`:**不可能解析**,所以「拿到正文」只可能是因为
|
||||||
|
/// 请求经由假代理(DNS 不会骗人,除非真走了代理)。
|
||||||
|
const TARGET: &str = "http://proxywire.invalid/meta.json";
|
||||||
|
const BODY: &[u8] = b"proxied payload";
|
||||||
|
|
||||||
|
fn request_for(harness: &Harness) -> DownloadRequest {
|
||||||
|
DownloadRequest::new(ResourceKind::Other, TARGET, harness.dir.join("meta.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **manual 档真的走代理**(任务书的头号判据)。
|
||||||
|
///
|
||||||
|
/// 变异:删掉 `ProxySetting::apply` 里那一行 `.proxy(proxy)` ⇒ 目标域名解析失败,`expect` 当场红。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn manual_mode_really_sends_the_request_through_the_proxy() {
|
||||||
|
let _serial = serial().await;
|
||||||
|
let proxy = FakeProxy::start(BODY).await;
|
||||||
|
let harness = engine_with_proxy(
|
||||||
|
ProxySetting::Manual { url: proxy.url() },
|
||||||
|
"engine-proxy-manual",
|
||||||
|
);
|
||||||
|
|
||||||
|
let bytes = harness
|
||||||
|
.engine
|
||||||
|
.download_bytes(request_for(&harness))
|
||||||
|
.await
|
||||||
|
.expect("manual 档必须能经由代理拿到正文(否则 .proxy() 那行没生效)");
|
||||||
|
assert_eq!(bytes, BODY, "拿到的必须是假代理回的那份字节");
|
||||||
|
|
||||||
|
let lines = proxy.request_lines();
|
||||||
|
assert_eq!(lines.len(), 1, "应该恰好一次经由代理的请求:{lines:?}");
|
||||||
|
assert!(
|
||||||
|
lines[0].starts_with("GET http://proxywire.invalid/meta.json"),
|
||||||
|
"走代理的请求行必须是**绝对 URI**(这是「经由代理」的直接证据):{lines:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
harness.engine.proxy_problem().is_none(),
|
||||||
|
"地址合法时不该有代理问题:{:?}",
|
||||||
|
harness.engine.proxy_problem()
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&harness.dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **direct 不读环境变量、system 跟随环境变量**(同一份环境、同一个目标,只有设置不同)。
|
||||||
|
///
|
||||||
|
/// 这里动的是**进程环境变量**(全局状态),所以用一把进程内锁串起来、退出时还原原值;同时把
|
||||||
|
/// `NO_PROXY` 钉成回环,让同进程里其它打回环 HTTP 的用例(`engine_mock` 的兄弟文件)绕过假代理,
|
||||||
|
/// 不被这一条污染。
|
||||||
|
///
|
||||||
|
/// 变异:`Direct` 少了 `.no_proxy()` ⇒ 第一段红(`.invalid` 会经由假代理「成功」,代理多记一行);
|
||||||
|
/// `System` 被写成 `.no_proxy()` ⇒ 第二段红(拿不到正文)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn direct_ignores_the_env_proxy_and_system_follows_it() {
|
||||||
|
let _serial = serial().await;
|
||||||
|
|
||||||
|
/// 记住两个变量的原值,`Drop` 时还原(用例 panic 也还原)。
|
||||||
|
struct Restore(Option<std::ffi::OsString>, Option<std::ffi::OsString>);
|
||||||
|
impl Drop for Restore {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
for (key, value) in [("HTTP_PROXY", self.0.clone()), ("NO_PROXY", self.1.clone())] {
|
||||||
|
match value {
|
||||||
|
Some(value) => std::env::set_var(key, value),
|
||||||
|
None => std::env::remove_var(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxy = FakeProxy::start(BODY).await;
|
||||||
|
let _restore = Restore(std::env::var_os("HTTP_PROXY"), std::env::var_os("NO_PROXY"));
|
||||||
|
std::env::set_var("HTTP_PROXY", proxy.url());
|
||||||
|
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
|
||||||
|
|
||||||
|
// ① system:跟随环境变量里的 HTTP_PROXY ⇒ 经由假代理拿到正文。
|
||||||
|
let system = engine_with_proxy(ProxySetting::System, "engine-proxy-system");
|
||||||
|
let bytes = system
|
||||||
|
.engine
|
||||||
|
.download_bytes(request_for(&system))
|
||||||
|
.await
|
||||||
|
.expect("system 档必须跟随环境变量里的代理");
|
||||||
|
assert_eq!(bytes, BODY);
|
||||||
|
let lines = proxy.request_lines();
|
||||||
|
assert_eq!(lines.len(), 1, "system 档应该经由代理一次:{lines:?}");
|
||||||
|
std::fs::remove_dir_all(&system.dir).ok();
|
||||||
|
|
||||||
|
// ② direct:**同一个环境变量、同一个目标** ⇒ 一个字节都不许经代理。
|
||||||
|
let direct = engine_with_proxy(ProxySetting::Direct, "engine-proxy-direct");
|
||||||
|
let error = direct
|
||||||
|
.engine
|
||||||
|
.download_bytes(request_for(&direct))
|
||||||
|
.await
|
||||||
|
.expect_err("direct 档不许经由环境变量里的代理拿到 .invalid 的正文");
|
||||||
|
assert_eq!(
|
||||||
|
proxy.request_lines().len(),
|
||||||
|
lines.len(),
|
||||||
|
"direct 档一条请求都不许发给代理(`.no_proxy()` 没生效):{:?}",
|
||||||
|
proxy.request_lines()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error.kind,
|
||||||
|
DownloadErrorKind::AllSourcesFailed,
|
||||||
|
"回落成「这个域名连不上」的有界失败,不是别的:{}",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&direct.dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **非法地址不得穿透**(任务书第 4 条):不 panic、有可读原因、配置不撒谎、回落是直连。
|
||||||
|
///
|
||||||
|
/// 变异:把 `build_client` 里 `cfg.proxy.apply(builder)?` 的 `?` 改成 `expect` ⇒ 本用例 panic(红);
|
||||||
|
/// 把引擎的回落改成 `reqwest::Client::new()`(跟随环境变量)⇒ `proxy_problem()` 仍在但回落不确定
|
||||||
|
/// (本机没有代理时这条断言照样绿——所以「回落是直连」由 `direct` 用例的 `.no_proxy()` 那半边兜,
|
||||||
|
/// 这里只说清「配置没被偷偷改掉」这一条)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unparseable_proxy_url_falls_back_to_direct_and_says_why() {
|
||||||
|
let _serial = serial().await;
|
||||||
|
let broken = ProxySetting::Manual {
|
||||||
|
url: "http://127.0.0.1:99999".to_string(),
|
||||||
|
};
|
||||||
|
let harness = engine_with_proxy(broken.clone(), "engine-proxy-broken");
|
||||||
|
|
||||||
|
let problem = harness
|
||||||
|
.engine
|
||||||
|
.proxy_problem()
|
||||||
|
.expect("地址解析不了必须留下原因(宿主靠它告诉用户)");
|
||||||
|
assert!(problem.contains("http://127.0.0.1:99999"), "{problem}");
|
||||||
|
assert!(problem.contains("代理地址无法解析"), "{problem}");
|
||||||
|
assert_eq!(
|
||||||
|
harness.engine.config().proxy,
|
||||||
|
broken,
|
||||||
|
"配置字段仍是用户写的 manual(回落是运行时行为,不是偷偷改配置)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 回落直连 ⇒ 请求打到一个不可能解析的域名上是有界失败,既不是 panic 也不是「用了某个代理」。
|
||||||
|
let error = harness
|
||||||
|
.engine
|
||||||
|
.download_bytes(request_for(&harness))
|
||||||
|
.await
|
||||||
|
.expect_err("直连打 .invalid 必须失败");
|
||||||
|
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
|
||||||
|
std::fs::remove_dir_all(&harness.dir).ok();
|
||||||
|
}
|
||||||
@@ -372,6 +372,10 @@ mod timeout;
|
|||||||
#[path = "engine_mock/bytes_timeout.rs"]
|
#[path = "engine_mock/bytes_timeout.rs"]
|
||||||
mod bytes_timeout;
|
mod bytes_timeout;
|
||||||
|
|
||||||
|
/// 代理真的接上(M295):本地假代理 + 生产装配路径,判据是「请求真的经由它」。
|
||||||
|
#[path = "engine_mock/proxy.rs"]
|
||||||
|
mod proxy;
|
||||||
|
|
||||||
/// 上面两条共用的夹具(引擎构造 / 外层保险 / 静默脚本)。
|
/// 上面两条共用的夹具(引擎构造 / 外层保险 / 静默脚本)。
|
||||||
#[path = "engine_mock/harness.rs"]
|
#[path = "engine_mock/harness.rs"]
|
||||||
mod harness;
|
mod harness;
|
||||||
@@ -18,6 +18,8 @@
|
|||||||
//! 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.proxy_mode ───────────┤ M295:代理两键进 RawTuning,
|
||||||
|
//! download.proxy_url ────────────┤ 三档翻译在 core 的 ProxySetting
|
||||||
//! download.region ───────────────┤ M294:两个枚举键,走
|
//! download.region ───────────────┤ M294:两个枚举键,走
|
||||||
//! download.mirror_preference ────┘ `with_source_selection`(回落 = auto)
|
//! download.mirror_preference ────┘ `with_source_selection`(回落 = auto)
|
||||||
//! ```
|
//! ```
|
||||||
@@ -134,7 +136,7 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
|
|||||||
.with(SettingRowView::choice(
|
.with(SettingRowView::choice(
|
||||||
"download.proxy_mode",
|
"download.proxy_mode",
|
||||||
"代理模式",
|
"代理模式",
|
||||||
"none / system(跟随系统)/ manual(下面填地址)",
|
"none = 不走代理(连环境变量里的 HTTP_PROXY 也不读)/ system = 跟随系统环境变量代理 / manual = 用下面填的地址",
|
||||||
&proxies,
|
&proxies,
|
||||||
selected(
|
selected(
|
||||||
&proxies,
|
&proxies,
|
||||||
@@ -145,10 +147,12 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
// M295:这一行只在 manual 档生效;别的档下地址被**忽略**(值仍留在文件里),地址解析不了
|
||||||
|
// 时引擎回落直连并说明原因——三件事都写进文案,否则「填了没生效」又得靠用户猜。
|
||||||
.with(SettingRowView::text(
|
.with(SettingRowView::text(
|
||||||
"download.proxy_url",
|
"download.proxy_url",
|
||||||
"HTTP 代理",
|
"HTTP 代理",
|
||||||
"manual 模式生效;留空则用系统代理",
|
"只在 manual 模式生效(其它模式忽略这段地址);manual 留空则用系统代理;地址写法不对时按直连处理,原值保留",
|
||||||
&text(env, "download.proxy_url"),
|
&text(env, "download.proxy_url"),
|
||||||
))
|
))
|
||||||
// M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把
|
// M193:gh-proxy 三态。**留空与 off 是两件事**,描述里必须都写出来——用户最容易把
|
||||||
@@ -256,11 +260,16 @@ fn gh_proxy_state(state: &GhProxyOverride) -> String {
|
|||||||
/// M294:另加两个「源选择」键(`download.region` / `download.mirror_preference`),它们是**枚举**
|
/// M294:另加两个「源选择」键(`download.region` / `download.mirror_preference`),它们是**枚举**
|
||||||
/// 而不是数字,走 [`DownloadTuning::with_source_selection`](同一个翻译层,回落 = `auto`)。
|
/// 而不是数字,走 [`DownloadTuning::with_source_selection`](同一个翻译层,回落 = `auto`)。
|
||||||
/// 少了这一步,那两行 chip 就只是画着好看——本轮之前正是如此。
|
/// 少了这一步,那两行 chip 就只是画着好看——本轮之前正是如此。
|
||||||
|
///
|
||||||
|
/// M295:代理两键(`download.proxy_mode` / `download.proxy_url`)进 [`RawTuning`](它们是字符串
|
||||||
|
/// 原文 + 一个开关,与 `gh_proxy` 同形),三档翻译与地址校验都在 core 的
|
||||||
|
/// `DownloadTuning::from_raw` / `ProxySetting`,**本函数不解释任何一个字**。
|
||||||
#[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_MIRROR_PREFERENCE,
|
RawTuning, KEY_CHUNK_STALL, KEY_CONCURRENCY, KEY_GH_PROXY, KEY_MIRROR_PREFERENCE,
|
||||||
KEY_REGION, KEY_RESPONSE_TIMEOUT, KEY_RETRY_LIMIT, KEY_THROUGHPUT_FLOOR,
|
KEY_PROXY_MODE, KEY_PROXY_URL, 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();
|
||||||
@@ -275,6 +284,8 @@ pub(crate) fn tuning_of(env: &HostEnv) -> DownloadTuning {
|
|||||||
throughput_floor_kib: doc.effective_i64(KEY_THROUGHPUT_FLOOR),
|
throughput_floor_kib: doc.effective_i64(KEY_THROUGHPUT_FLOOR),
|
||||||
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),
|
||||||
|
proxy_mode: doc.effective_str(KEY_PROXY_MODE),
|
||||||
|
proxy_url: doc.effective_str(KEY_PROXY_URL),
|
||||||
})
|
})
|
||||||
.with_source_selection(
|
.with_source_selection(
|
||||||
doc.effective_str(KEY_REGION),
|
doc.effective_str(KEY_REGION),
|
||||||
|
|||||||
@@ -383,16 +383,5 @@ fn the_three_timeout_keys_travel_from_the_file_to_the_engine_config() {
|
|||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **M294**:两个「源选择」键真的进了 `DownloadTuning`(删掉 `with_source_selection` ⇒ 两断言红)。
|
#[path = "download_tests/selection_and_proxy.rs"]
|
||||||
#[test]
|
mod selection_and_proxy;
|
||||||
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();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! **最近两轮接进来的两个键族**的守门:源选择(M294)与代理(M295)。
|
||||||
|
//!
|
||||||
|
//! 从 `download_tests.rs` 搬出只为守 400 行硬线(与 `support/server.rs` 拆出同一理由):那条文件
|
||||||
|
//! 里这一族是最后两段,搬过来正文一字未改,只是多了一句「搬家原因」。
|
||||||
|
//!
|
||||||
|
//! ## 代理两键(M295):`download.proxy_mode` / `download.proxy_url`
|
||||||
|
//!
|
||||||
|
//! 从文件走到**引擎配置**,走的是 GUI 的生产装配点 `host::engine::engine`(内部 `apply_tuning` →
|
||||||
|
//! `shared::set_tuning` → 按数据根重建)。
|
||||||
|
//!
|
||||||
|
//! 抓得住:① `tuning_of` 少读一个键(`proxy_mode` 或 `proxy_url`)⇒ 第一、三条红;② 非 manual 档
|
||||||
|
//! 也把地址塞进 `Manual` ⇒ 第一条红;③ `manual` + 空地址落成 `Direct`(设置页承诺的是「用系统代理」)
|
||||||
|
//! ⇒ 第四条红;④ 非法地址让引擎构造 panic ⇒ 最后一段红;⑤ 两行文案与三档语义脱钩 ⇒ 文案断言红。
|
||||||
|
//!
|
||||||
|
//! 抓不住:请求是不是**真的**经由代理(那是 core 的 `tests/download/engine_mock/proxy.rs`:本地假
|
||||||
|
//! 代理 + `.invalid` 目标 + 绝对 URI 请求行);也抓不住发布版 GUI 的「下载 JDK」页——那条路径仍用
|
||||||
|
//! `EngineConfig::default()`(见 `docs/download-settings.md` §3.1.1 的 ❌ 一行)。
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use unlucky_core::download::proxy::ProxySetting;
|
||||||
|
|
||||||
|
/// 写文件 → 走生产装配点 → 返回「引擎拿到的代理档 + 引擎自报的问题」。
|
||||||
|
fn applied(tag: &str, document: serde_json::Value) -> (ProxySetting, Option<String>) {
|
||||||
|
let (dir, env) = env_with(tag, document);
|
||||||
|
std::fs::create_dir_all(env.layout.cache()).expect("cache 目录");
|
||||||
|
let engine = crate::host::engine::engine(&env);
|
||||||
|
let seen = (
|
||||||
|
engine.config().proxy.clone(),
|
||||||
|
engine.proxy_problem().map(str::to_string),
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
seen
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_proxy_keys_travel_from_the_file_to_the_engine_config() {
|
||||||
|
use unlucky_core::download::config::{EngineConfig, KEY_PROXY_MODE, KEY_PROXY_URL};
|
||||||
|
|
||||||
|
// ① none(也是 schema 出厂值):直连;**非 manual 档的地址被忽略**。
|
||||||
|
assert_eq!(
|
||||||
|
applied(
|
||||||
|
"proxy-none",
|
||||||
|
serde_json::json!({ "download": { "proxy_mode": "none", "proxy_url": "http://127.0.0.1:7890" } })
|
||||||
|
),
|
||||||
|
(ProxySetting::Direct, None),
|
||||||
|
"none 档必须是真直连,且地址不参与"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ② system:跟随环境变量(同样是「不调代理 API」那一档,地址仍被忽略)。
|
||||||
|
assert_eq!(
|
||||||
|
applied(
|
||||||
|
"proxy-system",
|
||||||
|
serde_json::json!({ "download": { "proxy_mode": "system", "proxy_url": "http://127.0.0.1:7890" } })
|
||||||
|
),
|
||||||
|
(ProxySetting::System, None)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ③ manual:地址进结构(去空白)。
|
||||||
|
assert_eq!(
|
||||||
|
applied(
|
||||||
|
"proxy-manual",
|
||||||
|
serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": " http://127.0.0.1:7890 " } })
|
||||||
|
),
|
||||||
|
(
|
||||||
|
ProxySetting::Manual {
|
||||||
|
url: "http://127.0.0.1:7890".to_string()
|
||||||
|
},
|
||||||
|
None
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ④ manual + 空地址 ⇒ 设置页那行文案承诺的「用系统代理」。
|
||||||
|
assert_eq!(
|
||||||
|
applied(
|
||||||
|
"proxy-manual-empty",
|
||||||
|
serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": "" } })
|
||||||
|
),
|
||||||
|
(ProxySetting::System, None)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ⑤ 非法地址:不 panic、有可读原因、配置不撒谎(仍是用户写的 manual)。
|
||||||
|
let (proxy, problem) = applied(
|
||||||
|
"proxy-broken",
|
||||||
|
serde_json::json!({ "download": { "proxy_mode": "manual", "proxy_url": "http://127.0.0.1:99999" } }),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
proxy,
|
||||||
|
ProxySetting::Manual {
|
||||||
|
url: "http://127.0.0.1:99999".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let problem = problem.expect("解析不了必须留下原因(否则用户只会觉得「填了没用」)");
|
||||||
|
assert!(problem.contains("http://127.0.0.1:99999"), "{problem}");
|
||||||
|
|
||||||
|
// 两行文案必须与新语义一致(否则界面又回到「填了不知道算不算数」)。
|
||||||
|
let (dir, env) = env_with("proxy-rows", serde_json::json!({}));
|
||||||
|
let group_view = group(&env);
|
||||||
|
let mode_row = row(&group_view, KEY_PROXY_MODE);
|
||||||
|
assert!(
|
||||||
|
mode_row
|
||||||
|
.description
|
||||||
|
.contains("system = 跟随系统环境变量代理")
|
||||||
|
&& mode_row.description.contains("manual"),
|
||||||
|
"代理模式那行必须把三档说清:{}",
|
||||||
|
mode_row.description
|
||||||
|
);
|
||||||
|
let url_row = row(&group_view, KEY_PROXY_URL);
|
||||||
|
assert!(
|
||||||
|
url_row.description.contains("只在 manual 模式生效"),
|
||||||
|
"地址那行必须说清它在别的档被忽略:{}",
|
||||||
|
url_row.description
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
EngineConfig::default().proxy,
|
||||||
|
ProxySetting::System,
|
||||||
|
"「没读设置」的档仍是跟随环境变量(auth / JDK 页走它),与本文件的三档翻译是两件事"
|
||||||
|
);
|
||||||
|
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();
|
||||||
|
}
|
||||||
+97
-25
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 真的进引擎**;
|
一句话:**`download.concurrency` / `download.retry_limit` / `download.gh_proxy` 真的进引擎**;
|
||||||
`download.region` / `download.mirror_preference` 自 **M294** 起也真的进引擎(GUI 主路径、发布版 JDK
|
`download.region` / `download.mirror_preference` 自 **M294** 起也真的进引擎(GUI 主路径、发布版 JDK
|
||||||
下载页、CLI 四条命令都认,见 §5);只剩 `proxy_mode` / `proxy_url` 仍是**引擎侧零消费者**——
|
下载页、CLI 四条命令都认,见 §5);`download.proxy_mode` / `download.proxy_url` 自 **M295** 起接进
|
||||||
这一页把它们逐条标出来,别再让「设置项说假话」。
|
HTTP 客户端的代理三态(GUI 主路径、CLI 四条命令都认,**发布版 GUI 的「下载 JDK」页仍不认**,见
|
||||||
|
§3.1.1)。还剩的缺口只有 §3.1.1 表里那几个 ❌——这一页把八个下载类键逐条标出来,别再让
|
||||||
|
「设置项说假话」。
|
||||||
|
|
||||||
出处:M190 的 204 条 finding 复核(`DevDocs/PlanDoc/research/191-finding-triage.md` §3 A-3)挖出的
|
出处:M190 的 204 条 finding 复核(`DevDocs/PlanDoc/research/191-finding-triage.md` §3 A-3)挖出的
|
||||||
「用户能改、改了没用」缺口:schema 说并发 8,引擎恒跑 64,两处各写一个数。
|
「用户能改、改了没用」缺口:schema 说并发 8,引擎恒跑 64,两处各写一个数。
|
||||||
@@ -19,8 +21,8 @@
|
|||||||
| `download.gh_proxy` | 空串 / ≤256 字符(M197 登记) | 共享引擎装配点(`shared::build` 的 gh-proxy 候选链)+ CLI 的 `download::tuning` | 地区档案里的内置值(镜像优先时 `gh-proxy.com`) | ✅ 下沉到共享装配点,GUI 与四条 CLI 命令一致 |
|
| `download.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.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.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 | **M295 起有**:`DownloadTuning::from_raw` → `ProxySetting`,唯一消费点是 `http::build_client` | 只走环境变量代理(reqwest 默认):`none` / `manual` 都不生效,`system` 只是「碰巧」 | ✅ 接进引擎(M295):`none` = `.no_proxy()`、`system` = 跟随环境变量、`manual` = `Proxy::all(url)` |
|
||||||
| `download.proxy_url` | "" | **无** | 空(从不使用) | ❌ 未接(见 §5) |
|
| `download.proxy_url` | "" | 同上(**只在 manual 档被读**) | 空(从不使用) | ✅ 接进引擎(M295);非 manual 档**忽略**(值原样留在文件里) |
|
||||||
| `download.action.detect_region` | (动作键,不是设置) | presenter 的 `detect_region` | 清 `region.json` + 失效共享引擎 | ✅ 本来就真 |
|
| `download.action.detect_region` | (动作键,不是设置) | presenter 的 `detect_region` | 清 `region.json` + 失效共享引擎 | ✅ 本来就真 |
|
||||||
|
|
||||||
复现方法(改造前):`grep -rn "download.concurrency" crates/` 只命中 `setting/keys.rs` 的定义与
|
复现方法(改造前):`grep -rn "download.concurrency" crates/` 只命中 `setting/keys.rs` 的定义与
|
||||||
@@ -50,7 +52,9 @@ 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_raw(默认值/夹取/回落都在 core)
|
||||||
|
download.proxy_mode ──┤ │ ← M295:两键进 RawTuning,三档翻译在 ProxySetting
|
||||||
|
download.proxy_url ───┤ │
|
||||||
download.region ──────┤ │
|
download.region ──────┤ │
|
||||||
download.mirror_preference ─┐ │ ← M294:with_source_selection(GUI/CLI/JDK 页共用的唯一入口)
|
download.mirror_preference ─┐ │ ← M294:with_source_selection(GUI/CLI/JDK 页共用的唯一入口)
|
||||||
↓
|
↓
|
||||||
@@ -58,10 +62,11 @@ config/launcher.json
|
|||||||
↓ 变了 → 淘汰该根的登记项
|
↓ 变了 → 淘汰该根的登记项
|
||||||
shared::engine_for(<cache 根>) → shared::build
|
shared::engine_for(<cache 根>) → shared::build
|
||||||
↓
|
↓
|
||||||
EngineConfig { max_concurrency, max_attempts_per_source }
|
EngineConfig { max_concurrency, max_attempts_per_source, proxy }
|
||||||
+ RegionProfile { mode(源选择叠出来的), gh_proxy }
|
+ RegionProfile { mode(源选择叠出来的), gh_proxy }
|
||||||
↓
|
↓
|
||||||
Semaphore(容量) / attempts_for() / MirrorTable::expand() 的候选顺序 + gh-proxy 候选
|
Semaphore(容量) / attempts_for() / MirrorTable::expand() 的候选顺序 + gh-proxy 候选
|
||||||
|
/ http::build_client 的 .no_proxy() | 环境变量 | .proxy(Proxy::all(url))
|
||||||
```
|
```
|
||||||
|
|
||||||
**同步点是显式的**,两处,共用同一个函数 `apply_tuning`:
|
**同步点是显式的**,两处,共用同一个函数 `apply_tuning`:
|
||||||
@@ -90,17 +95,21 @@ config/launcher.json
|
|||||||
`shared::engine_with_tuning(<cache 根>, tuning)`。所以 `presenter` 的 `shared::build` 不再是
|
`shared::engine_with_tuning(<cache 根>, tuning)`。所以 `presenter` 的 `shared::build` 不再是
|
||||||
**全仓唯一**的装配点,但装配规则仍只有 core 那一份。
|
**全仓唯一**的装配点,但装配规则仍只有 core 那一份。
|
||||||
|
|
||||||
### 3.1.1 「源选择」两键(`region` / `mirror_preference`)的覆盖面(M294)
|
### 3.1.1 「源选择」两键(M294)与代理两键(M295)的覆盖面
|
||||||
|
|
||||||
| 路径 | 吃吗 | 装配点 |
|
| 键族 | 路径 | 吃吗 | 装配点 |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| GUI 主路径(内容库、版本清单、资源、字体、自更新…) | ✅ | `shared::engine_for` → `shared::build`(`tune.apply_source_selection`) |
|
| `region` / `mirror_preference` | GUI 主路径(内容库、版本清单、资源、字体、自更新…) | ✅ | `shared::engine_for` → `shared::build`(`tune.apply_source_selection`) |
|
||||||
| 发布版 GUI 的「下载 JDK」页 | ✅(M294 r1 补) | `app-iced` 的 `pages/settings/java/downloads/engine.rs::engine_with`(独立装配点:它要 `with_progress` 拿走引擎) |
|
| | 发布版 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`) |
|
| | CLI `ull install` / `java` / `pack` / `update` | ✅(M294 r1 补) | 四条共用 `cli/src/download.rs::tuning`(`.with_source_selection`) |
|
||||||
| `proxy_mode` / `proxy_url` | ❌ | 仍是零消费者,见 §5 |
|
| `proxy_mode` / `proxy_url` | GUI 主路径 | ✅(M295) | `shared::build`:`tune.apply_to(EngineConfig::default())` 把 `proxy` 盖上去 → `http::build_client` |
|
||||||
|
| | 发布版 GUI 的「下载 JDK」页 | ❌(M295 未接) | 那一处直接用 `EngineConfig::default()`(= `System`,跟随环境变量代理),**不读**这两个键 |
|
||||||
|
| | CLI `ull install` / `java` / `pack` / `update` | ✅(M295) | 同一个 `cli/src/download.rs::tuning`(进 `RawTuning`,宿主零字符串匹配) |
|
||||||
|
|
||||||
三个宿主读的是**同一个**翻译层 `DownloadTuning::with_source_selection`(`auto` 回落、优先级、
|
「源选择」两个键的翻译层是 `DownloadTuning::with_source_selection`(`auto` 回落、优先级、未知值
|
||||||
未知值怎么退都只写在 core 一处);宿主只负责「把 `launcher.json` 里那两个键读出来并显式传下去」。
|
怎么退都只写在 core 一处);代理两键的翻译层是 `ProxySetting::from_raw`(三档 + 那两句「非 manual
|
||||||
|
忽略地址」「manual 留空 = 系统代理」,同样只写在 core 一处)。两边都是:**宿主只负责把
|
||||||
|
`launcher.json` 里的键读出来并显式传下去**。
|
||||||
|
|
||||||
|
|
||||||
回落仍是静默的:`SettingStore::load` 永不失败(坏值只进 `problems()`),类型不对 → `None`,越界 → 出厂
|
回落仍是静默的:`SettingStore::load` 永不失败(坏值只进 `problems()`),类型不对 → `None`,越界 → 出厂
|
||||||
@@ -127,7 +136,7 @@ config/launcher.json
|
|||||||
这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现
|
这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现
|
||||||
「界面显示 0、引擎按 3 跑」。
|
「界面显示 0、引擎按 3 跑」。
|
||||||
|
|
||||||
## 5. 源选择两键已接(M294);只剩代理两键没接
|
## 5. 源选择两键(M294)与代理两键(M295)都已接
|
||||||
|
|
||||||
### 5.1 已接:`download.region` / `download.mirror_preference`(M294)
|
### 5.1 已接:`download.region` / `download.mirror_preference`(M294)
|
||||||
|
|
||||||
@@ -152,18 +161,52 @@ config/launcher.json
|
|||||||
* gh-proxy 三态在源选择**之后**叠(`apply_source_selection` 的次序),所以
|
* gh-proxy 三态在源选择**之后**叠(`apply_source_selection` 的次序),所以
|
||||||
「`region=cn` + `gh_proxy=off`」⇒ 一个前缀都不生成(守卫:`an_explicit_gh_proxy_off_wins_over_a_mirror_first_region`)。
|
「`region=cn` + `gh_proxy=off`」⇒ 一个前缀都不生成(守卫:`an_explicit_gh_proxy_off_wins_over_a_mirror_first_region`)。
|
||||||
|
|
||||||
### 5.2 未接:`download.proxy_mode` / `download.proxy_url`
|
### 5.2 已接:`download.proxy_mode` / `download.proxy_url`(M295)
|
||||||
|
|
||||||
**引擎侧零消费者**:`http::build_client` 只有 UA / 连接超时 / 重定向 / 默认头,**从不调 `.proxy()`**。
|
**M295 之前引擎侧零消费者**:`http::build_client` 只有 UA / 连接超时 / 重定向 / 默认头,**从不调
|
||||||
于是 `manual`(指定地址)与 `none`(强制直连)都不生效;`system` 那一档「碰巧能用」只是因为
|
`.proxy()`**。于是 `manual`(指定地址)与 `none`(强制直连)都不生效;`system` 那一档「碰巧能用」
|
||||||
reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是这段代码做的。要接需要:
|
只是因为 reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是那段代码做的。
|
||||||
|
|
||||||
* `EngineConfig` 加代理三态 → `http::build_client` 里 `.proxy(reqwest::Proxy::all(url)?)` / `.no_proxy()`;
|
现在两个键翻译成 `EngineConfig.proxy`(`core::download::proxy::ProxySetting`),唯一消费点是
|
||||||
* 探测路径(`region::probe_signals`)与自更新要不要走同一个代理,是**另一个决定**,得先定。
|
`http::build_client`:
|
||||||
|
|
||||||
已开 finding:`download.proxy_mode` / `download.proxy_url` 零消费者(代理问的是「怎么连」,
|
| `proxy_mode` | `ProxySetting` | 落到 client 上 | 含义 |
|
||||||
与「连哪里」不是同一个决定面,混在一个 diff 里审不动)。`setting/keys/proxy.rs` 的模块头那句
|
|---|---|---|---|
|
||||||
「`download.proxy_mode` → 引擎的 HTTP 客户端(`http::build_client`)」**目前是假的**,接线时一并改。
|
| `none`(schema 出厂值,键不存在也走这档) | `Direct` | `.no_proxy()` | **真直连**:连进程环境里的 `HTTP_PROXY` 也不读 |
|
||||||
|
| `system` | `System` | **不调任何代理 API** | 跟随进程环境变量(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`)= M295 之前所有下载的实际行为 |
|
||||||
|
| `manual` | `Manual { url }` | `.proxy(reqwest::Proxy::all(url)?)` | 整条流量走 `proxy_url`(http 与 https 目标都经它;`Proxy::all` 顺手关掉「自动读系统代理」) |
|
||||||
|
|
||||||
|
三条决定写在这里,省得下一个人再猜:
|
||||||
|
|
||||||
|
* **`proxy_url` 在非 manual 模式下被忽略**:`none` / `system` 下它既不校验、也不进 `ProxySetting`,
|
||||||
|
值原样留在 `launcher.json` 里(切回 `manual` 立刻生效)。模式是唯一开关,不存在「填了地址、模式
|
||||||
|
没选对、半生效」的中间态。
|
||||||
|
* **`manual` + 空地址 = 用系统代理**:设置页那行文案一直这么承诺(「manual 留空则用系统代理」),
|
||||||
|
核心按文案兑现,而不是落成第三种没人写过的语义。
|
||||||
|
* **非法地址不穿透**:`proxy_url` 写成解析不了的串时,`ProxySetting::apply` 返回有界错误
|
||||||
|
(`InvalidRequest` + 带原串的可读文案),**不 panic**;`DownloadEngine::new` 据此回落**直连**重建
|
||||||
|
(不是 `reqwest::Client::new()`——那会跟随环境变量代理,回落就不确定了),原因留在
|
||||||
|
`DownloadEngine::proxy_problem()` 上,CLI 的 `download::engine` 打一行 stderr。配置字段**不撒谎**:
|
||||||
|
`config().proxy` 仍是用户写的 `manual`。
|
||||||
|
|
||||||
|
**这是一次真实的行为变化**:`none` 从「碰巧跟随环境变量」变成「真直连」。所以
|
||||||
|
|
||||||
|
* 靠 `HTTP_PROXY` 上网的用户(公司代理 / CI)要在设置页选一次 `system`,否则 M295 之后下载会直连;
|
||||||
|
* 反过来,之前选过「直连」却仍在被环境变量代理的人,现在拿到的是他选的那一档。
|
||||||
|
* `region::probe_signals`(地区探测)、`auth` 的登录与发布版 GUI 的「下载 JDK」页**都不读**这两个键,
|
||||||
|
它们仍用 `EngineConfig::default()`(= `System`,跟随环境变量):这些路径的默认值故意与
|
||||||
|
`download.proxy_mode` 的出厂值(`none`)不同,才没被这次接线静默改了网络路径。
|
||||||
|
|
||||||
|
`setting/keys/proxy.rs` 模块头那句「`download.proxy_mode` → 引擎的 HTTP 客户端
|
||||||
|
(`http::build_client`)」**从 M295 起是真的**(此前是空头支票)。
|
||||||
|
|
||||||
|
两条实现注记(改这段代码前先读):
|
||||||
|
|
||||||
|
* **宿主忘了读 = 编译不过**:两个键是 `RawTuning` 的字段,两个宿主的构造都是**完整字段字面量**
|
||||||
|
(没有 `..RawTuning::default()`),漏一个就是 `E0063`。编译器能拦「忘了读」,拦不住「读到了但
|
||||||
|
传空」——后者由 §6 的 M20 / M21 咬。
|
||||||
|
* **`reqwest` 会替用户补 `http://`**:`Proxy::all("127.0.0.1:7890")` 是合法写法(少写 scheme 不算
|
||||||
|
「解析不了」)。真正解析不了的只剩端口越界(`…:99999`)、方括号不闭合这一类——用例挑的是前者。
|
||||||
|
|
||||||
## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原)
|
## 6. 守卫与变异证明(逐条只改实现侧,跑完即还原)
|
||||||
|
|
||||||
@@ -185,6 +228,17 @@ reqwest 默认会读进程环境变量代理(`HTTP_PROXY` 等),不是这
|
|||||||
| M14(M294) | `shared::build` 的 `tune.apply_source_selection(region)` 换回 `apply_gh_proxy(region)` | `download::preference::tests::the_settings_reach_the_engines_candidate_plan` | 红(`left: ["bmclapi","official"] / right: ["official","bmclapi"]`,4 passed 1 failed) |
|
| 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) |
|
| 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) |
|
| M16(M294) | `app-iced` 的 `engine_with` 删掉 `selection.apply(profile)`(换回裸档案) | `…::downloads::engine::tests::the_source_selection_settings_reach_the_jdk_download_engine` | 红(`left: ["bmclapi","official"]`,0 passed 1 failed) |
|
||||||
|
| M17(M295) | `ProxySetting::apply` 的 manual 档删掉 `.proxy(proxy)` | `…::engine_mock::proxy::manual_mode_really_sends_the_request_through_the_proxy` | 红(`AllSourcesFailed`:`.invalid` 解析不了 ⇒ 请求根本没到假代理;2 passed 1 failed) |
|
||||||
|
| M18(M295) | `ProxySetting::apply` 的 `Direct` 档删掉 `.no_proxy()` | `…::engine_mock::proxy::direct_ignores_the_env_proxy_and_system_follows_it` | 红(direct 档在教育环境变量的假代理上拿到了正文:`[112, 114, …]` = `proxied payload`) |
|
||||||
|
| M19(M295) | `ProxySetting::apply` 的 `System` 档改成 `.no_proxy()` | 同上 | 红(system 档不再跟随环境变量 ⇒ `AllSourcesFailed`) |
|
||||||
|
| M20(M295) | presenter 的 `tuning_of` 把 `proxy_mode` / `proxy_url` 传 `None`(= 没读这两个键) | `host::settings::download::tests::selection_and_proxy::the_proxy_keys_travel_from_the_file_to_the_engine_config` | 红(`left: (Direct, None)` vs `right: (System, None)`;2 passed 1 failed) |
|
||||||
|
| M21(M295) | CLI 的 `download::tuning` 同上 | `cli::download::tests::proxy::the_proxy_settings_reach_the_cli_engine_and_the_console_line` | 红(`left: Direct` vs `right: Manual { url: … }`;1 passed 1 failed) |
|
||||||
|
| M22(M295) | `ProxySetting::apply` 的 `Proxy::all(url)?` 改成 `expect(...)` | `…::engine_mock::proxy::an_unparseable_proxy_url_falls_back_to_direct_and_says_why`(+ core 单测 `a_broken_proxy_url_is_a_bounded_error_not_a_panic`) | 红(两处都 panic:`代理地址: … InvalidPort`) |
|
||||||
|
|
||||||
|
**M20 / M21 的一条附注**:直接把那两个字段**删掉**根本编译不过——两个宿主的 `RawTuning` 是完整字段
|
||||||
|
字面量(没有 `..RawTuning::default()`),编译器会报 `E0063 missing fields proxy_mode and proxy_url`。
|
||||||
|
所以「宿主忘了读这两个键」这一档是**编译期**拦住的;M20/M21 验的是「读到了但没用」(传 `None`),
|
||||||
|
那是编译器管不着的下一半。
|
||||||
|
|
||||||
## 6.1 gh-proxy 那一行的形状(P3-1):M201 之后已解决
|
## 6.1 gh-proxy 那一行的形状(P3-1):M201 之后已解决
|
||||||
|
|
||||||
@@ -245,3 +299,21 @@ M193 时期这一组把「GitHub 加速前缀」的三态画成三行(`GitHub
|
|||||||
两条 HTTP 路径发的是同一个字符串。证据只在**离线回环**上(发出去的字节 ==
|
两条 HTTP 路径发的是同一个字符串。证据只在**离线回环**上(发出去的字节 ==
|
||||||
`EngineConfig::default().user_agent`);**没有向 Modrinth / CurseForge 发过一条带新 UA 的真实请求**,
|
`EngineConfig::default().user_agent`);**没有向 Modrinth / CurseForge 发过一条带新 UA 的真实请求**,
|
||||||
限流阈值、CF 会不会因为多了 `(+URL)` 而拒、Modrinth 是否会因此放宽,全部未知。
|
限流阈值、CF 会不会因为多了 `(+URL)` 而拒、Modrinth 是否会因此放宽,全部未知。
|
||||||
|
* **代理只验到「明文 HTTP 真的经由代理」这一层**(M295):判据是本地假代理(`127.0.0.1` 的
|
||||||
|
`TcpListener`)+ RFC 2606 的 `.invalid` 目标——目标域名自己解析不了,能拿到正文就只可能是因为
|
||||||
|
请求走了代理;代理记到的请求行是**绝对 URI**(`GET http://…/path`),那是「经由代理」的直接证据。
|
||||||
|
**没验**:
|
||||||
|
* 真机上的「系统代理」:Windows 注册表 / macOS 网络偏好设置里的代理**没有被读**——本仓 reqwest
|
||||||
|
没开 `client-proxy-system` feature ⇒ `system` 档只认环境变量(`HTTP_PROXY` 等)。所以「我在
|
||||||
|
Windows 设置里开了系统代理」这件事对 `system` 档**无效**,这是已知限制,不是本页的承诺;
|
||||||
|
* PAC 脚本(`AutoConfigURL` / `PROXY.pac`)没测,代码里也没有任何 PAC 支持;
|
||||||
|
* 需要认证的代理(`Proxy-Authorization` / `Proxy::basic_auth`)没测;
|
||||||
|
* SOCKS 代理没测(reqwest 的 `socks` feature 本仓没开);
|
||||||
|
* HTTPS 目标的 `CONNECT` 隧道没测:夹具只讲明文 HTTP,`manual` 档对 https 目标的行为只在
|
||||||
|
「`Proxy::all` 会拦下 https」这一层有依据;
|
||||||
|
* 代理自身连不上/半死时被归到 `Network` 还是 `Timeout`(以及会不会被 `attempts_for` 重试)没测;
|
||||||
|
* **没在真的代理软件上点过**(Clash / v2rayN / 公司代理),也没在「只靠 `HTTP_PROXY` 上网」的
|
||||||
|
环境里回归过 `none` 由「碰巧跟随环境变量」变成真直连这件事——那是一次真实的行为变化(见 §5.2)。
|
||||||
|
* **发布版 GUI 的「下载 JDK」页仍不认代理两键**(M295):那一处是独立装配点,直接用
|
||||||
|
`EngineConfig::default()`(= `system`,跟随环境变量)。它与「并发 / 三段时间」是同一条已知缺口
|
||||||
|
(`app-iced/…/java/downloads/engine.rs` 模块头的对照表),要接就得把那一处并进 `wanted(cache_root)`。
|
||||||
Reference in new issue
Block a user