Files
Unlucky-Launcher/crates/core/src/setting/keys.rs
T
AskaEth fa53d72f73 feat(nav): M300 导航轨折叠按钮 + 折叠宽度令牌 + 宽度过渡与落盘
用户原话「默认左侧固定,允许折叠」:M297 盘查确认 iced 从来没接过折叠按钮,
presenter 的 toggle_nav/nav_collapsed 是 Slint 遗留、唯一调用方在已退役的 crates/app。

## 令牌(先定数值再动手)
- nav_collapsed_width = 56 + 2 × Space::X1 = **64**:胶囊(M283 定稿 56×32,本轮不动)
  两侧各留 4px 才不被裁切;旧值 48 的死因写进注释(48 − 2×4 = 40 < 56)。
- 断言:折叠宽 ≥ 胶囊宽 + 2×X1、≥ touch_target 48、在 4px 栅格上。

## 折叠按钮与折叠态
- 按钮放**轨底、头像位之上**:轨顶一个像素都不动(M283/M299 的展开态对照图仍可比),
  轨底本来就是「固定贴底」的槽,两态下位置不变。几何与导航项同一套(56×32 药丸 + 52 命中高)。
- 折叠态:隐藏文字标签与账户名,每项/头像/按钮都补 M3 气泡 tooltip(折叠态不许是一列无名符号);
  纵节奏不变(项高仍 52),所以切换时图标不上下跳。五项图标两两不同由用例钉住。

## 动效(复用底盘、零新常数)
- 帧泵加**第二条并列宽度轨**(nav ⇄ rail),时长/曲线复用同一张 rail_spec();
  借用右栗那条会让左轨折叠改掉右栗的目标(暗耦合),tower 已否掉那个方案。
- 起跑在 update 侧;启动按落盘档对表(幂等,仅在没有轨道在跑时);折叠**不置 view_dirty**
  —— 宽度在 view 里逐帧采样,帧由轨道自己续,跑完 needs_frames() 回 false。

## 落盘(任务要求「重启读回仍折叠」)
- core schema 登记 ui.nav.collapsed(纯增量 bool,不碰 schemaVersion);presenter 桥加
  load/persist 一对(走唯一写入口 write_with,现读→改→落盘);启动 construct 读回。
- 截图通道不读用户配置(D30),折叠态出图走 ULL_NAV_COLLAPSED=1。

## 判据
- app-iced: 1053 passed(新增 3 条接线用例 + 7 条 shell/几何/树结构用例 + 6 条帧泵用例);
  presenter: 408 passed(新增落盘往返 1 条);fmt / clippy --workspace --all-targets 均 0。
- 变异实验:删 observe_nav ⇒ 宽度停在 88 红;删启动对表 ⇒ 首次展开硬跳红。
- 像素(DevDocs/artifacts/m300/):折叠态内容左缘 112→88(24px 真回收)、药丸两态都 56 宽
  且折叠档右缘 59 < 64(不裁切)、图标行是展开态子集(纵节奏不变)。

## 已知偏离(已登记,tower 批了紧随 mission)
crates/app-iced/src/pages/layout.rs 的 rail_width() 仍是 const fn(恒 88),所以折叠态内容块
宽度不变、整块左移 24px ⇒ 右侧多一条 24px 空白带(像素图里保留、未裁)。
2026-11-13 16:15:16 +08:00

