用户实测四条抱怨逐条对应: - 「没有自动检测本机 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`(不再挂一条永远收不到的消息)
394 lines
16 KiB
Rust
394 lines
16 KiB
Rust
//! 「Java」分组的真读写(M175):设置三键 + 手动名单 + 后台扫描。
|
||
//!
|
||
//! ## 写盘走 `SettingStore`,与 `ai/store.rs` 同一条通路
|
||
//!
|
||
//! `java.selected_path` / `java.default_memory_mb` / `java.extra_args` 都是
|
||
//! `config/launcher.json` 里的 schema 键(`core::setting::keys` 已登记,本 mission 不动 schema)。
|
||
//! 于是这里只做「读一份 → 改几个键 → 存 → **再从磁盘读回来**」:`save()` 有 `Skipped` 一档
|
||
//! (只读 / 文件被更新版本写过 / 没有脏键),把它当成功就会出现「界面说已保存、重启回旧值」。
|
||
//! 失败一律走 [`WriteError`]:**要么是页面自己的拒绝原因(i18n key),要么是 core 给的原始句子**,
|
||
//! 不再把 Rust 枚举名(`"MemoryRange"`)当人话往上抛(M175 r1 复审 P2-2)。
|
||
//!
|
||
//! ## 扫描为什么是「进程级快照 + 后台线程」,不是 `Task::perform`
|
||
//!
|
||
//! 与 `fonts::install` 同一条理由(那里记了完整推导):本 crate 的执行器是 `thread-pool`,
|
||
//! 而扫描要的是**进度(3/7)+ 取消 + 不卡 UI**。快照方案的代价是一把短锁 + 1 拍一次比较,
|
||
//! 好处是窗口关掉之后不会往死掉的 channel 里写。界面的刷新节奏见 `app/update/clock.rs`
|
||
//! 与 `app/subscription.rs`(扫描期间按帧跑,扫描结束回到 1 Hz)。
|
||
//!
|
||
//! ### 三条与「取消」有关的硬约束(M175 r1 复审 P2-5)
|
||
//!
|
||
//! 1. **每个候选都有超时**:卡住的 `java -version` 由 `core::game::java::manual` 的
|
||
//! `run_with_timeout` kill 掉,扫描线程不会永远不结束;
|
||
//! 2. **取消打断的是「正在跑的那一个」**:取消位一路传进探测函数,不必等它自己结束;
|
||
//! 3. **同时只允许一个扫描线程**:[`ScanReport::running`] 的含义就是「线程还活着」,
|
||
//! 取消**不**翻这个位(只置 `cancelled`),于是取消到线程真退出之间点「立即扫描」
|
||
//! 不会起第二个线程——界面上那一小段显示的是「正在取消…」。
|
||
//!
|
||
//! ## 手动名单为什么是纯文本文件
|
||
//!
|
||
//! 它记的是「我手动加过哪些路径」,不是启动策略(真正决定用哪个 Java 的是 `java.selected_path`)。
|
||
//! 放进 `launcher.json` 要新登记一个 schema 键并推 minor(会让旧版本整份文件变只读),
|
||
//! 代价远大于收益;而 `core::game::java::manual` 的读写是纯文本、一行一个,坏了顶多丢名单。
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use std::sync::Mutex;
|
||
|
||
use unlucky_core::game::java::{discover, manual, staged, JavaRuntime};
|
||
use unlucky_core::platform::paths::{Platform, ProcessEnv};
|
||
use unlucky_core::setting::keys::launcher_schema;
|
||
use unlucky_core::setting::store::{SaveOutcome, SettingStore};
|
||
|
||
use super::state::{Refusal, RuntimeRow, Snapshot, State, Unwritable};
|
||
|
||
/// 扫描结果快照(后台线程写、界面读)。
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||
pub struct ScanReport {
|
||
/// 扫描线程**还活着**(取消之后到它真退出之前仍是 `true`——这一段界面上叫「正在取消…」)
|
||
pub running: bool,
|
||
/// 已探测
|
||
pub done: usize,
|
||
/// 候选总数(`0` = 候选表还在收集,界面不显示 `0/0`)
|
||
pub total: usize,
|
||
/// 至少完整扫完过一次
|
||
pub finished: bool,
|
||
/// 用户按过取消(线程还活着时是 `true`)
|
||
pub cancelled: bool,
|
||
/// 扫描结论(`finished` 且没被取消时有效)
|
||
pub runtimes: Vec<RuntimeRow>,
|
||
}
|
||
|
||
/// 一次扫描的作业(生产实现是 [`run_staged`];测试注入可控的假作业)。
|
||
pub type Job =
|
||
fn(&[staged::Candidate], &dyn Fn() -> bool, &mut dyn FnMut(usize, usize)) -> Vec<RuntimeRow>;
|
||
|
||
/// 一次扫描的**状态槽**:快照 + 取消位 + 「线程还活着」。
|
||
///
|
||
/// 为什么不是进程级 static(M175 r1 复审 P2-5 的附带项):全局状态有两个坏处——
|
||
/// ①候选收集要在 UI 线程上抢同一把锁;②单测之间互相污染(一个用例起的扫描会让另一个用例
|
||
/// 看到「变了」)。挂在 `State` 上之后,每个 `App` / 每个用例都有一份自己的槽。
|
||
///
|
||
/// 内部是 `Arc`:`State` 会被整体替换(hydrate),而扫描线程要活得比某一次 `State` 赋值更久。
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct ScanSlot {
|
||
inner: std::sync::Arc<ScanInner>,
|
||
}
|
||
|
||
#[derive(Debug, Default)]
|
||
struct ScanInner {
|
||
report: Mutex<ScanReport>,
|
||
cancel: AtomicBool,
|
||
}
|
||
|
||
impl ScanSlot {
|
||
/// 当前快照(界面每拍读一次;锁只在这几行里持有)。
|
||
#[must_use]
|
||
pub fn report(&self) -> ScanReport {
|
||
self.inner
|
||
.report
|
||
.lock()
|
||
.map(|report| report.clone())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// 取消正在跑的扫描:置取消位 + 标 `cancelled`,**不**动 `running`。
|
||
///
|
||
/// `running` 由扫描线程自己翻——它是「线程还活着」的唯一真相。取消若也翻它,`start()` 的
|
||
/// 「已在扫」判据就失效,用户能在旧线程还卡着的时候起第二个(复审 P2-5 的第三个问题)。
|
||
pub fn cancel(&self) {
|
||
self.inner.cancel.store(true, Ordering::SeqCst);
|
||
if let Ok(mut report) = self.inner.report.lock() {
|
||
if report.running {
|
||
report.cancelled = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 发起一次扫描(已在扫 ⇒ 什么都不做,返回 `false`)。
|
||
pub fn start(&self, roots: Vec<PathBuf>, manual: Vec<PathBuf>, system: bool) -> bool {
|
||
self.start_with(roots, manual, system, run_staged)
|
||
}
|
||
|
||
/// 同 [`ScanSlot::start`],但扫描作业可注入(测试用:一个可控的、会一直卡住的作业)。
|
||
pub fn start_with(
|
||
&self,
|
||
roots: Vec<PathBuf>,
|
||
manual: Vec<PathBuf>,
|
||
system: bool,
|
||
job: Job,
|
||
) -> bool {
|
||
{
|
||
let Ok(mut report) = self.inner.report.lock() else {
|
||
return false;
|
||
};
|
||
if report.running {
|
||
return false;
|
||
}
|
||
// 「占位」与「线程起来了」在同一把锁里完成:两个调用不可能同时通过上面那道判据。
|
||
*report = ScanReport {
|
||
running: true,
|
||
..ScanReport::default()
|
||
};
|
||
self.inner.cancel.store(false, Ordering::SeqCst);
|
||
}
|
||
|
||
let slot = self.clone();
|
||
std::thread::spawn(move || {
|
||
// 候选收集(注册表 / 常见目录)放在线程里:它是首屏那一拍最贵的一段,
|
||
// 放在 UI 线程上会拖慢开窗(复审 P2-5 的附带项)。
|
||
let candidates = staged::candidates(&roots, &manual, system);
|
||
if let Ok(mut report) = slot.inner.report.lock() {
|
||
report.total = candidates.len();
|
||
}
|
||
let cancelled = || slot.inner.cancel.load(Ordering::SeqCst);
|
||
let mut progress = |done: usize, _total: usize| {
|
||
if let Ok(mut report) = slot.inner.report.lock() {
|
||
report.done = done;
|
||
}
|
||
};
|
||
let runtimes = job(&candidates, &cancelled, &mut progress);
|
||
if let Ok(mut report) = slot.inner.report.lock() {
|
||
report.running = false;
|
||
report.done = candidates.len();
|
||
if !report.cancelled {
|
||
report.finished = true;
|
||
report.runtimes = runtimes;
|
||
}
|
||
}
|
||
});
|
||
true
|
||
}
|
||
}
|
||
|
||
/// 生产扫描作业:真扫盘 + 逐个探测(超时与取消都在 `staged::scan` 里)。
|
||
fn run_staged(
|
||
candidates: &[staged::Candidate],
|
||
cancelled: &dyn Fn() -> bool,
|
||
progress: &mut dyn FnMut(usize, usize),
|
||
) -> Vec<RuntimeRow> {
|
||
let found = staged::scan(
|
||
candidates,
|
||
|done, total| {
|
||
progress(done, total);
|
||
true
|
||
},
|
||
cancelled,
|
||
);
|
||
rows(&found, candidates)
|
||
}
|
||
|
||
/// `JavaRuntime` → 页面行(来源从候选表里按路径回查:`JavaRuntime.origin` 分不出「手动」)。
|
||
fn rows(found: &[JavaRuntime], candidates: &[staged::Candidate]) -> Vec<RuntimeRow> {
|
||
found
|
||
.iter()
|
||
.map(|runtime| RuntimeRow {
|
||
binary: runtime.binary.display().to_string(),
|
||
version: runtime.version.clone().unwrap_or_default(),
|
||
major: runtime.major,
|
||
vendor: runtime.vendor.clone(),
|
||
source: candidates
|
||
.iter()
|
||
.find(|candidate| candidate.binary == runtime.binary)
|
||
.map_or("system", |candidate| candidate.source.id()),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// 该不该**自动**发起一次扫描(首屏 / 配置文件刚被外部改过时)。
|
||
///
|
||
/// 四条都要成立:①不是截图通道(D30:同一份代码出同一张图,截图不许读本机装了什么);
|
||
/// ②没有在扫;③**没被取消过**(一次取消 = 本进程不再自动扫:被取消的扫描 `finished` 仍是
|
||
/// `false`,而 `launcher.json` 每改一次(存内存、改主题、改语言、存 AI 配置)心跳就会走一次
|
||
/// `hydrate_java_settings`,不看这一条就会把用户的取消无声撤销);④还没扫完过(一次就够——
|
||
/// 用户手动点「立即扫描」走的是 `start`,不看这里)。
|
||
///
|
||
/// 单独抽出来是因为它是「用户实测的头一条抱怨」(设置页从来自动检测本机 Java)的开关:
|
||
/// 判据写在这里,`tests/cmdline.rs` 逐档断言,改坏立刻红。
|
||
#[must_use]
|
||
pub fn auto_scan_due(report: &ScanReport, screenshot: bool) -> bool {
|
||
!screenshot && !report.running && !report.cancelled && !report.finished
|
||
}
|
||
|
||
/// 托管安装根(`<launcher home>/data/java-managed` + `java.installRoot` + 标准落点)。
|
||
///
|
||
/// 从**我们正在写的那份 `launcher.json`** 的上一级推 launcher home:这比重新解析一次数据根模式
|
||
/// 更可信(三模式解析已经在 `SettingsBridge::open` 里做过一遍了)。
|
||
#[must_use]
|
||
pub fn managed_roots(config: &Path) -> Vec<PathBuf> {
|
||
let Some(home) = config.parent().and_then(Path::parent) else {
|
||
return Vec::new();
|
||
};
|
||
let layout = unlucky_core::platform::layout::LauncherLayout::new(home);
|
||
let override_root = SettingStore::load(config, launcher_schema())
|
||
.doc()
|
||
.effective_str("java.installRoot")
|
||
.map(str::trim)
|
||
.filter(|text| !text.is_empty())
|
||
.map(PathBuf::from);
|
||
unlucky_core::game::java::location::scan_roots(
|
||
unlucky_core::platform::data_dir::DataMode::Portable,
|
||
&layout,
|
||
override_root.as_deref(),
|
||
Platform::current(),
|
||
&ProcessEnv,
|
||
)
|
||
}
|
||
|
||
/// 手动名单文件(与 `launcher.json` 同一个 `config/`)。
|
||
#[must_use]
|
||
pub fn manual_file(config: &Path) -> PathBuf {
|
||
config.parent().map_or_else(
|
||
|| PathBuf::from("java-extra-paths.txt"),
|
||
|dir| dir.join("java-extra-paths.txt"),
|
||
)
|
||
}
|
||
|
||
/// 读三键真值。
|
||
#[must_use]
|
||
pub fn load(config: &Path) -> Snapshot {
|
||
snapshot(SettingStore::load(config, launcher_schema()).doc())
|
||
}
|
||
|
||
/// 写盘失败的三种来源(都是「没写进去」,但对用户要说的不是同一句话)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum WriteError {
|
||
/// 页面自己的校验拒绝(i18n key)
|
||
Refused(Refusal),
|
||
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key)
|
||
Unwritable(Unwritable),
|
||
/// schema / 磁盘给的原始句子(core 的文案,不属于本页)
|
||
Schema(String),
|
||
}
|
||
|
||
/// 保存内存 + JVM 参数,返回**落盘后读回来**的真值。
|
||
///
|
||
/// # Errors
|
||
/// 本地校验不过([`WriteError::Refused`])、文件只读/不可信([`WriteError::Unwritable`])
|
||
/// 或 schema 拒绝([`WriteError::Schema`])时返回原因。
|
||
pub fn save(config: &Path, state: &State) -> Result<Snapshot, WriteError> {
|
||
let mb = state.validate().map_err(WriteError::Refused)?;
|
||
let mut store = SettingStore::load(config, launcher_schema());
|
||
store
|
||
.set("java.default_memory_mb", i64::from(mb).into())
|
||
.and_then(|()| store.set("java.extra_args", state.jvm.trim().to_string().into()))
|
||
.map_err(|error| WriteError::Schema(format!("{error}")))?;
|
||
finish(store, config)
|
||
}
|
||
|
||
/// 写 `java.selected_path`(选定默认 JDK),返回落盘后读回来的真值。
|
||
///
|
||
/// # Errors
|
||
/// 文件只读/不可信或 schema 拒绝时返回原因。
|
||
pub fn set_selected(config: &Path, binary: &str) -> Result<Snapshot, WriteError> {
|
||
let mut store = SettingStore::load(config, launcher_schema());
|
||
store
|
||
.set("java.selected_path", binary.to_string().into())
|
||
.map_err(|error| WriteError::Schema(format!("{error}")))?;
|
||
finish(store, config)
|
||
}
|
||
|
||
/// 落盘 + 判定 + 读回(`save` / `set_selected` 共用:只认 `Saved` 一档)。
|
||
fn finish(mut store: SettingStore, config: &Path) -> Result<Snapshot, WriteError> {
|
||
match store.save() {
|
||
Ok(SaveOutcome::Saved(_)) => Ok(load(config)),
|
||
Ok(SaveOutcome::Skipped(reason)) => Err(WriteError::Unwritable(unwritable(reason))),
|
||
Err(error) => Err(WriteError::Schema(format!("{error}"))),
|
||
}
|
||
}
|
||
|
||
/// `SkipReason` → 页面原因(**不**再用中文字符串:那是 r1 复审 P2-2 的第一条)。
|
||
fn unwritable(reason: unlucky_core::setting::store::SkipReason) -> Unwritable {
|
||
use unlucky_core::setting::store::SkipReason;
|
||
match reason {
|
||
SkipReason::NotDirty => Unwritable::NotDirty,
|
||
SkipReason::ReadOnly => Unwritable::ReadOnly,
|
||
SkipReason::Unreadable => Unwritable::Unreadable,
|
||
}
|
||
}
|
||
|
||
/// 文档 → 快照。
|
||
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
|
||
Snapshot {
|
||
selected: doc
|
||
.effective_str("java.selected_path")
|
||
.unwrap_or_default()
|
||
.trim()
|
||
.to_string(),
|
||
memory_mb: doc
|
||
.effective_i64("java.default_memory_mb")
|
||
.and_then(|value| u32::try_from(value).ok())
|
||
.unwrap_or(super::state::DEFAULT_MEMORY_MB),
|
||
jvm: doc
|
||
.effective_str("java.extra_args")
|
||
.unwrap_or_default()
|
||
.to_string(),
|
||
}
|
||
}
|
||
|
||
/// 读手动名单。
|
||
#[must_use]
|
||
pub fn load_manual(config: &Path) -> Vec<String> {
|
||
manual::load_paths(&manual_file(config))
|
||
.iter()
|
||
.map(|path| path.display().to_string())
|
||
.collect()
|
||
}
|
||
|
||
/// 手动加一个路径:严格校验(真跑 `java -version`)→ 进名单 → 落盘。
|
||
///
|
||
/// 拒绝时**什么都不写**(名单不变、设置不变)——这是「非法路径不污染设置」那条守卫的落点。
|
||
///
|
||
/// # Errors
|
||
/// 校验不过(见 [`Refusal`])或名单写盘失败时返回原因。
|
||
pub fn add_manual(config: &Path, input: &str) -> Result<(String, RuntimeRow), Refusal> {
|
||
add_manual_with(config, input, &manual::probe_manual)
|
||
}
|
||
|
||
/// 同 [`add_manual`],但探测函数可注入(测试用:不依赖本机装没装 JDK)。
|
||
///
|
||
/// # Errors
|
||
/// 探测被拒或名单写盘失败时返回原因。
|
||
pub fn add_manual_with(
|
||
config: &Path,
|
||
input: &str,
|
||
probe: &dyn Fn(&str) -> Result<JavaRuntime, manual::ManualFailure>,
|
||
) -> Result<(String, RuntimeRow), Refusal> {
|
||
let runtime = probe(input.trim()).map_err(failure_refusal)?;
|
||
let binary = runtime.binary.display().to_string();
|
||
let mut paths = manual::load_paths(&manual_file(config));
|
||
let key = discover::path_key(&runtime.binary);
|
||
if paths.iter().any(|known| discover::path_key(known) == key) {
|
||
return Err(Refusal::AddDuplicate);
|
||
}
|
||
paths.push(runtime.binary.clone());
|
||
manual::save_paths(&manual_file(config), &paths).map_err(|_| Refusal::AddWriteFailed)?;
|
||
Ok((
|
||
binary.clone(),
|
||
RuntimeRow {
|
||
binary,
|
||
version: runtime.version.clone().unwrap_or_default(),
|
||
major: runtime.major,
|
||
vendor: runtime.vendor.clone(),
|
||
source: "manual",
|
||
},
|
||
))
|
||
}
|
||
|
||
/// `core` 的拒绝原因 → 页面原因。
|
||
fn failure_refusal(failure: manual::ManualFailure) -> Refusal {
|
||
match failure {
|
||
manual::ManualFailure::Empty => Refusal::AddEmpty,
|
||
manual::ManualFailure::TooLong => Refusal::AddTooLong,
|
||
manual::ManualFailure::Missing => Refusal::AddMissing,
|
||
manual::ManualFailure::NotJava => Refusal::AddNotJava,
|
||
manual::ManualFailure::NoVersion => Refusal::AddNoVersion,
|
||
}
|
||
}
|
||
|
||
/// 设置文件路径(数据根解析不出来时没有)。
|
||
#[must_use]
|
||
pub fn config_of(bridge: &unlucky_presenter::settings_bridge::SettingsBridge) -> Option<PathBuf> {
|
||
bridge.path().map(Path::to_path_buf)
|
||
}
|