Files
Unlucky-Launcher/crates/app-iced/src/pages/settings/java/store.rs
T
AskaEth 8a050c4402 fix(m204): 补「扫描中/还没扫描过/正在取消」三格种子 + try_lock 说明对得上代码
视觉复审 r1 判 p2-1items:六张举证图里「取消」全程是禁用态,「正在扫描… 3/7」
一格都没拍,而 README 却写成「两态」——证据覆盖不到结论(与 M201 同一个坑)。
本提交补种子与拆文件,不出产品行为改动。

- `pages/settings/java/seed.rs`:新增 `scanning` / `unscanned` / `cancelling` 三档。
  三档都把快照写进 `ScanSlot`(新增 `ScanSlot::seed_report`)而不只改 `State.scanning`:
  `clock.advance` 每拍用槽里的快照重算 `scanning`,只改 State 的话第一拍就被推回「没在扫」。
- `pages/settings/java/scan.rs`:`ScanReport` / `Job` / `ScanSlot` / `run_staged` / `rows`
  从 `store.rs` 拆出(加 `seed_report` 之后那个文件 408 行,越过 400 行硬线);
  `store.rs` 用 `pub use` 再导出 `ScanReport` / `ScanSlot`,既有路径一个都没改。
- `app/view.rs`:`note_screenshot_group` 的锁从 `lock()` 换成 `try_lock()`——代码复审 r1 指出
  注释写着「拿不到锁就不打、不能把界面帧卡在锁上」,而实现用的是阻塞 `lock()`,只兜住中毒
  不兜住等待。选 try_lock 而不是改注释:注释承诺的保证应由代码提供,不能靠「今天只有一个
  调用点、锁里只有一次比较」这种会随改动失效的前提。
- `pages/settings/java/tests/cmdline.rs`:新增 `the_scan_seeds_cover_the_running_states`,
  逐档钉 `State.scanning` 与 `scan.report().running`(后者才是「心跳下一拍会不会把它推开」)。
2026-11-09 12:09:02 +08:00

244 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 「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 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};
// M204 r1:扫描槽与生产作业搬到了 [`super::scan`](本文件加 `ScanSlot::seed_report` 之后
// 408 行,越过 400 行硬线)。这里再导出,`store::ScanSlot` / `store::ScanReport` 这些既有路径
// **一个都不用改**;`Job` 只被 `scan` 内部用,不外抛(外抛会让本文件多一条 unused 警告)。
pub use super::scan::{ScanReport, ScanSlot};
/// 该不该**自动**发起一次扫描(首屏 / 配置文件刚被外部改过时)。
///
/// 四条都要成立:①不是截图通道(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 的第一条)。
pub(super) 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)
}