Merge branch 'main' into feat/m182-maccorners-follow-compositor-rounde
冲突只有一个 hunk:`crates/app-iced/src/platform/mod.rs` 的模块声明表。
- 我方(M182):`pub mod host;` / `pub mod linux;` / `pub mod macos;` + `pub use host::HostOs;`
+ `plan_corner_for` 重导出;
- 主线(M168 `449ded2`):`pub mod data;`(字体缓存数据根派生)。
两边都要,全部保留并按字母序排:`data, host, linux, locale, macos, win32`。
另把模块分册表补一行 `data`(M168 的产品在表里原本缺位),首段「四件事」随之改成「五件事」。
`pub use win32::corner::{…}` 取我方超集(main 的四个名字 + `plan_corner_for`),逐个名字比对无丢失。
非冲突文件零改动:我方 11 个文件(host.rs / linux/ / macos/ / win32/ / boot/diagnostics.rs /
design/shape.rs)合并后与 tip 28762ec 逐字相同;`decorations: false` 仍在
`app/boot/diagnostics.rs:43`(裁决 B 未被越过)。
This commit is contained in:
commit
c198a3c995
139 files changed
+16640
-1414
No files matched your search
@@ -130,6 +130,12 @@ pub struct App {
|
||||
pub(super) refreshes: u64,
|
||||
/// 首屏快照到位后是否已经自动选中过第一行(list-detail 的桌面惯例,见 `update::auto_select`)。
|
||||
pub(super) auto_selected: bool,
|
||||
/// 上一次看到的**中文字体下载状态**(M168)。
|
||||
///
|
||||
/// 下载在后台线程里推进,状态是一份进程级快照(`fonts::install::status`)。这里存一份是为了
|
||||
/// 「变了才重建视图模型」:设置页那一行因此自己会从「正在获取」翻成「已就绪 / 失败」,
|
||||
/// 而空闲态不会为了它每拍白重建一次。
|
||||
pub(super) font_seen: crate::fonts::install::CjkInstall,
|
||||
/// 窗口高度(背景图平铺网格的视口判据;宽度在 `width`)。
|
||||
pub(super) height: f32,
|
||||
/// 外观运行期状态(M93):面板 α / 背景图 / 配色来源 / 诊断。
|
||||
|
||||
@@ -86,6 +86,10 @@ impl App {
|
||||
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
|
||||
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
|
||||
let lang = LangState::boot(&bridge, options.screenshot.is_some());
|
||||
// M168(复审 r2 P2-2):出图通道**不许联网**——`log_cjk` 那一拍会走 `after_probe`,
|
||||
// 一台 zh-locale 且没有中文字体的机器上会真的下 17 MiB,并可能在出图过程中注册新字体
|
||||
// (D30:同一份代码要出同一张图)。产品路径不设这个开关。
|
||||
crate::fonts::install::set_offline(options.screenshot.is_some());
|
||||
let mut view = pages::Model::new(
|
||||
pages::Screen::Instances,
|
||||
width,
|
||||
@@ -114,6 +118,9 @@ impl App {
|
||||
frames: 0,
|
||||
refreshes: 0,
|
||||
auto_selected: false,
|
||||
// M168:中文字体下载状态。构造这一拍 `lang.log_cjk()` 与启动编排的 `fonts::resolve()`
|
||||
// 已经可能发起下载,所以初值就取**当前快照**(不能假定是 Unknown,否则第一拍会白重建)。
|
||||
font_seen: crate::fonts::install::status(),
|
||||
height,
|
||||
appearance,
|
||||
appearance_prefs,
|
||||
@@ -142,6 +149,10 @@ impl App {
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
app.rebuild_ui();
|
||||
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
|
||||
app.hydrate_ai_settings();
|
||||
// M175:Java 分组的首屏值 + **首屏自动扫一次盘**(截图通道走 `ULL_JAVA_STATE`)。
|
||||
app.hydrate_java_settings();
|
||||
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
|
||||
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
|
||||
app.lang.log();
|
||||
|
||||
@@ -87,6 +87,8 @@ impl App {
|
||||
self.has_screenshot()
|
||||
|| !self.state.toasts.is_empty()
|
||||
|| !self.state.running().is_empty()
|
||||
// M175:Java 扫描进行中(进度 3/7 要动起来,否则用户只看到「正在扫描」卡着)。
|
||||
|| self.settings.java.scanning.is_some()
|
||||
|| self
|
||||
.watch_until
|
||||
.is_some_and(|deadline| Instant::now() < deadline)
|
||||
|
||||
@@ -95,7 +95,7 @@ impl App {
|
||||
self.dispatch(AppCommand::AddAuthlibAccount(request));
|
||||
}
|
||||
}
|
||||
Panel::Microsoft => return self.on_account(Account::StartBrowser),
|
||||
Panel::Microsoft => return self.on_account(Account::StartDevice),
|
||||
Panel::None => {}
|
||||
},
|
||||
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
|
||||
@@ -136,7 +136,8 @@ impl App {
|
||||
match account.kind {
|
||||
AccountKind::Microsoft => {
|
||||
self.account_form.panel = Panel::Microsoft;
|
||||
self.dispatch(AppCommand::StartBrowserLogin);
|
||||
// M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
|
||||
self.dispatch(AppCommand::StartDeviceLogin);
|
||||
}
|
||||
AccountKind::Authlib => {
|
||||
self.account_form.open(Track::Authlib);
|
||||
@@ -173,6 +174,23 @@ pub(super) fn force_account_state(app: &mut App) {
|
||||
app.state.device_login_started =
|
||||
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
|
||||
}
|
||||
// M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。
|
||||
//
|
||||
// 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。
|
||||
// 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条
|
||||
// (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。
|
||||
"msa-fallback" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Microsoft;
|
||||
app.state.apply_device_login(
|
||||
unlucky_presenter::dto::DeviceLoginView::waiting(
|
||||
"ABCD-EFGH",
|
||||
"https://www.microsoft.com/link",
|
||||
900,
|
||||
"",
|
||||
)
|
||||
.with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered),
|
||||
);
|
||||
}
|
||||
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
|
||||
"authlib-failed" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Authlib;
|
||||
@@ -198,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) {
|
||||
"closed" => {
|
||||
let _ = app.on_account(crate::pages::account::Message::Close);
|
||||
}
|
||||
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
|
||||
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
|
||||
"msa-entry" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Microsoft;
|
||||
}
|
||||
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
|
||||
"add" => {
|
||||
app.account_form.section_open = true;
|
||||
|
||||
@@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() {
|
||||
let _ = app.on_account(Account::StartDevice);
|
||||
match &app.account.device {
|
||||
DevicePanel::Code {
|
||||
code, remaining, ..
|
||||
code, countdown, ..
|
||||
} => {
|
||||
assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
|
||||
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑");
|
||||
assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
|
||||
}
|
||||
other => panic!("进行中状态必须可见,实际 {other:?}"),
|
||||
}
|
||||
|
||||
@@ -42,6 +42,16 @@ impl App {
|
||||
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// 中文字体下载(M168):后台线程推进,这里只比对快照。变了就重建视图模型 ⇒ 设置页那一行
|
||||
// 从「正在获取中文字体…(16.9 MB)」翻到「已就绪 / 获取失败」,不需要用户再点一下。
|
||||
let font_status = crate::fonts::install::status();
|
||||
if font_status != self.font_seen {
|
||||
self.font_seen = font_status;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// Java 扫描(M175):同一条「后台线程 + 快照」路数。进度(3/7)与结论都在里面,
|
||||
// 扫描期间按帧跑(见 `subscription::needs_frames`),扫完回到 1 Hz。
|
||||
self.hydrate_java_scan();
|
||||
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
|
||||
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
|
||||
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
|
||||
@@ -68,6 +78,10 @@ impl App {
|
||||
self.apply_config_theme();
|
||||
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
|
||||
self.reload_appearance();
|
||||
// M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。
|
||||
self.hydrate_ai_settings();
|
||||
// M175:Java 三键同理(外部改了内存 / JVM 参数 / 默认 JDK,设置页 1 秒内跟上)。
|
||||
self.hydrate_java_settings();
|
||||
}
|
||||
|
||||
if self.view_dirty {
|
||||
|
||||
@@ -1,17 +1,170 @@
|
||||
//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。
|
||||
//! 助手(AI)分组消息的落点(M174):草稿编辑 / 保存 / 测试连接。
|
||||
//!
|
||||
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。
|
||||
//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`);状态与读写落在
|
||||
//! `pages/settings/ai/{state,store}.rs`。本文件只有三件事:
|
||||
//!
|
||||
//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。
|
||||
//! | 消息 | 做什么 |
|
||||
//! |---|---|
|
||||
//! | `ToggleEnabled` / `PickProvider` / `EditBaseUrl` / `EditModel` / `EditKey` / `Reveal` | 只改草稿(`dirty = true`) |
|
||||
//! | `Save` | 先 [`State::validate`](pages::settings::ai::State::validate),再 `ai::store::save`(真写 `launcher.json`) |
|
||||
//! | `Test` | `Task::perform` 起一次真探测(`unlucky_cli::commands::agent::probe`),结果走 `ProbeDone` |
|
||||
//!
|
||||
//! ## 为什么「保存」不派 `AppCommand::SetSetting`
|
||||
//!
|
||||
//! 那条指令是可序列化的 DTO,`ai.api_key` 走它就意味着明文进了命令通道(事件日志 / mock 回放都会
|
||||
//! 看到)。`pages/settings/ai/store.rs` 的模块文档记着完整理由:这里走的是同一条 schema 通路
|
||||
//! (`SettingStore::set` + `save`),只是不经过那条通道。
|
||||
//!
|
||||
//! ## 为什么「测试」读文件而不是读草稿
|
||||
//!
|
||||
//! 测的是**已经保存的那份配置**(`probe_file` 每次现读 `launcher.json`)。用户在输入框里改了但没
|
||||
//! 点保存时,按钮旁边那句「有未保存的改动」会提醒他——而不是偷偷拿草稿去测一个不存在的配置。
|
||||
|
||||
use super::super::*;
|
||||
|
||||
use crate::pages::settings::ai::{Probe, ProbeResult};
|
||||
|
||||
impl App {
|
||||
/// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。
|
||||
/// 助手分组的一条消息。
|
||||
pub(super) fn on_settings_ai_about(
|
||||
&mut self,
|
||||
message: pages::settings::ai_about::Message,
|
||||
) -> Task<Message> {
|
||||
match message {}
|
||||
use pages::settings::ai_about::Message;
|
||||
|
||||
match message {
|
||||
Message::ToggleEnabled(enabled) => {
|
||||
self.settings.ai_about.enabled = enabled;
|
||||
self.touch();
|
||||
}
|
||||
Message::PickProvider(provider) => {
|
||||
self.settings.ai_about.provider = provider;
|
||||
self.touch();
|
||||
}
|
||||
Message::EditBaseUrl(value) => {
|
||||
self.settings.ai_about.base_url = value;
|
||||
self.touch();
|
||||
}
|
||||
Message::EditModel(value) => {
|
||||
self.settings.ai_about.model = value;
|
||||
self.touch();
|
||||
}
|
||||
Message::EditKey(secret) => {
|
||||
self.settings.ai_about.key = secret;
|
||||
self.touch();
|
||||
}
|
||||
Message::Reveal(reveal) => {
|
||||
self.settings.ai_about.reveal = reveal;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Message::Save => return self.save_ai_settings(),
|
||||
Message::Test => return self.test_ai_connection(),
|
||||
Message::ProbeDone(result) => {
|
||||
self.settings.ai_about.probe = Probe::Done(*result);
|
||||
self.view_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
|
||||
fn touch(&mut self) {
|
||||
self.settings.ai_about.dirty = true;
|
||||
self.settings.ai_about.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 从真值(`config/launcher.json`)灌一次草稿;截图通道改用 `ULL_AI_STATE` 的种子。
|
||||
///
|
||||
/// 什么时候调:① 构造之后(首屏就要显示已存的值);② 配置文件被外部改过(`update/clock.rs`
|
||||
/// 的 mtime 轮询)——`ull config set ai.model …` 或 AI 的 `set_config` 改完 1 秒内可见。
|
||||
/// **脏草稿不覆盖**([`State::hydrate`](pages::settings::ai::State::hydrate) 自己判),
|
||||
/// 否则外部一改文件就把用户正在输入的内容冲掉。
|
||||
pub(in crate::app) fn hydrate_ai_settings(&mut self) {
|
||||
if self.options.screenshot.is_some() {
|
||||
let raw = std::env::var("ULL_AI_STATE").unwrap_or_else(|_| "unconfigured".to_string());
|
||||
if let Some(state) = pages::settings::ai::State::screenshot_seed(&raw) {
|
||||
self.settings.ai_about = state;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
|
||||
return;
|
||||
};
|
||||
let snapshot = pages::settings::ai::store::load(&path);
|
||||
if self.settings.ai_about.hydrate(snapshot) {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新草稿。
|
||||
fn save_ai_settings(&mut self) -> Task<Message> {
|
||||
use pages::settings::ai::{store, SaveNotice};
|
||||
|
||||
if let Err(refusal) = self.settings.ai_about.validate() {
|
||||
self.settings.ai_about.notice = Some(SaveNotice::Refused(refusal));
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
}
|
||||
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
|
||||
self.settings.ai_about.notice = Some(SaveNotice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let saved = store::save(&path, &self.settings.ai_about);
|
||||
match saved {
|
||||
Ok(snapshot) => {
|
||||
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
|
||||
self.settings.ai_about.dirty = false;
|
||||
self.settings.ai_about.hydrate(snapshot);
|
||||
self.settings.ai_about.key = pages::settings::ai::Secret::default();
|
||||
self.settings.ai_about.notice = Some(SaveNotice::Saved);
|
||||
}
|
||||
Err(reason) => {
|
||||
self.settings.ai_about.notice =
|
||||
Some(pages::settings::ai::SaveNotice::Failed(reason));
|
||||
}
|
||||
}
|
||||
self.view_dirty = true;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 测试连接:真探测在后台跑(`probe_file` 现读设置文件),结果经 `ProbeDone` 回来。
|
||||
fn test_ai_connection(&mut self) -> Task<Message> {
|
||||
if !self.settings.ai_about.can_test() {
|
||||
return Task::none();
|
||||
}
|
||||
let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else {
|
||||
self.settings.ai_about.probe = Probe::Done(ProbeResult::from_outcome(
|
||||
unlucky_cli::commands::agent::probe::ProbeOutcome::NotConfigured {
|
||||
missing: unlucky_cli::commands::agent::probe::Missing::BaseUrl,
|
||||
},
|
||||
0,
|
||||
));
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
self.settings.ai_about.probe = Probe::Running;
|
||||
self.view_dirty = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
let started = std::time::Instant::now();
|
||||
let outcome = unlucky_cli::commands::agent::probe::probe_file(
|
||||
&path,
|
||||
unlucky_cli::commands::agent::probe::DEFAULT_TIMEOUT,
|
||||
);
|
||||
ProbeResult::from_outcome(outcome, started.elapsed().as_millis())
|
||||
},
|
||||
|result| {
|
||||
Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::AiAbout(
|
||||
pages::settings::ai_about::Message::ProbeDone(Box::new(result)),
|
||||
),
|
||||
))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,291 @@
|
||||
//! Java 分组消息的落点(M101 `SETTINGSMSG`):**只归 java 线**。
|
||||
//! Java 分组消息的落点(M175):扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数。
|
||||
//!
|
||||
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`)。加一支消息 = 改那两个
|
||||
//! 文件,`pages/mod.rs` / `page_messages.rs` / `app/update/settings.rs` 都不用动。
|
||||
//! 消息口在 `pages/settings/java.rs`(`pages::settings::java::Message`);状态与读写落在
|
||||
//! `pages/settings/java/{state,store}.rs`。本文件只有四件事:
|
||||
//!
|
||||
//! 消息进来的通道有两条,都不经过共享文件:
|
||||
//! 1. **页面点击**:`pages::Message::Settings(GroupMessage::Java(..))` → `on_page` → `on_settings`;
|
||||
//! 2. **异步结果**:本文件自己构造 `Message::Page(pages::Message::Settings(GroupMessage::Java(..)))`
|
||||
//! (`app::Message::Page` 是既有通道,不用加新变体)。
|
||||
//! | 消息 | 做什么 |
|
||||
//! |---|---|
|
||||
//! | `Scan` / `CancelScan` | 起一次后台真扫盘 / 置取消位(进度由心跳灌回状态) |
|
||||
//! | `AddDraft` / `Add` | 只改草稿 / 严格校验(真跑 `java -version`)+ 写手动名单 |
|
||||
//! | `SelectRuntime` | 写 `java.selected_path`(**立刻落盘**,与「设为默认」的语义一致) |
|
||||
//! | `EditMemory` / `EditJvm` / `Save` | 改草稿 / 校验后写 `java.default_memory_mb` + `java.extra_args` |
|
||||
//!
|
||||
//! 穷尽性:这里对 `java::Message` 做**穷尽**匹配(没有 `_ =>`、没有 `other =>`)。加了变体不接线
|
||||
//! 立刻 E0004;写成兜底会把编译错误降级成「点了没反应」,`app/update/settings/tests.rs` 的
|
||||
//! `the_group_dispatch_has_no_catch_all_arm` 会红。
|
||||
//! ## 为什么「设为默认」不攒到「保存」里
|
||||
//!
|
||||
//! 它与内存 / JVM 参数是两件事:前者是**选择**(点哪个用哪个,与方案里其它单选控件一致),
|
||||
//! 后者是**编辑**(有草稿、有脏标记、有保存)。把选择也攒进保存队列,用户点完「设为默认」看到
|
||||
//! 界面还标着旧的默认值,只会以为没生效。
|
||||
//!
|
||||
//! ## 版本门槛(「为什么选它」那句话的数据)
|
||||
//!
|
||||
//! `requirement_for(mc_version)` 是 core 的纯函数(静态表,读不到版本 JSON 时也成立)。
|
||||
//! 页面自己不看盘:本文件在每次 hydrate 时算好 [`Context`] 塞进状态,渲染器只负责画。
|
||||
|
||||
use super::super::*;
|
||||
|
||||
use crate::pages::settings::java::{Context, Layer, Notice, Requirement};
|
||||
|
||||
impl App {
|
||||
/// Java 分组的一条消息。
|
||||
pub(super) fn on_settings_java(
|
||||
&mut self,
|
||||
message: pages::settings::java::Message,
|
||||
) -> Task<Message> {
|
||||
use pages::settings::java::Message;
|
||||
|
||||
match message {
|
||||
// M101 r2 的**活的端到端证据**:只有测试构造这一支(java 线接第一个真控件时换成真变体)。
|
||||
// 它真的改一个 app 状态,所以「消息从外壳走到 handler」这件事有运行时证据,不只是编译期。
|
||||
#[cfg(test)]
|
||||
pages::settings::java::Message::Ping => {
|
||||
self.view_dirty = true;
|
||||
Task::none()
|
||||
Message::Scan => return self.start_java_scan(),
|
||||
Message::CancelScan => {
|
||||
self.settings.java.scan.cancel();
|
||||
self.hydrate_java_scan();
|
||||
}
|
||||
Message::AddDraft(value) => {
|
||||
self.settings.java.add_draft = value;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
Message::Add => self.add_java_manual(),
|
||||
Message::SelectRuntime(binary) => self.select_java_default(&binary),
|
||||
Message::EditMemory(value) => {
|
||||
self.settings.java.memory = value;
|
||||
self.touch_java();
|
||||
}
|
||||
Message::EditJvm(value) => {
|
||||
self.settings.java.jvm = value;
|
||||
self.touch_java();
|
||||
}
|
||||
Message::Save => self.save_java_settings(),
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
|
||||
fn touch_java(&mut self) {
|
||||
self.settings.java.dirty = true;
|
||||
self.settings.java.notice = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 从真值(`config/launcher.json` + 手动名单 + 扫描快照)灌一次状态。
|
||||
///
|
||||
/// 什么时候调:① 构造之后(首屏就显示已存的值);② 配置文件被外部改过(`update/clock.rs`
|
||||
/// 的 mtime 轮询)。**脏草稿不覆盖**(`State::hydrate` 自己判)。
|
||||
///
|
||||
/// 首次调用会**自动发起一次扫描**(用户实测的头一条抱怨就是「设置页内并没有自动检测本机已
|
||||
/// 安装过的 Java 路径」):已经扫完 / 正在扫 / 截图通道都不重扫,所以它只发生一次。
|
||||
///
|
||||
/// 截图通道的两档:`ULL_JAVA_STATE=<empty|two|editing>` 用种子(D30 可复现);
|
||||
/// `ULL_JAVA_STATE=live` 走**真路径**(读本机配置 + 真扫盘),用来人工看一眼「别的机器上到底
|
||||
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
|
||||
pub(in crate::app) fn hydrate_java_settings(&mut self) {
|
||||
let seeded = self.options.screenshot.is_some();
|
||||
let mut live = false;
|
||||
if seeded {
|
||||
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
|
||||
if raw.trim() == "live" {
|
||||
live = true;
|
||||
} else {
|
||||
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
|
||||
self.settings.java = state;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java = pages::settings::java::State::default();
|
||||
return;
|
||||
};
|
||||
let snapshot = pages::settings::java::store::load(&config);
|
||||
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
|
||||
let changed = self.settings.java.hydrate(snapshot);
|
||||
let scan_changed = self.hydrate_java_scan();
|
||||
self.settings.java.context = self.java_context();
|
||||
if changed || scan_changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
// 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起**
|
||||
// (单测不该真去查注册表 / 跑 PATH 上的 java);判据与发起本身由
|
||||
// [`App::auto_scan_java_if_due`] 承载,测试直接调它。
|
||||
if !cfg!(test) {
|
||||
self.auto_scan_java_if_due(seeded && !live);
|
||||
}
|
||||
}
|
||||
|
||||
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
|
||||
///
|
||||
/// 单独抽出来是为了可测:`hydrate_java_settings` 的自动发起被 `cfg!(test)` 关掉了,
|
||||
/// 而「取消过就不再自动扫」这条语义必须有一条能红的用例(r2 复审 P2-2)。
|
||||
pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool {
|
||||
let report = self.settings.java.scan.report();
|
||||
if pages::settings::java::store::auto_scan_due(&report, screenshot) {
|
||||
let _ = self.start_java_scan();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 把后台扫描的快照灌进状态。返回是否变了。
|
||||
pub(in crate::app) fn hydrate_java_scan(&mut self) -> bool {
|
||||
let report = self.settings.java.scan.report();
|
||||
let changed = self.settings.java.apply_scan(&report);
|
||||
if changed {
|
||||
self.view_dirty = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 起一次扫描(真扫盘:托管根 + `JAVA_HOME`/`PATH`/标准安装位置 + 手动名单)。
|
||||
fn start_java_scan(&mut self) -> Task<Message> {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return Task::none();
|
||||
};
|
||||
let manual = pages::settings::java::store::load_manual(&config)
|
||||
.into_iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
let roots = pages::settings::java::store::managed_roots(&config);
|
||||
self.settings.java.notice = None;
|
||||
if self.settings.java.scan.start(roots, manual, true) {
|
||||
self.hydrate_java_scan();
|
||||
}
|
||||
self.view_dirty = true;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 手动加一条 JDK 路径:严格校验 → 进名单 → 立刻出现在列表里。
|
||||
fn add_java_manual(&mut self) {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
let draft = self.settings.java.add_draft.clone();
|
||||
match pages::settings::java::store::add_manual(&config, &draft) {
|
||||
Ok((binary, row)) => {
|
||||
self.settings.java.manual = pages::settings::java::store::load_manual(&config);
|
||||
if !self
|
||||
.settings
|
||||
.java
|
||||
.runtimes
|
||||
.iter()
|
||||
.any(|known| pages::settings::java::same_path(&known.binary, &binary))
|
||||
{
|
||||
self.settings.java.runtimes.push(row);
|
||||
self.settings
|
||||
.java
|
||||
.runtimes
|
||||
.sort_by_key(|runtime| runtime.major);
|
||||
}
|
||||
self.settings.java.add_draft = String::new();
|
||||
// 手动加的这条已经是「盘上真扫到的一行」,所以顺便把「还没扫描过」这一档收掉:
|
||||
// 否则列表里明明有一行,状态行却写着「还没扫描过」。
|
||||
self.settings.java.scanned = true;
|
||||
self.settings.java.notice = Some(Notice::Added(binary));
|
||||
}
|
||||
Err(refusal) => self.settings.java.notice = Some(Notice::Refused(refusal)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 把某个运行时设为默认(写 `java.selected_path`,写完读回)。
|
||||
fn select_java_default(&mut self, binary: &str) {
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::store::set_selected(&config, binary) {
|
||||
Ok(snapshot) => {
|
||||
// **不许清脏**(r1 复审 P2-1):这一次只写了 `java.selected_path`,内存与 JVM
|
||||
// 参数的草稿一个字节都没落盘。`hydrate` 在 `dirty` 时本来就保留草稿、只刷新
|
||||
// `selected` / `saved_*`,所以这里唯一正确的做法是什么都不动。
|
||||
self.settings.java.hydrate(snapshot);
|
||||
self.settings.java.context = self.java_context();
|
||||
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 保存内存 + JVM 参数:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新。
|
||||
fn save_java_settings(&mut self) {
|
||||
let state = &self.settings.java;
|
||||
if let Err(refusal) = state.validate() {
|
||||
self.settings.java.notice = Some(Notice::Refused(refusal));
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
}
|
||||
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
|
||||
self.settings.java.notice = Some(Notice::NoStorage);
|
||||
self.view_dirty = true;
|
||||
return;
|
||||
};
|
||||
match pages::settings::java::store::save(&config, &self.settings.java) {
|
||||
Ok(snapshot) => {
|
||||
// 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。
|
||||
self.settings.java.dirty = false;
|
||||
self.settings.java.hydrate(snapshot);
|
||||
self.settings.java.context = self.java_context();
|
||||
self.settings.java.notice = Some(Notice::Saved);
|
||||
}
|
||||
Err(error) => self.settings.java.notice = Some(write_notice(error)),
|
||||
}
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
/// 当前实例的上下文(版本门槛 + 生效内存来自哪一层)。
|
||||
///
|
||||
/// 「当前实例」= 路由栈顶是详情页时那个实例,否则列表第一行。没有实例 ⇒ 空上下文
|
||||
/// (页面就不画门槛那一行,也不假装有要求)。
|
||||
fn java_context(&self) -> Context {
|
||||
let tiles = self.state.tiles();
|
||||
let routed = match self.state.route() {
|
||||
unlucky_presenter::router::Route::Detail(id) => {
|
||||
tiles.iter().find(|cell| &cell.instance.id == id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some(cell) = routed.or_else(|| tiles.first()) else {
|
||||
return Context::default();
|
||||
};
|
||||
let instance = cell.instance;
|
||||
let requirement = unlucky_core::game::java::requirement_for(&instance.mc_version);
|
||||
Context {
|
||||
required: Some(Requirement {
|
||||
instance: instance.name.clone(),
|
||||
version: instance.mc_version.clone(),
|
||||
minimum: requirement.minimum,
|
||||
maximum: requirement.maximum,
|
||||
}),
|
||||
layer: Some(match instance.launch.memory_mb {
|
||||
Some(mb) => Layer::Instance {
|
||||
instance: instance.name.clone(),
|
||||
mb,
|
||||
},
|
||||
None => Layer::Global,
|
||||
}),
|
||||
effective_memory_mb: Some(instance.launch.memory_effective_mb),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写盘失败 → 回执:三档都走 i18n key。
|
||||
///
|
||||
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
|
||||
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
|
||||
/// 界面上给一句页面自己的「写不进去」。
|
||||
fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
|
||||
use pages::settings::java::store::WriteError;
|
||||
match error {
|
||||
WriteError::Refused(refusal) => Notice::Refused(refusal),
|
||||
WriteError::Unwritable(reason) => Notice::Unwritable(reason),
|
||||
WriteError::Schema(detail) => {
|
||||
eprintln!("[java] 设置写盘失败:{detail}");
|
||||
Notice::WriteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,8 +256,12 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
|
||||
let mut app = headless();
|
||||
app.view_dirty = false;
|
||||
|
||||
// M175:`Ping` 随演示行一起删了,改用一个**真变体**(改内存草稿)——它同样只经这条漏斗
|
||||
// 改 `App` 状态,所以证据强度不变,还顺带证明这条臂真的接到了 handler 上。
|
||||
let _ = app.update(Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::Java(pages::settings::java::Message::Ping),
|
||||
pages::settings::GroupMessage::Java(pages::settings::java::Message::EditMemory(
|
||||
"4096".to_string(),
|
||||
)),
|
||||
)));
|
||||
|
||||
assert!(
|
||||
@@ -265,3 +269,128 @@ fn a_group_message_reaches_its_handler_through_the_funnel() {
|
||||
"java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了"
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- M174:助手配置的活路径
|
||||
|
||||
/// 一个指向临时目录的设置桥(真文件,不是内存态)。
|
||||
pub(super) fn bridged_app(label: &str) -> (App, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-ai-app-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let app = App::with_presenter(
|
||||
Options::default(),
|
||||
Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
},
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::with_path(&path),
|
||||
);
|
||||
(app, path)
|
||||
}
|
||||
|
||||
fn ai(message: pages::settings::ai_about::Message) -> Message {
|
||||
Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::AiAbout(message),
|
||||
))
|
||||
}
|
||||
|
||||
/// **M174 的活路径**:设置页那四条消息(改端点/模型/密钥)+ 保存,真的写进 `launcher.json`。
|
||||
///
|
||||
/// 抓得住:handler 没接 `Save`、`store::save` 没写某一个键、写错键名、密钥没落盘。
|
||||
/// 抓不住:用户真的点了那颗按钮(那是 iced 事件循环的事),也抓不住「保存后 agent 读到了」
|
||||
/// (那一半由 `unlucky-cli` 的 `probe_file` 用例证明:它读的就是这个文件)。
|
||||
#[test]
|
||||
fn saving_the_ai_form_writes_the_real_settings_file() {
|
||||
use pages::settings::ai_about::Message as Ai;
|
||||
|
||||
let (mut app, path) = bridged_app("save");
|
||||
let _ = app.update(ai(Ai::PickProvider(
|
||||
pages::settings::ai::ProviderId::OpenAiCompatible,
|
||||
)));
|
||||
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
|
||||
let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string())));
|
||||
let _ = app.update(ai(Ai::EditKey(pages::settings::ai::Secret::new(
|
||||
"sk-live-app-path",
|
||||
))));
|
||||
let _ = app.update(ai(Ai::Save));
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件");
|
||||
assert!(
|
||||
text.contains("\"base_url\": \"http://127.0.0.1:8080/v1\""),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("\"model\": \"qwen2.5-7b\""), "{text}");
|
||||
assert!(
|
||||
text.contains("\"provider\": \"openai_compatible\""),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("\"api_key\": \"sk-live-app-path\""), "{text}");
|
||||
|
||||
// 保存之后:草稿清干净、回执是「已保存」、密钥输入框清空(值已进文件,不再留在 UI 状态里)。
|
||||
let state = &app.settings.ai_about;
|
||||
assert_eq!(state.notice, Some(pages::settings::ai::SaveNotice::Saved));
|
||||
assert!(!state.dirty);
|
||||
assert!(state.key.is_empty());
|
||||
assert_eq!(state.key_chars, "sk-live-app-path".chars().count());
|
||||
let printed = format!("{state:?}");
|
||||
assert!(
|
||||
!printed.contains("sk-live-app-path"),
|
||||
"UI 状态泄漏密钥:{printed}"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// **非法值不保存**:端点没有协议头时,保存被拦下、文件不动、回执是具名拒绝原因。
|
||||
#[test]
|
||||
fn refusing_to_save_leaves_the_file_untouched() {
|
||||
use pages::settings::ai_about::Message as Ai;
|
||||
|
||||
let (mut app, path) = bridged_app("refuse");
|
||||
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
|
||||
let _ = app.update(ai(Ai::EditModel("qwen".to_string())));
|
||||
let _ = app.update(ai(Ai::Save));
|
||||
let saved = std::fs::read_to_string(&path).expect("第一次应当写成");
|
||||
|
||||
let _ = app.update(ai(Ai::EditBaseUrl("api.deepseek.com/v1".to_string())));
|
||||
let _ = app.update(ai(Ai::Save));
|
||||
assert_eq!(
|
||||
app.settings.ai_about.notice,
|
||||
Some(pages::settings::ai::SaveNotice::Refused(
|
||||
pages::settings::ai::state::Refusal::BaseUrl
|
||||
)),
|
||||
"没有协议头的端点必须被拦下,且要说清原因"
|
||||
);
|
||||
let after = std::fs::read_to_string(&path).expect("读文件");
|
||||
assert_eq!(saved, after, "被拒绝的保存不许改动文件:{after}");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// **未配置时不发请求**(GUI 侧那一半):缺模型名时点「测试连接」不会起任务、不留 Running 态。
|
||||
#[test]
|
||||
fn testing_without_configuration_does_nothing() {
|
||||
use pages::settings::ai_about::Message as Ai;
|
||||
|
||||
let (mut app, path) = bridged_app("no-test");
|
||||
let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string())));
|
||||
// 模型名空着 ⇒ `can_test()` 为假
|
||||
assert!(app.settings.ai_about.missing().is_some());
|
||||
let _ = app.update(ai(Ai::Test));
|
||||
assert_eq!(
|
||||
app.settings.ai_about.probe,
|
||||
pages::settings::ai::Probe::Idle,
|
||||
"未配置时点测试不该有任何探测态(更不该发请求)"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// M175 r1 复审的用例(拆出来守 400 行硬线;`gate::every_source_file_stays_under_the_line_limit` 会红)。
|
||||
#[path = "tests/java.rs"]
|
||||
mod java;
|
||||
@@ -0,0 +1,163 @@
|
||||
//! M175 的 App 层用例:**「设为默认」不许吃掉未保存的内存 / JVM 草稿**(r1 复审 P2-1)。
|
||||
//!
|
||||
//! 为什么单独一个文件:`tests.rs` 加上它之后 446 行,越过 400 行硬线
|
||||
//! (`app/components/gate.rs` 的 `every_source_file_stays_under_the_line_limit` 会判红)。
|
||||
|
||||
use super::bridged_app;
|
||||
use crate::app::Message;
|
||||
use crate::pages;
|
||||
|
||||
// --------------------------------------------------------------- M175 r1 P2-1:设为默认不许吃草稿
|
||||
|
||||
/// 一条 Java 分组消息 → 页面消息。
|
||||
fn java(message: pages::settings::java::Message) -> Message {
|
||||
Message::Page(pages::Message::Settings(
|
||||
pages::settings::GroupMessage::Java(message),
|
||||
))
|
||||
}
|
||||
|
||||
/// 改内存 / JVM 草稿(还没保存)之后去点「设为默认」,**草稿必须还在**,而且仍算未保存。
|
||||
///
|
||||
/// 复审 r1 P2-1 的现场:旧代码在 `select_java_default` 里先 `dirty = false` 再 `hydrate`,
|
||||
/// 于是只写了 `java.selected_path` 的一次操作把用户没保存的内存 / JVM 参数一起冲掉,
|
||||
/// 一个字节都没落盘、也没有任何提示。
|
||||
///
|
||||
/// 变异证明:把 `dirty = false` 那一行加回 `select_java_default` ⇒ 这条红(内存回到 2048、
|
||||
/// JVM 回到空、`dirty` 变假)。
|
||||
#[test]
|
||||
fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
|
||||
let (mut app, path) = bridged_app("java-select");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::EditMemory(
|
||||
"6144".to_string(),
|
||||
)));
|
||||
let _ = app.update(java(pages::settings::java::Message::EditJvm(
|
||||
"-XX:+UseG1GC".to_string(),
|
||||
)));
|
||||
assert!(app.settings.java.dirty, "改过草稿就该是脏的");
|
||||
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值还没变");
|
||||
|
||||
let _ = app.update(java(pages::settings::java::Message::SelectRuntime(
|
||||
"C:/jdk-21/bin/java.exe".to_string(),
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
app.settings.java.selected, "C:/jdk-21/bin/java.exe",
|
||||
"设为默认必须真的写进去"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.memory, "6144",
|
||||
"内存草稿被「设为默认」清掉了(P2-1 的数据丢失)"
|
||||
);
|
||||
assert_eq!(
|
||||
app.settings.java.jvm, "-XX:+UseG1GC",
|
||||
"JVM 草稿被「设为默认」清掉了(P2-1 的数据丢失)"
|
||||
);
|
||||
assert!(
|
||||
app.settings.java.dirty,
|
||||
"内存 / JVM 参数还没保存,不许标成已保存"
|
||||
);
|
||||
assert_eq!(app.settings.java.saved_memory, 2048, "落盘真值仍然没变");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来
|
||||
|
||||
static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
|
||||
/// 一个会一直卡住的扫描作业(直到被取消)。
|
||||
fn held_job(
|
||||
_candidates: &[unlucky_core::game::java::staged::Candidate],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
_progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Vec<pages::settings::java::RuntimeRow> {
|
||||
while !HOLD.load(std::sync::atomic::Ordering::SeqCst) && !cancelled() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 取消过一次之后,**配置文件再变(心跳重读)也不会把扫描自己拉起来**,状态行仍是「已取消」。
|
||||
///
|
||||
/// 现场(r2 复审 P2-2):被取消的扫描 `running=false` / `cancelled=true` / `finished` 仍是
|
||||
/// `false`,而 `auto_scan_due` 不看 `cancelled` ⇒ `clock.rs` 的 `reload_if_changed` 每变一次
|
||||
/// `launcher.json`(存内存、改主题、改语言都算)就重新起一个扫描线程,用户的取消被无声撤销。
|
||||
///
|
||||
/// 变异证明:把 `auto_scan_due` 里的 `!report.cancelled` 去掉 ⇒ 这里 `auto_scan_java_if_due`
|
||||
/// 会真的起第二个线程,`scanning` 变 `Some`、状态行不再是「已取消」⇒ 本条红。
|
||||
#[test]
|
||||
fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
HOLD.store(false, Ordering::SeqCst);
|
||||
let (mut app, path) = bridged_app("java-cancel");
|
||||
|
||||
// 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成
|
||||
// cancelled=true / finished=false —— 正是复审指认的那个形状。
|
||||
let slot = app.settings.java.scan.clone();
|
||||
assert!(slot.start_with(Vec::new(), Vec::new(), false, held_job));
|
||||
slot.cancel();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while slot.report().running && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(!slot.report().running, "取消之后线程必须退出");
|
||||
assert!(slot.report().cancelled);
|
||||
assert!(!slot.report().finished);
|
||||
|
||||
// 心跳那条路:把状态灌一次(`hydrate_java_scan`),再走「该自动扫就扫」的判据。
|
||||
app.hydrate_java_scan();
|
||||
assert!(app.settings.java.cancelled, "状态行要留着「已取消」");
|
||||
assert!(app.settings.java.scanning.is_none());
|
||||
|
||||
let started = app.auto_scan_java_if_due(false);
|
||||
assert!(
|
||||
!started,
|
||||
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销)"
|
||||
);
|
||||
assert!(
|
||||
!slot.report().running,
|
||||
"不许起第二个扫描线程:{:?}",
|
||||
slot.report()
|
||||
);
|
||||
app.hydrate_java_scan();
|
||||
assert!(
|
||||
app.settings.java.scanning.is_none(),
|
||||
"状态行不许回到「正在扫描」"
|
||||
);
|
||||
assert!(
|
||||
app.settings.java.cancelled,
|
||||
"取消标记要保持到下一次手动扫描为止"
|
||||
);
|
||||
|
||||
HOLD.store(true, Ordering::SeqCst);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- r2 P3-3:core 的中文串不许直出
|
||||
|
||||
/// 写盘被 schema 拒时,界面拿到的是**页面自己的 key**,而不是 `StoreError` 的中文 `Display`。
|
||||
///
|
||||
/// 现场(r2 复审 P3-3):`WriteError::Schema(format!("{error}"))` 里 `core::setting::StoreError`
|
||||
/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是
|
||||
/// `Not saved: IO 失败 @ …`。
|
||||
///
|
||||
/// 变异证明:`write_notice` 里把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 本条红。
|
||||
#[test]
|
||||
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
|
||||
let (mut app, path) = bridged_app("java-schema");
|
||||
|
||||
// `java.extra_args` 的 schema 是 `Text{max_len: 1024}`:本地校验(内存/引号)不管长度,
|
||||
// 所以这一条会走到 `store.set` 的 schema 拒绝那一档。
|
||||
let too_long = "-D".to_string() + &"x".repeat(2000);
|
||||
let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long)));
|
||||
let _ = app.update(java(pages::settings::java::Message::Save));
|
||||
|
||||
assert_eq!(
|
||||
app.settings.java.notice,
|
||||
Some(pages::settings::java::Notice::WriteFailed),
|
||||
"schema 拒绝要走页面的「写不进去」key,不能把 core 的中文句子原样上屏"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
//! layout.rs ← COMPBASE 独占:Q8/Q9 两个布局陷阱的固定解法
|
||||
//! buttons/ ← COMPBASE(12b §1 按钮家族 + §2 IconButton)
|
||||
//! divider.rs ← COMPBASE(15 §22)
|
||||
//! scroll.rs ← M173:竖直滚动区的滚动条槽(轨道不压内容)
|
||||
//! selection/ ← 本文件只放桩;后续 mission 在自己的目录里填(§18/§19/§10/§13)
|
||||
//! inputs/ ← 同上(§9/§20/§11/§8)
|
||||
//! feedback/ ← 同上(§12/§14/§7/§15)
|
||||
@@ -52,6 +53,7 @@ pub mod inputs;
|
||||
pub mod interaction;
|
||||
pub mod layout;
|
||||
pub mod navigation;
|
||||
pub mod scroll;
|
||||
pub mod selection;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! 滚动条**并排**在内容右侧,不压在内容上(M173)。
|
||||
//!
|
||||
//! ## 根因(读 iced 0.14 的源码,不是猜测)
|
||||
//!
|
||||
//! `scrollable` 的竖直滚动条是**浮层**,而且内容区不扣任何横向内边距:
|
||||
//!
|
||||
//! - 内容拿到的约束仍是整个视口宽——`iced_widget-0.14.2/src/scrollable.rs:437` 那条
|
||||
//! `layout::padded(limits, self.width, self.height, Padding::ZERO, …)`:`Scrollbar::spacing`
|
||||
//! 默认 `None`,右内边距**恒 0**;
|
||||
//! - 轨道按视口右边缘定位——同文件 `scrollable.rs:1970-1980`
|
||||
//! `x = bounds.x + bounds.width - total_scrollbar_width`,`total_scrollbar_width =
|
||||
//! width.max(scroller_width) + 2 * margin`。
|
||||
//!
|
||||
//! 于是轨道(本仓 6 + 2×2 = **10dp**)整条压在内容最右边那 10dp 上。屏幕上的观感就是
|
||||
//! 「滚动条骑在卡片右边缘」。
|
||||
//!
|
||||
//! ## 为什么不用 iced 自己的 `Scrollbar::spacing(Some(..))`
|
||||
//!
|
||||
//! 它**只在滑块可见时**才预留(`scrollable.rs:478-500`:`if state.is_scrollbar_visible
|
||||
//! { padding } else { 0.0 }`)。长页面 ⇄ 短页面切换时内容宽度会跳 10dp,卡片会横移——
|
||||
//! 那是另一个缺陷(`M173` 的判据要求「有无滚动条时内容宽度一致」)。
|
||||
//!
|
||||
//! ## 本模块的做法:恒定槽位
|
||||
//!
|
||||
//! 内容外面包一层「右侧恒留 `slot`」的容器,再交给 `scrollable`:
|
||||
//!
|
||||
//! ```text
|
||||
//! 视口 0 ───────────────────────── 视口宽 − slot │ slot │
|
||||
//! │ 内容(可滚) │ 轨道 │
|
||||
//! ```
|
||||
//!
|
||||
//! `内容可用宽度 = 视口宽 − slot` 与滑块在不在**无关**(因为内边距来自本模块,不来自 iced 的
|
||||
//! 可见性判断)。`slot = rail_width + 2 * margin` 与 iced 的 `total_scrollbar_width` 同口径,
|
||||
//! 轨道整个落在槽里,滑块再内缩 `margin`,所以内容与滑块之间永远隔着 `margin`。
|
||||
//!
|
||||
//! 逻辑像素(dp):高 DPI 下 iced 按缩放因子整体放大,槽与轨道同比例,二者不会重新重叠。
|
||||
//!
|
||||
//! ## 谁该用
|
||||
//!
|
||||
//! 所有「内容宽度吃满视口」的竖直滚动区。设置页(`pages/settings/frame.rs`)是第一个用户;
|
||||
//! 实例页列表列 / 详情列、日志面板、内容库正文、版本管理侧栏目前仍是 `scrollable(...)` 裸调,
|
||||
//! 同一处重叠在那些页面上照样存在(M173 的 finding 里有清单)。
|
||||
|
||||
use iced::widget::{container, scrollable};
|
||||
use iced::{Element, Length, Padding, Theme};
|
||||
|
||||
/// 滚动条轨占的横向槽宽(dp)= 轨宽 + 两侧留白。
|
||||
///
|
||||
/// 与 iced 的 `total_scrollbar_width`(`width.max(scroller_width) + 2 * margin`)同一个式子:
|
||||
/// 参数里的 `rail_width` 传的是 `Scrollbar::width` 与 `scroller_width` 的同一个值(本仓两者相等)。
|
||||
pub fn rail_slot(rail_width: f32, margin: f32) -> f32 {
|
||||
rail_width + 2.0 * margin
|
||||
}
|
||||
|
||||
/// 内容可用宽度(dp)= 视口宽 − 槽宽。
|
||||
///
|
||||
/// 这条式子是本模块的**判据**:`components/scroll.rs` 的测试用 iced 的布局引擎真量一遍,
|
||||
/// `pages/settings/tests/scroll_fit.rs` 在产品那条 `frame::scroll` 上再量一遍。
|
||||
/// 负数按 0 处理——视口比槽还窄时(产品窗口不会发生)宁可内容零宽,也不给 iced 送负宽。
|
||||
#[cfg(test)]
|
||||
pub fn content_width(viewport: f32, slot: f32) -> f32 {
|
||||
(viewport - slot).max(0.0)
|
||||
}
|
||||
|
||||
/// 竖直滚动区:内容右侧恒留 [`rail_slot`] 的槽,滚动条画在槽里。
|
||||
///
|
||||
/// `style` 就是 `scrollable::style` 回调(配色由调用方按令牌给),本模块只管几何。
|
||||
pub fn vertical<'a, Message, Renderer>(
|
||||
rail_width: f32,
|
||||
margin: f32,
|
||||
style: impl Fn(&Theme, scrollable::Status) -> scrollable::Style + 'a,
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Renderer: iced::advanced::text::Renderer + 'a,
|
||||
{
|
||||
scrollable(beside(rail_slot(rail_width, margin), content))
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(rail_width)
|
||||
.scroller_width(rail_width)
|
||||
.margin(margin),
|
||||
))
|
||||
.height(Length::Fill)
|
||||
.style(style)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 内容 + 右侧槽位。容器宽度仍是 `Fill`(= 视口宽),扣掉的是**内容的**可用宽度。
|
||||
fn beside<'a, Message, Renderer>(
|
||||
slot: f32,
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Renderer: iced::advanced::Renderer + 'a,
|
||||
{
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(Padding {
|
||||
right: slot,
|
||||
..Padding::ZERO
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! ## 为什么这里的守卫是**真的布局**而不是源码扫描
|
||||
//!
|
||||
//! iced 的控件树支持在测试里跑一遍布局:`iced_core` 在 debug 构建下给 `()` 实现了全套
|
||||
//! `Renderer` trait(`iced_core-0.14.0/src/renderer/null.rs`),所以 `Element<_, _, Theme, ()>`
|
||||
//! 能被 `Widget::layout` 真正量出尺寸——**不需要开窗、不需要 GPU**。
|
||||
//!
|
||||
//! 于是「内容可用宽度 = 视口宽 − 槽宽」是一条数值断言,而不是「看图觉得对」;把
|
||||
//! [`beside`] 的 `padding` 去掉,下面的 `content_width` 立刻变成 434 而断言红。
|
||||
//!
|
||||
//! ⚠️ 诚实边界:`()` 的 Renderer 实现带 `#[cfg(debug_assertions)]`,所以这些测试在
|
||||
//! `cargo test --release` 下编不过(本仓 CI/本地一律 dev profile 跑测试)。
|
||||
|
||||
use iced::advanced::layout::Limits;
|
||||
use iced::advanced::widget::Tree;
|
||||
use iced::widget::{container, scrollable, space};
|
||||
use iced::{Rectangle, Size};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 本仓的轨道参数(真源是 `pages/frame/pieces.rs` 的两个常量,这里按「用户取值」复核)。
|
||||
const RAIL: f32 = 6.0;
|
||||
const MARGIN: f32 = 2.0;
|
||||
/// 设置页表单列在 1180 宽窗口下的列宽(`measure_cap_px` = 424)。
|
||||
const VIEWPORT: f32 = 424.0;
|
||||
/// 视口高(1180×760 减去页头/页边距后的量级;只要求「长页 > 视口」「短页 < 视口」)。
|
||||
const VIEWPORT_HEIGHT: f32 = 600.0;
|
||||
/// 长页面(一定出滑块)与短页面(一定不出滑块)的内容高。
|
||||
const LONG: f32 = 4000.0;
|
||||
const SHORT: f32 = 120.0;
|
||||
|
||||
/// 真跑一遍布局,返回 (槽容器, 内容) 两个矩形。
|
||||
///
|
||||
/// 节点树:`scrollable → [槽容器(Fill 宽,右内边距 = slot) → [内容]]`,见
|
||||
/// `iced_core/src/layout.rs:201`(`padded` 的节点恒为「自己 + 一个子节点」)。
|
||||
fn laid_out(content_height: f32) -> (Rectangle, Rectangle) {
|
||||
let filler: Element<'_, (), Theme, ()> = container(space())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(content_height))
|
||||
.into();
|
||||
|
||||
let mut element: Element<'_, (), Theme, ()> =
|
||||
vertical::<(), ()>(RAIL, MARGIN, scrollable::default, filler);
|
||||
let mut tree = Tree::new(element.as_widget());
|
||||
|
||||
let node = element.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&Limits::new(Size::ZERO, Size::new(VIEWPORT, VIEWPORT_HEIGHT)),
|
||||
);
|
||||
|
||||
let gutter = node.children()[0].bounds();
|
||||
let content = node.children()[0].children()[0].bounds();
|
||||
|
||||
(gutter, content)
|
||||
}
|
||||
|
||||
/// **本 mission 的核心判据**:内容可用宽度 = 视口宽 − 滚动条槽,且与有没有滑块无关。
|
||||
#[test]
|
||||
fn the_content_width_leaves_the_rail_its_own_slot() {
|
||||
let slot = rail_slot(RAIL, MARGIN);
|
||||
assert_eq!(slot, RAIL + 2.0 * MARGIN, "槽宽口径 = 轨宽 + 两侧留白");
|
||||
|
||||
let (_, long) = laid_out(LONG);
|
||||
let (_, short) = laid_out(SHORT);
|
||||
|
||||
assert_eq!(
|
||||
long.width,
|
||||
content_width(VIEWPORT, slot),
|
||||
"长页面(出滑块)的内容宽度必须是「视口宽 − 槽宽」"
|
||||
);
|
||||
assert_eq!(
|
||||
short.width,
|
||||
content_width(VIEWPORT, slot),
|
||||
"短页面(不出滑块)的内容宽度必须**一样**:否则滑块一出现内容就横跳"
|
||||
);
|
||||
assert_eq!(VIEWPORT - long.width, slot, "右侧扣掉的正好是槽宽");
|
||||
|
||||
// 滑块落在槽的中间:内容右缘 → margin → 滑块 → margin → 视口右缘。
|
||||
// 轨道按 iced 的算法定位(`scrollable.rs:1970-2005`:槽宽 = 轨宽 + 2×margin,滑块再内缩 margin),
|
||||
// 所以「内容右缘 + margin」就是滑块的左缘。
|
||||
let content_right = long.x + long.width;
|
||||
let pill_left = content_right + MARGIN;
|
||||
let pill_right = pill_left + RAIL;
|
||||
assert_eq!(
|
||||
pill_right + MARGIN,
|
||||
VIEWPORT,
|
||||
"滑块必须整个落在槽内(两侧各留 margin),否则「并排」变成「压着」"
|
||||
);
|
||||
}
|
||||
|
||||
/// 槽容器自己仍是满宽(`Fill`),否则页面为它预留的 424 会被再吃掉一截。
|
||||
#[test]
|
||||
fn the_slot_wrapper_keeps_the_full_viewport_width() {
|
||||
let (gutter, _) = laid_out(LONG);
|
||||
assert_eq!(gutter.width, VIEWPORT);
|
||||
}
|
||||
|
||||
/// 左边界与宽度在长/短两态下**逐项相同**(M173 明写的「两张对照图 + 数值」里的数值部分)。
|
||||
#[test]
|
||||
fn long_and_short_pages_share_the_same_left_edge_and_width() {
|
||||
let (_, long) = laid_out(LONG);
|
||||
let (_, short) = laid_out(SHORT);
|
||||
|
||||
assert_eq!(long.x, short.x, "左边界必须一致");
|
||||
assert_eq!(long.width, short.width, "宽度必须一致");
|
||||
assert_eq!(short.x, 0.0, "槽只占右侧,左侧内边距为 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_width_never_goes_negative() {
|
||||
assert_eq!(content_width(RAIL, rail_slot(RAIL, MARGIN)), 0.0);
|
||||
assert_eq!(content_width(0.0, rail_slot(RAIL, MARGIN)), 0.0);
|
||||
assert_eq!(content_width(VIEWPORT, 0.0), VIEWPORT);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,22 @@ impl<'a, M> Chip<'a, M> {
|
||||
Self::new(label, ChipVariant::Input, false, on_press)
|
||||
}
|
||||
|
||||
/// **纯展示**的 filter chip(选中态、无消息 ⇒ 永远不可交互)。
|
||||
///
|
||||
/// 与 `filter(label, true, msg).disabled()` 等价,但**不要求调用方编一条永远收不到的消息**:
|
||||
/// 本轮 Java 页那颗「默认」标记就是这么用的(`Message::Noop` 已删,别再拿一条真消息当占位)。
|
||||
pub fn badge(label: &'a str) -> Self {
|
||||
Self {
|
||||
label,
|
||||
variant: ChipVariant::Filter,
|
||||
selected: true,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
on_press: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 转成禁用:**只清消息**,图标 / 选中态 / 状态层全部保留(复审 P2-1)。
|
||||
///
|
||||
/// 官方禁用态是「容器/描边 12%、文字与**图标** 38%」——只降**不删**。早先的
|
||||
|
||||
@@ -18,18 +18,31 @@
|
||||
|
||||
//! ## 不做什么
|
||||
//!
|
||||
//! **中文字体 CDN 下载不在这里**(后续 mission)。本模块只做「系统字体优先」这一半,
|
||||
//! 也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,M60 Q3 的 spike 做过)。
|
||||
//! 本模块只做「系统字体优先」这一半,也没有能力判断 CJK 字形覆盖(那是 `ttf-parser` 查 cmap 的活,
|
||||
//! M60 Q3 的 spike 做过)。中文字体的下载与注册在 [`install`] / [`cdn`](M168 实装)。
|
||||
//!
|
||||
//! ## 中文那一半(M92 / D28 后半)
|
||||
//! ## 中文那一半(M92 / D28 后半 / M168 实装下载)
|
||||
//!
|
||||
//! 语言探测 + 「中文环境有没有中文字体」的判据在 [`cjk`](判据本体在零依赖的 `unlucky_i18n::font`),
|
||||
//! 语言的真值源是 [`crate::platform::locale`](Windows 上 `reg.exe` 读注册表,零 FFI),
|
||||
//! 结论作为 [`Resolved::cjk`] 进同一行启动日志。**英文环境结论恒为 `NotNeeded`**:不要求中文字体、
|
||||
//! 不下载任何字体(系统里已有的 CJK 族照旧躺在 iced 的字体库里,我们既不注册新的也删不掉——那是
|
||||
//! cosmic-text 的全局状态,不是本模块能管的事)。
|
||||
//!
|
||||
//! 判出 `NeedsDownload` 之后**真的去下载**这件事在 [`install`](M168):
|
||||
//!
|
||||
//! ```text
|
||||
//! fonts::resolve_with / fonts::cjk_for → 判据(这里)
|
||||
//! fonts::install::after_probe → 只有 NeedsDownload 才发起(后台线程)
|
||||
//! fonts::cdn::CdnFetcher → core::download 的共享引擎(sha256 校验 + 缓存)
|
||||
//! fonts::install::register_into_font_system → 注册进 iced,随后的帧立刻能用中文字形
|
||||
//! ```
|
||||
//!
|
||||
//! 两个入口都只是**发起**:17 MiB 的字体不许挡在开窗之前,失败也只降级(继续用系统字体)。
|
||||
|
||||
pub mod cdn;
|
||||
mod cjk;
|
||||
pub mod install;
|
||||
|
||||
use cjk::CjkReport;
|
||||
use unlucky_design::typography::{FontFamily, Platform, Weight, WeightMatch};
|
||||
@@ -173,16 +186,30 @@ fn register_termux_fonts() {
|
||||
}
|
||||
|
||||
/// 同 [`resolve`],但把配置里 `ui.language` 的显式值喂给中文策略(D28 的第一级)。
|
||||
///
|
||||
/// 只做「取真实字体库 + 交给 [`resolve_with_faces`]」两件事:真正的求值在后者,于是
|
||||
/// **「启动这一拍不发起下载」这件事有测试可打**(复审 r3 P2-2:`system_faces()` 不可注入,
|
||||
/// 上一轮的修复没有任何用例守着,把 `after_probe` 加回来整套测试仍绿)。
|
||||
pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
register_termux_fonts();
|
||||
resolve_with_faces(setting, system_faces().as_deref())
|
||||
}
|
||||
|
||||
/// [`resolve_with`] 的**纯**求值:给定「某族装没装」的字面表,算出 [`Resolved`]。
|
||||
///
|
||||
/// `faces = None` 表示字体库读不出来(→ 中文策略 `Unprobeable`,不是「缺字体」)。
|
||||
///
|
||||
/// **这里不发起下载**(复审 r2 P2-2 ① / r3 P2-2):启动那一拍(`boot.rs` / `pages/harness/app.rs`)
|
||||
/// 拿到的 `setting` 是 `None`,只按系统 locale 判——zh-locale + 配置 `en_us` 的机器若在这里触发,
|
||||
/// 就会白下 17 MiB,而随后 `App` 读到配置又把状态翻成「当前界面语言不需要中文字体」。
|
||||
/// 唯一的触发点是 [`cjk_for`](`ui/lang.rs::log_cjk`,那一拍已经知道**生效语言**)。
|
||||
///
|
||||
/// 这条不变量由 `fonts::tests::resolving_fonts_never_starts_a_download` 守着。
|
||||
pub fn resolve_with_faces(setting: Option<&str>, faces: Option<&[Face]>) -> Resolved {
|
||||
let platform = Platform::current();
|
||||
let faces = system_faces();
|
||||
|
||||
let is_installed = |family: &str| -> bool {
|
||||
faces
|
||||
.as_ref()
|
||||
.is_none_or(|faces: &Vec<Face>| faces.iter().any(|face| face.family == family))
|
||||
faces.is_none_or(|faces: &[Face]| faces.iter().any(|face| face.family == family))
|
||||
};
|
||||
|
||||
let families = [
|
||||
@@ -206,11 +233,11 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
// 字重:**优先用系统里这个族真实提供的档位**(令牌表的 `WeightCoverage` 是「这个族通常有哪几档」,
|
||||
// 装机版可能是静态子集:Win Server / 精简版只有经典 Segoe UI,没有 500)。查不到才回落到令牌纯函数。
|
||||
let weights = [Weight::Regular, Weight::Medium, Weight::Bold]
|
||||
.map(|weight| weight_report(platform, families[0].family, weight, faces.as_deref()));
|
||||
.map(|weight| weight_report(platform, families[0].family, weight, faces));
|
||||
|
||||
// D28 后半:语言 + 中文环境的中文字体策略。`faces` 的 `None` 在这里是「字体库读不出来」,
|
||||
// 适配层会把它翻成 `Unprobeable` 而不是「缺字体」(否则会误触发一次 CDN 下载)。
|
||||
let cjk_report = cjk::probe_with(setting, faces.as_deref());
|
||||
let cjk_report = cjk::probe_with(setting, faces);
|
||||
|
||||
Resolved {
|
||||
platform,
|
||||
@@ -220,20 +247,34 @@ pub fn resolve_with(setting: Option<&str>) -> Resolved {
|
||||
}
|
||||
}
|
||||
|
||||
/// 语言切换后的中文策略重算(M106):`setting` 是配置里 `ui.language` 的**显式值**
|
||||
/// (`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
|
||||
/// 语言切换后的中文策略重算(M106 判据)+ **唯一**的下载触发点(M168):`setting` 是配置里
|
||||
/// `ui.language` 的**显式值**(`None` = 跟随系统,由 [`crate::platform::locale::SystemLocale`] 决定)。
|
||||
///
|
||||
/// 为什么需要它:启动那一轮([`resolve`])拿不到配置,只按系统 locale 判。用户在英文系统上
|
||||
/// 手选「简体中文」时,若不复算,策略会停在 `NotNeeded`——界面文字是中文、字体却没有中文字形,
|
||||
/// 那就是白块。复算出的 `NeedsDownload` 是 D28 的**信号**(下载器仍是 `Source::PendingPin`,
|
||||
/// 见 `unlucky_i18n::font`),本 crate 只负责把它打进日志、不让它静默。
|
||||
/// 那就是白块。
|
||||
///
|
||||
/// **为什么触发只留在这里**(复审 r2 P2-2 ①):这一拍的 `setting` 与 `LangChoice` 同源
|
||||
/// (`ULL_LANG` > 配置 > 系统 locale),也就是「界面实际用哪种语言」的那一份判据。
|
||||
/// 放在 [`resolve`] 里就会用「系统 locale」代表「用户选择」,zh-locale + 配置 `en_us` 的机器
|
||||
/// 会白下一次 17 MiB。
|
||||
///
|
||||
/// 两条通道各自靠什么做到「一次都不发」:**出图 harness**(`pages/harness/app.rs`)不经
|
||||
/// `App::with_presenter`,它零流量靠的是「[`resolve_with`] 根本不触发」;**`--screenshot`**
|
||||
/// 走真 `App`,除了同一条不变量,还有 `install::set_offline(true)` 这道闸。别把前者的功劳
|
||||
/// 记到 `set_offline` 上(复审 r3 P2-4 ②)。
|
||||
pub fn cjk_for(setting: Option<&str>) -> CjkReport {
|
||||
cjk::probe_with(setting, system_faces().as_deref())
|
||||
let report = cjk::probe_with(setting, system_faces().as_deref());
|
||||
install::after_probe(report.strategy);
|
||||
report
|
||||
}
|
||||
|
||||
/// 一条系统字面(族名 + 字重)。
|
||||
///
|
||||
/// `pub` 是因为 [`resolve_with_faces`] 把它作为参数暴露出去(那是「启动不发起下载」的唯一
|
||||
/// 可测入口,复审 r3 P2-2)——`private_interfaces` 会拦「pub 函数收私有类型」。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Face {
|
||||
pub struct Face {
|
||||
family: String,
|
||||
weight: u16,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 真实字体下载器(M168):把 `unlucky_i18n::font` 的 pin 接到 `unlucky_core::download`。
|
||||
//!
|
||||
//! ## 一句话
|
||||
//!
|
||||
//! ```text
|
||||
//! i18n 的 CjkFont.source(常量:URL 候选 + sha256 + 字节数)
|
||||
//! ↓ 允许设置改写首选 URL(`ui.font_cdn`)
|
||||
//! core 的 FontSpec(值)
|
||||
//! ↓ core::download::font::fetch_blocking(缓存 → 共享引擎 → 复验 → 原子落盘)
|
||||
//! Vec<u8>(交给 install 注册进 iced 的字体系统)
|
||||
//! ```
|
||||
//!
|
||||
//! 本模块只做三件适配:常量 → 值、缓存根从哪来([`crate::platform::data`])、错误类别翻译。
|
||||
//! 判据与落盘都不在这里——那两半各有自己的守卫(`i18n::font` 的单测、`core::download::font` 的
|
||||
//! 单测)。
|
||||
//!
|
||||
//! ## 「不联网」的三条路
|
||||
//!
|
||||
//! | 情形 | 谁拦住 |
|
||||
//! |---|---|
|
||||
//! | 英文环境 / 系统已有字体 | `i18n::font::ensure` 根本不调 [`Fetcher`] |
|
||||
//! | 同一进程第二次(缓存已在盘上) | `core::download::font` 的缓存分支(零 HTTP) |
|
||||
//! | 二次启动 | 同上:缓存文件按 `<sha256 前 16 位>` 命名,命中即不联网 |
|
||||
//!
|
||||
//! ## 设置改写只换 URL,不换哈希
|
||||
//!
|
||||
//! `ui.font_cdn` 允许换成国内镜像(整条 URL)。**哈希与字节数仍是内置那一份**:镜像是同一份内容
|
||||
//! 的不同出口,给了别的内容就该被拒——宁可不下载,也不把没校验过的字节注册成字体。
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::download::font::{self as core_font, FontFetchError, FontSpec};
|
||||
use unlucky_i18n::font::{CjkFont, FetchError, Source};
|
||||
|
||||
use crate::platform::data;
|
||||
|
||||
/// 字体下载器(一个进程用一份就够:无状态,除了缓存根)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CdnFetcher {
|
||||
cache_root: PathBuf,
|
||||
spec: FontSpec,
|
||||
/// 最近一次 `fetch` 是否**真的**命中缓存(`None` = 还没取过)。
|
||||
///
|
||||
/// 为什么不在调用方「取之前看一眼文件在不在」:那个判据会说谎——缓存文件若已损坏,
|
||||
/// `core::download::font` 会删掉重下(零网络不成立),而「取之前在不在」仍然是 true。
|
||||
/// 复审 r1 次要项点名的就是这个:日志 `from_cache=true` 必须等于「这次没联网」。
|
||||
last_from_cache: Cell<Option<bool>>,
|
||||
}
|
||||
|
||||
impl CdnFetcher {
|
||||
/// 按 pin 建一个下载器(`cache_root` 由调用方给,便于测试与截图通道用临时根)。
|
||||
///
|
||||
/// # Errors
|
||||
/// pin 与覆盖设置拼不出自洽的 spec 时返回人话错误(不发请求)。
|
||||
pub fn new(font: &CjkFont, cache_root: PathBuf) -> Result<Self, FetchError> {
|
||||
Ok(Self {
|
||||
cache_root,
|
||||
spec: spec_of(font)?,
|
||||
last_from_cache: Cell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// 缓存文件是不是已经在盘上(只 stat,不做校验)。
|
||||
///
|
||||
/// 用途只有**诊断**(`[font] cjk-download start … cache_present=`)。它**不是**命中判据:
|
||||
/// 真正的命中判定在 `core::download::font`(读出来 + 复验哈希与魔数),而「这次有没有联网」
|
||||
/// 只看 [`Self::was_cached`]——坏缓存会被删掉重下,那时这个函数仍然会返回 `true`。
|
||||
#[must_use]
|
||||
pub fn has_cached(&self) -> bool {
|
||||
core_font::cache_path(&self.cache_root, &self.spec).is_file()
|
||||
}
|
||||
|
||||
/// 最近一次 [`Fetcher::fetch`] 是否零网络命中缓存(`None` = 还没取过)。
|
||||
///
|
||||
/// 状态行与日志的 `from_cache` 只认这个值;`has_cached()` 只是「文件在不在」,两者不可互换。
|
||||
#[must_use]
|
||||
pub fn was_cached(&self) -> Option<bool> {
|
||||
self.last_from_cache.get()
|
||||
}
|
||||
|
||||
/// 缓存文件落点(诊断日志用)。
|
||||
#[must_use]
|
||||
pub fn cache_file(&self) -> PathBuf {
|
||||
core_font::cache_path(&self.cache_root, &self.spec)
|
||||
}
|
||||
}
|
||||
|
||||
impl unlucky_i18n::font::Fetcher for CdnFetcher {
|
||||
fn fetch(&self, _font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
let outcome = core_font::fetch_blocking(&self.cache_root, &self.spec).map_err(translate);
|
||||
self.last_from_cache
|
||||
.set(outcome.as_ref().ok().map(|fetch| fetch.from_cache));
|
||||
outcome.map(|fetch| fetch.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// i18n 的常量 pin + 设置覆盖 → core 的 `FontSpec`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 覆盖值不是 http(s) URL、或 pin 自身不自洽时返回 [`FetchError::Integrity`]。
|
||||
pub fn spec_of(font: &CjkFont) -> Result<FontSpec, FetchError> {
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = font.source;
|
||||
|
||||
let mut candidates: Vec<String> = urls.iter().map(|url| (*url).to_owned()).collect();
|
||||
if let Some(override_url) = data::font_cdn_override() {
|
||||
// 覆盖值排在最前(用户显式选了它),内置候选留作后备:覆盖值挂了还能回落到默认镜像。
|
||||
candidates.insert(0, override_url);
|
||||
}
|
||||
|
||||
FontSpec::new(candidates, sha256, bytes)
|
||||
.map_err(|error| FetchError::Integrity(error.to_string()))
|
||||
}
|
||||
|
||||
/// core 的三类错误 → i18n 的三类(字符串原样带过去,日志里能看到根因)。
|
||||
fn translate(error: FontFetchError) -> FetchError {
|
||||
match error {
|
||||
FontFetchError::Network(message) => FetchError::Network(message),
|
||||
FontFetchError::Integrity(message) => FetchError::Integrity(message),
|
||||
FontFetchError::Disk(message) => FetchError::Disk(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use unlucky_i18n::font::NOTO_SANS_SC;
|
||||
|
||||
/// pin → spec 的搬运:URL 候选、哈希、字节数一个都不许掉。
|
||||
///
|
||||
/// 抓得住:①漏搬 `sha256`(下载器就会「只要 200 就落盘」);②把候选列表截成一条(备用镜像
|
||||
/// 静默消失);③字节数被写成 0(大小上限判据失效)。
|
||||
/// 抓不住:`ui.font_cdn` 覆盖值在真机上读到什么(那要数据根 + 配置文件;见 `platform::data`)。
|
||||
#[test]
|
||||
fn the_pin_survives_the_trip_to_core() {
|
||||
let spec = spec_of(&NOTO_SANS_SC).expect("内置 pin 应自洽");
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = NOTO_SANS_SC.source;
|
||||
|
||||
assert!(
|
||||
spec.urls.len() >= urls.len(),
|
||||
"候选不该变少:{:?}",
|
||||
spec.urls
|
||||
);
|
||||
assert_eq!(spec.sha256, sha256);
|
||||
assert_eq!(spec.bytes, bytes);
|
||||
assert_eq!(
|
||||
spec.urls[spec.urls.len() - urls.len()],
|
||||
urls[0],
|
||||
"内置首选不许被挤掉"
|
||||
);
|
||||
}
|
||||
|
||||
/// 缓存命中判定只看文件在不在(**不联网**):一个刚建的空目录 ⇒ `has_cached() == false`。
|
||||
#[test]
|
||||
fn an_empty_cache_root_is_not_a_hit() {
|
||||
let dir = std::env::temp_dir().join(format!("ull-cdn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let fetcher = CdnFetcher::new(&NOTO_SANS_SC, dir.clone()).expect("下载器");
|
||||
assert!(!fetcher.has_cached());
|
||||
assert_eq!(fetcher.was_cached(), None, "还没取过 ⇒ 不知道");
|
||||
assert!(fetcher
|
||||
.cache_file()
|
||||
.ends_with("cjk-noto-sans-sc-a3041811a78c361b.ttf"));
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,9 @@
|
||||
//!
|
||||
//! - **抓得住**:语言 + 字体库族名 → 策略(含 `Unprobeable` 这一支);优先级顺序(注入的假源可测);
|
||||
//! - **抓不住**:①族名存在 ≠ 字形覆盖(`SimSun` 也可能是个残缺子集)。真判据要开 `ttf-parser`
|
||||
//! 查 cmap,本 mission 不引依赖(M60 Q3 spike 做过,结论见 `DevDocs`);
|
||||
//! ②下载与注册(`Strategy::NeedsDownload` 只是信号,`Source::PendingPin` 还没钉 URL/哈希)。
|
||||
//! 查 cmap,本 crate 不引依赖(M60 Q3 spike 做过);
|
||||
//! ②`Strategy::NeedsDownload` 之后的下载与注册(M168 已实装,见 [`super::cdn`] /
|
||||
//! [`super::install`]:URL 与 sha256 钉在 `unlucky_i18n::font::NOTO_SANS_SC`,本模块只到判据为止)。
|
||||
//!
|
||||
//! ## `ui.language` 的接线(M106)
|
||||
//!
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! 中文字体下载的**执行与状态**(M168):谁触发、什么时候触发、结果怎么被看见。
|
||||
//!
|
||||
//! ## 一句话
|
||||
//!
|
||||
//! ```text
|
||||
//! i18n::font::decide(判据) → Strategy
|
||||
//! fonts::install::after_probe(触发) → 此刻要不要开始下载(一份进程级状态)
|
||||
//! fonts::cdn::CdnFetcher(下载) → bytes 或三类错误
|
||||
//! fonts::install::register(注册) → iced 的字体系统里多一个族
|
||||
//! ```
|
||||
//!
|
||||
//! ## 两个触发点(都在 `fonts.rs`,因为那里是判据的两个调用点)
|
||||
//!
|
||||
//! | 入口 | 时机 | 谁来 |
|
||||
//! |---|---|---|
|
||||
//! | [`super::resolve_with`] | 启动(按系统 locale / `ULL_LANG`) | `app/boot.rs` |
|
||||
//! | [`super::cjk_for`] | 设置页切语言(`ui/lang.rs::log_cjk`) | 用户手选中文 |
|
||||
//!
|
||||
//! 两条路都只把**判据结论**交给 [`after_probe`]:只有 `NeedsDownload` 才会真的开始下载
|
||||
//! (英文环境 / 系统已有字体 / 探测不出,三路一次网络都不发;守卫见 `tests.rs`)。
|
||||
//!
|
||||
//! ## 为什么下载在后台线程而不是启动那一拍
|
||||
//!
|
||||
//! 一份 16.9 MiB 的字体在弱网下要几十秒。启动编排(`app/boot.rs`)是在**开窗之前**同步跑的,
|
||||
//! 在那里下载等于「点了图标,窗口几十秒不出来」。所以:启动路径只**发起**,窗口照常起来,
|
||||
//! 状态行(设置页 General 组)与 stderr 日志承担「正在获取 / 完成 / 失败」这三句话。
|
||||
//!
|
||||
//! ## 失败**不是**致命错误
|
||||
//!
|
||||
//! 任何一类失败(网络 / 校验 / 磁盘)都只让状态变成 [`CjkInstall::Failed`]:
|
||||
//! 界面继续用系统字体(中文用户)+ 系统字体(英文用户),程序照常可用。这正是 D28 的要求
|
||||
//! (「下载失败不崩」)。
|
||||
//!
|
||||
//! ## 状态是一份进程级快照
|
||||
//!
|
||||
//! `App` 每秒(心跳)比对一次快照,变了就重建视图模型(设置页那一行因此自己会刷新)。
|
||||
//! 为什么不用 iced 的 `Task::stream` 推事件:本 crate 的执行器是 `thread-pool`(`Cargo.toml`
|
||||
//! 的 feature 决定),推送通道要多一条生命周期不明的流;而「每秒看一眼快照」的代价是一把
|
||||
//! 短锁 + 一次比较,且天然不会在窗口关掉之后还往一个死掉的 channel 里写。
|
||||
//!
|
||||
//! ## 截图通道
|
||||
//!
|
||||
//! `ULL_FONT_STATUS=` 可把状态钉成任意一档(`system` / `downloading` / `ready` / `failed:network`
|
||||
//! / `not-needed` / `unprobeable`),只为了出图——与 `ULL_LANG` / `ULL_APPEARANCE_*` 同一族
|
||||
//! 取证开关(D30 可复现:同一份代码出同一张图)。产品路径不设这个变量。
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use unlucky_i18n::font::{self, FetchError, Fetched, Fetcher, Strategy};
|
||||
|
||||
/// 失败类别(与 `unlucky_i18n::font::FetchError` 的三类一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FetchClass {
|
||||
/// 网络层。
|
||||
Network,
|
||||
/// 校验层(哈希 / 魔数 / 大小上限)。
|
||||
Integrity,
|
||||
/// 磁盘层。
|
||||
Disk,
|
||||
}
|
||||
|
||||
impl FetchClass {
|
||||
/// 稳定类别名(日志用;UI 文案按它选 i18n key)。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Network => "network",
|
||||
Self::Integrity => "integrity",
|
||||
Self::Disk => "disk",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&FetchError> for FetchClass {
|
||||
fn from(error: &FetchError) -> Self {
|
||||
match error {
|
||||
FetchError::Network(_) => Self::Network,
|
||||
FetchError::Integrity(_) => Self::Integrity,
|
||||
FetchError::Disk(_) => Self::Disk,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文字体这一件的当前状态(设置页那一行与日志共用一份)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CjkInstall {
|
||||
/// 还没判定(启动前 / 判据没跑过)。
|
||||
Unknown,
|
||||
/// 英文环境:不需要中文字体(D28 的第一条)。
|
||||
NotNeeded,
|
||||
/// 系统里已有中文字体(含上一次下载后仍在字体库里的那份)。
|
||||
System {
|
||||
/// 命中的族名。
|
||||
family: String,
|
||||
},
|
||||
/// 探测不出(拿不到系统字体库)——**不下载**。
|
||||
Unprobeable,
|
||||
/// 正在下载。
|
||||
Downloading {
|
||||
/// 字节数(pin 里的期望值;用来写「16.9 MB」这种可核对的量)。
|
||||
bytes: u64,
|
||||
},
|
||||
/// 已就绪(下载完成并注册进字体系统)。
|
||||
Ready {
|
||||
/// 注册后的族名(真从字体库里读回来的,不是常量抄的)。
|
||||
family: String,
|
||||
/// 是否零网络命中缓存。
|
||||
from_cache: bool,
|
||||
},
|
||||
/// 失败(三类之一)——**不阻塞**,界面回落到系统字体照常可用。
|
||||
Failed {
|
||||
/// 类别。
|
||||
class: FetchClass,
|
||||
/// 人话原因(日志/诊断用;UI 只按类别选词)。
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CjkInstall {
|
||||
/// 是否正在下载(设置页要显示「进行中」,心跳也靠它决定要不要更勤地看图)。
|
||||
#[must_use]
|
||||
pub const fn is_downloading(&self) -> bool {
|
||||
matches!(self, Self::Downloading { .. })
|
||||
}
|
||||
|
||||
/// 一行日志片段(`cjk-install=…`)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Self::Unknown => "cjk-install=unknown".to_owned(),
|
||||
Self::NotNeeded => "cjk-install=not-needed".to_owned(),
|
||||
Self::System { family } => format!("cjk-install=system(\"{family}\")"),
|
||||
Self::Unprobeable => "cjk-install=unprobeable".to_owned(),
|
||||
Self::Downloading { bytes } => format!("cjk-install=downloading({bytes} bytes)"),
|
||||
Self::Ready { family, from_cache } => {
|
||||
format!("cjk-install=ready(\"{family}\",from_cache={from_cache})")
|
||||
}
|
||||
Self::Failed { class, message } => {
|
||||
format!("cjk-install=failed({},{message})", class.as_str())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程级状态(一份)。
|
||||
pub(super) fn slot() -> &'static Mutex<CjkInstall> {
|
||||
static STATE: OnceLock<Mutex<CjkInstall>> = OnceLock::new();
|
||||
STATE.get_or_init(|| Mutex::new(CjkInstall::Unknown))
|
||||
}
|
||||
|
||||
/// 当前状态(设置页那一行、心跳、日志都读它)。
|
||||
#[must_use]
|
||||
pub fn status() -> CjkInstall {
|
||||
if let Some(forced) = capture_override() {
|
||||
return forced;
|
||||
}
|
||||
slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 截图通道的状态钉子(`ULL_FONT_STATUS=…`)。
|
||||
fn capture_override() -> Option<CjkInstall> {
|
||||
static FORCED: OnceLock<Option<CjkInstall>> = OnceLock::new();
|
||||
FORCED
|
||||
.get_or_init(|| parse_capture(&std::env::var(CAPTURE_ENV).ok()?))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 取证开关名(与 `ULL_LANG` / `ULL_APPEARANCE_*` 同族)。
|
||||
pub const CAPTURE_ENV: &str = "ULL_FONT_STATUS";
|
||||
|
||||
/// `ULL_FONT_STATUS` 的取值 → 状态(纯函数,截图夹具直接打这张表)。
|
||||
#[must_use]
|
||||
pub fn parse_capture(raw: &str) -> Option<CjkInstall> {
|
||||
let value = raw.trim();
|
||||
Some(match value {
|
||||
"not-needed" => CjkInstall::NotNeeded,
|
||||
"unprobeable" => CjkInstall::Unprobeable,
|
||||
"system" => CjkInstall::System {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
},
|
||||
"downloading" => CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
"ready" => CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
},
|
||||
"failed" | "failed:network" => CjkInstall::Failed {
|
||||
class: FetchClass::Network,
|
||||
message: "capture fixture".to_owned(),
|
||||
},
|
||||
"failed:integrity" => CjkInstall::Failed {
|
||||
class: FetchClass::Integrity,
|
||||
message: "capture fixture".to_owned(),
|
||||
},
|
||||
"failed:disk" => CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: "capture fixture".to_owned(),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 纯状态机:判据结论 + 下载器 + 注册器 → 结局(**不碰全局状态、不起线程**)。
|
||||
///
|
||||
/// 单测直接打这张表(守「不需要时一次都不联网」与「下载失败不崩」)。
|
||||
pub fn ensure_for(
|
||||
strategy: Strategy,
|
||||
fetcher: &dyn Fetcher,
|
||||
register: &dyn Fn(Vec<u8>) -> Result<String, String>,
|
||||
) -> CjkInstall {
|
||||
match font::ensure(strategy, fetcher) {
|
||||
Ok(Fetched::NothingToDo) => CjkInstall::NotNeeded,
|
||||
Ok(Fetched::AlreadySystem { family }) => CjkInstall::System {
|
||||
family: family.to_owned(),
|
||||
},
|
||||
Ok(Fetched::Unknown) => CjkInstall::Unprobeable,
|
||||
Ok(Fetched::Downloaded(bytes)) => match register(bytes) {
|
||||
Ok(family) => CjkInstall::Ready {
|
||||
family,
|
||||
from_cache: false,
|
||||
},
|
||||
Err(message) => CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message,
|
||||
},
|
||||
},
|
||||
Err(error) => CjkInstall::Failed {
|
||||
class: FetchClass::from(&error),
|
||||
message: error.message().to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 判据结论 + **当前状态** → 这一步该做什么(纯函数,不碰全局状态、不起线程)。
|
||||
///
|
||||
/// 这是幂等闸的**唯一判据**:`after_probe` 只是它的执行者。之所以拎成纯函数,是因为上一版
|
||||
/// 有一条「自称守卫、其实什么都没测」的用例(复审 r1 P2-4):它在循环里断言一个从没被传进任何
|
||||
/// 函数的假下载器「没被调用」,恒绿。现在守卫直接打这张表——把 `Skip` 那一臂删掉,用例必红。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Step {
|
||||
/// 什么都不做(已经在下载 / 已就绪)。
|
||||
Skip,
|
||||
/// 只把状态改成这个(零网络)。
|
||||
Apply(CjkInstall),
|
||||
/// 真的发起一次下载。
|
||||
Start(&'static font::CjkFont),
|
||||
}
|
||||
|
||||
/// 判据表(判据本体就在这张表的 `match` 里,`after_probe` 不含任何判据)。
|
||||
///
|
||||
/// | 当前状态 | 策略 | 这一步 |
|
||||
/// |---|---|---|
|
||||
/// | **下载中**(任意策略) | 任意 | **`Skip`**(闸在最上面,见下) |
|
||||
/// | `Ready` | 任意 | `Skip`(幂等:已就绪不再发起) |
|
||||
/// | 其它 | `NotNeeded` / `System` / `Unprobeable` | `Apply`(只改状态,零网络) |
|
||||
/// | 其它 | `NeedsDownload` | `Start`(失败过的重试只能由用户再切一次语言触发) |
|
||||
///
|
||||
/// ## 为什么「下载中」要在**所有**策略之前先跳过(复审 r2 P2-2 ②)
|
||||
///
|
||||
/// 上一版把 `is_downloading()` 只放在 `NeedsDownload` 分支里,于是「下载中 + `Unprobeable`」
|
||||
/// 会走 `Apply(Unprobeable)` —— 把状态从「正在下载」改回「无法探测」,而**线程还在飞**:
|
||||
/// 下完之后 `Ready` 又把它盖回去,状态行在 30 秒里来回翻。当时给出的理由是「否则界面会停在
|
||||
/// 正在下载」——那个理由只对**僵死**的 `Downloading` 成立(线程 panic 才会僵死),而在飞是
|
||||
/// 常见情形。
|
||||
///
|
||||
/// ## 「僵死」那一半按构建档分两说(复审 r3 P2-1,别再当成一条统一的兜底)
|
||||
///
|
||||
/// | 档 | panic 语义 | 线程 panic 之后 |
|
||||
/// |---|---|---|
|
||||
/// | `release`(`Cargo.toml` 的 `panic = "abort"`,发布就走它) | 进程直接 abort | 不存在「停在 Downloading」,因为**进程没了**;反过来说「下载失败不崩」在 panic 这一支上对本档**不成立**(注册路径 panic ⇒ 启动器整体死掉,不是降级) |
|
||||
/// | `dev` / `test` / `fast`(unwind) | 栈展开 | `worker` 的 `catch_unwind` 把状态写成 `Failed`,闸因此不会卡死 |
|
||||
///
|
||||
/// 也就是说:**闸前移在两种档下都安全**(release 靠「进程退出」,unwind 档靠 `catch_unwind`),
|
||||
/// 但 `catch_unwind` **不能**用来给「下载失败不崩」背书——它只在 unwind 档存在。字体注册路径的
|
||||
/// panic 在发布版仍会让启动器 abort,这是本 mission 未解决的一项(报告里列为未验/风险)。
|
||||
///
|
||||
/// `offline = true`(出图通道)时**永不** `Start`:D30 要求同一份代码出同一张图,而下载会额外
|
||||
/// 产生网络流量并可能在出图过程中注册新字体。
|
||||
#[must_use]
|
||||
pub fn plan_with_offline(strategy: Strategy, current: &CjkInstall, offline: bool) -> Step {
|
||||
if current.is_downloading() || matches!(current, CjkInstall::Ready { .. }) {
|
||||
return Step::Skip;
|
||||
}
|
||||
match strategy {
|
||||
Strategy::NotNeeded => Step::Apply(CjkInstall::NotNeeded),
|
||||
Strategy::System { family } => Step::Apply(CjkInstall::System {
|
||||
family: family.to_owned(),
|
||||
}),
|
||||
Strategy::Unprobeable => Step::Apply(CjkInstall::Unprobeable),
|
||||
// 出图通道需要下载 ⇒ **什么都不做**(也不改状态):写「不需要下载」是谎话,
|
||||
// 写「正在下载」也是谎话(并没有在下载)。
|
||||
Strategy::NeedsDownload(_) if offline => Step::Skip,
|
||||
Strategy::NeedsDownload(font) => Step::Start(font),
|
||||
}
|
||||
}
|
||||
|
||||
/// 同 [`plan_with_offline`],但按本进程当前的联网策略(生产判据入口)。
|
||||
#[must_use]
|
||||
pub fn plan(strategy: Strategy, current: &CjkInstall) -> Step {
|
||||
plan_with_offline(strategy, current, offline())
|
||||
}
|
||||
|
||||
/// 判据结论 → 现在这一步的结局(生产入口;判据全在 [`plan`],这里只执行)。
|
||||
///
|
||||
/// * 已经在下载 / 已就绪 → 什么都不做(幂等:切一次语言不会起第二条下载、不会重复注册 16.9 MiB);
|
||||
/// * 英文环境 / 系统已有 / 探测不出 → 只更新状态(零网络);
|
||||
/// * 上次失败过 → 只有再次切语言才会重试(用户显式动作),不会自己反复联网;
|
||||
/// * 出图通道([`set_offline`])→ 永不发起。
|
||||
pub fn after_probe(strategy: Strategy) -> CjkInstall {
|
||||
match plan(strategy, &status()) {
|
||||
Step::Skip => status(),
|
||||
Step::Apply(state) => {
|
||||
update(state.clone());
|
||||
state
|
||||
}
|
||||
Step::Start(font) => {
|
||||
worker::start(font);
|
||||
status()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 本进程是否**禁止**发起字体下载(出图通道用;D30:同一份代码出同一张图)。
|
||||
///
|
||||
/// 为什么需要它:截图/演示通道也会走 `App` 构造 → `lang.log_cjk()` → `cjk_for` → `after_probe`。
|
||||
/// 一台 zh-locale、没有中文字体的机器上,出图会真的下 16.9 MiB 并在出图过程中注册新字体——
|
||||
/// 这既产生流量,也让「同一份代码出同一张图」不再成立。
|
||||
pub fn set_offline(offline: bool) {
|
||||
OFFLINE.store(offline, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// 当前是否处于 [`set_offline`] 的状态。
|
||||
#[must_use]
|
||||
pub fn offline() -> bool {
|
||||
OFFLINE.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 进程级联网开关(默认允许)。
|
||||
static OFFLINE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// 状态变更时记一行(只有真变了才打,避免每秒刷一行)。
|
||||
pub(super) fn update(state: CjkInstall) {
|
||||
let previous = {
|
||||
let mut guard = slot()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
std::mem::replace(&mut *guard, state.clone())
|
||||
};
|
||||
if previous != state {
|
||||
eprintln!("[font] {}", state.describe());
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载线程 + 字体注册(`install/worker.rs`):`start` 只在这里被触发。
|
||||
mod worker;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "install/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,369 @@
|
||||
//! `fonts::install` 的守卫(M168):**三条能变红的断言**,每条都写明「抓得住什么」。
|
||||
//!
|
||||
//! 这些用例全部脱机:下载器是假的(记调用次数),注册器也是假的(记收到的字节)。真下载、真注册
|
||||
//! 进 iced 字体系统这两件事**不在**本文件的能力范围内(真注册要 iced 的全局字体系统,见报告)。
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use unlucky_i18n::font::{CjkFont, FetchError, Fetcher, Strategy, NOTO_SANS_SC};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 假下载器:记调用次数与拿到的字体族名,返回预置结果。
|
||||
struct FakeFetcher {
|
||||
calls: RefCell<usize>,
|
||||
seen: RefCell<Option<&'static str>>,
|
||||
result: Result<Vec<u8>, FetchError>,
|
||||
}
|
||||
|
||||
impl FakeFetcher {
|
||||
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
|
||||
Self {
|
||||
calls: RefCell::new(0),
|
||||
seen: RefCell::new(None),
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
*self.calls.borrow()
|
||||
}
|
||||
}
|
||||
|
||||
impl Fetcher for FakeFetcher {
|
||||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
*self.calls.borrow_mut() += 1;
|
||||
*self.seen.borrow_mut() = Some(font.family);
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 假注册器收到的字节长度(只关心「注册了几次、每次多大」)。
|
||||
type Registered = std::rc::Rc<RefCell<Vec<usize>>>;
|
||||
|
||||
/// 假注册器:记收到的字节数,返回一个族名。
|
||||
fn recorder() -> (Registered, impl Fn(Vec<u8>) -> Result<String, String>) {
|
||||
let seen = std::rc::Rc::new(RefCell::new(Vec::new()));
|
||||
let recorder = std::rc::Rc::clone(&seen);
|
||||
let register = move |bytes: Vec<u8>| {
|
||||
recorder.borrow_mut().push(bytes.len());
|
||||
Ok("Noto Sans SC".to_owned())
|
||||
};
|
||||
(seen, register)
|
||||
}
|
||||
|
||||
/// 守卫 ①:**英文环境 / 系统已有字体 / 探测不出 ⇒ 一次都不联网**。
|
||||
///
|
||||
/// 抓得住:把 `ensure_for` 的判据换成「先试着下一下看看」、或 `after_probe` 里对这三种策略也走
|
||||
/// `start()`——本用例在 `fetcher.calls() == 0` 处当场红(这是新路径,不是 i18n 那条既有用例)。
|
||||
/// 抓不住:`after_probe` 起线程之后的真实网络行为(那由 `core::download::font` 的守卫与
|
||||
/// `CdnFetcher` 的缓存判定承担)。
|
||||
#[test]
|
||||
fn the_three_idle_strategies_never_touch_the_fetcher() {
|
||||
let (registered, register) = recorder();
|
||||
|
||||
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::NotNeeded, &idle, ®ister),
|
||||
CjkInstall::NotNeeded
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::System { family: "SimSun" }, &idle, ®ister),
|
||||
CjkInstall::System {
|
||||
family: "SimSun".to_owned()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::Unprobeable, &idle, ®ister),
|
||||
CjkInstall::Unprobeable
|
||||
);
|
||||
assert_eq!(
|
||||
idle.calls(),
|
||||
0,
|
||||
"英文 / 系统已有 / 探测不出:一次都不许调下载器"
|
||||
);
|
||||
assert!(registered.borrow().is_empty(), "这三路不许注册任何字体");
|
||||
|
||||
// 反方向:真需要时才调一次(否则上面那三个 0 可能是「永远不调」的假绿)。
|
||||
let working = FakeFetcher::new(Ok(vec![9; 4096]));
|
||||
assert_eq!(
|
||||
ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &working, ®ister),
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: false
|
||||
}
|
||||
);
|
||||
assert_eq!(working.calls(), 1);
|
||||
assert_eq!(working.seen.borrow().as_deref(), Some("Noto Sans SC"));
|
||||
assert_eq!(*registered.borrow(), vec![4096]);
|
||||
}
|
||||
|
||||
/// 守卫 ②:**校验失败 ⇒ 状态 `Failed(integrity)`,且一个字节都不注册**。
|
||||
///
|
||||
/// 抓得住:把 `ensure_for` 的错误分支写成「失败也注册回落的字节」、或把类别压成 `network`
|
||||
/// (设置页就会说「网络不通」,而真相是镜像给了不对的东西)。
|
||||
/// 抓不住:sha256 到底比得对不对(那是 `core::download::font` 的守卫)。
|
||||
#[test]
|
||||
fn an_integrity_failure_registers_nothing_and_keeps_the_class() {
|
||||
let (registered, register) = recorder();
|
||||
let broken = FakeFetcher::new(Err(FetchError::Integrity("sha256 不符".to_owned())));
|
||||
|
||||
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken, ®ister);
|
||||
assert_eq!(
|
||||
state,
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Integrity,
|
||||
message: "sha256 不符".to_owned()
|
||||
}
|
||||
);
|
||||
assert!(registered.borrow().is_empty(), "校验失败不许注册");
|
||||
assert!(!state.is_downloading(), "失败是终态:不再转圈、不阻塞界面");
|
||||
}
|
||||
|
||||
/// 三类失败各归各的类别(设置页按类别选词,串了就等于对用户说错话)。
|
||||
#[test]
|
||||
fn every_failure_class_survives_the_trip() {
|
||||
let (_, register) = recorder();
|
||||
for (error, class) in [
|
||||
(FetchError::Network("超时".to_owned()), FetchClass::Network),
|
||||
(
|
||||
FetchError::Integrity("哈希不符".to_owned()),
|
||||
FetchClass::Integrity,
|
||||
),
|
||||
(FetchError::Disk("磁盘满".to_owned()), FetchClass::Disk),
|
||||
] {
|
||||
let fetcher = FakeFetcher::new(Err(error));
|
||||
let state = ensure_for(Strategy::NeedsDownload(&NOTO_SANS_SC), &fetcher, ®ister);
|
||||
match state {
|
||||
CjkInstall::Failed { class: got, .. } => assert_eq!(got, class),
|
||||
other => panic!("应当是 Failed:{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// 注册本身失败(字体系统锁毒掉 / 字体库不认)也要落到失败的磁盘档,而不是 Ok。
|
||||
let fetcher = FakeFetcher::new(Ok(vec![7; 8]));
|
||||
let state = ensure_for(
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
&fetcher,
|
||||
&|_bytes| Err("字体系统锁被毒掉".to_owned()),
|
||||
);
|
||||
assert!(matches!(
|
||||
state,
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// 幂等闸(M168 复审 P2-4:这条以前是**假守卫**,现在打真判据)。
|
||||
///
|
||||
/// 复审的原话:上一版的循环体只断言「一个从没被传进任何函数的假下载器没被调用」,把
|
||||
/// `after_probe` 里的 `is_downloading() || Ready` 整段删掉它**依然全绿**。
|
||||
///
|
||||
/// 现在直接打 [`plan`]:删掉 `Skip` 那一臂(或把它改成 `Start`),本用例在
|
||||
/// `assert_eq!(plan(...), Step::Skip)` 处当场红。为什么这条闸不是可有可无:去掉之后每切一次
|
||||
/// 语言就 spawn 一条线程,线程会命中缓存(不联网)但会**再注册一遍 17 MiB 字体**
|
||||
/// (`load_font(Cow::Owned)` 不做去重),内存随切换次数累积。
|
||||
///
|
||||
/// 抓得住:幂等判据被删/被改反(下载中、已就绪两种状态各自一行)。
|
||||
/// 抓不住:`after_probe` 真的 spawn 几条线程(那要真线程 + 真网络,见报告「未验」)。
|
||||
#[test]
|
||||
fn a_started_or_finished_install_is_not_restarted() {
|
||||
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
|
||||
let ready = CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
};
|
||||
|
||||
for current in [downloading, ready] {
|
||||
assert_eq!(
|
||||
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t, false),
|
||||
Step::Skip,
|
||||
"{current:?}:已经在下载 / 已就绪时再问一次,必须什么都不做"
|
||||
);
|
||||
}
|
||||
|
||||
// 反方向(否则上面两条可能是「永远 Skip」的假绿):其余三种当前状态都要真的发起。
|
||||
for current in [
|
||||
CjkInstall::Unknown,
|
||||
CjkInstall::NotNeeded,
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Network,
|
||||
message: "超时".to_owned(),
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
plan_with_offline(Strategy::NeedsDownload(&NOTO_SANS_SC), ¤t, false),
|
||||
Step::Start(&NOTO_SANS_SC),
|
||||
"{current:?}:需要下载且还没成就该发起(失败后的重试也只能走这条)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 三种「不需要下载」的策略在判据表里只产生 `Apply`(**零网络**),且状态文案对得上。
|
||||
///
|
||||
/// 这里显式给 `offline = false`(`plan_with_offline`)而不是用 `plan`:`plan` 会读进程级的
|
||||
/// `set_offline` 开关,而同进程里 `headless(screenshot_options())` 那 20+ 处会把它置 true 且
|
||||
/// 从不还原(复审 r3 P2-4 ①)——用 `plan` 的话这三条的结果取决于执行顺序。
|
||||
///
|
||||
/// 抓得住:谁把 `NotNeeded` / `System` / `Unprobeable` 的臂改成 `Start`(那就会替英文用户偷偷
|
||||
/// 下一次 17 MiB);也抓得住「系统已有字体」被记成「需要下载」这种状态映射错。
|
||||
/// 抓不住:真线程行为(`after_probe` 的执行部分)。
|
||||
#[test]
|
||||
fn the_idle_strategies_only_apply_state() {
|
||||
assert_eq!(
|
||||
plan_with_offline(Strategy::NotNeeded, &CjkInstall::Unknown, false),
|
||||
Step::Apply(CjkInstall::NotNeeded)
|
||||
);
|
||||
assert_eq!(
|
||||
plan_with_offline(
|
||||
Strategy::System { family: "SimSun" },
|
||||
&CjkInstall::Unknown,
|
||||
false
|
||||
),
|
||||
Step::Apply(CjkInstall::System {
|
||||
family: "SimSun".to_owned()
|
||||
})
|
||||
);
|
||||
// 复审 r2 P2-2 ② 改判:**下载中一律 Skip**,即使策略说「探测不出」。
|
||||
// 上一版在这里断言 `Apply(Unprobeable)`,理由是「否则界面会停在正在下载」——那个理由只对
|
||||
// 僵死成立(线程 panic),而在飞是常见情形:线程还在下,状态却被改成「无法探测」,下完又
|
||||
// 翻回「已就绪」,状态行在几十秒里来回翻。「僵死」那一半按构建档分两说(worker 模块文档):
|
||||
// unwind 档(dev/test/fast)由 `catch_unwind` 兜底;发布档 `panic = "abort"` 是进程退出,
|
||||
// 不存在停在 Downloading 的状态。
|
||||
assert_eq!(
|
||||
plan_with_offline(
|
||||
Strategy::Unprobeable,
|
||||
&CjkInstall::Downloading { bytes: 1 },
|
||||
false
|
||||
),
|
||||
Step::Skip,
|
||||
"在飞的下载不许被别的策略改写状态"
|
||||
);
|
||||
}
|
||||
|
||||
/// **下载中一律 `Skip`**(复审 r2 P2-2 ②):四种策略挨个打一遍。
|
||||
///
|
||||
/// 抓得住:把 `is_downloading()` 那道闸挪回 `NeedsDownload` 分支里(上一版就是这样)——
|
||||
/// `Unprobeable` / `NotNeeded` / `System` 三行立刻红。
|
||||
/// 抓不住:worker 真的永不僵死(那要制造 panic,见 `worker` 的 `catch_unwind` 与报告的「没验」)。
|
||||
#[test]
|
||||
fn every_strategy_skips_while_a_download_is_in_flight() {
|
||||
let downloading = CjkInstall::Downloading { bytes: 17_772_300 };
|
||||
for strategy in [
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
Strategy::NotNeeded,
|
||||
Strategy::System { family: "SimSun" },
|
||||
Strategy::Unprobeable,
|
||||
] {
|
||||
assert_eq!(
|
||||
plan_with_offline(strategy, &downloading, false),
|
||||
Step::Skip,
|
||||
"{strategy:?}:下载中不许改状态、不许起第二条线程"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 出图通道(`offline`)**永不发起下载**(D30:同一份代码出同一张图)。
|
||||
///
|
||||
/// 抓得住:删掉 `plan_with_offline` 里 `NeedsDownload(_) if offline => Skip` 那一臂 ⇒ 截图/演示
|
||||
/// 通道会在 zh-locale 且无中文字体的机器上真下 17 MiB,用例在 `Step::Skip` 处红。
|
||||
/// 抓不住:真出图时有没有产生流量(那要真机跑 `--screenshot`,见报告「没验」)。
|
||||
#[test]
|
||||
fn the_screenshot_channel_never_starts_a_download() {
|
||||
assert_eq!(
|
||||
plan_with_offline(
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
&CjkInstall::Unknown,
|
||||
true
|
||||
),
|
||||
Step::Skip,
|
||||
"出图通道不许联网"
|
||||
);
|
||||
assert_eq!(
|
||||
plan_with_offline(
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
&CjkInstall::Unknown,
|
||||
false
|
||||
),
|
||||
Step::Start(&NOTO_SANS_SC),
|
||||
"正常通道该发起(否则上面那条可能是「永远 Skip」的假绿)"
|
||||
);
|
||||
// 只改状态的三种策略在两条通道里表现一致(它们本来就不联网)。
|
||||
for strategy in [
|
||||
Strategy::NotNeeded,
|
||||
Strategy::System { family: "SimSun" },
|
||||
Strategy::Unprobeable,
|
||||
] {
|
||||
assert_eq!(
|
||||
plan_with_offline(strategy, &CjkInstall::Unknown, false),
|
||||
plan_with_offline(strategy, &CjkInstall::Unknown, true),
|
||||
"{strategy:?}:不联网的策略与通道无关"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图钉子(D30):每个 `ULL_FONT_STATUS` 取值都能解析成一档状态,坏值不猜。
|
||||
///
|
||||
/// 抓得住:出图夹具写了一个不存在的取值却静默出「未知」状态的图(报告就会撒谎)。
|
||||
#[test]
|
||||
fn the_capture_switch_parses_every_state() {
|
||||
assert_eq!(parse_capture("not-needed"), Some(CjkInstall::NotNeeded));
|
||||
assert_eq!(parse_capture("unprobeable"), Some(CjkInstall::Unprobeable));
|
||||
assert!(matches!(
|
||||
parse_capture("system"),
|
||||
Some(CjkInstall::System { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_capture("downloading"),
|
||||
Some(CjkInstall::Downloading { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_capture("ready"),
|
||||
Some(CjkInstall::Ready {
|
||||
from_cache: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
for (raw, class) in [
|
||||
("failed", FetchClass::Network),
|
||||
("failed:network", FetchClass::Network),
|
||||
("failed:integrity", FetchClass::Integrity),
|
||||
("failed:disk", FetchClass::Disk),
|
||||
] {
|
||||
match parse_capture(raw) {
|
||||
Some(CjkInstall::Failed { class: got, .. }) => assert_eq!(got, class, "{raw}"),
|
||||
other => panic!("{raw} 应解析成 Failed:{other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(parse_capture("bogus"), None);
|
||||
assert_eq!(parse_capture(""), None);
|
||||
}
|
||||
|
||||
/// 一行日志片段:每种状态都能被 grep 到,且**不带换行**(启动日志按行解析)。
|
||||
#[test]
|
||||
fn every_state_describes_itself_on_one_line() {
|
||||
let states = [
|
||||
CjkInstall::Unknown,
|
||||
CjkInstall::NotNeeded,
|
||||
CjkInstall::System {
|
||||
family: "SimSun".to_owned(),
|
||||
},
|
||||
CjkInstall::Unprobeable,
|
||||
CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: false,
|
||||
},
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Integrity,
|
||||
message: "sha256 不符".to_owned(),
|
||||
},
|
||||
];
|
||||
for state in states {
|
||||
let line = state.describe();
|
||||
assert_eq!(line.lines().count(), 1, "{line}");
|
||||
assert!(line.starts_with("cjk-install="), "{line}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 下载线程与字体注册(M168):`install.rs` 的「动作」那一半。
|
||||
//!
|
||||
//! ## 为什么拆出来
|
||||
//!
|
||||
//! `install.rs` 是「状态 + 判据 + 触发」,已经贴着 400 行硬线。这里只放真正干活的两件:
|
||||
//!
|
||||
//! 1. [`start`]:起一条后台线程(进程内同时只有一条),把「正在下载 → 结果」写回状态槽;
|
||||
//! 2. [`register_into_font_system`]:把字节交给 iced 的全局字体系统(运行时注册)。
|
||||
//!
|
||||
//! ## 为什么下载在后台线程
|
||||
//!
|
||||
//! 一份 16.9 MiB 的字体在弱网下要几十秒,而触发点(设置页切语言)在 UI 线程上。
|
||||
//! 在 UI 线程同步下完 = 窗口几十秒不响应。
|
||||
//!
|
||||
//! ## 线程里做了什么(顺序即判据)
|
||||
//!
|
||||
//! ```text
|
||||
//! CdnFetcher(缓存命中 ⇒ 零网络;否则共享引擎下载 + sha256 复验 + 整体时限)
|
||||
//! → i18n::font::ensure 只有 NeedsDownload 才调 fetcher
|
||||
//! → 成功:注册进 iced,状态 Ready{family, from_cache}
|
||||
//! → 失败:状态 Failed{class},**什么都不注册**(降级回系统字体,界面照常可用)
|
||||
//! ```
|
||||
//!
|
||||
//! ## panic 这件事分两档说(复审 r3 P2-1)
|
||||
//!
|
||||
//! | 档 | panic 语义 | 线程 panic 之后 |
|
||||
//! |---|---|---|
|
||||
//! | `release`(`Cargo.toml`:`panic = "abort"`,发布走它) | 进程 abort | 状态槽随进程消失(**不会**停在 Downloading);但「降级回系统字体」**也不存在**——启动器整体退出 |
|
||||
//! | `dev` / `test` / `fast`(unwind) | 栈展开 | [`run`] 里的 `catch_unwind` 把状态写成 `Failed` |
|
||||
//!
|
||||
//! 所以 `catch_unwind` 是**给 unwind 档的**,不能拿它给「下载失败不崩」背书:发布版在注册路径
|
||||
//! panic 时仍会 abort。要让发布版也降级,得让注册路径不 panic(或改成 `panic = "unwind"`,
|
||||
//! 那属于发布 profile 的决定,不在本 mission)。
|
||||
//!
|
||||
//! ## 诚实边界
|
||||
//!
|
||||
//! - **抓得住**:状态流转、失败不注册、日志三行(start / ok / failed);
|
||||
//! - **抓不住**:真机上字体注册之后**出字的样子**(cosmic-text 用 `wght` 轴选字重这件事只核对了
|
||||
//! 上游源码,没有真机截图对照;macOS / Linux 完全没有真机),也抓不住发布档的 abort 行为
|
||||
//! (那要真跑一个 `--release` 的 panic 用例)。
|
||||
|
||||
use unlucky_i18n::font::{self, Strategy};
|
||||
|
||||
use super::{ensure_for, update, CjkInstall, FetchClass};
|
||||
use crate::fonts::cdn::CdnFetcher;
|
||||
use crate::platform::data;
|
||||
|
||||
/// 起一条后台下载(进程内同时只允许一条:`install::after_probe` 负责那道闸)。
|
||||
///
|
||||
/// 状态先落 `Downloading` 再起线程;**线程起不来时**立刻落 `Failed`(用 `Builder::spawn` 而不是
|
||||
/// 裸 `thread::spawn`:后者失败会 panic,而发布档 `panic = "abort"` ⇒ 整个启动器退出)。
|
||||
pub(super) fn start(font: &'static font::CjkFont) {
|
||||
let font::Source::Pinned { bytes, .. } = font.source;
|
||||
let Some(cache_root) = data::cache_root() else {
|
||||
update(CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: "数据根解析不出来,字体无处可落".to_owned(),
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let fetcher = match CdnFetcher::new(font, cache_root) {
|
||||
Ok(fetcher) => fetcher,
|
||||
Err(error) => {
|
||||
update(CjkInstall::Failed {
|
||||
class: FetchClass::from(&error),
|
||||
message: error.message().to_owned(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let cache_file = fetcher.cache_file();
|
||||
update(CjkInstall::Downloading { bytes });
|
||||
eprintln!(
|
||||
"[font] cjk-download start family=\"{}\" bytes={bytes} file={} cache_present={}",
|
||||
font.family,
|
||||
cache_file.display(),
|
||||
// 这条只是「文件在不在」(诊断用),**不是** from_cache:坏缓存会被删掉重下,
|
||||
// 真正的命中结论在 `fetcher.was_cached()`(取完之后)。
|
||||
fetcher.has_cached()
|
||||
);
|
||||
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("ull-cjk-font".to_owned())
|
||||
.spawn(move || run(font, fetcher));
|
||||
|
||||
if let Err(error) = spawned {
|
||||
update(CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: format!("起不了字体下载线程({error})"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 线程体:取字体 → 注册 → 写终态(**一定**写终态,见模块文档「panic 这件事分两档说」)。
|
||||
fn run(font: &'static font::CjkFont, fetcher: CdnFetcher) {
|
||||
let state = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
ensure_for(
|
||||
Strategy::NeedsDownload(font),
|
||||
&fetcher,
|
||||
®ister_into_font_system,
|
||||
)
|
||||
})) {
|
||||
Ok(state) => state,
|
||||
Err(_) => CjkInstall::Failed {
|
||||
class: FetchClass::Disk,
|
||||
message: "字体安装线程 panic(已捕获,状态回到终态;界面继续用系统字体)".to_owned(),
|
||||
},
|
||||
};
|
||||
|
||||
// `from_cache` 只认下载器**这次**的结论(复审 r1 次要项):取之前「文件在不在」会说谎——
|
||||
// 坏缓存会被删掉重下,那时 `has_cached()` 还是 true,而真相是「联网下了一次」。
|
||||
let from_cache = fetcher.was_cached().unwrap_or(false);
|
||||
let state = match state {
|
||||
CjkInstall::Ready { family, .. } => CjkInstall::Ready { family, from_cache },
|
||||
other => other,
|
||||
};
|
||||
|
||||
match &state {
|
||||
CjkInstall::Ready { family, from_cache } => eprintln!(
|
||||
"[font] cjk-download ok family=\"{family}\" from_cache={from_cache} \
|
||||
(中文字形现在由它兜底,界面不需要重启)"
|
||||
),
|
||||
CjkInstall::Failed { class, message } => eprintln!(
|
||||
"[font] cjk-download failed class={} {message}(降级:继续用系统字体,界面照常可用)",
|
||||
class.as_str()
|
||||
),
|
||||
other => eprintln!("[font] cjk-download {}", other.describe()),
|
||||
}
|
||||
update(state);
|
||||
}
|
||||
|
||||
/// 把字体字节注册进 iced 的**全局字体系统**(`iced_graphics` 的 `FontSystem::load_font`)。
|
||||
///
|
||||
/// 返回注册后的族名(从字体库里读回来的):变量字体的 `name` 表里族名是 `Noto Sans SC`、
|
||||
/// 而默认实例是 Thin,所以**不能**假设「注册了就叫 NOTO_SANS_SC.family」——这里读回来核对。
|
||||
///
|
||||
/// # Errors
|
||||
/// 字体系统锁被毒掉、或注册后字体库里找不到那个族(字节不是字体 / `fontdb` 不认)。
|
||||
pub fn register_into_font_system(bytes: Vec<u8>) -> Result<String, String> {
|
||||
let lock = iced::advanced::graphics::text::font_system();
|
||||
let mut font_system = lock
|
||||
.write()
|
||||
.map_err(|_| "字体系统的读写锁被毒掉(另一处注册 panic 过)".to_owned())?;
|
||||
|
||||
font_system.load_font(std::borrow::Cow::Owned(bytes));
|
||||
|
||||
// 先把族名抄出来再判:`faces()` 的迭代器借着 `font_system`,直接链到 `ok_or_else` 会让
|
||||
// 那个借用活过守卫(E0597)。
|
||||
let families: Vec<String> = font_system
|
||||
.raw()
|
||||
.db()
|
||||
.faces()
|
||||
.flat_map(|face| face.families.iter())
|
||||
.map(|(family, _)| family.clone())
|
||||
.collect();
|
||||
|
||||
families
|
||||
.iter()
|
||||
.find(|family| family.as_str() == font::NOTO_SANS_SC.family)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("注册后在字体库里找不到 \"{}\"", font::NOTO_SANS_SC.family))
|
||||
}
|
||||
@@ -173,3 +173,42 @@ fn type_roles_pick_the_display_stack_for_big_text() {
|
||||
);
|
||||
assert_eq!(TypeRole::BodyMedium.style().family, FontFamily::Text);
|
||||
}
|
||||
|
||||
/// **求值字体不许发起下载**(复审 r3 P2-2:上一轮的修复没有任何用例守着)。
|
||||
///
|
||||
/// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有
|
||||
/// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。
|
||||
///
|
||||
/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法)
|
||||
/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。
|
||||
/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。
|
||||
/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。
|
||||
#[test]
|
||||
fn resolving_fonts_never_starts_a_download() {
|
||||
use unlucky_i18n::font::Strategy;
|
||||
|
||||
use crate::fonts::install;
|
||||
|
||||
// 只有拉丁族 ⇒ 中文环境一定判 needs-download,于是这条用例对「触发」是敏感的。
|
||||
let latin: Vec<Face> = ["Segoe UI", "Consolas"]
|
||||
.iter()
|
||||
.map(|family| Face {
|
||||
family: (*family).to_owned(),
|
||||
weight: 400,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let before = install::status();
|
||||
let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin));
|
||||
let after = install::status();
|
||||
|
||||
assert_eq!(
|
||||
resolved.cjk.strategy,
|
||||
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC),
|
||||
"这张脸表必须判出「需要下载」,否则下面的断言证明不了什么"
|
||||
);
|
||||
assert!(
|
||||
!after.is_downloading(),
|
||||
"求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})"
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,9 @@ pub mod widgets;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "account/tests_msa.rs"]
|
||||
mod tests_msa;
|
||||
#[cfg(test)]
|
||||
#[path = "account/tests_text.rs"]
|
||||
mod tests_text;
|
||||
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
//!
|
||||
//! ## 为什么不是「一个转圈」
|
||||
//!
|
||||
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
||||
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
|
||||
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
|
||||
//! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
||||
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。
|
||||
//!
|
||||
//! ## 文案一律**借用模型里的串**(M178)
|
||||
//!
|
||||
//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n),
|
||||
//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`:
|
||||
//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白),
|
||||
//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Alignment, Element};
|
||||
@@ -13,7 +19,7 @@ use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::LoginFailure;
|
||||
use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::pages::frame;
|
||||
@@ -36,34 +42,40 @@ pub fn panel<'a>(
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
link,
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
} => device_code(ui, code, link, *remaining, *expired, catalog),
|
||||
note,
|
||||
} => device_code(ui, code, link, countdown, *expired, *note, catalog),
|
||||
DevicePanel::Browser {
|
||||
link,
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
} => browser(ui, link, *remaining, *expired, catalog),
|
||||
DevicePanel::Failed { failure } => failed(ui, *failure, catalog),
|
||||
} => browser(ui, link, countdown, *expired, catalog),
|
||||
DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog),
|
||||
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
|
||||
}
|
||||
}
|
||||
|
||||
/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。
|
||||
/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
|
||||
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
|
||||
/// 没登记时在预检处直接回退设备码)。
|
||||
///
|
||||
/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」;
|
||||
/// 「没有浏览器时用这个」放在按钮下面一行说明里。
|
||||
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
let body = column![row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDeviceBrowser),
|
||||
Account::StartBrowser.into_page(),
|
||||
catalog.text(Key::AccountDeviceSignIn),
|
||||
Account::StartDevice.into_page(),
|
||||
)
|
||||
),
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDeviceUseCode),
|
||||
Account::StartDevice.into_page(),
|
||||
catalog.text(Key::AccountDeviceBrowser),
|
||||
Account::StartBrowser.into_page(),
|
||||
)
|
||||
),
|
||||
buttons::text(
|
||||
@@ -76,7 +88,14 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),]
|
||||
.spacing(ui.space(Space::X3));
|
||||
.spacing(ui.space(Space::X3))
|
||||
.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::AccountDeviceFallbackHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
@@ -105,13 +124,14 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 设备码等待态:**码 + 链接 + 倒计时**。
|
||||
/// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。
|
||||
fn device_code<'a>(
|
||||
ui: &'a Ui,
|
||||
code: &'a str,
|
||||
link: &'a str,
|
||||
remaining: Option<u64>,
|
||||
countdown: &'a str,
|
||||
expired: bool,
|
||||
note: Option<BrowserLoginNote>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut actions = row![
|
||||
@@ -150,9 +170,7 @@ fn device_code<'a>(
|
||||
),
|
||||
));
|
||||
|
||||
let countdown = countdown_line(ui, remaining, expired, catalog);
|
||||
|
||||
let body = column![
|
||||
let mut body = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
@@ -174,7 +192,7 @@ fn device_code<'a>(
|
||||
link,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
countdown,
|
||||
countdown_line(ui, countdown, expired),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
@@ -182,17 +200,21 @@ fn device_code<'a>(
|
||||
catalog.text(Key::AccountDeviceHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
actions,
|
||||
]
|
||||
.spacing(ui.space(Space::X3));
|
||||
frame::card(ui, body)
|
||||
// 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」,
|
||||
// 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。
|
||||
if let Some(text) = note_text(ui, note, catalog) {
|
||||
body = body.push(text);
|
||||
}
|
||||
frame::card(ui, body.push(actions))
|
||||
}
|
||||
|
||||
/// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。
|
||||
fn browser<'a>(
|
||||
ui: &'a Ui,
|
||||
link: &'a str,
|
||||
remaining: Option<u64>,
|
||||
countdown: &'a str,
|
||||
expired: bool,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
@@ -237,37 +259,19 @@ fn browser<'a>(
|
||||
link,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
countdown_line(ui, remaining, expired, catalog),
|
||||
countdown_line(ui, countdown, expired),
|
||||
actions,
|
||||
]
|
||||
.spacing(ui.space(Space::X3));
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
|
||||
fn countdown_line<'a>(
|
||||
ui: &'a Ui,
|
||||
remaining: Option<u64>,
|
||||
expired: bool,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**;文案由模型拼好,这里只定颜色与强调)。
|
||||
fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> {
|
||||
if expired {
|
||||
return frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
true,
|
||||
catalog.text(Key::AccountDeviceExpired),
|
||||
ui.color(Role::Error),
|
||||
);
|
||||
return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error));
|
||||
}
|
||||
let text = remaining.map_or_else(String::new, |secs| {
|
||||
// mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式
|
||||
// (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。
|
||||
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
|
||||
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
|
||||
});
|
||||
// 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`)
|
||||
frame::text_role_owned(
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
@@ -276,16 +280,45 @@ fn countdown_line<'a>(
|
||||
)
|
||||
}
|
||||
|
||||
/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。
|
||||
pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str {
|
||||
catalog.text(super::model::note_key(note))
|
||||
}
|
||||
|
||||
/// 回退说明那一行(`None` = 没有这回事)。
|
||||
fn note_text<'a>(
|
||||
ui: &'a Ui,
|
||||
note: Option<BrowserLoginNote>,
|
||||
catalog: Catalog,
|
||||
) -> Option<Element<'a, PageMessage>> {
|
||||
let note = note?;
|
||||
// 用正文色而不是 `Error`:这不是失败态(设备码接着跑),画成红色会让人以为登录已经挂了。
|
||||
Some(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
browser_note_text(note, catalog),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
}
|
||||
|
||||
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
|
||||
///
|
||||
/// 三点修订叠在这里:
|
||||
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
||||
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
|
||||
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小);
|
||||
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的
|
||||
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。
|
||||
fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
let body = catalog.text(failure_body_key(failure)).to_string();
|
||||
/// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
||||
/// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调;
|
||||
/// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」;
|
||||
/// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
|
||||
fn failed<'a>(
|
||||
ui: &'a Ui,
|
||||
failure: LoginFailure,
|
||||
note: Option<BrowserLoginNote>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut body = catalog.text(failure_body_key(failure)).to_string();
|
||||
if let Some(note) = note {
|
||||
body.push_str("\n\n");
|
||||
body.push_str(browser_note_text(note, catalog));
|
||||
}
|
||||
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
|
||||
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
|
||||
if !matches!(failure, LoginFailure::MsaNotConfigured) {
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
use iced::widget::image;
|
||||
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView};
|
||||
use unlucky_presenter::dto::{
|
||||
AccountKind, AccountNotice, AccountNoticeCode, AccountView, BrowserLoginNote,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use super::avatar::{Avatar, Cache};
|
||||
@@ -164,6 +166,18 @@ impl Form {
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退说明码 → i18n key(**唯一的「码→文案」映射点**;原文永远不上屏)。
|
||||
///
|
||||
/// 与 [`Track::title_key`] 同一种写法:页面只认 key,后端只给码,两边独立演进。
|
||||
#[must_use]
|
||||
pub const fn note_key(note: BrowserLoginNote) -> Key {
|
||||
match note {
|
||||
BrowserLoginNote::RedirectNotRegistered => Key::AccountBrowserFallbackNotRegistered,
|
||||
BrowserLoginNote::PreflightUnavailable => Key::AccountBrowserFallbackUnverified,
|
||||
BrowserLoginNote::AttemptFailed => Key::AccountBrowserFallbackFailed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行 / 当前账户卡片用的数据。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Row {
|
||||
@@ -253,7 +267,8 @@ impl Page {
|
||||
.map(|account| row_of(account, avatars, lang))
|
||||
.collect();
|
||||
let active = rows.iter().position(|row| row.active);
|
||||
let device = device_panel(&state.device_login, state.device_code_remaining());
|
||||
let remaining = state.device_code_remaining();
|
||||
let device = device_panel(&state.device_login, remaining, lang.catalog());
|
||||
// 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、
|
||||
// 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。
|
||||
let panel = if matches!(device, DevicePanel::Hidden) {
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。
|
||||
//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。
|
||||
|
||||
use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure};
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
|
||||
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -25,17 +28,23 @@ pub enum DevicePanel {
|
||||
code: String,
|
||||
/// 验证链接
|
||||
link: String,
|
||||
/// 剩余秒数
|
||||
remaining: Option<u64>,
|
||||
/// 是否已过期
|
||||
/// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」)
|
||||
///
|
||||
/// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径
|
||||
/// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、
|
||||
/// 视图用 `&'a str` 借用,两边都不依赖那条路径。
|
||||
countdown: String,
|
||||
/// 是否已过期(决定颜色与「重试」按钮)
|
||||
expired: bool,
|
||||
/// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事)
|
||||
note: Option<BrowserLoginNote>,
|
||||
},
|
||||
/// 授权码流:等浏览器回调
|
||||
Browser {
|
||||
/// 授权链接(可复制)
|
||||
link: String,
|
||||
/// 剩余秒数
|
||||
remaining: Option<u64>,
|
||||
/// 倒计时那行已渲染好的文案(同上)
|
||||
countdown: String,
|
||||
/// 是否已过期
|
||||
expired: bool,
|
||||
},
|
||||
@@ -43,6 +52,8 @@ pub enum DevicePanel {
|
||||
Failed {
|
||||
/// 原因码(页面据此选 i18n)
|
||||
failure: LoginFailure,
|
||||
/// 回退说明(浏览器登录没走成之后设备码也失败了;M178)
|
||||
note: Option<BrowserLoginNote>,
|
||||
},
|
||||
/// 成功
|
||||
Succeeded {
|
||||
@@ -76,8 +87,13 @@ pub enum AuthlibPanel {
|
||||
}
|
||||
|
||||
/// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。
|
||||
pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> DevicePanel {
|
||||
pub(super) fn device_panel(
|
||||
view: &DeviceLoginView,
|
||||
remaining: Option<u64>,
|
||||
catalog: Catalog,
|
||||
) -> DevicePanel {
|
||||
let expired = remaining == Some(0);
|
||||
let countdown = countdown_text(remaining, expired, catalog);
|
||||
match view.phase {
|
||||
DeviceLoginPhase::Idle => DevicePanel::Hidden,
|
||||
DeviceLoginPhase::Requesting => DevicePanel::Requesting,
|
||||
@@ -85,20 +101,22 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
|
||||
if view.user_code.trim().is_empty() {
|
||||
DevicePanel::Browser {
|
||||
link: view.verification_uri.clone(),
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
}
|
||||
} else {
|
||||
DevicePanel::Code {
|
||||
code: view.user_code.clone(),
|
||||
link: view.qr_link.clone(),
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
note: view.browser_note,
|
||||
}
|
||||
}
|
||||
}
|
||||
DeviceLoginPhase::Failed => DevicePanel::Failed {
|
||||
failure: view.failure.unwrap_or(LoginFailure::Other),
|
||||
note: view.browser_note,
|
||||
},
|
||||
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
|
||||
name: view.account_name.clone(),
|
||||
@@ -106,6 +124,19 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
|
||||
}
|
||||
}
|
||||
|
||||
/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。
|
||||
///
|
||||
/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。
|
||||
fn countdown_text(remaining: Option<u64>, expired: bool, catalog: Catalog) -> String {
|
||||
if expired {
|
||||
return catalog.text(Key::AccountDeviceExpired).to_string();
|
||||
}
|
||||
remaining.map_or_else(String::new, |secs| {
|
||||
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
|
||||
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
|
||||
})
|
||||
}
|
||||
|
||||
/// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。
|
||||
pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) {
|
||||
match view.phase {
|
||||
|
||||
@@ -33,7 +33,7 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
|
||||
}
|
||||
}
|
||||
|
||||
fn build(state: &AppState, form: &Form) -> Page {
|
||||
pub(super) fn build(state: &AppState, form: &Form) -> Page {
|
||||
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
|
||||
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
|
||||
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
|
||||
@@ -212,12 +212,15 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
|
||||
match &page.device {
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(code, "ABCD-EFGH");
|
||||
assert_eq!(*remaining, Some(900));
|
||||
assert!(
|
||||
countdown.contains("15:00"),
|
||||
"倒计时要画成 mm:ss:{countdown}"
|
||||
);
|
||||
assert!(!expired);
|
||||
}
|
||||
other => panic!("等待态应该是设备码面板,实际 {other:?}"),
|
||||
@@ -232,10 +235,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
|
||||
let page = build(&state, &Form::new());
|
||||
match &page.device {
|
||||
DevicePanel::Code {
|
||||
remaining, expired, ..
|
||||
countdown, expired, ..
|
||||
} => {
|
||||
assert_eq!(*remaining, Some(0));
|
||||
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
|
||||
assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行");
|
||||
}
|
||||
other => panic!("过期态仍是设备码面板,实际 {other:?}"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! M178 的登录回退判据(从 `tests.rs` 拆出来守 400 行硬线)。
|
||||
//!
|
||||
//! 这两条钉的是**页面这一半**:浏览器登录没走成、已回退设备码时,用户看到的是
|
||||
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
|
||||
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use super::avatar::Cache;
|
||||
use super::login::browser_note_text;
|
||||
use super::model::{DevicePanel, Form, Page};
|
||||
use super::tests::build;
|
||||
use super::tests_text::{has_cjk, on_screen};
|
||||
|
||||
/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。
|
||||
fn build_zh(state: &AppState, form: &Form) -> Page {
|
||||
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
|
||||
}
|
||||
|
||||
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
|
||||
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
|
||||
///
|
||||
/// 抓得住:`browser_note` 在 `device_panel` 里被丢掉(面板就只剩用户码,用户不知道为什么
|
||||
/// 变成了设备码)、或有人把 presenter 的原始诊断串(`invalid_request ...`)画上屏。
|
||||
/// 抓不住:这句提示在 1180 宽下会不会换行难看(那靠出图给 tower 看)。
|
||||
#[test]
|
||||
fn a_browser_fallback_shows_a_readable_note_not_the_raw_aad_error() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
|
||||
DeviceLoginView {
|
||||
message: "invalid_request: The provided value for the input parameter 'redirect_uri' \
|
||||
is not valid. The expected value is a URI which matches a redirect URI registered for this client \
|
||||
application."
|
||||
.to_string(),
|
||||
..DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "")
|
||||
}
|
||||
.with_browser_note(BrowserLoginNote::RedirectNotRegistered),
|
||||
)));
|
||||
let page = build(&state, &Form::new());
|
||||
|
||||
// 1. 面板态带着说明码(不是只剩一个用户码)
|
||||
match &page.device {
|
||||
DevicePanel::Code { note, code, .. } => {
|
||||
assert_eq!(*note, Some(BrowserLoginNote::RedirectNotRegistered));
|
||||
assert_eq!(code, "ABCD-EFGH");
|
||||
}
|
||||
other => panic!("回退后仍是设备码面板,实际 {other:?}"),
|
||||
}
|
||||
|
||||
// 2. 英文界面上画出来的那一段:是 i18n 文案、含可照抄的修法,且没有 AAD 原始报文/中文
|
||||
let text = on_screen(&page);
|
||||
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
|
||||
assert!(
|
||||
text.contains("http://localhost/auth"),
|
||||
"提示里要给出可照抄的重定向 URI:{text}"
|
||||
);
|
||||
assert!(
|
||||
text.to_lowercase().contains("redirect uri"),
|
||||
"提示里要点名「重定向 URI」这一项:{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("invalid_request"),
|
||||
"原始报文不许上屏:{text}"
|
||||
);
|
||||
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
|
||||
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
|
||||
|
||||
// 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口**
|
||||
// `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。
|
||||
let zh = build_zh(&state, &Form::new());
|
||||
let zh_text = on_screen(&zh);
|
||||
assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}");
|
||||
let note_text = browser_note_text(
|
||||
BrowserLoginNote::RedirectNotRegistered,
|
||||
Lang::ZhCn.catalog(),
|
||||
);
|
||||
assert!(
|
||||
note_text.contains("http://localhost/auth"),
|
||||
"中文文案也要给出可照抄的重定向 URI:{note_text}"
|
||||
);
|
||||
assert!(note_text.contains("重定向 URI"), "{note_text}");
|
||||
assert!(note_text.contains("设备码"), "{note_text}");
|
||||
for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] {
|
||||
assert!(
|
||||
!zh_text.contains(forbidden),
|
||||
"中文界面混进了原始报文:{zh_text}"
|
||||
);
|
||||
assert!(
|
||||
!note_text.contains(forbidden),
|
||||
"词条混进了原始报文:{note_text}"
|
||||
);
|
||||
}
|
||||
// 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。
|
||||
assert_eq!(
|
||||
browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()),
|
||||
"Could not confirm that browser sign-in is usable, so it switched to a device code."
|
||||
);
|
||||
}
|
||||
|
||||
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
|
||||
#[test]
|
||||
fn a_failed_device_login_keeps_the_fallback_note() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
|
||||
DeviceLoginView::failed_as(
|
||||
LoginFailure::Unreachable,
|
||||
"连不上 login.microsoftonline.com",
|
||||
)
|
||||
.with_browser_note(BrowserLoginNote::PreflightUnavailable),
|
||||
)));
|
||||
let page = build(&state, &Form::new());
|
||||
match &page.device {
|
||||
DevicePanel::Failed { failure, note } => {
|
||||
assert_eq!(*failure, LoginFailure::Unreachable);
|
||||
assert_eq!(*note, Some(BrowserLoginNote::PreflightUnavailable));
|
||||
}
|
||||
other => panic!("应是失败面板,实际 {other:?}"),
|
||||
}
|
||||
let text = on_screen(&page);
|
||||
assert!(!has_cjk(&text), "{text}");
|
||||
assert!(text.contains("unreachable"), "失败码要画出来:{text}");
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::AccountNoticeCode;
|
||||
|
||||
use super::login::{failure_body_key, failure_title_key};
|
||||
use super::login::{browser_note_text, failure_body_key, failure_title_key};
|
||||
use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
|
||||
|
||||
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
|
||||
@@ -60,23 +60,33 @@ pub(super) fn on_screen(page: &Page) -> String {
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
link,
|
||||
remaining,
|
||||
expired,
|
||||
countdown,
|
||||
note,
|
||||
..
|
||||
} => {
|
||||
out.push_str(code);
|
||||
out.push_str(link);
|
||||
if *expired {
|
||||
out.push_str(catalog.text(Key::AccountDeviceExpired));
|
||||
} else if let Some(secs) = remaining {
|
||||
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
|
||||
out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]));
|
||||
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
|
||||
out.push_str(countdown);
|
||||
// M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`),
|
||||
// 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。
|
||||
if let Some(note) = note {
|
||||
out.push_str(browser_note_text(*note, catalog));
|
||||
}
|
||||
}
|
||||
DevicePanel::Browser { link, .. } => out.push_str(link),
|
||||
DevicePanel::Failed { failure } => {
|
||||
DevicePanel::Browser {
|
||||
link, countdown, ..
|
||||
} => {
|
||||
out.push_str(link);
|
||||
out.push_str(countdown);
|
||||
}
|
||||
DevicePanel::Failed { failure, note } => {
|
||||
out.push_str(catalog.text(failure_title_key(*failure)));
|
||||
out.push_str(catalog.text(failure_body_key(*failure)));
|
||||
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
|
||||
if let Some(note) = note {
|
||||
out.push_str(browser_note_text(*note, catalog));
|
||||
}
|
||||
}
|
||||
DevicePanel::Succeeded { name } => {
|
||||
out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)]));
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//! | [`background`] | 背景图解码(png/jpeg/bmp)、平铺网格、暗化系数、可读诊断 | 读盘一次(`load`) |
|
||||
//! | `palette` | D38 三级优先序的调用侧 + 提示文案 | 无(像素由 `background` 给) |
|
||||
//! | `state` | 运行期状态:把上面三件的输入输出连起来 | 只在 `refresh_background` |
|
||||
//! | `section` | 设置页「外观」分组(真控件) | 无 |
|
||||
//! | `section/` | 设置页「外观」分组(真控件):壳 + 五段控件,M179 从单文件拆开 | 无 |
|
||||
//! | `picture` | 背景层 Element:真图直出(Cover/Contain/Tile)+ 暗化叠层 | 无 |
|
||||
//!
|
||||
//! ## 偏离登记(D30 要求逐条写清)
|
||||
@@ -65,10 +65,12 @@ pub mod background;
|
||||
|
||||
mod palette;
|
||||
mod picture;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod probe;
|
||||
mod section;
|
||||
mod state;
|
||||
|
||||
/// 状态提示的 **tonal notice**(`section.rs` 的 400 行硬顶把它放在模块根)。
|
||||
/// 状态提示的 **tonal notice**(行数硬顶把 `section/` 撑开之后它仍放在模块根)。
|
||||
///
|
||||
/// `SurfaceContainerHighest` 底 + `ShapeScale::Small` 圆角 + 两侧 `X2` 内边距,文字 `OnSurfaceVariant`。
|
||||
/// 用它而不是「换色的正文」:容器才是 M3 表达「当前状态」的形态,也和说明段在**形态**上分开
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! **测试专用**:记录「画一帧时从词表取出的每一条译文」。
|
||||
//!
|
||||
//! ## 为什么需要这一层
|
||||
//!
|
||||
//! 判据是「中文界面下外观分组渲染出来的字里必须含汉字」,但 iced 0.14 **没有**可以遍历控件树的
|
||||
//! 公开接口:
|
||||
//!
|
||||
//! - `Element::as_widget()` 拿到的是 `&dyn Widget`,而 `Widget::children()` 返回的是**状态** `Tree`
|
||||
//! (不是子控件),没有 `as_any` 之类的向下转型口;
|
||||
//! - 想走 `Widget::operate` 得自己造 `Tree` + 跑布局 + 传 `Renderer`,而单测里**没有渲染器**
|
||||
//! (`pages/download/tests/options_screen.rs` 记的正是这条边界)。
|
||||
//!
|
||||
//! 所以取证点退到「字进树之前」那一层:`section::t` 是本分组**唯一**的取词口,它把每条译文
|
||||
//! 顺手记一份,测试再断言这批字里有没有汉字。这不是「看源码字符串」那种假守卫——它要求真的把
|
||||
//! [`crate::pages::appearance::section`] 跑一遍(并且走 `settings::view` 的语言分发),只是不画像素。
|
||||
//!
|
||||
//! ## 为什么是 thread-local
|
||||
//!
|
||||
//! `cargo test` 默认多线程跑,每个测试一个线程;thread-local 让并发测试的取证互不串台,
|
||||
//! 也不需要加锁或串行化。
|
||||
//!
|
||||
//! 整个模块只在 `#[cfg(test)]` 下编译,产品路径零代价。
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use unlucky_i18n::catalog::Key;
|
||||
|
||||
// 自上次 `take` 以来记录下的 `(key, 译文)`,顺序 = 取词顺序。
|
||||
thread_local! {
|
||||
// clippy 1.99.1(= 本仓 CI 的 stable)对这条 lint **误报**:初始化器已经写成 `const { … }`
|
||||
// 它还是报「can be made const」。最小复现(clippy-driver 原地可验,无本仓任何符号):
|
||||
// `thread_local!{ static X: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) }; }`
|
||||
// —— 同一个文件加不加 `const` 报同一句。等 clippy 修掉这条误报即可删掉本行 allow。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static TEXTS: RefCell<Vec<(Key, &'static str)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// 记一条(由 [`super::section::t`] 调用;不对外)。
|
||||
pub(crate) fn record(key: Key, text: &'static str) {
|
||||
TEXTS.with(|texts| texts.borrow_mut().push((key, text)));
|
||||
}
|
||||
|
||||
/// 取走并清空本线程记录下的取词(测试用)。
|
||||
pub(crate) fn take() -> Vec<(Key, &'static str)> {
|
||||
TEXTS.with(|texts| std::mem::take(&mut *texts.borrow_mut()))
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
|
||||
//!
|
||||
//! ## 控件与键的对应
|
||||
//!
|
||||
//! | 控件 | 键 | 改动消息 |
|
||||
//! |---|---|---|
|
||||
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
|
||||
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
|
||||
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
|
||||
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
|
||||
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
|
||||
//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
|
||||
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
|
||||
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
|
||||
//!
|
||||
//! 文案一律英文基线(i18n 是别的 mission;本文件不拼中文、不 import 尚不存在的 crate)。
|
||||
//!
|
||||
//! ## 为什么「值」与「生效值」分开显示
|
||||
//!
|
||||
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
|
||||
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
|
||||
|
||||
use iced::widget::{button, column, container, row, space};
|
||||
use iced::{
|
||||
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
|
||||
};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_presenter::prefs::{BgFill, PaletteSource, WindowCorner};
|
||||
|
||||
use crate::app::components::inputs::{slider, text_field, Field, Slider};
|
||||
use crate::pages::{frame, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::alpha::{
|
||||
panel_alpha_from_fraction, panel_fraction, window_alpha_from_fraction, window_fraction,
|
||||
WINDOW_ALPHA_FLOOR,
|
||||
};
|
||||
use super::background::{self, BG_DIM_MAX};
|
||||
use super::state::State;
|
||||
use super::Change;
|
||||
|
||||
/// 「外观」分组整块(一张卡)。
|
||||
pub fn section<'a>(ui: &'a Ui, state: &'a State) -> Element<'a, Message> {
|
||||
let mut rows = column![].spacing(ui.space(Space::X1)).width(Length::Fill);
|
||||
|
||||
// ① 整窗透明度
|
||||
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
|
||||
rows = rows.push(slider_row(
|
||||
ui,
|
||||
"Window transparency",
|
||||
window_percent,
|
||||
format!(
|
||||
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
|
||||
WINDOW_ALPHA_FLOOR
|
||||
),
|
||||
state.window_opacity < 1.0,
|
||||
slider(
|
||||
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
|
||||
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
// ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。
|
||||
let corner = state.window_corner();
|
||||
rows = rows.push(choice_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCorner),
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSystem),
|
||||
corner == WindowCorner::System,
|
||||
Change::Corner(WindowCorner::System),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSquare),
|
||||
corner == WindowCorner::Square,
|
||||
Change::Corner(WindowCorner::Square),
|
||||
),
|
||||
],
|
||||
));
|
||||
// **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。
|
||||
// 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。
|
||||
if corner == WindowCorner::System && state.window_opacity < 1.0 {
|
||||
rows = rows.push(super::notice(ui, t(Key::SettingsAppearanceCornerSuspended)));
|
||||
let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into();
|
||||
rows = rows.push(gap);
|
||||
}
|
||||
let tone = ui.color(Role::OnSurfaceVariant);
|
||||
rows = rows.push(note_line(ui, t(Key::SettingsAppearanceCornerHint), tone));
|
||||
|
||||
// ③ 面板透明度(CPU 档强制 1.0)
|
||||
let forced = state.panel_is_forced();
|
||||
let panel_percent = if forced {
|
||||
format!("100% (forced from {:.2})", state.panel_alpha)
|
||||
} else {
|
||||
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
|
||||
};
|
||||
let panel_hint = if forced {
|
||||
"Software renderer: panels stay fully opaque (window transparency still works)."
|
||||
} else {
|
||||
"Only the panel/card layer. Floor 0.40; independent of window transparency."
|
||||
};
|
||||
let panel_control = if forced {
|
||||
slider(Slider::new(ui, panel_fraction(1.0)))
|
||||
} else {
|
||||
slider(
|
||||
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
|
||||
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
|
||||
}),
|
||||
)
|
||||
};
|
||||
rows = rows.push(slider_row(
|
||||
ui,
|
||||
"Panel transparency",
|
||||
panel_percent,
|
||||
panel_hint.to_owned(),
|
||||
!forced,
|
||||
panel_control,
|
||||
));
|
||||
|
||||
// ④ 背景图开关
|
||||
rows = rows.push(choice_row(
|
||||
ui,
|
||||
"Background image",
|
||||
vec![
|
||||
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
|
||||
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
|
||||
],
|
||||
));
|
||||
|
||||
// ⑤ 路径(文本框:受控,输入即生效)
|
||||
let path_field: Element<'a, Message> = text_field(
|
||||
ui,
|
||||
Field::new(&state.bg_path)
|
||||
.label("Image path")
|
||||
.placeholder("D:/Pictures/background.png"),
|
||||
|value| Message::Appearance(Change::BgImage(value)),
|
||||
);
|
||||
rows = rows.push(
|
||||
column![label(ui, "Image path"), path_field]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
);
|
||||
|
||||
// ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
|
||||
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
|
||||
for note in state.diagnostics() {
|
||||
rows = rows.push(note_line(ui, note, ui.color(Role::Error)));
|
||||
}
|
||||
if let Some(note) = state.backend_note() {
|
||||
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
if let Some(note) = state
|
||||
.image()
|
||||
.and_then(|image| background::small_tile_note(image, state.bg_fill))
|
||||
{
|
||||
rows = rows.push(note_line(ui, ¬e, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
if let Some(note) = state.palette_note() {
|
||||
rows = rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
let resolved = format!("Colours: {}", state.palette_level());
|
||||
rows = rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
|
||||
|
||||
// ⑦ 填充
|
||||
rows = rows.push(choice_row(
|
||||
ui,
|
||||
"Fill",
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
"Cover",
|
||||
state.bg_fill == BgFill::Cover,
|
||||
Change::BgFill(BgFill::Cover),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Contain",
|
||||
state.bg_fill == BgFill::Contain,
|
||||
Change::BgFill(BgFill::Contain),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Tile",
|
||||
state.bg_fill == BgFill::Tile,
|
||||
Change::BgFill(BgFill::Tile),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
// ⑧ 暗化
|
||||
rows = rows.push(slider_row(
|
||||
ui,
|
||||
"Dim",
|
||||
format!("{:.0}%", state.bg_dim * 100.0),
|
||||
format!(
|
||||
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
|
||||
),
|
||||
state.bg_dim > 0.0,
|
||||
slider(
|
||||
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
|
||||
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
|
||||
),
|
||||
));
|
||||
|
||||
// ⑨ 配色来源
|
||||
rows = rows.push(choice_row(
|
||||
ui,
|
||||
"Colour source",
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
"Background",
|
||||
state.palette_source == PaletteSource::BackgroundImage,
|
||||
Change::PaletteSource(PaletteSource::BackgroundImage),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Wallpaper",
|
||||
state.palette_source == PaletteSource::Wallpaper,
|
||||
Change::PaletteSource(PaletteSource::Wallpaper),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Preset",
|
||||
state.palette_source == PaletteSource::Preset,
|
||||
Change::PaletteSource(PaletteSource::Preset),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
card(ui, rows)
|
||||
}
|
||||
|
||||
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
|
||||
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
let background = ui.layer(MaterialLayer::Card);
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
border: ui.outlined(spec.shape),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 行与控件
|
||||
|
||||
fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
content,
|
||||
ui.color(Role::OnSurface),
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
|
||||
///
|
||||
/// 为什么是 [`Lang::En`]:本文件的既有文案是英文基线(i18n 接线是另一条 mission,
|
||||
/// 见本文件头部「文案一律英文基线」)。走词表的意义是**新增条目已经有中英两套**,
|
||||
/// 接线时只换这一行,不用回来重写控件。
|
||||
fn t(key: Key) -> &'static str {
|
||||
unlucky_i18n::Lang::En.catalog().text(key)
|
||||
}
|
||||
|
||||
fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
|
||||
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
|
||||
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
|
||||
}
|
||||
|
||||
/// 一行「标签 + 值 + 滑块 + 说明」。
|
||||
fn slider_row<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
value: String,
|
||||
hint: String,
|
||||
enabled: bool,
|
||||
control: Element<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
let value_color = if enabled {
|
||||
ui.color(Role::OnSurface)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
let head = row![
|
||||
container(label(ui, title))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
column![
|
||||
head,
|
||||
control,
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一行「标签 + 一排互斥按钮」。
|
||||
fn choice_row<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
choices: Vec<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
container(label(ui, title))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
row(choices)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
|
||||
fn choice<'a>(ui: &'a Ui, text: &'a str, selected: bool, change: Change) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height;
|
||||
let content_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
button(
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
selected,
|
||||
text,
|
||||
content_color,
|
||||
))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = if selected {
|
||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
||||
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
Some(Background::Color(ui.state_over(
|
||||
unlucky_design::color::StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background,
|
||||
text_color: content_color,
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(Message::Appearance(change))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 外观分组第 ④ ⑤ ⑥ ⑦ ⑧ 段:**背景图**——开关、路径、诊断、填充档、暗化强度。
|
||||
//!
|
||||
//! 这五段挤在一起是因为它们读同一份 `bg_*` 状态,而且**诊断必须紧贴控件**(复审 r1 P2-3:
|
||||
//! 读盘失败 / CPU 降级 / 配色回落这些提示落在折叠线以下等于没提示)。
|
||||
|
||||
use iced::widget::column;
|
||||
use iced::{Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_presenter::prefs::BgFill;
|
||||
|
||||
use crate::app::components::inputs::{slider, text_field, Field, Slider};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::background::{self, BG_DIM_MAX};
|
||||
use super::super::state::State;
|
||||
use super::super::Change;
|
||||
use super::{choice, choice_row, label, note_line, slider_row};
|
||||
|
||||
/// 把 ④ 开关、⑤ 路径、⑥ 诊断、⑦ 填充、⑧ 暗化按视觉顺序推进 `rows`。
|
||||
pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a, Message>>) {
|
||||
// ④ 背景图开关
|
||||
rows.push(choice_row(
|
||||
ui,
|
||||
"Background image",
|
||||
vec![
|
||||
choice(ui, "Off", !state.bg_enabled, Change::BgEnabled(false)),
|
||||
choice(ui, "On", state.bg_enabled, Change::BgEnabled(true)),
|
||||
],
|
||||
));
|
||||
|
||||
// ⑤ 路径(文本框:受控,输入即生效)
|
||||
let path_field: Element<'a, Message> = text_field(
|
||||
ui,
|
||||
Field::new(&state.bg_path)
|
||||
.label("Image path")
|
||||
.placeholder("D:/Pictures/background.png"),
|
||||
|value| Message::Appearance(Change::BgImage(value)),
|
||||
);
|
||||
rows.push(
|
||||
column![label(ui, "Image path"), path_field]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
);
|
||||
|
||||
// ⑥ 诊断(读盘失败 / CPU 降级 / 配色回落)——**不许静默**,且必须在**首屏可见处**
|
||||
// (复审 r1 P2-3:文案落在折叠线以下等于没提示)。位置就在背景图那两行的紧下方。
|
||||
for note in state.diagnostics() {
|
||||
rows.push(note_line(ui, note, ui.color(Role::Error)));
|
||||
}
|
||||
if let Some(note) = state.backend_note() {
|
||||
rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
if let Some(note) = state
|
||||
.image()
|
||||
.and_then(|image| background::small_tile_note(image, state.bg_fill))
|
||||
{
|
||||
rows.push(note_line(ui, ¬e, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
if let Some(note) = state.palette_note() {
|
||||
rows.push(note_line(ui, note, ui.color(Role::OnSurfaceVariant)));
|
||||
}
|
||||
let resolved = format!("Colours: {}", state.palette_level());
|
||||
rows.push(note_line(ui, &resolved, ui.color(Role::OnSurfaceVariant)));
|
||||
|
||||
// ⑦ 填充
|
||||
rows.push(choice_row(
|
||||
ui,
|
||||
"Fill",
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
"Cover",
|
||||
state.bg_fill == BgFill::Cover,
|
||||
Change::BgFill(BgFill::Cover),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Contain",
|
||||
state.bg_fill == BgFill::Contain,
|
||||
Change::BgFill(BgFill::Contain),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Tile",
|
||||
state.bg_fill == BgFill::Tile,
|
||||
Change::BgFill(BgFill::Tile),
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
// ⑧ 暗化
|
||||
rows.push(slider_row(
|
||||
ui,
|
||||
"Dim",
|
||||
format!("{:.0}%", state.bg_dim * 100.0),
|
||||
format!(
|
||||
"Blends the image toward the panel colour so text stays readable. Max {BG_DIM_MAX:.2}."
|
||||
),
|
||||
state.bg_dim > 0.0,
|
||||
slider(
|
||||
Slider::new(ui, state.bg_dim / BG_DIM_MAX)
|
||||
.on_change(|fraction| Message::Appearance(Change::BgDim(fraction * BG_DIM_MAX))),
|
||||
),
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! 外观分组第 ⑨ 段:**配色来源**(背景图 / 壁纸 / 色卡三档;它选的正是 D38 三级优先序的起点)。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_presenter::prefs::PaletteSource;
|
||||
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::state::State;
|
||||
use super::super::Change;
|
||||
use super::{choice, choice_row};
|
||||
|
||||
/// 把 ⑨ 配色来源推进行 `rows`。
|
||||
pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a, Message>>) {
|
||||
// ⑨ 配色来源
|
||||
rows.push(choice_row(
|
||||
ui,
|
||||
"Colour source",
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
"Background",
|
||||
state.palette_source == PaletteSource::BackgroundImage,
|
||||
Change::PaletteSource(PaletteSource::BackgroundImage),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Wallpaper",
|
||||
state.palette_source == PaletteSource::Wallpaper,
|
||||
Change::PaletteSource(PaletteSource::Wallpaper),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
"Preset",
|
||||
state.palette_source == PaletteSource::Preset,
|
||||
Change::PaletteSource(PaletteSource::Preset),
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! 设置页「外观」分组(**真控件**)+ 背景层 Element + 面板层配色。
|
||||
//!
|
||||
//! ## 控件与键的对应
|
||||
//!
|
||||
//! | 控件 | 键 | 改动消息 |
|
||||
//! |---|---|---|
|
||||
//! | 整窗透明度滑块(下限 0.08) | `appearance.window_opacity` | [`Change::WindowOpacity`] |
|
||||
//! | 窗口圆角(跟随系统 / 直角,M116) | `appearance.window_corner` | [`Change::Corner`] |
|
||||
//! | 面板透明度滑块(下限 0.4,CPU 档禁用) | `appearance.panel_alpha` | [`Change::PanelAlpha`] |
|
||||
//! | 背景图开关 | `appearance.bg_image_enabled` | [`Change::BgEnabled`] |
|
||||
//! | 背景图路径(文本框) | `appearance.bg_image` | [`Change::BgImage`] |
|
||||
//! | 填充(封面 / 拉伸 / 平铺) | `appearance.bg_fill` | [`Change::BgFill`] |
|
||||
//! | 暗化强度(0~0.95) | `appearance.bg_dim` | [`Change::BgDim`] |
|
||||
//! | 配色来源(三档) | `appearance.palette_source` | [`Change::PaletteSource`] |
|
||||
//!
|
||||
//! ## 文件分工(M179 `I18NSEAM`:纯搬移 + 语言接线)
|
||||
//!
|
||||
//! 本模块原来是一个 395 行的 `section.rs`,而全仓行数守卫是 400(`components/gate.rs`),
|
||||
//! 剩 5 行——加不了任何东西。M179 按「一段控件一个文件」切开了它:
|
||||
//!
|
||||
//! | 文件 | 是什么 |
|
||||
//! |---|---|
|
||||
//! | 本文件(`section/mod.rs`) | **壳**:[`section`] 按顺序拼各段 + [`card`] 卡片 + 行控件([`label`] / [`slider_row`] / [`choice_row`] / [`choice`] / [`note_line`])+ 取词口 [`t`] |
|
||||
//! | [`window`] | ① 整窗透明度 + ② 窗口圆角(含「因整窗 α 不生效」的 notice 与说明段) |
|
||||
//! | [`panel`] | ③ 面板透明度(CPU 档强制 1.0,值 / 生效值分开显示) |
|
||||
//! | [`bg_image`] | ④ 背景图开关 + ⑤ 路径 + ⑥ 诊断 + ⑦ 填充 + ⑧ 暗化 |
|
||||
//! | [`colour_source`] | ⑨ 配色来源(三档) |
|
||||
//!
|
||||
//! 拆分口径:**纯搬移**——每段的语句逐字搬过去,只有下面这几处机械改写(M179 报告里用「去空白/
|
||||
//! 去尾逗号 diff」逐段证明:改完之后与原文件逐字相同,断言一条没动):
|
||||
//!
|
||||
//! 1. `rows = rows.push(…)` → `rows.push(…)`(壳现在传 `&mut Vec<Element>`);
|
||||
//! 2. `t(Key::X)` → `t(Key::X, lang)`(本 mission 的语言接线);
|
||||
//! 3. `super::notice(…)` → `super::super::notice(…)`(多了一层模块);
|
||||
//! 4. 唯一一处非 `Element` 实参:`rows.push(column![…])` → 末尾加 `.into()`(`Vec::push` 不做 `Into`);
|
||||
//! 5. rustfmt 因为多出来的 `, lang` 把两处调用换成了多行。
|
||||
//!
|
||||
//! ## 语言(M179 只接管子,不补文案)
|
||||
//!
|
||||
//! [`t`] 是**唯一**取词口,现在收 `lang`(真值从 `settings::view` 的 `Language.effective`
|
||||
//! → `Group.body` → [`section`] 一路递进来),所以中文界面上走词表的那 5 处(窗口圆角一行、
|
||||
//! 两个选项、说明段、notice)真的是中文。**其余文案仍是英文基线**(字面量,没有 key)——
|
||||
//! 补文案是下一条 mission,M179 只负责让「管子」通到这一层。
|
||||
//!
|
||||
//! ## 为什么「值」与「生效值」分开显示
|
||||
//!
|
||||
//! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写
|
||||
//! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。
|
||||
|
||||
use iced::widget::{button, column, container, row};
|
||||
use iced::{
|
||||
Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme,
|
||||
};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use crate::pages::{frame, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::state::State;
|
||||
use super::Change;
|
||||
|
||||
mod bg_image;
|
||||
mod colour_source;
|
||||
mod panel;
|
||||
mod window;
|
||||
|
||||
/// 「外观」分组整块(一张卡)。
|
||||
///
|
||||
/// `lang` 是**这一次生效的语言**:`settings::view` 的 `Language.effective`(M179)。壳把各段按
|
||||
/// **视觉顺序**拼起来;每一段的语句逐字来自拆分前的 `section()`。
|
||||
pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
|
||||
let mut rows: Vec<Element<'a, Message>> = Vec::new();
|
||||
|
||||
window::push(ui, state, lang, &mut rows);
|
||||
panel::push(ui, state, &mut rows);
|
||||
bg_image::push(ui, state, &mut rows);
|
||||
colour_source::push(ui, state, &mut rows);
|
||||
|
||||
card(
|
||||
ui,
|
||||
column(rows)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
}
|
||||
|
||||
/// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。
|
||||
fn card<'a>(ui: &'a Ui, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
let spec = Component::OutlinedCard.spec();
|
||||
let background = ui.layer(MaterialLayer::Card);
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(background)),
|
||||
border: ui.outlined(spec.shape),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 行与控件
|
||||
//
|
||||
// 五个行控件由各段共用,所以留在壳里(`pub(super)` = 只给 `section/` 下的兄弟模块用)。
|
||||
|
||||
pub(super) fn label<'a>(ui: &'a Ui, content: &'a str) -> Element<'a, Message> {
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
content,
|
||||
ui.color(Role::OnSurface),
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置页文案的 i18n 取词口(M116 起新条目一律走词表,不再写字面量)。
|
||||
///
|
||||
/// `lang` 是**这一次生效的语言**,由 [`section`] 一路递下来(M179 `I18NSEAM` 之前这里按英文目录
|
||||
/// 取词,于是中文界面上本分组的 27 条文案里真正本地化 0 条)。现在这个函数是全模块
|
||||
/// **唯一**的取词点:谁硬编码英文,就不会经过这里——M179 报告里逐行列了那些字面量,补文案是
|
||||
/// 下一条 mission。
|
||||
pub(super) fn t(key: Key, lang: Lang) -> &'static str {
|
||||
let text = lang.catalog().text(key);
|
||||
// 测试专用:把「这一帧真的取了哪条译文」记一份(iced 0.14 的 `Element` 没有可遍历控件树的
|
||||
// 公开接口,所以「渲染出来的字是不是中文」只能在取词口取证,见 `appearance::probe`)。
|
||||
#[cfg(test)]
|
||||
super::probe::record(key, text);
|
||||
text
|
||||
}
|
||||
|
||||
pub(super) fn note_line<'a>(ui: &'a Ui, content: &str, color: Color) -> Element<'a, Message> {
|
||||
// 诊断文案是**运行时拼出来的**(路径、错误原因),只能走 owned 版本。
|
||||
frame::text_role_owned(ui, TypeRole::BodySmall, false, content.to_owned(), color)
|
||||
}
|
||||
|
||||
/// 一行「标签 + 值 + 滑块 + 说明」。
|
||||
pub(super) fn slider_row<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
value: String,
|
||||
hint: String,
|
||||
enabled: bool,
|
||||
control: Element<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
let value_color = if enabled {
|
||||
ui.color(Role::OnSurface)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
let head = row![
|
||||
container(label(ui, title))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
frame::text_role_owned(ui, TypeRole::LabelLarge, true, value, value_color),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
column![
|
||||
head,
|
||||
control,
|
||||
frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一行「标签 + 一排互斥按钮」。
|
||||
pub(super) fn choice_row<'a>(
|
||||
ui: &'a Ui,
|
||||
title: &'a str,
|
||||
choices: Vec<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
container(label(ui, title))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
row(choices)
|
||||
.spacing(ui.space(Space::X1))
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一个互斥按钮(选中态用 `SecondaryContainer`,未选中用透明 + `OnSurfaceVariant`)。
|
||||
pub(super) fn choice<'a>(
|
||||
ui: &'a Ui,
|
||||
text: &'a str,
|
||||
selected: bool,
|
||||
change: Change,
|
||||
) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height;
|
||||
let content_color = if selected {
|
||||
ui.color(Role::OnSecondaryContainer)
|
||||
} else {
|
||||
ui.color(Role::OnSurfaceVariant)
|
||||
};
|
||||
|
||||
button(
|
||||
container(frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelLarge,
|
||||
selected,
|
||||
text,
|
||||
content_color,
|
||||
))
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.height(height)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X3)]))
|
||||
.style(move |_theme: &IcedTheme, status| {
|
||||
let background = if selected {
|
||||
Some(Background::Color(ui.color(Role::SecondaryContainer)))
|
||||
} else if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||
Some(Background::Color(ui.state_over(
|
||||
unlucky_design::color::StateLayer::Hover,
|
||||
Role::OnSurface,
|
||||
Role::Surface,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
button::Style {
|
||||
background,
|
||||
text_color: content_color,
|
||||
border: Border {
|
||||
radius: ui.radius(ShapeScale::Full, height),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
})
|
||||
.on_press(Message::Appearance(change))
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! 外观分组第 ③ 段:**面板透明度**滑块(下限 0.4;CPU 档强制 1.0 并禁用滑块)。
|
||||
|
||||
use iced::Element;
|
||||
|
||||
use crate::app::components::inputs::{slider, Slider};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::alpha::{panel_alpha_from_fraction, panel_fraction};
|
||||
use super::super::state::State;
|
||||
use super::super::Change;
|
||||
use super::slider_row;
|
||||
|
||||
/// 把 ③ 面板透明度推进 `rows`。
|
||||
pub(super) fn push<'a>(ui: &'a Ui, state: &'a State, rows: &mut Vec<Element<'a, Message>>) {
|
||||
// ③ 面板透明度(CPU 档强制 1.0)
|
||||
let forced = state.panel_is_forced();
|
||||
let panel_percent = if forced {
|
||||
format!("100% (forced from {:.2})", state.panel_alpha)
|
||||
} else {
|
||||
format!("{:.0}%", state.effective_panel_alpha() * 100.0)
|
||||
};
|
||||
let panel_hint = if forced {
|
||||
"Software renderer: panels stay fully opaque (window transparency still works)."
|
||||
} else {
|
||||
"Only the panel/card layer. Floor 0.40; independent of window transparency."
|
||||
};
|
||||
let panel_control = if forced {
|
||||
slider(Slider::new(ui, panel_fraction(1.0)))
|
||||
} else {
|
||||
slider(
|
||||
Slider::new(ui, panel_fraction(state.panel_alpha)).on_change(|fraction| {
|
||||
Message::Appearance(Change::PanelAlpha(panel_alpha_from_fraction(fraction)))
|
||||
}),
|
||||
)
|
||||
};
|
||||
rows.push(slider_row(
|
||||
ui,
|
||||
"Panel transparency",
|
||||
panel_percent,
|
||||
panel_hint.to_owned(),
|
||||
!forced,
|
||||
panel_control,
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! 外观分组第 ① ② 段:**整窗**两件——整窗透明度滑块、窗口圆角(含 notice 与说明段)。
|
||||
//!
|
||||
//! 这两段是唯一**一个控件牵动另一个控件**的地方:圆角「跟随系统」时把整窗透明度调低会让圆角失效
|
||||
//! (`DwmExtendFrameIntoClientArea` 的世界里没有半透明边框),所以只有这一对放在同一个文件里——
|
||||
//! notice 的判据(`corner == System && window_opacity < 1.0`)跨这两段。
|
||||
|
||||
use iced::widget::space;
|
||||
use iced::Element;
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::prefs::WindowCorner;
|
||||
|
||||
use crate::app::components::inputs::{slider, Slider};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::alpha::{window_alpha_from_fraction, window_fraction, WINDOW_ALPHA_FLOOR};
|
||||
use super::super::state::State;
|
||||
use super::super::Change;
|
||||
use super::{choice, choice_row, note_line, slider_row, t};
|
||||
|
||||
/// 把 ① 整窗透明度与 ② 窗口圆角按视觉顺序推进 `rows`。
|
||||
pub(super) fn push<'a>(
|
||||
ui: &'a Ui,
|
||||
state: &'a State,
|
||||
lang: Lang,
|
||||
rows: &mut Vec<Element<'a, Message>>,
|
||||
) {
|
||||
// ① 整窗透明度
|
||||
let window_percent = format!("{:.0}%", state.window_opacity * 100.0);
|
||||
rows.push(slider_row(
|
||||
ui,
|
||||
"Window transparency",
|
||||
window_percent,
|
||||
format!(
|
||||
"Applies to the whole window. Floor {:.2}; 1.00 = fully opaque.",
|
||||
WINDOW_ALPHA_FLOOR
|
||||
),
|
||||
state.window_opacity < 1.0,
|
||||
slider(
|
||||
Slider::new(ui, window_fraction(state.window_opacity)).on_change(|fraction| {
|
||||
Message::Appearance(Change::WindowOpacity(window_alpha_from_fraction(fraction)))
|
||||
}),
|
||||
),
|
||||
));
|
||||
|
||||
// ② 窗口圆角(M116):只有两档 + 一句按平台说明;不做 8/12/16 档(系统不暴露半径)。
|
||||
let corner = state.window_corner();
|
||||
rows.push(choice_row(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCorner, lang),
|
||||
vec![
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSystem, lang),
|
||||
corner == WindowCorner::System,
|
||||
Change::Corner(WindowCorner::System),
|
||||
),
|
||||
choice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSquare, lang),
|
||||
corner == WindowCorner::Square,
|
||||
Change::Corner(WindowCorner::Square),
|
||||
),
|
||||
],
|
||||
));
|
||||
// **tonal notice**(r5 视觉终稿):M3 里表达「当前状态」的惯用形态是**容器**,不是改字色。
|
||||
// 只在「跟随系统 + α<1」出现;与下面的说明段之间再留 `Space::X1`(列间距 X1 + 这里 X1 = X2)。
|
||||
if corner == WindowCorner::System && state.window_opacity < 1.0 {
|
||||
rows.push(super::super::notice(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerSuspended, lang),
|
||||
));
|
||||
let gap: Element<'a, Message> = space::vertical().height(ui.space(Space::X1)).into();
|
||||
rows.push(gap);
|
||||
}
|
||||
let tone = ui.color(Role::OnSurfaceVariant);
|
||||
rows.push(note_line(
|
||||
ui,
|
||||
t(Key::SettingsAppearanceCornerHint, lang),
|
||||
tone,
|
||||
));
|
||||
}
|
||||
@@ -268,7 +268,7 @@ pub fn app<'a>(
|
||||
|
||||
/// 同 [`app`],但可以带上**外观运行期状态**。
|
||||
///
|
||||
/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section.rs`);
|
||||
/// `Some(state)` ⇒ 设置页「外观」分组画**真控件**(`pages/appearance/section/`);
|
||||
/// `None` ⇒ 画演示行,与 [`app`] 逐像素等价。harness 的 `--appearance=real` 走的就是这一支:
|
||||
/// M100 之前 harness 恒传 `None`,于是 D30 要的「带真实外观控件」那一屏**根本不存在**——
|
||||
/// 报告上写着「设置页已接真控件」,图里却只有演示行(U1 的形状)。
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! 设置页「助手(AI)」分组(M174):提供商 / 端点 / 模型 / 密钥 + 「测试连接」。
|
||||
//!
|
||||
//! ## 为什么单独一个目录
|
||||
//!
|
||||
//! `pages/settings/mod.rs` 里只挂一行 `mod ai;`(与 M173 的版面改动不碰面),本组的状态 / 读写 /
|
||||
//! 渲染 / 用例都在这四个文件里:[`state`](草稿 + 探测结论 + 校验)、[`store`](真读写
|
||||
//! `config/launcher.json` 的 `ai.*` 五键)、[`view`](一张卡片)与 `tests`。
|
||||
//!
|
||||
//! ## 三条纪律(本组的全部安全面)
|
||||
//!
|
||||
//! 1. **密钥不进 DTO、不进日志、不进错误文案**:草稿是 [`state::Secret`](`Debug` 手写成掩码),
|
||||
//! 落盘走 [`store::save`](在进程内直接过 `core::setting` 的 schema,**不经过**
|
||||
//! `AppCommand::SetSetting` 那条可序列化的指令通道——那条通道会把明文带进事件流/日志);
|
||||
//! 界面上默认掩码,只有用户点「显示」才明文。已存密钥的**长度**是唯一允许上屏的数值。
|
||||
//! 2. **空/非法值不保存**:[`state::State::validate`] 先拦(提供商词表、`http(s)://` 前缀、模型名),
|
||||
//! schema 再拦(越界/类型),两次都过才写盘。失败时界面上给的是拒绝原因,不是「已保存」。
|
||||
//! 3. **不配置就不可能发请求**:「测试连接」按钮在 [`state::State::can_test`] 为假时是禁用的,
|
||||
//! 真发不发由 `unlucky_cli::commands::agent::probe` 里的 `to_config()` 再拦一次
|
||||
//! (假 provider 构造计数 == 0 的守卫在那里)。
|
||||
|
||||
pub mod outcome;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
pub mod view;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use outcome::ProbeResult;
|
||||
pub use state::{Probe, ProviderId, SaveNotice, Secret, State};
|
||||
@@ -0,0 +1,197 @@
|
||||
//! 「测试连接」的结论(**界面要画的那一份**):缺项 / 分类 / 目标 / 耗时 / 脱敏详情。
|
||||
//!
|
||||
//! 为什么从 `state.rs` 拆出来:那个文件被 400 行硬线卡着(`components/gate.rs` 的
|
||||
//! `every_source_file_stays_under_the_line_limit` 按全仓判红),而这几件是**纯数据 + 翻译**,
|
||||
//! 与草稿状态机没有耦合。分类判断不在本文件:那是
|
||||
//! `unlucky_cli::commands::agent::probe` 的事(四类各有真 TCP 假服务器用例),这里只翻译。
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
|
||||
/// 缺哪一项(「未配置」时界面上要说清差什么)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MissingField {
|
||||
/// 已关闭 / 提供商是 disabled
|
||||
Disabled,
|
||||
/// 提供商取值不认识
|
||||
Provider,
|
||||
/// 端点为空
|
||||
BaseUrl,
|
||||
/// 模型名为空
|
||||
Model,
|
||||
/// Anthropic 缺密钥
|
||||
ApiKey,
|
||||
}
|
||||
|
||||
impl MissingField {
|
||||
/// 界面文案 key。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> Key {
|
||||
match self {
|
||||
Self::Disabled => Key::SettingsAiMissingDisabled,
|
||||
Self::Provider => Key::SettingsAiReasonProvider,
|
||||
Self::BaseUrl => Key::SettingsAiMissingBaseUrl,
|
||||
Self::Model => Key::SettingsAiMissingModel,
|
||||
Self::ApiKey => Key::SettingsAiMissingApiKey,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「测试连接」的结论大类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProbeKind {
|
||||
/// 通过
|
||||
Passed,
|
||||
/// 鉴权失败
|
||||
Auth,
|
||||
/// 模型不存在
|
||||
ModelNotFound,
|
||||
/// 端点不可达
|
||||
Unreachable,
|
||||
/// 超时
|
||||
Timeout,
|
||||
/// 其它失败
|
||||
Failed,
|
||||
/// 配置不完整,**没有发请求**
|
||||
NotConfigured,
|
||||
}
|
||||
|
||||
impl ProbeKind {
|
||||
/// 界面文案 key。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> Key {
|
||||
match self {
|
||||
Self::Passed => Key::SettingsAiTestPassed,
|
||||
Self::Auth => Key::SettingsAiTestAuth,
|
||||
Self::ModelNotFound => Key::SettingsAiTestModel,
|
||||
Self::Unreachable => Key::SettingsAiTestUnreachable,
|
||||
Self::Timeout => Key::SettingsAiTestTimeout,
|
||||
Self::Failed => Key::SettingsAiTestOther,
|
||||
Self::NotConfigured => Key::SettingsAiTestNotConfigured,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是不是「不通」那一类(界面用错误色)。
|
||||
#[must_use]
|
||||
pub const fn is_failure(self) -> bool {
|
||||
!matches!(self, Self::Passed)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次探测的结论(数值 + 分类 + 脱敏后的详情)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProbeResult {
|
||||
/// 大类。
|
||||
pub kind: ProbeKind,
|
||||
/// 未配置时缺的那一项。
|
||||
pub missing: Option<MissingField>,
|
||||
/// 实际请求的端点(`resolved_base_url`)。
|
||||
pub base_url: String,
|
||||
/// 实际请求的模型名。
|
||||
pub model: String,
|
||||
/// 耗时(毫秒)。
|
||||
pub elapsed_ms: u128,
|
||||
/// 脱敏后的原因(可能为空)。
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl ProbeResult {
|
||||
/// 「测试连接」的结论(`unlucky_cli` 的探测器产出)→ 界面要画的那一份。
|
||||
///
|
||||
/// 只做**翻译**:分类判断全在 `unlucky_cli::commands::agent::probe`(那边有四条真 TCP 假
|
||||
/// 服务器用例逐个钉住),这里不重新猜一遍。`elapsed_ms` 由发起处计时(含建连 + 首 token)。
|
||||
#[must_use]
|
||||
pub fn from_outcome(
|
||||
outcome: unlucky_cli::commands::agent::probe::ProbeOutcome,
|
||||
elapsed_ms: u128,
|
||||
) -> Self {
|
||||
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome};
|
||||
|
||||
let missing = match &outcome {
|
||||
ProbeOutcome::NotConfigured { missing } => Some(match missing {
|
||||
Missing::Disabled => MissingField::Disabled,
|
||||
Missing::Provider => MissingField::Provider,
|
||||
Missing::BaseUrl => MissingField::BaseUrl,
|
||||
Missing::Model => MissingField::Model,
|
||||
Missing::ApiKey => MissingField::ApiKey,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
// r1 复审 P2-1:**失败也带 target**(端点 + 模型)。以前这里对失败类一律给空串,界面上
|
||||
// 就成了「目标: · 模型 …」,用户改完 key 也无法确认改的是不是那一份配置。
|
||||
let (kind, target, detail) = match outcome {
|
||||
ProbeOutcome::NotConfigured { .. } => (ProbeKind::NotConfigured, None, String::new()),
|
||||
ProbeOutcome::Passed { target } => (ProbeKind::Passed, Some(target), String::new()),
|
||||
ProbeOutcome::Auth { target, detail } => (ProbeKind::Auth, Some(target), detail),
|
||||
ProbeOutcome::ModelNotFound { target, detail } => {
|
||||
(ProbeKind::ModelNotFound, Some(target), detail)
|
||||
}
|
||||
ProbeOutcome::Unreachable { target, detail } => {
|
||||
(ProbeKind::Unreachable, Some(target), detail)
|
||||
}
|
||||
ProbeOutcome::Timeout { target, detail } => (ProbeKind::Timeout, Some(target), detail),
|
||||
ProbeOutcome::Failed { target, detail } => (ProbeKind::Failed, Some(target), detail),
|
||||
};
|
||||
Self {
|
||||
kind,
|
||||
missing,
|
||||
base_url: target
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |t| t.base_url.clone()),
|
||||
model: target.map_or_else(String::new, |t| t.model.clone()),
|
||||
elapsed_ms,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图种子(`ULL_AI_STATE`):**走 [`ProbeResult::from_outcome`] 的同一条路**。
|
||||
///
|
||||
/// 为什么必须这样(r1 复审 P2-1):上一版种子直接手填 `ProbeResult` 的字段,于是「失败态
|
||||
/// 的目标是空串」这个缺陷只在产品路径上出现,截图永远看不见——**出图不镜像产品路径,等于
|
||||
/// 自欺**。现在种子只会伪造「探测结论是什么」,形状(哪些字段有值)由产品那条路决定。
|
||||
#[must_use]
|
||||
pub fn screenshot_seed(
|
||||
kind: ProbeKind,
|
||||
base_url: String,
|
||||
model: String,
|
||||
elapsed_ms: u128,
|
||||
) -> Self {
|
||||
use unlucky_cli::commands::agent::probe::{ProbeOutcome, Target};
|
||||
|
||||
let target = Target { base_url, model };
|
||||
let detail = "ProviderResponseError: status 401 Unauthorized: {\"error\":{\"message\":\"Incorrect API key provided\"}}";
|
||||
let outcome = match kind {
|
||||
ProbeKind::Passed => ProbeOutcome::Passed { target },
|
||||
ProbeKind::Auth => ProbeOutcome::Auth {
|
||||
target,
|
||||
detail: detail.to_string(),
|
||||
},
|
||||
ProbeKind::ModelNotFound => ProbeOutcome::ModelNotFound {
|
||||
target,
|
||||
detail: detail.to_string(),
|
||||
},
|
||||
ProbeKind::Unreachable => ProbeOutcome::Unreachable {
|
||||
target,
|
||||
detail: "Http client error: error sending request".to_string(),
|
||||
},
|
||||
ProbeKind::Timeout => ProbeOutcome::Timeout {
|
||||
target,
|
||||
detail: "8 s".to_string(),
|
||||
},
|
||||
ProbeKind::Failed => ProbeOutcome::Failed {
|
||||
target,
|
||||
detail: detail.to_string(),
|
||||
},
|
||||
// 未配置态没有 target,也就不该有「测过」的结论:种子里退到「没发请求」。
|
||||
ProbeKind::NotConfigured => ProbeOutcome::NotConfigured {
|
||||
missing: unlucky_cli::commands::agent::probe::Missing::Model,
|
||||
},
|
||||
};
|
||||
Self::from_outcome(outcome, elapsed_ms)
|
||||
}
|
||||
|
||||
/// 失败的三类之外的那几类(`NotConfigured` 没发请求)。
|
||||
#[must_use]
|
||||
pub fn sent_a_request(&self) -> bool {
|
||||
self.kind != ProbeKind::NotConfigured
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! 「助手」分组的运行期状态:四条草稿 + 一颗探测结论 + 校验。
|
||||
//!
|
||||
//! 状态住在 `pages::settings::Groups.ai_about`([`State`]),由 `App` 持有:
|
||||
//! 用户正在输入的值**不能**放进 `pages::Model`(那个模型每次事件都整棵重建,见
|
||||
//! `app/state.rs` 里 `download_draft` 的同一条理由)。
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
|
||||
use super::outcome::{MissingField, ProbeKind, ProbeResult};
|
||||
|
||||
/// 四个提供商(值 = `ai.provider` 的词表,真源是 `core::setting::keys::AI_PROVIDERS`,
|
||||
/// `ai/tests.rs` 里对表)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ProviderId {
|
||||
/// OpenAI 兼容(含 llama.cpp / LM Studio / DeepSeek / Qwen)
|
||||
#[default]
|
||||
OpenAiCompatible,
|
||||
/// Anthropic 原生
|
||||
Anthropic,
|
||||
/// Ollama 原生
|
||||
Ollama,
|
||||
/// 禁用
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl ProviderId {
|
||||
/// 四态(界面上的顺序 = 这张表)。
|
||||
pub const ALL: [Self; 4] = [
|
||||
Self::OpenAiCompatible,
|
||||
Self::Anthropic,
|
||||
Self::Ollama,
|
||||
Self::Disabled,
|
||||
];
|
||||
|
||||
/// `ai.provider` 的取值。
|
||||
#[must_use]
|
||||
pub const fn setting(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiCompatible => "openai_compatible",
|
||||
Self::Anthropic => "anthropic",
|
||||
Self::Ollama => "ollama",
|
||||
Self::Disabled => "disabled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 取值 → 四态(未知/空值回落 OpenAI 兼容,与 schema 的默认值同一档)。
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Self {
|
||||
match value.trim() {
|
||||
"anthropic" => Self::Anthropic,
|
||||
"ollama" => Self::Ollama,
|
||||
"disabled" => Self::Disabled,
|
||||
_ => Self::OpenAiCompatible,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该形态有没有统一默认端点(没有 ⇒ base_url 必填)。
|
||||
#[must_use]
|
||||
pub const fn default_base_url(self) -> &'static str {
|
||||
match self {
|
||||
Self::OpenAiCompatible => "",
|
||||
Self::Anthropic => "https://api.anthropic.com",
|
||||
Self::Ollama => "http://127.0.0.1:11434",
|
||||
Self::Disabled => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// 界面标签 key。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> Key {
|
||||
match self {
|
||||
Self::OpenAiCompatible => Key::SettingsAiProviderOpenAi,
|
||||
Self::Anthropic => Key::SettingsAiProviderAnthropic,
|
||||
Self::Ollama => Key::SettingsAiProviderOllama,
|
||||
Self::Disabled => Key::SettingsAiProviderDisabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户刚输入的密钥草稿:`Debug` 掩码,只有 [`Secret::expose`] 能拿到明文。
|
||||
///
|
||||
/// 为什么不用裸 `String`:`Groups` / `State` 都 `derive(Debug)`,`App` 里任何一处 `{:?}`(日志、
|
||||
/// panic 消息、测试断言失败输出)都会把明文打出来。类型自己守住这一条,比每次记得手写便宜。
|
||||
#[derive(Clone, Default, PartialEq, Eq)]
|
||||
pub struct Secret(String);
|
||||
|
||||
impl Secret {
|
||||
/// 从输入框的内容构造。
|
||||
#[must_use]
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
/// 是否空(空白串也算空)。
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.trim().is_empty()
|
||||
}
|
||||
|
||||
/// 明文(**只给落盘用**;不要拿它拼任何上屏/日志文本)。
|
||||
#[must_use]
|
||||
pub fn expose(&self) -> &str {
|
||||
self.0.trim()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.is_empty() {
|
||||
f.write_str("Secret(空)")
|
||||
} else {
|
||||
f.write_str("Secret(***)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置文件里那一份真值(`ai.*` 五键)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Snapshot {
|
||||
/// `ai.enabled`
|
||||
pub enabled: bool,
|
||||
/// `ai.provider`
|
||||
pub provider: ProviderId,
|
||||
/// `ai.base_url`
|
||||
pub base_url: String,
|
||||
/// `ai.model`
|
||||
pub model: String,
|
||||
/// 已存密钥的字符数(0 = 没存)。**只带长度不带值**:DTO 里不该有密钥。
|
||||
pub key_chars: usize,
|
||||
}
|
||||
|
||||
/// 探测三态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum Probe {
|
||||
/// 还没测
|
||||
#[default]
|
||||
Idle,
|
||||
/// 正在测
|
||||
Running,
|
||||
/// 测完了
|
||||
Done(ProbeResult),
|
||||
}
|
||||
|
||||
/// 保存结果(用户点「保存」之后的回执)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SaveNotice {
|
||||
/// 已落盘
|
||||
Saved,
|
||||
/// 被校验拦下(没写盘)
|
||||
Refused(Refusal),
|
||||
/// schema/磁盘拒绝(没写盘)
|
||||
Failed(String),
|
||||
/// 设置文件不可用(内存态运行)
|
||||
NoStorage,
|
||||
}
|
||||
|
||||
/// 拒绝保存的原因(本地校验,先于 schema)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// 端点不是 http(s)
|
||||
BaseUrl,
|
||||
/// 缺模型名
|
||||
Model,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// 界面文案 key。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> Key {
|
||||
match self {
|
||||
Self::BaseUrl => Key::SettingsAiReasonBaseUrl,
|
||||
Self::Model => Key::SettingsAiReasonModel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「助手」分组的运行期状态。
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
/// `ai.enabled`
|
||||
pub enabled: bool,
|
||||
/// `ai.provider`
|
||||
pub provider: ProviderId,
|
||||
/// `ai.base_url` 草稿
|
||||
pub base_url: String,
|
||||
/// `ai.model` 草稿
|
||||
pub model: String,
|
||||
/// 新密钥草稿(空 = 不改已存的那把)
|
||||
pub key: Secret,
|
||||
/// 已存密钥的字符数(0 = 没存)
|
||||
pub key_chars: usize,
|
||||
/// 是否明文显示密钥输入框
|
||||
pub reveal: bool,
|
||||
/// 有未保存的改动
|
||||
pub dirty: bool,
|
||||
/// 设置文件可用(内存态运行时置假:保存与测试都禁用)
|
||||
pub storage: bool,
|
||||
/// 上一次保存的回执
|
||||
pub notice: Option<SaveNotice>,
|
||||
/// 探测结论
|
||||
pub probe: Probe,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
provider: ProviderId::OpenAiCompatible,
|
||||
base_url: String::new(),
|
||||
model: String::new(),
|
||||
key: Secret::default(),
|
||||
key_chars: 0,
|
||||
reveal: false,
|
||||
dirty: false,
|
||||
storage: false,
|
||||
notice: None,
|
||||
probe: Probe::Idle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// 用设置文件里的真值重置草稿(**只在没脏的时候**:不许冲掉用户正在输入的内容)。
|
||||
///
|
||||
/// 返回是否真的改了(调用方据此决定要不要重建视图模型)。
|
||||
pub fn hydrate(&mut self, snapshot: Snapshot) -> bool {
|
||||
self.storage = true;
|
||||
self.key_chars = snapshot.key_chars;
|
||||
if self.dirty {
|
||||
return false;
|
||||
}
|
||||
let changed = self.enabled != snapshot.enabled
|
||||
|| self.provider != snapshot.provider
|
||||
|| self.base_url != snapshot.base_url
|
||||
|| self.model != snapshot.model;
|
||||
self.enabled = snapshot.enabled;
|
||||
self.provider = snapshot.provider;
|
||||
self.base_url = snapshot.base_url;
|
||||
self.model = snapshot.model;
|
||||
self.keep_probe_if_targeted();
|
||||
changed
|
||||
}
|
||||
|
||||
/// 端点/模型变了之后,「已通过」的旧结论不再是关于现在这套配置的——清掉,免得说谎。
|
||||
fn keep_probe_if_targeted(&mut self) {
|
||||
if let Probe::Done(result) = &self.probe {
|
||||
if result.base_url != self.resolved_base_url() || result.model != self.model.trim() {
|
||||
self.probe = Probe::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实际生效的端点(空 = 用该形态的默认值)。
|
||||
#[must_use]
|
||||
pub fn resolved_base_url(&self) -> String {
|
||||
let draft = self.base_url.trim();
|
||||
if draft.is_empty() {
|
||||
self.provider.default_base_url().to_string()
|
||||
} else {
|
||||
draft.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置齐了没有(不齐时「测试连接」禁用,界面上说缺哪一项)。
|
||||
#[must_use]
|
||||
pub fn missing(&self) -> Option<MissingField> {
|
||||
if !self.enabled || self.provider == ProviderId::Disabled {
|
||||
return Some(MissingField::Disabled);
|
||||
}
|
||||
if self.resolved_base_url().is_empty() {
|
||||
return Some(MissingField::BaseUrl);
|
||||
}
|
||||
if self.model.trim().is_empty() {
|
||||
return Some(MissingField::Model);
|
||||
}
|
||||
if self.provider == ProviderId::Anthropic && self.key.is_empty() && self.key_chars == 0 {
|
||||
return Some(MissingField::ApiKey);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 现在能不能点「测试连接」。
|
||||
#[must_use]
|
||||
pub fn can_test(&self) -> bool {
|
||||
self.storage && self.missing().is_none() && !matches!(self.probe, Probe::Running)
|
||||
}
|
||||
|
||||
/// 状态矩阵的三档(未配置 / 已配置 / 已通过 / 测试失败)。
|
||||
#[must_use]
|
||||
pub fn status(&self) -> Status {
|
||||
match &self.probe {
|
||||
Probe::Done(result) if result.kind == ProbeKind::Passed => Status::Verified,
|
||||
Probe::Done(result) if result.kind.is_failure() => Status::Failed,
|
||||
_ if self.missing().is_some() => Status::Unconfigured,
|
||||
_ => Status::Configured,
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存前校验(用户输入侧);过不了就不写盘。
|
||||
///
|
||||
/// # Errors
|
||||
/// 端点不是 `http(s)://` 或该形态缺模型名时返回原因。
|
||||
pub fn validate(&self) -> Result<(), Refusal> {
|
||||
if self.provider == ProviderId::Disabled || !self.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let base_url = self.resolved_base_url();
|
||||
if base_url.is_empty()
|
||||
|| !(base_url.starts_with("http://") || base_url.starts_with("https://"))
|
||||
{
|
||||
return Err(Refusal::BaseUrl);
|
||||
}
|
||||
if self.model.trim().is_empty() {
|
||||
return Err(Refusal::Model);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 取证开关 `ULL_AI_STATE`(只给 `--screenshot` 用,见 `app/state/construct.rs`)。
|
||||
///
|
||||
/// 为什么要它:D30 的状态矩阵要出「未配置 / 已配置并测过 / 测试失败」三张图,而真实路径要
|
||||
/// 点按钮、要联网。语义与 `ULL_UPDATE_STATE` / `ULL_ACCOUNT_STATE` 一致:只影响这一次进程。
|
||||
#[must_use]
|
||||
pub fn screenshot_seed(raw: &str) -> Option<Self> {
|
||||
let mut state = Self {
|
||||
storage: true,
|
||||
..Self::default()
|
||||
};
|
||||
match raw.trim() {
|
||||
"unconfigured" => {}
|
||||
"configured" => {
|
||||
state.base_url = "https://api.deepseek.com/v1".to_string();
|
||||
state.model = "deepseek-chat".to_string();
|
||||
state.key_chars = 32;
|
||||
}
|
||||
other => {
|
||||
let kind = match other {
|
||||
"verified" => ProbeKind::Passed,
|
||||
"failed-auth" => ProbeKind::Auth,
|
||||
"failed-model" => ProbeKind::ModelNotFound,
|
||||
"failed-unreachable" => ProbeKind::Unreachable,
|
||||
"failed-timeout" => ProbeKind::Timeout,
|
||||
_ => return None,
|
||||
};
|
||||
state.base_url = "https://api.deepseek.com/v1".to_string();
|
||||
state.model = "deepseek-chat".to_string();
|
||||
state.key_chars = 32;
|
||||
// r1 复审 P2-1:种子的**形状**必须由产品路径(`ProbeResult::from_outcome`)决定,
|
||||
// 这里只伪造「结论是哪一档」。手填 `ProbeResult` 的字段会让「失败态目标为空」
|
||||
// 这类缺陷只在产品上出现、截图永远看不见。
|
||||
state.probe = Probe::Done(ProbeResult::screenshot_seed(
|
||||
kind,
|
||||
state.resolved_base_url(),
|
||||
state.model.clone(),
|
||||
412,
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(state)
|
||||
}
|
||||
}
|
||||
|
||||
/// 页头的状态档(D30 的状态矩阵:四档各自一句文案 + 一种颜色)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// 未配置(缺项,按钮禁用)
|
||||
Unconfigured,
|
||||
/// 已配置(还没测或测过但目标改了)
|
||||
Configured,
|
||||
/// 连接已通过
|
||||
Verified,
|
||||
/// 测试失败
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// 文案 key。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> Key {
|
||||
match self {
|
||||
Self::Unconfigured => Key::SettingsAiStatusUnconfigured,
|
||||
Self::Configured => Key::SettingsAiStatusConfigured,
|
||||
Self::Verified => Key::SettingsAiStatusVerified,
|
||||
Self::Failed => Key::SettingsAiStatusTestFailed,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! 「助手」分组的**真读写**:`config/launcher.json` 的 `ai.enabled` / `ai.provider` /
|
||||
//! `ai.base_url` / `ai.model` / `ai.api_key` 五键。
|
||||
//!
|
||||
//! ## 为什么在进程内直接过 `SettingStore`,不走 `AppCommand::SetSetting`
|
||||
//!
|
||||
//! `AppCommand` 是可序列化的 DTO(`dto/events.rs` 的 `derive(Serialize, Deserialize)`),
|
||||
//! 把 `ai.api_key` 塞进去就等于让明文进了「命令通道」——事件日志、调试转储、mock 后端的事件
|
||||
//! 回放都会看到它。M174 的守卫①要求密钥**不出进程内**,所以本组写盘走
|
||||
//! `core::setting::store::SettingStore`:schema 校验 + 原子写照旧(不自己拼 JSON、不绕 schema),
|
||||
//! 只是不经过那条可序列化的通道。这与 `SettingsBridge::persist` 写外观是同一条做法。
|
||||
//!
|
||||
//! ## 为什么每次现读现写
|
||||
//!
|
||||
//! `SettingStore::load` 每次从磁盘读一份(`HostEnv::store()` 也是这个口径),所以「读 → 改 → 存」
|
||||
//! 不会用启动时的旧快照盖掉别人刚写的键(AI / `ull config set` 是同一个文件的外部写者)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
|
||||
|
||||
use super::state::{ProviderId, Snapshot, State};
|
||||
|
||||
/// 读一份真值。文件缺失/坏掉时 `SettingStore::load` 自己降级,这里只会读到 schema 默认值。
|
||||
#[must_use]
|
||||
pub fn load(path: &Path) -> Snapshot {
|
||||
let store = SettingStore::load(path, launcher_schema());
|
||||
snapshot(store.doc())
|
||||
}
|
||||
|
||||
/// 把草稿落盘,返回**落盘后**再读回来的真值(界面据此刷新,而不是相信自己的草稿)。
|
||||
///
|
||||
/// # Errors
|
||||
/// schema 拒绝(越界/类型/只读文件)或写盘失败时返回人话(已过 [`super::state::State::validate`],
|
||||
/// 所以这里只剩「schema/磁盘」这一类)。
|
||||
pub fn save(path: &Path, state: &State) -> Result<Snapshot, String> {
|
||||
let mut store = SettingStore::load(path, launcher_schema());
|
||||
// `Value` 是 `set` 的形参类型:用 `.into()` 让目标类型从签名推出,本 crate 就不必直接依赖
|
||||
// `serde_json`(`Cargo.toml` 不在本 mission 的 scope,加依赖要单独走)。
|
||||
store
|
||||
.set("ai.enabled", state.enabled.into())
|
||||
.and_then(|()| store.set("ai.provider", state.provider.setting().to_string().into()))
|
||||
.and_then(|()| store.set("ai.base_url", state.base_url.trim().to_string().into()))
|
||||
.and_then(|()| store.set("ai.model", state.model.trim().to_string().into()))
|
||||
.map_err(|error| format!("{error}"))?;
|
||||
|
||||
// 密钥:草稿非空才写(空 = 保留已存的那一把,界面上写着这条规则)。
|
||||
if !state.key.is_empty() {
|
||||
store
|
||||
.set("ai.api_key", state.key.expose().to_string().into())
|
||||
.map_err(|error| format!("{error}"))?;
|
||||
}
|
||||
|
||||
// **只有 `SaveOutcome::Saved` 算成功**(M174 r2 复审):`save()` 还有一档 `Skipped(reason)`
|
||||
// ——只读(`$schema` 认不出 / 文件被更新版本的启动器写过)、不可信文件、没有脏键。旧代码
|
||||
// 用 `.is_ok()` 把这一档也当成功,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。
|
||||
match store.save() {
|
||||
Ok(SaveOutcome::Saved(_)) => {}
|
||||
Ok(SaveOutcome::Skipped(reason)) => {
|
||||
return Err(format!("未写入磁盘({})", skip_reason(reason)));
|
||||
}
|
||||
Err(error) => return Err(format!("{error}")),
|
||||
}
|
||||
|
||||
// 落盘之后**真的从磁盘再读一份**:`store.doc()` 是这次改过的内存视图,它证明不了「文件里
|
||||
// 现在是什么」(上面那句注释在 r1 就有,代码却没照做——r2 复审按这句判的)。
|
||||
let reloaded = SettingStore::load(path, launcher_schema());
|
||||
Ok(snapshot(reloaded.doc()))
|
||||
}
|
||||
|
||||
/// `SkipReason` → 人话(`core` 的稳定名对用户没意义)。
|
||||
fn skip_reason(reason: unlucky_core::setting::store::SkipReason) -> &'static str {
|
||||
use unlucky_core::setting::store::SkipReason;
|
||||
match reason {
|
||||
SkipReason::NotDirty => "没有改动",
|
||||
SkipReason::ReadOnly => "配置文件只读:它被更新版本的启动器写过,或被标记为不可信",
|
||||
SkipReason::Unreadable => "配置文件不可信(加载时就降级成只读)",
|
||||
}
|
||||
}
|
||||
|
||||
/// 文档 → 快照(密钥只取长度,值不出本函数)。
|
||||
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
|
||||
let text = |key: &str| doc.effective_str(key).unwrap_or_default().to_string();
|
||||
let key_chars = doc
|
||||
.effective_str("ai.api_key")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map_or(0, |value| value.chars().count());
|
||||
Snapshot {
|
||||
enabled: doc.effective_bool("ai.enabled").unwrap_or(true),
|
||||
provider: ProviderId::from_setting(&text("ai.provider")),
|
||||
base_url: text("ai.base_url"),
|
||||
model: text("ai.model"),
|
||||
key_chars,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "store_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,211 @@
|
||||
//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘 + 外观写回不盖 AI 键。
|
||||
//!
|
||||
//! | 用例 | 守什么 | 变异 |
|
||||
//! |---|---|---|
|
||||
//! | `five_keys_round_trip` | 四项 + 密钥都真的落进 `launcher.json` | 少写一个键 / 写错键名 |
|
||||
//! | `snapshot_only_reports_key_length` | 快照里没有密钥值,只有长度 | 把 `api_key` 值放进 `Snapshot` |
|
||||
//! | `empty_draft_keeps_the_stored_key` | 密钥输入框留空 = 保留旧密钥 | 无条件写空串覆盖 |
|
||||
//! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` |
|
||||
//! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 |
|
||||
//! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 |
|
||||
//! | `read_only_config_is_reported_as_a_failure` | 只读配置上保存**不许**报成功 | `save()` 恒返回 Ok |
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{load, save};
|
||||
use crate::pages::settings::ai::state::{ProviderId, Secret, State};
|
||||
|
||||
fn temp_config(label: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-ai-store-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
|
||||
let path = dir.join("config/launcher.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
path
|
||||
}
|
||||
|
||||
fn draft() -> State {
|
||||
State {
|
||||
enabled: true,
|
||||
provider: ProviderId::Anthropic,
|
||||
base_url: "https://api.anthropic.com".to_string(),
|
||||
model: "claude-sonnet-4".to_string(),
|
||||
key: Secret::new("sk-ant-secret"),
|
||||
dirty: true,
|
||||
storage: true,
|
||||
..State::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_keys_round_trip() {
|
||||
let path = temp_config("roundtrip");
|
||||
let saved = save(&path, &draft()).expect("应当能保存");
|
||||
assert_eq!(saved.provider, ProviderId::Anthropic);
|
||||
assert_eq!(saved.base_url, "https://api.anthropic.com");
|
||||
assert_eq!(saved.model, "claude-sonnet-4");
|
||||
assert_eq!(saved.key_chars, "sk-ant-secret".chars().count());
|
||||
|
||||
// 文件里真的是这五个键(不是我们内存里的对象)。
|
||||
let text = std::fs::read_to_string(&path).expect("读文件");
|
||||
assert!(
|
||||
text.contains("\"base_url\": \"https://api.anthropic.com\""),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("\"model\": \"claude-sonnet-4\""), "{text}");
|
||||
assert!(text.contains("\"provider\": \"anthropic\""), "{text}");
|
||||
assert!(text.contains("\"api_key\": \"sk-ant-secret\""), "{text}");
|
||||
|
||||
// 新开一份读回来(= 重启语义)。
|
||||
let reloaded = load(&path);
|
||||
assert_eq!(reloaded, saved);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_only_reports_key_length() {
|
||||
let path = temp_config("length");
|
||||
let saved = save(&path, &draft()).expect("保存");
|
||||
// `Snapshot` 里唯一与密钥有关的字段是长度:类型上就没有 `String` 可以装明文。
|
||||
let printed = format!("{saved:?}");
|
||||
assert!(
|
||||
!printed.contains("sk-ant-secret"),
|
||||
"快照泄漏密钥:{printed}"
|
||||
);
|
||||
assert_eq!(saved.key_chars, "sk-ant-secret".len());
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_draft_keeps_the_stored_key() {
|
||||
let path = temp_config("keep-key");
|
||||
save(&path, &draft()).expect("先存一把");
|
||||
let mut next = draft();
|
||||
next.key = Secret::default();
|
||||
next.model = "claude-opus-4".to_string();
|
||||
let saved = save(&path, &next).expect("再存一次(密钥留空)");
|
||||
assert_eq!(saved.model, "claude-opus-4");
|
||||
assert_eq!(
|
||||
saved.key_chars,
|
||||
"sk-ant-secret".chars().count(),
|
||||
"密钥输入框留空时必须保留旧密钥"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_rejects_and_nothing_is_written() {
|
||||
let path = temp_config("reject");
|
||||
save(&path, &draft()).expect("先存一份合法的");
|
||||
let before = std::fs::read_to_string(&path).expect("读文件");
|
||||
|
||||
// `ai.model` 的上限是 128 字符:越界的值必须被 schema 拒绝,**且文件不动**。
|
||||
let mut bad = draft();
|
||||
bad.model = "m".repeat(400);
|
||||
bad.base_url = "https://example.invalid/v1".to_string();
|
||||
let error = save(&path, &bad).expect_err("越界模型名必须被拒");
|
||||
assert!(!error.is_empty());
|
||||
let after = std::fs::read_to_string(&path).expect("读文件");
|
||||
assert_eq!(before, after, "被拒绝的保存不该改动文件:{after}");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_edit_is_read_back() {
|
||||
let path = temp_config("external");
|
||||
save(&path, &draft()).expect("先存一份");
|
||||
// 外部写者(`ull config set ai.model …`)改一个键:我们不缓存,下一次读就是新值。
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"ai":{"enabled":true,"provider":"ollama","base_url":"http://127.0.0.1:11434","model":"llama3.1"}}"#,
|
||||
)
|
||||
.expect("写文件");
|
||||
let reloaded = load(&path);
|
||||
assert_eq!(reloaded.provider, ProviderId::Ollama);
|
||||
assert_eq!(reloaded.model, "llama3.1");
|
||||
assert_eq!(reloaded.key_chars, 0, "外部把 api_key 删了,长度就该是 0");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// **P2-4 的用户场景原样**(r1 复审要求的用例):设置页保存 AI 配置 → 同一秒内改主题 → 值还在。
|
||||
///
|
||||
/// 这一条与 `presenter/src/settings_bridge.rs` 的 `persist_keeps_keys_another_writer_just_saved`
|
||||
/// 守的是同一个缺陷,但从**产品的两个写者**出发:`ai::store::save`(现读现写)与
|
||||
/// `SettingsBridge::persist`(长生命 store + 写前 reload)。任一方少了「写前现读」,这条红。
|
||||
#[test]
|
||||
fn appearance_write_back_keeps_the_ai_keys() {
|
||||
use unlucky_presenter::prefs::{AppearancePrefs, ThemeMode};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
let path = temp_config("appearance-keeps-ai");
|
||||
// 外观桥先加载过一份文件(它的 store 就是从这里开始的旧快照)。
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
|
||||
|
||||
// 用户在「助手」页保存配置(真落盘、含密钥)。
|
||||
let mut draft = draft();
|
||||
draft.key = Secret::new("sk-must-survive");
|
||||
save(&path, &draft).expect("保存 AI 配置");
|
||||
|
||||
// 紧接着用户去「外观」改主题(外观桥写回它那份整文档)。
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("读文件");
|
||||
assert!(
|
||||
text.contains("sk-must-survive"),
|
||||
"密钥被外观写回盖掉了:{text}"
|
||||
);
|
||||
assert!(text.contains("claude-sonnet-4"), "模型名也被盖掉了:{text}");
|
||||
assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count());
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// **P2-1(r2)第二条**:只读配置上的保存必须报**失败**,而不是「已保存」。
|
||||
///
|
||||
/// 现场:`$schema` 认不出(或文件被更新版本的启动器写过)⇒ `FileAccess::ReadOnly`。
|
||||
/// `SettingStore::save` 在这种文件上会 `Skipped(ReadOnly)`(**不是** Err),旧代码用 `.is_ok()`
|
||||
/// 判定,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。
|
||||
///
|
||||
/// 抓得住:①把 `save()` 的 `SaveOutcome` 分支去掉(恒返回 Ok);②把只读档当成功。
|
||||
/// 抓不住:`Skipped` 分支自身的文案(见 [`super::skip_reason`] 的单测)。
|
||||
#[test]
|
||||
fn read_only_config_is_reported_as_a_failure() {
|
||||
let path = temp_config("read-only");
|
||||
// `Invalid` 兼容档(`$schema` 前缀认不出)⇒ `FileAccess::ReadOnly`:值照读,不许改写。
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ai":{"model":"keep-me"}}"#,
|
||||
)
|
||||
.expect("写配置");
|
||||
let before = std::fs::read_to_string(&path).expect("读配置");
|
||||
|
||||
let error = save(&path, &draft()).expect_err("只读配置上的保存必须报失败");
|
||||
assert!(
|
||||
error.contains("只读") || error.contains("只读文件") || error.contains("9.9.9"),
|
||||
"错误文案要说得清为什么没写:{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).expect("读配置"),
|
||||
before,
|
||||
"被只读闸门拦下的保存不许改动文件"
|
||||
);
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// `skip_reason` 的三档都有人话(`Skipped` 不许被静默当成成功)。
|
||||
#[test]
|
||||
fn skip_reasons_are_named() {
|
||||
use super::skip_reason;
|
||||
use unlucky_core::setting::store::SkipReason;
|
||||
|
||||
for reason in [
|
||||
SkipReason::NotDirty,
|
||||
SkipReason::ReadOnly,
|
||||
SkipReason::Unreadable,
|
||||
] {
|
||||
assert!(!skip_reason(reason).is_empty(), "{reason:?} 没有文案");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//! 「助手」分组状态机的用例(M174)。
|
||||
//!
|
||||
//! | 用例 | 守什么 | 变异(改成什么会红) |
|
||||
//! |---|---|---|
|
||||
//! | `provider_vocabulary_is_the_schema_vocabulary` | 四个取值 == `core::setting::keys::AI_PROVIDERS` | 词表里写 `open_ai_compatible` |
|
||||
//! | `secret_debug_is_masked` | 密钥不进 `{:?}` | 把 `Secret` 改成 `String` |
|
||||
//! | `validate_refuses_bad_endpoint_and_empty_model` | 空/非法值不保存 | 删掉 `starts_with("http")` 检查 |
|
||||
//! | `missing_names_exactly_one_field` | 「未配置」说清缺哪一项 | 所有缺项都返回 `BaseUrl` |
|
||||
//! | `can_test_is_false_until_configured` | 未配置时按钮禁用 | `can_test` 恒 `true` |
|
||||
//! | `dirty_draft_is_not_overwritten` | 外部改动不冲掉用户输入 | `hydrate` 先无条件赋值 |
|
||||
//! | `saving_a_new_target_drops_the_old_pass` | 端点/模型变了,旧「已通过」作废 | `keep_probe_if_targeted` 删掉 |
|
||||
//! | `not_configured_outcome_carries_no_target` | 「没发请求」的结论里没有目标/详情 | 把 `ProbeOutcome::NotConfigured` 当失败类 |
|
||||
|
||||
use unlucky_core::setting::keys::AI_PROVIDERS;
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
|
||||
use super::outcome::{MissingField, ProbeKind, ProbeResult};
|
||||
use super::state::{Probe, ProviderId, Refusal, Secret, Snapshot, State, Status};
|
||||
|
||||
fn configured() -> State {
|
||||
State {
|
||||
enabled: true,
|
||||
provider: ProviderId::OpenAiCompatible,
|
||||
base_url: "https://api.deepseek.com/v1".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
key: Secret::new("sk-test"),
|
||||
key_chars: 0,
|
||||
dirty: false,
|
||||
storage: true,
|
||||
..State::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_vocabulary_is_the_schema_vocabulary() {
|
||||
let ours: Vec<&str> = ProviderId::ALL.iter().map(|id| id.setting()).collect();
|
||||
assert_eq!(
|
||||
ours, AI_PROVIDERS,
|
||||
"四个取值必须与 schema 的允许值表逐条相等"
|
||||
);
|
||||
for id in ProviderId::ALL {
|
||||
assert_eq!(ProviderId::from_setting(id.setting()), id);
|
||||
}
|
||||
// 未知值回落 OpenAI 兼容(与 schema 默认值同一档),不是 panic 也不是禁用。
|
||||
assert_eq!(
|
||||
ProviderId::from_setting("open_ai_compatible"),
|
||||
ProviderId::OpenAiCompatible
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_debug_is_masked() {
|
||||
let secret = Secret::new("sk-live-LEAK-MARKER");
|
||||
let printed = format!("{secret:?}");
|
||||
assert!(!printed.contains("sk-live"), "Debug 漏了密钥:{printed}");
|
||||
assert!(printed.contains("***"), "应当显示掩码:{printed}");
|
||||
// 空草稿的 Debug 也要能分辨(否则「留空 = 保留旧密钥」这条规则在日志里看不出)。
|
||||
assert!(format!("{:?}", Secret::default()).contains("空"));
|
||||
assert!(Secret::new(" ").is_empty());
|
||||
}
|
||||
|
||||
/// **P2-2**:`Message::EditKey` 的 `Debug` 不许把密钥打进日志。
|
||||
///
|
||||
/// 现场(r1 复审):载荷是裸 `String`,而 harness / 断言失败输出会把页面消息 `{:?}` 打出来 ⇒
|
||||
/// 明文 key 进 stderr 与 CI 日志。载荷改成 [`Secret`] 之后由它自己的 `Debug` 兜住。
|
||||
///
|
||||
/// 抓得住:谁把 `EditKey(String)` 改回去(`Secret` 的 Debug 掩码见 [`secret_debug_is_masked`])。
|
||||
#[test]
|
||||
fn the_key_message_never_prints_the_key() {
|
||||
use crate::pages::settings::ai_about::Message;
|
||||
|
||||
const SECRET: &str = "sk-live-MSG-LEAK-7c2f";
|
||||
let message = Message::EditKey(Secret::new(SECRET));
|
||||
let printed = format!("{message:?}");
|
||||
assert!(
|
||||
!printed.contains(SECRET),
|
||||
"页面消息的 Debug 泄漏了密钥(它会进日志/CI 输出):{printed}"
|
||||
);
|
||||
assert!(printed.contains("***"), "应当看得见掩码标记:{printed}");
|
||||
|
||||
// 整条投递口(`pages::Message`)也一样:它才是真的被一层层 `{:?}` 的那个类型。
|
||||
let page =
|
||||
crate::pages::Message::Settings(crate::pages::settings::GroupMessage::AiAbout(message));
|
||||
let printed = format!("{page:?}");
|
||||
assert!(
|
||||
!printed.contains(SECRET),
|
||||
"页面消息漏斗泄漏了密钥:{printed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_refuses_bad_endpoint_and_empty_model() {
|
||||
let mut state = configured();
|
||||
assert_eq!(state.validate(), Ok(()));
|
||||
|
||||
state.base_url = "api.deepseek.com/v1".to_string();
|
||||
assert_eq!(
|
||||
state.validate(),
|
||||
Err(Refusal::BaseUrl),
|
||||
"没有协议头不算端点"
|
||||
);
|
||||
|
||||
state.base_url = String::new();
|
||||
assert_eq!(
|
||||
state.validate(),
|
||||
Err(Refusal::BaseUrl),
|
||||
"OpenAI 兼容形态没有默认端点,空值必须拒绝(不保存)"
|
||||
);
|
||||
|
||||
state.base_url = "https://api.deepseek.com/v1".to_string();
|
||||
state.model = " ".to_string();
|
||||
assert_eq!(state.validate(), Err(Refusal::Model));
|
||||
|
||||
// Ollama 有默认端点:空 base_url 是合法的(生效值回落默认)。
|
||||
state.provider = ProviderId::Ollama;
|
||||
state.base_url = String::new();
|
||||
state.model = "llama3".to_string();
|
||||
assert_eq!(state.validate(), Ok(()));
|
||||
assert_eq!(state.resolved_base_url(), "http://127.0.0.1:11434");
|
||||
|
||||
// 禁用档:什么都不填也能保存(那正是「关掉助手」)。
|
||||
state.provider = ProviderId::Disabled;
|
||||
state.model = String::new();
|
||||
assert_eq!(state.validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_names_exactly_one_field() {
|
||||
let mut state = configured();
|
||||
assert_eq!(state.missing(), None);
|
||||
|
||||
state.enabled = false;
|
||||
assert_eq!(state.missing(), Some(MissingField::Disabled));
|
||||
|
||||
let mut state = configured();
|
||||
state.base_url = String::new();
|
||||
assert_eq!(state.missing(), Some(MissingField::BaseUrl));
|
||||
|
||||
let mut state = configured();
|
||||
state.model = String::new();
|
||||
assert_eq!(state.missing(), Some(MissingField::Model));
|
||||
|
||||
// Anthropic 必须带密钥:草稿空 + 已存 0 字符 = 缺密钥。
|
||||
let mut state = configured();
|
||||
state.provider = ProviderId::Anthropic;
|
||||
state.base_url = String::new();
|
||||
state.model = "claude-sonnet-4".to_string();
|
||||
state.key = Secret::default();
|
||||
state.key_chars = 0;
|
||||
assert_eq!(state.missing(), Some(MissingField::ApiKey));
|
||||
// 已存过密钥(只有长度,没有值)就算配置齐了。
|
||||
state.key_chars = 40;
|
||||
assert_eq!(state.missing(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_test_is_false_until_configured() {
|
||||
let mut state = configured();
|
||||
assert!(state.can_test());
|
||||
|
||||
state.model = String::new();
|
||||
assert!(!state.can_test(), "缺模型名时「测试连接」必须禁用");
|
||||
|
||||
let mut state = configured();
|
||||
state.storage = false;
|
||||
assert!(!state.can_test(), "没有设置文件时也禁用");
|
||||
|
||||
let mut state = configured();
|
||||
state.probe = Probe::Running;
|
||||
assert!(!state.can_test(), "正在测的时候不许再点一次");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_draft_is_not_overwritten() {
|
||||
let mut state = configured();
|
||||
state.base_url = "https://typed.example/v1".to_string();
|
||||
state.dirty = true;
|
||||
let changed = state.hydrate(Snapshot {
|
||||
enabled: true,
|
||||
provider: ProviderId::Ollama,
|
||||
base_url: "http://127.0.0.1:11434".to_string(),
|
||||
model: "llama3".to_string(),
|
||||
key_chars: 12,
|
||||
});
|
||||
assert!(!changed);
|
||||
assert_eq!(
|
||||
state.base_url, "https://typed.example/v1",
|
||||
"脏草稿不许被冲掉"
|
||||
);
|
||||
assert_eq!(state.provider, ProviderId::OpenAiCompatible);
|
||||
// 密钥长度不属于草稿:外部改了密钥,长度照样要更新(界面上的「已存 N 字符」不能骗人)。
|
||||
assert_eq!(state.key_chars, 12);
|
||||
|
||||
// 不脏的时候照常灌入。
|
||||
let mut state = State::default();
|
||||
let changed = state.hydrate(Snapshot {
|
||||
enabled: true,
|
||||
provider: ProviderId::Ollama,
|
||||
base_url: "http://127.0.0.1:11434".to_string(),
|
||||
model: "llama3".to_string(),
|
||||
key_chars: 0,
|
||||
});
|
||||
assert!(changed);
|
||||
assert_eq!(state.provider, ProviderId::Ollama);
|
||||
assert!(state.storage, "灌过真值之后设置文件就是可用的");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_a_new_target_drops_the_old_pass() {
|
||||
let mut state = configured();
|
||||
state.probe = Probe::Done(ProbeResult {
|
||||
kind: ProbeKind::Passed,
|
||||
missing: None,
|
||||
base_url: state.resolved_base_url(),
|
||||
model: state.model.clone(),
|
||||
elapsed_ms: 120,
|
||||
detail: String::new(),
|
||||
});
|
||||
assert_eq!(state.status(), Status::Verified);
|
||||
|
||||
// 同一个目标重复灌入:结论仍然有效。
|
||||
let snapshot = Snapshot {
|
||||
enabled: true,
|
||||
provider: state.provider,
|
||||
base_url: state.base_url.clone(),
|
||||
model: state.model.clone(),
|
||||
key_chars: 0,
|
||||
};
|
||||
state.hydrate(snapshot);
|
||||
assert_eq!(
|
||||
state.status(),
|
||||
Status::Verified,
|
||||
"目标没变,别无故作废旧结论"
|
||||
);
|
||||
|
||||
// 换了端点:旧结论不再关于现在这套配置。
|
||||
state.dirty = false;
|
||||
state.hydrate(Snapshot {
|
||||
enabled: true,
|
||||
provider: ProviderId::OpenAiCompatible,
|
||||
base_url: "https://other.example/v1".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
key_chars: 0,
|
||||
});
|
||||
assert_eq!(state.probe, Probe::Idle, "目标变了,旧「已通过」必须作废");
|
||||
assert_eq!(state.status(), Status::Configured);
|
||||
}
|
||||
|
||||
/// **P2-1**:失败结论必须带着「对哪个端点 / 哪个模型」,只有「没发请求」才允许没有 target。
|
||||
///
|
||||
/// 抓得住:`ProbeResult::from_outcome` 又把失败类的 target 丢掉(那正是 r1 复审的缺陷——
|
||||
/// 界面上显示成「目标: · 模型 …」,而截图因为走的是手填种子看不见它)。
|
||||
#[test]
|
||||
fn failures_carry_the_target_that_was_dialed() {
|
||||
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome, Target};
|
||||
|
||||
let target = || Target {
|
||||
base_url: "https://api.deepseek.com/v1".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
};
|
||||
|
||||
let not_configured = ProbeResult::from_outcome(
|
||||
ProbeOutcome::NotConfigured {
|
||||
missing: Missing::Model,
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_eq!(not_configured.kind, ProbeKind::NotConfigured);
|
||||
assert_eq!(not_configured.missing, Some(MissingField::Model));
|
||||
assert!(not_configured.base_url.is_empty(), "没发请求就没有目标地址");
|
||||
assert!(not_configured.model.is_empty());
|
||||
assert!(!not_configured.sent_a_request());
|
||||
|
||||
for (outcome, expected) in [
|
||||
(
|
||||
ProbeOutcome::Auth {
|
||||
target: target(),
|
||||
detail: "401".to_string(),
|
||||
},
|
||||
ProbeKind::Auth,
|
||||
),
|
||||
(
|
||||
ProbeOutcome::ModelNotFound {
|
||||
target: target(),
|
||||
detail: "404".to_string(),
|
||||
},
|
||||
ProbeKind::ModelNotFound,
|
||||
),
|
||||
(
|
||||
ProbeOutcome::Unreachable {
|
||||
target: target(),
|
||||
detail: "refused".to_string(),
|
||||
},
|
||||
ProbeKind::Unreachable,
|
||||
),
|
||||
(
|
||||
ProbeOutcome::Timeout {
|
||||
target: target(),
|
||||
detail: "8 s".to_string(),
|
||||
},
|
||||
ProbeKind::Timeout,
|
||||
),
|
||||
(
|
||||
ProbeOutcome::Failed {
|
||||
target: target(),
|
||||
detail: "?".to_string(),
|
||||
},
|
||||
ProbeKind::Failed,
|
||||
),
|
||||
(ProbeOutcome::Passed { target: target() }, ProbeKind::Passed),
|
||||
] {
|
||||
let result = ProbeResult::from_outcome(outcome, 5);
|
||||
assert_eq!(result.kind, expected);
|
||||
assert_eq!(
|
||||
result.base_url, "https://api.deepseek.com/v1",
|
||||
"{expected:?}"
|
||||
);
|
||||
assert_eq!(result.model, "deepseek-chat", "{expected:?}");
|
||||
if expected != ProbeKind::Passed {
|
||||
assert!(result.sent_a_request());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshot_seed_covers_the_state_matrix() {
|
||||
// 未配置 / 已配置 / 已通过 / 失败四档都要能出图(D30 的矩阵)。
|
||||
let unconfigured = State::screenshot_seed("unconfigured").expect("种子");
|
||||
assert_eq!(unconfigured.status(), Status::Unconfigured);
|
||||
assert!(unconfigured.storage, "截图里也要如实显示「有设置文件」");
|
||||
|
||||
let configured = State::screenshot_seed("configured").expect("种子");
|
||||
assert_eq!(configured.status(), Status::Configured);
|
||||
assert_eq!(configured.model, "deepseek-chat");
|
||||
|
||||
let verified = State::screenshot_seed("verified").expect("种子");
|
||||
assert_eq!(verified.status(), Status::Verified);
|
||||
|
||||
let failed = State::screenshot_seed("failed-auth").expect("种子");
|
||||
assert_eq!(failed.status(), Status::Failed);
|
||||
// P2-1:种子的**形状**要走产品路径(`ProbeResult::from_outcome`),所以失败态也带 target。
|
||||
// 上一版种子手填字段、把 target 填满,于是「产品上失败态目标是空串」这个缺陷截图看不见。
|
||||
if let Probe::Done(result) = &failed.probe {
|
||||
assert_eq!(result.base_url, "https://api.deepseek.com/v1");
|
||||
assert_eq!(result.model, "deepseek-chat");
|
||||
assert!(!result.detail.is_empty(), "失败态应当带一句脱敏详情");
|
||||
} else {
|
||||
panic!("失败种子没带上探测结论");
|
||||
}
|
||||
assert!(
|
||||
State::screenshot_seed("nonsense").is_none(),
|
||||
"未知种子不伪造状态"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_key_used_here_exists_in_the_catalog() {
|
||||
// 中英各一遍,逐条取词:目录里没这条 key 就编译不过(`Key` 是枚举),
|
||||
// 但**译文为空**这件事只有这里抓得住。
|
||||
let mut keys: Vec<unlucky_i18n::Key> = ProviderId::ALL.iter().map(|id| id.label()).collect();
|
||||
keys.extend([
|
||||
MissingField::Disabled.label(),
|
||||
MissingField::Provider.label(),
|
||||
MissingField::BaseUrl.label(),
|
||||
MissingField::Model.label(),
|
||||
MissingField::ApiKey.label(),
|
||||
Refusal::BaseUrl.label(),
|
||||
Refusal::Model.label(),
|
||||
Status::Unconfigured.label(),
|
||||
Status::Configured.label(),
|
||||
Status::Verified.label(),
|
||||
Status::Failed.label(),
|
||||
ProbeKind::Passed.label(),
|
||||
ProbeKind::Auth.label(),
|
||||
ProbeKind::ModelNotFound.label(),
|
||||
ProbeKind::Unreachable.label(),
|
||||
ProbeKind::Timeout.label(),
|
||||
ProbeKind::Failed.label(),
|
||||
ProbeKind::NotConfigured.label(),
|
||||
]);
|
||||
for lang in [Lang::ZhCn, Lang::En] {
|
||||
let catalog = Catalog::of(lang);
|
||||
for key in &keys {
|
||||
assert!(
|
||||
!catalog.text(*key).trim().is_empty(),
|
||||
"{key:?} 在 {lang:?} 下没有译文"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
//! 「助手」分组的一张卡片(M174)。
|
||||
//!
|
||||
//! ## 版面(D30 的状态矩阵 + 数值)
|
||||
//!
|
||||
//! | 行 | 内容 | 数值 |
|
||||
//! |---|---|---|
|
||||
//! | 状态 | 未配置 / 已配置 / 连接已通过 / 测试失败 | 生效端点 + 模型名 |
|
||||
//! | 开关 | 启用助手(`ai.enabled`) | — |
|
||||
//! | 提供商 | 四态 filter chip(`ai.provider`) | 存的值 |
|
||||
//! | 端点 | `base_url` 文本框 | 生效值(空 = 该形态默认) |
|
||||
//! | 模型 | `ai.model` 文本框 | — |
|
||||
//! | 密钥 | 密码框(默认掩码)+「显示」 | **只显示字符数**,不显示值 |
|
||||
//! | 动作 | 保存 / 测试连接 | 测试结论带耗时(毫秒) |
|
||||
//!
|
||||
//! 「未配置」时「测试连接」是**禁用**的,并在下面直接说清缺哪一项——不是灰着什么都不说。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::inputs::{text_field, Field, FieldVariant};
|
||||
use crate::app::components::selection::{chip, switch, Chip, Switch};
|
||||
use crate::pages::frame::{card, divider, text_role_owned};
|
||||
use crate::pages::settings::ai::outcome::ProbeKind;
|
||||
use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, Secret, State, Status};
|
||||
use crate::pages::settings::{ai_about, GroupMessage, States};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 一条本组消息 → 页面消息(左列/正文都用这一处,别在下面各写一遍)。
|
||||
fn msg(message: ai_about::Message) -> Message {
|
||||
Message::Settings(GroupMessage::AiAbout(message))
|
||||
}
|
||||
|
||||
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来,见 `settings/mod.rs`)。
|
||||
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
|
||||
body_of(ui, &states.groups.ai_about, lang)
|
||||
}
|
||||
|
||||
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
|
||||
let catalog = lang.catalog();
|
||||
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
|
||||
|
||||
stacked = stacked.push(status_row(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(enable_row(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(provider_row(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(endpoint_field(ui, catalog, state));
|
||||
stacked = stacked.push(model_field(ui, catalog, state));
|
||||
stacked = stacked.push(key_field(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(actions_row(ui, catalog, state));
|
||||
stacked = stacked.push(hint_line(ui, catalog, state));
|
||||
|
||||
if let Some(notice) = &state.notice {
|
||||
stacked = stacked.push(notice_line(ui, catalog, notice));
|
||||
}
|
||||
if let Probe::Done(result) = &state.probe {
|
||||
stacked = stacked.push(probe_line(ui, catalog, result.kind));
|
||||
// 目标行只在真有值时才画(r1 复审 P2-1 的第二道保险):失败类现在**都会**带上 target,
|
||||
// 但「没发请求」与将来的新档可能没有;空着画出来就是「目标: · 模型 · N 毫秒」。
|
||||
if !(result.base_url.is_empty() && result.model.is_empty()) {
|
||||
stacked = stacked.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::SettingsAiTestTarget,
|
||||
&[
|
||||
("base_url", result.base_url.as_str()),
|
||||
("model", result.model.as_str()),
|
||||
("ms", result.elapsed_ms.to_string().as_str()),
|
||||
],
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
if !result.detail.trim().is_empty() {
|
||||
stacked = stacked.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(
|
||||
Key::SettingsAiTestDetail,
|
||||
&[("detail", result.detail.as_str())],
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
card(ui, stacked)
|
||||
}
|
||||
|
||||
/// 状态行:四档文案 + 生效端点 / 模型(数值直接上屏,不藏在提示里)。
|
||||
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
|
||||
let status = state.status();
|
||||
let color = match status {
|
||||
Status::Failed => ui.color(Role::Error),
|
||||
Status::Verified => ui.color(Role::Primary),
|
||||
_ => ui.color(Role::OnSurfaceVariant),
|
||||
};
|
||||
let target = if state.model.trim().is_empty() {
|
||||
state.resolved_base_url()
|
||||
} else {
|
||||
format!("{} · {}", state.resolved_base_url(), state.model.trim())
|
||||
};
|
||||
let line = catalog.render(
|
||||
Key::SettingsAiStatusLabel,
|
||||
&[("status", catalog.text(status.label()))],
|
||||
);
|
||||
row![
|
||||
container(text_role_owned(ui, TypeRole::BodyMedium, true, line, color)).width(Length::Fill),
|
||||
text_role_owned(ui, TypeRole::BodySmall, false, target, color),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn enable_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
|
||||
column![
|
||||
switch(
|
||||
ui,
|
||||
Switch::new(
|
||||
catalog.text(Key::SettingsAiEnable),
|
||||
state.enabled,
|
||||
msg(ai_about::Message::ToggleEnabled(!state.enabled)),
|
||||
),
|
||||
),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::SettingsAiEnableHint).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn provider_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
|
||||
// `.wrap()`:四颗 chip 在 424 宽的正文列里放不下一行时**整颗换行**,而不是把最后一颗
|
||||
// 压成竖排(「已禁 / 用」那种断字)。`Wrapping` 只提供 `push`,所以先建后 wrap。
|
||||
let mut chips = row![]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center);
|
||||
for provider in ProviderId::ALL {
|
||||
chips = chips.push(chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(provider.label()),
|
||||
state.provider == provider,
|
||||
msg(ai_about::Message::PickProvider(provider)),
|
||||
),
|
||||
));
|
||||
}
|
||||
row![
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::SettingsAiProviderLabel).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
chips.wrap(),
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn endpoint_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let field = Field::new(&state.base_url)
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsAiBaseUrl))
|
||||
.placeholder(catalog.text(Key::SettingsAiBaseUrlPlaceholder))
|
||||
.supporting(catalog.text(Key::SettingsAiBaseUrlHint));
|
||||
// 「生效:…」只在**回落默认值**(草稿为空、该形态有默认端点)时才值得说一句:
|
||||
// 草稿就是生效值时它是废话,空着且没有默认值时它是空串(比不说更糟)。
|
||||
let effective = state.resolved_base_url();
|
||||
let mut block = column![text_field(ui, field, |value| msg(
|
||||
ai_about::Message::EditBaseUrl(value)
|
||||
))]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
if !effective.is_empty() && effective != state.base_url.trim() {
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.render(Key::SettingsAiEffective, &[("value", effective.as_str())]),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
}
|
||||
block.into()
|
||||
}
|
||||
|
||||
fn model_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let field = Field::new(&state.model)
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsAiModel))
|
||||
.placeholder(catalog.text(Key::SettingsAiModelPlaceholder));
|
||||
text_field(ui, field, |value| msg(ai_about::Message::EditModel(value)))
|
||||
}
|
||||
|
||||
/// 密钥行:密码框 + 「显示 / 隐藏」chip + 已存状态(**只有字符数**)。
|
||||
fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let chars = state.key_chars.to_string();
|
||||
let supporting = if state.key_chars > 0 {
|
||||
catalog.render(
|
||||
Key::SettingsAiApiKeyConfigured,
|
||||
&[("chars", chars.as_str())],
|
||||
)
|
||||
} else {
|
||||
catalog.text(Key::SettingsAiApiKeyMissing).to_string()
|
||||
};
|
||||
let field = Field::new(state.key.expose())
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsAiApiKey))
|
||||
.placeholder(catalog.text(Key::SettingsAiApiKeyPlaceholder))
|
||||
.secure(!state.reveal);
|
||||
let toggle_label = if state.reveal {
|
||||
catalog.text(Key::SettingsAiHide)
|
||||
} else {
|
||||
catalog.text(Key::SettingsAiShow)
|
||||
};
|
||||
let toggle = chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
toggle_label,
|
||||
state.reveal,
|
||||
msg(ai_about::Message::Reveal(!state.reveal)),
|
||||
),
|
||||
);
|
||||
let field = container(text_field(ui, field, |value| {
|
||||
msg(ai_about::Message::EditKey(Secret::new(value)))
|
||||
}))
|
||||
.width(Length::Fill);
|
||||
column![
|
||||
row![field, toggle]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),
|
||||
// 「已存 N 字符 / 还没有密钥」这一行是**格式化出来的**(`Field::supporting` 借的是
|
||||
// `&'a str`,临时 String 借不进去),所以单独一行;它只讲长度,不讲值。
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
supporting,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn actions_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
|
||||
let save_label = catalog.text(Key::SettingsAiSave);
|
||||
let save = if state.dirty && state.storage {
|
||||
buttons::filled(ui, Button::new(save_label, msg(ai_about::Message::Save)))
|
||||
} else {
|
||||
buttons::filled(ui, Button::disabled(save_label))
|
||||
};
|
||||
|
||||
let test_label = if matches!(state.probe, Probe::Running) {
|
||||
catalog.text(Key::SettingsAiTesting)
|
||||
} else {
|
||||
catalog.text(Key::SettingsAiTest)
|
||||
};
|
||||
let test = if state.can_test() {
|
||||
buttons::tonal(ui, Button::new(test_label, msg(ai_about::Message::Test)))
|
||||
} else {
|
||||
buttons::tonal(ui, Button::disabled(test_label))
|
||||
};
|
||||
|
||||
row![save, test]
|
||||
.spacing(ui.space(Space::X3))
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 按钮为什么是灰的 / 下一步该做什么——**永远说清**,不留「点了没反应」。
|
||||
fn hint_line<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
|
||||
let (text, error) = if !state.storage {
|
||||
(catalog.text(Key::SettingsAiNoStorage).to_string(), true)
|
||||
} else if let Some(missing) = state.missing() {
|
||||
(
|
||||
catalog.render(
|
||||
Key::SettingsAiTestDisabled,
|
||||
&[("missing", catalog.text(missing.label()))],
|
||||
),
|
||||
false,
|
||||
)
|
||||
} else if state.dirty {
|
||||
(catalog.text(Key::SettingsAiUnsaved).to_string(), false)
|
||||
} else {
|
||||
(String::new(), false)
|
||||
};
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(if error {
|
||||
Role::Error
|
||||
} else {
|
||||
Role::OnSurfaceVariant
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn notice_line<'a>(ui: &'a Ui, catalog: Catalog, notice: &SaveNotice) -> Element<'a, Message> {
|
||||
let (text, error) = match notice {
|
||||
SaveNotice::Saved => (catalog.text(Key::SettingsAiSaved).to_string(), false),
|
||||
SaveNotice::Refused(reason) => (
|
||||
catalog.render(
|
||||
Key::SettingsAiRefused,
|
||||
&[("reason", catalog.text(reason.label()))],
|
||||
),
|
||||
true,
|
||||
),
|
||||
SaveNotice::Failed(reason) => (
|
||||
catalog.render(Key::SettingsAiRefused, &[("reason", reason.as_str())]),
|
||||
true,
|
||||
),
|
||||
SaveNotice::NoStorage => (catalog.text(Key::SettingsAiNoStorage).to_string(), true),
|
||||
};
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(if error { Role::Error } else { Role::OnSurface }),
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试结论一行:分类一句话(目标/耗时/详情在它下面三行)。
|
||||
fn probe_line<'a>(ui: &'a Ui, catalog: Catalog, kind: ProbeKind) -> Element<'a, Message> {
|
||||
let error = kind.is_failure();
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
true,
|
||||
catalog.text(kind.label()).to_string(),
|
||||
ui.color(if error { Role::Error } else { Role::Primary }),
|
||||
)
|
||||
}
|
||||
@@ -1,39 +1,80 @@
|
||||
//! 设置页「AI / About」分组:**预留**(页面上还没有这个分组,左列 4 项里没有它)。
|
||||
//! 设置页「助手(AI)」分组(M174):左列第 6 组的**挂载点**与本线的消息口。
|
||||
//!
|
||||
//! 为什么先留一个空文件:设置页正文接下来由四条线并行接(java / download / language+appearance /
|
||||
//! ai+about)。文件位置先占好,四条线就不会在同一天抢同一个新文件名;本文件现在是**只有文档**的
|
||||
//! 空模块(不产生任何代码,`-D warnings` 不会红)。
|
||||
//! 本文件只留三样东西,正文/状态/读写都在 `pages/settings/ai/` 里:
|
||||
//!
|
||||
//! ## 接线步骤(ai+about 线照做)
|
||||
//! | 这里 | 内容 |
|
||||
//! |---|---|
|
||||
//! | [`GROUP`] | 左列名 / 图标 / 演示行(产品路径走 [`super::ai::view::body`]) |
|
||||
//! | [`AI_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update 同一条**按下标分发**:本组尚未迁到 `Group.body` 槽位,见下节) |
|
||||
//! | [`Message`] | 本线的消息口(落点 `app/update/settings/ai_about.rs`) |
|
||||
//! | [`State`] | 转发 `ai::State`(`Groups.ai_about` 的载荷类型不变,`settings/state.rs` 一行不用改) |
|
||||
//!
|
||||
//! 1. 在本文件写 `pub(super) const GROUP: Group = Group { … }`,形状抄 `java.rs`;
|
||||
//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai_about::GROUP,`,并把数组长度 `4` 改成 `5`;
|
||||
//! 3. 文案 key 写进 `crates/i18n/src/catalog/settings/ai.rs`(那边已经接进装配链,同样只改一个文件)。
|
||||
//! ## 为什么 `body` 是 `None` 而不是 `Some(..)`
|
||||
//!
|
||||
//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。
|
||||
//! `Group.body` 的槽位(M179 起)拿的是 [`States`](super::States) 与**生效语言** `Lang`——本组要的
|
||||
//! 两件它其实都装得下。本组仍走按下标分发,是因为**还没迁过去**:槽位要求返回 `Option<Element>`,
|
||||
//! 而 `ai::view::body` 返回 `Element`,把本组(以及 Java 组)统一迁到槽位上是一次独立重构。
|
||||
//! M106 的语言行与 M159 的更新组当初也是同一个理由走上这条路,代价是 `settings/mod.rs` 多一行 `else if`。
|
||||
//!
|
||||
//! ## M101 起本文件还装本线的状态与消息口
|
||||
//! ## 密钥纪律
|
||||
//!
|
||||
//! - 状态:[`State`](`Groups.ai_about` 的载荷,加字段只改本文件);
|
||||
//! - 消息:[`Message`](落点 `app/update/settings/ai_about.rs`)。
|
||||
//!
|
||||
//! 上第 5 组时**额外**要动的共享文件(按上面的步骤 2 + 测试装配):
|
||||
//! - `settings/mod.rs` 的 `GROUPS`(加一行,长度 +1);
|
||||
//! - `settings/tests/mod.rs` 顶部加一行 `mod ai_section;`(新测试文件不挂 `mod` 就不编译),
|
||||
//! 并且左列顺序那条字面量断言会红——那是刻意的(页面上多一组就是观感变了,要人确认)。
|
||||
//!
|
||||
//! 注意 i18n 那边是按**分组**命名的,文案文件叫 `ai.rs` 而不是 `ai_about.rs`。
|
||||
//! [`Message::EditKey`] 的载荷是用户刚敲的明文(输入框必须有它),它**只**活在这个进程里:
|
||||
//! 落盘走 `ai::store::save`(不经 `AppCommand::SetSetting` 那条可序列化通道),
|
||||
//! 上屏默认掩码(`State::reveal` 才明文),`Debug` 由 `ai::state::Secret` 掩码。
|
||||
|
||||
/// AI / About 分组的运行期状态(M101 r2:**只归 ai+about 线**)。
|
||||
///
|
||||
/// 加字段只改本文件:`Groups` 只认 `Groups.ai_about` 这一个字段。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct State {}
|
||||
pub use super::ai::State;
|
||||
|
||||
/// AI / About 线的消息(**只归本线**)。
|
||||
///
|
||||
/// 首版是空枚举:这一条线的分组还没上页面。空枚举的好处是
|
||||
/// `app/update/settings/ai_about.rs` 里的 `match message {}` 天然穷尽——加了变体不接线就报
|
||||
/// E0004,不会静默。
|
||||
use unlucky_design::icons::IconKind;
|
||||
|
||||
use super::Group;
|
||||
|
||||
/// 本组在 [`super::GROUPS`] 里的下标(**必须是最后一个**:加组只追加不重排)。
|
||||
pub(crate) const AI_GROUP: usize = 5;
|
||||
|
||||
/// 「助手」分组(左列名固定英文:左列是导航,与导航轨同源,不进 catalog)。
|
||||
pub(super) const GROUP: Group = Group {
|
||||
name: "Assistant",
|
||||
icon: IconKind::Ai,
|
||||
// 只给 harness / 窄窗「按组堆叠」路径用的演示行:产品路径走 `ai::view::body`。
|
||||
rows: &[
|
||||
("Provider", "OpenAI compatible"),
|
||||
("Endpoint", "https://api.deepseek.com/v1"),
|
||||
("Model", "deepseek-chat"),
|
||||
],
|
||||
body: None,
|
||||
};
|
||||
|
||||
/// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/ai_about.rs`。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {}
|
||||
pub enum Message {
|
||||
/// 启用开关(`ai.enabled`)
|
||||
ToggleEnabled(bool),
|
||||
/// 选提供商(`ai.provider`)
|
||||
PickProvider(super::ai::ProviderId),
|
||||
/// 端点草稿(`ai.base_url`)
|
||||
EditBaseUrl(String),
|
||||
/// 模型名草稿(`ai.model`)
|
||||
EditModel(String),
|
||||
/// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)。
|
||||
///
|
||||
/// 载荷是 [`super::ai::Secret`] 而不是 `String`:**`Debug` 会打掩码**。这条消息会被 harness
|
||||
/// 的 `{:?}` 打进 stderr(r1 复审 P2-2 的现场),也会出现在任何 `assert_eq!` 失败输出里,
|
||||
/// 用裸 `String` 等于给密钥开了一条到日志的路。
|
||||
EditKey(super::ai::Secret),
|
||||
/// 密钥输入框明文/掩码
|
||||
Reveal(bool),
|
||||
/// 保存(校验 + 落盘)
|
||||
Save,
|
||||
/// 测试连接(异步;结果走 [`Message::ProbeDone`])
|
||||
Test,
|
||||
/// 测试连接的结果(分类 + 数值 + 脱敏详情)
|
||||
ProbeDone(Box<super::ai::ProbeResult>),
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::AiAbout`)。
|
||||
#[must_use]
|
||||
pub fn into_group(self) -> super::GroupMessage {
|
||||
super::GroupMessage::AiAbout(self)
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,25 @@
|
||||
//! 「Appearance」这个名字**;分组在左列的下标仍由 `crate::pages::appearance::APPEARANCE_GROUP`
|
||||
//! 声明,`settings/tests/mod.rs` 拿它和本文件比对(顺序被调换会红)。
|
||||
//!
|
||||
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section.rs`(本文件只负责
|
||||
//! **这一组归 language+appearance 线**:真控件的实现全在 `pages/appearance/section/`(本文件只负责
|
||||
//! 把它挂进注册表),所以改控件时不用碰设置页目录。
|
||||
//!
|
||||
//! M101 r2:本组的渲染器签名改成收 [`States`](组状态 + 外观状态),于是「这一组要读哪些状态」
|
||||
//! 由本文件决定——`settings/mod.rs` 不再认识 `appearance::State`。
|
||||
//!
|
||||
//! M179 `I18NSEAM`:签名再加一个 `lang`(`Group.body` 的槽位本身就带语言,见 `settings/mod.rs` 的
|
||||
//! `Body` 类型别名),于是这一组的中文界面文案真的走词表——此前它只能写死 `Lang::En`,
|
||||
//! 因为渲染器拿不到「这一次生效的语言」。
|
||||
|
||||
use iced::Element;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::{Group, Message, States};
|
||||
|
||||
/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section.rs` 的真控件);
|
||||
/// Appearance 分组。`body` 指着 [`body`](它再转给 `pages/appearance/section/` 的真控件);
|
||||
/// 没有外观运行期状态时(harness / `pages_demo`)返回 `None`,由 `form` 退回下面的演示行。
|
||||
pub(super) const GROUP: Group = Group {
|
||||
name: "Appearance",
|
||||
@@ -33,8 +38,10 @@ pub(super) const GROUP: Group = Group {
|
||||
};
|
||||
|
||||
/// 本组的真控件渲染器:有外观状态就画真控件(M93),没有(harness)返回 `None`。
|
||||
fn body<'a>(ui: &'a Ui, states: States<'a>) -> Option<Element<'a, Message>> {
|
||||
///
|
||||
/// `lang` 一路递给 `pages::appearance::section`——它是这一组唯一知道「界面现在是什么语言」的东西。
|
||||
fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Option<Element<'a, Message>> {
|
||||
states
|
||||
.appearance
|
||||
.map(|state| crate::pages::appearance::section(ui, state))
|
||||
.map(|state| crate::pages::appearance::section(ui, state, lang))
|
||||
}
|
||||
@@ -12,8 +12,8 @@ pub(super) use super::super::frame::{
|
||||
text_action, text_role, Header, ICON_PX, SCROLLBAR_MARGIN, SCROLLBAR_WIDTH,
|
||||
};
|
||||
|
||||
use iced::widget::{column, container, mouse_area, row, scrollable};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
||||
use iced::widget::{column, container, mouse_area, row};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding, Theme};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
@@ -137,20 +137,39 @@ fn group_item<'a>(
|
||||
/// 表单列的滚动区:只有正文滚,页头不动(与实例页列表列同一套口径)。
|
||||
///
|
||||
/// 「外观」分组有 7 个控件 + 最多 3 行诊断,1180×760 下会超出可视区,所以这一层是必需的。
|
||||
pub(super) fn scroll<'a>(
|
||||
///
|
||||
/// ## M173:滚动条**并排**在内容右侧
|
||||
///
|
||||
/// 原来这里是 `scrollable(content)` 裸调 + 轨宽 6 / 内缩 2。iced 的竖直滚动条是浮层:轨道按
|
||||
/// 视口右边缘定位(`iced_widget-0.14.2/src/scrollable.rs:1970`),而内容拿到的横向约束是**整个
|
||||
/// 视口宽**(同文件 `:437`,`Scrollbar::spacing` 默认 `None` ⇒ 右内边距 0)。于是滚动条占的那条
|
||||
/// 10dp 竖带(轨 6 + 两侧内缩 2×2)整条压在表单列最右边——用户看到的就是「滚动条骑在卡片上」。
|
||||
///
|
||||
/// 改法在 [`crate::app::components::scroll::vertical`]:内容右侧恒留一条 10dp 的槽,轨道画在槽里。
|
||||
/// 槽宽来自同一个式子 `轨宽 + 2 × margin`,所以:
|
||||
///
|
||||
/// - 内容可用宽度 = 视口宽(424)− 槽宽(10)= **414**;
|
||||
/// - 长页面(出滑块)与短页面(不出滑块)**内容宽度一致**——槽是内边距,不依赖 iced 的
|
||||
/// `is_scrollbar_visible`(那正是 `Scrollbar::spacing` 不能用在这里的理由:它只在可见时预留,
|
||||
/// 滑块一出现内容就横跳 10dp)。
|
||||
///
|
||||
/// 数值由 `components/scroll.rs` 与 `tests/scroll_fit.rs` 的**布局级**断言钉住(`Renderer = ()`
|
||||
/// 真跑 iced 的 `layout()`),不是看图。
|
||||
///
|
||||
/// 只泛型到 `Renderer` 是为了让上面那条断言能在 `()` 渲染器上跑;产品调用点不写泛型。
|
||||
pub(super) fn scroll<'a, Renderer>(
|
||||
ui: &'a Ui,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
scrollable(content)
|
||||
.direction(scrollable::Direction::Vertical(
|
||||
scrollable::Scrollbar::new()
|
||||
.width(SCROLLBAR_WIDTH)
|
||||
.scroller_width(SCROLLBAR_WIDTH)
|
||||
.margin(SCROLLBAR_MARGIN),
|
||||
))
|
||||
.height(Length::Fill)
|
||||
.style(scroll_style(ui))
|
||||
.into()
|
||||
content: impl Into<Element<'a, Message, Theme, Renderer>>,
|
||||
) -> Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: iced::advanced::text::Renderer + 'a,
|
||||
{
|
||||
crate::app::components::scroll::vertical(
|
||||
SCROLLBAR_WIDTH,
|
||||
SCROLLBAR_MARGIN,
|
||||
scroll_style(ui),
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
/// 演示行构成的一组设置 = 一张卡,行间 1px 分隔线(M3 设置列表的常规做法)。
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
//!
|
||||
//! ## 这一组为什么特殊
|
||||
//!
|
||||
//! 它**不走** `Group::body`:那个渲染器的签名是 `fn(&Ui, States) -> Option<Element>`,`States` 只有
|
||||
//! `appearance` + `groups` 两格,装不下「当前语言档位 + 生效语言」。语言行要的两件在
|
||||
//! `pages::Language` 里,由 `pages::settings::view` 的 `language` 形参一路递进来、`form` 按下标
|
||||
//! 转交 [`body`];所以 `GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」照样成立。
|
||||
//! 它**不走** `Group::body`:那个渲染器拿的是 [`States`](super::States) 与**生效语言** `Lang`
|
||||
//! (M179 起;此前连 `Lang` 都没有),而语言行要的是「当前语言**档位** `LangChoice` + 生效语言」
|
||||
//! 这一对——档位不在 `States` 里。所以它由 `pages::settings::view` 的 `language` 形参一路递进来、
|
||||
//! `form` 按下标转交 [`body`];`GROUP.body` 仍是 `None`,`tests/mod.rs` 那条「只有外观组带真控件」
|
||||
//! 照样成立。
|
||||
//!
|
||||
//! ## 语言切换**没有**走 M101 的 `GroupMessage` 投递口(偏离,已登记)
|
||||
//!
|
||||
@@ -46,6 +47,7 @@ use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::LangChoice;
|
||||
|
||||
use crate::fonts::install::{CjkInstall, FetchClass};
|
||||
use crate::pages::{frame, Language, Message};
|
||||
use crate::theme::Ui;
|
||||
|
||||
@@ -71,6 +73,10 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let mut stacked = column![].spacing(0.0).width(Length::Fill);
|
||||
stacked = stacked.push(language_row(ui, language));
|
||||
// M168(D28 后半):中文字体的下载状态行。紧贴语言行——用户在这里手选中文,反馈就该在这行下面,
|
||||
// 而不是等他切到别的页面才发现「刚才那几秒在干嘛」。
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(font_row(ui, language));
|
||||
|
||||
// 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。
|
||||
for (label, value) in [
|
||||
@@ -85,6 +91,110 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
card(ui, stacked)
|
||||
}
|
||||
|
||||
/// 中文字体状态行的**文案**(纯函数:状态 × 目录 → 一句话 + 可选的一句解释)。
|
||||
///
|
||||
/// 为什么单独拎出来:截图只能证明「画出来了」,而这里能被断言证明「四种状态的词都在、占位符都被
|
||||
/// 替换、两套目录都没有 `⟪missing⟫`」(见 `tests/general_section.rs`)。
|
||||
///
|
||||
/// 失败态**不写具体错误串**:那是给我们看的(`[font] cjk-download failed` 那行日志),用户只需要
|
||||
/// 知道「网络 / 内容 / 磁盘」里哪一类,以及「界面照常可用」。
|
||||
pub(super) fn font_status(
|
||||
catalog: &unlucky_i18n::catalog::Catalog,
|
||||
status: &CjkInstall,
|
||||
) -> (String, Option<String>) {
|
||||
match status {
|
||||
CjkInstall::Unknown => (catalog.text(Key::FontCjkStatusUnknown).to_owned(), None),
|
||||
CjkInstall::Unprobeable => (catalog.text(Key::FontCjkStatusUnprobeable).to_owned(), None),
|
||||
CjkInstall::NotNeeded => (catalog.text(Key::FontCjkStatusNotNeeded).to_owned(), None),
|
||||
CjkInstall::System { family } => (
|
||||
catalog.render(Key::FontCjkStatusSystem, &[("family", family)]),
|
||||
None,
|
||||
),
|
||||
CjkInstall::Downloading { bytes } => (
|
||||
catalog.render(
|
||||
Key::FontCjkStatusDownloading,
|
||||
&[("size", &format_size(*bytes))],
|
||||
),
|
||||
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
|
||||
),
|
||||
CjkInstall::Ready { family, .. } => (
|
||||
catalog.render(Key::FontCjkReady, &[("family", family)]),
|
||||
Some(catalog.text(Key::FontCjkStatusHint).to_owned()),
|
||||
),
|
||||
CjkInstall::Failed { class, .. } => {
|
||||
let reason = catalog.text(match class {
|
||||
FetchClass::Network => Key::FontCjkReasonNetwork,
|
||||
FetchClass::Integrity => Key::FontCjkReasonIntegrity,
|
||||
FetchClass::Disk => Key::FontCjkReasonDisk,
|
||||
});
|
||||
(
|
||||
catalog.render(Key::FontCjkStatusFailed, &[("reason", reason)]),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节数 → 人话体积(17 772 300 B → `16.9 MB`,按 1 MiB = 1048576 B)。只用于「≈」级别的量。
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const MIB: f64 = 1024.0 * 1024.0;
|
||||
let mib = bytes as f64 / MIB;
|
||||
if mib >= 1.0 {
|
||||
format!("{mib:.1} MB")
|
||||
} else {
|
||||
format!("{:.0} KB", bytes as f64 / 1024.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 中文字体状态行:标签一行、状态一行(失败红、就绪主色)、需要时再补一句解释。
|
||||
///
|
||||
/// 为什么**竖排**而不是像主题那几行「标签左、取值右」:失败那句话是整句(「获取失败(网络不通)。
|
||||
/// 界面照常可用…」),塞进右列会把它压成一行挤掉标签(实测截图就是一整行红字、标签消失)。
|
||||
/// 语言行也是竖排(标题 + 选项 + 说明),这一行贴着它,观感一致。
|
||||
fn font_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
let status = crate::fonts::install::status();
|
||||
let (value, hint) = font_status(&catalog, &status);
|
||||
let color = match status {
|
||||
CjkInstall::Failed { .. } => ui.color(Role::Error),
|
||||
CjkInstall::Ready { .. } | CjkInstall::System { .. } => ui.color(Role::Primary),
|
||||
_ => ui.color(Role::OnSurfaceVariant),
|
||||
};
|
||||
|
||||
let mut stacked = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
false,
|
||||
catalog.text(Key::FontCjkStatusLabel),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
container(frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
value,
|
||||
color
|
||||
))
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.padding(Padding::from([ui.space(Space::X2), 0.0]));
|
||||
|
||||
if let Some(hint) = hint {
|
||||
stacked = stacked.push(container(frame::text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
hint,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)));
|
||||
}
|
||||
|
||||
stacked.into()
|
||||
}
|
||||
|
||||
/// 语言行:标题 + 生效语言回显 + 三档互斥按钮 + 一句说明。
|
||||
fn language_row<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> {
|
||||
let catalog = language.effective.catalog();
|
||||
@@ -175,7 +285,7 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
|
||||
|
||||
/// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。
|
||||
///
|
||||
/// 与 `pages/appearance/section.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。
|
||||
/// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。
|
||||
fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> {
|
||||
let height = ui.metrics().control_height;
|
||||
let content_color = if selected {
|
||||
|
||||
@@ -1,44 +1,84 @@
|
||||
//! 设置页「Java」分组:运行时 / 默认内存 / JVM 参数(演示行)。
|
||||
//! 设置页「Java」分组(M175):运行时列表 / 默认 JDK / 内存 / JVM 参数。
|
||||
//!
|
||||
//! **这一组归 java 线**:接真控件时在本文件把 `body` 换成 `Some(java::body)`(渲染器也写在本文件),
|
||||
//! 再把 `rows` 换成真数据源给的文本;`settings/mod.rs` 的注册表不用动。
|
||||
//! 本文件只剩三样东西,正文 / 状态 / 读写都在 `pages/settings/java/` 里:
|
||||
//!
|
||||
//! 图标说明(视觉复审 P2-3):令牌里 `Play` 是填充字形,和同屏其它描边图标语汇打架;
|
||||
//! Java 分组讲的是运行时安装位置,用目录图标语义也对得上。
|
||||
//! | 这里 | 内容 |
|
||||
//! |---|---|
|
||||
//! | [`GROUP`] | 左列名 / 图标(正文走 [`view::body`],`body` 槽位保持 `None`) |
|
||||
//! | [`JAVA_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update / Assistant 同一条**按下标分发**:本组要 `Lang`,`Group.body` 的签名里没有它) |
|
||||
//! | [`Message`] | 本线的消息口(落点 `app/update/settings/java.rs`) |
|
||||
//!
|
||||
//! ## M101 的演示行去哪了
|
||||
//!
|
||||
//! M101 留的 `rows: &[("Runtime","Temurin 21.0.4"), …]` 与绊线测试
|
||||
//! `the_java_group_is_still_demo_rows`(断言 `body.is_none()`)在 M175 一起删掉了:
|
||||
//! 那三个值是**写死的演示字符串**,用户实测把它们当成了真检测结果
|
||||
//! (「它默认显示 Temurin 21.0.4 我甚至不能确定它是否是一个硬编码的值」)。
|
||||
//! 现在的真值来自三段真读盘:`core::game::java::staged`(扫描)、`core::game::java::manual`
|
||||
//! (手动路径严格探测)、`config/launcher.json`(`java.*` 三键)。
|
||||
//!
|
||||
//! `rows` 仍留成空切片:它是 `Group` 的字段(另外五组在用),而本组的 `body` 分发永远命中
|
||||
//! [`view::body`],空切片不会被画出来——宁可空着,也不留一句会撒谎的演示值。
|
||||
|
||||
mod fields;
|
||||
mod row;
|
||||
mod seed;
|
||||
mod state;
|
||||
pub(crate) mod store;
|
||||
pub(super) mod view;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use state::{same_path, Context, Layer, Notice, Requirement, State};
|
||||
|
||||
/// 测试里的注入作业要写 `Vec<RuntimeRow>`(App 层在生产路径上只用 `State`,不需要这个名字)。
|
||||
#[cfg(test)]
|
||||
pub use row::RuntimeRow;
|
||||
|
||||
use unlucky_design::icons::IconKind;
|
||||
|
||||
use super::Group;
|
||||
|
||||
/// 本组在 [`super::GROUPS`] 里的下标(`GROUPS` 的顺序是 general / appearance / **java** / …)。
|
||||
///
|
||||
/// 写死 2 与 `groups[2]` 同一个来源:`tests/mod.rs` 的「左列顺序」把这张表钉住了。
|
||||
pub(crate) const JAVA_GROUP: usize = 2;
|
||||
|
||||
/// Java 分组。
|
||||
pub(super) const GROUP: Group = Group {
|
||||
name: "Java",
|
||||
icon: IconKind::Folder,
|
||||
rows: &[
|
||||
("Runtime", "Temurin 21.0.4"),
|
||||
("Default memory", "4 GiB"),
|
||||
("JVM arguments", "Automatic"),
|
||||
],
|
||||
// 空:产品路径与窄窗「按组堆叠」都走 `view::body`(见文件头「M101 的演示行去哪了」)。
|
||||
rows: &[],
|
||||
body: None,
|
||||
};
|
||||
|
||||
/// Java 分组的运行期状态(M101 r2:**只归 java 线**)。
|
||||
///
|
||||
/// 加字段只改本文件:`Groups` 只认 `Groups.java`(一个字段),不认这个类型里的任何字段,
|
||||
/// 所以 `settings/mod.rs` / `app/state.rs` / `app/view.rs` 都不用动。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct State {}
|
||||
|
||||
/// Java 分组的消息(M101:**只归 java 线**,加变体只改本文件)。
|
||||
///
|
||||
/// 落点是 `app/update/settings/java.rs`;`pages/mod.rs` 与 `page_messages.rs` 都不用动(它们只认
|
||||
/// [`GroupMessage`](super::GroupMessage))。
|
||||
/// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/java.rs`。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
/// **只有测试构造它**:M101 r2 留的「活的端到端证据」——外壳消息 →
|
||||
/// `on_page` → `on_settings` → `on_settings_java` 这条路在运行时真的跑通,不是只靠编译期穷尽。
|
||||
/// java 线接第一个真控件时把这一支换成真变体:`tests/java_section.rs` 的
|
||||
/// `the_java_group_is_still_demo_rows` 就是那条会提醒你的绊线(它断言 `body.is_none()`)。
|
||||
#[cfg(test)]
|
||||
Ping,
|
||||
/// 手动触发一次自动扫描(真扫盘,后台线程;进度与结果由心跳灌回来)
|
||||
Scan,
|
||||
/// 取消正在跑的扫描
|
||||
CancelScan,
|
||||
/// 手动路径输入框
|
||||
AddDraft(String),
|
||||
/// 提交手动添加(严格校验:真跑 `java -version`)
|
||||
Add,
|
||||
/// 把某个运行时设为默认(写 `java.selected_path`)
|
||||
SelectRuntime(String),
|
||||
/// 默认内存草稿(MB 文本)
|
||||
EditMemory(String),
|
||||
/// JVM 额外参数草稿
|
||||
EditJvm(String),
|
||||
/// 保存内存 + JVM 参数(校验 + 落盘 + 回读)
|
||||
Save,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::Java`)。
|
||||
#[must_use]
|
||||
pub fn into_group(self) -> super::GroupMessage {
|
||||
super::GroupMessage::Java(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 「Java」分组的**编辑区**(M175):手动路径 / 默认内存 / JVM 参数 / 保存与回执。
|
||||
//!
|
||||
//! 从 `view.rs` 拆出来的(那个文件加了守卫之后贴到 438 行,越过 400 行硬线):这里全是
|
||||
//! 「用户能改的东西」,`view.rs` 只剩「扫到了什么」与版面。两半共用 `view::msg` 这一个消息口,
|
||||
//! 所以「一条控件对应哪条消息」不会各写一遍。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::inputs::{text_field, Field, FieldVariant};
|
||||
use crate::pages::frame::text_role_owned;
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::state::{Layer, Notice, Refusal, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
|
||||
use super::view::msg;
|
||||
use super::Message as JavaMessage;
|
||||
|
||||
/// 手动添加一行:输入框 + 「添加」按钮 + 原因。
|
||||
pub(super) fn add_row<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let field = Field::new(&state.add_draft)
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsJavaAdd))
|
||||
.placeholder(catalog.text(Key::SettingsJavaAddPlaceholder))
|
||||
.supporting(catalog.text(Key::SettingsJavaAddHint));
|
||||
let add = if state.add_draft.trim().is_empty() || !state.storage {
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::disabled(catalog.text(Key::SettingsJavaAddAction)),
|
||||
)
|
||||
} else {
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::SettingsJavaAddAction),
|
||||
msg(JavaMessage::Add),
|
||||
),
|
||||
)
|
||||
};
|
||||
row![
|
||||
container(text_field(ui, field, |value| msg(JavaMessage::AddDraft(
|
||||
value
|
||||
))))
|
||||
.width(Length::Fill),
|
||||
add,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 默认内存:数字框 + 范围 + 「生效值来自哪一层」。
|
||||
pub(super) fn memory_field<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
) -> Element<'a, Message> {
|
||||
let range = catalog.render(
|
||||
Key::SettingsJavaMemoryHint,
|
||||
&[
|
||||
("min", MIN_MEMORY_MB.to_string().as_str()),
|
||||
("max", MAX_MEMORY_MB.to_string().as_str()),
|
||||
],
|
||||
);
|
||||
let field = Field::new(&state.memory)
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsJavaMemory));
|
||||
// 校验不过就把输入框本身标红(不只是下面那行字):用户一眼看到「是这个框有问题」。
|
||||
let field = match state.validate() {
|
||||
Err(Refusal::MemoryEmpty | Refusal::MemoryNotNumber | Refusal::MemoryRange) => {
|
||||
field.error()
|
||||
}
|
||||
_ => field,
|
||||
};
|
||||
let effective = match state.layer() {
|
||||
Layer::Global => catalog.render(
|
||||
Key::SettingsJavaLayerGlobal,
|
||||
&[("mb", state.saved_memory.to_string().as_str())],
|
||||
),
|
||||
Layer::Instance { instance, mb } => catalog.render(
|
||||
Key::SettingsJavaLayerInstance,
|
||||
&[
|
||||
("mb", mb.to_string().as_str()),
|
||||
("instance", instance.as_str()),
|
||||
],
|
||||
),
|
||||
};
|
||||
column![
|
||||
text_field(ui, field, |value| msg(JavaMessage::EditMemory(value))),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
effective,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
range,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// JVM 额外参数:文本框 + 「会拼到每次启动」的说明。
|
||||
pub(super) fn jvm_field<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
) -> Element<'a, Message> {
|
||||
let field = Field::new(&state.jvm)
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsJavaJvm))
|
||||
.placeholder(catalog.text(Key::SettingsJavaJvmPlaceholder))
|
||||
.supporting(catalog.text(Key::SettingsJavaJvmHint));
|
||||
let field = if matches!(state.validate(), Err(Refusal::JvmQuotes)) {
|
||||
field.error()
|
||||
} else {
|
||||
field
|
||||
};
|
||||
// 已保存值:空的时候说「还没有保存任何额外参数」,而不是让「已保存的值:」后面空着。
|
||||
let saved = if state.saved_jvm.trim().is_empty() {
|
||||
catalog.text(Key::SettingsJavaJvmSavedNone).to_string()
|
||||
} else {
|
||||
catalog.render(
|
||||
Key::SettingsJavaJvmSaved,
|
||||
&[("value", state.saved_jvm.as_str())],
|
||||
)
|
||||
};
|
||||
column![
|
||||
text_field(ui, field, |value| msg(JavaMessage::EditJvm(value))),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
saved,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn actions_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
) -> Element<'a, Message> {
|
||||
let label = catalog.text(Key::SettingsJavaSave);
|
||||
if state.can_save() {
|
||||
buttons::filled(ui, Button::new(label, msg(JavaMessage::Save)))
|
||||
} else {
|
||||
buttons::filled(ui, Button::disabled(label))
|
||||
}
|
||||
}
|
||||
|
||||
/// 按钮为什么灰着 / 下一步做什么——**永远说清**。
|
||||
pub(super) fn hint_line<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
) -> Element<'a, Message> {
|
||||
let (text, error) = if !state.storage {
|
||||
(catalog.text(Key::SettingsJavaNoStorage).to_string(), true)
|
||||
} else if state.dirty {
|
||||
(catalog.text(Key::SettingsJavaUnsaved).to_string(), false)
|
||||
} else {
|
||||
(String::new(), false)
|
||||
};
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(if error {
|
||||
Role::Error
|
||||
} else {
|
||||
Role::OnSurfaceVariant
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn notice_line<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
notice: &Notice,
|
||||
) -> Element<'a, Message> {
|
||||
let (text, error) = match notice {
|
||||
Notice::Saved => (catalog.text(Key::SettingsJavaSaved).to_string(), false),
|
||||
Notice::Refused(reason) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaRefused,
|
||||
&[("reason", catalog.text(refusal_key(*reason)))],
|
||||
),
|
||||
true,
|
||||
),
|
||||
Notice::Unwritable(reason) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaRefused,
|
||||
&[("reason", catalog.text(reason.label()))],
|
||||
),
|
||||
true,
|
||||
),
|
||||
Notice::WriteFailed => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaRefused,
|
||||
&[("reason", catalog.text(Key::SettingsJavaReasonWriteFailed))],
|
||||
),
|
||||
true,
|
||||
),
|
||||
Notice::NoStorage => (catalog.text(Key::SettingsJavaNoStorage).to_string(), true),
|
||||
Notice::DefaultSet(path) => (
|
||||
catalog.render(Key::SettingsJavaDefaultSet, &[("path", path.as_str())]),
|
||||
false,
|
||||
),
|
||||
Notice::Added(path) => (
|
||||
catalog.render(Key::SettingsJavaAdded, &[("path", path.as_str())]),
|
||||
false,
|
||||
),
|
||||
};
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(if error { Role::Error } else { Role::OnSurface }),
|
||||
)
|
||||
}
|
||||
|
||||
/// 拒绝原因 → 文案 key(`Refusal::label` 已是这张表,这里只是给 `match` 一个名字)。
|
||||
pub(super) fn refusal_key(refusal: Refusal) -> Key {
|
||||
refusal.label()
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 「Java」分组的一行运行时(M175):路径 / 版本 / 厂商 / 来源 + 上屏用的截断。
|
||||
//!
|
||||
//! 从 `state.rs` 拆出来的(那个文件加了上下文与校验之后到 424 行,越过 400 行硬线):
|
||||
//! 这里只放「一行长什么样」,状态与校验仍在 `state.rs`。
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
|
||||
/// 一个扫到的 / 手动加的运行时(页面要的那几格)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeRow {
|
||||
/// `bin/java[.exe]` 的绝对路径(也是 `java.selected_path` 的取值)。
|
||||
pub binary: String,
|
||||
/// `java -version` 的首行(探测失败时为空串)。
|
||||
pub version: String,
|
||||
/// 主版本号(探测不出为 `None`)。
|
||||
pub major: Option<u32>,
|
||||
/// 发行方(认不出为 `None`)。
|
||||
pub vendor: Option<String>,
|
||||
/// 来源 id(`managed` / `env` / `path` / `system` / `manual`)。
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
impl RuntimeRow {
|
||||
/// 版本号:**能区分 `17.0.13` 与 `17.0.9`** 的那一位,而不是只给大版本(r1 复审 P2-4)。
|
||||
///
|
||||
/// 三层取法,逐层降级(不许因为解析不出来就只显示 `17`):
|
||||
/// 1. `-version` 首行引号里的版本号(`openjdk version "21.0.11" …` → `21.0.11`);
|
||||
/// 2. 无引号形态里的第一个版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`);
|
||||
/// 3. 整行原样(原始版本串比一个大版本号有用);
|
||||
/// 4. 连首行都没有(探测失败)→ 大版本;再没有 → `?`。
|
||||
#[must_use]
|
||||
pub fn version_label(&self) -> String {
|
||||
if let Some(quoted) = quoted_version(&self.version) {
|
||||
return quoted;
|
||||
}
|
||||
if let Some(bare) = bare_version(&self.version) {
|
||||
return bare;
|
||||
}
|
||||
let line = self.version.trim();
|
||||
if !line.is_empty() {
|
||||
return line.to_string();
|
||||
}
|
||||
self.major
|
||||
.map_or_else(|| "?".to_string(), |major| major.to_string())
|
||||
}
|
||||
|
||||
/// 版本一句话:`Java 21.0.11 · Microsoft`。
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
let version = self.version_label();
|
||||
match &self.vendor {
|
||||
Some(vendor) => format!("Java {version} · {vendor}"),
|
||||
None => format!("Java {version}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// JDK 安装目录(`…/bin/java.exe` 的上一级再上一级)。
|
||||
///
|
||||
/// 上屏用它而不是整条可执行文件路径:424 宽的正文列放不下一条 `C:\Program Files\…\bin\java.exe`,
|
||||
/// 而用户认的是 JDK 目录(也是他手动添加时会粘的那一段)。
|
||||
#[must_use]
|
||||
pub fn home(&self) -> String {
|
||||
let path = std::path::Path::new(self.binary.trim());
|
||||
let parent = path.parent();
|
||||
match parent.filter(|dir| {
|
||||
dir.file_name()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("bin"))
|
||||
}) {
|
||||
Some(bin) => bin
|
||||
.parent()
|
||||
.map_or_else(|| self.binary.clone(), |home| home.display().to_string()),
|
||||
None => parent.map_or_else(|| self.binary.clone(), |dir| dir.display().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 上屏用的目录:过长时保留**尾部**两段,前面用 `…` 收掉。
|
||||
///
|
||||
/// 为什么必须截:无空格的 Windows 路径在 iced 的文本排版里不可断行,一条 60 字符的路径会直接
|
||||
/// 盖住右边的「设为默认」chip(M175 出图时实测到了这个重叠)。保尾部而不是开头,因为磁盘名
|
||||
/// (`C:`)谁都知道,而 `…\Java\temurin\17.0.13` 才是能区分两条 JDK 的那一段。
|
||||
#[must_use]
|
||||
pub fn display_home(&self) -> String {
|
||||
const LIMIT: usize = 39;
|
||||
let home = self.home();
|
||||
if home.chars().count() <= LIMIT {
|
||||
return home;
|
||||
}
|
||||
let tail: String = home
|
||||
.chars()
|
||||
.rev()
|
||||
.take(LIMIT - 1)
|
||||
.collect::<Vec<char>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
// 从第一个分隔符之后开始(别让 `…` 后面接着半截目录名)。
|
||||
let tail = tail
|
||||
.find(['\\', '/'])
|
||||
.map_or(tail.clone(), |at| tail[at + 1..].to_string());
|
||||
format!("…{}{}", std::path::MAIN_SEPARATOR, tail)
|
||||
}
|
||||
|
||||
/// 来源文案 key。
|
||||
#[must_use]
|
||||
pub fn source_key(&self) -> Key {
|
||||
match self.source {
|
||||
"managed" => Key::SettingsJavaSourceManaged,
|
||||
"env" => Key::SettingsJavaSourceEnv,
|
||||
"path" => Key::SettingsJavaSourcePath,
|
||||
"manual" => Key::SettingsJavaSourceManual,
|
||||
_ => Key::SettingsJavaSourceSystem,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 引号里的版本号(`openjdk version "21.0.11" 2025-10-21 LTS` → `21.0.11`)。
|
||||
fn quoted_version(line: &str) -> Option<String> {
|
||||
let start = line.find('"')? + 1;
|
||||
let rest = &line[start..];
|
||||
let end = rest.find('"')?;
|
||||
let value = rest[..end].trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
/// 无引号形态里的版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`)。
|
||||
fn bare_version(line: &str) -> Option<String> {
|
||||
line.split_whitespace().find_map(|token| {
|
||||
let candidate = token.trim_matches([',', '(', ')', '"']);
|
||||
let mut parts = candidate.split('.');
|
||||
let first = parts.next()?;
|
||||
let looks_like_version = first.chars().all(|c| c.is_ascii_digit())
|
||||
&& !first.is_empty()
|
||||
&& parts.next().is_some();
|
||||
looks_like_version.then(|| candidate.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RuntimeRow;
|
||||
|
||||
fn row(version: &str, major: Option<u32>) -> RuntimeRow {
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk/bin/java.exe".to_string(),
|
||||
version: version.to_string(),
|
||||
major,
|
||||
vendor: Some("Microsoft".to_string()),
|
||||
source: "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本号必须能区分同一大版本的两份 JDK(`17.0.13` vs `17.0.9`),不许只给 `17`。
|
||||
///
|
||||
/// 变异证明:把 `version_label` 换回「只用 `major`」,这条立刻红。
|
||||
#[test]
|
||||
fn the_version_label_keeps_the_minor_digits() {
|
||||
assert_eq!(
|
||||
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).version_label(),
|
||||
"17.0.13"
|
||||
);
|
||||
assert_eq!(
|
||||
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).version_label(),
|
||||
"17.0.9"
|
||||
);
|
||||
assert_ne!(
|
||||
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).summary(),
|
||||
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).summary(),
|
||||
"两份同大版本 JDK 的摘要必须不同"
|
||||
);
|
||||
assert_eq!(
|
||||
row("openjdk 25.0.3 2026-04-21", Some(25)).version_label(),
|
||||
"25.0.3",
|
||||
"无引号形态也要认"
|
||||
);
|
||||
assert_eq!(
|
||||
row("something unexpected 21", Some(21)).version_label(),
|
||||
"something unexpected 21",
|
||||
"解析不出来时给原始版本串,不给光秃秃的大版本"
|
||||
);
|
||||
assert_eq!(row("", Some(21)).version_label(), "21");
|
||||
assert_eq!(row("", None).version_label(), "?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_carries_version_and_vendor() {
|
||||
assert_eq!(
|
||||
row("openjdk version \"21.0.11\" 2025-10-21 LTS", Some(21)).summary(),
|
||||
"Java 21.0.11 · Microsoft"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! 「Java」分组的截图种子(M175,`ULL_JAVA_STATE`):**只给 `--screenshot` 用**。
|
||||
//!
|
||||
//! 三档对应任务书要的三张图:未安装 Java 的空态 / 检测到两个 JDK 并选中其一 / 内存与 JVM 参数
|
||||
//! 的编辑态。编辑态只让**内存**越界(`validate()` 一次只报第一个错,所以图上看到的是「内存超出
|
||||
//! 允许范围」+ 内存框标红;引号那条另有单测,不靠这张图证明)。
|
||||
//!
|
||||
//! 与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` / `ULL_FONT_STATUS` 同一族取证开关:只影响这一次进程,
|
||||
//! 产品路径不设它。种子里的路径是**写死的示例路径**(真实机器上没有),所以它只在出图通道里出现;
|
||||
//! 产品路径的真值一律来自 `core::game::java::staged` 的真扫盘。
|
||||
|
||||
use super::state::{Context, Layer, Notice, Refusal, Requirement, RuntimeRow, State};
|
||||
|
||||
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用)。
|
||||
///
|
||||
/// 三档对应三张图:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与
|
||||
/// JVM 参数、还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
|
||||
#[must_use]
|
||||
pub fn build(raw: &str) -> Option<State> {
|
||||
let mut state = State {
|
||||
storage: true,
|
||||
scanned: true,
|
||||
..State::default()
|
||||
};
|
||||
match raw.trim() {
|
||||
"empty" => {
|
||||
state.memory = "2048".to_string();
|
||||
state.saved_memory = 2048;
|
||||
}
|
||||
"two" => {
|
||||
state.runtimes = vec![
|
||||
RuntimeRow {
|
||||
binary: "C:/Program Files/Eclipse Adoptium/jdk-17.0.13/bin/java.exe"
|
||||
.to_string(),
|
||||
version: "openjdk version \"17.0.13\" 2024-10-15".to_string(),
|
||||
major: Some(17),
|
||||
vendor: Some("Temurin".to_string()),
|
||||
source: "system",
|
||||
},
|
||||
RuntimeRow {
|
||||
binary: "C:/Program Files/Microsoft/jdk-21.0.4/bin/java.exe".to_string(),
|
||||
version: "openjdk version \"21.0.4\" 2024-07-16 LTS".to_string(),
|
||||
major: Some(21),
|
||||
vendor: Some("Microsoft".to_string()),
|
||||
source: "system",
|
||||
},
|
||||
];
|
||||
state.selected = state.runtimes[1].binary.clone();
|
||||
state.manual = vec!["D:/jdk/zulu-21".to_string()];
|
||||
state.memory = "4096".to_string();
|
||||
state.saved_memory = 4096;
|
||||
state.jvm = "-XX:+UseG1GC".to_string();
|
||||
state.saved_jvm = "-XX:+UseG1GC".to_string();
|
||||
state.context = Context {
|
||||
required: Some(Requirement {
|
||||
instance: "生存服".to_string(),
|
||||
version: "1.20.1".to_string(),
|
||||
minimum: 17,
|
||||
maximum: None,
|
||||
}),
|
||||
layer: Some(Layer::Global),
|
||||
effective_memory_mb: Some(4096),
|
||||
};
|
||||
}
|
||||
"editing" => {
|
||||
let mut seeded = build("two")?;
|
||||
seeded.memory = "128".to_string();
|
||||
seeded.jvm = "-XX:+UseG1GC -Xss\"512k".to_string();
|
||||
seeded.dirty = true;
|
||||
seeded.notice = Some(Notice::Refused(Refusal::MemoryRange));
|
||||
return Some(seeded);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(state)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! 「Java」分组的运行期状态(M175):扫描结论 + 手动名单 + 两个可编辑项。
|
||||
//!
|
||||
//! 状态住在 `pages::settings::Groups.java`([`State`]),由 `App` 持有。三条边界:
|
||||
//!
|
||||
//! | 东西 | 住哪 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 扫描结论 / 进度 | 本结构(由后台线程的快照 1 拍一拍灌进来) | 用户切走再回来不该重扫 |
|
||||
//! | 手动加的路径名单 | 本结构 + 一个纯文本文件(`store::manual_file`) | 它是「我加过哪些」的记忆,不是启动策略 |
|
||||
//! | `java.selected_path` / `default_memory_mb` / `extra_args` | **`config/launcher.json`** | 真值只有一份,读回也读它 |
|
||||
//!
|
||||
//! ## 为什么内存草稿要记两层(`memory` 文本 + `saved_memory` 数值)
|
||||
//!
|
||||
//! 输入框里的是**文本**(用户可能刚敲了半截「819」),而「现在生效多少」是**落盘的真值**。
|
||||
//! 两者混成一个字段就会重演「显示的就是生效的」这种假象:改到一半的数字不是生效值。
|
||||
//! 于是草稿只影响「有未保存改动」与「保存按钮能不能点」,生效值一律来自 `saved_*`(落盘后读回的)。
|
||||
//!
|
||||
//! ## 版本门槛从哪来
|
||||
//!
|
||||
//! [`Context::required`] 由 **app 层**(`app/update/settings/java.rs`)算好塞进来:它要读实例快照
|
||||
//! (`requirement_for(mc_version)`),而页面渲染器只拿得到 [`super::States`]。
|
||||
//! 页面自己不去读盘、也不复制版本表。
|
||||
|
||||
pub use super::row::RuntimeRow;
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
|
||||
/// `java.default_memory_mb` 的下限(MB)。
|
||||
///
|
||||
/// **必须**等于 `core::setting::keys` 里那个键登记的下限(512):UI 放行一个 schema 会拒的值,
|
||||
/// 用户点保存只会得到一条「schema 拒绝」的错。一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
|
||||
pub const MIN_MEMORY_MB: u32 = 512;
|
||||
/// `java.default_memory_mb` 的上限(MB)。
|
||||
pub const MAX_MEMORY_MB: u32 = 65536;
|
||||
/// `java.default_memory_mb` 的出厂默认值(MB):与 schema 的默认值同源,
|
||||
/// 一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
|
||||
pub const DEFAULT_MEMORY_MB: u32 = 2048;
|
||||
|
||||
/// 扫描进度(`None` = 没在扫)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
/// 已探测的候选数
|
||||
pub done: usize,
|
||||
/// 候选总数
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// 当前实例对 Java 的版本要求(app 层算好塞进来)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Requirement {
|
||||
/// 实例名(「为什么选它」那句话里要指名道姓)
|
||||
pub instance: String,
|
||||
/// 实例绑的版本 id(`1.20.1`)
|
||||
pub version: String,
|
||||
/// 最低主版本
|
||||
pub minimum: u32,
|
||||
/// 最高主版本(老版本才有;`None` = 不封顶)
|
||||
pub maximum: Option<u32>,
|
||||
}
|
||||
|
||||
impl Requirement {
|
||||
/// 某个大版本满不满足。
|
||||
#[must_use]
|
||||
pub fn satisfied_by(&self, major: u32) -> bool {
|
||||
major >= self.minimum && self.maximum.is_none_or(|max| major <= max)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生效内存来自哪一层。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Layer {
|
||||
/// 全局 `java.default_memory_mb`
|
||||
Global,
|
||||
/// 某个实例的 `launch.memoryMb` 覆盖了全局
|
||||
Instance {
|
||||
/// 实例名
|
||||
instance: String,
|
||||
/// 实例层的值
|
||||
mb: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// 上下文(app 层每次 hydrate 重算)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
/// 当前实例的版本要求(没有实例 ⇒ `None`)
|
||||
pub required: Option<Requirement>,
|
||||
/// 当前生效内存来自哪一层
|
||||
pub layer: Option<Layer>,
|
||||
/// 当前生效内存(三级回落之后的数值)
|
||||
pub effective_memory_mb: Option<u32>,
|
||||
}
|
||||
|
||||
/// 保存 / 操作的回执。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Notice {
|
||||
/// 已落盘并读回
|
||||
Saved,
|
||||
/// 本地校验拒绝(没写盘)
|
||||
Refused(Refusal),
|
||||
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key,不拼字符串)
|
||||
Unwritable(Unwritable),
|
||||
/// 写盘失败(schema / 磁盘):core 的 `Display` 是中文,不能直出给英文界面,
|
||||
/// 所以这里只带一个页面自己的 key;原始句子进 stderr 日志。
|
||||
WriteFailed,
|
||||
/// 没有可写文件(内存态运行)
|
||||
NoStorage,
|
||||
/// 默认 JDK 已写进 `java.selected_path`
|
||||
DefaultSet(String),
|
||||
/// 手动路径已加入名单
|
||||
Added(String),
|
||||
}
|
||||
|
||||
/// 设置文件层面拒绝的三档(`SettingStore::save` 的 `Skipped` 三因)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Unwritable {
|
||||
/// 没有任何脏键(我们以为改了、其实没有)
|
||||
NotDirty,
|
||||
/// 文件只读(被更新版本的启动器写过 / 被标记不可信)
|
||||
ReadOnly,
|
||||
/// 文件不可信(加载时就降级成只读)
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
impl Unwritable {
|
||||
/// 文案 key。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> Key {
|
||||
match self {
|
||||
Self::NotDirty => Key::SettingsJavaSkipNotDirty,
|
||||
Self::ReadOnly => Key::SettingsJavaSkipReadOnly,
|
||||
Self::Unreadable => Key::SettingsJavaSkipUnreadable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地校验的拒绝原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// 内存草稿为空
|
||||
MemoryEmpty,
|
||||
/// 内存草稿不是整数
|
||||
MemoryNotNumber,
|
||||
/// 内存越界
|
||||
MemoryRange,
|
||||
/// JVM 参数里的引号没配成对
|
||||
JvmQuotes,
|
||||
/// 手动路径为空
|
||||
AddEmpty,
|
||||
/// 手动路径在盘上找不到
|
||||
AddMissing,
|
||||
/// 有这个东西,但不是 java 可执行文件
|
||||
AddNotJava,
|
||||
/// 跑不出 `-version`
|
||||
AddNoVersion,
|
||||
/// 路径太长(粘了一整段日志)
|
||||
AddTooLong,
|
||||
/// 名单文件写不进去(磁盘 / 权限)
|
||||
AddWriteFailed,
|
||||
/// 已经加过了
|
||||
AddDuplicate,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// 文案 key。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> Key {
|
||||
match self {
|
||||
Self::MemoryEmpty => Key::SettingsJavaReasonMemoryEmpty,
|
||||
Self::MemoryNotNumber => Key::SettingsJavaReasonMemoryNotNumber,
|
||||
Self::MemoryRange => Key::SettingsJavaReasonMemoryRange,
|
||||
Self::JvmQuotes => Key::SettingsJavaReasonJvmQuotes,
|
||||
Self::AddEmpty => Key::SettingsJavaReasonAddEmpty,
|
||||
Self::AddMissing => Key::SettingsJavaReasonAddMissing,
|
||||
Self::AddNotJava => Key::SettingsJavaReasonAddNotJava,
|
||||
Self::AddNoVersion => Key::SettingsJavaReasonAddNoVersion,
|
||||
Self::AddTooLong => Key::SettingsJavaReasonAddTooLong,
|
||||
Self::AddWriteFailed => Key::SettingsJavaReasonAddWriteFailed,
|
||||
Self::AddDuplicate => Key::SettingsJavaReasonAddDuplicate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「Java」分组的运行期状态。
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
/// 扫到的运行时(顺序 = 大版本升序,探测不到主版本的排最后)
|
||||
pub runtimes: Vec<RuntimeRow>,
|
||||
/// 至少完整扫过一次(区分「未检测到」与「还没扫」)
|
||||
pub scanned: bool,
|
||||
/// 扫描进度
|
||||
pub scanning: Option<Progress>,
|
||||
/// 上一次扫描被用户取消(界面上要说一句,否则「列表没变」会被当成没扫)
|
||||
pub cancelled: bool,
|
||||
/// 扫描状态槽(快照 + 取消位;线程与界面共享同一份)
|
||||
pub scan: super::store::ScanSlot,
|
||||
/// 用户手动加的路径(原样,持久化在 `manual_file`)
|
||||
pub manual: Vec<String>,
|
||||
/// `java.selected_path` 的真值(空 = 没指定,启动时自动挑)
|
||||
pub selected: String,
|
||||
/// 内存草稿(MB 文本)
|
||||
pub memory: String,
|
||||
/// 落盘的内存真值(MB)
|
||||
pub saved_memory: u32,
|
||||
/// JVM 参数草稿
|
||||
pub jvm: String,
|
||||
/// 落盘的 JVM 参数真值
|
||||
pub saved_jvm: String,
|
||||
/// 手动路径输入框
|
||||
pub add_draft: String,
|
||||
/// 有未保存的改动
|
||||
pub dirty: bool,
|
||||
/// 这一次运行有没有可写的设置文件
|
||||
pub storage: bool,
|
||||
/// 上一次操作的回执
|
||||
pub notice: Option<Notice>,
|
||||
/// 当前实例的上下文(版本门槛 / 生效内存层级)
|
||||
pub context: Context,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
runtimes: Vec::new(),
|
||||
scanned: false,
|
||||
scanning: None,
|
||||
cancelled: false,
|
||||
scan: super::store::ScanSlot::default(),
|
||||
manual: Vec::new(),
|
||||
selected: String::new(),
|
||||
memory: String::new(),
|
||||
saved_memory: DEFAULT_MEMORY_MB,
|
||||
jvm: String::new(),
|
||||
saved_jvm: String::new(),
|
||||
add_draft: String::new(),
|
||||
dirty: false,
|
||||
storage: false,
|
||||
notice: None,
|
||||
context: Context::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `launcher.json` 读回来的三键真值。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Snapshot {
|
||||
/// `java.selected_path`
|
||||
pub selected: String,
|
||||
/// `java.default_memory_mb`
|
||||
pub memory_mb: u32,
|
||||
/// `java.extra_args`
|
||||
pub jvm: String,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// 用真值刷新(**脏草稿不覆盖**,否则外部一改文件就冲掉用户正在输入的内容)。
|
||||
///
|
||||
/// 返回是否改了「生效值」那一半(调用方据此决定要不要重建视图模型)。
|
||||
pub fn hydrate(&mut self, snapshot: Snapshot) -> bool {
|
||||
self.storage = true;
|
||||
let Snapshot {
|
||||
selected,
|
||||
memory_mb,
|
||||
jvm,
|
||||
} = snapshot;
|
||||
let changed =
|
||||
self.selected != selected || self.saved_memory != memory_mb || self.saved_jvm != jvm;
|
||||
self.selected = selected;
|
||||
self.saved_memory = memory_mb;
|
||||
if !self.dirty {
|
||||
self.memory = memory_mb.to_string();
|
||||
self.jvm = jvm.clone();
|
||||
}
|
||||
self.saved_jvm = jvm;
|
||||
changed
|
||||
}
|
||||
|
||||
/// 灌一次扫描结论(后台线程的快照)。返回是否变了。
|
||||
pub fn apply_scan(&mut self, report: &super::store::ScanReport) -> bool {
|
||||
let scanning = report.running.then_some(Progress {
|
||||
done: report.done,
|
||||
total: report.total,
|
||||
});
|
||||
let changed = self.scanning != scanning
|
||||
|| self.cancelled != report.cancelled
|
||||
|| (report.finished && !self.scanned)
|
||||
|| (report.finished && self.runtimes != report.runtimes);
|
||||
self.scanning = scanning;
|
||||
self.cancelled = report.cancelled;
|
||||
if report.finished {
|
||||
self.scanned = true;
|
||||
self.runtimes = report.runtimes.clone();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 某个大版本够不够当前实例用。
|
||||
#[must_use]
|
||||
pub fn satisfied(&self, major: Option<u32>) -> Option<bool> {
|
||||
let (Some(required), Some(major)) = (self.context.required.as_ref(), major) else {
|
||||
return None;
|
||||
};
|
||||
Some(required.satisfied_by(major))
|
||||
}
|
||||
|
||||
/// 保存前的本地校验(过不了就不写盘)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 内存不是整数 / 越界,或 JVM 参数的引号没配成对时返回原因。
|
||||
pub fn validate(&self) -> Result<u32, Refusal> {
|
||||
let text = self.memory.trim();
|
||||
if text.is_empty() {
|
||||
return Err(Refusal::MemoryEmpty);
|
||||
}
|
||||
let Ok(mb) = text.parse::<u32>() else {
|
||||
return Err(Refusal::MemoryNotNumber);
|
||||
};
|
||||
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&mb) {
|
||||
return Err(Refusal::MemoryRange);
|
||||
}
|
||||
if unbalanced_quotes(&self.jvm) {
|
||||
return Err(Refusal::JvmQuotes);
|
||||
}
|
||||
Ok(mb)
|
||||
}
|
||||
|
||||
/// 界面上「现在生效多少」的那句话用哪一层(没有上下文时按全局)。
|
||||
#[must_use]
|
||||
pub fn layer(&self) -> Layer {
|
||||
self.context.layer.clone().unwrap_or(Layer::Global)
|
||||
}
|
||||
|
||||
/// 现在能不能点保存。
|
||||
#[must_use]
|
||||
pub fn can_save(&self) -> bool {
|
||||
self.storage && self.dirty
|
||||
}
|
||||
|
||||
/// 当前默认运行时那一行(`selected` 指向的)。
|
||||
#[must_use]
|
||||
pub fn default_row(&self) -> Option<&RuntimeRow> {
|
||||
self.runtimes
|
||||
.iter()
|
||||
.find(|row| same_path(&row.binary, &self.selected))
|
||||
}
|
||||
|
||||
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用;三档的实现在 `java/seed.rs`)。
|
||||
///
|
||||
/// 三档:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与 JVM 参数、
|
||||
/// 还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
|
||||
#[must_use]
|
||||
pub fn screenshot_seed(raw: &str) -> Option<Self> {
|
||||
super::seed::build(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// 两条路径是不是同一个(`/` 与 `\` 视为同一分隔符;Windows 折大小写)。
|
||||
#[must_use]
|
||||
pub fn same_path(left: &str, right: &str) -> bool {
|
||||
let key = |text: &str| {
|
||||
let text = text.replace('\\', "/");
|
||||
if cfg!(windows) {
|
||||
text.to_ascii_lowercase()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
};
|
||||
!left.trim().is_empty() && key(left.trim()) == key(right.trim())
|
||||
}
|
||||
|
||||
/// 引号(`"` 与 `'`)数量是不是奇数——奇数就意味着用户漏了一半。
|
||||
#[must_use]
|
||||
pub fn unbalanced_quotes(text: &str) -> bool {
|
||||
text.matches('"').count() % 2 == 1 || text.matches('\'').count() % 2 == 1
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! 「Java」分组的真读写(M175):设置三键 + 手动名单 + 后台扫描。
|
||||
//!
|
||||
//! ## 写盘走 `SettingStore`,与 `ai/store.rs` 同一条通路
|
||||
//!
|
||||
//! `java.selected_path` / `java.default_memory_mb` / `java.extra_args` 都是
|
||||
//! `config/launcher.json` 里的 schema 键(`core::setting::keys` 已登记,本 mission 不动 schema)。
|
||||
//! 于是这里只做「读一份 → 改几个键 → 存 → **再从磁盘读回来**」:`save()` 有 `Skipped` 一档
|
||||
//! (只读 / 文件被更新版本写过 / 没有脏键),把它当成功就会出现「界面说已保存、重启回旧值」。
|
||||
//! 失败一律走 [`WriteError`]:**要么是页面自己的拒绝原因(i18n key),要么是 core 给的原始句子**,
|
||||
//! 不再把 Rust 枚举名(`"MemoryRange"`)当人话往上抛(M175 r1 复审 P2-2)。
|
||||
//!
|
||||
//! ## 扫描为什么是「进程级快照 + 后台线程」,不是 `Task::perform`
|
||||
//!
|
||||
//! 与 `fonts::install` 同一条理由(那里记了完整推导):本 crate 的执行器是 `thread-pool`,
|
||||
//! 而扫描要的是**进度(3/7)+ 取消 + 不卡 UI**。快照方案的代价是一把短锁 + 1 拍一次比较,
|
||||
//! 好处是窗口关掉之后不会往死掉的 channel 里写。界面的刷新节奏见 `app/update/clock.rs`
|
||||
//! 与 `app/subscription.rs`(扫描期间按帧跑,扫描结束回到 1 Hz)。
|
||||
//!
|
||||
//! ### 三条与「取消」有关的硬约束(M175 r1 复审 P2-5)
|
||||
//!
|
||||
//! 1. **每个候选都有超时**:卡住的 `java -version` 由 `core::game::java::manual` 的
|
||||
//! `run_with_timeout` kill 掉,扫描线程不会永远不结束;
|
||||
//! 2. **取消打断的是「正在跑的那一个」**:取消位一路传进探测函数,不必等它自己结束;
|
||||
//! 3. **同时只允许一个扫描线程**:[`ScanReport::running`] 的含义就是「线程还活着」,
|
||||
//! 取消**不**翻这个位(只置 `cancelled`),于是取消到线程真退出之间点「立即扫描」
|
||||
//! 不会起第二个线程——界面上那一小段显示的是「正在取消…」。
|
||||
//!
|
||||
//! ## 手动名单为什么是纯文本文件
|
||||
//!
|
||||
//! 它记的是「我手动加过哪些路径」,不是启动策略(真正决定用哪个 Java 的是 `java.selected_path`)。
|
||||
//! 放进 `launcher.json` 要新登记一个 schema 键并推 minor(会让旧版本整份文件变只读),
|
||||
//! 代价远大于收益;而 `core::game::java::manual` 的读写是纯文本、一行一个,坏了顶多丢名单。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use unlucky_core::game::java::{discover, manual, staged, JavaRuntime};
|
||||
use unlucky_core::platform::paths::{Platform, ProcessEnv};
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
|
||||
|
||||
use super::state::{Refusal, RuntimeRow, Snapshot, State, Unwritable};
|
||||
|
||||
/// 扫描结果快照(后台线程写、界面读)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ScanReport {
|
||||
/// 扫描线程**还活着**(取消之后到它真退出之前仍是 `true`——这一段界面上叫「正在取消…」)
|
||||
pub running: bool,
|
||||
/// 已探测
|
||||
pub done: usize,
|
||||
/// 候选总数(`0` = 候选表还在收集,界面不显示 `0/0`)
|
||||
pub total: usize,
|
||||
/// 至少完整扫完过一次
|
||||
pub finished: bool,
|
||||
/// 用户按过取消(线程还活着时是 `true`)
|
||||
pub cancelled: bool,
|
||||
/// 扫描结论(`finished` 且没被取消时有效)
|
||||
pub runtimes: Vec<RuntimeRow>,
|
||||
}
|
||||
|
||||
/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。
|
||||
pub type Job =
|
||||
fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec<RuntimeRow>;
|
||||
|
||||
/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。
|
||||
///
|
||||
/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处——
|
||||
/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例
|
||||
/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。
|
||||
///
|
||||
/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ScanSlot {
|
||||
inner: std::sync::Arc<ScanInner>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ScanInner {
|
||||
report: Mutex<ScanReport>,
|
||||
cancel: AtomicBool,
|
||||
}
|
||||
|
||||
impl ScanSlot {
|
||||
/// 当前快照(界面每拍读一次;锁只在这几行里持有)。
|
||||
#[must_use]
|
||||
pub fn report(&self) -> ScanReport {
|
||||
self.inner
|
||||
.report
|
||||
.lock()
|
||||
.map(|report| report.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 取消正在跑的扫描:置取消位 + 标 `cancelled`,**不**动 `running`。
|
||||
///
|
||||
/// `running` 由扫描线程自己翻——它是「线程还活着」的唯一真相。取消若也翻它,`start()` 的
|
||||
/// 「已在扫」判据就失效,用户能在旧线程还卡着的时候起第二个(复审 P2-5 的第三个问题)。
|
||||
pub fn cancel(&self) {
|
||||
self.inner.cancel.store(true, Ordering::SeqCst);
|
||||
if let Ok(mut report) = self.inner.report.lock() {
|
||||
if report.running {
|
||||
report.cancelled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 发起一次扫描(已在扫 ⇒ 什么都不做,返回 `false`)。
|
||||
pub fn start(&self, roots: Vec<PathBuf>, manual: Vec<PathBuf>, system: bool) -> bool {
|
||||
self.start_with(roots, manual, system, run_staged)
|
||||
}
|
||||
|
||||
/// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。
|
||||
pub fn start_with(
|
||||
&self,
|
||||
roots: Vec<PathBuf>,
|
||||
manual: Vec<PathBuf>,
|
||||
system: bool,
|
||||
job: Job,
|
||||
) -> bool {
|
||||
{
|
||||
let Ok(mut report) = self.inner.report.lock() else {
|
||||
return false;
|
||||
};
|
||||
if report.running {
|
||||
return false;
|
||||
}
|
||||
// 「占位」与「线程起来了」在同一把锁里完成:两个调用不可能同时通过上面那道判据。
|
||||
*report = ScanReport {
|
||||
running: true,
|
||||
..ScanReport::default()
|
||||
};
|
||||
self.inner.cancel.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
let slot = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
// 候选收集(注册表 / 常见目录)放在线程里:它是首屏那一拍最贵的一段,
|
||||
// 放在 UI 线程上会拖慢开窗(复审 P2-5 的附带项)。
|
||||
let candidates = staged::candidates(&roots, &manual, system);
|
||||
if let Ok(mut report) = slot.inner.report.lock() {
|
||||
report.total = candidates.len();
|
||||
}
|
||||
let cancelled = || slot.inner.cancel.load(Ordering::SeqCst);
|
||||
let mut progress = |done: usize, _total: usize| {
|
||||
if let Ok(mut report) = slot.inner.report.lock() {
|
||||
report.done = done;
|
||||
}
|
||||
};
|
||||
let runtimes = job(&candidates, &cancelled, &mut progress);
|
||||
if let Ok(mut report) = slot.inner.report.lock() {
|
||||
report.running = false;
|
||||
report.done = candidates.len();
|
||||
if !report.cancelled {
|
||||
report.finished = true;
|
||||
report.runtimes = runtimes;
|
||||
}
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// 生产扫描作业:真扫盘 + 逐个探测(超时与取消都在 `staged::scan` 里)。
|
||||
fn run_staged(
|
||||
candidates: &[staged::Candidate],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Vec<RuntimeRow> {
|
||||
let found = staged::scan(
|
||||
candidates,
|
||||
|done, total| {
|
||||
progress(done, total);
|
||||
true
|
||||
},
|
||||
cancelled,
|
||||
);
|
||||
rows(&found, candidates)
|
||||
}
|
||||
|
||||
/// `JavaRuntime` → 页面行(来源从候选表里按路径回查:`JavaRuntime.origin` 分不出「手动」)。
|
||||
fn rows(found: &[JavaRuntime], candidates: &[staged::Candidate]) -> Vec<RuntimeRow> {
|
||||
found
|
||||
.iter()
|
||||
.map(|runtime| RuntimeRow {
|
||||
binary: runtime.binary.display().to_string(),
|
||||
version: runtime.version.clone().unwrap_or_default(),
|
||||
major: runtime.major,
|
||||
vendor: runtime.vendor.clone(),
|
||||
source: candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.binary == runtime.binary)
|
||||
.map_or("system", |candidate| candidate.source.id()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 该不该**自动**发起一次扫描(首屏 / 配置文件刚被外部改过时)。
|
||||
///
|
||||
/// 四条都要成立:①不是截图通道(D30:同一份代码出同一张图,截图不许读本机装了什么);
|
||||
/// ②没有在扫;③**没被取消过**(一次取消 = 本进程不再自动扫:被取消的扫描 `finished` 仍是
|
||||
/// `false`,而 `launcher.json` 每改一次(存内存、改主题、改语言、存 AI 配置)心跳就会走一次
|
||||
/// `hydrate_java_settings`,不看这一条就会把用户的取消无声撤销);④还没扫完过(一次就够——
|
||||
/// 用户手动点「立即扫描」走的是 `start`,不看这里)。
|
||||
///
|
||||
/// 单独抽出来是因为它是「用户实测的头一条抱怨」(设置页从来自动检测本机 Java)的开关:
|
||||
/// 判据写在这里,`tests/cmdline.rs` 逐档断言,改坏立刻红。
|
||||
#[must_use]
|
||||
pub fn auto_scan_due(report: &ScanReport, screenshot: bool) -> bool {
|
||||
!screenshot && !report.running && !report.cancelled && !report.finished
|
||||
}
|
||||
|
||||
/// 托管安装根(`<launcher home>/data/java-managed` + `java.installRoot` + 标准落点)。
|
||||
///
|
||||
/// 从**我们正在写的那份 `launcher.json`** 的上一级推 launcher home:这比重新解析一次数据根模式
|
||||
/// 更可信(三模式解析已经在 `SettingsBridge::open` 里做过一遍了)。
|
||||
#[must_use]
|
||||
pub fn managed_roots(config: &Path) -> Vec<PathBuf> {
|
||||
let Some(home) = config.parent().and_then(Path::parent) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let layout = unlucky_core::platform::layout::LauncherLayout::new(home);
|
||||
let override_root = SettingStore::load(config, launcher_schema())
|
||||
.doc()
|
||||
.effective_str("java.installRoot")
|
||||
.map(str::trim)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(PathBuf::from);
|
||||
unlucky_core::game::java::location::scan_roots(
|
||||
unlucky_core::platform::data_dir::DataMode::Portable,
|
||||
&layout,
|
||||
override_root.as_deref(),
|
||||
Platform::current(),
|
||||
&ProcessEnv,
|
||||
)
|
||||
}
|
||||
|
||||
/// 手动名单文件(与 `launcher.json` 同一个 `config/`)。
|
||||
#[must_use]
|
||||
pub fn manual_file(config: &Path) -> PathBuf {
|
||||
config.parent().map_or_else(
|
||||
|| PathBuf::from("java-extra-paths.txt"),
|
||||
|dir| dir.join("java-extra-paths.txt"),
|
||||
)
|
||||
}
|
||||
|
||||
/// 读三键真值。
|
||||
#[must_use]
|
||||
pub fn load(config: &Path) -> Snapshot {
|
||||
snapshot(SettingStore::load(config, launcher_schema()).doc())
|
||||
}
|
||||
|
||||
/// 写盘失败的三种来源(都是「没写进去」,但对用户要说的不是同一句话)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WriteError {
|
||||
/// 页面自己的校验拒绝(i18n key)
|
||||
Refused(Refusal),
|
||||
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key)
|
||||
Unwritable(Unwritable),
|
||||
/// schema / 磁盘给的原始句子(core 的文案,不属于本页)
|
||||
Schema(String),
|
||||
}
|
||||
|
||||
/// 保存内存 + JVM 参数,返回**落盘后读回来**的真值。
|
||||
///
|
||||
/// # Errors
|
||||
/// 本地校验不过([`WriteError::Refused`])、文件只读/不可信([`WriteError::Unwritable`])
|
||||
/// 或 schema 拒绝([`WriteError::Schema`])时返回原因。
|
||||
pub fn save(config: &Path, state: &State) -> Result<Snapshot, WriteError> {
|
||||
let mb = state.validate().map_err(WriteError::Refused)?;
|
||||
let mut store = SettingStore::load(config, launcher_schema());
|
||||
store
|
||||
.set("java.default_memory_mb", i64::from(mb).into())
|
||||
.and_then(|()| store.set("java.extra_args", state.jvm.trim().to_string().into()))
|
||||
.map_err(|error| WriteError::Schema(format!("{error}")))?;
|
||||
finish(store, config)
|
||||
}
|
||||
|
||||
/// 写 `java.selected_path`(选定默认 JDK),返回落盘后读回来的真值。
|
||||
///
|
||||
/// # Errors
|
||||
/// 文件只读/不可信或 schema 拒绝时返回原因。
|
||||
pub fn set_selected(config: &Path, binary: &str) -> Result<Snapshot, WriteError> {
|
||||
let mut store = SettingStore::load(config, launcher_schema());
|
||||
store
|
||||
.set("java.selected_path", binary.to_string().into())
|
||||
.map_err(|error| WriteError::Schema(format!("{error}")))?;
|
||||
finish(store, config)
|
||||
}
|
||||
|
||||
/// 落盘 + 判定 + 读回(`save` / `set_selected` 共用:只认 `Saved` 一档)。
|
||||
fn finish(mut store: SettingStore, config: &Path) -> Result<Snapshot, WriteError> {
|
||||
match store.save() {
|
||||
Ok(SaveOutcome::Saved(_)) => Ok(load(config)),
|
||||
Ok(SaveOutcome::Skipped(reason)) => Err(WriteError::Unwritable(unwritable(reason))),
|
||||
Err(error) => Err(WriteError::Schema(format!("{error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `SkipReason` → 页面原因(**不**再用中文字符串:那是 r1 复审 P2-2 的第一条)。
|
||||
fn unwritable(reason: unlucky_core::setting::store::SkipReason) -> Unwritable {
|
||||
use unlucky_core::setting::store::SkipReason;
|
||||
match reason {
|
||||
SkipReason::NotDirty => Unwritable::NotDirty,
|
||||
SkipReason::ReadOnly => Unwritable::ReadOnly,
|
||||
SkipReason::Unreadable => Unwritable::Unreadable,
|
||||
}
|
||||
}
|
||||
|
||||
/// 文档 → 快照。
|
||||
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
|
||||
Snapshot {
|
||||
selected: doc
|
||||
.effective_str("java.selected_path")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string(),
|
||||
memory_mb: doc
|
||||
.effective_i64("java.default_memory_mb")
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.unwrap_or(super::state::DEFAULT_MEMORY_MB),
|
||||
jvm: doc
|
||||
.effective_str("java.extra_args")
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读手动名单。
|
||||
#[must_use]
|
||||
pub fn load_manual(config: &Path) -> Vec<String> {
|
||||
manual::load_paths(&manual_file(config))
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 手动加一个路径:严格校验(真跑 `java -version`)→ 进名单 → 落盘。
|
||||
///
|
||||
/// 拒绝时**什么都不写**(名单不变、设置不变)——这是「非法路径不污染设置」那条守卫的落点。
|
||||
///
|
||||
/// # Errors
|
||||
/// 校验不过(见 [`Refusal`])或名单写盘失败时返回原因。
|
||||
pub fn add_manual(config: &Path, input: &str) -> Result<(String, RuntimeRow), Refusal> {
|
||||
add_manual_with(config, input, &manual::probe_manual)
|
||||
}
|
||||
|
||||
/// 同 [`add_manual`],但探测函数可注入(测试用:不依赖本机装没装 JDK)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 探测被拒或名单写盘失败时返回原因。
|
||||
pub fn add_manual_with(
|
||||
config: &Path,
|
||||
input: &str,
|
||||
probe: &dyn Fn(&str) -> Result<JavaRuntime, manual::ManualFailure>,
|
||||
) -> Result<(String, RuntimeRow), Refusal> {
|
||||
let runtime = probe(input.trim()).map_err(failure_refusal)?;
|
||||
let binary = runtime.binary.display().to_string();
|
||||
let mut paths = manual::load_paths(&manual_file(config));
|
||||
let key = discover::path_key(&runtime.binary);
|
||||
if paths.iter().any(|known| discover::path_key(known) == key) {
|
||||
return Err(Refusal::AddDuplicate);
|
||||
}
|
||||
paths.push(runtime.binary.clone());
|
||||
manual::save_paths(&manual_file(config), &paths).map_err(|_| Refusal::AddWriteFailed)?;
|
||||
Ok((
|
||||
binary.clone(),
|
||||
RuntimeRow {
|
||||
binary,
|
||||
version: runtime.version.clone().unwrap_or_default(),
|
||||
major: runtime.major,
|
||||
vendor: runtime.vendor.clone(),
|
||||
source: "manual",
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// `core` 的拒绝原因 → 页面原因。
|
||||
fn failure_refusal(failure: manual::ManualFailure) -> Refusal {
|
||||
match failure {
|
||||
manual::ManualFailure::Empty => Refusal::AddEmpty,
|
||||
manual::ManualFailure::TooLong => Refusal::AddTooLong,
|
||||
manual::ManualFailure::Missing => Refusal::AddMissing,
|
||||
manual::ManualFailure::NotJava => Refusal::AddNotJava,
|
||||
manual::ManualFailure::NoVersion => Refusal::AddNoVersion,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置文件路径(数据根解析不出来时没有)。
|
||||
#[must_use]
|
||||
pub fn config_of(bridge: &unlucky_presenter::settings_bridge::SettingsBridge) -> Option<PathBuf> {
|
||||
bridge.path().map(Path::to_path_buf)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! 「Java」分组的真数据源断言(M175):扫描 / 手动路径 / 内存 / JVM 参数 / 真命令行。
|
||||
//!
|
||||
//! 每一条都**能变红**(报告的「变异证明」逐条给了改哪一行会红)。四条守卫:
|
||||
//!
|
||||
//! | 守卫 | 断言 | 改回什么会红 |
|
||||
//! |---|---|---|
|
||||
//! | 列表来自真扫盘 | [`the_runtime_list_comes_from_the_disk`] | 把行换成写死的字符串 / 不读候选表 |
|
||||
//! | 非法路径不污染设置 | [`an_invalid_manual_path_is_refused_and_writes_nothing`] | 删掉 `manual::probe_manual` 的校验 |
|
||||
//! | 内存越界被拒 | [`the_memory_bounds_are_enforced_before_writing`] | 删掉范围判断 / 允许非数字 |
|
||||
//! | 内存真进命令行 | [`the_saved_memory_reaches_the_real_command_line`] | 把 `-Xmx` 的来源改回常量 |
|
||||
//!
|
||||
//! 前三条不依赖本机装没装 JDK(假的 `bin/java` 空文件就够:它扫得到、但答不出版本),
|
||||
//! 第四条(`tests/cmdline.rs`)走 core 的**真组装链**(`launch::args_table::assemble`)——
|
||||
//! 不是读配置回显。落盘 / 读回 / 版本门槛 / 截图种子也在那个文件里(同一族「真值」断言)。
|
||||
|
||||
#[path = "tests/cmdline.rs"]
|
||||
mod cmdline;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::state::{Refusal, RuntimeRow, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
|
||||
use super::store;
|
||||
|
||||
/// 每个用例一个临时数据根(`<dir>/config/launcher.json` 的形状与真实数据根一致)。
|
||||
pub(super) fn root(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-java-page-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("config")).expect("config");
|
||||
dir
|
||||
}
|
||||
|
||||
pub(super) fn config(dir: &Path) -> PathBuf {
|
||||
dir.join("config").join("launcher.json")
|
||||
}
|
||||
|
||||
/// 造一个「看起来像 JDK」的目录:`bin/java` 是空文件(扫得到、但 `-version` 答不出)。
|
||||
pub(super) fn fake_jdk(dir: &Path, name: &str) -> PathBuf {
|
||||
let jdk = dir.join(name);
|
||||
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
|
||||
std::fs::write(jdk.join("bin").join(java_bin()), b"").expect("写 java");
|
||||
jdk
|
||||
}
|
||||
|
||||
pub(super) fn java_bin() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"java.exe"
|
||||
} else {
|
||||
"java"
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫①:列表来自真扫盘
|
||||
|
||||
/// 扫描列表必须来自**盘上真有的目录**:造两个 → 两项;删一个 → 一项。
|
||||
///
|
||||
/// 变异证明:把 `store::start` 里的候选表换成一个写死的两行常量,删目录之后仍是两项 ⇒ 红。
|
||||
#[test]
|
||||
fn the_runtime_list_comes_from_the_disk() {
|
||||
let dir = root("scan");
|
||||
let one = fake_jdk(&dir, "jdk-a");
|
||||
let two = fake_jdk(&dir, "jdk-b");
|
||||
|
||||
let found =
|
||||
unlucky_core::game::java::staged::scan_roots(&[], &[one.clone(), two.clone()], false);
|
||||
assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}");
|
||||
|
||||
std::fs::remove_dir_all(&one).expect("删 jdk-a");
|
||||
let after = unlucky_core::game::java::staged::scan_roots(&[], &[one, two], false);
|
||||
assert_eq!(after.len(), 1, "删掉一个之后必须少一项:{after:?}");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 扫描结论灌进页面状态之后,**行数与路径**都来自那次扫描(不是常量)。
|
||||
#[test]
|
||||
fn applying_a_scan_report_replaces_every_row() {
|
||||
let mut state = State::default();
|
||||
let report = store::ScanReport {
|
||||
running: false,
|
||||
done: 2,
|
||||
total: 2,
|
||||
finished: true,
|
||||
cancelled: false,
|
||||
runtimes: vec![
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk-a/bin/java.exe".to_string(),
|
||||
version: String::new(),
|
||||
major: None,
|
||||
vendor: None,
|
||||
source: "manual",
|
||||
},
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk-b/bin/java.exe".to_string(),
|
||||
version: String::new(),
|
||||
major: Some(21),
|
||||
vendor: Some("Temurin".to_string()),
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(state.apply_scan(&report), "第一次灌结论必须报告「变了」");
|
||||
assert_eq!(state.runtimes.len(), 2);
|
||||
assert!(state.scanned, "扫完之后「未检测到」与「还没扫」必须能分开");
|
||||
assert_eq!(state.runtimes[1].summary(), "Java 21 · Temurin");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫②:非法路径不污染设置
|
||||
|
||||
/// 非法路径被拒、**设置与名单一个字节都不写**。
|
||||
///
|
||||
/// 变异证明:删掉 `store::add_manual` 里的 `manual::probe_manual`(改成只看 `is_dir()`),
|
||||
/// 「不存在的路径」就会被加进名单 ⇒ 红。
|
||||
#[test]
|
||||
fn an_invalid_manual_path_is_refused_and_writes_nothing() {
|
||||
let dir = root("manual");
|
||||
let file = config(&dir);
|
||||
|
||||
assert_eq!(
|
||||
store::add_manual(&file, "").err(),
|
||||
Some(Refusal::AddEmpty),
|
||||
"空输入要被拒"
|
||||
);
|
||||
assert_eq!(
|
||||
store::add_manual(&file, "Z:/definitely/not/here").err(),
|
||||
Some(Refusal::AddMissing),
|
||||
"不存在的路径要被拒"
|
||||
);
|
||||
let empty = dir.join("empty-jdk");
|
||||
std::fs::create_dir_all(&empty).expect("空目录");
|
||||
assert_eq!(
|
||||
store::add_manual(&file, &empty.display().to_string()).err(),
|
||||
Some(Refusal::AddNotJava),
|
||||
"目录里没有 bin/java 要被拒"
|
||||
);
|
||||
let broken = fake_jdk(&dir, "broken-jdk");
|
||||
assert_eq!(
|
||||
store::add_manual(&file, &broken.display().to_string()).err(),
|
||||
Some(Refusal::AddNoVersion),
|
||||
"跑不出 java -version 要被拒"
|
||||
);
|
||||
|
||||
assert!(
|
||||
store::load_manual(&file).is_empty(),
|
||||
"被拒的路径不许进名单:{:?}",
|
||||
store::load_manual(&file)
|
||||
);
|
||||
let snapshot = store::load(&file);
|
||||
assert!(
|
||||
snapshot.selected.is_empty(),
|
||||
"被拒的路径不许污染 java.selected_path:{snapshot:?}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫③:内存越界被拒
|
||||
|
||||
/// 内存必须落在 schema 登记的区间里,非数字也要拒,而且**不写盘**。
|
||||
///
|
||||
/// 变异证明:删掉 `State::validate` 里的范围判断(只留 `parse`),128 与 999999 会落盘 ⇒ 红;
|
||||
/// 把 `parse::<u32>` 换成 `unwrap_or(2048)`,`"abc"` 不再报错 ⇒ 红。
|
||||
#[test]
|
||||
fn the_memory_bounds_are_enforced_before_writing() {
|
||||
let mut state = State {
|
||||
storage: true,
|
||||
..State::default()
|
||||
};
|
||||
|
||||
state.memory = (MIN_MEMORY_MB - 1).to_string();
|
||||
assert_eq!(
|
||||
state.validate().err(),
|
||||
Some(Refusal::MemoryRange),
|
||||
"低于下限"
|
||||
);
|
||||
state.memory = (MAX_MEMORY_MB + 1).to_string();
|
||||
assert_eq!(
|
||||
state.validate().err(),
|
||||
Some(Refusal::MemoryRange),
|
||||
"高于上限"
|
||||
);
|
||||
state.memory = "abc".to_string();
|
||||
assert_eq!(
|
||||
state.validate().err(),
|
||||
Some(Refusal::MemoryNotNumber),
|
||||
"非数字"
|
||||
);
|
||||
state.memory = " ".to_string();
|
||||
assert_eq!(state.validate().err(), Some(Refusal::MemoryEmpty), "空");
|
||||
state.memory = "4096".to_string();
|
||||
assert!(state.validate().is_ok());
|
||||
state.jvm = "-XX:+UseG1GC".to_string();
|
||||
assert!(state.validate().is_ok());
|
||||
state.jvm = "-Xss\"512k".to_string();
|
||||
assert_eq!(
|
||||
state.validate().err(),
|
||||
Some(Refusal::JvmQuotes),
|
||||
"引号没配对"
|
||||
);
|
||||
|
||||
// 真的不写盘:一次越界保存之后,文件里还是出厂默认值。
|
||||
let dir = root("memory");
|
||||
let file = config(&dir);
|
||||
let state = State {
|
||||
storage: true,
|
||||
memory: "128".to_string(),
|
||||
..State::default()
|
||||
};
|
||||
assert!(store::save(&file, &state).is_err(), "越界必须写不进去");
|
||||
assert_eq!(
|
||||
store::load(&file).memory_mb,
|
||||
super::state::DEFAULT_MEMORY_MB,
|
||||
"被拒的保存不许落盘"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 两个数字常量必须与 schema 登记的一致(否则 UI 放行的值会被 schema 拒)。
|
||||
///
|
||||
/// 变异证明:把 `MIN_MEMORY_MB` 改成 256(core 的 `launch_overrides` 那一档),这条立刻红。
|
||||
#[test]
|
||||
fn the_memory_bounds_mirror_the_schema() {
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::spec::ValueKind;
|
||||
let schema = launcher_schema();
|
||||
let kind = schema
|
||||
.spec("java.default_memory_mb")
|
||||
.map(|spec| &spec.kind)
|
||||
.expect("schema 里必须有 java.default_memory_mb");
|
||||
let ValueKind::Int { min, max } = kind else {
|
||||
panic!("java.default_memory_mb 应该是 Int:{kind:?}");
|
||||
};
|
||||
assert_eq!(*min, i64::from(MIN_MEMORY_MB));
|
||||
assert_eq!(*max, i64::from(MAX_MEMORY_MB));
|
||||
}
|
||||
#[path = "tests/runtime.rs"]
|
||||
mod runtime;
|
||||
@@ -0,0 +1,262 @@
|
||||
//! `tests.rs` 的后半:真命令行 / 落盘读回 / 版本门槛 / 截图种子。
|
||||
//!
|
||||
//! 拆出来的理由:`tests.rs` 加上守卫之后 434 行,越过 400 行硬线;这一半是「真值走完整条生产链」
|
||||
//! 的一族(`launcher.json` → 组装链 / 重启后读回),与前半的「输入校验」不是一件事。
|
||||
//! 公共的临时目录与假 JDK 构造在父模块(`super::root` / `super::fake_jdk`)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::super::state::{Layer, Notice, Requirement, RuntimeRow, State};
|
||||
use super::super::store;
|
||||
use super::{config, fake_jdk, root};
|
||||
|
||||
// --------------------------------------------------------------- 守卫④:内存真进命令行
|
||||
|
||||
/// 写进 `java.default_memory_mb` 的值必须出现在**真组装出来的 JVM 参数**里。
|
||||
///
|
||||
/// 走的是生产代码那条链:`launcher.json` → `GlobalLaunchDefaults` → `launch_overrides::resolve`
|
||||
/// → `launch::overrides::apply` → `launch::args_table::assemble`(`ull launch --dry-run` 用的是同一条)。
|
||||
/// 不是读配置回显:断言的是 `-Xmx<新值>m` 这个 argv 元素。
|
||||
///
|
||||
/// 变异证明:把 `store::save` 里写 `java.default_memory_mb` 的那一行删掉(或写死 2048),
|
||||
/// 这条立刻红(argv 里还是 `-Xmx2048m`)。
|
||||
#[test]
|
||||
fn the_saved_memory_reaches_the_real_command_line() {
|
||||
let dir = root("cmdline");
|
||||
let file = config(&dir);
|
||||
let state = State {
|
||||
storage: true,
|
||||
memory: "6144".to_string(),
|
||||
jvm: "-Dfile.encoding=UTF-8".to_string(),
|
||||
..State::default()
|
||||
};
|
||||
assert!(state.validate().is_ok());
|
||||
let saved = store::save(&file, &state).expect("保存应成功");
|
||||
assert_eq!(saved.memory_mb, 6144, "落盘后读回的值");
|
||||
assert_eq!(saved.jvm, "-Dfile.encoding=UTF-8");
|
||||
|
||||
let args = real_argv(&file);
|
||||
assert!(
|
||||
args.iter().any(|arg| arg == "-Xmx6144m"),
|
||||
"内存字段必须长成 -Xmx6144m:{args:?}"
|
||||
);
|
||||
assert!(
|
||||
args.iter().any(|arg| arg == "-Dfile.encoding=UTF-8"),
|
||||
"java.extra_args 必须进命令行:{args:?}"
|
||||
);
|
||||
assert!(
|
||||
!args.iter().any(|arg| arg == "-Xmx2048m"),
|
||||
"出厂默认值不许还在里面:{args:?}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 用生产链组装一次命令行(`launch.json` 之外什么都不读)。
|
||||
fn real_argv(config: &Path) -> Vec<String> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::launch_overrides::{resolve, GlobalLaunchDefaults};
|
||||
use unlucky_core::launch::args_table::assemble;
|
||||
use unlucky_core::launch::overrides;
|
||||
use unlucky_core::launch::platform::{OsFamily, PlatformCtx};
|
||||
use unlucky_core::launch::request::{
|
||||
InstanceInfo, JavaSelection, LaunchDirs, ManifestArgs, MemoryOptions,
|
||||
};
|
||||
use unlucky_core::launch::request::{
|
||||
LaunchRequest, LauncherInfo, Log4jOptions, OptimizationOptions,
|
||||
};
|
||||
|
||||
let snapshot = store::load(config);
|
||||
let resolved = resolve(
|
||||
None,
|
||||
&GlobalLaunchDefaults {
|
||||
jvm_args: Some(snapshot.jvm.clone()),
|
||||
memory_mb: Some(snapshot.memory_mb),
|
||||
..GlobalLaunchDefaults::default()
|
||||
},
|
||||
);
|
||||
let mut request = LaunchRequest {
|
||||
instance: InstanceInfo {
|
||||
id: "inst-1".to_string(),
|
||||
name: "1.20.1".to_string(),
|
||||
version: unlucky_core::launch::game_version::GameVersion::parse("1.20.1"),
|
||||
version_type: "release".to_string(),
|
||||
},
|
||||
launcher: LauncherInfo {
|
||||
name: "UnluckyLauncher".to_string(),
|
||||
version: "0.2.3".to_string(),
|
||||
},
|
||||
java: JavaSelection::new("C:/java/bin/java.exe", 21, 64),
|
||||
memory: MemoryOptions {
|
||||
min_mb: None,
|
||||
max_mb: None,
|
||||
},
|
||||
manifest: ManifestArgs {
|
||||
main_class: Some("net.minecraft.client.main.Main".to_string()),
|
||||
..ManifestArgs::default()
|
||||
},
|
||||
dirs: LaunchDirs {
|
||||
game_dir: PathBuf::from("C:/mc"),
|
||||
primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"),
|
||||
..LaunchDirs::default()
|
||||
},
|
||||
classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")],
|
||||
log4j: Log4jOptions {
|
||||
enabled: false,
|
||||
..Log4jOptions::default()
|
||||
},
|
||||
optimization: OptimizationOptions {
|
||||
enabled: false,
|
||||
physical_memory_mb: None,
|
||||
},
|
||||
language: "zh-CN".to_string(),
|
||||
..LaunchRequest::default()
|
||||
};
|
||||
overrides::apply(&mut request, &resolved);
|
||||
assemble(&request, &PlatformCtx::new(OsFamily::Windows, "x86_64"))
|
||||
.expect("组装应成功")
|
||||
.args
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 落盘 / 读回 / 门槛
|
||||
|
||||
/// 三键都能落盘、能在「重启后」读回来(新的 `State` + `hydrate` 就是重启的等价物)。
|
||||
#[test]
|
||||
fn the_settings_survive_a_restart() {
|
||||
let dir = root("restart");
|
||||
let file = config(&dir);
|
||||
let state = State {
|
||||
storage: true,
|
||||
memory: "8192".to_string(),
|
||||
jvm: "-XX:+UseG1GC".to_string(),
|
||||
..State::default()
|
||||
};
|
||||
store::save(&file, &state).expect("保存");
|
||||
let selected = store::set_selected(&file, "C:/jdk-17/bin/java.exe").expect("选默认");
|
||||
|
||||
// 「重启」:全新的 State 从盘上灌一次。
|
||||
let mut reborn = State::default();
|
||||
assert!(reborn.hydrate(store::load(&file)));
|
||||
assert_eq!(reborn.memory, "8192");
|
||||
assert_eq!(reborn.saved_memory, 8192);
|
||||
assert_eq!(reborn.jvm, "-XX:+UseG1GC");
|
||||
assert_eq!(reborn.selected, "C:/jdk-17/bin/java.exe");
|
||||
assert_eq!(selected.selected, "C:/jdk-17/bin/java.exe");
|
||||
|
||||
// 手动名单也在(与设置同一个 config/ 目录下的纯文本)。
|
||||
let jdk = fake_jdk(&dir, "keep-me");
|
||||
std::fs::write(store::manual_file(&file), format!("{}\n", jdk.display())).expect("写名单");
|
||||
assert_eq!(store::load_manual(&file).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 「为什么选它」那一行:版本门槛来自实例,选中的运行时够不够要能算出来。
|
||||
#[test]
|
||||
fn the_requirement_explains_why_a_runtime_fits_or_not() {
|
||||
let mut state = State {
|
||||
scanned: true,
|
||||
runtimes: vec![
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk-8/bin/java.exe".to_string(),
|
||||
version: String::new(),
|
||||
major: Some(8),
|
||||
vendor: None,
|
||||
source: "system",
|
||||
},
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk-21/bin/java.exe".to_string(),
|
||||
version: String::new(),
|
||||
major: Some(21),
|
||||
vendor: None,
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
selected: "C:/jdk-8/bin/java.exe".to_string(),
|
||||
..State::default()
|
||||
};
|
||||
assert_eq!(state.satisfied(Some(8)), None, "没有要求时不下结论");
|
||||
|
||||
state.context.required = Some(Requirement {
|
||||
instance: "生存服".to_string(),
|
||||
version: "1.20.1".to_string(),
|
||||
minimum: 17,
|
||||
maximum: None,
|
||||
});
|
||||
assert_eq!(state.satisfied(Some(8)), Some(false));
|
||||
assert_eq!(state.satisfied(Some(21)), Some(true));
|
||||
assert_eq!(
|
||||
state.default_row().map(|row| row.binary.clone()),
|
||||
Some("C:/jdk-8/bin/java.exe".to_string()),
|
||||
"默认那一行按 selected 找出来"
|
||||
);
|
||||
|
||||
state.context.layer = Some(Layer::Instance {
|
||||
instance: "生存服".to_string(),
|
||||
mb: 8192,
|
||||
});
|
||||
assert!(matches!(state.layer(), Layer::Instance { mb: 8192, .. }));
|
||||
}
|
||||
|
||||
/// 截图种子:三档都得给得出来(`--screenshot` 的取证开关)。
|
||||
#[test]
|
||||
fn the_screenshot_seeds_cover_the_three_states() {
|
||||
let empty = State::screenshot_seed("empty").expect("empty 档");
|
||||
assert!(empty.runtimes.is_empty() && empty.scanned);
|
||||
|
||||
let two = State::screenshot_seed("two").expect("two 档");
|
||||
assert_eq!(two.runtimes.len(), 2);
|
||||
assert!(two.default_row().is_some(), "两个 JDK 里要选中其一");
|
||||
|
||||
let editing = State::screenshot_seed("editing").expect("editing 档");
|
||||
assert!(editing.dirty, "编辑态要有未保存改动");
|
||||
assert!(matches!(editing.notice, Some(Notice::Refused(_))));
|
||||
assert!(State::screenshot_seed("nonsense").is_none());
|
||||
}
|
||||
|
||||
/// 自动扫描的判据:首屏扫一次,扫过就不重扫,截图通道永不扫。
|
||||
///
|
||||
/// 变异证明:把 `auto_scan_due` 改成恒 `true`,第二、三档立刻红(每次心跳都发起扫描);
|
||||
/// 改成恒 `false`,第一档红(用户实测的头一条抱怨就是「设置页从来自动检测」)。
|
||||
#[test]
|
||||
fn the_auto_scan_predicate_runs_once_and_never_in_screenshots() {
|
||||
let fresh = store::ScanReport::default();
|
||||
assert!(
|
||||
store::auto_scan_due(&fresh, false),
|
||||
"首屏(没扫过、没在扫)应该自动扫一次"
|
||||
);
|
||||
assert!(
|
||||
!store::auto_scan_due(&fresh, true),
|
||||
"截图通道不许读本机装了什么"
|
||||
);
|
||||
|
||||
let running = store::ScanReport {
|
||||
running: true,
|
||||
..store::ScanReport::default()
|
||||
};
|
||||
assert!(!store::auto_scan_due(&running, false), "正在扫就不重入");
|
||||
|
||||
let finished = store::ScanReport {
|
||||
finished: true,
|
||||
..store::ScanReport::default()
|
||||
};
|
||||
assert!(
|
||||
!store::auto_scan_due(&finished, false),
|
||||
"扫过一次就不再自动扫(手动「立即扫描」仍可再扫)"
|
||||
);
|
||||
|
||||
// 被取消过的扫描:`finished` 仍是 false(结论作废),但**不许**自动重来——否则
|
||||
// `launcher.json` 每变一次,心跳就会把用户刚做的取消无声撤销(r2 复审 P2-2)。
|
||||
let cancelled = store::ScanReport {
|
||||
cancelled: true,
|
||||
..store::ScanReport::default()
|
||||
};
|
||||
assert!(
|
||||
!store::auto_scan_due(&cancelled, false),
|
||||
"取消过一次之后,本进程不再自动扫描"
|
||||
);
|
||||
// 手动「立即扫描」走的是 `ScanSlot::start`,不看这个判据 ⇒ 取消不是永久锁死。
|
||||
assert!(
|
||||
store::auto_scan_due(&store::ScanReport::default(), false),
|
||||
"全新进程(没扫过、没取消)仍然自动扫一次"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! `tests.rs` 的第三块(M175 r2):手动名单的落盘 / 扫描槽的并发与取消 / 错误文案的 i18n。
|
||||
//!
|
||||
//! 守卫①~④(列表来自真扫盘、非法路径被拒、内存越界、内存真进命令行)在 `tests.rs` 与
|
||||
//! `tests/cmdline.rs`;这一块是 r1 复审补的三条(P2-3 / P2-5 / P2-2)。
|
||||
|
||||
use super::super::state::{Refusal, RuntimeRow, State, Unwritable};
|
||||
use super::super::store;
|
||||
use super::{config, fake_jdk, java_bin, root};
|
||||
|
||||
// --------------------------------------------------------------- 守卫⑤:手动名单真落盘(P2-3)
|
||||
|
||||
/// 手动加一条合法路径 → **从盘上新读一份** → 名单里有它;「重启」之后仍在。
|
||||
///
|
||||
/// 为什么用注入的探测函数:`probe_manual` 要真跑 `java -version`,用例不能依赖本机装没装 JDK;
|
||||
/// 但**落盘那一段走的是生产代码**(`manual::save_paths` + `manual::load_paths`),所以这条
|
||||
/// 是「持久化」的正面断言,而不是「内存里的 vec 有它」。
|
||||
///
|
||||
/// 变异证明:删掉 `store::add_manual_with` 里的 `manual::save_paths(...)` 那一行 ⇒ 这条红
|
||||
/// (`load_manual` 从盘上读回 0 条);把去重那段删掉 ⇒ 末尾那条 `AddDuplicate` 断言红。
|
||||
#[test]
|
||||
fn a_manual_path_is_written_to_disk_and_survives_a_restart() {
|
||||
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
|
||||
|
||||
let dir = root("manual-ok");
|
||||
let file = config(&dir);
|
||||
let jdk = fake_jdk(&dir, "manual-jdk");
|
||||
let binary = jdk.join("bin").join(java_bin());
|
||||
let runtime = JavaRuntime {
|
||||
binary: binary.clone(),
|
||||
origin: JavaOrigin::System,
|
||||
version: Some("openjdk version \"21.0.4\" 2024-07-16 LTS".to_string()),
|
||||
major: Some(21),
|
||||
bits: 64,
|
||||
vendor: Some("Temurin".to_string()),
|
||||
};
|
||||
let probe = |_input: &str| Ok(runtime.clone());
|
||||
|
||||
// 刚加之前:盘上什么都没有。
|
||||
assert!(store::load_manual(&file).is_empty());
|
||||
|
||||
let (added, row) = store::add_manual_with(&file, &jdk.display().to_string(), &probe)
|
||||
.expect("合法路径必须加得进去");
|
||||
assert_eq!(added, binary.display().to_string());
|
||||
assert_eq!(row.major, Some(21));
|
||||
|
||||
// **从盘上新读一份**(不是看内存里的 vec)。
|
||||
let listed = store::load_manual(&file);
|
||||
assert_eq!(listed.len(), 1, "名单必须真的写到了盘上:{listed:?}");
|
||||
assert_eq!(listed[0], binary.display().to_string());
|
||||
assert!(
|
||||
store::manual_file(&file).is_file(),
|
||||
"名单文件必须真的存在:{:?}",
|
||||
store::manual_file(&file)
|
||||
);
|
||||
|
||||
// 「重启」:一个全新的 State 从盘上灌,名单还在。
|
||||
let reborn = State {
|
||||
manual: store::load_manual(&file),
|
||||
..State::default()
|
||||
};
|
||||
assert_eq!(reborn.manual.len(), 1);
|
||||
|
||||
// 再加同一份 ⇒ 重复(去重也走盘上的名单)。
|
||||
assert_eq!(
|
||||
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
|
||||
Some(Refusal::AddDuplicate)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫⑥:扫描只许一个线程(P2-5)
|
||||
|
||||
static JOB_LIVE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
static JOB_PEAK: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
static JOB_RELEASE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// 一个「卡住」的扫描作业:直到被取消或 `JOB_RELEASE` 放开才返回。
|
||||
fn blocking_job(
|
||||
_candidates: &[unlucky_core::game::java::staged::Candidate],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
_progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Vec<RuntimeRow> {
|
||||
use std::sync::atomic::Ordering;
|
||||
let live = JOB_LIVE.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
JOB_PEAK.fetch_max(live, Ordering::SeqCst);
|
||||
while !JOB_RELEASE.load(Ordering::SeqCst) && !cancelled() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
JOB_LIVE.fetch_sub(1, Ordering::SeqCst);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 扫描进行中点「立即扫描」不许起第二个线程;「取消」必须让线程**真的退出**(之后才允许再扫)。
|
||||
///
|
||||
/// 变异证明:把 `start_with` 开头那道 `if report.running { return false }` 删掉 ⇒ 第二次
|
||||
/// `start_with` 返回 `true`、`JOB_PEAK` 变成 2,这条红;把 `cancel()` 里置 `cancelled` 那两行
|
||||
/// 删掉 ⇒ 作业永远看不到取消、`report().running` 不退,超时断言红。
|
||||
#[test]
|
||||
fn only_one_scan_thread_can_run_and_cancel_finishes_it() {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
JOB_RELEASE.store(false, Ordering::SeqCst);
|
||||
JOB_PEAK.store(0, Ordering::SeqCst);
|
||||
let slot = store::ScanSlot::default();
|
||||
|
||||
assert!(
|
||||
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
|
||||
"第一次扫描应该能起来"
|
||||
);
|
||||
assert!(
|
||||
!slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
|
||||
"扫描进行中不许起第二个线程"
|
||||
);
|
||||
assert!(slot.report().running, "线程活着时 running 必须为真");
|
||||
|
||||
slot.cancel();
|
||||
assert!(slot.report().cancelled, "取消之后要标记 cancelled");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while slot.report().running && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
!slot.report().running,
|
||||
"取消必须让扫描线程在预算内真的退出(不是只把界面翻成「已取消」)"
|
||||
);
|
||||
assert!(!slot.report().finished, "被取消的扫描不许把结论当真");
|
||||
assert_eq!(
|
||||
JOB_PEAK.load(Ordering::SeqCst),
|
||||
1,
|
||||
"任何时刻只有一个扫描线程"
|
||||
);
|
||||
|
||||
// 线程真退出之后允许重新扫(这就是「取消 ≠ 永久锁死」)。
|
||||
JOB_RELEASE.store(true, Ordering::SeqCst);
|
||||
assert!(
|
||||
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
|
||||
"上一个线程退出之后应该能重新扫"
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while slot.report().running && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(slot.report().finished, "这次应该正常扫完");
|
||||
JOB_RELEASE.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫⑦:错误文案是 i18n 的(P2-2)
|
||||
|
||||
/// 用户可见的失败原因**全部**来自 catalog:三档「写不进去」各有各的一句,手动路径的
|
||||
/// 「太长 / 写不进名单」也不再顶替「路径是空的 / 路径上没有东西」。
|
||||
///
|
||||
/// 抓得住:①三档文案串台(只读说成「没有改动」);②新加的原因复用旧 key;③把中文硬编码回
|
||||
/// `store.rs`(源码扫描那一段)。
|
||||
/// 抓不住:译文翻得好不好(那是金表与人工的事)。
|
||||
///
|
||||
/// 变异证明:`Unwritable::ReadOnly` 的 key 改成 `SettingsJavaSkipNotDirty` ⇒ 这条红;
|
||||
/// 把 `store.rs` 里加回一句硬编码中文 ⇒ 源码扫描那段红。
|
||||
#[test]
|
||||
fn the_unwritable_reasons_have_distinct_text() {
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
|
||||
let en = Lang::En.catalog();
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
let reasons = [
|
||||
Unwritable::NotDirty,
|
||||
Unwritable::ReadOnly,
|
||||
Unwritable::Unreadable,
|
||||
];
|
||||
|
||||
for reason in reasons {
|
||||
assert_ne!(
|
||||
en.text(reason.label()),
|
||||
zh.text(reason.label()),
|
||||
"{} 未翻译",
|
||||
reason.label().name()
|
||||
);
|
||||
}
|
||||
for (index, left) in reasons.iter().enumerate() {
|
||||
for right in reasons.iter().skip(index + 1) {
|
||||
assert_ne!(
|
||||
en.text(left.label()),
|
||||
en.text(right.label()),
|
||||
"「写不进去」的两档用了同一句文案:{} / {}",
|
||||
left.label().name(),
|
||||
right.label().name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 手动路径的两档新原因不许顶替旧原因(P2-2 的第三条与第四条)。
|
||||
assert_eq!(
|
||||
Refusal::AddTooLong.label(),
|
||||
Key::SettingsJavaReasonAddTooLong
|
||||
);
|
||||
assert_eq!(
|
||||
Refusal::AddWriteFailed.label(),
|
||||
Key::SettingsJavaReasonAddWriteFailed
|
||||
);
|
||||
assert_ne!(Refusal::AddTooLong.label(), Refusal::AddEmpty.label());
|
||||
assert_ne!(Refusal::AddWriteFailed.label(), Refusal::AddMissing.label());
|
||||
|
||||
// 源码扫描:`store.rs` 里不许再有「把 Rust 枚举名或中文字符串当人话」的写法。
|
||||
let source = include_str!("../store.rs");
|
||||
assert!(
|
||||
!source.contains("format!(\"{refusal:?}\")"),
|
||||
"不许把 Rust 枚举名(Refusal 的 Debug 串)当用户文案"
|
||||
);
|
||||
for hardcoded in ["没有改动", "配置文件只读", "配置文件不可信"] {
|
||||
// 只认**字符串字面量**(带引号):文档注释里出现这几个词是正常的说明。
|
||||
let literal = format!("\"{hardcoded}\"");
|
||||
assert!(
|
||||
!source.contains(&literal),
|
||||
"store.rs 里还有硬编码中文字符串:{literal}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `core` 的每种拒绝原因都要落到**自己的**页面原因上(P2-2 的第三、四条)。
|
||||
///
|
||||
/// 抓得住:`ManualFailure::TooLong` 被写成 `Refusal::AddEmpty`(界面会对一条 600 字符的路径说
|
||||
/// 「路径是空的」)、名单写盘失败被写成 `Refusal::AddMissing`(磁盘写不进去却说「路径上没有东西」)。
|
||||
/// 抓不住:文案翻得好不好(那是金表与人工的事)。
|
||||
///
|
||||
/// 变异证明:`failure_refusal` 里 `TooLong => AddTooLong` 改成 `AddEmpty` ⇒ 本条红;
|
||||
/// 写盘失败那行 `map_err(|_| AddWriteFailed)` 改成 `AddMissing` ⇒ 本条红。
|
||||
#[test]
|
||||
fn every_manual_failure_maps_to_its_own_reason() {
|
||||
use unlucky_core::game::java::manual::ManualFailure;
|
||||
|
||||
let dir = root("manual-map");
|
||||
let file = config(&dir);
|
||||
for (failure, expected) in [
|
||||
(ManualFailure::Empty, Refusal::AddEmpty),
|
||||
(ManualFailure::TooLong, Refusal::AddTooLong),
|
||||
(ManualFailure::Missing, Refusal::AddMissing),
|
||||
(ManualFailure::NotJava, Refusal::AddNotJava),
|
||||
(ManualFailure::NoVersion, Refusal::AddNoVersion),
|
||||
] {
|
||||
let probe = move |_input: &str| Err(failure);
|
||||
assert_eq!(
|
||||
store::add_manual_with(&file, "whatever", &probe).err(),
|
||||
Some(expected),
|
||||
"{failure:?} 应该映射到 {expected:?}"
|
||||
);
|
||||
}
|
||||
// 一个都没写进去。
|
||||
assert!(store::load_manual(&file).is_empty());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 名单文件写不进去(父路径是个文件)时,报的是「写不进名单」而不是「路径上没有东西」。
|
||||
#[test]
|
||||
fn a_failed_list_write_reports_the_write_not_the_path() {
|
||||
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
|
||||
|
||||
let dir = root("manual-write-fail");
|
||||
// `<dir>/config` 做成**文件**:`manual_file` 的父目录建不出来 ⇒ save_paths 失败。
|
||||
// (`root()` 会先建好 `config/`,所以这里先删掉再放一个同名文件。)
|
||||
let blocker = dir.join("config");
|
||||
std::fs::remove_dir_all(&blocker).expect("删掉空 config 目录");
|
||||
std::fs::write(&blocker, b"not a directory").expect("写占位文件");
|
||||
let file = blocker.join("launcher.json");
|
||||
let jdk = fake_jdk(&dir, "manual-jdk");
|
||||
let binary = jdk.join("bin").join(java_bin());
|
||||
let probe = |_input: &str| {
|
||||
Ok(JavaRuntime {
|
||||
binary: binary.clone(),
|
||||
origin: JavaOrigin::System,
|
||||
version: Some("openjdk version \"17.0.13\" 2024-10-15".to_string()),
|
||||
major: Some(17),
|
||||
bits: 64,
|
||||
vendor: Some("Temurin".to_string()),
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
|
||||
Some(Refusal::AddWriteFailed),
|
||||
"磁盘写不进去要说「写不进名单」,不能说「路径不存在」"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- r2 P2-1:空态不许指向不存在的页
|
||||
|
||||
/// 空态给的那条「还有别的办法」必须是**真的存在**的入口。
|
||||
///
|
||||
/// 现场(r2 复审 P2-1):原文写「或者去 Java 页装一个」,而 GUI 里根本没有 Java 页
|
||||
/// (`presenter::router::Route` 只有 Instances / Download / Content / Logs / Settings /
|
||||
/// Accounts / Detail / Agent,`spawn_install` 在 `crates/app-iced/src` 里零消费者)。
|
||||
/// 于是「一个 JDK 都没装」的用户——正是这个 mission 要救的那位——被指到一个不存在的入口。
|
||||
///
|
||||
/// 抓得住:文案被改回「Java 页 / the Java page」,或把命令行入口那半句删掉。
|
||||
/// 抓不住:命令行的措辞好不好读(那是人工的事)。
|
||||
///
|
||||
/// 变异证明:把 `SettingsJavaEmptyBody` 改回「或者去 Java 页装一个」⇒ 本条红。
|
||||
#[test]
|
||||
fn the_empty_state_points_at_a_real_entry() {
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let text = lang
|
||||
.catalog()
|
||||
.text(unlucky_i18n::Key::SettingsJavaEmptyBody);
|
||||
assert!(
|
||||
text.contains("ull java install"),
|
||||
"{}:空态要给一条真能走的路(命令行)——{text}",
|
||||
lang.tag()
|
||||
);
|
||||
for dead in ["Java page", "Java 页"] {
|
||||
assert!(
|
||||
!text.contains(dead),
|
||||
"{}:空态指向了不存在的入口「{dead}」——{text}",
|
||||
lang.tag()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! 「Java」分组的一张卡片(M175)。
|
||||
//!
|
||||
//! ## 版面(自上而下)
|
||||
//!
|
||||
//! | 行 | 内容 | 数值 / 证据 |
|
||||
//! |---|---|---|
|
||||
//! | 状态 | 未检测到 / N 个 JDK / 正在扫描 3/7 | 扫描按钮与取消按钮同步禁用 |
|
||||
//! | 运行时列表 | 每个 JDK 一行:路径 + 版本 + 发行方 + 来源 | 「默认」标记 / 「设为默认」chip |
|
||||
//! | 版本门槛 | 当前实例需要 Java ≥ N,哪一行不满足 | 来自 app 层算好的 [`Context`] |
|
||||
//! | 手动添加 | 输入框 + 「添加」 | 校验不过时下面直接说原因 |
|
||||
//! | 默认内存 | 数字框 + 范围提示 + 「现在生效多少来自哪一层」 | 落盘值(不是草稿) |
|
||||
//! | JVM 参数 | 文本框 + 拼命令行提示 | 落盘的 `java.extra_args` |
|
||||
//! | 动作 | 保存 | 灰掉时下面是原因 |
|
||||
//!
|
||||
//! 文案一律走 [`unlucky_i18n::Catalog`]:本组要 `Lang`,所以走 `settings/mod.rs` 的按下标分发
|
||||
//! (与 General / Update / Assistant 同一条路),`Group.body` 保持 `None`。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::pages::frame::{card, divider, text_role_owned};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::fields::{actions_row, add_row, hint_line, jvm_field, memory_field, notice_line};
|
||||
use super::state::{Progress, RuntimeRow, State};
|
||||
use super::Message as JavaMessage;
|
||||
use crate::pages::settings::{GroupMessage, States};
|
||||
|
||||
/// 一条本组消息 → 页面消息。
|
||||
pub(super) fn msg(message: JavaMessage) -> Message {
|
||||
Message::Settings(GroupMessage::Java(message))
|
||||
}
|
||||
|
||||
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来)。
|
||||
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
|
||||
body_of(ui, &states.groups.java, lang)
|
||||
}
|
||||
|
||||
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
|
||||
let catalog = lang.catalog();
|
||||
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
|
||||
|
||||
stacked = stacked.push(status_row(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(runtime_list(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(add_row(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(memory_field(ui, catalog, state));
|
||||
stacked = stacked.push(jvm_field(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(actions_row(ui, catalog, state));
|
||||
stacked = stacked.push(hint_line(ui, catalog, state));
|
||||
|
||||
if let Some(notice) = &state.notice {
|
||||
stacked = stacked.push(notice_line(ui, catalog, notice));
|
||||
}
|
||||
|
||||
card(ui, stacked)
|
||||
}
|
||||
|
||||
/// 状态行:未检测到 / N 个 JDK / 正在扫描 / 正在取消 + 两个按钮。
|
||||
///
|
||||
/// 「取消」的语义(r1 复审 P2-5):按下之后到线程真退出之间,界面显示的是**正在取消…**,
|
||||
/// 「立即扫描」保持禁用——否则用户能在旧线程还活着的时候起第二个。
|
||||
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
|
||||
let (text, scanning) = match (state.scanning, state.scanned) {
|
||||
(Some(_), _) if state.cancelled => {
|
||||
(catalog.text(Key::SettingsJavaCancelling).to_string(), true)
|
||||
}
|
||||
// 候选表还在收集(`total == 0`):不显示 `0/0`,给一句「正在扫描…」。
|
||||
(Some(Progress { total: 0, .. }), _) => (
|
||||
catalog.text(Key::SettingsJavaScanningStart).to_string(),
|
||||
true,
|
||||
),
|
||||
(Some(Progress { done, total }), _) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaScanning,
|
||||
&[
|
||||
("done", done.to_string().as_str()),
|
||||
("total", total.to_string().as_str()),
|
||||
],
|
||||
),
|
||||
true,
|
||||
),
|
||||
(None, true) if state.runtimes.is_empty() => {
|
||||
(catalog.text(Key::SettingsJavaEmptyTitle).to_string(), false)
|
||||
}
|
||||
(None, true) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaScanDone,
|
||||
&[("count", state.runtimes.len().to_string().as_str())],
|
||||
),
|
||||
false,
|
||||
),
|
||||
(None, false) => (
|
||||
catalog.text(Key::SettingsJavaNeverScanned).to_string(),
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
let scan_label = catalog.text(Key::SettingsJavaScan);
|
||||
let scan = if scanning || !state.storage {
|
||||
buttons::filled(ui, Button::disabled(scan_label))
|
||||
} else {
|
||||
buttons::filled(ui, Button::new(scan_label, msg(JavaMessage::Scan)))
|
||||
};
|
||||
// 已经按过取消(线程还没退出)时,「取消」也灰掉:再按一次没有任何意义。
|
||||
let cancel = buttons::tonal(
|
||||
ui,
|
||||
if scanning && !state.cancelled {
|
||||
Button::new(
|
||||
catalog.text(Key::SettingsJavaCancel),
|
||||
msg(JavaMessage::CancelScan),
|
||||
)
|
||||
} else {
|
||||
Button::disabled(catalog.text(Key::SettingsJavaCancel))
|
||||
},
|
||||
);
|
||||
|
||||
// 「扫描已取消」那句只在线程**真退出之后**出现;退出之前状态行本身写着「正在取消…」。
|
||||
let cancelled = (state.cancelled && state.scanning.is_none()).then(|| {
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::SettingsJavaCancelled).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
});
|
||||
|
||||
let mut block = column![row![
|
||||
container(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
true,
|
||||
text,
|
||||
ui.color(if state.scanning.is_some() {
|
||||
Role::Primary
|
||||
} else {
|
||||
Role::OnSurface
|
||||
}),
|
||||
))
|
||||
.width(Length::Fill),
|
||||
scan,
|
||||
cancel,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
if let Some(line) = cancelled {
|
||||
block = block.push(line);
|
||||
}
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 运行时列表:每行一个 JDK,带「默认」标记与「设为默认」chip。
|
||||
fn runtime_list<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let mut list = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
|
||||
|
||||
if state.runtimes.is_empty() {
|
||||
let body = if state.scanned {
|
||||
catalog.text(Key::SettingsJavaEmptyBody)
|
||||
} else {
|
||||
catalog.text(Key::SettingsJavaNeverScannedBody)
|
||||
};
|
||||
return text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
body.to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
}
|
||||
|
||||
for runtime in &state.runtimes {
|
||||
list = list.push(runtime_row(ui, catalog, state, runtime));
|
||||
}
|
||||
list.into()
|
||||
}
|
||||
|
||||
fn runtime_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
runtime: &'a RuntimeRow,
|
||||
) -> Element<'a, Message> {
|
||||
let is_default = super::state::same_path(&runtime.binary, &state.selected);
|
||||
let satisfied = state.satisfied(runtime.major);
|
||||
|
||||
let (verdict, verdict_color) = match satisfied {
|
||||
Some(true) => (
|
||||
catalog.text(Key::SettingsJavaFits).to_string(),
|
||||
ui.color(Role::Primary),
|
||||
),
|
||||
Some(false) => {
|
||||
let required = state
|
||||
.context
|
||||
.required
|
||||
.as_ref()
|
||||
.map(|required| required.minimum.to_string())
|
||||
.unwrap_or_default();
|
||||
(
|
||||
catalog.render(Key::SettingsJavaTooOld, &[("minimum", required.as_str())]),
|
||||
ui.color(Role::Error),
|
||||
)
|
||||
}
|
||||
None => (String::new(), ui.color(Role::OnSurfaceVariant)),
|
||||
};
|
||||
|
||||
let action = if is_default {
|
||||
chip(
|
||||
ui,
|
||||
// 已选中那颗是**纯展示**的 badge(没有消息,不可交互):r2 复审要求清掉
|
||||
// 「禁用态 chip 上还挂着一条真消息」的残留。
|
||||
Chip::badge(catalog.text(Key::SettingsJavaDefault)),
|
||||
)
|
||||
} else {
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::SettingsJavaSetDefault),
|
||||
false,
|
||||
msg(JavaMessage::SelectRuntime(runtime.binary.clone())),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let mut block = column![row![
|
||||
container(
|
||||
column![
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
is_default,
|
||||
runtime.display_home(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
format!(
|
||||
"{} · {}",
|
||||
runtime.summary(),
|
||||
catalog.text(runtime.source_key())
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
action,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
if !verdict.is_empty() {
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
verdict,
|
||||
verdict_color,
|
||||
));
|
||||
}
|
||||
block.into()
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
//! | `crates/i18n/src/catalog/settings/general.rs` / `appearance.rs` | 两组的文案 key |
|
||||
//! | `crates/app-iced/src/app/update/settings/language_appearance.rs` | 消息的落点 |
|
||||
//!
|
||||
//! 真控件那半边在 `pages/appearance/section.rs`(`appearance.rs` 只负责把 `body` 挂上去)。
|
||||
//! 真控件那半边在 `pages/appearance/section/`(`appearance.rs` 只负责把 `body` 挂上去)。
|
||||
|
||||
/// 语言 + 外观线的状态(**只归本线**)。
|
||||
///
|
||||
|
||||
@@ -25,10 +25,11 @@
|
||||
//! | 分组 | 文件(正文 / 状态 / 消息) | 谁会动它 |
|
||||
//! |---|---|---|
|
||||
//! | General | [`general`] | language+appearance 线(语言下拉 + 主题 / 启动页) |
|
||||
//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) |
|
||||
//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section/`) |
|
||||
//! | Java | [`java`] | java 线 |
|
||||
//! | Downloads | [`download`] | download 线 |
|
||||
//! | (页面上还没有这个分组) | [`ai_about`] | ai+about 线:先建分组再接线 |
|
||||
//! | Updates | [`update`] | update 线(M159,按下标分发) |
|
||||
//! | Assistant | [`ai_about`](正文在 [`ai`]) | ai 线(M174,按下标分发:本组要 `Lang`) |
|
||||
//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) |
|
||||
//!
|
||||
//! ## 测试也按 section 分(M100)
|
||||
@@ -93,9 +94,11 @@
|
||||
//! (`split_keeps_every_translation` 逐条比 key + 英/中译文)。**注意**:`catalog.rs` 的 `Key`
|
||||
//! 枚举由 `catalog!` 宏从各域文件累积生成,加 key **不用**改 `catalog.rs`,也不用改
|
||||
//! `catalog/settings/mod.rs`(五个域已全部接进装配链)。
|
||||
//! 2. 本文件的 [`GROUPS`] —— 只有 ai+about 线**上第 5 组**时加一行(页面上多一组本来就是整页的变化)。
|
||||
//! 3. `tests/mod.rs` —— ai+about 线新建 `tests/ai_section.rs` 时要加一行 `mod ai_section;`
|
||||
//! (测试文件不挂 `mod` 就不编译),以及「左列顺序」那条字面量断言会连带红,需要人确认。
|
||||
//! 2. 本文件的 [`GROUPS`] —— 加一整组时加一行(M174 的助手组就是这么来的:`GROUPS` 与
|
||||
//! [`GROUP_LABELS`] 各加一行、长度 +1,`tests/mod.rs` 的「左列顺序」随之更新)。
|
||||
//! 3. `tests/mod.rs` —— 「左列顺序」那条字面量断言会连带红,需要人确认(M174 就是把它加了一个
|
||||
//! `"Assistant"`)。加分组自己的测试文件时还要在 `tests/mod.rs` 顶部挂一行 `mod <名>;`;
|
||||
//! M174 的用例走的是组内路径(`pages/settings/ai/tests.rs`),所以没有新增挂载行。
|
||||
//! 4. **读页外真值**(r2 复审 P1 补的)—— [`States`] 只有 `appearance` + `groups` 两格,组正文读不到
|
||||
//! `pages::Model`(「当前语言」的值在 `Model.lang`,`pages/model.rs`),也读不到 presenter 的设置
|
||||
//! 快照。两条路的**真实成本**(`States` 没有 `Default`,加字段必然 E0063,已实测):
|
||||
@@ -114,7 +117,7 @@
|
||||
//! 与 M106 同形状),要么读 `Groups` 里本线的字段。两种形状都是「谁先撞谁付一次钱」的一次性代价,
|
||||
//! 不是永久性四路撞车。
|
||||
//! 5. **首帧取数 / 异步发起**(同上,r2 复审 P1 补)—— [`Body`] 是
|
||||
//! `fn(&Ui, States) -> Option<Element>`,返回不了 `Task`;而且 `settings::view` → `pages::render`
|
||||
//! `fn(&Ui, States, Lang) -> Option<Element>`,返回不了 `Task`;而且 `settings::view` → `pages::render`
|
||||
//! → `App::view` **一路都返回 `Element`**(iced 0.14 的 `view` 也只返回 `Element`,`view(&self)`
|
||||
//! 又改不了状态),**view 期间产出的 `Task` 没有出口**。所以只有一条路:在 `app/update.rs` 的
|
||||
//! `Message::Boot` 分支补一次首帧派发(**1 个共享文件**——`on_boot` 本来就在派
|
||||
@@ -132,6 +135,7 @@
|
||||
// 四条填页线的文件要能被**各自那条落点**(`app/update/settings/<线>.rs`)与状态架子
|
||||
// (`state::Groups`)命名,所以是 `pub(crate)`:最小面,不是 crate 公开 API。
|
||||
// 分组正文文件 `appearance` / `frame` / `general` 只在设置页内部用,保持私有。
|
||||
pub(crate) mod ai;
|
||||
pub(crate) mod ai_about;
|
||||
mod appearance;
|
||||
pub(crate) mod download;
|
||||
@@ -189,13 +193,16 @@ impl Default for Language {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态),
|
||||
/// 返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下没有外观状态),
|
||||
/// 由 [`form`] 退回演示行。
|
||||
/// 一个分组正文的**真控件渲染器**:入参是「画一帧时那份只读状态」(组状态 + 外观状态)
|
||||
/// 与**这一次生效的语言**,返回 `None` = 这一组现在没东西可画(例如外观分组在 harness 路径下
|
||||
/// 没有外观状态),由 [`form`] 退回演示行。
|
||||
///
|
||||
/// 各组自己的渲染器**定义在自己文件里**,签名是这一条:于是「这一组要读哪些状态」由它自己决定,
|
||||
/// 本文件不需要认识任何一组的字段(r1 复审 P1 要的正是这一点)。
|
||||
type Body = for<'a> fn(&'a Ui, States<'a>) -> Option<Element<'a, Message>>;
|
||||
///
|
||||
/// `lang` 是 M179 加的:槽位此前拿不到语言,唯一挂了真控件的外观组只能写死英文;接进**唯一的那个
|
||||
/// 槽位**比再给 [`form`] 加一条按下标分发的 `else if` 更省行数(本文件贴着 400 行硬顶)。
|
||||
type Body = for<'a> fn(&'a Ui, States<'a>, Lang) -> Option<Element<'a, Message>>;
|
||||
|
||||
/// 一个设置分组:名字、图标、演示行、(可选)把这一组正文画成真控件的渲染器。
|
||||
///
|
||||
@@ -215,7 +222,7 @@ struct Group {
|
||||
/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。
|
||||
///
|
||||
/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。
|
||||
const GROUPS: [Group; 5] = [
|
||||
const GROUPS: [Group; 6] = [
|
||||
general::GROUP,
|
||||
appearance::GROUP,
|
||||
java::GROUP,
|
||||
@@ -223,6 +230,8 @@ const GROUPS: [Group; 5] = [
|
||||
// M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标
|
||||
// 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。
|
||||
update::GROUP,
|
||||
// M174:助手(AI)配置。同样只追加(下标 = `ai_about::AI_GROUP`),正文走按下标分发。
|
||||
ai_about::GROUP,
|
||||
];
|
||||
|
||||
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
|
||||
@@ -230,9 +239,9 @@ const SELECTED: usize = 0;
|
||||
|
||||
/// General 分组在 [`GROUPS`] 里的下标(0)。它与 [`SELECTED`] 同值**是巧合,不是依赖**。
|
||||
///
|
||||
/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 的签名只带
|
||||
/// [`States`](外观 + 组状态)——给 `States` 加字段要动 `settings/state.rs` 与 4 处 struct literal
|
||||
/// (见本文件文档「仍然共享」第 4 条),给 `Body` 加形参会令另外三组各自的 `body` 函数编译不过。
|
||||
/// 为什么需要它:语言行要的是「当前档位 + 生效语言」([`Language`]),而 `Group.body` 只带
|
||||
/// [`States`] 与**生效语言**(`Lang`,M179)——档位 `LangChoice` 装不进去(`States` 也装不下:
|
||||
/// 加字段要动 `settings/state.rs` 与 4 处 struct literal,见本文件文档「仍然共享」第 4 条)。
|
||||
/// 所以这里做一次**按下标的分发**:代价是一行 `if`,换来 `general::GROUP.body` 仍是 `None`
|
||||
/// (`tests/mod.rs` 的「只有外观组带真控件 body」照样成立)。
|
||||
const GENERAL_GROUP: usize = 0;
|
||||
@@ -241,13 +250,15 @@ const GENERAL_GROUP: usize = 0;
|
||||
///
|
||||
/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过——
|
||||
/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。
|
||||
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [
|
||||
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 6] = [
|
||||
unlucky_i18n::Key::SettingsGroupGeneral,
|
||||
unlucky_i18n::Key::SettingsGroupAppearance,
|
||||
unlucky_i18n::Key::SettingsGroupJava,
|
||||
unlucky_i18n::Key::SettingsGroupDownloads,
|
||||
// M159
|
||||
unlucky_i18n::Key::SettingsGroupUpdate,
|
||||
// M174
|
||||
unlucky_i18n::Key::SettingsGroupAssistant,
|
||||
];
|
||||
|
||||
/// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。
|
||||
@@ -256,6 +267,19 @@ pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [
|
||||
/// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。
|
||||
const UPDATE_GROUP: usize = update::UPDATE_GROUP;
|
||||
|
||||
/// 「助手(AI)」分组在 [`GROUPS`] 里的下标(真源在 `settings/ai_about.rs`,这里只转发)。
|
||||
///
|
||||
/// 与 [`GENERAL_GROUP`] / [`UPDATE_GROUP`] 同一条做法:由 `view` 的 `language` 形参递到 [`form`]
|
||||
/// 按本下标分发,`GROUP.body` 保持 `None`(本组**尚未**迁到 [`Body`] 槽位:槽位现在带 `Lang` 了,
|
||||
/// 迁它要把 `ai::view::body` 的返回值改成 `Option<Element>`,是一次独立重构——M179 不做)。
|
||||
const AI_GROUP: usize = ai_about::AI_GROUP;
|
||||
|
||||
/// 「Java」分组在 [`GROUPS`] 里的下标(真源在 `settings/java.rs`,这里只转发)。
|
||||
///
|
||||
/// M175 起这一组也按下标分发:它要 `Lang`(中英文案)并且要读后台扫描的快照
|
||||
/// (扫描线程不碰 `App`,结论由心跳灌进 `Groups.java`,页面只从 [`States`] 里取)。
|
||||
const JAVA_GROUP: usize = java::JAVA_GROUP;
|
||||
|
||||
/// 整页。
|
||||
///
|
||||
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
|
||||
@@ -292,9 +316,15 @@ pub fn view<'a>(
|
||||
/// 表单列(正文列宽 ≤424)。`only = Some(i)` 只渲染一组(supporting-pane),
|
||||
/// `None` 渲染全部(窄窗:分组导航退化成按组堆叠)。
|
||||
///
|
||||
/// **分发点**:一组的正文由它自己文件里的 `GROUP.body` 决定——`Some(f)` 让 `f` 自己去
|
||||
/// [`States`] 里取它要的数据(`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行
|
||||
/// ([`frame::demo_card`])。这里不出现任何一个分组名,也不比较下标,更不认识任何一组的字段。
|
||||
/// **分发点(两条路,M106 起就并存)**:
|
||||
///
|
||||
/// 1. `GROUP.body`——`Some(f)` 让 `f` 自己去 [`States`] 里取它要的数据、并从形参拿**生效语言**
|
||||
/// (`None` 返回值 ⇒ 退回演示行),`None` 直接画演示行([`frame::demo_card`]);
|
||||
/// 2. **按下标分发**——General(要语言档位)、Update(要 presenter 快照)、Assistant(要 `Lang`
|
||||
/// 与测试结论)、Java(要 `Lang` 与扫描槽)四组在下面各有 `else if`,它们的 `GROUP.body`
|
||||
/// 仍是 `None`(理由见各自的 `<组>_GROUP` 常量)。
|
||||
///
|
||||
/// 除此之外这里不认识任何一组的字段:分组名只作为 `GROUPS` 元素的 id 出现。
|
||||
fn form<'a>(
|
||||
ui: &'a Ui,
|
||||
only: Option<usize>,
|
||||
@@ -321,10 +351,16 @@ fn form<'a>(
|
||||
} else if index == UPDATE_GROUP {
|
||||
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
|
||||
update::body(ui, update, language.effective)
|
||||
} else if index == AI_GROUP {
|
||||
// M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。
|
||||
ai::view::body(ui, states, language.effective)
|
||||
} else if index == JAVA_GROUP {
|
||||
// M175:Java 组要 i18n 文案 + 扫描快照,同样按下标分发(`GROUP.body` 保持 `None`)。
|
||||
java::view::body(ui, states, language.effective)
|
||||
} else {
|
||||
group
|
||||
.body
|
||||
.and_then(|body| body(ui, states))
|
||||
.and_then(|body| body(ui, states, language.effective))
|
||||
.unwrap_or_else(|| frame::demo_card(ui, group.rows))
|
||||
};
|
||||
|
||||
|
||||
@@ -2,12 +2,25 @@
|
||||
//! 一条 section 断言都没有;这 3 条是新增,不是搬移)。
|
||||
//!
|
||||
//! **归 language+appearance 线**:这是设置页唯一接了真控件的一组,真控件实现全在
|
||||
//! `pages/appearance/section.rs`;本文件只守「注册表这一侧的接线」。
|
||||
//! `pages/appearance/section/`;本文件只守「注册表这一侧的接线」。
|
||||
//! 本组已经把 `body` 接上了;`tests/mod.rs` 那条「只有一组有真控件」的**聚合绊线已在 M101 r2 删除**
|
||||
//! (被四条 per-group 断言完全蕴含——代价是「唯一性」从此没人守,那段说明写在 `tests/mod.rs` 同处)。
|
||||
//!
|
||||
//! M179 `I18NSEAM` 加的第 4 条守的是**语言真的进到了渲染层**(此前这一组在中文界面上 0/27 条本地化)。
|
||||
|
||||
use super::super::appearance;
|
||||
use super::super::{demo_groups, view, Language, States};
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_i18n::catalog::Key;
|
||||
use unlucky_i18n::{Lang, LangChoice};
|
||||
use unlucky_presenter::prefs::{AppearanceBase, AppearancePrefs};
|
||||
|
||||
use crate::cli::Backend;
|
||||
use crate::pages::appearance::{probe, State as AppearanceState};
|
||||
use crate::pages::layout::{frame_metrics, PaneKit};
|
||||
use crate::pages::Update;
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
/// 这一组的**演示行**(没有外观运行期状态时才画)。
|
||||
///
|
||||
@@ -50,3 +63,109 @@ fn the_appearance_group_keeps_a_real_control_body() {
|
||||
`body` 是本文件那个本地 fn;具体渲染器由那一行源码 + 截图担保,函数指针相等性断言不了)"
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 语言(M179 I18NSEAM)
|
||||
|
||||
/// **本组的语言绊线**:中文界面下,外观分组渲染出来的字里**必须含汉字**。
|
||||
///
|
||||
/// 走的是真路:`settings::view`(真语言分发)→ `Group.body(lang)` → `pages::appearance::section`
|
||||
/// → `section::t(key, lang)`;取词口 `t` 把每条译文记进 `pages::appearance::probe`,测试断言这批字。
|
||||
///
|
||||
/// 为什么不在 `Element` 上遍历:iced 0.14 **没有**可遍历控件树的公开接口(`Widget::children()`
|
||||
/// 返回的是状态 `Tree`;走 `operate` 要 `Tree` + 布局 + `Renderer`,单测里没有渲染器)。取证点
|
||||
/// 因此退到「字进树之前」的唯一取词口,代价是**看不见像素**——字体能不能画出汉字要截图判
|
||||
/// (M179 报告「没验的部分」列了这条)。
|
||||
///
|
||||
/// 抓得住:① `section::t` 退回写死 `Lang::En`(M179 之前正是这一行 ⇒ 中文界面上这一组 0/27
|
||||
/// 条本地化);② `settings::view` 不把 `language.effective` 递给 `Group.body`(语言到不了渲染层);
|
||||
/// ③ 五个 key 的译文在金表里丢了中文。
|
||||
/// 抓不住:本组其余文案仍是英文**字面量**(没有 key,M172 口径的 27 条里占 22 条)——这条守的是
|
||||
/// 语言的**管子**,不是文案;补文案是下一条 mission(M179 报告逐行列了那些字面量)。
|
||||
#[test]
|
||||
fn the_appearance_group_speaks_chinese_when_the_interface_does() {
|
||||
let zh = appearance_texts(Lang::ZhCn);
|
||||
assert!(
|
||||
!zh.is_empty(),
|
||||
"外观分组一条词表文案都没取到:渲染路径没走到 `section::t`(语言管子断了?)"
|
||||
);
|
||||
assert!(
|
||||
zh.iter()
|
||||
.any(|(key, _)| *key == Key::SettingsAppearanceCorner),
|
||||
"渲染路径没画到「窗口圆角」那一行:取词记录里没有它的 key,{zh:?}"
|
||||
);
|
||||
|
||||
let han: Vec<&str> = zh
|
||||
.iter()
|
||||
.map(|(_, text)| *text)
|
||||
.filter(|text| text.chars().any(is_han))
|
||||
.collect();
|
||||
assert!(
|
||||
!han.is_empty(),
|
||||
"中文界面下外观分组渲染出来的字全是非汉字(`section::t` 没跟随语言):{zh:?}"
|
||||
);
|
||||
|
||||
// 反向:英文界面下同一批字里不许出现汉字(否则「含汉字」可能来自写死的中文,而不是语言管子)。
|
||||
let en = appearance_texts(Lang::En);
|
||||
assert!(
|
||||
en.iter().all(|(_, text)| !text.chars().any(is_han)),
|
||||
"英文界面下外观分组出现了汉字:{en:?}"
|
||||
);
|
||||
let corner = |texts: &[(Key, &'static str)]| {
|
||||
texts
|
||||
.iter()
|
||||
.find(|(key, _)| *key == Key::SettingsAppearanceCorner)
|
||||
.map(|(_, text)| *text)
|
||||
};
|
||||
assert_ne!(
|
||||
corner(&zh),
|
||||
corner(&en),
|
||||
"同一个 key 在中英两种语言下取到了同一条译文:语言没生效"
|
||||
);
|
||||
}
|
||||
|
||||
/// 画一帧设置页(选中「外观」分组)并取走这一帧从词表取到的全部译文。
|
||||
fn appearance_texts(lang: Lang) -> Vec<(Key, &'static str)> {
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
let ui = Ui::new(SchemeFlavor::Light, FAMILIES);
|
||||
let state = AppearanceState::new(
|
||||
&AppearancePrefs::factory(),
|
||||
&AppearanceBase::factory(),
|
||||
Backend::Auto,
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
assert_eq!(
|
||||
state.selected_group,
|
||||
crate::pages::appearance::APPEARANCE_GROUP,
|
||||
"工厂态默认选中「外观」分组(否则这一帧根本画不到本组)"
|
||||
);
|
||||
// 前面几条用例不取词,这里先清一次,避免把别的渲染留下的记录算进来。
|
||||
probe::take();
|
||||
|
||||
let metrics = frame_metrics(PaneKit::Supporting, 1180.0);
|
||||
let update = Update::default();
|
||||
let _tree = view(
|
||||
&ui,
|
||||
metrics,
|
||||
States {
|
||||
appearance: Some(&state),
|
||||
groups: demo_groups(),
|
||||
},
|
||||
Language {
|
||||
choice: LangChoice::System,
|
||||
effective: lang,
|
||||
},
|
||||
&update,
|
||||
);
|
||||
|
||||
probe::take()
|
||||
}
|
||||
|
||||
/// 有没有汉字(判据与 `app/view/tests/launch.rs` 的 `is_cjk` 同一段码位)。
|
||||
fn is_han(ch: char) -> bool {
|
||||
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
//! 假值不许回来,语言行的三档必须真的能在两套目录里取到词。
|
||||
//!
|
||||
//! 接线形状:语言行的正文由 [`general::body`] 画(入口是 `pages::settings` 的 `GENERAL_GROUP`
|
||||
//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位的签名是 `fn(&Ui, States) -> Option<Element>`,
|
||||
//! `States` 只有 `appearance` + `groups` 两格,装不下「当前语言档位」。所以 M101 r2 的
|
||||
//! 按下标分发),`GROUP.body` 仍是 `None`——那个槽位现在带**生效语言**(`Lang`,M179)但仍是
|
||||
//! `States` + 语言,装不下「当前语言档位」(`LangChoice`)。所以 M101 r2 的
|
||||
//! 「本组接真控件只需改两个文件」这条口径,在语言行上**仍然是两个文件**。
|
||||
|
||||
use super::super::general;
|
||||
@@ -35,6 +35,82 @@ fn the_general_group_keeps_theme_startup_and_close_as_static_rows() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **M168(D28 后半)**:中文字体下载状态行的四种状态都得有话说,且两套目录都不许 `⟪missing⟫`。
|
||||
///
|
||||
/// 抓得住:①只画「下载成功」一种(失败的用户会以为程序坏了);②失败态的文案写成「保持英文」
|
||||
/// (D28 的实际行为是**继续用系统字体**,界面语言不变);③占位符没被替换(用户会看到 `{family}`);
|
||||
/// ④新 key 只进了英文表(中文界面上出现 `⟪missing⟫`)。
|
||||
/// 抓不住:真下载时的样子(那要联网)、字体有没有真的出字(截图 + 真机,见报告)。
|
||||
#[test]
|
||||
fn the_font_status_row_speaks_in_four_states_and_two_languages() {
|
||||
use crate::fonts::install::{CjkInstall, FetchClass};
|
||||
|
||||
let states = [
|
||||
CjkInstall::Downloading { bytes: 17_772_300 },
|
||||
CjkInstall::Ready {
|
||||
family: "Noto Sans SC".to_owned(),
|
||||
from_cache: true,
|
||||
},
|
||||
CjkInstall::Failed {
|
||||
class: FetchClass::Network,
|
||||
message: "timeout".to_owned(),
|
||||
},
|
||||
CjkInstall::System {
|
||||
family: "Microsoft YaHei".to_owned(),
|
||||
},
|
||||
CjkInstall::NotNeeded,
|
||||
CjkInstall::Unprobeable,
|
||||
CjkInstall::Unknown,
|
||||
];
|
||||
|
||||
for lang in Lang::ALL {
|
||||
let catalog = lang.catalog();
|
||||
assert!(
|
||||
catalog.get(Key::FontCjkStatusLabel).is_some(),
|
||||
"{} 目录里缺中文字体那一行的标签",
|
||||
lang.tag()
|
||||
);
|
||||
for state in &states {
|
||||
let (value, hint) = general::font_status(&catalog, state);
|
||||
assert!(!value.is_empty(), "{state:?} 没话说");
|
||||
assert!(!value.contains('{'), "{state:?} 占位符没替换:{value}");
|
||||
assert!(
|
||||
!value.contains(unlucky_i18n::catalog::MISSING),
|
||||
"{state:?} 在 {} 目录里缺译:{value}",
|
||||
lang.tag()
|
||||
);
|
||||
if let Some(hint) = hint {
|
||||
assert!(!hint.contains('{'), "{state:?} 的提示没替换:{hint}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 失败态必须说清三类原因之一(而不是一句「失败了」)。
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
for (class, reason) in [
|
||||
(FetchClass::Network, "网络不通"),
|
||||
(FetchClass::Integrity, "镜像内容校验不通过"),
|
||||
(FetchClass::Disk, "写不进数据目录"),
|
||||
] {
|
||||
let (value, _) = general::font_status(
|
||||
&zh,
|
||||
&CjkInstall::Failed {
|
||||
class,
|
||||
message: "x".to_owned(),
|
||||
},
|
||||
);
|
||||
assert!(value.contains(reason), "失败原因没进文案:{value}");
|
||||
}
|
||||
|
||||
// 下载中写的是**真实**字节数(17 772 300 B → 16.9 MiB),不是假百分比。
|
||||
let (value, _) = general::font_status(&zh, &CjkInstall::Downloading { bytes: 17_772_300 });
|
||||
assert!(
|
||||
value.contains("16.9 MB"),
|
||||
"17 772 300 B ≈ 16.9 MiB:{value}"
|
||||
);
|
||||
assert!(!value.contains('%'), "没有真进度就不许写百分比:{value}");
|
||||
}
|
||||
|
||||
/// 左列图标(`IconKind::Settings`)。分组图标是左列的语义标签,换掉就是换观感。
|
||||
#[test]
|
||||
fn the_general_group_keeps_its_icon() {
|
||||
@@ -111,8 +187,8 @@ fn the_language_row_offers_three_choices_in_both_languages() {
|
||||
/// **绊线**:这一组仍不走 `Group.body`。
|
||||
///
|
||||
/// 为什么不是「接了真控件」:语言行的入口是 `pages::Language`(`GENERAL_GROUP` 那条分发),
|
||||
/// 而 `Group.body` 那个槽位的签名是 `fn(&Ui, &pages::appearance::State)`——装不下「当前语言档位」。
|
||||
/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到另外三组的文件),这条断言逼他先想一遍。
|
||||
/// 而 `Group.body` 那个槽位只拿到 `States` 与**生效语言**(`Lang`,M179)——装不下「当前语言档位」。
|
||||
/// 谁把语言行塞进 `body`,就得先给它换签名(那会改到别人那个组的文件),这条断言逼他先想一遍。
|
||||
#[test]
|
||||
fn the_general_group_does_not_use_the_appearance_body_slot() {
|
||||
assert!(
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
//! 「Java」分组自己的断言(M100 **新增**:`settings/tests.rs` 里一条 section 断言都没有,
|
||||
//! 那 7 条全是跨 section 的;所以这 3 条是新增,不是搬移)。
|
||||
//! 「Java」分组自己的断言(M100 起归 java 线;M175 重写)。
|
||||
//!
|
||||
//! **归 java 线**。接真控件那天要改**两个**文件(M101 r2 删掉了原先的第 3 处跨 section 绊线,
|
||||
//! 见 `tests/mod.rs` 的 `every_line_can_be_wired_alone` 说明):
|
||||
//! 1. `settings/java.rs`(`body = Some(java::body)` + 渲染器);
|
||||
//! 2. 本文件 `the_java_group_is_still_demo_rows`(把 `is_none()` 改成 `is_some()`)。
|
||||
//! M101 的三条里有两条在 M175 换掉了:
|
||||
//!
|
||||
//! download / general 同型。
|
||||
//! | 旧断言 | M175 之后 |
|
||||
//! |---|---|
|
||||
//! | `the_java_group_shows_runtime_memory_and_jvm_arguments`(逐字钉住 `Temurin 21.0.4` / `4 GiB`) | 换成「演示行必须是空的」——那三行假数据正是用户实测投诉的源头 |
|
||||
//! | `the_java_group_is_still_demo_rows`(断言 `body.is_none()`) | 保留(`body` 仍是 `None`:本组走按下标分发,见 `settings/java.rs` 的文件头) |
|
||||
//! | `the_java_group_keeps_the_folder_icon_from_the_visual_review` | 不动 |
|
||||
//!
|
||||
//! 判据(M100 交付里正面回答的那条):给 java 加一条**新断言** = **只改本文件**。
|
||||
//! 真数据源的断言在 `pages/settings/java/tests.rs`(扫描 / 校验 / 落盘 / 真命令行),
|
||||
//! 这里只守「左列那一格」——两处的分工与 M100 的 section 划分一致。
|
||||
|
||||
use super::super::java;
|
||||
use unlucky_design::icons::IconKind;
|
||||
|
||||
/// 这一组的行集合(标签 + 值)。改文案就是改观感,所以逐条钉住,而不是「非空就行」。
|
||||
/// 这一组**不再有演示行**:三个假值(运行时 / 内存 / JVM 参数)已经换成真控件。
|
||||
///
|
||||
/// 抓得住:① 四条线并行合并时把别人的行粘到本文件(四份 `GROUP` 长得几乎一样,最容易整块贴错);
|
||||
/// ② 漏掉/多出一条行;③ 标签与值错位(`("Default memory", "4 GiB")` 被写成 `"8 threads"`)。
|
||||
/// 抓不住:值对不对(`Temurin 21.0.4` 是不是本机真检测到的运行时)——那是接线后真数据源的事。
|
||||
/// 抓得住:谁把演示行加回来(那会让 harness 路径又画出一份「看起来像真检测」的假数据);
|
||||
/// 也抓得住「接真控件却把 `rows` 留着」这种半吊子状态。
|
||||
/// 抓不住:真控件里画了什么(那是 `pages/settings/java/tests.rs` 的事)。
|
||||
#[test]
|
||||
fn the_java_group_shows_runtime_memory_and_jvm_arguments() {
|
||||
let rows: Vec<(&str, &str)> = java::GROUP.rows.to_vec();
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
("Runtime", "Temurin 21.0.4"),
|
||||
("Default memory", "4 GiB"),
|
||||
("JVM arguments", "Automatic"),
|
||||
]
|
||||
fn the_java_group_has_no_demo_rows_left() {
|
||||
assert!(
|
||||
java::GROUP.rows.is_empty(),
|
||||
"Java 组不该再有演示行(真值来自扫描 / 手动路径 / launcher.json):{:?}",
|
||||
java::GROUP.rows
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,17 +38,15 @@ fn the_java_group_keeps_the_folder_icon_from_the_visual_review() {
|
||||
assert_eq!(java::GROUP.icon, IconKind::Folder);
|
||||
}
|
||||
|
||||
/// **绊线**:这一组还没接真控件,画的是演示行。
|
||||
/// `body` 槽位仍是 `None`:本组靠 `settings/mod.rs` 的按下标分发([`java::JAVA_GROUP`])画正文。
|
||||
///
|
||||
/// 抓得住:java 线接上了 `body` 却没更新这里的期望(那会让本文件静默变成假绿)。
|
||||
/// 抓不住:界面上真控件长什么样。
|
||||
///
|
||||
/// 接真控件时:把 `is_none()` 改成 `is_some()`,**只改这一行**(M101 r2 起不必再去
|
||||
/// `tests/mod.rs` 报到:那条跨 section 聚合绊线已删,见那里的 `every_line_can_be_wired_alone`)。
|
||||
/// 抓得住:谁把 `body: Some(..)` 挂上来(M179 之后槽位自带 `Lang`,所以「挂了 body 却没语言」
|
||||
/// 这种漂移不再可能——挂上来就必然拿到语言;这条守的是**分发形状**别变)。
|
||||
/// 抓不住:分发那一行本身有没有被删(`settings/mod.rs` 的 `form` 里有 `else if`;删了这组就空白)。
|
||||
#[test]
|
||||
fn the_java_group_is_still_demo_rows() {
|
||||
fn the_java_group_is_dispatched_by_index_not_by_body() {
|
||||
assert!(
|
||||
java::GROUP.body.is_none(),
|
||||
"Java 还没接真控件:接了就改本断言"
|
||||
"Java 组走按下标分发(要 Lang),body 必须保持 None"
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ mod appearance_section;
|
||||
mod download_section;
|
||||
mod general_section;
|
||||
mod java_section;
|
||||
mod scroll_fit;
|
||||
mod update_section;
|
||||
|
||||
use super::super::layout::{self, frame_metrics, measure_cap_px, PaneKit, SIDE_NAV_WIDTH};
|
||||
@@ -87,18 +88,30 @@ fn narrow_window_stacks_the_groups_instead_of_the_side_nav() {
|
||||
|
||||
// --------------------------------------------------------------- 注册表(四组一起看)
|
||||
|
||||
/// 守的是**注册表的结构不变量**:每组都有名字、图标、演示行,且演示下标在范围内。
|
||||
/// 守的是**注册表的结构不变量**:每组都有名字、图标,且「有真控件分发」或「有演示行」至少占一样。
|
||||
///
|
||||
/// 下放不了:它遍历的是 `GROUPS`(四组拼出来的数组),单组文件拿不到其它三组,
|
||||
/// 而「新增一组忘了填 rows」正是这条要抓的。
|
||||
/// 下放不了:它遍历的是 `GROUPS`,单组文件拿不到其它组,而「新增一组忘了填 rows、又没有分发」
|
||||
/// 正是这条要抓的。
|
||||
///
|
||||
/// M175 起 Java 组的 `rows` 是**空**的:它那三行演示值(`Temurin 21.0.4` / `4 GiB` /
|
||||
/// `Automatic`)正是用户实测投诉的假数据源,真控件接上之后演示行就删干净了。所以判据从
|
||||
/// 「必须有 rows」放宽成「必须有 rows **或** 有别的分发路径」——按下标分发的那几组
|
||||
/// (general / update / assistant / java)在 `settings/mod.rs` 的 `form` 里各有 `else if`,
|
||||
/// 本断言只对「既没有 rows 又不在这张名单里」的组报错。
|
||||
#[test]
|
||||
fn every_group_has_a_name_icon_and_rows() {
|
||||
assert!(GROUPS.len() >= 3);
|
||||
assert!(SELECTED < GROUPS.len());
|
||||
// 按下标分发(`settings/mod.rs` 的 `form`):这些组的正文不在 `Group.rows` 里。
|
||||
const INDEX_DISPATCHED: [&str; 4] = ["General", "Updates", "Assistant", "Java"];
|
||||
for group in &GROUPS {
|
||||
assert!(!group.name.is_empty());
|
||||
assert!(!group.icon.icon().path.is_empty(), "{} 缺图标", group.name);
|
||||
assert!(!group.rows.is_empty(), "{} 没有设置行", group.name);
|
||||
assert!(
|
||||
!group.rows.is_empty() || INDEX_DISPATCHED.contains(&group.name),
|
||||
"{} 既没有设置行,也不在按下标分发的名单里",
|
||||
group.name
|
||||
);
|
||||
for (label, value) in group.rows {
|
||||
assert!(!label.is_empty() && !value.is_empty());
|
||||
}
|
||||
@@ -131,7 +144,15 @@ fn the_split_kept_the_left_column_order() {
|
||||
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec!["General", "Appearance", "Java", "Downloads", "Updates"]
|
||||
vec![
|
||||
"General",
|
||||
"Appearance",
|
||||
"Java",
|
||||
"Downloads",
|
||||
"Updates",
|
||||
// M174:助手(AI)配置只追加在末尾
|
||||
"Assistant",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,9 +163,10 @@ fn the_split_kept_the_left_column_order() {
|
||||
/// 抓不住:各组的**字段**(那些活在各组自己的文件里,本文件本来就不该知道,也就没有可断言的东西)。
|
||||
///
|
||||
/// **M106 的例外要读清楚**:General 已经接了真控件(语言行),但它**不走 `Group.body`**——
|
||||
/// 那个槽位的签名是 `fn(&Ui, States) -> Option<Element>`,`States` 只有 `appearance` + `groups`
|
||||
/// 两格,装不下「当前语言档位 + 生效语言」。它走 `settings::view` 的 `language` 形参 +
|
||||
/// `GENERAL_GROUP` 那条按下标分发,`body` 仍是 `None`,所以本断言不动。
|
||||
/// 那个槽位的签名是 `fn(&Ui, States, Lang) -> Option<Element>`(`Lang` 是 M179 加的),
|
||||
/// `States` 只有 `appearance` + `groups` 两格、槽位只有**生效语言**,都装不下「当前语言档位 +
|
||||
/// 生效语言」这一对。它走 `settings::view` 的 `language` 形参 + `GENERAL_GROUP` 那条按下标分发,
|
||||
/// `body` 仍是 `None`,所以本断言不动。
|
||||
/// 判据没变:**`body: Some(..)` 仍然只有外观组**。
|
||||
#[test]
|
||||
fn the_settings_module_does_not_name_any_group_state() {
|
||||
@@ -197,6 +219,10 @@ fn the_settings_module_does_not_name_any_group_state() {
|
||||
fn every_line_can_be_wired_alone() {
|
||||
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
|
||||
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
|
||||
assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组");
|
||||
assert_eq!(
|
||||
GROUPS.len(),
|
||||
6,
|
||||
"四条填页线 + M159 的更新组 + M174 的助手组"
|
||||
);
|
||||
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! M173:设置页表单列的**滚动条槽**(滚动条并排,不压内容)。
|
||||
//!
|
||||
//! 这里守的是整页级别的数值契约,不是「哪一组有哪些行」——所以它不属于任何 section 文件。
|
||||
//!
|
||||
//! ## 怎么量
|
||||
//!
|
||||
//! iced 的控件树支持在测试里真跑布局:`iced_core` 在 debug 构建下给 `()` 实现了全套 `Renderer`
|
||||
//! trait(`iced_core-0.14.0/src/renderer/null.rs`),于是 `frame::scroll::<()>(…)` 返回的
|
||||
//! `Element<_, Message, Theme, ()>` 能被 `Widget::layout` 量出**真的**子节点尺寸。不需要开窗,
|
||||
//! 也不需要 GPU——这是「数值断言,不是看图」的落点。
|
||||
//!
|
||||
//! 节点树(`iced_core/src/layout.rs:201`:`padded` 恒为「自己 + 一个子节点」):
|
||||
//!
|
||||
//! ```text
|
||||
//! scrollable → [槽容器(Fill,右内边距 = 槽宽) → [内容]]
|
||||
//! ```
|
||||
//!
|
||||
//! ## 抓什么 / 抓不住什么
|
||||
//!
|
||||
//! 抓得住:内容右侧真的留出了「轨宽 + 两侧 margin」的槽;长/短页面(出/不出滑块)内容宽度与左
|
||||
//! 边界**逐项相同**;把 `app::components::scroll` 的 padding 改回 0,本文件立刻红。
|
||||
//! 抓不住:滑块画在槽里的**像素**位置(那由 `pages/settings` 的出图与 iced 自己的 `Rail::new`
|
||||
//! 公式担保,见 `src/components/scroll.rs` 文档里的源码行号)。
|
||||
|
||||
use iced::advanced::layout::Limits;
|
||||
use iced::advanced::widget::Tree;
|
||||
use iced::widget::{container, space};
|
||||
use iced::{Element, Length, Rectangle, Size, Theme};
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
use super::super::frame;
|
||||
use super::super::frame::{SCROLLBAR_MARGIN, SCROLLBAR_WIDTH};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
/// 设置页表单列宽:`frame_metrics` 的 `column`(= `measure_cap_px()`,1180 与 880 窗口下都是它)。
|
||||
const COLUMN: f32 = 424.0;
|
||||
/// 视口高(1180×760 减去标题栏 / 页头 / 上下页边距之后的量级;只要求「长页 > 它」「短页 < 它」)。
|
||||
const VIEWPORT_HEIGHT: f32 = 600.0;
|
||||
/// 长页面(正文远超视口 ⇒ 一定出滑块)。
|
||||
const LONG: f32 = 4000.0;
|
||||
/// 短页面(正文装得下 ⇒ 一定不出滑块)。
|
||||
const SHORT: f32 = 120.0;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 滚动条槽宽(dp):轨道 + 两侧留白,与 iced 的 `total_scrollbar_width` 同口径。
|
||||
fn slot() -> f32 {
|
||||
SCROLLBAR_WIDTH + 2.0 * SCROLLBAR_MARGIN
|
||||
}
|
||||
|
||||
/// 真跑一遍布局,返回 (槽容器, 内容) 两个矩形。
|
||||
fn laid_out(content_height: f32) -> (Rectangle, Rectangle) {
|
||||
let ui = ui();
|
||||
|
||||
let filler: Element<'_, Message, Theme, ()> = container(space())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(content_height))
|
||||
.into();
|
||||
|
||||
// 走的是**产品那条** `frame::scroll`(泛型只为换成 `()` 渲染器,几何一字不改)。
|
||||
let mut element = frame::scroll::<()>(&ui, filler);
|
||||
let mut tree = Tree::new(element.as_widget());
|
||||
|
||||
let node = element.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&(),
|
||||
&Limits::new(Size::ZERO, Size::new(COLUMN, VIEWPORT_HEIGHT)),
|
||||
);
|
||||
|
||||
let gutter = node.children()[0].bounds();
|
||||
let content = node.children()[0].children()[0].bounds();
|
||||
|
||||
(gutter, content)
|
||||
}
|
||||
|
||||
/// **本条 mission 的核心数值断言**:内容可用宽度 = 容器宽 − 滚动条占位。
|
||||
#[test]
|
||||
fn the_form_column_reserves_a_rail_slot_beside_the_content() {
|
||||
let (gutter, content) = laid_out(LONG);
|
||||
|
||||
assert_eq!(slot(), 10.0, "6 轨宽 + 2×2 内缩 = 10dp 的槽");
|
||||
assert_eq!(gutter.width, COLUMN, "槽容器自身仍是满宽");
|
||||
assert_eq!(
|
||||
content.width,
|
||||
COLUMN - slot(),
|
||||
"内容可用宽度必须是「容器宽 − 滚动条槽」,否则轨道就压在内容右缘上"
|
||||
);
|
||||
assert_eq!(content.width, 414.0, "424 − 10 = 414");
|
||||
}
|
||||
|
||||
/// 长页面(出滑块)与短页面(不出滑块)——内容宽度与左边界必须**逐项相同**。
|
||||
///
|
||||
/// 这是 M173 要求的「两张对照图 + 数值」里的数值那一半:只给一张出滑块的图说明不了「滑块出现时
|
||||
/// 内容会不会跳」,必须两态同框比。
|
||||
#[test]
|
||||
fn the_content_does_not_jump_between_a_long_and_a_short_page() {
|
||||
let (long_gutter, long) = laid_out(LONG);
|
||||
let (short_gutter, short) = laid_out(SHORT);
|
||||
|
||||
assert_eq!(long_gutter.x, short_gutter.x, "槽容器的左边界不许变");
|
||||
assert_eq!(long_gutter.width, short_gutter.width, "槽容器的宽度不许变");
|
||||
assert_eq!(long.x, short.x, "左边界不许横跳");
|
||||
assert_eq!(long.width, short.width, "宽度不许横跳");
|
||||
assert_eq!(short.x, 0.0, "槽只占右侧:左侧内边距为 0");
|
||||
assert_eq!(
|
||||
short.x + short.width,
|
||||
COLUMN - slot(),
|
||||
"内容右缘 = 轨道的左缘(再往右 10dp 才是轨道自己)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 反证:`content_width` 这条纯量与实际布局量出来的是同一个数(两处不许各算一套)。
|
||||
#[test]
|
||||
fn the_layout_agrees_with_the_pure_quantity() {
|
||||
let (_, content) = laid_out(LONG);
|
||||
assert_eq!(
|
||||
content.width,
|
||||
crate::app::components::scroll::content_width(COLUMN, slot())
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,20 @@ use super::super::{GROUPS, SELECTED};
|
||||
use crate::pages::update::UpdateStatus;
|
||||
use crate::pages::{Screen, Update};
|
||||
|
||||
/// 更新组**必须是最后一组**:加组只追加不重排,插到中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP`
|
||||
/// 两个下标一起失效。
|
||||
/// 更新组**下标固定为 4**:加组只追加不重排,插到它前面会让 `APPEARANCE_GROUP` / `GENERAL_GROUP`
|
||||
/// 两个下标一起失效,也会把更新组自己的 `UPDATE_GROUP` 指到别人身上。
|
||||
///
|
||||
/// M174 之后它**不再是最后一组**(助手组追加在它后面):这条断言因此从「len == 下标 + 1」
|
||||
/// 改成「下标 4 上就是更新组,且它是**倒数第二**组」——追加语义仍然成立,而「更新组被挤走」
|
||||
/// 照样会红。
|
||||
#[test]
|
||||
fn the_update_group_sits_at_the_end_of_the_registry() {
|
||||
assert_eq!(GROUPS.len(), UPDATE_GROUP + 1, "更新组之后不该还有别的组");
|
||||
assert_eq!(UPDATE_GROUP, 4, "更新组的下标不许变(加组只追加)");
|
||||
assert_eq!(
|
||||
GROUPS.len(),
|
||||
UPDATE_GROUP + 2,
|
||||
"更新组之后只该有 M174 的助手组"
|
||||
);
|
||||
assert_eq!(GROUPS[UPDATE_GROUP].name, UPDATE_GROUP_META.name);
|
||||
assert_eq!(GROUPS[UPDATE_GROUP].name, "Updates");
|
||||
assert!(SELECTED < GROUPS.len());
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! ## 为什么这一组不走 `Group.body`
|
||||
//!
|
||||
//! `Group.body` 的签名是 `fn(&Ui, States) -> Option<Element>`,而 `States` 只有「外观状态 + 各组
|
||||
//! 自己的状态」两格。更新状态是 **presenter 快照**(不是某一组的字段),与 M106 的语言行同一种
|
||||
//! 情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的 `update` 形参交给
|
||||
//! [`body`],`GROUP.body` 仍是 `None`。
|
||||
//! `Group.body` 的槽位拿的是 [`States`](super::States) 与**生效语言** `Lang`(M179 起),而更新状态
|
||||
//! 是 **presenter 快照**(`pages::Update`,不是某一组的字段、也不在 `States` 里),槽位装不下它。
|
||||
//! 与 M106 的语言行同一种情形:`settings/mod.rs` 按下标([`UPDATE_GROUP`])把 `settings::view` 的
|
||||
//! `update` 形参交给 [`body`],`GROUP.body` 仍是 `None`。
|
||||
//!
|
||||
//! 消息也**不**走 `GroupMessage`:更新动作是「跨页的一次性意图」(检查 / 下载安装 / 忽略 /
|
||||
//! 重启),落点是 `app/update/update_action.rs`,`pages::Message::Update(UpdateAction)` 一支就够。
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! 数据根派生:**字体缓存放哪** + **一条设置键读什么**(M168)。
|
||||
//!
|
||||
//! ## 为什么需要它
|
||||
//!
|
||||
//! 字体下载要把 17 MiB 落在数据根里(`<launcher_home>/cache/fonts/`),而「数据根在哪」这件事
|
||||
//! 已经有一个真源:`unlucky_presenter::settings_bridge::SettingsBridge::open()`(三模式数据根 +
|
||||
//! `core.*` 覆盖键 + 便携回落,见那边的 `resolve_config_path`)。本模块**不重写**那套解析,
|
||||
//! 只做两件它没暴露的事:
|
||||
//!
|
||||
//! 1. 由配置文件路径 `<home>/config/launcher.json` 反推 `<home>`,再用 [`LauncherLayout::cache`]
|
||||
//! 取缓存分区(分区名只从 core 的 `layout` 常量来,不手拼字符串);
|
||||
//! 2. 读一条**设置键**的生效值(`ui.font_cdn`):`SettingsBridge` 只暴露了 `ui.language` 的读写,
|
||||
//! 而 `ui.font_cdn` 是 M168 新增的键。
|
||||
//!
|
||||
//! ## 诚实边界
|
||||
//!
|
||||
//! - **抓得住**:路径推导的形状(`<home>/config/x` → `<home>/cache`)与「配置读不到时返回 `None`
|
||||
//! 而不是猜一个目录」;
|
||||
//! - **抓不住**:真机上 `SettingsBridge::open()` 解析出哪个根(那是 presenter + core 的用例),
|
||||
//! 以及 macOS `.app` 只读这类平台差异(没有真机)。
|
||||
//!
|
||||
//! ## 后续收口(已登记 finding)
|
||||
//!
|
||||
//! 更干净的做法是让 presenter 暴露一个通用的 `SettingsBridge::load_text(key)`,本模块就只剩
|
||||
//! 「布局 + 一条键」。presenter 不在 M168 的 scope 里,所以这里先按上面的方式落地。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
use unlucky_core::setting::document::SettingDocument;
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
/// 中文字体下载地址的覆盖键(M168;空 = 用内置镜像)。
|
||||
pub const FONT_CDN_KEY: &str = "ui.font_cdn";
|
||||
|
||||
/// 本进程的数据根布局(`None` = 数据根解析不出来,例如 exe 目录都拿不到)。
|
||||
///
|
||||
/// 解析只发生一次:配方来自 `SettingsBridge::open()`(它内部走 `DataDirRequest` + `Resolver`)。
|
||||
pub fn layout() -> Option<LauncherLayout> {
|
||||
static LAYOUT: std::sync::OnceLock<Option<LauncherLayout>> = std::sync::OnceLock::new();
|
||||
LAYOUT
|
||||
.get_or_init(|| {
|
||||
let config = SettingsBridge::open().path()?.to_path_buf();
|
||||
// `<home>/config/launcher.json` → `<home>`
|
||||
let home = config.parent()?.parent()?.to_path_buf();
|
||||
Some(LauncherLayout::new(home))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 字体缓存的根(`<cache>/fonts/` 的父目录;**不**等于字体目录本身)。
|
||||
///
|
||||
/// 用 `cache/` 而不是 `data/`:字体随时可删、删了会自动重下(D28 的「数据根」语义里,
|
||||
/// 这就是「可重建」的那一类)。
|
||||
pub fn cache_root() -> Option<PathBuf> {
|
||||
layout().map(|layout| layout.cache())
|
||||
}
|
||||
|
||||
/// 读一条设置键的**生效值**(schema 默认值也算;空串会被 `trim` 掉但仍是 `Some`)。
|
||||
///
|
||||
/// `None` = 配置读不到(数据根解析失败 / 文件损坏到 `SettingStore` 都装不起来)。
|
||||
pub fn setting_text(key: &str) -> Option<String> {
|
||||
let path = SettingsBridge::open().path()?.to_path_buf();
|
||||
let schema = launcher_schema();
|
||||
let store = SettingStore::load(&path, schema);
|
||||
let document: &SettingDocument = store.doc();
|
||||
document.effective_str(key).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// `ui.font_cdn` 的覆盖值(`None` = 没配 / 配了空串 ⇒ 用内置镜像)。
|
||||
pub fn font_cdn_override() -> Option<String> {
|
||||
let raw = setting_text(FONT_CDN_KEY)?;
|
||||
let trimmed = raw.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 键名与 core 的 schema 一致:写错一个字符,用户配了也不生效(而 UI 不会报错)。
|
||||
///
|
||||
/// 抓得住:本模块的常量与 `core::setting::keys` 里登记的键名对不上。
|
||||
/// 抓不住:真机上读到的值(那要数据根与配置文件)。
|
||||
#[test]
|
||||
fn the_cdn_key_is_registered_in_the_launcher_schema() {
|
||||
let schema = launcher_schema();
|
||||
assert!(
|
||||
schema.spec(FONT_CDN_KEY).is_some(),
|
||||
"{FONT_CDN_KEY} 没在 core 的 schema 里登记"
|
||||
);
|
||||
}
|
||||
|
||||
/// 布局推导的形状:`<home>/config/launcher.json` → `<home>/cache`。
|
||||
///
|
||||
/// 抓得住:有人把「上两级」写成「上一级」(缓存会落进 `config/` 里),或改用 `data/` 分区。
|
||||
/// 抓不住:真机上 `<home>` 解析成什么(presenter 的 `resolve_config_path` 管那一层)。
|
||||
#[test]
|
||||
fn the_cache_partition_is_the_sibling_of_config() {
|
||||
let config = PathBuf::from("/tmp/ull-home/config/launcher.json");
|
||||
let home = config.parent().unwrap().parent().unwrap().to_path_buf();
|
||||
let layout = LauncherLayout::new(home);
|
||||
assert_eq!(layout.cache(), PathBuf::from("/tmp/ull-home/cache"));
|
||||
assert_eq!(
|
||||
layout.cache().join("fonts"),
|
||||
PathBuf::from("/tmp/ull-home/cache/fonts")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 平台相关的窗口能力(P4 / D36 / M116 / M182):整窗透明度与窗口圆角的**判据**、唯一 `unsafe` 落点,
|
||||
//! 以及系统语言真值源,四件事分居四处。
|
||||
//! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182):整窗透明度与窗口圆角的**判据**、唯一 `unsafe`
|
||||
//! 落点、系统语言真值源、以及数据根派生,五件事分居五处。
|
||||
//!
|
||||
//! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄,
|
||||
//! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」;
|
||||
@@ -18,6 +18,7 @@
|
||||
//!
|
||||
//! | 模块 | 里面有什么 | 有没有 FFI |
|
||||
//! |---|---|---|
|
||||
//! | [`data`] | 数据根派生(字体缓存分区 + 一条设置键的生效值,M168) | 没有 |
|
||||
//! | [`host`] | [`HostOs`]:平台族的**值**(判据的输入,不是散落的 `cfg`) | 没有 |
|
||||
//! | [`macos`] | macOS 为什么是直角的三环证据 + 「迁移原生标题栏」的未实现配方 | 没有(本轮只有文档) |
|
||||
//! | [`linux`] | X11 / Wayland 谁负责圆角、为什么我们不自绘 | 没有(本轮只有文档) |
|
||||
@@ -31,6 +32,7 @@ use unlucky_presenter::prefs::WINDOW_ALPHA_MIN;
|
||||
|
||||
use crate::Backend;
|
||||
|
||||
pub mod data;
|
||||
pub mod host;
|
||||
pub mod linux;
|
||||
pub mod locale;
|
||||
|
||||
@@ -139,8 +139,12 @@ impl LangState {
|
||||
report.strategy,
|
||||
unlucky_i18n::font::Strategy::NeedsDownload(_)
|
||||
) {
|
||||
// M168:这里**不再**说「通道还没做」——下载在这一拍之后就已经发起(同一份报告交给
|
||||
// `fonts::install::after_probe`),结果与状态在 `[font] cjk-download …` 那几行里,
|
||||
// 也在设置页 General 组的状态行上。上一版这句 stderr 在下载发起后说「还是 PendingPin」,
|
||||
// 等于当面否定刚做的事(复审 r1 P2-3)。
|
||||
eprintln!(
|
||||
"[i18n] 缺中文字体:界面会是中文但字形可能缺失(D28 的下载通道还是 PendingPin)"
|
||||
"[i18n] 缺中文字体:已按 D28 发起下载,进度与结果见 [font] cjk-download … 与设置页「中文字体」那一行;失败会降级用系统字体"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user