diff --git a/crates/app-iced/src/pages/settings/download.rs b/crates/app-iced/src/pages/settings/download.rs index e064dae..5dd288b 100644 --- a/crates/app-iced/src/pages/settings/download.rs +++ b/crates/app-iced/src/pages/settings/download.rs @@ -31,7 +31,7 @@ pub(super) const GROUP: Group = Group { icon: IconKind::Download, rows: &[ ("并发数", "出厂 8 · 可调 1~64"), - ("单源尝试次数", "出厂 3 · 含首次 · 换源另算"), + ("单源尝试次数", "出厂 3 · 含首次 · 0/1 = 只试一次"), ("GitHub 加速前缀", "留空 = 跟随内置默认"), ("关掉它", "填 off = 关闭"), ("换掉它", "填 URL 前缀 = 覆盖"), diff --git a/crates/app-iced/src/pages/settings/tests/download_section.rs b/crates/app-iced/src/pages/settings/tests/download_section.rs index 46b3fc7..1ddd0cb 100644 --- a/crates/app-iced/src/pages/settings/tests/download_section.rs +++ b/crates/app-iced/src/pages/settings/tests/download_section.rs @@ -21,7 +21,7 @@ fn the_downloads_group_shows_concurrency_retries_and_the_gh_proxy_key() { rows, vec![ ("并发数", "出厂 8 · 可调 1~64"), - ("单源尝试次数", "出厂 3 · 含首次 · 换源另算"), + ("单源尝试次数", "出厂 3 · 含首次 · 0/1 = 只试一次"), ("GitHub 加速前缀", "留空 = 跟随内置默认"), ("关掉它", "填 off = 关闭"), ("换掉它", "填 URL 前缀 = 覆盖"), @@ -72,6 +72,8 @@ fn the_downloads_rows_state_semantics_and_never_fake_a_current_value() { "填 URL 前缀 = 覆盖", // 硬上限必须在界面上说清(任务书的边界条款)。 "1~64", + // P2-1:0 是旧版提供过的档位,界面必须说清它 = 只试一次(不是「不下载」)。 + "0/1 = 只试一次", // 三个键各自是哪一行(标签本身也是契约:用户按它认设置)。 "并发数", "单源尝试次数", diff --git a/crates/core/src/download/config.rs b/crates/core/src/download/config.rs index e384ca6..ec5d084 100644 --- a/crates/core/src/download/config.rs +++ b/crates/core/src/download/config.rs @@ -141,8 +141,8 @@ impl DownloadTuning { /// 设置原文 → 调优(**未填 / 越界 / 非法值一律回落出厂默认**,绝不报错阻断下载)。 /// /// 口径与 schema 的 `(min, max)` 同源但独立实现(用户可能手改文件绕过 `store.set`):键不存在 - /// → 出厂默认;落在区间内 → 原值;0 / 负数 / 超上限 → 出厂默认(**不夹到边界**:那是「想 9999 - /// 给 64」,界面仍会说谎)。 + /// → 出厂默认;落在区间内 → 原值;负数 / 超上限 → 出厂默认(**不夹到边界**:那是「想 9999 给 + /// 64」,界面仍会说谎)。`retry_limit` 的 `0` 在区间内,落成「只试一次」(见实现里的注释)。 #[must_use] pub fn from_settings( concurrency: Option, @@ -155,10 +155,12 @@ impl DownloadTuning { .and_then(|raw| usize::try_from(raw).ok()) .filter(|raw| (1..=MAX_CONCURRENCY).contains(raw)) .unwrap_or(default.max_concurrency), + // `0` 是**合法值**(P2-1 复审:旧配置里真的有 0,且旧 GUI 把它当档位):语义是 + // 「只试一次 = 不重试」,所以 0 与 1 都落成 1;负数 / 超上限才回落出厂默认。 max_attempts_per_source: attempts .and_then(|raw| u32::try_from(raw).ok()) - .filter(|raw| (1..=MAX_ATTEMPTS_PER_SOURCE).contains(raw)) - .unwrap_or(default.max_attempts_per_source), + .filter(|raw| *raw <= MAX_ATTEMPTS_PER_SOURCE) + .map_or(default.max_attempts_per_source, |raw| raw.max(1)), gh_proxy: gh_proxy.map_or(GhProxyOverride::Builtin, gh_proxy_override), } } @@ -225,36 +227,47 @@ mod tests { use super::*; use crate::setting::keys::{launcher_schema, launcher_version}; - /// **守门③**:schema 的默认值与 `EngineConfig::default()` / 调优默认值是同一个常量。 + /// **守门③**:schema 的默认值 / 区间 / 键名与 `EngineConfig::default()` / 调优默认值取同一组常量。 /// - /// 变异证明(只改实现侧):把 `EngineConfig::default()` 的 `max_concurrency` 写回字面量 64 - /// ⇒ 本用例红在 `max_concurrency`;把 `setting/keys.rs` 的 `download.concurrency` 默认值写回 - /// 字面量 8 而常量改成 16 ⇒ 红在 schema 一侧。这条防的正是「两处各写一个数」长回来 - /// (8 vs 64 就是这么长出来的)。 + /// 变异证明(只改实现侧):`EngineConfig::default()` 的并发写回字面量 64 ⇒ 红;schema 默认值 + /// 写成字面量 16 ⇒ 红;`MAX_CONCURRENCY` 改了而 schema 仍写 64 ⇒ 红。这条防的正是「两处各写 + /// 一个数」长回来(8 vs 64 就是这么长出来的)。 #[test] - fn the_schema_defaults_are_the_engine_defaults() { + fn the_schema_contract_is_single_sourced() { let schema = launcher_schema(); assert_eq!(schema.version(), launcher_version()); let cfg = EngineConfig::default(); let tune = DownloadTuning::default(); - // 键名常量与 schema 登记是同一份(宿主按常量读,schema 按常量登记)。 - assert!(schema.spec(KEY_CONCURRENCY).is_some(), "{KEY_CONCURRENCY}"); - assert!(schema.spec(KEY_RETRY_LIMIT).is_some(), "{KEY_RETRY_LIMIT}"); - - let concurrency = schema.spec(KEY_CONCURRENCY).expect("键已登记"); - assert_eq!( - concurrency.default, - serde_json::json!(DEFAULT_MAX_CONCURRENCY) - ); + // 并发 1~64(0 张许可没意义);尝试次数 **0**~10(P2-1:0 = 只试一次,旧配置里真有)。 + let contract: [(&str, i64, i64, usize); 2] = [ + ( + KEY_CONCURRENCY, + i64::try_from(DEFAULT_MAX_CONCURRENCY).expect("装得下"), + 1, + MAX_CONCURRENCY, + ), + ( + KEY_RETRY_LIMIT, + i64::from(DEFAULT_MAX_ATTEMPTS_PER_SOURCE), + 0, + usize::try_from(MAX_ATTEMPTS_PER_SOURCE).expect("装得下"), + ), + ]; + for (key, default, min, max) in contract { + let spec = schema.spec(key).expect("键已登记"); + assert_eq!(spec.default, serde_json::json!(default), "{key}"); + assert_eq!( + spec.kind, + crate::setting::spec::ValueKind::Int { + min, + max: i64::try_from(max).expect("装得下"), + }, + "{key}" + ); + } assert_eq!(cfg.max_concurrency, DEFAULT_MAX_CONCURRENCY); assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY); - - let retry = schema.spec(KEY_RETRY_LIMIT).expect("键已登记"); - assert_eq!( - retry.default, - serde_json::json!(DEFAULT_MAX_ATTEMPTS_PER_SOURCE) - ); assert_eq!(cfg.max_attempts_per_source, DEFAULT_MAX_ATTEMPTS_PER_SOURCE); assert_eq!( tune.max_attempts_per_source, @@ -262,44 +275,38 @@ mod tests { ); } - /// 硬上限:schema 的上界与引擎的夹取上限是同一个常量(防「schema 收 256、引擎不知情」)。 - /// 变异证明:只改 `MAX_CONCURRENCY` 而 schema 仍写 64 ⇒ 红。 - #[test] - fn the_hard_caps_are_single_sourced() { - let schema = launcher_schema(); - let concurrency = i64::try_from(MAX_CONCURRENCY).expect("装得下"); - let attempts = i64::from(MAX_ATTEMPTS_PER_SOURCE); - for (key, max) in [(KEY_CONCURRENCY, concurrency), (KEY_RETRY_LIMIT, attempts)] { - let spec = schema.spec(key).expect("键已登记"); - assert_eq!( - spec.kind, - crate::setting::spec::ValueKind::Int { min: 1, max } - ); - } - } - - /// 未填 / 非法值回落默认,不阻断(任务书的边界条款)。 + /// 未填 / 越界回落默认(不阻断下载);**`retry_limit: 0` 是合法值**,语义 = 只试一次。 /// - /// 变异证明:把 `filter(|raw| (1..=MAX_CONCURRENCY).contains(raw))` 删掉 ⇒ `9999` 与 `0` - /// 两处断言红;把 `unwrap_or(default.max_concurrency)` 改成 `unwrap_or(1)` ⇒ `None` 断言红。 + /// 变异证明:把并发的区间过滤删掉 ⇒ `0` / `9999` 两条红;把尝试次数的 `.max(1)` 删掉 ⇒ + /// `0 → 1` 那条红(引擎会拿到 0 次尝试);把 `0` 判成越界 ⇒ 同样红。 #[test] fn missing_or_illegal_values_fall_back_to_the_defaults() { - let tune = DownloadTuning::from_settings(None, None, None); - assert_eq!(tune, DownloadTuning::default()); + assert_eq!( + DownloadTuning::from_settings(None, None, None), + DownloadTuning::default() + ); let just_over = i64::try_from(MAX_CONCURRENCY).expect("上限装得下") + 1; for bad in [0_i64, -1, 9_999, just_over] { - let tune = DownloadTuning::from_settings(Some(bad), Some(bad), None); + let tune = DownloadTuning::from_settings(Some(bad), None, None); + assert_eq!(tune.max_concurrency, DEFAULT_MAX_CONCURRENCY, "{bad}"); + } + // 0 与 1 都 = 只试一次:旧配置里的 0 不许回落成出厂 3(那正是「界面 0、引擎 3」)。 + for zero_or_one in [0_i64, 1] { + let tune = DownloadTuning::from_settings(None, Some(zero_or_one), None); + assert_eq!(tune.max_attempts_per_source, 1, "{zero_or_one}"); + } + for bad in [-1_i64, 9_999] { + let tune = DownloadTuning::from_settings(None, Some(bad), None); assert_eq!( - tune, - DownloadTuning::default(), - "{bad} 越界时应回落默认而不是夹到边界" + tune.max_attempts_per_source, + DEFAULT_MAX_ATTEMPTS_PER_SOURCE ); } - let tune = DownloadTuning::from_settings(Some(2), Some(1), None); + let tune = DownloadTuning::from_settings(Some(2), Some(2), None); assert_eq!(tune.max_concurrency, 2); - assert_eq!(tune.max_attempts_per_source, 1); + assert_eq!(tune.max_attempts_per_source, 2); } /// 三态:**空串 ≠ 关**(这条是「设置项说假话」的高发点,M197 的键语义同源)。 @@ -323,21 +330,21 @@ mod tests { ); } - /// `apply_gh_proxy` 的三档真的改到档案上、并且**改到候选链上**(`MirrorTable::expand()` 读的就是 - /// 档案里那个前缀,内容库/版本清单都走它)。这是「改键 ⇒ 候选链变」的 core 级一半。 + /// `apply_gh_proxy` 的三档真的改到档案上、并且**改到候选链上**(`MirrorTable::expand()` 读的 + /// 就是档案里那个前缀)。端到端(schema 键 → 调优 → `plan()`)由 `shared.rs` 与 presenter 守。 /// 变异证明:把 `Off` 分支改成原样返回 `profile` ⇒ `off` 那条红。 #[test] - fn gh_proxy_override_reaches_the_region_profile_and_the_candidate_chain() { + fn gh_proxy_override_reaches_the_profile_and_the_chain() { use crate::download::mirrors::{MirrorTable, SOURCE_GH_PROXY}; use crate::download::types::ResourceKind; let url = "https://github.com/o/r/releases/download/v1/a.jar"; let table = MirrorTable::builtin(); - let chain = |profile: &RegionProfile| -> Vec { + let sources = |profile: &RegionProfile| -> Vec { table .expand(url, ResourceKind::Other, profile) .into_iter() - .map(|candidate| candidate.url) + .map(|candidate| candidate.source) .collect() }; @@ -345,36 +352,22 @@ mod tests { let kept = DownloadTuning::default().apply_gh_proxy(mirror_first.clone()); assert_eq!(kept.gh_proxy, mirror_first.gh_proxy, "Builtin 不该动档案"); assert!( - chain(&kept).iter().any(|url| url.contains("gh-proxy")), - "出厂默认会套前缀" + sources(&kept).contains(&SOURCE_GH_PROXY.to_string()), + "出厂默认套前缀" ); let off = DownloadTuning::from_settings(None, None, Some("off")) .apply_gh_proxy(mirror_first.clone()); - assert!(!off.gh_proxy_enabled(), "off 必须关掉 gh-proxy"); - assert_eq!(off.gh_proxy, None); + assert!(!off.gh_proxy_enabled()); assert!( - !chain(&off).iter().any(|url| url.contains("gh-proxy")), - "关了就不该有候选" - ); - assert!( - table - .expand(url, ResourceKind::Other, &off) - .iter() - .all(|candidate| candidate.source != SOURCE_GH_PROXY), - "连 source 也不该出现" + !sources(&off).contains(&SOURCE_GH_PROXY.to_string()), + "关了就没有候选" ); let prefix = DownloadTuning::from_settings(None, None, Some("https://gh.example/")) .apply_gh_proxy(mirror_first); assert_eq!(prefix.gh_proxy.as_deref(), Some("https://gh.example/")); assert!(prefix.gh_proxy_enabled()); - assert!( - chain(&prefix) - .iter() - .any(|url| url.starts_with("https://gh.example/")), - "覆盖前缀必须进候选链" - ); } /// 每个数据根的调优互不串味(一个根改成 2 不该改另一个根的引擎)。 diff --git a/crates/core/src/download/mirrors.rs b/crates/core/src/download/mirrors.rs index e48e3bc..31fca8d 100644 --- a/crates/core/src/download/mirrors.rs +++ b/crates/core/src/download/mirrors.rs @@ -130,7 +130,8 @@ pub struct MirrorTable { /// Zulu 元数据(v3 新增)。 #[serde(default)] pub zulu: Option, - /// gh-proxy 类前缀。 + /// gh-proxy 类前缀(**目前无生产读者**:生效前缀的唯一真相源是 `RegionProfile.gh_proxy`; + /// 这一列只作为 `mirrors.json` 的解析留档,别再按它做「改这里能生效」的接口)。 #[serde(default)] pub gh_proxies: Vec, } @@ -184,7 +185,7 @@ impl MirrorTable { /// GitHub host 且允许时套 gh-proxy 前缀。 /// /// **生效前缀的唯一真相源是 `region.gh_proxy`**(设置层/地区判定写入,`None` = 用户关闭)。 - /// 表格里的 `gh_proxies` 只是「已知可用前缀」清单(诊断页可读),**改它不会生效**——M193 + /// 表格里的 `gh_proxies` 只是「已知可用前缀」的留档(无生产读者),**改它不会生效**——M193 /// 删掉了原先那个 `MirrorTable::set_gh_proxy`,正是因为它改了这张表而 `expand()` 读的是档案, /// 属于「看起来能改、其实没人读」的入口。 #[must_use] diff --git a/crates/core/src/download/shared.rs b/crates/core/src/download/shared.rs index f3a59b6..3f0257a 100644 --- a/crates/core/src/download/shared.rs +++ b/crates/core/src/download/shared.rs @@ -136,11 +136,10 @@ pub fn constructions(cache_root: impl AsRef) -> usize { .unwrap_or(0) } -/// 真实构造(**唯一**一处 `DownloadEngine::new` 的宿主侧装配)。装配参数从该数据根的 -/// [`wanted`] 取,不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore` 容量、尝试次数 → -/// `attempts_for()` 上限、gh_proxy → 地区档案(`MirrorTable::expand()` 只认档案里那个前缀,所以 -/// 内容库 / 版本清单 / 字体 / 安装**所有**路径一起生效)。值由宿主翻译后经 [`set_tuning`] 灌入—— -/// core 不认识设置键名,也不依赖 `core::setting`。 +/// 真实构造(**presenter 侧**唯一一处 `DownloadEngine::new` 的宿主侧装配;CLI 还有三处自建, +/// 见 `docs/download-settings.md` §3 的「哪些路径吃这套设置」)。装配参数从该数据根的 [`wanted`] 取, +/// 不用裸 `EngineConfig::default()`(M193):并发 → `Semaphore` 容量、尝试次数 → `attempts_for()` +/// 上限、gh_proxy → 地区档案(`expand()` 只认档案里那个前缀)。值由宿主经 [`set_tuning`] 灌入。 fn build(cache_root: &Path) -> DownloadEngine { let now = now_ms(); let tune = wanted(cache_root); @@ -179,7 +178,7 @@ pub fn set_tuning(cache_root: impl AsRef, next: DownloadTuning) -> bool { mod tests { use super::*; - /// 每个用例一个独立数据根(登记处是进程级的,键撞了会互相干扰)。 + /// 每个用例一个独立数据根(登记处与调优都是进程级、按根存,键撞了会互相干扰)。 fn root(tag: &str) -> PathBuf { static SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -325,17 +324,16 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } - /// **守门①②**:设置说 2 ⇒ 引擎**真实的并发闸门**就是 2(探针读 `Semaphore`,不是读配置); - /// 同一口装配点上的 gh-proxy 三档也真的改变 `plan()` 的候选链。 + /// **守门①②**:设置说 2 ⇒ 引擎**真实的并发闸门**就是 2(探针读 `Semaphore` 而非配置);gh-proxy + /// 三档也真的改变 `plan()` 的候选链;`retry_limit = 0` 落成「只试一次」。 /// - /// 变异证明(只改实现侧):① `build` 的 `tune.apply_to(...)` 换回裸 `EngineConfig::default()` - /// ⇒ 探针 8,红;② `tune.apply_gh_proxy(region)` 换回 `region` ⇒ `off` 档仍有候选,红; - /// ③ 删掉 `set_tuning` 里那条 `remove` ⇒ 换参数后交回旧引擎(探针 2 ≠ 5),红。 - /// gh-proxy 的**端到端**一半(schema 键 → 调优)要等 M197 登记该键,这里只守「调优 → 候选链」。 + /// 变异证明(只改实现侧):① `build` 换回裸 `EngineConfig::default()` ⇒ 探针 8 红;② + /// `apply_gh_proxy(region)` 换回 `region` ⇒ `off` 档仍有候选,红;③ 删 `set_tuning` 的 + /// `remove` ⇒ 换参数后交回旧引擎,红。gh-proxy 的端到端(schema 键 → 调优)等 M197 登记该键。 #[test] fn the_setting_and_the_gh_proxy_override_reach_the_engine() { use super::super::types::{DownloadRequest, ResourceKind}; - use super::super::RegionProfile; + use super::super::{config, RegionProfile}; let dir = root("tune"); // 镜像优先的档案:gh-proxy 只在这个模式下才生成候选。 @@ -363,13 +361,8 @@ mod tests { set(Some(2), Some(1), "off"); let engine = engine_for(&dir); assert_eq!(engine.concurrency_probe(), 2, "闸门必须真是 2 张许可"); - assert_eq!( - engine.config().max_attempts_per_source, - 1, - "尝试次数同一条路" - ); let off = chain(&dir); - assert!(!off.iter().any(|url| url.contains("gh-proxy")), "{off:?}"); + assert!(!off.iter().any(|u| u.contains("gh-proxy")), "{off:?}"); set(Some(5), None, "https://gh.example/"); let rebuilt = engine_for(&dir); @@ -377,23 +370,27 @@ mod tests { assert_eq!(rebuilt.concurrency_probe(), 5, "新闸门吃新值"); let over = chain(&dir); assert!( - over.iter() - .any(|url| url.starts_with("https://gh.example/")), - "覆盖前缀必须出现在候选链里:{over:?}" + over.iter().any(|u| u.starts_with("https://gh.example/")), + "{over:?}" ); - // 硬上限(纵深防御):宿主绕开 `from_settings` 直接塞 9999,闸门也只能开到 64。 + // P2-1:`retry_limit = 0`(旧配置里真有)不许回落出厂 3。硬上限:塞 9999 也只开 64。 + set(Some(8), Some(0), ""); + let zero = engine_for(&dir); + assert_eq!(zero.config().max_attempts_per_source, 1); + assert_eq!( + zero.attempts_for("official", ResourceKind::Other, now_ms()), + 1 + ); let wild = DownloadTuning { max_concurrency: 9_999, - ..DownloadTuning::default() + ..Default::default() }; set_tuning(&dir, wild); assert_eq!( engine_for(&dir).concurrency_probe(), - super::super::config::MAX_CONCURRENCY, - "9999 张许可不能真开" + config::MAX_CONCURRENCY ); - std::fs::remove_dir_all(&dir).ok(); } } diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 687e3ba..177d81d 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -4,8 +4,6 @@ use super::catalog::SettingSchema; use super::schema::{SchemaId, SchemaVersion}; use super::spec::{AiPolicy, SettingSpec}; - -// M193:下载两键的默认值与硬上限的唯一来源在 `download::config`(引擎侧),这里只登记成 schema。 use crate::download::config as download; /// `core.data_mode` 允许值。 @@ -48,18 +46,23 @@ pub mod update; /// /// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。 /// `1.2.0` 登记 D23 的 JDK 落点与发行版优先级键(`java.installRoot` / `java.vendorPriority`): -/// 只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认。 +/// 同样只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认, +/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。 /// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` / -/// `accent_color`)与 `1.5.0` 的窗口圆角、`1.4.0` 的外观下层三键(M91):都是纯增量—— -/// 出厂全默认即等于老文件缺键。 -/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量 + 一步迁移(见 [`super::update_prefs`])。 +/// `accent_color`):同一套纯增量语义——出厂全默认(高级特性全关)即等于老文件缺键。 +/// `1.4.0` 登记 P4 的外观下层三键(`window_opacity` / `titlebar_mode` / `palette_source`,M91): +/// 仍然只推 minor、不加迁移步骤——三者的出厂值正是「老文件没有这些键」时的等价物。 +/// `1.5.0` 登记 M116 的窗口圆角策略(`appearance.window_corner`,出厂 `system`):纯增量。 +/// `1.6.0` 登记 M161 的「自更新」两键(见 [`update`]):纯增量但带一步数据迁移 +/// (此前住在 `cache/update/prefs.json`,迁移见 [`super::update_prefs`])。 /// `1.7.0` 登记 M168 的中文字体下载地址覆盖键(`ui.font_cdn`,见 [`font`]):纯增量、无迁移。 /// /// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时, /// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` → -/// `ReadOnlyPreserveSchema` → `FileAccess::ReadOnly`:**整份配置对它只读**(值照读、`store.set` -/// 一律 `SettingError::ReadOnly`),不是「只是少一档」。这正是新键必须推 minor 的原因—— -/// 回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里。 +/// `ReadOnlyPreserveSchema` → [`super::schema::SchemaCompat::access`] → `FileAccess::ReadOnly`。 +/// 也就是说**整份配置对它只读**(值照读、`store.set` 一律 `SettingError::ReadOnly`、`save` 跳过), +/// 不是「只是少一档」。这是 minor 提升的既定代价——也正是新键必须推 minor 而不是偷偷塞进旧版本的 +/// 原因:回退到旧版本会出现「设置改不动」,而原因只在 `$schema` 那一行里,不看版本号无从排查。 #[must_use] pub fn launcher_version() -> SchemaVersion { SchemaVersion::new(1, 7, 0) @@ -100,8 +103,6 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::sensitive(), "实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)", )) - // M193:默认值/区间只有一个来源——`download::config` 的常量。此前这里写 8、 - // `EngineConfig::default()` 写 64,界面说「当前 8」引擎却跑 64,调并发完全无效。 .with(SettingSpec::int( "download.concurrency", download::DEFAULT_MAX_CONCURRENCY as i64, @@ -112,9 +113,9 @@ pub fn launcher_schema() -> SettingSchema { .with(SettingSpec::int( "download.retry_limit", download::DEFAULT_MAX_ATTEMPTS_PER_SOURCE as i64, - (1, download::MAX_ATTEMPTS_PER_SOURCE as i64), + (0, download::MAX_ATTEMPTS_PER_SOURCE as i64), AiPolicy::silent(), - "单个下载源的最多尝试次数(含首次,1 = 不重试;出厂 3)。换源不受此限:候选链上有几个源就各给几次", + "单个下载源的最多尝试次数(含首次;出厂 3)。0 与 1 都等于「只试一次、不重试」——0 是旧版 GUI 提供过的档位,保留合法但不代表「不下载」。换源不受此限", )) .with(SettingSpec::enumeration( "download.region", @@ -227,9 +228,8 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::locked(), "LLM API 密钥——AI 永不读写的凭据(D9)", )) - // M39(P3 MCP server,02 §0「传输定论」):对外 agent 工具服务。 - // 三个开关默认「关 / 只读」——外部客户端拿到的是与内置客户端**同一注册表**的工具, - // 但写权限必须用户显式打开(02 §1「同一注册表、同一安全策略、同一审计日志」)。 + // M39(P3 MCP server,02 §0「传输定论」):对外 agent 工具服务。三个开关默认「关 / 只读」—— + // 外部客户端拿到的是与内置客户端**同一注册表**的工具,但写权限必须用户显式打开(02 §1)。 .with(SettingSpec::bool( "agent.mcp.enabled", false, diff --git a/crates/presenter/src/host/settings/download.rs b/crates/presenter/src/host/settings/download.rs index 22c0320..4e6be69 100644 --- a/crates/presenter/src/host/settings/download.rs +++ b/crates/presenter/src/host/settings/download.rs @@ -22,7 +22,7 @@ //! 「读起来是纯读、实际改全局状态」正是本仓吃过亏的那类形状。 use unlucky_core::download::config::{ - DownloadTuning, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY, + DownloadTuning, DEFAULT_MAX_ATTEMPTS_PER_SOURCE, DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY, }; use unlucky_core::download::shared; @@ -33,8 +33,8 @@ use super::{int, selected, text}; /// 并发数的推荐档位(`selected()` 找不到时回 0;手改出来的非档位值会被插进选项表,见 [`group`])。 const CONCURRENCY_LADDER: [&str; 5] = ["4", "8", "16", "32", "64"]; -/// 单源尝试次数的推荐档位。 -const RETRY_LADDER: [&str; 5] = ["1", "2", "3", "5", "10"]; +/// 单源尝试次数的推荐档位(`0` 是旧版 GUI 提供过的档位,语义 = 只试一次,见 schema 描述)。 +const RETRY_LADDER: [&str; 6] = ["0", "1", "2", "3", "5", "10"]; /// 下载(D7):并发/重试/地区/镜像偏好/代理/GitHub 加速前缀。 pub(super) fn group(env: &HostEnv) -> SettingGroupView { @@ -64,14 +64,18 @@ pub(super) fn group(env: &HostEnv) -> SettingGroupView { .with(SettingRowView::choice( unlucky_core::download::config::KEY_CONCURRENCY, "并发数", - "同时下载的文件数:出厂 8,可调 1~64(引擎硬上限 64,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效", + &format!( + "同时下载的文件数:出厂 {DEFAULT_MAX_CONCURRENCY},可调 1~{MAX_CONCURRENCY}(引擎硬上限,防止填 9999 把机器拖死);越界或手改坏值按出厂值生效" + ), &concurrency_options, selected(&concurrency_options, &concurrency), )) .with(SettingRowView::choice( unlucky_core::download::config::KEY_RETRY_LIMIT, "单源尝试次数", - "同一个源最多试几次(含首次;出厂 3)。换源不受此限:候选链上有几个源就各给几次", + &format!( + "同一个源最多试几次(含首次;出厂 {DEFAULT_MAX_ATTEMPTS_PER_SOURCE})。0 与 1 都等于「只试一次、不重试」——0 是旧版提供的档位,不代表「不下载」。换源不受此限" + ), &retry_options, selected(&retry_options, &retry), )) diff --git a/crates/presenter/src/host/settings_tests.rs b/crates/presenter/src/host/settings_tests.rs index 480747a..3dd3b7b 100644 --- a/crates/presenter/src/host/settings_tests.rs +++ b/crates/presenter/src/host/settings_tests.rs @@ -45,6 +45,20 @@ fn the_concurrency_setting_reaches_the_real_download_engine() { "尝试次数也要吃到设置" ); + // P2-1:`retry_limit = 0`(旧配置里真有)必须写得回去、且引擎真的只试一次(不是出厂 3)。 + set(&env, "download.retry_limit", "0").expect("0 是合法值(旧版档位)"); + let zero = crate::host::engine::engine(&env); + println!( + "retry_limit=0 => store={:?} engine.config().max_attempts_per_source={}", + env.store().doc().effective_i64("download.retry_limit"), + zero.config().max_attempts_per_source + ); + assert_eq!( + zero.config().max_attempts_per_source, + 1, + "0 必须落成「只试一次」,不许回落出厂 3" + ); + // 改回去 ⇒ 下一次取到的引擎跟着换(不是「重启才生效」)。 set(&env, "download.concurrency", "16").expect("再写并发"); let rebuilt = crate::host::engine::engine(&env); diff --git a/docs/download-settings.md b/docs/download-settings.md index c52972b..0c0ff43 100644 --- a/docs/download-settings.md +++ b/docs/download-settings.md @@ -67,6 +67,21 @@ config/launcher.json 调优按**数据根**存(与引擎登记处同键),不是进程级:同一进程里多个数据根时互不串味。 +## 3.1 哪些路径吃这套设置(哪些不吃) + +| 路径 | 入口 | 吃设置吗 | +|---|---|---| +| GUI 的**全部**下载:内容库(Modrinth/CurseForge)、版本清单、资源/库/日志文件、字体、JDK 安装、自更新 | `presenter::host::engine::engine` → `core::download::shared::engine_for` | ✅ 吃(并发/尝试次数/gh-proxy 都吃) | +| CLI `ull install` | `crates/cli/src/commands/install/mod.rs:95` 自建 `DownloadEngine::new(EngineConfig::default())` | ❌ **不吃**:并发与尝试次数是**出厂值**(8 / 3),不是用户设置值 | +| CLI `ull java` | `crates/cli/src/commands/java.rs:326` 同上 | ❌ 不吃 | +| CLI `ull pack` | `crates/cli/src/commands/pack.rs:286` 同上 | ❌ 不吃 | + +也就是说 `shared::build` 是 **presenter 侧**唯一一处宿主装配,但**不是全仓唯一**:CLI 有三处自建引擎, +它们在 M193 之前也不读设置(一直是裸 `EngineConfig::default()`),而 M193 把默认并发从 64 改成 8 之后, +`ull install` / `ull java` / `ull pack` 的并发**跟着从 64 变成 8**(出厂值变化,不是读到了设置)。 +要让它们也吃设置,得改成走 `shared::engine_for` + 从 CLI 自己的 store 灌 tuning —— 已立 finding, +不在 M193 的 scope(`crates/cli`)。 + ## 4. 三态语义:`download.gh_proxy` | 值 | 含义 | 效果 | @@ -78,8 +93,13 @@ config/launcher.json **空串 ≠ 关**:「我还没决定」和「我明确不要」是两件事。设置页的说明文案(presenter 行 + iced 行) 两档必须都写出来——这是最容易让用户以为「关掉了、其实还在套前缀」的地方。 -边界:非法值(0、负数、超上限、手改坏值)**一律回落出厂默认**,不报错、不阻断下载; -schema 在写入口就拒绝越界值,引擎侧再夹一次是纵深防御(老文件里可能躺着旧 schema 允许的大数)。 +边界:非法值(负数、超上限、手改坏值)**一律回落出厂默认**,不报错、不阻断下载;schema 在写入口 +就拒绝越界值,引擎侧再夹一次是纵深防御(老文件里可能躺着旧 schema 允许的大数)。 + +**`download.retry_limit = 0` 是合法值**(P2-1 复审的结论):旧版 GUI 的档位表里就有 `0`,所以 +范围保留 `0~10`,语义定为 **`0` 与 `1` 都 = 只试一次、不重试**(`attempts = max(1, value)`)。 +这样老配置零迁移、`ull config get` 读到的 0 与引擎真做的事一致(试 1 次),不会再出现 +「界面显示 0、引擎按 3 跑」。`) ## 5. 没接的四个键(已登记为 finding) @@ -106,6 +126,18 @@ schema 在写入口就拒绝越界值,引擎侧再夹一次是纵深防御( | M7 | 删掉 `host/engine.rs::engine` 里的 `apply_tuning` | `host::settings::tests::the_concurrency_setting…` | 红(探针 8,期望 2) | | M8 | 下载设置页的 gh-proxy 行改回 `("Preferred source", "Automatic (region)")` | `the_downloads_rows_state_semantics…` | 红 | +## 6.1 已知的观感问题(P3-1,归属下一条 mission) + +下载设置页把「GitHub 加速前缀」的三态画成了三行(`GitHub 加速前缀 / 留空 = 跟随内置默认`、 +`关掉它 / 填 off = 关闭`、`换掉它 / 填 URL 前缀 = 覆盖`)。这是**已知的观感问题**: +①`关掉它` / `换掉它` 读起来像按钮而不是取值说明;②同一个设置画成三行比三态本身更难懂。 + +不改的理由不是不重要,而是渲染器不支持「一行 + 副说明」:`pages/settings/frame.rs` 的 +`setting_row` 只画「左标签 + 右单行值」,没有副说明位;要改得动 `frame.rs`(M196 正在改它)。 +正确的家是**下一条「iced 设置数据通路」mission**——那三行会变成真控件,形状问题自然消失。 +(同一条 finding 里还记着 presenter 侧描述里的 `1~64` / 出厂值:presenter 已改成用常量 `format!`, +iced 侧的 `rows` 是 `&'static str`,仍是**已知的第二真相源**,等真控件一并解决。) + ## 7. 没验的部分 * **真实网络下的并发/重试行为未验**:守卫读的是 `Semaphore` 的真实许可数与 `attempts_for()` 的 diff --git a/docs/images/downloads-settings-zh.png b/docs/images/downloads-settings-zh.png index a9ecf73..e3cef2d 100644 Binary files a/docs/images/downloads-settings-zh.png and b/docs/images/downloads-settings-zh.png differ