fix(m197): 复审 r1 的 P1 + 三条 P2(描述对齐行为 / 内部编号下屏 / kind 守卫 / gh-proxy 可覆盖)

P1(阻断)`java.auto_install` 的描述与行为不一致——那句会进 AI 工具的 JSON Schema
`description`,等于让助手对用户说一件实现刻意不做的事:
- `crates/core/src/setting/keys.rs`:改成「缺 Java 时在 Java 页标出推荐版本并可一键安装;
  不会自动下载」,去掉 `(D13 基准 JDK17)`。
- `SettingsJavaAutoInstallHint` 中英两侧各补一句显式否定(「不会自己开始下载 —— 点「安装」
  才会下」/ "Nothing is downloaded on its own — you press Install."),标签从「自动准备」改成
  「缺 JDK 时推荐一档」;金表同步。
- 新增守卫 `the_auto_install_description_says_it_never_downloads_by_itself`(描述必须含
  「不会自动下载」、不含旧说法与内部编号)。

P2-1 内部决策编号印在界面上:`SettingsJavaDownloadBaseline` 的 `Baseline (D13)` /
`基准版本(D13)` → `Recommended baseline` / `推荐基准版本`(金表同步)。另加两条目录级守卫
(`no_user_visible_string_mentions_an_internal_decision_id` /
`no_user_visible_string_carries_markdown_backticks`);后者顺带清掉 M168 遗留的
`FontCjkStatusHint` 里的 `ui.font_cdn` 反引号——全仓最后一条。

P2-2 `ProgressEvent::SourceFailed.kind` 没有守卫:`engine_mock_test`
- `reports_progress_events_and_errors_when_all_sources_fail` 的解构补 `kind`,断言
  `ServerError`;
- 新增 `a_404_is_classified_as_not_found_and_never_cools_the_source_down`(404 → `NotFound`,
  与「源坏了」分开)。

P2-3 交付任务书那句「(默认值可改)」:新增 `download.gh_proxy`(空 = 跟随内置默认 /
`off` = 关 / 其它 = 覆盖前缀),`engine_with` 是它的真消费者(改 `RegionProfile`,因为
`gh_proxy_candidates` 读的就是档案里的前缀);Java 页不画 UI(归 M193)。**内置默认值没动**。
schema minor 1.7.0 → 1.8.0(新键必须推 minor),三处硬编码 minor 的用例同步。

另:真下载用例改成 panic 也清理(复审顺带项 7④);`downloads/refusal.rs` 的「四档」改「五档」。
This commit is contained in:
AskaEth committed 2026-11-08 21:35:13 +08:00
1 parent 9a7b138d08
commit a14b00aced
15 files changed
+387 -28

No files matched your search

