Merge branch 'feat/m204-javagroup-java-runtime-settings-gro'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-09 12:11:26 +08:00
commit 46559910c9
13 files changed
+724 -156

No files matched your search

@@ -72,6 +72,35 @@ impl App {
Task::none()
}
/// 三键写盘成功之后,请 presenter 重发一次设置快照(M204)。
///
/// ## 为什么需要这一拍
///
/// 本组的三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)走的是页面
/// 自己的 `SettingStore`(`pages/settings/java/store.rs`:`set` 几个键 + **一次** `save()`),
/// **不经过** `AppCommand::SetSetting` 的写盘分支。而 `CoreEvent::SettingsChanged` 全仓只有两处
/// 发出(`dispatch.rs` 的 `SetSetting` 分支、`host/mod.rs` 的 `refresh_all`)——不请一次,
/// `AppState::settings_groups` 里 java 组那几行会停在旧值,直到下一次任何别的写入。
///
/// ## 为什么不是 `AppCommand::RefreshAll`
///
/// `refresh_all()` 除重发快照之外还会 `versions::spawn_list`(拉版本清单)与
/// `start_update_check`(自更新检查)——每点一次「保存内存」打两个网络请求,代价与动作不相称。
/// 动作键 `JAVA_REFRESH_ACTION` 走的是同一条 `SetSetting` 分支的四个副作用臂,不写盘、不发网络。
///
/// ## 抓得住 / 抓不住
///
/// 抓得住:`tests/java_refresh.rs` 的 `saving_the_java_form_refreshes_the_host_settings_snapshot`
/// 用**真 `CoreHost` + 临时数据根**跑完整链,摘掉这一行的 dispatch 就红(实测)。
/// 抓不住:presenter 是否会因为**别的原因**(下一次外部写盘)自己刷新——所以那条用例的判据是
/// 「写盘后**当场**快照里有新值」,而不是「最终会有」。
fn request_settings_refresh(&mut self) {
self.dispatch(AppCommand::SetSetting {
key: unlucky_presenter::dto::JAVA_REFRESH_ACTION.to_string(),
value: String::new(),
});
}
/// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。
fn touch_java(&mut self) {
self.settings.java.dirty = true;
@@ -231,6 +260,8 @@ impl App {
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::DefaultSet(binary.to_string()));
// M204:这一笔也写进 launcher.json 了(`java.selected_path`),请 host 重发快照。
self.request_settings_refresh();
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
@@ -257,6 +288,8 @@ impl App {
self.settings.java.hydrate(snapshot);
self.settings.java.context = self.java_context();
self.settings.java.notice = Some(Notice::Saved);
// M204:内存 + JVM 参数已经落盘(一次 `store.save()`),请 host 重发快照。
self.request_settings_refresh();
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
@@ -85,6 +85,10 @@ impl App {
// 顺序要紧:`start_java_scan` 会清 `notice`,所以回执写在它之后。
let _ = self.start_java_scan();
self.settings.java.notice = Some(Notice::PrefsSaved);
// M204:三键也已落盘(`java.installRoot` / `java.auto_install` / `java.vendorPriority`
// 一次 `store.save()`),请 host 重发快照——否则 `AppState::settings_groups` 里
// java 组那三行停在旧值,要等下一次任何别的写入才跟上。
self.request_settings_refresh();
}
Err(error) => self.settings.java.notice = Some(write_notice(error)),
}
@@ -394,3 +394,7 @@ fn testing_without_configuration_does_nothing() {
/// M175 r1 复审的用例(拆出来守 400 行硬线;`gate::every_source_file_stays_under_the_line_limit` 会红)。
#[path = "tests/java.rs"]
mod java;
/// M204:Java 组写盘之后请 host 重发快照(动作键 `JAVA_REFRESH_ACTION`)。
#[path = "tests/java_refresh.rs"]
mod java_refresh;
@@ -0,0 +1,307 @@
//! M204:Java 组**写盘成功之后请 host 重发设置快照**这一拍(`JAVA_REFRESH_ACTION`)。
//!
//! ## 为什么需要这一条(缺口是什么)
//!
//! Java 组的三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)走的是页面
//! 自己的 `SettingStore`(`pages/settings/java/store.rs`:`set` 几个键 + **一次** `save()`)——
//! 那是**同步、原子**的一次落盘,但**不经过** `AppCommand::SetSetting` 的写盘分支。而
//! `CoreEvent::SettingsChanged` 全仓只有两处发出:`host/dispatch.rs` 的 `SetSetting` 分支与
//! `host/mod.rs` 的 `refresh_all`。于是三键写成功之后,`AppState::settings_groups` 里 java 组那几行
//! 会**停在旧值**,直到下一次任何别的写入(改并发、改主题……)顺手带一份新快照回来。
//!
//! M204 的修法:落盘成功之后派发一条动作键(`dto::JAVA_REFRESH_ACTION`,`host::settings::set`
//! 见到它只返回 `Ok(())`,不写盘),让 `SetSetting` 分支照常重发 `SettingsChanged`。**没有**改用
//! `RefreshAll`:那个还会 `versions::spawn_list` 与 `start_update_check`,每点一次保存打两个网络请求。
//!
//! ## 两条用例证明的事不同
//!
//! | 用例 | 后端 | 证明 |
//! |---|---|---|
//! | 第一条 | **真 `CoreHost` + 临时数据根** | 写盘 ⇒ 快照真的变了(磁盘 + 快照两头的字节级证据) |
//! | 第二条 | 假后端(记账)+ 本地校验拒绝 | **被拒的保存不许请求刷新**(那一拍只该跟在真落盘之后) |
use std::cell::RefCell;
use std::rc::Rc;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::{find_row, AppCommand, AppEvent, Backend, SettingGroupView};
use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::cli::Options;
use crate::pages;
use crate::theme::Families;
use super::*;
/// 本组要断言的键(与 presenter 快照 / schema 同名)。
const JVM: &str = "java.extra_args";
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 一条 Java 分组消息 → 页面消息(与 `tests/java.rs` 同一个漏斗)。
fn java(message: pages::settings::java::Message) -> Message {
Message::Page(pages::Message::Settings(
pages::settings::GroupMessage::Java(message),
))
}
/// 快照里某个键的**上屏文本**(`None` = 快照里没这一行)。
///
/// 用 `value_text` 而不是控件序号:Java 组这三个键里 `extra_args` / `selected_path` 是 text 行,
/// `value_text` 就是文件里的原文;内存那一行是四档 choice(见报告「没做/没验」第 ③ 条),
/// 它的**当前值**表达在 `options[selected]` 上,所以本文件不对内存行做取值断言——那会把
/// 「presenter 的四档表达不了 6144」这件事伪装成绿。
fn row_value(groups: &[SettingGroupView], key: &str) -> Option<String> {
find_row(groups, key).map(|row| row.value_text.clone())
}
/// **主用例**:改内存 + JVM 参数 ⇒ 点保存 ⇒ 磁盘变了 **且** host 发回来的快照也变了。
///
/// 链子全长(与本组的产品路径逐段对应):`Message::EditMemory/EditJvm` → `State` 草稿 →
/// `Message::Save` → `store::save`(一次 `store.save()` 写两个键)→ `request_settings_refresh()`
/// → 真 `CoreHost` 的 `SetSetting` 分支(`settings::set` 认动作键、不写盘)→
/// `CoreEvent::SettingsChanged(settings::groups(env))` → `AppState::settings_groups` →
/// `pages::Model::settings`。断言落在**磁盘字节**与**快照文本**上,不落在任何中间变量上。
///
/// 抓得住:
/// 1. 摘掉 `request_settings_refresh()` 的 dispatch(`save_java_settings` 里那一行)⇒ 快照仍是
/// 空串 ⇒ 红(**这就是本用例的变异验证**,且是本文件第二条都不红的唯一原因);
/// 2. 写盘没真发生(`store::save` 的 `save()` 被删)⇒ 磁盘断言红;
/// 3. 动作键在 presenter 侧被当成真键(`host::settings::set` 少了那条早退)⇒ `store.set` 报
/// 「未知键」⇒ 页面拿到 error Toast、快照不更新 ⇒ 红;
/// 4. 快照回流被丢掉(`view::model` 的 `settings:` 那行被删)⇒ `app.view.settings` 空 ⇒ 红。
///
/// 抓不住:真实鼠标点击(本用例从 `Message` 起跑);「页面上的值是不是旧值」这一半——写盘成功后
/// 页面本来就该显示新值,旧值那一半由 `tests/java.rs` 的拒绝用例守(本地校验不过时文件一个字节
/// 不动、回执是具名原因)。
#[test]
fn saving_the_java_form_refreshes_the_host_settings_snapshot() {
let dir = std::env::temp_dir().join(format!("ull-m204-java-refresh-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
let config = dir.join(".ULL/config/launcher.json");
let env = unlucky_presenter::host::HostEnv::at_root(&dir);
let mut app = App::with_presenter(
Options {
data_dir: Some(dir.clone()),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(unlucky_presenter::host::CoreHost::new(env)),
// 设置桥也指向同一个根:不碰用户的真配置。
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
);
// 首屏:产品路径在 `on_boot` 里派的就是这一条(真 host 会发 `SettingsChanged`)。
app.dispatch(AppCommand::RefreshAll);
assert_eq!(
row_value(app.settings_snapshot(), JVM).as_deref(),
Some(""),
"临时根里没有 `java.extra_args` ⇒ 快照这一行是空串(出厂值就是没有额外参数)"
);
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(),
)));
let _ = app.update(java(pages::settings::java::Message::Save));
let written = std::fs::read_to_string(&config).expect("写回之后 launcher.json 必须存在");
assert!(
written.contains("\"default_memory_mb\": 6144"),
"磁盘上必须真的有 6144:{written}"
);
assert!(
written.contains("-XX:+UseG1GC"),
"磁盘上必须真的有那段 JVM 参数:{written}"
);
assert_eq!(
row_value(app.settings_snapshot(), JVM).as_deref(),
Some("-XX:+UseG1GC"),
"host 必须为刚写进去的三键重发一次快照(否则 AppState 里这一行还是旧值)"
);
assert_eq!(
row_value(&app.view.settings, JVM).as_deref(),
Some("-XX:+UseG1GC"),
"页面模型也得跟着换:下一帧画的是 `Model.settings`"
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 被拒的保存不许请求刷新
/// 只记账、不回事件的后端(本用例只关心「有没有发出那条动作键」)。
struct Recorder(Rc<RefCell<Vec<AppCommand>>>);
impl Backend for Recorder {
fn drain_events(&mut self) -> Vec<AppEvent> {
Vec::new()
}
fn dispatch(&mut self, command: AppCommand) {
self.0.borrow_mut().push(command);
}
}
/// 本地校验把保存拦下来时,**一次刷新都不许请求**:那一拍只该跟在真落盘之后。
///
/// 抓得住:把 `request_settings_refresh()` 挪到 `validate()` 之前(或无条件调用)⇒ 这里能看见
/// 动作键 ⇒ 红。抓不住:写盘失败的回执文案(那是 `tests/java.rs` 的 `write_notice` 用例)。
#[test]
fn a_refused_save_does_not_ask_for_a_refresh() {
use pages::settings::java::Notice;
let commands = Rc::new(RefCell::new(Vec::new()));
let dir = std::env::temp_dir().join(format!("ull-m204-java-refuse-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
let path = dir.join("config/launcher.json");
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&commands))),
SettingsBridge::with_path(&path),
);
// 128 MB 低于 schema 的下限(512):本地校验就该拦下,不写盘。
let _ = app.update(java(pages::settings::java::Message::EditMemory(
"128".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::Save));
// 越界的内存要在本地就被拒(具名原因 = schema 下限 512 那条文案,不是通用的「写不进去」)。
let notice = app
.settings
.java
.notice
.clone()
.expect("被拒的保存必须留下一条回执");
let Notice::Refused(reason) = notice else {
panic!("越界的内存必须是具名的本地拒绝:{notice:?}");
};
assert_eq!(
reason.label(),
unlucky_i18n::Key::SettingsJavaReasonMemoryRange,
"拒绝原因要落到「超出允许范围」那条文案上"
);
assert!(!path.exists(), "被拒的保存不许碰磁盘");
assert!(
!commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, .. }
if key == unlucky_presenter::dto::JAVA_REFRESH_ACTION
)),
"没落盘就没有可刷新的事实(实际下发:{:?})",
commands.borrow()
);
std::fs::remove_dir_all(&dir).ok();
}
/// 真的落盘时,动作键**必须**发出去(正面那一半;与第一条互为背书)。
///
/// 为什么还要这一条:第一条用真 `CoreHost`,它的失败可能是「快照没回来」的任意一环;这一条把
/// 「到底有没有派出那条动作键」单独钉住,红的行号不同,排查时不必再猜是哪一环。
///
/// 抓得住:`request_settings_refresh()` 的键名写错(比如漏了 `action` 段)⇒ presenter 会把它当
/// 真键报「未知键」,而这条用例在本层就能看见键名对不对。
/// 抓不住:presenter 收到动作键之后有没有真的重发快照(那是第一条的事)。
#[test]
fn a_successful_save_dispatches_the_refresh_action_key() {
let commands = Rc::new(RefCell::new(Vec::new()));
let dir = std::env::temp_dir().join(format!("ull-m204-java-dispatch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
let path = dir.join("config/launcher.json");
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder(Rc::clone(&commands))),
SettingsBridge::with_path(&path),
);
let _ = app.update(java(pages::settings::java::Message::EditJvm(
"-XX:+UseG1GC".to_string(),
)));
let _ = app.update(java(pages::settings::java::Message::Save));
assert!(path.exists(), "这一条的前提是保存真的落盘了(临时根可写)");
assert!(
commands.borrow().iter().any(|command| matches!(
command,
AppCommand::SetSetting { key, .. }
if key == unlucky_presenter::dto::JAVA_REFRESH_ACTION
)),
"落盘之后必须派发动作键(实际下发:{:?})",
commands.borrow()
);
std::fs::remove_dir_all(&dir).ok();
}
// --------------------------------------------------------------- 「立即扫描」真的接线
/// 「立即扫描」在 App 层是**真接线**的:`Message::Scan` ⇒ 起一次真扫盘(不是只画了颗按钮)。
///
/// 为什么这一条住在本文件(而不是 `tests/java.rs`):那个文件 374 行、`tests.rs` 正好 400 行,
/// 再开一个模块文件就要往 `tests.rs` 加一行 `mod`(400 → 401,撞 `gate` 的 400 行红线)。
/// 主题上也勉强算同一族:两者都是「设置页那一组对 App 做了什么」的守卫(这条守扫描入口,
/// 上面几条守写回闭环)。
///
/// 抓得住:`on_settings_java` 的 `Message::Scan => return self.start_java_scan()` 被改成
/// `Task::none()`(只画按钮不接线 —— 用户在早期投诉里点名的那一条)⇒ `report().running`
/// 仍是 `false`、`scanning` 仍是 `None` ⇒ 红(实测见报告「变异验证」M3)。
/// 抓不住:扫描**结论**是否写对了(那是 `pages/settings/java/tests/runtime.rs` 的事),
/// 也抓不住真实鼠标点击(本用例从 `Message` 起跑)。
#[test]
fn pressing_scan_starts_a_real_scan() {
// 用截图档构造(`Options.screenshot = Some(..)`)**只为一个目的**:让首屏那次
// `auto_scan_java_if_due` 不发生(`hydrate_java_settings` 的截图分支灌种子后直接 return),
// 于是「开局没有扫描在跑」这个前提是确定的。手动点「立即扫描」走的是**同一条产品代码**
// (`start_java_scan` → `ScanSlot::start`),截图档不拦它。
let dir = std::env::temp_dir().join(format!("ull-m204-java-scan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("config")).expect("config 目录");
let path = dir.join("config/launcher.json");
let mut app = App::with_presenter(
Options {
screenshot: Some(std::path::PathBuf::from("m204-scan.png")),
..Options::default()
},
FAMILIES,
SchemeFlavor::Light,
Box::new(Recorder(Rc::new(RefCell::new(Vec::new())))),
SettingsBridge::with_path(&path),
);
assert!(
!app.settings.java.scan.report().running,
"这一条的前提:构造时没有扫描在跑"
);
let _ = app.update(java(pages::settings::java::Message::Scan));
let report = app.settings.java.scan.report();
assert!(
report.running,
"「立即扫描」必须真的起一条扫描线程:{report:?}"
);
assert!(
app.settings.java.scanning.is_some(),
"状态行要能区分「正在扫描」(`scanning` 不能还是 None)"
);
// 别把这条用例起的真扫盘留给后面的用例:置取消位,线程会在下一个候选边界退出。
app.settings.java.scan.cancel();
std::fs::remove_dir_all(&dir).ok();
}
+56
View File
@@ -30,6 +30,50 @@ use instances::{detail_of, runtime_status_row, supporting_line};
use logs::logs_model;
use update::update_of;
/// 上一次记过的「设置页看到的组 + 主题」(`None` = 还没记过)。
///
/// 只给 [`note_screenshot_group`] 用:`view` 每帧都会跑,日志必须只在身份**变化**时落一行。
/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-m3-light` / `ull-m3-dark`),
/// 把两件事挤进一个整数要引入「高半位 = 深色」这种与主题命名耦合的约定,不如老实用一对值。
static SHOT_GROUP_LOGGED: std::sync::Mutex<Option<(usize, String)>> = std::sync::Mutex::new(None);
/// D30 §2.1 的「哪一行日志证明它是哪个组/主题」:出图通道专用的一行身份日志。
///
/// ## 为什么必须有
///
/// 举证图自带不了身份。M201 的事故就是 worker 交了一张「外观」页当下载组的证据,报告里的结论
/// 建立在一个错的「这是哪个组」上,直到视觉 reviewer 自己重拍才发现。所以每张交付图都要能对上
/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-m3-light`。
///
/// ## 为什么只在变化时打
///
/// `App::view` 每帧都调用;每帧一行会把 `launcher.log` 刷满,反而没人能从中找到那一行。
/// 判据是(组序号,主题名)这一对——同一次截图进程里最多两行(首帧 + 主题切一次)。
///
/// ## 为什么是 `try_lock` 而不是 `lock`
///
/// 这里在 UI 线程的每帧路径上。`try_lock` 拿不到锁就**直接返回**(这一帧不打,下一帧还会再来,
/// 身份行只是晚一拍、不会丢),于是「界面帧永远不会被这把锁挡住」是**代码保证的**,不是靠
/// 「今天只有一个调用点、锁里只有一次比较」这种会随改动失效的前提。M204 r1 复审把原先那句
/// 「拿不到锁就不打」和当时用的阻塞 `lock()` 对上了:注释承诺的保证必须由代码提供。
///
/// **非截图通道一律不打**:交互模式下用户每点一次左列就多一行日志,没有价值。
fn note_screenshot_group(screenshot: bool, route: &Route, group: usize, theme: &str) {
if !screenshot {
return;
}
let Ok(mut last) = SHOT_GROUP_LOGGED.try_lock() else {
return;
};
let seen = (group, theme.to_string());
if last.as_ref() == Some(&seen) {
return;
}
*last = Some(seen);
drop(last);
crate::app::diag::diag!("[shot] settings route={route:?} group={group} theme={theme}");
}
impl App {
/// 主题名(iced 只在 `Theme::name()` 变化时重算受主题影响的缓存)。
pub(super) fn theme(&self) -> Theme {
@@ -45,6 +89,18 @@ impl App {
pub(super) fn view(&self) -> Element<'_, Message> {
log_first_frame();
// D30 §2.1:出图通道要有一行能**自证身份**的日志——「这张图是哪个设置组、哪个主题」。
// 上一次 M201 的事故就是拿外观页当下载组的证据;图不自证身份,结论就无处落地。
// 只对设置页打(左列那几组才有「哪个组」这件事)。
if self.state.route() == &Route::Settings {
note_screenshot_group(
self.options.screenshot.is_some(),
self.state.route(),
self.appearance.selected_group,
self.ui().name(),
);
}
// `--screen=gallery`:组件库 harness(D39 §5)。它用自己的 DemoMessage,
// 这里只做一次 map,组件库与业务 Message 依然解耦。
if self.options.screen == Screen::Gallery {
@@ -28,6 +28,7 @@ pub(crate) mod prefs;
pub(crate) mod prefs_store;
mod prefs_view;
mod row;
pub(crate) mod scan;
mod seed;
mod state;
pub(crate) mod store;
@@ -0,0 +1,188 @@
//! 「Java」分组的**扫描槽与生产作业**(M204 r1 从 `store.rs` 拆出:那个文件加了
//! `ScanSlot::seed_report` 之后 408 行,越过 400 行硬线)。
//!
//! 两件事:
//!
//! | 东西 | 为什么在这 |
//! |---|---|
//! | [`ScanSlot`] | 后台线程写、界面每拍读的快照 + 取消位 + 「线程还活着」。它是**每份 `State` 一个**的槽(不是进程级 static),理由见结构体文档 |
//! | [`run_staged`] / [`rows`] | 生产作业:真扫盘 + 逐个探测(超时与取消都在 `core::game::java::staged::scan` 里),并把 `JavaRuntime` 翻成页面行 |
//!
//! `store.rs` 用 `pub use` 把 [`Job`] / [`ScanReport`] / [`ScanSlot`] 再导出,所以
//! `pages::settings::java::store::ScanSlot` 这类既有路径**一个都不用改**。
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use unlucky_core::game::java::{staged, JavaRuntime};
use super::state::RuntimeRow;
/// 扫描结果快照(后台线程写、界面读)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScanReport {
/// 扫描线程**还活着**(取消之后到它真退出之前仍是 `true`——这一段界面上叫「正在取消…」)
pub running: bool,
/// 已探测
pub done: usize,
/// 候选总数(`0` = 候选表还在收集,界面不显示 `0/0`)
pub total: usize,
/// 至少完整扫完过一次
pub finished: bool,
/// 用户按过取消(线程还活着时是 `true`)
pub cancelled: bool,
/// 扫描结论(`finished` 且没被取消时有效)
pub runtimes: Vec<RuntimeRow>,
}
/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。
pub type Job =
fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec<RuntimeRow>;
/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。
///
/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处——
/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例
/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。
///
/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。
#[derive(Debug, Clone, Default)]
pub struct ScanSlot {
inner: std::sync::Arc<ScanInner>,
}
#[derive(Debug, Default)]
struct ScanInner {
report: Mutex<ScanReport>,
cancel: AtomicBool,
}
impl ScanSlot {
/// 当前快照(界面每拍读一次;锁只在这几行里持有)。
#[must_use]
pub fn report(&self) -> ScanReport {
self.inner
.report
.lock()
.map(|report| report.clone())
.unwrap_or_default()
}
/// 把快照**直接摆成**给定值(只给 `--screenshot` 的种子用,M204 r1)。
///
/// 为什么种子不能只写 `State.scanning`:`clock.advance` 每拍都调
/// `hydrate_java_scan` → `State::apply_scan(&self.scan.report())`,而 `scanning`
/// 是从**槽里的快照**推出来的。只改 `State` 的话,第一拍就会被推回「没在扫」,
/// 出图拍到的是另一种状态。与 `downloads::Slot::seed_report` 同一个理由、同一种做法。
///
/// 与 [`ScanSlot::start`] 的区别:这里不起线程、不摸取消位,只摆一个静态快照——
/// 出图要的是**可复现**的一帧,不是真的在扫。
pub fn seed_report(&self, report: ScanReport) {
if let Ok(mut slot) = self.inner.report.lock() {
*slot = report;
}
}
/// 取消正在跑的扫描:置取消位 + 标 `cancelled`,**不**动 `running`。
///
/// `running` 由扫描线程自己翻——它是「线程还活着」的唯一真相。取消若也翻它,`start()` 的
/// 「已在扫」判据就失效,用户能在旧线程还卡着的时候起第二个(复审 P2-5 的第三个问题)。
pub fn cancel(&self) {
self.inner.cancel.store(true, Ordering::SeqCst);
if let Ok(mut report) = self.inner.report.lock() {
if report.running {
report.cancelled = true;
}
}
}
/// 发起一次扫描(已在扫 ⇒ 什么都不做,返回 `false`)。
pub fn start(&self, roots: Vec<PathBuf>, manual: Vec<PathBuf>, system: bool) -> bool {
self.start_with(roots, manual, system, run_staged)
}
/// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。
pub fn start_with(
&self,
roots: Vec<PathBuf>,
manual: Vec<PathBuf>,
system: bool,
job: Job,
) -> bool {
{
let Ok(mut report) = self.inner.report.lock() else {
return false;
};
if report.running {
return false;
}
// 「占位」与「线程起来了」在同一把锁里完成:两个调用不可能同时通过上面那道判据。
*report = ScanReport {
running: true,
..ScanReport::default()
};
self.inner.cancel.store(false, Ordering::SeqCst);
}
let slot = self.clone();
std::thread::spawn(move || {
// 候选收集(注册表 / 常见目录)放在线程里:它是首屏那一拍最贵的一段,
// 放在 UI 线程上会拖慢开窗(复审 P2-5 的附带项)。
let candidates = staged::candidates(&roots, &manual, system);
if let Ok(mut report) = slot.inner.report.lock() {
report.total = candidates.len();
}
let cancelled = || slot.inner.cancel.load(Ordering::SeqCst);
let mut progress = |done: usize, _total: usize| {
if let Ok(mut report) = slot.inner.report.lock() {
report.done = done;
}
};
let runtimes = job(&candidates, &cancelled, &mut progress);
if let Ok(mut report) = slot.inner.report.lock() {
report.running = false;
report.done = candidates.len();
if !report.cancelled {
report.finished = true;
report.runtimes = runtimes;
}
}
});
true
}
}
/// 生产扫描作业:真扫盘 + 逐个探测(超时与取消都在 `staged::scan` 里)。
fn run_staged(
candidates: &[staged::Candidate],
cancelled: &dyn Fn() -> bool,
progress: &mut dyn FnMut(usize, usize),
) -> Vec<RuntimeRow> {
let found = staged::scan(
candidates,
|done, total| {
progress(done, total);
true
},
cancelled,
);
rows(&found, candidates)
}
/// `JavaRuntime` → 页面行(来源从候选表里按路径回查:`JavaRuntime.origin` 分不出「手动」)。
fn rows(found: &[JavaRuntime], candidates: &[staged::Candidate]) -> Vec<RuntimeRow> {
found
.iter()
.map(|runtime| RuntimeRow {
binary: runtime.binary.display().to_string(),
version: runtime.version.clone().unwrap_or_default(),
major: runtime.major,
vendor: runtime.vendor.clone(),
source: candidates
.iter()
.find(|candidate| candidate.binary == runtime.binary)
.map_or("system", |candidate| candidate.source.id()),
})
.collect()
}
@@ -12,6 +12,11 @@
//! 与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` / `ULL_FONT_STATUS` 同一族取证开关:只影响这一次进程,
//! 产品路径不设它。种子里的路径是**写死的示例路径**(真实机器上没有),所以它只在出图通道里出现;
//! 产品路径的真值一律来自 `core::game::java::staged` 的真扫盘。
//!
//! M204 r1(视觉复审 p2-1)又补了三档:`scanning`(正在扫描… 3/7,「取消」可点)/ `unscanned`
//! (还没扫描过)/ `cancelling`(正在取消…,两颗按钮都灰)。起因是六张举证图里「取消」全程是
//! 禁用态,「扫描中」一格都没拍,而 README 却写成「两态」——**证据覆盖不到结论**(M201 同一个坑)。
//! 这三档都把快照写进 `ScanSlot`(`store::ScanSlot::seed_report`),理由见那个函数的文档。
use super::state::{Context, Layer, Notice, Refusal, Requirement, RuntimeRow, State};
@@ -74,6 +79,53 @@ pub fn build(raw: &str) -> Option<State> {
seeded.notice = Some(Notice::Refused(Refusal::MemoryRange));
return Some(seeded);
}
// ── M204 r1(视觉复审 p2-1):扫描的三格。复审指出六张图里「取消」全程是禁用态、
// 「正在扫描… 3/7」一张没有,而 README 却写成了「两态」——这里补上能画出来的三格
// (扫描中 / 还没扫描过 / 正在取消…;「扫描已取消…」那一句仍没有图,见 README §3)。
//
// 三档都把快照**写进槽**(`ScanSlot::seed_report`)而不只是改 `State.scanning`:
// 心跳每拍用槽里的快照重算 `scanning`,只改 `State` 的话第一拍就被推回「没在扫」。
"scanning" => {
let mut seeded = build("two")?;
// 扫描中:列表还没有结论(`scanned` 仍是 false),状态行画「正在扫描… 3/7」,
// 「立即扫描」灰掉、「取消」可点(`view.rs` 的 `scanning && !cancelled`)。
seeded.scanned = false;
seeded.runtimes.clear();
seeded.cancelled = false;
seeded.scanning = Some(super::state::Progress { done: 3, total: 7 });
seeded.scan.seed_report(super::store::ScanReport {
running: true,
done: 3,
total: 7,
finished: false,
cancelled: false,
runtimes: Vec::new(),
});
return Some(seeded);
}
"unscanned" => {
// 还没扫描过:状态行「还没扫描过」,列表处画「列表还是空的…」(`view.rs:110-113`)。
let mut seeded = build("empty")?;
seeded.scanned = false;
seeded.scanning = None;
seeded.cancelled = false;
seeded.scan.seed_report(super::store::ScanReport::default());
return Some(seeded);
}
"cancelling" => {
// 取消已按下、线程还没退出:状态行「正在取消…」、两颗按钮都灰(复审点名的另一格)。
let mut seeded = build("scanning")?;
seeded.cancelled = true;
seeded.scan.seed_report(super::store::ScanReport {
running: true,
done: 3,
total: 7,
finished: false,
cancelled: true,
runtimes: Vec::new(),
});
return Some(seeded);
}
// ── M197:「下载一个 JDK」的三态
"download" => {
let mut seeded = build("two")?;
@@ -32,168 +32,18 @@
//! 代价远大于收益;而 `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::game::java::{discover, manual, JavaRuntime};
use unlucky_core::platform::paths::{Platform, ProcessEnv};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
use super::state::{Refusal, RuntimeRow, Snapshot, State, Unwritable};
/// 扫描结果快照(后台线程写、界面读)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScanReport {
/// 扫描线程**还活着**(取消之后到它真退出之前仍是 `true`——这一段界面上叫「正在取消…」)
pub running: bool,
/// 已探测
pub done: usize,
/// 候选总数(`0` = 候选表还在收集,界面不显示 `0/0`)
pub total: usize,
/// 至少完整扫完过一次
pub finished: bool,
/// 用户按过取消(线程还活着时是 `true`)
pub cancelled: bool,
/// 扫描结论(`finished` 且没被取消时有效)
pub runtimes: Vec<RuntimeRow>,
}
/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。
pub type Job =
fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec<RuntimeRow>;
/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。
///
/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处——
/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例
/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。
///
/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。
#[derive(Debug, Clone, Default)]
pub struct ScanSlot {
inner: std::sync::Arc<ScanInner>,
}
#[derive(Debug, Default)]
struct ScanInner {
report: Mutex<ScanReport>,
cancel: AtomicBool,
}
impl ScanSlot {
/// 当前快照(界面每拍读一次;锁只在这几行里持有)。
#[must_use]
pub fn report(&self) -> ScanReport {
self.inner
.report
.lock()
.map(|report| report.clone())
.unwrap_or_default()
}
/// 取消正在跑的扫描:置取消位 + 标 `cancelled`,**不**动 `running`。
///
/// `running` 由扫描线程自己翻——它是「线程还活着」的唯一真相。取消若也翻它,`start()` 的
/// 「已在扫」判据就失效,用户能在旧线程还卡着的时候起第二个(复审 P2-5 的第三个问题)。
pub fn cancel(&self) {
self.inner.cancel.store(true, Ordering::SeqCst);
if let Ok(mut report) = self.inner.report.lock() {
if report.running {
report.cancelled = true;
}
}
}
/// 发起一次扫描(已在扫 ⇒ 什么都不做,返回 `false`)。
pub fn start(&self, roots: Vec<PathBuf>, manual: Vec<PathBuf>, system: bool) -> bool {
self.start_with(roots, manual, system, run_staged)
}
/// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。
pub fn start_with(
&self,
roots: Vec<PathBuf>,
manual: Vec<PathBuf>,
system: bool,
job: Job,
) -> bool {
{
let Ok(mut report) = self.inner.report.lock() else {
return false;
};
if report.running {
return false;
}
// 「占位」与「线程起来了」在同一把锁里完成:两个调用不可能同时通过上面那道判据。
*report = ScanReport {
running: true,
..ScanReport::default()
};
self.inner.cancel.store(false, Ordering::SeqCst);
}
let slot = self.clone();
std::thread::spawn(move || {
// 候选收集(注册表 / 常见目录)放在线程里:它是首屏那一拍最贵的一段,
// 放在 UI 线程上会拖慢开窗(复审 P2-5 的附带项)。
let candidates = staged::candidates(&roots, &manual, system);
if let Ok(mut report) = slot.inner.report.lock() {
report.total = candidates.len();
}
let cancelled = || slot.inner.cancel.load(Ordering::SeqCst);
let mut progress = |done: usize, _total: usize| {
if let Ok(mut report) = slot.inner.report.lock() {
report.done = done;
}
};
let runtimes = job(&candidates, &cancelled, &mut progress);
if let Ok(mut report) = slot.inner.report.lock() {
report.running = false;
report.done = candidates.len();
if !report.cancelled {
report.finished = true;
report.runtimes = runtimes;
}
}
});
true
}
}
/// 生产扫描作业:真扫盘 + 逐个探测(超时与取消都在 `staged::scan` 里)。
fn run_staged(
candidates: &[staged::Candidate],
cancelled: &dyn Fn() -> bool,
progress: &mut dyn FnMut(usize, usize),
) -> Vec<RuntimeRow> {
let found = staged::scan(
candidates,
|done, total| {
progress(done, total);
true
},
cancelled,
);
rows(&found, candidates)
}
/// `JavaRuntime` → 页面行(来源从候选表里按路径回查:`JavaRuntime.origin` 分不出「手动」)。
fn rows(found: &[JavaRuntime], candidates: &[staged::Candidate]) -> Vec<RuntimeRow> {
found
.iter()
.map(|runtime| RuntimeRow {
binary: runtime.binary.display().to_string(),
version: runtime.version.clone().unwrap_or_default(),
major: runtime.major,
vendor: runtime.vendor.clone(),
source: candidates
.iter()
.find(|candidate| candidate.binary == runtime.binary)
.map_or("system", |candidate| candidate.source.id()),
})
.collect()
}
// M204 r1:扫描槽与生产作业搬到了 [`super::scan`](本文件加 `ScanSlot::seed_report` 之后
// 408 行,越过 400 行硬线)。这里再导出,`store::ScanSlot` / `store::ScanReport` 这些既有路径
// **一个都不用改**;`Job` 只被 `scan` 内部用,不外抛(外抛会让本文件多一条 unused 警告)。
pub use super::scan::{ScanReport, ScanSlot};
/// 该不该**自动**发起一次扫描(首屏 / 配置文件刚被外部改过时)。
///
@@ -220,6 +220,42 @@ fn the_screenshot_seeds_cover_the_three_states() {
assert!(State::screenshot_seed("nonsense").is_none());
}
/// M204 r1:扫描的三档(扫描中 / 还没扫描过 / 正在取消)——视觉复审 p2-1 要的正是这三格。
///
/// 每档的判据都落在**界面真的会读的那两个字段**上:`State.scanning`(状态行画什么)与
/// `State.scan.report().running`(心跳下一拍会不会把 `scanning` 推开)。
///
/// 变异证明:把 `"scanning"` 档里的 `scan.seed_report(...)` 换成不调 ⇒ `scanning` 在心跳第一拍
/// 就被推回 `None`(`apply_scan` 用槽里的快照重算)⇒ 第二段红。这也正是这两档必须写槽、
/// 不能只改 `State` 的理由。
#[test]
fn the_scan_seeds_cover_the_running_states() {
let scanning = State::screenshot_seed("scanning").expect("scanning 档");
assert_eq!(
scanning.scanning,
Some(super::super::state::Progress { done: 3, total: 7 }),
"状态行要画「正在扫描… 3/7」"
);
assert!(
scanning.scan.report().running,
"槽里的快照也得是「在扫」,否则下一拍就把 scanning 推开"
);
assert!(!scanning.scanned, "还没扫完过");
assert!(!scanning.cancelled, "没按过取消 ⇒「取消」这颗按钮可点");
assert!(scanning.runtimes.is_empty(), "扫描中列表还没有结论");
let cancelling = State::screenshot_seed("cancelling").expect("cancelling 档");
assert!(cancelling.cancelled && cancelling.scan.report().running);
assert!(
cancelling.scanning.is_some(),
"线程还没退出 ⇒ 状态行仍是活动态(画的是「正在取消…」)"
);
let unscanned = State::screenshot_seed("unscanned").expect("unscanned 档");
assert!(unscanned.scanning.is_none() && !unscanned.scanned);
assert!(!unscanned.scan.report().running, "这一档没有扫描在跑");
}
/// M197 的三档:版本选择列表 / 下载中 / 下载失败——出图靠它们,所以每档都要有**可断言的特征**。
///
/// 变异证明:把 `seed::build` 里 `"downloading"` 那一档的 `seed_report` 换成 `Report::default()`
+3 -1
View File
@@ -67,7 +67,9 @@ pub use pack::{
ImportState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
};
pub use settings::{find_row, SettingControl, SettingGroupView, SettingRowView};
pub use settings::{
find_row, SettingControl, SettingGroupView, SettingRowView, JAVA_REFRESH_ACTION,
};
// M159: 自更新
pub use update::{
ReadyUpdate, UpdateBanner, UpdateCandidateView, UpdateFailureView, UpdateOutcome, UpdateView,
+27
View File
@@ -6,6 +6,33 @@
use serde::{Deserialize, Serialize};
/// 设置页 Java 组「写回完成,请重发快照」的动作键(M204)。
///
/// ## 它解决的问题
///
/// Java 组把三键(`java.default_memory_mb` / `java.extra_args` / `java.selected_path`)**一次原子**
/// 写进 `launcher.json`(`app-iced` 的 `pages/settings/java/store.rs`:`set` 几个键 + 一次
/// `save()`),走的是页面自己的 `SettingStore`,**不经过** [`crate::dto::AppCommand::SetSetting`]
/// 的写盘分支。而 `CoreEvent::SettingsChanged` 全仓只有两处发出(`host/dispatch.rs` 的
/// `SetSetting` 分支、`host/mod.rs` 的 `refresh_all`)。于是三键写成功之后,presenter 的 java 组
/// 快照会陈旧到下一次任何别的写入——`AppState::settings_groups` 里的 `java.selected_path` /
/// `java.extra_args` 还是旧值。
///
/// ## 为什么不是「把写盘搬进 SetSetting」
///
/// 那会把一次原子写拆成最多三条命令(`SetSetting` 一次只收一个键),于是内存与 JVM 参数的保存
/// 会出现「第一键落盘、第二键被 schema 拒」的中间态;每条命令还要各走一次异步落盘,用户点一次
/// 保存会看到三次写盘与三次快照回流。取舍见 M204 报告。
///
/// ## 形状
///
/// 与 `host::engine::DETECT_REGION_ACTION`(`download.action.detect_region`,M165)同一族:
/// **动作键不写盘**,`host::settings::set` 见到它就返回 `Ok(())`,于是 `dispatch.rs` 的
/// `SetSetting` 分支照常执行它那四个副作用臂(重建引擎 / 重发 `SettingsChanged` / 清 Java 链)。
/// 键名带 `action` 段,是为了让人一眼看出它**不是** schema 键(`core::setting::keys` 里没有它,
/// 手写进 `launcher.json` 也只会被当成未知键忽略)。
pub const JAVA_REFRESH_ACTION: &str = "java.action.refresh";
/// 控件类型(决定设置页画什么)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SettingControl {
+8
View File
@@ -250,6 +250,14 @@ pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
if key == super::engine::DETECT_REGION_ACTION {
return detect_region(env);
}
// M204:设置页 Java 组的「写回完成,重发快照」动作键。**不写盘**——页面那一侧已经把三键
// 一次原子写进 `launcher.json` 了(`pages/settings/java/store.rs::save`),这里只要返回
// `Ok(())`,`dispatch.rs` 的 `SetSetting` 分支就会重发 `CoreEvent::SettingsChanged`,
// `AppState::settings_groups` 里 java 组那几行随之变成刚写进去的真值。
// 键的来历与取舍见 `crate::dto::JAVA_REFRESH_ACTION` 的文档。
if key == crate::dto::JAVA_REFRESH_ACTION {
return Ok(());
}
let parsed = parse_value(key, value)?;
let mut store = env.store();
store