diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index e808b8b..dfa0abe 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -49,14 +49,20 @@ static SHOT_GROUP_LOGGED: std::sync::Mutex> = std::sync: /// /// `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.lock() else { + let Ok(mut last) = SHOT_GROUP_LOGGED.try_lock() else { return; }; let seen = (group, theme.to_string()); diff --git a/crates/app-iced/src/pages/settings/java.rs b/crates/app-iced/src/pages/settings/java.rs index e023bd4..8fad52f 100644 --- a/crates/app-iced/src/pages/settings/java.rs +++ b/crates/app-iced/src/pages/settings/java.rs @@ -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; diff --git a/crates/app-iced/src/pages/settings/java/scan.rs b/crates/app-iced/src/pages/settings/java/scan.rs new file mode 100644 index 0000000..095bbfd --- /dev/null +++ b/crates/app-iced/src/pages/settings/java/scan.rs @@ -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, +} + +/// 一次扫描的作业(生产实现是 [`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() + } + + /// 把快照**直接摆成**给定值(只给 `--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, 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() +} diff --git a/crates/app-iced/src/pages/settings/java/seed.rs b/crates/app-iced/src/pages/settings/java/seed.rs index 7d99862..1730fab 100644 --- a/crates/app-iced/src/pages/settings/java/seed.rs +++ b/crates/app-iced/src/pages/settings/java/seed.rs @@ -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 { 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")?; diff --git a/crates/app-iced/src/pages/settings/java/store.rs b/crates/app-iced/src/pages/settings/java/store.rs index 1102bc0..e8cae9c 100644 --- a/crates/app-iced/src/pages/settings/java/store.rs +++ b/crates/app-iced/src/pages/settings/java/store.rs @@ -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, -} - -/// 一次扫描的作业(生产实现是 [`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() -} +// M204 r1:扫描槽与生产作业搬到了 [`super::scan`](本文件加 `ScanSlot::seed_report` 之后 +// 408 行,越过 400 行硬线)。这里再导出,`store::ScanSlot` / `store::ScanReport` 这些既有路径 +// **一个都不用改**;`Job` 只被 `scan` 内部用,不外抛(外抛会让本文件多一条 unused 警告)。 +pub use super::scan::{ScanReport, ScanSlot}; /// 该不该**自动**发起一次扫描(首屏 / 配置文件刚被外部改过时)。 /// diff --git a/crates/app-iced/src/pages/settings/java/tests/cmdline.rs b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs index 830d363..da6225c 100644 --- a/crates/app-iced/src/pages/settings/java/tests/cmdline.rs +++ b/crates/app-iced/src/pages/settings/java/tests/cmdline.rs @@ -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()`