@@ -54,6 +54,7 @@ mod engine;
mod recommend;
mod refusal;
pub use engine::{gh_proxy_of_config, GhProxy};
pub use recommend::recommend;
pub use refusal::Refusal;
@@ -208,6 +209,8 @@ pub struct Request {
pub install_root: JavaInstallRoot,
/// 用户优先级(`java.vendorPriority`;空 = 地区默认序)
pub priority: Vec<JdkVendor>,
/// `download.gh_proxy`(`Builtin` = 跟随内置默认;`Off` = 关;`Prefix` = 覆盖前缀)
pub gh_proxy: GhProxy,
}
/// 作业槽:快照 + 「线程还活着」。
@@ -269,7 +272,7 @@ impl Slot {
feed.apply(event);
}
});
let engine = engine::engine_with(&request.cache_root, emitter);
let engine = engine::engine_with(&request.cache_root, emitter, &request.gh_proxy);
let install_request = InstallRequest::new(request.major).with_priority(request.priority);
let result = block_on(install(&engine, &request.install_root, install_request));
// 引擎丢掉 ⇒ 发射器丢掉 ⇒ 抽干线程读到 `None` 自然退出。
@@ -1,7 +1,25 @@
//! 带进度发射器的下载引擎(M197):一次 JDK 安装一个实例。
//! 带进度发射器的下载引擎(M197):一次 JDK 安装一个实例 + `download.gh_proxy` 的真消费者。
//!
//! 从 `downloads.rs` 拆出来(行数 + 口径独立:这里只说「为什么不用共享引擎」)。
//! 装配口径与 `core::download::shared::build` 一致,只多挂一个 `ProgressEmitter`。
//!
//! ## gh-proxy 的取值链(任务书那句「(默认值可改)」的落点)
//!
//! | `download.gh_proxy` | [`GhProxy`] | 效果 |
//! |---|---|---|
//! | 空(出厂) | [`GhProxy::Builtin`] | 跟随内置默认:`region.json` 判成镜像优先时用 `mirrors.json` 里的 `gh-proxy.com` |
//! | `off` / `none` / `direct` / `disable` | [`GhProxy::Off`] | `RegionProfile::with_gh_proxy(None)` —— 一个 gh-proxy 候选都不生成 |
//! | 其它非空值 | [`GhProxy::Prefix`] | 用这个前缀覆盖(仍然只在镜像优先/国内判定时启用) |
//!
//! **为什么只改 `RegionProfile`、不碰 `MirrorTable::set_gh_proxy`**:真正决定「要不要生成
//! gh-proxy 候选」的是 `MirrorTable::gh_proxy_candidates`,它读的是 `region.gh_proxy` 与
//! `region.mode`(`mirrors.rs:222-241`);表里的 `gh_proxies` 只是「已知可用前缀」的目录
//! (`default_gh_proxy()` 用它给设置做默认值)。改表不改档案 = 界面上改了、下载时没变,
//! 那是最坏的一种「可配置」。所以本文件只动档案,并在 `downloads/tests.rs` 里用**候选链出现
//! 该前缀**来证明它真的生效。
//!
//! 内置默认值本身没有动(`mirrors.json` 仍是 `https://gh-proxy.com/`):空串这一档等于
//! 「老文件没有这个键」的行为。
use std::path::Path;
use std::sync::Arc;
@@ -12,21 +30,76 @@ use unlucky_core::download::region_system::detect_signals;
use unlucky_core::download::{
ContentCache, DownloadEngine, EngineConfig, HealthRegistry, MirrorTable, ProgressEmitter,
};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
/// 带进度发射器的引擎(装配口径与 `download::shared::build` 一致,只多挂一个 emitter)。
/// `download.gh_proxy` 的三种取值。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum GhProxy {
/// 跟随内置默认(`region.json` 的判定 + `mirrors.json` 的前缀)
#[default]
Builtin,
/// 明确关掉(`with_gh_proxy(None)`)
Off,
/// 用这个前缀覆盖
Prefix(String),
}
/// 设置原文 → 取值(纯函数:`off` 与 URL 的边界在这里定死,不散落在别处)。
///
/// `off` / `none` / `direct` / `disable` 都认(大小写不敏感、前后空白忽略):用户拿哪个词表达
/// 「关掉」都合理,专门要求某一个反而会造出「填了没生效」的坑。
#[must_use]
pub fn gh_proxy_from(raw: &str) -> GhProxy {
let text = raw.trim();
if text.is_empty() {
return GhProxy::Builtin;
}
let lowered = text.to_ascii_lowercase();
if matches!(
lowered.as_str(),
"off" | "none" | "direct" | "disable" | "disabled" | "no"
) {
return GhProxy::Off;
}
GhProxy::Prefix(text.to_string())
}
/// 从 `launcher.json` 读 `download.gh_proxy`(读不到 / 没有文件 = 跟随内置默认)。
#[must_use]
pub fn gh_proxy_of_config(config: &Path) -> GhProxy {
let store = SettingStore::load(config, launcher_schema());
gh_proxy_from(
store
.doc()
.effective_str("download.gh_proxy")
.unwrap_or_default(),
)
}
/// 带进度发射器的引擎(装配口径与 `download::shared::build` 一致,只多挂一个 emitter + gh-proxy 覆盖)。
///
/// 为什么不再走 `shared::engine_for`:那个登记处交出的是 `Arc<DownloadEngine>`,而挂进度要
/// 拿走引擎(`with_progress(self)`)。要让共享引擎带「按作业」的观察者,得改引擎内部的
/// `progress` 字段为观察者名单——那是引擎侧(M193 的镜像/并发那摊)的范围。
/// 代价:一次 JDK 安装有自己的连接池;**盘上的** `health.json` / `index.json` / `region.json`
/// 是同一个,所以换源经验仍然跨作业累积。
pub(super) fn engine_with(cache_root: &Path, progress: ProgressEmitter) -> DownloadEngine {
pub(super) fn engine_with(
cache_root: &Path,
progress: ProgressEmitter,
gh_proxy: &GhProxy,
) -> DownloadEngine {
let now = now_ms();
let profile = load_cached(&cache_root.join("region.json"), now).unwrap_or_else(|| {
let profile = decide_from(&detect_signals(), now);
let _ = std::fs::create_dir_all(cache_root);
profile
});
let profile = match gh_proxy {
GhProxy::Builtin => profile,
GhProxy::Off => profile.with_gh_proxy(None),
GhProxy::Prefix(prefix) => profile.with_gh_proxy(Some(prefix)),
};
DownloadEngine::new(
EngineConfig::default(),
Arc::new(MirrorTable::builtin()),
@@ -1,4 +1,4 @@
//! 「下载按钮为什么没开始」的四档原因(M197)。
//! 「下载按钮为什么没开始」的五档原因(M197)。
//!
//! 从 `downloads.rs` 拆出来守 400 行硬线(与 `downloads/tests.rs` 同一种拆法)。
//! 点了没反应是禁止的:每一种拒绝都要在下载区顶上写清是哪一档。
@@ -173,7 +173,17 @@ fn the_real_download_installs_a_usable_jdk() {
cache_root: dir.join("cache"),
install_root: JavaInstallRoot::custom(dir.join("jdks")),
priority: Vec::new(),
gh_proxy: super::GhProxy::Builtin,
}));
// 失败/panic 也要清理:这条用例会真下 ~180 MB,留在系统 temp 里是最贵的垃圾。
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _cleanup = Cleanup(dir.clone());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(900);
while slot.busy() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(200));
@@ -189,7 +199,6 @@ fn the_real_download_installs_a_usable_jdk() {
"bin/java 应存在:{}",
success.binary
);
let _ = std::fs::remove_dir_all(&dir);
}
/// 失败要留档:分类 + 大版本 + 最后试到的源(界面靠这三样说清「为什么没装成」)。
@@ -298,3 +307,117 @@ fn every_download_template_renders_without_leftover_placeholders() {
}
}
}
// --------------------------------------------------------------- P2-3:`download.gh_proxy` 的取值与真消费者
/// 设置原文 → 三态:空 = 跟随内置默认,`off` 一族 = 关,其余 = 覆盖前缀。
///
/// 变异证明:删掉 `gh_proxy_from` 里那段 `matches!(lowered, "off" | …)` ⇒ 本用例红
/// (`off` 会被当成一个前缀 `off`,拼出 `offhttps://…` 这种 URL)。
#[test]
fn the_gh_proxy_setting_maps_to_three_states() {
use super::engine::gh_proxy_from;
use super::GhProxy;
assert_eq!(gh_proxy_from(""), GhProxy::Builtin, "空 = 跟随内置默认");
assert_eq!(gh_proxy_from(" "), GhProxy::Builtin);
for off in [
"off", "OFF", " none ", "direct", "Disable", "disabled", "no",
] {
assert_eq!(gh_proxy_from(off), GhProxy::Off, "{off} 应表示关掉");
}
assert_eq!(
gh_proxy_from(" https://gh.example/ "),
GhProxy::Prefix("https://gh.example/".to_string()),
"前后空白要去掉,值原样保留"
);
}
/// **真消费者**:`download.gh_proxy` 落盘 → 引擎档案 → **候选链里真的出现该前缀**。
///
/// 三步都要成立才算「接了」:① 键读得出来;② 档案被覆盖;③ `expand()` 用它生成候选。
/// 只断言 ① 会漏掉「读了但没用」(本 mission 修的就是这一类),只断言 ② 会漏掉「expand 不认
/// 档案里的前缀」。
///
/// 变异证明:让 `engine_with` 忽略 `gh_proxy` 参数(`let profile = profile;`)⇒ ②③ 两条红;
/// 把 `gh_proxy_of_config` 写成恒 `Builtin` ⇒ ① 先红。
#[test]
fn the_gh_proxy_setting_reaches_the_candidate_chain() {
use super::engine::engine_with;
use super::{gh_proxy_of_config, GhProxy};
use unlucky_core::download::cache::now_ms;
use unlucky_core::download::region::{store_cached, RegionProfile};
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
use unlucky_core::download::ProgressEmitter;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
let dir = std::env::temp_dir().join(format!("ull-gh-proxy-{}-{}", std::process::id(), line!()));
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");
let config = dir.join("config").join("launcher.json");
// ① 键读得出来
let mut store = SettingStore::load(&config, launcher_schema());
store
.set(
"download.gh_proxy",
"https://gh.example/".to_string().into(),
)
.expect("schema 认识 download.gh_proxy");
store.save().expect("写盘");
assert_eq!(
gh_proxy_of_config(&config),
GhProxy::Prefix("https://gh.example/".to_string())
);
// region.json 钉成镜像优先:否则 gh-proxy 候选本来就不生成,测不出「设了有没有用」。
let cache = dir.join("cache");
store_cached(
&cache.join("region.json"),
&RegionProfile::mirror_first(now_ms()),
)
.expect("写 region.json");
let engine = engine_with(
&cache,
ProgressEmitter::silent(),
&gh_proxy_of_config(&config),
);
// ② 档案被覆盖
assert_eq!(
engine.region().gh_proxy.as_deref(),
Some("https://gh.example/"),
"设置值必须覆盖档案里的前缀"
);
// ③ 候选链里真的出现这个前缀
let request = DownloadRequest::new(
ResourceKind::Library,
"https://github.com/owner/repo/releases/download/v1/a.jar".to_string(),
dir.join("a.jar"),
);
let plan = engine.plan(&request, 0);
assert!(
plan.iter()
.any(|candidate| candidate.url.starts_with("https://gh.example/")),
"gh-proxy 候选没出现在候选链里:{:?}",
plan.iter()
.map(|candidate| candidate.url.clone())
.collect::<Vec<_>>()
);
// 关掉那一档:一个代理候选都不生成(同一份 region.json,只换设置值)。
let off = engine_with(&cache, ProgressEmitter::silent(), &GhProxy::Off);
assert_eq!(off.region().gh_proxy, None, "off 必须把前缀清掉");
let plan = off.plan(&request, 0);
assert!(
!plan
.iter()
.any(|candidate| candidate.url.starts_with("https://gh.example/")),
"关掉之后不该再有 gh-proxy 候选"
);
let _ = std::fs::remove_dir_all(&dir);
}