From a14b00aced455afe332e5e4206980d1049f906d3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 21:35:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(m197):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E7=9A=84=20P1=20+=20=E4=B8=89=E6=9D=A1=20P2=EF=BC=88=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E5=AF=B9=E9=BD=90=E8=A1=8C=E4=B8=BA=20/=20=E5=86=85?= =?UTF-8?q?=E9=83=A8=E7=BC=96=E5=8F=B7=E4=B8=8B=E5=B1=8F=20/=20kind=20?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=20/=20gh-proxy=20=E5=8F=AF=E8=A6=86=E7=9B=96?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 的「四档」改「五档」。 --- .../src/app/update/settings/java/downloads.rs | 5 +- .../src/pages/settings/java/downloads.rs | 5 +- .../pages/settings/java/downloads/engine.rs | 79 ++++++++++- .../pages/settings/java/downloads/refusal.rs | 2 +- .../pages/settings/java/downloads/tests.rs | 125 +++++++++++++++++- crates/core/src/setting/keys.rs | 19 ++- .../core/tests/download/engine_mock_test.rs | 66 ++++++++- crates/core/tests/setting/appearance_keys.rs | 4 +- crates/core/tests/setting/isolation_keys.rs | 7 +- .../core/tests/setting/java_install_keys.rs | 36 ++++- crates/i18n/src/catalog/font_install.rs | 2 +- crates/i18n/src/catalog/golden/tail.rs | 6 +- crates/i18n/src/catalog/golden/tail2.rs | 2 +- crates/i18n/src/catalog/settings/java.rs | 8 +- crates/i18n/src/catalog/tests.rs | 49 +++++++ 15 files changed, 387 insertions(+), 28 deletions(-) diff --git a/crates/app-iced/src/app/update/settings/java/downloads.rs b/crates/app-iced/src/app/update/settings/java/downloads.rs index 46fcd6a..2583b91 100644 --- a/crates/app-iced/src/app/update/settings/java/downloads.rs +++ b/crates/app-iced/src/app/update/settings/java/downloads.rs @@ -37,7 +37,7 @@ impl App { /// ① 目录里有这个大版本;② 没有别的作业在跑;③ 有可写的设置文件与能解析的落点; /// ④ 这一档在这台机器上真有一家发行版承接(纯判据,不联网)。 pub(super) fn start_java_download(&mut self, major: u32) { - use pages::settings::java::downloads::{Refusal, Request}; + use pages::settings::java::downloads::{gh_proxy_of_config, Refusal, Request}; self.settings.java.download_notice = None; self.settings.java.notice = None; @@ -77,6 +77,9 @@ impl App { cache_root, install_root, priority, + // `download.gh_proxy`(M197 的任务书那句「默认值可改」):空 = 跟随内置默认, + // `off` = 关,其余 = 覆盖前缀。Java 页不画这一项的 UI(归 M193 的下载设置页)。 + gh_proxy: gh_proxy_of_config(&config), }); if !started { self.settings.java.download_notice = Some(Refusal::Busy); diff --git a/crates/app-iced/src/pages/settings/java/downloads.rs b/crates/app-iced/src/pages/settings/java/downloads.rs index bbadd92..0535e2f 100644 --- a/crates/app-iced/src/pages/settings/java/downloads.rs +++ b/crates/app-iced/src/pages/settings/java/downloads.rs @@ -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, + /// `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` 自然退出。 diff --git a/crates/app-iced/src/pages/settings/java/downloads/engine.rs b/crates/app-iced/src/pages/settings/java/downloads/engine.rs index eef833a..074a609 100644 --- a/crates/app-iced/src/pages/settings/java/downloads/engine.rs +++ b/crates/app-iced/src/pages/settings/java/downloads/engine.rs @@ -1,7 +1,25 @@ -//! 带进度发射器的下载引擎(M197):一次 JDK 安装一个实例。 +//! 带进度发射器的下载引擎(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`,而挂进度要 /// 拿走引擎(`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()), diff --git a/crates/app-iced/src/pages/settings/java/downloads/refusal.rs b/crates/app-iced/src/pages/settings/java/downloads/refusal.rs index 906885d..a281c99 100644 --- a/crates/app-iced/src/pages/settings/java/downloads/refusal.rs +++ b/crates/app-iced/src/pages/settings/java/downloads/refusal.rs @@ -1,4 +1,4 @@ -//! 「下载按钮为什么没开始」的四档原因(M197)。 +//! 「下载按钮为什么没开始」的五档原因(M197)。 //! //! 从 `downloads.rs` 拆出来守 400 行硬线(与 `downloads/tests.rs` 同一种拆法)。 //! 点了没反应是禁止的:每一种拒绝都要在下载区顶上写清是哪一档。 diff --git a/crates/app-iced/src/pages/settings/java/downloads/tests.rs b/crates/app-iced/src/pages/settings/java/downloads/tests.rs index b301299..5e2450d 100644 --- a/crates/app-iced/src/pages/settings/java/downloads/tests.rs +++ b/crates/app-iced/src/pages/settings/java/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::>() + ); + + // 关掉那一档:一个代理候选都不生成(同一份 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); +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index ada28a0..8be239d 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -55,6 +55,8 @@ pub mod update; /// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移 /// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。 /// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。 +/// `1.8.0` 登记 M197 的 gh-proxy 前缀覆盖键(`download.gh_proxy`):同样纯增量——出厂值是空串, +/// 语义正是「老文件没有这个键」时的行为(跟随内置镜像表的默认前缀)。 /// /// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时, /// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` → @@ -64,7 +66,7 @@ pub mod update; /// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。 #[must_use] pub fn launcher_version() -> SchemaVersion { - SchemaVersion::new(1, 7, 0) + SchemaVersion::new(1, 8, 0) } /// `config/launcher.json` 的 schema。 @@ -144,6 +146,16 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::sensitive(), "manual 代理地址(如 http://127.0.0.1:7890)", )) + // M197:gh-proxy 前缀的用户覆盖。语义在 `core::download::region`: + // 空串 = 跟随内置默认(镜像表里的 gh-proxy.com,且只有镜像优先/国内判定才启用); + // `off` = 明确关掉;其余非空值 = 用这个前缀覆盖。 + .with(SettingSpec::text( + "download.gh_proxy", + "", + 256, + AiPolicy::sensitive(), + "GitHub 下载的加速前缀(gh-proxy)。留空 = 跟随内置默认(镜像表里的 gh-proxy.com,仅镜像优先/国内判定时启用);填 off 关闭;填一个 URL 前缀则覆盖它", + )) .with(SettingSpec::path( "java.selected_path", AiPolicy::sensitive(), @@ -153,7 +165,10 @@ pub fn launcher_schema() -> SettingSchema { "java.auto_install", true, AiPolicy::audited(), - "缺 Java 时自动下载(D13 基准 JDK17)", + // M197:这句会进 AI 工具的 JSON Schema `description`(`catalog.rs` 直接塞 `spec.doc`), + // 所以必须写**实现真做的事**:缺 Java 时在 Java 页标出推荐版本并可一键安装, + // 不会自己开始下载(是否下载由用户点「安装」决定)。 + "缺 Java 时在 Java 页标出推荐版本并可一键安装;不会自动下载", )) // D23:托管 JDK 的落点三模式。语义在 `game::java::location`: // 未设置(空串)→ 按 `core.data_mode` 派生(Portable 数据根 / Standard 系统目录); diff --git a/crates/core/tests/download/engine_mock_test.rs b/crates/core/tests/download/engine_mock_test.rs index 528e664..5b9ce04 100644 --- a/crates/core/tests/download/engine_mock_test.rs +++ b/crates/core/tests/download/engine_mock_test.rs @@ -338,9 +338,19 @@ async fn reports_progress_events_and_errors_when_all_sources_fail() { started = true; assert!(candidates >= 1); } - ProgressEvent::SourceFailed { will_retry, .. } => { + ProgressEvent::SourceFailed { + will_retry, kind, .. + } => { failed_source = true; assert!(!will_retry, "单次尝试用尽后不应再声明重试"); + // M197:`kind` 是诊断字段,必须来自**真实**的失败分类(HTTP 500 → ServerError)。 + // 变异证明:把 `engine.rs` 的 `kind: err.kind` 写成常量 + // `DownloadErrorKind::Network` ⇒ 本断言红。 + assert_eq!( + kind, + DownloadErrorKind::ServerError, + "HTTP 500 必须被分类成 ServerError(诊断字段不许是常量)" + ); } _ => {} } @@ -348,3 +358,57 @@ async fn reports_progress_events_and_errors_when_all_sources_fail() { assert!(started && failed_source, "进度事件缺失"); server.stop(); } + +/// `SourceFailed.kind` 的另一半:**404 不是「源坏了」**,是「该源没有这个文件」。 +/// +/// 这两档在引擎里的后果完全不同(`NotFound` 走 `record_missing`,不冷却源;`ServerError` 走 +/// `record_failure`)。把两者混成一个常量最贵的地方就在这里:院校镜像只留最新补丁,请求旧补丁 +/// 必然 404,判成「源坏了」会把唯一可用镜像冷却掉(M33/M34 实测)。 +/// +/// 变异证明:把 `engine.rs` / `bytes.rs` 两处 `kind: err.kind` 改成 `DownloadErrorKind::NotFound` +/// 常量 ⇒ 上面那条 500 用例红;改成 `Network` ⇒ 本条红。 +#[tokio::test] +async fn a_404_is_classified_as_not_found_and_never_cools_the_source_down() { + let server = MockServer::start(vec![ + MockResponse::status(404), + MockResponse::status(404), + MockResponse::status(404), + MockResponse::status(404), + ]) + .await; + let url = server.url("/gone.jar"); + let dir = temp_dir("engine-404"); + let cache = Arc::new(ContentCache::open(dir.join("cache"))); + let (emitter, mut events) = ProgressEmitter::channel(); + let engine = DownloadEngine::new( + fast_config(1), + Arc::new(MirrorTable::builtin()), + Arc::new(direct_region()), + Arc::new(HealthRegistry::in_memory()), + cache, + ) + .with_progress(emitter); + + let error = engine + .download(DownloadRequest::new( + ResourceKind::Library, + url.as_str(), + dir.join("gone.jar"), + )) + .await + .expect_err("只有 404 的源必须失败"); + assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed); + + let mut seen: Vec = Vec::new(); + while let Ok(event) = events.try_recv() { + if let ProgressEvent::SourceFailed { kind, .. } = event { + seen.push(kind); + } + } + assert!(!seen.is_empty(), "至少要有一条 SourceFailed"); + assert!( + seen.iter().all(|kind| *kind == DownloadErrorKind::NotFound), + "404 必须分类成 NotFound,实际:{seen:?}" + ); + server.stop(); +} diff --git a/crates/core/tests/setting/appearance_keys.rs b/crates/core/tests/setting/appearance_keys.rs index 3eb5910..fc59988 100644 --- a/crates/core/tests/setting/appearance_keys.rs +++ b/crates/core/tests/setting/appearance_keys.rs @@ -131,8 +131,8 @@ fn appearance_keys_are_all_ai_writable_and_registered() { fn schema_registration_shape_is_right() { assert_eq!( launcher_version().minor, - 7, - "1.7.0 登记 M168 的 ui.font_cdn(1.6.0 是 M161 的自更新两键)" + 8, + "1.8.0 登记 M197 的 download.gh_proxy(1.7.0 是 M168 的 ui.font_cdn)" ); let schema = launcher_schema(); assert_eq!( diff --git a/crates/core/tests/setting/isolation_keys.rs b/crates/core/tests/setting/isolation_keys.rs index 60d98a9..d628dab 100644 --- a/crates/core/tests/setting/isolation_keys.rs +++ b/crates/core/tests/setting/isolation_keys.rs @@ -128,9 +128,10 @@ fn overrides_key_is_an_enum_whitelist() { #[test] fn schema_versions_register_the_new_keys_without_migration() { - // 1.7.0 登记 M168 的 `ui.font_cdn`(1.6.0 登记 M161 的自更新两键,1.5.0 = M116 窗口圆角, - // 1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」,1.2.0 = D23 的 JDK 落点/优先级键)。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0)); + // 1.8.0 登记 M197 的 `download.gh_proxy`(1.7.0 = M168 的 `ui.font_cdn`,1.6.0 登记 M161 的 + // 自更新两键,1.5.0 = M116 窗口圆角,1.4.0 = M91 外观下层三键,1.3.0 = D17「外观(高级)」, + // 1.2.0 = D23 的 JDK 落点/优先级键)。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 8, 0)); assert_eq!(instance_version(), SchemaVersion::new(1, 1, 0)); assert_eq!(launcher_schema().id(), SchemaId::LAUNCHER); // major 仍是 1:迁移链(按 major 走)不需要新步骤,老文件缺键即默认值。 diff --git a/crates/core/tests/setting/java_install_keys.rs b/crates/core/tests/setting/java_install_keys.rs index 0168f55..2cc7d4a 100644 --- a/crates/core/tests/setting/java_install_keys.rs +++ b/crates/core/tests/setting/java_install_keys.rs @@ -66,10 +66,10 @@ fn install_root_accepts_a_path_and_keeps_empty_as_unset() { #[test] fn schema_version_bumps_minor_only_so_no_migration_step_is_needed() { - // 1.7.0 = M168 的中文字体下载地址覆盖键(`ui.font_cdn`;此前 1.6.0 是 M161 的自更新两键, - // 1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**,不是巧合: - // 老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。 - assert_eq!(launcher_version(), SchemaVersion::new(1, 7, 0)); + // 1.8.0 = M197 的 gh-proxy 覆盖键(`download.gh_proxy`;此前 1.7.0 是 M168 的 `ui.font_cdn`, + // 1.6.0 是 M161 的自更新两键,1.5.0 是 M116 窗口圆角)。每加一批键推一次 minor 是**纪律**, + // 不是巧合:老版本读新文件会判 `ReadOnlyPreserveSchema`,所以版本号本身就是「新键存在」的对外声明。 + assert_eq!(launcher_version(), SchemaVersion::new(1, 8, 0)); assert_eq!( launcher_version().major, 1, @@ -120,3 +120,31 @@ fn an_old_config_file_without_the_new_keys_still_loads() { ); let _ = std::fs::remove_dir_all(&dir); } + +/// `java.auto_install` 的 schema 描述必须与实现一致:**不会**自动下载。 +/// +/// 这句会进 AI 工具的 JSON Schema `description`(`setting/catalog.rs` 把 `spec.doc` 原样塞进去), +/// 所以它不是注释而是**对外文案**:写「缺 Java 时自动下载」等于让启动器自己的助手对用户说 +/// 一件实现刻意不做的事(M197 复审 r1 的 P1)。 +/// +/// 变异证明:把描述改回「缺 Java 时自动下载(D13 基准 JDK17)」⇒ 本用例红(三句断言都会红)。 +#[test] +fn the_auto_install_description_says_it_never_downloads_by_itself() { + let schema = launcher_schema(); + let doc = schema + .spec("java.auto_install") + .expect("java.auto_install 必须登记") + .doc; + assert!( + doc.contains("不会自动下载"), + "描述必须写明「不会自动下载」(AI 会照这句话告诉用户):{doc}" + ); + assert!( + !doc.contains("缺 Java 时自动下载"), + "描述不许再写「缺 Java 时自动下载」:{doc}" + ); + assert!( + !doc.contains("D13"), + "描述里不许出现内部决策编号(用户与助手都不知道 D13 是什么):{doc}" + ); +} diff --git a/crates/i18n/src/catalog/font_install.rs b/crates/i18n/src/catalog/font_install.rs index e4056e1..d389514 100644 --- a/crates/i18n/src/catalog/font_install.rs +++ b/crates/i18n/src/catalog/font_install.rs @@ -62,7 +62,7 @@ macro_rules! font_install_entries { "无法探测系统字体,因此没有下载" ), FontCjkStatusHint => ( - "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with `ui.font_cdn`.", + "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with ui.font_cdn.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。" ), } } diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 4109603..b659db2 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -100,7 +100,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsJavaDownloadTitle", "Download a JDK", "下载一个 JDK"), ("SettingsJavaDownloadHint", "Downloads go through the built-in mirror table: in China the campus mirrors are tried first (Temurin over Tsinghua TUNA), and GitHub-hosted archives are fetched through gh-proxy. Every archive is verified against its SHA-256 before it is unpacked.", "下载走内置镜像表:国内先试院校镜像(Temurin 走清华 TUNA),GitHub 上的归档套 gh-proxy。落盘前一律按 SHA-256 校验。"), ("SettingsJavaDownloadLts", "LTS", "长期支持版(LTS)"), - ("SettingsJavaDownloadBaseline", "Baseline (D13)", "基准版本(D13)"), + ("SettingsJavaDownloadBaseline", "Recommended baseline", "推荐基准版本"), ("SettingsJavaDownloadVendors", "Vendors: {vendors}", "发行版:{vendors}"), ("SettingsJavaDownloadNoVendor", "No vendor publishes this major version on this platform.", "这个平台上没有发行版发这个大版本。"), ("SettingsJavaDownloadAction", "Install", "安装"), @@ -155,8 +155,8 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsJavaVendorDown", "Down", "下移"), ("SettingsJavaVendorReset", "Reset to the region default", "恢复地区默认序"), ("SettingsJavaVendorSupports", "ships {majors}", "发 {majors}"), - ("SettingsJavaAutoInstall", "Prepare a JDK when none is found", "缺 JDK 时自动准备"), - ("SettingsJavaAutoInstallHint", "On: the catalog below marks the version the current instance needs, and a freshly installed JDK becomes the default when no default is set yet. Off: you pick everything yourself.", "开:下面的目录会按当前实例的要求标出该装哪一档,装完之后若还没有默认 JDK 就自动设为默认。关:全部自己挑。"), + ("SettingsJavaAutoInstall", "Recommend a JDK when none is found", "缺 JDK 时推荐一档"), + ("SettingsJavaAutoInstallHint", "On: the catalog below marks the version the current instance needs, and a freshly installed JDK becomes the default when no default is set yet. Nothing is downloaded on its own — you press Install. Off: nothing is marked and you pick everything yourself.", "开:下面的目录会按当前实例的要求标出该装哪一档,装完之后若还没有默认 JDK 就自动设为默认。不会自己开始下载 —— 点「安装」才会下。关:什么也不标,全部自己挑。"), ("SettingsJavaAutoInstallOn", "on", "开"), ("SettingsJavaAutoInstallOff", "off", "关"), ("SettingsJavaPrefsSave", "Save", "保存"), diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 8f2d27f..44c7302 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -220,5 +220,5 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("FontCjkReasonDisk", "cannot write to the data directory", "写不进数据目录"), ("FontCjkStatusUnknown", "Not determined yet; nothing was downloaded", "尚未判定;没有下载任何字体"), ("FontCjkStatusUnprobeable", "System fonts cannot be probed, so nothing was downloaded", "无法探测系统字体,因此没有下载"), - ("FontCjkStatusHint", "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with `ui.font_cdn`.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。"), + ("FontCjkStatusHint", "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with ui.font_cdn.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。"), ]; diff --git a/crates/i18n/src/catalog/settings/java.rs b/crates/i18n/src/catalog/settings/java.rs index 149e1f4..b38aeae 100644 --- a/crates/i18n/src/catalog/settings/java.rs +++ b/crates/i18n/src/catalog/settings/java.rs @@ -160,7 +160,7 @@ macro_rules! settings_java_entries { "下载走内置镜像表:国内先试院校镜像(Temurin 走清华 TUNA),GitHub 上的归档套 gh-proxy。落盘前一律按 SHA-256 校验。" ), SettingsJavaDownloadLts => ("LTS", "长期支持版(LTS)"), - SettingsJavaDownloadBaseline => ("Baseline (D13)", "基准版本(D13)"), + SettingsJavaDownloadBaseline => ("Recommended baseline", "推荐基准版本"), SettingsJavaDownloadVendors => ("Vendors: {vendors}", "发行版:{vendors}"), SettingsJavaDownloadNoVendor => ( "No vendor publishes this major version on this platform.", @@ -292,10 +292,10 @@ macro_rules! settings_java_entries { SettingsJavaVendorDown => ("Down", "下移"), SettingsJavaVendorReset => ("Reset to the region default", "恢复地区默认序"), SettingsJavaVendorSupports => ("ships {majors}", "发 {majors}"), - SettingsJavaAutoInstall => ("Prepare a JDK when none is found", "缺 JDK 时自动准备"), + SettingsJavaAutoInstall => ("Recommend a JDK when none is found", "缺 JDK 时推荐一档"), SettingsJavaAutoInstallHint => ( - "On: the catalog below marks the version the current instance needs, and a freshly installed JDK becomes the default when no default is set yet. Off: you pick everything yourself.", - "开:下面的目录会按当前实例的要求标出该装哪一档,装完之后若还没有默认 JDK 就自动设为默认。关:全部自己挑。" + "On: the catalog below marks the version the current instance needs, and a freshly installed JDK becomes the default when no default is set yet. Nothing is downloaded on its own — you press Install. Off: nothing is marked and you pick everything yourself.", + "开:下面的目录会按当前实例的要求标出该装哪一档,装完之后若还没有默认 JDK 就自动设为默认。不会自己开始下载 —— 点「安装」才会下。关:什么也不标,全部自己挑。" ), SettingsJavaAutoInstallOn => ("on", "开"), SettingsJavaAutoInstallOff => ("off", "关"), diff --git a/crates/i18n/src/catalog/tests.rs b/crates/i18n/src/catalog/tests.rs index 5c137cc..db2874f 100644 --- a/crates/i18n/src/catalog/tests.rs +++ b/crates/i18n/src/catalog/tests.rs @@ -201,3 +201,52 @@ fn split_keeps_every_translation() { assert_eq!(got.2, expected.2, "{} 的中文译文变了", expected.0); } } + +/// 用户可见文案里**不许**出现内部决策编号(`(D13)` / `(M175)` 这类)。 +/// +/// 为什么要在目录这一层守:`(D13)` 对用户没有任何意义(他不知道 D13 是什么),而这类串一旦 +/// 混进来就再没人记得删。M197 出图时实测到下载目录的基准版本徽标写着 `基准版本(D13)`。 +/// +/// 变异证明:把 `SettingsJavaDownloadBaseline` 的译文改回 `Baseline (D13)` / `基准版本(D13)` +/// ⇒ 本用例红(中英两侧都扫)。判据是 `(`/`(` + `D`/`M` + 数字,不误伤 `(404)` / `(5xx / 429)` +/// 这类真对用户有意义的括号短语。 +#[test] +fn no_user_visible_string_mentions_an_internal_decision_id() { + for (key, en, zh) in super::golden::rows().copied() { + for (side, text) in [("en", en), ("zh-CN", zh)] { + assert!( + !mentions_decision_id(text), + "{key} 的{side}译文里出现了内部决策编号:{text}" + ); + } + } +} + +/// 用户可见文案里**不许**出现 markdown 反引号。 +/// +/// 渲染层没有内联代码通道(M175 r3 复审已核实 `RichText|markdown|Span::|spans(` 全树只命中 +/// 进度条与注释),反引号会原样上屏。M175 修过三处、M197 又修了三处;M168 的 +/// `FontCjkStatusHint` 是最后一条(M197 复审 r1 指出的),这里一并钉住。 +/// +/// 变异证明:给任意一条译文加一对反引号 ⇒ 本用例红。 +#[test] +fn no_user_visible_string_carries_markdown_backticks() { + for (key, en, zh) in super::golden::rows().copied() { + for (side, text) in [("en", en), ("zh-CN", zh)] { + assert!( + !text.contains('`'), + "{key} 的{side}译文里还有 markdown 反引号:{text}" + ); + } + } +} + +/// 串里有没有「(D13)」「(M175)」这类内部编号(全角半角括号都算)。 +fn mentions_decision_id(text: &str) -> bool { + let chars: Vec = text.chars().collect(); + chars.windows(3).any(|window| { + (window[0] == '(' || window[0] == '(') + && (window[1] == 'D' || window[1] == 'M') + && window[2].is_ascii_digit() + }) +}