From df331f6a6098f897e827a5502e6fbe89d693ae7e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 19:52:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(m175):=20Java=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=8E=A5=E7=9C=9F=E8=BF=90=E8=A1=8C=E6=97=B6=EF=BC=88?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=20/=20=E6=89=8B=E5=8A=A8=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=20/=20=E9=BB=98=E8=AE=A4=20JDK=20/=20=E5=86=85=E5=AD=98=20/=20?= =?UTF-8?q?JVM=20=E5=8F=82=E6=95=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户实测四条抱怨逐条对应: - 「没有自动检测本机 Java」:首屏自动扫一次盘(托管根 + JAVA_HOME + PATH + 标准安装位置 + 注册表),之后由「立即扫描」手动触发;扫描在后台线程里跑,带 done/total 进度与取消 - 「显示 Temurin 21.0.4 不知道是不是硬编码」:演示行与绊线测试一起删掉,列表来自真读盘 - 「没一个能改」:默认内存(MB,512~65536,范围与 schema 同源)与 JVM 参数可真改、真落盘、读回 - 「不能手动指定 Java 路径 / 不能改默认版本」:手动添加走严格校验(真跑 java -version), 设为默认写 java.selected_path 并给出「为什么选它」(当前实例的版本门槛 + 每行满足与否) core:java::manual(严格探测 + 厂商识别 + 名单持久化)、java::staged(候选收集 + 带进度与 取消的扫描)、java::proc(带超时且可打断的子进程执行:卡住的 java -version 会被 kill)。 i18n:catalog/settings/java.rs 55 条中英 + 金表按装配顺序插入。 复审 r1(p2-5items)全修: - P2-1 「设为默认」不再清脏:只写 java.selected_path,内存/JVM 草稿一个字节都不动 - P2-2 错误文案全部走 i18n(三档「写不进去」/「路径太长」/「名单写不进去」各自一个 key), 不再把 Rust 枚举名当人话 - P2-3 手动名单落盘有正面断言(加一条 → 从盘上新读一份 → 在) - P2-4 版本号显示到小版本(17.0.13 / 17.0.9 可区分),解析不出来时给原始版本串 - P2-5 每候选 8 秒超时并 kill、取消打断正在跑的探测、扫描槽挂在 State 上、线程活着期间 不许起第二个(界面显示「正在取消…」) 复审 r2(p2-2items)全修: - P2-1 空态不再指向不存在的「Java 页」,改成真实存在的 `ull java install 21` 命令行入口 - P2-2 `auto_scan_due` 增加 `!report.cancelled`:取消过一次之后本进程不再自动扫, 配置文件再变(存内存 / 改主题)也不会把取消无声撤销;手动「立即扫描」不受影响 - 顺带:proc 的 kill+wait 补「子进程真死了」的回归守卫(标记文件从未出现)、 core 的 Schema 错误改成页面自己的 key(不再把中文 Display 直出给英文界面)、 默认态 chip 用新的 `Chip::badge`(不再挂一条永远收不到的消息) --- crates/app-iced/src/app/state/construct.rs | 2 + crates/app-iced/src/app/subscription.rs | 2 + crates/app-iced/src/app/update/clock.rs | 5 + .../app-iced/src/app/update/settings/java.rs | 290 ++++++++++++- .../app-iced/src/app/update/settings/tests.rs | 12 +- .../src/app/update/settings/tests/java.rs | 163 ++++++++ .../app-iced/src/components/selection/chip.rs | 16 + crates/app-iced/src/pages/settings/java.rs | 94 +++-- .../src/pages/settings/java/fields.rs | 246 +++++++++++ .../app-iced/src/pages/settings/java/row.rs | 191 +++++++++ .../app-iced/src/pages/settings/java/seed.rs | 75 ++++ .../app-iced/src/pages/settings/java/state.rs | 374 +++++++++++++++++ .../app-iced/src/pages/settings/java/store.rs | 393 ++++++++++++++++++ .../app-iced/src/pages/settings/java/tests.rs | 242 +++++++++++ .../src/pages/settings/java/tests/cmdline.rs | 262 ++++++++++++ .../src/pages/settings/java/tests/runtime.rs | 318 ++++++++++++++ .../app-iced/src/pages/settings/java/view.rs | 284 +++++++++++++ crates/app-iced/src/pages/settings/mod.rs | 21 +- .../src/pages/settings/tests/java_section.rs | 52 +-- .../app-iced/src/pages/settings/tests/mod.rs | 20 +- crates/core/src/game/java/manual.rs | 324 +++++++++++++++ crates/core/src/game/java/mod.rs | 3 + crates/core/src/game/java/proc.rs | 169 ++++++++ crates/core/src/game/java/staged.rs | 347 ++++++++++++++++ crates/i18n/src/catalog/golden/tail.rs | 59 +++ crates/i18n/src/catalog/settings/java.rs | 151 ++++++- 26 files changed, 4030 insertions(+), 85 deletions(-) create mode 100644 crates/app-iced/src/app/update/settings/tests/java.rs create mode 100644 crates/app-iced/src/pages/settings/java/fields.rs create mode 100644 crates/app-iced/src/pages/settings/java/row.rs create mode 100644 crates/app-iced/src/pages/settings/java/seed.rs create mode 100644 crates/app-iced/src/pages/settings/java/state.rs create mode 100644 crates/app-iced/src/pages/settings/java/store.rs create mode 100644 crates/app-iced/src/pages/settings/java/tests.rs create mode 100644 crates/app-iced/src/pages/settings/java/tests/cmdline.rs create mode 100644 crates/app-iced/src/pages/settings/java/tests/runtime.rs create mode 100644 crates/app-iced/src/pages/settings/java/view.rs create mode 100644 crates/core/src/game/java/manual.rs create mode 100644 crates/core/src/game/java/proc.rs create mode 100644 crates/core/src/game/java/staged.rs diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index cd262a7..231d64b 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -151,6 +151,8 @@ impl App { 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(); diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 15393d6..1511ebb 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -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) diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index 3a7971e..31f9aed 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -49,6 +49,9 @@ impl App { 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)); @@ -77,6 +80,8 @@ impl App { 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 { diff --git a/crates/app-iced/src/app/update/settings/java.rs b/crates/app-iced/src/app/update/settings/java.rs index a8f3fe7..7747a6f 100644 --- a/crates/app-iced/src/app/update/settings/java.rs +++ b/crates/app-iced/src/app/update/settings/java.rs @@ -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 { + 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=` 用种子(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 { + 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::>(); + 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 } } } diff --git a/crates/app-iced/src/app/update/settings/tests.rs b/crates/app-iced/src/app/update/settings/tests.rs index 248897f..8d42d94 100644 --- a/crates/app-iced/src/app/update/settings/tests.rs +++ b/crates/app-iced/src/app/update/settings/tests.rs @@ -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!( @@ -269,7 +273,7 @@ fn a_group_message_reaches_its_handler_through_the_funnel() { // --------------------------------------------------------------- M174:助手配置的活路径 /// 一个指向临时目录的设置桥(真文件,不是内存态)。 -fn bridged_app(label: &str) -> (App, std::path::PathBuf) { +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(), @@ -386,3 +390,7 @@ fn testing_without_configuration_does_nothing() { ); 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; diff --git a/crates/app-iced/src/app/update/settings/tests/java.rs b/crates/app-iced/src/app/update/settings/tests/java.rs new file mode 100644 index 0000000..9ff9e55 --- /dev/null +++ b/crates/app-iced/src/app/update/settings/tests/java.rs @@ -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 { + 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(); +} diff --git a/crates/app-iced/src/components/selection/chip.rs b/crates/app-iced/src/components/selection/chip.rs index e84353d..fc149a1 100644 --- a/crates/app-iced/src/components/selection/chip.rs +++ b/crates/app-iced/src/components/selection/chip.rs @@ -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%」——只降**不删**。早先的 diff --git a/crates/app-iced/src/pages/settings/java.rs b/crates/app-iced/src/pages/settings/java.rs index dac13c9..ff6341a 100644 --- a/crates/app-iced/src/pages/settings/java.rs +++ b/crates/app-iced/src/pages/settings/java.rs @@ -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`(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) + } } diff --git a/crates/app-iced/src/pages/settings/java/fields.rs b/crates/app-iced/src/pages/settings/java/fields.rs new file mode 100644 index 0000000..66d9ad9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/fields.rs @@ -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() +} diff --git a/crates/app-iced/src/pages/settings/java/row.rs b/crates/app-iced/src/pages/settings/java/row.rs new file mode 100644 index 0000000..42f41eb --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/row.rs @@ -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, + /// 发行方(认不出为 `None`)。 + pub vendor: Option, + /// 来源 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::>() + .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 { + 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 { + 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) -> 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" + ); + } +} diff --git a/crates/app-iced/src/pages/settings/java/seed.rs b/crates/app-iced/src/pages/settings/java/seed.rs new file mode 100644 index 0000000..4f59203 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/seed.rs @@ -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 { + 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) +} diff --git a/crates/app-iced/src/pages/settings/java/state.rs b/crates/app-iced/src/pages/settings/java/state.rs new file mode 100644 index 0000000..62e1bb9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/state.rs @@ -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, +} + +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, + /// 当前生效内存来自哪一层 + pub layer: Option, + /// 当前生效内存(三级回落之后的数值) + pub effective_memory_mb: Option, +} + +/// 保存 / 操作的回执。 +#[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, + /// 至少完整扫过一次(区分「未检测到」与「还没扫」) + pub scanned: bool, + /// 扫描进度 + pub scanning: Option, + /// 上一次扫描被用户取消(界面上要说一句,否则「列表没变」会被当成没扫) + pub cancelled: bool, + /// 扫描状态槽(快照 + 取消位;线程与界面共享同一份) + pub scan: super::store::ScanSlot, + /// 用户手动加的路径(原样,持久化在 `manual_file`) + pub manual: Vec, + /// `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, + /// 当前实例的上下文(版本门槛 / 生效内存层级) + 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) -> Option { + 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 { + let text = self.memory.trim(); + if text.is_empty() { + return Err(Refusal::MemoryEmpty); + } + let Ok(mb) = text.parse::() 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 { + 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 +} diff --git a/crates/app-iced/src/pages/settings/java/store.rs b/crates/app-iced/src/pages/settings/java/store.rs new file mode 100644 index 0000000..b5a795d --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/store.rs @@ -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, +} + +/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。 +pub type Job = + fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec; + +/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。 +/// +/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处—— +/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例 +/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。 +/// +/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。 +#[derive(Debug, Clone, Default)] +pub struct ScanSlot { + inner: std::sync::Arc, +} + +#[derive(Debug, Default)] +struct ScanInner { + report: Mutex, + 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, manual: Vec, system: bool) -> bool { + self.start_with(roots, manual, system, run_staged) + } + + /// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。 + pub fn start_with( + &self, + roots: Vec, + manual: Vec, + 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 { + 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 { + 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 +} + +/// 托管安装根(`/data/java-managed` + `java.installRoot` + 标准落点)。 +/// +/// 从**我们正在写的那份 `launcher.json`** 的上一级推 launcher home:这比重新解析一次数据根模式 +/// 更可信(三模式解析已经在 `SettingsBridge::open` 里做过一遍了)。 +#[must_use] +pub fn managed_roots(config: &Path) -> Vec { + 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 { + 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 { + 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 { + 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 { + 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, +) -> 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 { + bridge.path().map(Path::to_path_buf) +} diff --git a/crates/app-iced/src/pages/settings/java/tests.rs b/crates/app-iced/src/pages/settings/java/tests.rs new file mode 100644 index 0000000..5df6fe9 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests.rs @@ -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; + +/// 每个用例一个临时数据根(`/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::` 换成 `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; diff --git a/crates/app-iced/src/pages/settings/java/tests/cmdline.rs b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs new file mode 100644 index 0000000..882bcc2 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs @@ -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 { + 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), + "全新进程(没扫过、没取消)仍然自动扫一次" + ); +} diff --git a/crates/app-iced/src/pages/settings/java/tests/runtime.rs b/crates/app-iced/src/pages/settings/java/tests/runtime.rs new file mode 100644 index 0000000..c74294f --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/tests/runtime.rs @@ -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 { + 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"); + // `/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() + ); + } + } +} diff --git a/crates/app-iced/src/pages/settings/java/view.rs b/crates/app-iced/src/pages/settings/java/view.rs new file mode 100644 index 0000000..944c022 --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/view.rs @@ -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() +} diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 546db1b..e96fcdb 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -271,6 +271,12 @@ const UPDATE_GROUP: usize = update::UPDATE_GROUP; /// 递到 [`form`] 的分发点,`GROUP.body` 保持 `None`。 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 接进来的外观运行期状态 @@ -307,9 +313,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, @@ -339,6 +351,9 @@ fn form<'a>( } 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 diff --git a/crates/app-iced/src/pages/settings/tests/java_section.rs b/crates/app-iced/src/pages/settings/tests/java_section.rs index 0dbcef4..18ca878 100644 --- a/crates/app-iced/src/pages/settings/tests/java_section.rs +++ b/crates/app-iced/src/pages/settings/tests/java_section.rs @@ -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,14 @@ 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(..)` 挂上来却不给它 `Lang`(那样中文界面上这一组会退回英文)。 +/// 抓不住:分发那一行本身有没有被删(`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" ); } diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index 2e1f4e3..57c7965 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -88,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()); } diff --git a/crates/core/src/game/java/manual.rs b/crates/core/src/game/java/manual.rs new file mode 100644 index 0000000..7bb390e --- /dev/null +++ b/crates/core/src/game/java/manual.rs @@ -0,0 +1,324 @@ +//! 用户手动指定的 JDK 路径(M175):校验、厂商识别、名单持久化。 +//! +//! 场景:MSI 装的 JDK 落在 `%ProgramFiles%` 之外的盘符上、或者用户自己解压的一份 JDK —— +//! [`super::discover`] 的标准位置扫描看不见它们,用户只能在设置页里**手动指一个路径**。 +//! +//! # 三条口径 +//! +//! 1. **收目录也收可执行文件**:两样都是用户会粘的东西,统一折成 `bin/java[.exe]`; +//! 2. **校验就是真跑一次 `java -version`**:目录存在、里面有 `bin/java` 都不算数—— +//! 一份放错的 `java`(空文件、别的平台的可执行文件)必须在这里被拒,而不是等启动时崩; +//! 3. **这份名单是 UI 的持久化,不是启动策略**:真正「用哪个 Java 启动」仍由 +//! `java.selected_path`(core 的 schema 键)决定,名单只是「我手动加过哪些」的记忆。 +//! +//! 名单落一个纯文本文件(一行一个路径):它是 UI 侧的登记表,不参与 schema 校验, +//! 也不该借道 `launcher.json`(那条路要新键 ⇒ 动 schema 版本与迁移,代价远大于收益)。 + +use std::path::{Path, PathBuf}; + +use super::extract::java_binary_name; +use super::probe::{self, JavaOrigin, JavaRuntime}; + +/// 用户可粘路径的长度上限(防手滑粘进来一整段日志)。 +pub const MAX_PATH_CHARS: usize = 512; + +/// 手动路径被拒的原因(稳定 id 给 UI 选文案;不做本地化)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManualFailure { + /// 空串 / 全空白 + Empty, + /// 超过 [`MAX_PATH_CHARS`] + TooLong, + /// 盘上没这个文件或目录 + Missing, + /// 有这个东西,但里面没有 `bin/java[.exe]`(或给的不是一个 java 可执行文件) + NotJava, + /// 有 `java`,但 `java -version` 跑不起来 / 读不出版本号 + NoVersion, +} + +impl ManualFailure { + /// 稳定 id(UI 文案 key 的后缀,见 `pages/settings/java`)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Empty => "empty", + Self::TooLong => "too-long", + Self::Missing => "missing", + Self::NotJava => "not-java", + Self::NoVersion => "no-version", + } + } +} + +/// 用户给的路径 → `java` 可执行文件(不探测,只看文件系统)。 +/// +/// # Errors +/// 空 / 太长 / 不存在 / 不是 java 可执行文件时返回原因。 +pub fn resolve_binary(input: &str) -> Result { + let text = input.trim(); + if text.is_empty() { + return Err(ManualFailure::Empty); + } + if text.chars().count() > MAX_PATH_CHARS { + return Err(ManualFailure::TooLong); + } + let path = Path::new(text); + if path.is_file() { + return looks_like_java(path) + .then(|| path.to_path_buf()) + .ok_or(ManualFailure::NotJava); + } + if path.is_dir() { + let binary = path.join("bin").join(java_binary_name()); + return binary + .is_file() + .then_some(binary) + .ok_or(ManualFailure::NotJava); + } + Err(ManualFailure::Missing) +} + +/// 文件名是不是 `java` / `javaw`(Windows 带 `.exe`;POSIX 不区分大小写属于宽容)。 +fn looks_like_java(path: &Path) -> bool { + path.file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| stem.eq_ignore_ascii_case("java") || stem.eq_ignore_ascii_case("javaw")) +} + +/// 跑一次 `java -version`,同时把**原始输出**交回来(厂商要从整段输出里认,不能只看首行)。 +/// +/// 为什么需要原始输出:`-version` 的第一行只有 `openjdk version "21.0.11"`,发行方印在**第二行** +/// (`OpenJDK Runtime Environment Microsoft-11230047` / `… Temurin-21.0.4+7`)。只拿首行去认厂商, +/// Microsoft / Temurin 的 JDK 会一律显示成 `OpenJDK`——M175 出图时实测到了这个错。 +#[must_use] +pub fn probe_with_output(binary: &Path, origin: JavaOrigin) -> (JavaRuntime, String) { + probe_with_timeout(binary, origin, super::proc::PROBE_TIMEOUT, &|| false) +} + +/// 同 [`probe_with_output`],但带**超时与取消**(扫描线程用这一条)。 +#[must_use] +pub fn probe_with_timeout( + binary: &Path, + origin: JavaOrigin, + timeout: std::time::Duration, + cancelled: &dyn Fn() -> bool, +) -> (JavaRuntime, String) { + let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin); + let mut command = std::process::Command::new(binary); + command.arg("-version"); + let Some(output) = super::proc::run_with_timeout(command, timeout, cancelled) else { + return (runtime, String::new()); + }; + let mut text = String::from_utf8_lossy(&output.stderr).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + if let Some((line, major)) = probe::parse_version_output(&text) { + runtime.major = Some(major); + runtime.version = Some(line); + runtime.bits = probe::bits_of(&text); + } + runtime.vendor = vendor_of(&text); + (runtime, text) +} + +/// 严格探测:必须真跑得起来 `-version`。 +/// +/// 与 [`probe::probe`] 的差别只有一个:后者「探测不出也返回一个 runtime」,这里探测不出就是**拒绝**。 +/// 手动添加这条路上,一个答不出话的 `java` 就是无效输入,不能进名单。 +/// +/// # Errors +/// 校验不过([`resolve_binary`])或 `-version` 读不出版本号时返回原因。 +pub fn probe_manual(input: &str) -> Result { + let binary = resolve_binary(input)?; + let (runtime, _output) = probe_with_output(&binary, JavaOrigin::System); + if runtime.major.is_none() { + return Err(ManualFailure::NoVersion); + } + Ok(runtime) +} + +/// `java -version` 输出里的发行方(认不出返回 `None`,不猜)。 +/// +/// 为什么从输出里认而不是从安装目录名认:用户手动指的路径跟目录命名毫无关系, +/// 而 `java -version` 的首行/次行本来就把发行方印出来了 +/// (`OpenJDK Runtime Environment Temurin-21.0.4+7`、`Java(TM) SE Runtime Environment`)。 +#[must_use] +pub fn vendor_of(text: &str) -> Option { + let lowered = text.to_ascii_lowercase(); + // 顺序即判据:「Temurin 构建的 OpenJDK」也能匹配到 openjdk,所以具体品牌必须排在前面。 + const MARKERS: &[(&str, &str)] = &[ + ("temurin", "Temurin"), + ("adoptium", "Temurin"), + ("microsoft", "Microsoft"), + ("zulu", "Zulu"), + ("azul", "Zulu"), + ("corretto", "Corretto"), + ("semeru", "Semeru"), + ("graalvm", "GraalVM"), + ("sapmachine", "SapMachine"), + ("oracle", "Oracle"), + // 老 Oracle / HotSpot 的 `-version` 不提 "Oracle"(Java 8 就是 `Java(TM) SE Runtime + // Environment` + `Java HotSpot(TM) 64-Bit Server VM`),这两条是那一代的唯一指纹。 + ("se runtime", "Oracle"), + ("hotspot", "Oracle"), + ("openjdk", "OpenJDK"), + ]; + MARKERS + .iter() + .find(|(needle, _)| lowered.contains(needle)) + .map(|(_, display)| (*display).to_string()) +} + +/// 读名单(文件不存在 / 读不动 ⇒ 空名单,不报错)。 +/// +/// 去掉空行、去重(按 [`probe::discover::path_key`](super::discover::path_key) 的同一套键), +/// 顺序保持文件里的顺序(用户添加的先后)。 +#[must_use] +pub fn load_paths(file: &Path) -> Vec { + let Ok(text) = std::fs::read_to_string(file) else { + return Vec::new(); + }; + let mut paths: Vec = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let path = PathBuf::from(line); + if !paths + .iter() + .any(|known| super::discover::path_key(known) == super::discover::path_key(&path)) + { + paths.push(path); + } + } + paths +} + +/// 写名单(一行一个,末尾换行;父目录不存在就建)。 +/// +/// # Errors +/// 目录建不出来或写盘失败时返回 IO 错误。 +pub fn save_paths(file: &Path, paths: &[PathBuf]) -> std::io::Result<()> { + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + let mut text = String::new(); + for path in paths { + text.push_str(&path.display().to_string()); + text.push('\n'); + } + std::fs::write(file, text) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-java-manual-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("临时目录"); + dir + } + + #[test] + fn recognises_vendors_from_real_version_lines() { + assert_eq!( + vendor_of("OpenJDK Runtime Environment Temurin-21.0.4+7 (build 21.0.4+7-LTS)"), + Some("Temurin".to_string()) + ); + assert_eq!( + vendor_of("OpenJDK 64-Bit Server VM Microsoft-8321 (build 17.0.13+11)"), + Some("Microsoft".to_string()) + ); + assert_eq!( + vendor_of("openjdk version \"17.0.9\" 2023-10-17\nOpenJDK Runtime Environment Zulu17.46+19-CA"), + Some("Zulu".to_string()) + ); + assert_eq!( + vendor_of( + "java version \"1.8.0_402\"\nJava(TM) SE Runtime Environment (build 1.8.0_402-b06)" + ), + Some("Oracle".to_string()) + ); + assert_eq!(vendor_of("nothing useful here"), None); + } + + #[test] + fn accepts_a_directory_and_a_binary_but_rejects_anything_else() { + let dir = temp("resolve"); + let jdk = dir.join("jdk-21"); + std::fs::create_dir_all(jdk.join("bin")).expect("bin"); + let binary = jdk.join("bin").join(java_binary_name()); + std::fs::write(&binary, b"").expect("写 java"); + + assert_eq!( + resolve_binary(&jdk.display().to_string()), + Ok(binary.clone()) + ); + assert_eq!(resolve_binary(&binary.display().to_string()), Ok(binary)); + assert_eq!(resolve_binary(" "), Err(ManualFailure::Empty)); + assert_eq!( + resolve_binary(&dir.join("nope").display().to_string()), + Err(ManualFailure::Missing) + ); + let empty = dir.join("empty"); + std::fs::create_dir_all(&empty).expect("empty"); + assert_eq!( + resolve_binary(&empty.display().to_string()), + Err(ManualFailure::NotJava) + ); + // 目录里有一个**不叫 java 的**可执行文件:也不算。 + let text = dir.join("notes.txt"); + std::fs::write(&text, b"hi").expect("写文本"); + assert_eq!( + resolve_binary(&text.display().to_string()), + Err(ManualFailure::NotJava) + ); + let long = "a".repeat(MAX_PATH_CHARS + 1); + assert_eq!(resolve_binary(&long), Err(ManualFailure::TooLong)); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_binary_that_cannot_answer_version_is_refused() { + let dir = temp("noversion"); + let jdk = dir.join("jdk"); + std::fs::create_dir_all(jdk.join("bin")).expect("bin"); + // 空文件:解析不出版本号 ⇒ 拒绝(这条正是「文件存在 ≠ 能用」的判据)。 + std::fs::write(jdk.join("bin").join(java_binary_name()), b"").expect("写 java"); + assert_eq!( + probe_manual(&jdk.display().to_string()).err(), + Some(ManualFailure::NoVersion) + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_list_round_trips_and_dedupes() { + let dir = temp("list"); + let file = dir.join("config").join("java-extra-paths.txt"); + let paths = vec![ + PathBuf::from("C:/jdk/17"), + PathBuf::from("C:/jdk/21"), + PathBuf::from("C:\\jdk\\17"), + ]; + save_paths(&file, &paths).expect("写名单"); + // 第三条是第一条的另一种写法:读回来只留一条(与扫描去重同一套键)。 + assert_eq!(load_paths(&file).len(), 2); + assert_eq!(load_paths(&file)[0], PathBuf::from("C:/jdk/17")); + + let missing = dir.join("nope.txt"); + assert!(load_paths(&missing).is_empty(), "缺文件是空名单,不是错误"); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/core/src/game/java/mod.rs b/crates/core/src/game/java/mod.rs index dd2aad7..1b93f57 100644 --- a/crates/core/src/game/java/mod.rs +++ b/crates/core/src/game/java/mod.rs @@ -45,14 +45,17 @@ pub mod error; pub mod extract; pub mod install; pub mod location; +pub mod manual; pub mod mapping; pub mod microsoft; pub mod net; pub mod oracle; pub mod probe; +pub mod proc; pub mod release; pub mod select; pub mod source; +pub mod staged; pub mod target; pub mod termux; pub mod vendor; diff --git a/crates/core/src/game/java/proc.rs b/crates/core/src/game/java/proc.rs new file mode 100644 index 0000000..beeb35d --- /dev/null +++ b/crates/core/src/game/java/proc.rs @@ -0,0 +1,169 @@ +//! 子进程的**带超时 / 可打断**执行(M175 r1 复审 P2-5)。 +//! +//! `Command::output()` 是「等到底」:一份放在挂死网络盘上的 `java` 会让调用它的扫描线程永远不 +//! 返回,界面上「取消」也就永远兑现不了。这里只做一件事——**要么拿到输出,要么把它杀掉**。 + +use std::time::Duration; + +/// 单个候选的探测预算(`java -version` 正常几十毫秒;8 秒还没答话就是卡住了)。 +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(8); + +/// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。 +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// 跑一个命令并等它结束,**带超时与可打断**:`cancelled()` 为真或超时就把子进程 kill 掉。 +/// +/// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。 +/// +/// 为什么不用 `Command::output()`:它是「等到底」,一个卡住的子进程会把整个扫描线程钉死, +/// 而取消位只能在两次探测之间被看到——取消就永远兑现不了。 +#[must_use] +pub fn run_with_timeout( + mut command: std::process::Command, + timeout: Duration, + cancelled: &dyn Fn() -> bool, +) -> Option { + use std::process::Stdio; + + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().ok()?; + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => { + if cancelled() || started.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(POLL_INTERVAL); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + child.wait_with_output().ok() +} + +#[cfg(test)] +mod tests { + use super::run_with_timeout; + + #[test] + fn a_hung_probe_is_killed_after_the_timeout() { + // 一个「永远不返回」的子进程:不杀掉它,扫描线程就永远不结束(复审 P2-5)。 + let started = std::time::Instant::now(); + let output = run_with_timeout( + sleeping_command(30), + std::time::Duration::from_millis(400), + &|| false, + ); + assert!(output.is_none(), "超时必须判成「没有结论」"); + let elapsed = started.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(5), + "超时之后必须 kill 掉子进程并返回,实际等了 {elapsed:?}" + ); + } + + #[test] + fn a_cancel_interrupts_the_probe_that_is_already_running() { + // 取消不能只在「两次探测之间」被看到:卡住的那一个也要能打断。 + let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let tripper = flag.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(150)); + tripper.store(true, std::sync::atomic::Ordering::SeqCst); + }); + let started = std::time::Instant::now(); + let output = run_with_timeout( + sleeping_command(30), + std::time::Duration::from_secs(30), + &|| flag.load(std::sync::atomic::Ordering::SeqCst), + ); + assert!(output.is_none(), "被取消的探测没有结论"); + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "取消必须打断正在跑的那个探测,而不是等它自己结束" + ); + } + + /// 超时之后子进程必须**真的死了**:让它延时写一个标记文件,断言那个文件从未出现。 + /// + /// 为什么要有这一条(r2 复审 P3-1):上面两条只断言「返回得快」,把 `kill()+wait()` + /// 两行删掉它们照样绿——而没杀掉的子进程会继续跑(写它自己的文件、占句柄)。 + /// + /// 变异证明:删掉超时分支里的 `child.kill()` + `child.wait()` ⇒ 子进程活到延时结束、把 + /// 标记文件写出来 ⇒ 本条红。 + #[test] + fn a_timed_out_child_is_really_killed() { + let dir = std::env::temp_dir().join(format!( + "ull-proc-kill-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("临时目录"); + let marker = dir.join("survived.txt"); + let _ = std::fs::remove_file(&marker); + + let output = run_with_timeout( + marker_command(&marker, 4), + std::time::Duration::from_millis(300), + &|| false, + ); + assert!(output.is_none(), "超时没有结论"); + // 给「要是没被杀」足够的时间把标记写出来(4 秒的延时 + 余量)。 + std::thread::sleep(std::time::Duration::from_secs(6)); + assert!( + !marker.exists(), + "子进程在超时后仍然活着并执行了尾巴(标记文件出现了):kill 没生效" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// 延时 `seconds` 秒之后写一个标记文件的命令(两个平台各一条,语义相同)。 + fn marker_command(marker: &std::path::Path, seconds: u32) -> std::process::Command { + let target = marker.display().to_string(); + if cfg!(windows) { + // PowerShell 而不是 `cmd /C "…"`:Rust 会把整个参数加一层双引号,cmd 再把里面的 + // 引号吃掉,重定向目标就被截断(实测命令立刻失败 ⇒ 本用例假红)。这里路径用 + // PowerShell 的单引号,外层只剩 Rust 加的那一层。 + let mut command = std::process::Command::new("powershell"); + command.args([ + "-NoProfile", + "-Command", + &format!( + "Start-Sleep -Seconds {seconds}; Set-Content -Path '{target}' -Value alive" + ), + ]); + command + } else { + let mut command = std::process::Command::new("sh"); + command.args(["-c", &format!("sleep {seconds}; echo alive > '{target}'")]); + command + } + } + + /// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。 + fn sleeping_command(seconds: u32) -> std::process::Command { + if cfg!(windows) { + let mut command = std::process::Command::new("cmd"); + command.args(["/C", &format!("ping -n {seconds} 127.0.0.1 > NUL")]); + command + } else { + let mut command = std::process::Command::new("sleep"); + command.arg(seconds.to_string()); + command + } + } +} diff --git a/crates/core/src/game/java/staged.rs b/crates/core/src/game/java/staged.rs new file mode 100644 index 0000000..c4e7f86 --- /dev/null +++ b/crates/core/src/game/java/staged.rs @@ -0,0 +1,347 @@ +//! 带进度与取消的 Java 扫描(M175):**先收集候选、再逐个探测**。 +//! +//! 为什么不能直接调 [`super::discover::candidates_in_roots`]:那条路把「收集」与「探测」揉在一起, +//! 一个 JDK 跑一次 `java -version`(几十到几百毫秒),一台装了七八个 JDK 的机器上整段要几秒。 +//! 设置页要的是**能显示 3/7、能中途取消、且不卡住 UI**——于是这里把两段拆开: +//! +//! ```text +//! candidates(roots, manual, system) 纯文件系统,快、无子进程 +//! → scan(&candidates, on_progress, cancelled) 逐个探测;任一回调说停就停 +//! ``` +//! +//! 判据与 [`super::discover`] 一致(同一套去重键、同一套标准位置),差别只有「可观测」这一层—— +//! 所以 `ull java list` 与设置页看到的是同一批 JDK。 + +use std::path::PathBuf; + +use super::discover::{self, path_key}; +use super::extract::{java_binary_name, read_stamp}; +use super::probe::{self, JavaOrigin, JavaRuntime}; +use super::vendor::JdkVendor; + +/// 候选来源(比 [`JavaOrigin`] 多一个「用户手动加的」,UI 也按它分组)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// 托管目录(`ull java install` 装的) + Managed, + /// `JAVA_HOME` + JavaHome, + /// `PATH` 上的 `java` + Path, + /// 标准安装位置(Program Files 系 / 官方启动器 runtime / 注册表 / `/usr/lib/jvm`) + System, + /// 用户在设置页手动指的路径 + Manual, +} + +impl Source { + /// 稳定 id(DTO / UI 文案选档用)。 + #[must_use] + pub const fn id(self) -> &'static str { + match self { + Self::Managed => "managed", + Self::JavaHome => "env", + Self::Path => "path", + Self::System => "system", + Self::Manual => "manual", + } + } + + /// 展示优先级(小者先;与 [`JavaOrigin::rank`] 同序,手动路径排在系统安装之前—— + /// 用户亲手指的是明确的意图)。 + #[must_use] + pub const fn rank(self) -> u8 { + match self { + Self::Managed => 1, + Self::JavaHome => 2, + Self::Path => 3, + Self::Manual => 4, + Self::System => 5, + } + } + + /// 探测时用的 [`JavaOrigin`](`JavaOrigin` 没有「手动」这一档,用 `System` 承接下来即可: + /// 展示走本枚举的 [`Source::id`],不看 `JavaRuntime.origin`)。 + #[must_use] + pub const fn origin(self) -> JavaOrigin { + match self { + Self::Managed => JavaOrigin::Managed, + Self::JavaHome => JavaOrigin::JavaHome, + Self::Path => JavaOrigin::Path, + Self::System | Self::Manual => JavaOrigin::System, + } + } +} + +/// 一个待探测的候选。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// `bin/java[.exe]` 的绝对路径 + pub binary: PathBuf, + /// 来源 + pub source: Source, +} + +/// 收集候选(**不探测**,所以很快;目录不存在一律跳过)。 +/// +/// `system = false` 用于测试与「只看托管区」的场景:它跳过 `JAVA_HOME`/`PATH`/标准位置, +/// 于是同一份输入在任何机器上得到同一份候选表(不依赖本机装了什么)。 +#[must_use] +pub fn candidates(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec { + let mut out: Vec = Vec::new(); + for root in managed_roots { + out.extend(managed_in(root)); + } + // 手动路径:目录 → `bin/java`;文件原样(用户在输入框里粘过两种形态,都认)。 + for path in manual { + let binary = if path.is_dir() { + path.join("bin").join(java_binary_name()) + } else { + path.clone() + }; + if binary.is_file() { + out.push(Candidate { + binary, + source: Source::Manual, + }); + } + } + if system { + out.extend(env_candidates()); + out.extend( + discover::scan_java_binaries() + .into_iter() + .map(|binary| Candidate { + binary, + source: Source::System, + }), + ); + } + dedupe(out) +} + +/// 托管安装根里的候选(两种布局都认,**必须有戳记**——没戳记的目录可能是用户塞的别的东西)。 +fn managed_in(root: &std::path::Path) -> Vec { + let mut found = Vec::new(); + let Ok(entries) = std::fs::read_dir(root) else { + return found; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if !path.is_dir() || name.starts_with('.') { + continue; + } + if let Some(binary) = stamped_binary(&path) { + found.push(Candidate { + binary, + source: Source::Managed, + }); + continue; + } + // 两层布局 `/`:厂商名取自父目录名。 + if JdkVendor::parse(&name).is_some() { + let Ok(children) = std::fs::read_dir(&path) else { + continue; + }; + for child in children.flatten() { + if let Some(binary) = stamped_binary(&child.path()) { + found.push(Candidate { + binary, + source: Source::Managed, + }); + } + } + } + } + found +} + +/// 带戳记、且 `bin/java[.exe]` 在盘上的托管目录。 +fn stamped_binary(dir: &std::path::Path) -> Option { + if !dir.is_dir() || read_stamp(dir).is_none() { + return None; + } + let binary = dir.join("bin").join(java_binary_name()); + binary.is_file().then_some(binary) +} + +/// `JAVA_HOME` 与 `PATH`(与 [`discover::candidates_in_roots`] 同一口径)。 +fn env_candidates() -> Vec { + let mut found = Vec::new(); + if let Some(home) = std::env::var_os("JAVA_HOME") { + let binary = PathBuf::from(home).join("bin").join(java_binary_name()); + if binary.is_file() { + found.push(Candidate { + binary, + source: Source::JavaHome, + }); + } + } + if let Some(binary) = probe::resolve_on_path(java_binary_name()) { + found.push(Candidate { + binary, + source: Source::Path, + }); + } + found +} + +/// 按路径去重,保留**先出现**的(调用方按优先级排好序)。 +fn dedupe(candidates: Vec) -> Vec { + let mut seen: Vec = Vec::with_capacity(candidates.len()); + let mut kept = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let key = path_key(&candidate.binary); + if seen.contains(&key) { + continue; + } + seen.push(key); + kept.push(candidate); + } + kept +} + +/// 逐个探测。`on_progress(done, total)` 返回 `false` ⇒ 立刻停(取消),已探测的照样返回。 +/// +/// `cancelled()` 在一次探测**进行中**也会被查询:卡住的候选会被 [`super::proc::PROBE_TIMEOUT`] +/// 或取消打断,而不是把扫描线程钉死在一个 `-version` 上(见 [`super::proc::run_with_timeout`])。 +/// +/// 返回的列表按**大版本升序、同版本按路径**排序,探测不出主版本的排在最后 +/// (与 [`super::probe::discover_managed`] 的排序口径一致,用户看到的第一行不该是「版本未知」)。 +#[must_use] +pub fn scan( + candidates: &[Candidate], + mut on_progress: F, + cancelled: &dyn Fn() -> bool, +) -> Vec +where + F: FnMut(usize, usize) -> bool, +{ + let total = candidates.len(); + let mut found: Vec = Vec::with_capacity(total); + for (index, candidate) in candidates.iter().enumerate() { + if cancelled() || !on_progress(index, total) { + break; + } + // 发行方要从**整段** `-version` 输出里认(首行只有 `openjdk version "…"`,品牌在第二行)。 + // 超时与取消都交给这一层:卡住的子进程会被 kill,取消不必等它自己结束。 + let (runtime, _output) = super::manual::probe_with_timeout( + &candidate.binary, + candidate.source.origin(), + super::proc::PROBE_TIMEOUT, + cancelled, + ); + found.push(runtime); + } + found.sort_by(|left, right| { + left.major + .unwrap_or(u32::MAX) + .cmp(&right.major.unwrap_or(u32::MAX)) + .then_with(|| left.binary.cmp(&right.binary)) + }); + found +} + +/// 候选 + 扫描一步到位(不与 UI 打交道的调用方用;设置页走两段式)。 +#[must_use] +pub fn scan_roots(managed_roots: &[PathBuf], manual: &[PathBuf], system: bool) -> Vec { + let list = candidates(managed_roots, manual, system); + scan(&list, |_, _| true, &|| false) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-java-staged-{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).expect("临时目录"); + dir + } + + /// 造一个「看起来像 JDK」的目录(`bin/java` 是空文件 ⇒ 探测出「版本未知」, + /// 但它确实被扫到了——这正是「列表来自真实扫描」要的证据)。 + fn fake_jdk(root: &std::path::Path, name: &str) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(dir.join("bin")).expect("bin"); + std::fs::write(dir.join("bin").join(java_binary_name()), b"").expect("写 java"); + dir + } + + #[test] + fn the_list_comes_from_the_disk_not_from_a_constant() { + let root = temp("list"); + let one = fake_jdk(&root, "jdk-a"); + let two = fake_jdk(&root, "jdk-b"); + + let found = scan_roots(&[], &[one.clone(), two.clone()], false); + assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}"); + assert!(found + .iter() + .any(|runtime| runtime.binary == one.join("bin").join(java_binary_name()))); + + // 删掉一个:列表必须少一项(证明它读的是盘,不是写死的字符串)。 + std::fs::remove_dir_all(&one).expect("删 jdk-a"); + let after = scan_roots(&[], &[one, two], false); + assert_eq!(after.len(), 1, "删掉一个 JDK 之后列表必须少一项:{after:?}"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn a_managed_directory_without_a_stamp_is_not_claimed() { + let root = temp("stamp"); + // 长得像托管目录,但没有戳记 ⇒ 不是我们装的,不去猜。 + fake_jdk(&root, "jdk-21-temurin"); + let found = scan_roots(std::slice::from_ref(&root), &[], false); + assert!(found.is_empty(), "无戳记目录不该被认领:{found:?}"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn progress_reports_every_candidate_and_cancel_stops_early() { + let root = temp("progress"); + let paths: Vec = (0..3) + .map(|n| fake_jdk(&root, &format!("jdk-{n}"))) + .collect(); + let list = candidates(&[], &paths, false); + assert_eq!(list.len(), 3); + + let mut seen = Vec::new(); + let all = scan( + &list, + |done, total| { + seen.push((done, total)); + true + }, + &|| false, + ); + assert_eq!(seen, vec![(0, 3), (1, 3), (2, 3)], "每个候选都要报一次进度"); + assert_eq!(all.len(), 3); + + let stopped = scan(&list, |done, _| done < 1, &|| false); + assert_eq!(stopped.len(), 1, "取消之后只留已探测的那一项"); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn candidates_dedupe_the_same_jdk_written_two_ways() { + let root = temp("dedupe"); + let jdk = fake_jdk(&root, "jdk-x"); + let binary = jdk.join("bin").join(java_binary_name()); + // 同一份 JDK 用「目录」和「可执行文件」两种写法各登记一次。 + let list = candidates(&[], &[jdk, binary], false); + assert_eq!(list.len(), 1, "同一路径只列一次:{list:?}"); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index a8a89d3..8972de0 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -29,6 +29,65 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsAppearanceCornerSquare", "Square", "直角"), ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"), ("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"), + // M175:设置页「Java」分组(`catalog/settings/java.rs` 的 `settings_java_entries`)。 + // 位置就是装配链里那个域的位置:`settings_java_entries` 在 `settings_appearance_entries` 之后、 + // `settings_download_entries`(空)与 `settings_ai_entries`(M174)之前,所以这几行必须落在 + // `SettingsAppearanceCornerSuspended` 与 `SettingsGroupAssistant` 之间(顺序 = `Key::ALL`)。 + ("SettingsJavaScan", "Scan now", "立即扫描"), + ("SettingsJavaScanningStart", "Scanning…", "正在扫描…"), + ("SettingsJavaScanning", "Scanning… {done}/{total}", "正在扫描… {done}/{total}"), + ("SettingsJavaCancelling", "Cancelling…", "正在取消…"), + ("SettingsJavaCancel", "Cancel", "取消"), + ("SettingsJavaCancelled", "The scan was cancelled; the list below is from the previous run.", "扫描已取消;下面的列表还是上一次的结果。"), + ("SettingsJavaScanDone", "{count} JDK found", "找到 {count} 个 JDK"), + ("SettingsJavaNeverScanned", "Not scanned yet", "还没扫描过"), + ("SettingsJavaNeverScannedBody", "Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.", "点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。"), + ("SettingsJavaEmptyTitle", "No Java runtime found", "未检测到 Java 运行时"), + ("SettingsJavaEmptyBody", "Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.", "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。"), + ("SettingsJavaDefault", "Default", "默认"), + ("SettingsJavaSetDefault", "Use as default", "设为默认"), + ("SettingsJavaFits", "Meets the current instance requirement", "满足当前实例的要求"), + ("SettingsJavaTooOld", "Below the requirement: this instance needs Java {minimum}+", "低于要求:当前实例需要 Java {minimum} 及以上"), + ("SettingsJavaDefaultSet", "Default JDK is now {path}", "默认 JDK 已设为 {path}"), + ("SettingsJavaSourceManaged", "installed by this launcher", "本启动器安装"), + ("SettingsJavaSourceEnv", "JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"), + ("SettingsJavaSourcePath", "on PATH", "PATH 上"), + ("SettingsJavaSourceSystem", "system install", "系统安装"), + ("SettingsJavaSourceManual", "added by you", "手动添加"), + ("SettingsJavaAdd", "Add a JDK path", "手动添加 JDK 路径"), + ("SettingsJavaAddPlaceholder", "C:\\Program Files\\Java\\jdk-21", "例:C:\\Program Files\\Java\\jdk-21"), + ("SettingsJavaAddHint", "A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.", "JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。"), + ("SettingsJavaAddAction", "Add", "添加"), + ("SettingsJavaAdded", "Added {path}", "已添加 {path}"), + ("SettingsJavaMemory", "Default memory (MB)", "默认内存(MB)"), + ("SettingsJavaMemoryHint", "Whole megabytes, {min}–{max}. Instances without their own override follow this value.", "整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。"), + ("SettingsJavaLayerGlobal", "In effect: {mb} MB, from the global default.", "生效:{mb} MB,来自全局默认值。"), + ("SettingsJavaLayerInstance", "In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.", "生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。"), + ("SettingsJavaJvm", "Extra JVM arguments", "额外 JVM 参数"), + ("SettingsJavaJvmPlaceholder", "-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"), + ("SettingsJavaJvmHint", "Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.", "按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。"), + ("SettingsJavaJvmSaved", "Saved value: {value}", "已保存的值:{value}"), + ("SettingsJavaJvmSavedNone", "No extra JVM arguments are saved yet.", "还没有保存任何额外参数。"), + ("SettingsJavaSave", "Save", "保存"), + ("SettingsJavaSaved", "Saved and read back from disk. The next launch uses the new value.", "已保存,并从磁盘读回确认。下次启动就用新值。"), + ("SettingsJavaUnsaved", "There are unsaved changes.", "有未保存的改动。"), + ("SettingsJavaRefused", "Not saved: {reason}", "未保存:{reason}"), + ("SettingsJavaSkipNotDirty", "there was nothing to change, so no file was written", "没有任何改动,因此没有写文件"), + ("SettingsJavaSkipReadOnly", "the settings file is read-only: a newer launcher wrote it, or it is marked untrusted", "配置文件只读:它被更新版本的启动器写过,或被标记为不可信"), + ("SettingsJavaSkipUnreadable", "the settings file is untrusted, so it is treated as read-only", "配置文件不可信,因此按只读处理"), + ("SettingsJavaNoStorage", "No settings file is available in this run, so nothing can be scanned or saved.", "这一次运行没有可用的设置文件,扫描与保存都做不了。"), + ("SettingsJavaReasonMemoryEmpty", "the memory field is empty", "内存没填"), + ("SettingsJavaReasonMemoryNotNumber", "the memory must be a whole number of MB", "内存必须是整数 MB"), + ("SettingsJavaReasonMemoryRange", "the memory is outside the allowed range", "内存超出允许范围"), + ("SettingsJavaReasonJvmQuotes", "the JVM arguments have an unclosed quote", "JVM 参数里有没配对的引号"), + ("SettingsJavaReasonAddEmpty", "the path is empty", "路径是空的"), + ("SettingsJavaReasonAddMissing", "nothing exists at that path", "那个路径上什么都没有"), + ("SettingsJavaReasonAddNotJava", "there is no java executable at that path", "那个路径里没有 java 可执行文件"), + ("SettingsJavaReasonAddNoVersion", "java -version did not answer there, so it is not a usable JDK", "在那里跑不出 java -version,不是一份能用的 JDK"), + ("SettingsJavaReasonAddTooLong", "the path is too long to be a JDK location", "路径太长,不像是一个 JDK 位置"), + ("SettingsJavaReasonAddWriteFailed", "the JDK is fine, but the list file could not be written (disk or permission)", "这份 JDK 没问题,但名单文件写不进去(磁盘或权限)"), + ("SettingsJavaReasonAddDuplicate", "that JDK is already in the list", "这份 JDK 已经在列表里了"), + ("SettingsJavaReasonWriteFailed", "the settings file could not be written (disk full, no permission, or the value was rejected)", "设置写不进去(磁盘满、没权限,或这个值被拒绝)"), // M174:设置页「助手(AI)」分组(`catalog/settings/ai.rs` 的 `settings_ai_entries`)。 // 位置就是装配链里那个域的位置:`settings_ai_entries` 在 `settings_download_entries` 之后、 // `core_text_entries` 之前,所以这几行必须落在 `SettingsAppearanceCornerSuspended` 与 diff --git a/crates/i18n/src/catalog/settings/java.rs b/crates/i18n/src/catalog/settings/java.rs index db75b75..40e0c53 100644 --- a/crates/i18n/src/catalog/settings/java.rs +++ b/crates/i18n/src/catalog/settings/java.rs @@ -1,11 +1,154 @@ -//! 设置页「Java」分组文案:**预留**(页面侧的真控件还没接)。 +//! 设置页「Java」分组文案(M175):扫描 / 运行时列表 / 默认 JDK / 内存 / JVM 参数。 //! -//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。 -//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。 +//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域在装配链里排在 +//! `settings_appearance_entries` 之后、`settings_download_entries` 之前,所以金表那批行要插在 +//! `SettingsAppearanceCornerSuspended` 之后、`SettingsGroupAssistant`(M174 的助手域)之前 +//! ——**顺序即 `Key::ALL`**。 +//! +//! 三条口径: +//! +//! 1. 技术名词不翻:`JDK` / `JVM` / `-version` / `bin/java` / `java.selected_path` 原样; +//! 2. **不许出现写死的版本号或数字**:本组以前那三行(`Temurin 21.0.4` / `4 GiB`)正是被用户 +//! 当成假数据的那三条,现在所有数值都由页面从真读盘的结果里格式化进来; +//! 3. 拒绝原因一句一条(空 / 不存在 / 不是 java / 跑不出 -version / 重复),合成一句 +//! 「路径无效」等于让用户自己猜。 macro_rules! settings_java_entries { ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { - $next! { $($rest,)* { $($acc)* } } + $next! { $($rest,)* { + $($acc)* + // ── 扫描(状态 / 进度 / 取消) + SettingsJavaScan => ("Scan now", "立即扫描"), + SettingsJavaScanningStart => ("Scanning…", "正在扫描…"), + SettingsJavaScanning => ("Scanning… {done}/{total}", "正在扫描… {done}/{total}"), + SettingsJavaCancelling => ("Cancelling…", "正在取消…"), + SettingsJavaCancel => ("Cancel", "取消"), + SettingsJavaCancelled => ( + "The scan was cancelled; the list below is from the previous run.", + "扫描已取消;下面的列表还是上一次的结果。" + ), + SettingsJavaScanDone => ("{count} JDK found", "找到 {count} 个 JDK"), + SettingsJavaNeverScanned => ("Not scanned yet", "还没扫描过"), + SettingsJavaNeverScannedBody => ( + "Press Scan to look for installed JDKs (registry, common install folders, JAVA_HOME and PATH). Nothing runs until you do.", + "点「立即扫描」去找已装的 JDK(注册表、常见安装目录、JAVA_HOME 与 PATH)。在那之前启动器不会自己去扫。" + ), + SettingsJavaEmptyTitle => ("No Java runtime found", "未检测到 Java 运行时"), + SettingsJavaEmptyBody => ( + "Nothing matched. Add the path of a JDK you already unpacked below, or install one with `ull java install 21` on the command line.", + "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 `ull java install 21` 装一个。" + ), + // ── 运行时列表 + SettingsJavaDefault => ("Default", "默认"), + SettingsJavaSetDefault => ("Use as default", "设为默认"), + SettingsJavaFits => ("Meets the current instance requirement", "满足当前实例的要求"), + SettingsJavaTooOld => ( + "Below the requirement: this instance needs Java {minimum}+", + "低于要求:当前实例需要 Java {minimum} 及以上" + ), + SettingsJavaDefaultSet => ("Default JDK is now {path}", "默认 JDK 已设为 {path}"), + SettingsJavaSourceManaged => ("installed by this launcher", "本启动器安装"), + SettingsJavaSourceEnv => ("JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"), + SettingsJavaSourcePath => ("on PATH", "PATH 上"), + SettingsJavaSourceSystem => ("system install", "系统安装"), + SettingsJavaSourceManual => ("added by you", "手动添加"), + // ── 手动添加 + SettingsJavaAdd => ("Add a JDK path", "手动添加 JDK 路径"), + SettingsJavaAddPlaceholder => ( + "C:\\Program Files\\Java\\jdk-21", + "例:C:\\Program Files\\Java\\jdk-21" + ), + SettingsJavaAddHint => ( + "A JDK folder or its java executable. We run `java -version` on it; anything that cannot answer is refused.", + "JDK 目录或它的 java 可执行文件。会真跑一次 `java -version`;答不出话的一律拒绝。" + ), + SettingsJavaAddAction => ("Add", "添加"), + SettingsJavaAdded => ("Added {path}", "已添加 {path}"), + // ── 内存 + SettingsJavaMemory => ("Default memory (MB)", "默认内存(MB)"), + SettingsJavaMemoryHint => ( + "Whole megabytes, {min}–{max}. Instances without their own override follow this value.", + "整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。" + ), + SettingsJavaLayerGlobal => ( + "In effect: {mb} MB, from the global default.", + "生效:{mb} MB,来自全局默认值。" + ), + SettingsJavaLayerInstance => ( + "In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.", + "生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。" + ), + // ── JVM 参数 + SettingsJavaJvm => ("Extra JVM arguments", "额外 JVM 参数"), + SettingsJavaJvmPlaceholder => ("-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"), + SettingsJavaJvmHint => ( + "Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.", + "按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。" + ), + SettingsJavaJvmSaved => ("Saved value: {value}", "已保存的值:{value}"), + SettingsJavaJvmSavedNone => ( + "No extra JVM arguments are saved yet.", + "还没有保存任何额外参数。" + ), + // ── 保存与回执 + SettingsJavaSave => ("Save", "保存"), + SettingsJavaSaved => ( + "Saved and read back from disk. The next launch uses the new value.", + "已保存,并从磁盘读回确认。下次启动就用新值。" + ), + SettingsJavaUnsaved => ("There are unsaved changes.", "有未保存的改动。"), + SettingsJavaRefused => ("Not saved: {reason}", "未保存:{reason}"), + SettingsJavaSkipNotDirty => ( + "there was nothing to change, so no file was written", + "没有任何改动,因此没有写文件" + ), + SettingsJavaSkipReadOnly => ( + "the settings file is read-only: a newer launcher wrote it, or it is marked untrusted", + "配置文件只读:它被更新版本的启动器写过,或被标记为不可信" + ), + SettingsJavaSkipUnreadable => ( + "the settings file is untrusted, so it is treated as read-only", + "配置文件不可信,因此按只读处理" + ), + SettingsJavaNoStorage => ( + "No settings file is available in this run, so nothing can be scanned or saved.", + "这一次运行没有可用的设置文件,扫描与保存都做不了。" + ), + // ── 拒绝原因(一条一句) + SettingsJavaReasonMemoryEmpty => ("the memory field is empty", "内存没填"), + SettingsJavaReasonMemoryNotNumber => ("the memory must be a whole number of MB", "内存必须是整数 MB"), + SettingsJavaReasonMemoryRange => ( + "the memory is outside the allowed range", + "内存超出允许范围" + ), + SettingsJavaReasonJvmQuotes => ( + "the JVM arguments have an unclosed quote", + "JVM 参数里有没配对的引号" + ), + SettingsJavaReasonAddEmpty => ("the path is empty", "路径是空的"), + SettingsJavaReasonAddMissing => ("nothing exists at that path", "那个路径上什么都没有"), + SettingsJavaReasonAddNotJava => ( + "there is no java executable at that path", + "那个路径里没有 java 可执行文件" + ), + SettingsJavaReasonAddNoVersion => ( + "java -version did not answer there, so it is not a usable JDK", + "在那里跑不出 java -version,不是一份能用的 JDK" + ), + SettingsJavaReasonAddTooLong => ( + "the path is too long to be a JDK location", + "路径太长,不像是一个 JDK 位置" + ), + SettingsJavaReasonAddWriteFailed => ( + "the JDK is fine, but the list file could not be written (disk or permission)", + "这份 JDK 没问题,但名单文件写不进去(磁盘或权限)" + ), + SettingsJavaReasonAddDuplicate => ("that JDK is already in the list", "这份 JDK 已经在列表里了"), + SettingsJavaReasonWriteFailed => ( + "the settings file could not be written (disk full, no permission, or the value was rejected)", + "设置写不进去(磁盘满、没权限,或这个值被拒绝)" + ), + } } }; }