386 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `config/launcher.json` 的键表(全局设置)。键名与默认值对齐 `04-data-and-config.md` §2-§3;
//! AI 可写 + 敏感分级按 `02-ai-agent-design.md` §3.1 落:**密钥类一律 locked**。
use super::catalog::SettingSchema;
use super::schema::{SchemaId, SchemaVersion};
use super::spec::{AiPolicy, SettingSpec};
/// `core.data_mode` 允许值。
pub const DATA_MODES: &[&str] = &["portable", "standard", "custom"];
/// `download.region` 允许值。
pub const REGIONS: &[&str] = &["auto", "cn", "global"];
/// `download.mirror_preference` 允许值。
pub const MIRROR_PREFERENCES: &[&str] = &["auto", "official", "mirror"];
/// `download.proxy_mode` 允许值。
pub const PROXY_MODES: &[&str] = &["none", "system", "manual"];
/// `ai.provider` 允许值。
pub const AI_PROVIDERS: &[&str] = &["openai_compatible", "anthropic", "ollama", "disabled"];
/// `ui.language` 允许值。
pub const LANGUAGES: &[&str] = &["zh_cn", "en_us"];
/// `appearance.theme_mode` 允许值(D17 定案:auto/light/dark)。
pub const THEME_MODES: &[&str] = &["auto", "light", "dark"];
/// `ui.theme` 的旧允许值(该键已废弃,仅读兼容,不再落盘)。
pub const LEGACY_THEMES: &[&str] = &["system", "metro_light", "metro_dark"];
/// 旧 `ui.theme` 值 → 新 `appearance.theme_mode` 值(tower 键冲突裁决的读兼容映射)。
pub const THEME_ALIASES: &[(&str, &str)] = &[
("system", "auto"),
("metro_light", "light"),
("metro_dark", "dark"),
];
/// `log.level` 允许值。
pub const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"];
/// `instances.mode` 允许值(D20 定案:默认隔离,用户可手动切共享)。
pub const INSTANCE_MODES: &[&str] = &["isolated", "shared"];
/// `java.installRoot` 未设置时的哨兵(空串 = 按数据目录模式派生落点)。
pub const JAVA_INSTALL_ROOT_UNSET: &str = "";
/// `java.vendorPriority` 的允许值(D23:四家发行版,顺序即优先级)。与 `game::java::vendor::VENDOR_IDS`
/// 必须同一份集合(setting 层不依赖 game 层),一致性由 `tests/setting/java_install_keys.rs` 守。
pub const JDK_VENDORS: &[&str] = &["temurin", "oracle", "microsoft", "zulu", "termux"];
/// 拆到各自文件的键域:中文字体 CDN(M168)/ 自更新(M161)/ 代理与 gh-proxy(M197)/
/// 下载调优四键(M292)。主表各留一行登记。
pub mod download;
pub mod font;
pub mod proxy;
pub mod update;
/// launcher.json 当前 schema 版本。
///
/// `1.1.0` 登记 D20 实例隔离键(`instances.mode` 等)。
/// `1.2.0` 登记 D23 的 JDK 落点与发行版优先级键(`java.installRoot` / `java.vendorPriority`):
/// 同样只推 minor、不强制迁移——[`super::document`] 的「默认值不落盘」让老文件缺键即取默认,
/// 迁移链(按 major 走)不需要新步骤;`1.1.0 → 1.2.0` 的差异只体现在文件里出现新键之后。
/// `1.3.0` 登记 D17 的「外观(高级)」P1 键(`appearance.bg_image*` / `panel_alpha` / `glass` /
/// `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.8.0` 登记 M197 的 gh-proxy 前缀覆盖键(`download.gh_proxy`):同样纯增量——出厂值是空串,
/// 语义正是「老文件没有这个键」时的行为(跟随内置镜像表的默认前缀)。
/// `1.9.0` 登记 M292 的三个下载超时/阈值键(`download.throughput_floor_kib` /
/// `stall_timeout_secs` / `response_timeout_secs`,见 [`download`]):纯增量,且**默认值与 M289 的
/// 硬编码逐值相等**——老文件缺这三键时的行为就是 M292 之前的行为。
///
/// **旧读新(方向相反,措辞必须精确)**:1.4.0 的客户端读 1.5.0 的文件时,
/// [`super::schema::SchemaCompat::check_url`] 走 `found.minor > expected.minor` →
/// `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, 9, 0)
}
/// `config/launcher.json` 的 schema。
#[must_use]
pub fn launcher_schema() -> SettingSchema {
SettingSchema::new(SchemaId::LAUNCHER, launcher_version())
.with(SettingSpec::enumeration(
"core.data_mode",
"portable",
DATA_MODES,
AiPolicy::sensitive(),
"数据目录模式:portable(exe 旁)/ standard(系统标准目录)/ custom(自定义)",
))
.with(SettingSpec::path(
"core.data_custom_root",
AiPolicy::sensitive(),
"custom 模式的根目录(必须是绝对路径)",
))
.with(SettingSpec::path(
"core.launcher_home",
AiPolicy::sensitive(),
"显式覆盖启动器数据目录(.ULL 的等价物)",
))
.with(SettingSpec::path(
"core.game_home",
AiPolicy::sensitive(),
"游戏目录(.minecraft),与数据目录分离指定",
))
// D20(research/04 §5.1):只留一个全局默认档,实例级 override 在
// `instance_keys::instance_schema`(`isolation`),派生逻辑在 `game::isolation`。
.with(SettingSpec::enumeration(
"instances.mode",
"isolated",
INSTANCE_MODES,
AiPolicy::sensitive(),
"实例默认隔离模式:isolated(运行目录派生 versions/<版本 id>,D20 默认)/ shared(所有实例共用 .minecraft 根)",
))
// M292:下载调优四键(并发 / 单源尝试次数 / 地区 / 镜像偏好)搬到 [`download`]——主表这一行
// 代替原先四条 `.with(..)`,登记顺序与搬到那边之前逐项相同。
.with_all(download::specs())
// M197:代理与 gh-proxy 三键(`download.proxy_mode` / `proxy_url` / `gh_proxy`)搬到
// [`proxy`]——主表这一段的注释也一起搬过去,见那个模块的表格。
.with_all(proxy::specs())
.with(SettingSpec::path(
"java.selected_path",
AiPolicy::sensitive(),
"用户选定的 Java 可执行文件路径",
))
.with(SettingSpec::bool(
"java.auto_install",
true,
AiPolicy::audited(),
// M197:这句会进 AI 工具的 JSON Schema `description`(`catalog.rs` 直接塞 `spec.doc`),
// 所以必须写**实现真做的事**:缺 Java 时在 Java 页标出推荐版本并可一键安装,
// 不会自己开始下载(是否下载由用户点「安装」决定)。
"缺 Java 时在 Java 页标出推荐版本并可一键安装;不会自动下载",
))
// D23:托管 JDK 的落点三模式。语义在 `game::java::location`:
// 未设置(空串)→ 按 `core.data_mode` 派生(Portable 数据根 / Standard 系统目录);
// 设置了 → 覆盖前两者,按 `<vendor>/<version>` 分层。
.with(SettingSpec::text(
"java.installRoot",
JAVA_INSTALL_ROOT_UNSET,
512,
AiPolicy::sensitive(),
"托管 JDK 的自定义安装根(留空 = 按数据目录模式:portable 进数据根、standard 进 %APPDATA%\\Java);设置后覆盖两者,JDK 落在 <root>/<发行方>/<版本>/",
))
// D23:发行版回退链的用户优先级。空数组 = 按地区默认序
// (国内 Temurin→Oracle→Microsoft→Zulu;国外 Temurin→Microsoft→Oracle→Zulu)。
.with(SettingSpec::enum_list(
"java.vendorPriority",
JDK_VENDORS,
AiPolicy::audited(),
"JDK 发行版优先级(temurin/oracle/microsoft/zulu);留空按地区默认序,只写几家则其余按默认序补齐",
))
.with(SettingSpec::int(
"java.default_memory_mb",
2048,
(512, 65536),
AiPolicy::silent(),
"默认分配给游戏的最大堆内存(MB)",
))
.with(SettingSpec::text(
"java.extra_args",
"",
1024,
AiPolicy::sensitive(),
"附加 JVM 参数(原样拼到命令行)",
))
.with(SettingSpec::bool(
"ai.enabled",
true,
AiPolicy::silent(),
"启用内置 AI agent(关闭后规则引擎诊断仍可用)",
))
.with(SettingSpec::enumeration(
"ai.provider",
"openai_compatible",
AI_PROVIDERS,
AiPolicy::sensitive(),
"LLM 提供方:openai_compatible(含本地 llama.cpp/DeepSeek/Qwen)/ anthropic / ollama / disabled",
))
.with(SettingSpec::text(
"ai.base_url",
"",
512,
AiPolicy::sensitive(),
"LLM 端点 base_url(OpenAI 兼容形态)",
))
.with(SettingSpec::text(
"ai.model",
"",
128,
AiPolicy::audited(),
"模型名(如 gpt-4o-mini / deepseek-chat)",
))
.with(SettingSpec::bool(
"ai.yolo",
false,
AiPolicy::sensitive(),
"自动批准全部工具调用(审计照记,白名单不变)",
))
.with(SettingSpec::new(
"ai.api_key",
super::spec::ValueKind::Text { max_len: 4096 },
serde_json::Value::Null,
AiPolicy::locked(),
"LLM API 密钥——AI 永不读写的凭据(D9)",
))
// M39(P3 MCP server,02 §0「传输定论」):对外 agent 工具服务。
// 三个开关默认「关 / 只读」——外部客户端拿到的是与内置客户端**同一注册表**的工具,
// 但写权限必须用户显式打开(02 §1「同一注册表、同一安全策略、同一审计日志」)。
.with(SettingSpec::bool(
"agent.mcp.enabled",
false,
AiPolicy::sensitive(),
"对外 MCP server 总开关(127.0.0.1 上的 Streamable HTTP;stdio 形态按需显式启动)",
))
.with(SettingSpec::int(
"agent.mcp.port",
47823,
(1024, 65535),
AiPolicy::sensitive(),
"MCP server 的 Streamable HTTP 端口(只绑 127.0.0.1)",
))
.with(SettingSpec::bool(
"agent.mcp.allow_write",
false,
AiPolicy::sensitive(),
"允许外部 MCP 客户端调用写工具(默认只读;敏感键仍受分级确认与 YOLO 规则约束)",
))
.with(SettingSpec::text(
"agent.mcp.token",
"",
256,
AiPolicy::locked(),
"MCP server 本地访问令牌(Authorization: Bearer;空 = 不校验,仅绑 127.0.0.1)",
))
.with(SettingSpec::enumeration(
"ui.language",
"zh_cn",
LANGUAGES,
AiPolicy::audited(),
"界面语言",
))
// M300(NAVCOLLAPSE):左侧导航轨折叠成图标条。纯视觉、一键可复原(切回 `false` 即展开),
// 没有安全面 ⇒ 分级与 `ui.language` 同族取 audited(AI 改过要留痕,但不要确认框)。
// 键名与 `unlucky_presenter::prefs::keys::NAV_COLLAPSED` 逐字相同(两侧各有一份常量,
// 相不相同由 `presenter/src/prefs/tests.rs` 与 `settings_bridge` 的往返用例一起钉住)。
.with(SettingSpec::bool(
"ui.nav.collapsed",
false,
AiPolicy::audited(),
"左侧导航轨折叠成图标条(false = 展开,带文字标签)",
))
.with(SettingSpec::enumeration(
"appearance.theme_mode",
"auto",
THEME_MODES,
AiPolicy::silent(),
"主题模式:auto(跟随系统)/ light(Metro 浅色)/ dark(Metro 深色)",
))
// D17「外观(高级)」P1 四件(09 §101-104 / §111):**默认全部关闭**(出厂 Metro 纯色)。
// 分级一律 audited(Medium):全部是纯视觉、可一键复原的键,没有安全面(不碰凭据/网络/
// 数据根),谈不上 High 级确认;但 AI 改过什么要留审计痕迹,故不用 silent。
.with(SettingSpec::bool(
"appearance.bg_image_enabled",
false,
AiPolicy::audited(),
"自定义窗口背景图开关(默认关;开启时设置页给出对比度提示)",
))
.with(SettingSpec::text(
"appearance.bg_image",
"",
512,
AiPolicy::audited(),
"背景图路径(PNG;空 = 未设置。Slint 默认解码器只带 PNG)",
))
.with(SettingSpec::enumeration(
"appearance.bg_fill",
"cover",
super::appearance::BG_FILLS,
AiPolicy::audited(),
"背景图填充模式:cover(铺满裁切)/ contain(完整留底色)/ tile(平铺)",
))
.with(SettingSpec::float(
"appearance.bg_dim",
0.35,
(
super::appearance::BG_DIM_MIN,
super::appearance::BG_DIM_MAX,
),
AiPolicy::audited(),
"背景图叠加暗化层强度(越大前景越清晰;09 §101 的对比度保底手法)",
))
.with(SettingSpec::float(
"appearance.panel_alpha",
1.0,
(super::appearance::PANEL_ALPHA_MIN, 1.0),
AiPolicy::audited(),
"全局面板半透明度(卡片/导航/AI 栏统一透明度;1.0 = 出厂不透明)",
))
.with(SettingSpec::bool(
"appearance.glass",
false,
AiPolicy::audited(),
"玻璃拟态卡片(背景图预生成模糊层作卡片底;Slint 无 backdrop-filter 的替代)",
))
.with(SettingSpec::text(
"appearance.accent_color",
"",
9,
AiPolicy::audited(),
"自定义强调色 #RRGGBB(空 = 出厂品牌板;非空时只换 primary/secondary/tertiary 三族,中性表面与 error 保持基线;非法值不写盘,设置页给错行)",
))
// M91(P4 / D36·D37·D38):「外观下层」三键——只登记取值与范围,判定与落地都在 UI 侧。
// 分级同 D17 那批(audited/Medium):纯视觉、可一键复原,但要留审计痕迹。
.with(SettingSpec::float(
"appearance.window_opacity",
1.0,
(super::appearance::WINDOW_ALPHA_MIN, 1.0),
AiPolicy::audited(),
"整窗透明度 0.08~1.0(1.0 = 完全不透明;下限 0.08 是为了不把窗口写到穿透,D36)",
))
.with(SettingSpec::enumeration(
"appearance.titlebar_mode",
"auto",
super::appearance::TITLEBAR_MODES,
AiPolicy::audited(),
"标题栏三态:auto(按合成器判定)/ always(总是自绘)/ never(合成器已装饰),默认 auto(D37)",
))
.with(SettingSpec::enumeration(
"appearance.palette_source",
"preset",
super::appearance::PALETTE_SOURCES,
AiPolicy::audited(),
"配色来源档(D38 三级):background-image(背景图取色)/ wallpaper(桌面壁纸取色)/ preset(内置色卡,默认)",
))
// M116:窗口外框圆角策略。只有两档(system / square)——系统不暴露圆角半径,
// 「固定 8/12/16」只能是自绘圆角,而自绘圆角在无边框 + 整窗 α 上要裁内容层,
// 会连标题栏三键与缩放热区一起裁掉(用户定案 2026-11-03)。
.with(SettingSpec::enumeration(
"appearance.window_corner",
"system",
super::appearance::WINDOW_CORNERS,
AiPolicy::audited(),
"窗口圆角:system(跟随系统:Win11/Server 2025 走 DWM,Win10 保持直角)/ square(总是直角),默认 system",
))
// 已废弃:D17 定案 appearance.theme_mode 为准(旧值读兼容映射,写永远走新键)。
.with(
SettingSpec::new(
"ui.theme",
super::spec::ValueKind::Enum(LEGACY_THEMES),
serde_json::Value::Null,
AiPolicy::retired(),
"【已废弃】改用 appearance.theme_mode;旧值读兼容:system→auto / metro_light→light / metro_dark→dark",
)
.deprecated("appearance.theme_mode", THEME_ALIASES),
)
.with(SettingSpec::float(
"ui.scale",
1.0,
(0.75, 2.0),
AiPolicy::silent(),
"界面缩放系数",
))
.with(SettingSpec::int(
"ui.window.width",
1280,
(320, 16384),
AiPolicy::silent(),
"主窗口宽度(逻辑像素)",
))
.with(SettingSpec::int(
"ui.window.height",
800,
(200, 16384),
AiPolicy::silent(),
"主窗口高度(逻辑像素)",
))
.with(SettingSpec::enumeration(
"log.level",
"info",
LOG_LEVELS,
AiPolicy::audited(),
"启动器日志级别",
))
.with_all(update::specs())
.with(font::cdn_spec())
}