fix(m193): 复审 r1 三条 P2——版本史还原、retry_limit 0 有诚实语义、装配点措辞更正

- P2-2:keys.rs 的 schema 版本史按原文逐字还原(1.4.0/1.5.0 的键名、1.2.0 的迁移口径、
  旧读新的完整结论),不再用版本文档换行数;腾行改从我自己新加的注释里出。
- P2-1:download.retry_limit 范围恢复 0~10,并把 0 的语义定成「只试一次」(attempts = max(1, 0)):
  老配置合法、零迁移、ull config get 读到的 0 与引擎真做的事一致(不再回落出厂 3)。
  schema 描述与 presenter/iced 两处 UI 都写清「0 与 1 都 = 只试一次,不代表不下载」。
- P2-3:shared.rs 的「唯一一处装配」更正为「presenter 侧唯一一处」(CLI 还有三处自建),
  docs 加「哪些路径吃这套设置」表,并立 finding(CLI install/java/pack 不读设置、并发 64→8)。
- P3-3:mirrors.rs 的 gh_proxies 注释改成「无生产读者,仅解析留档」。
- P3-4(presenter 侧):并发/重试描述的出厂值与上限改用常量 format!,不再是第二处字面量。
- 出图重拍(含 0/1 = 只试一次)。
This commit is contained in:
worker-dlmsettings authored and AskaEth committed 2026-11-09 05:24:12 +08:00
1 parent e8544b19a7
commit 94f56f9d9c
10 files changed
+173 -130

No files matched your search

+69 -76
View File
@@ -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<i64>,
@@ -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<String> {
let sources = |profile: &RegionProfile| -> Vec<String> {
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 不该改另一个根的引擎)。
+3 -2
View File
@@ -130,7 +130,8 @@ pub struct MirrorTable {
/// Zulu 元数据(v3 新增)。
#[serde(default)]
pub zulu: Option<ZuluTable>,
/// gh-proxy 类前缀。
/// gh-proxy 类前缀(**目前无生产读者**:生效前缀的唯一真相源是 `RegionProfile.gh_proxy`;
/// 这一列只作为 `mirrors.json` 的解析留档,别再按它做「改这里能生效」的接口)。
#[serde(default)]
pub gh_proxies: Vec<GhProxy>,
}
@@ -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]
+24 -27
View File
@@ -136,11 +136,10 @@ pub fn constructions(cache_root: impl AsRef<Path>) -> 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<Path>, 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();
}
}
+16 -16
View File
@@ -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,