//! 「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) }