feat(m175): Java 设置页接真运行时(扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数)
用户实测四条抱怨逐条对应: - 「没有自动检测本机 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`(不再挂一条永远收不到的消息)
This commit is contained in:
1 parent
30b61e2684
commit
df331f6a60
26 files changed
+4030
-85
No files matched your search
@@ -0,0 +1,246 @@
|
||||
//! 「Java」分组的**编辑区**(M175):手动路径 / 默认内存 / JVM 参数 / 保存与回执。
|
||||
//!
|
||||
//! 从 `view.rs` 拆出来的(那个文件加了守卫之后贴到 438 行,越过 400 行硬线):这里全是
|
||||
//! 「用户能改的东西」,`view.rs` 只剩「扫到了什么」与版面。两半共用 `view::msg` 这一个消息口,
|
||||
//! 所以「一条控件对应哪条消息」不会各写一遍。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::inputs::{text_field, Field, FieldVariant};
|
||||
use crate::pages::frame::text_role_owned;
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::state::{Layer, Notice, Refusal, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
|
||||
use super::view::msg;
|
||||
use super::Message as JavaMessage;
|
||||
|
||||
/// 手动添加一行:输入框 + 「添加」按钮 + 原因。
|
||||
pub(super) fn add_row<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let field = Field::new(&state.add_draft)
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsJavaAdd))
|
||||
.placeholder(catalog.text(Key::SettingsJavaAddPlaceholder))
|
||||
.supporting(catalog.text(Key::SettingsJavaAddHint));
|
||||
let add = if state.add_draft.trim().is_empty() || !state.storage {
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::disabled(catalog.text(Key::SettingsJavaAddAction)),
|
||||
)
|
||||
} else {
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::SettingsJavaAddAction),
|
||||
msg(JavaMessage::Add),
|
||||
),
|
||||
)
|
||||
};
|
||||
row![
|
||||
container(text_field(ui, field, |value| msg(JavaMessage::AddDraft(
|
||||
value
|
||||
))))
|
||||
.width(Length::Fill),
|
||||
add,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 默认内存:数字框 + 范围 + 「生效值来自哪一层」。
|
||||
pub(super) fn memory_field<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
) -> Element<'a, Message> {
|
||||
let range = catalog.render(
|
||||
Key::SettingsJavaMemoryHint,
|
||||
&[
|
||||
("min", MIN_MEMORY_MB.to_string().as_str()),
|
||||
("max", MAX_MEMORY_MB.to_string().as_str()),
|
||||
],
|
||||
);
|
||||
let field = Field::new(&state.memory)
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsJavaMemory));
|
||||
// 校验不过就把输入框本身标红(不只是下面那行字):用户一眼看到「是这个框有问题」。
|
||||
let field = match state.validate() {
|
||||
Err(Refusal::MemoryEmpty | Refusal::MemoryNotNumber | Refusal::MemoryRange) => {
|
||||
field.error()
|
||||
}
|
||||
_ => field,
|
||||
};
|
||||
let effective = match state.layer() {
|
||||
Layer::Global => catalog.render(
|
||||
Key::SettingsJavaLayerGlobal,
|
||||
&[("mb", state.saved_memory.to_string().as_str())],
|
||||
),
|
||||
Layer::Instance { instance, mb } => catalog.render(
|
||||
Key::SettingsJavaLayerInstance,
|
||||
&[
|
||||
("mb", mb.to_string().as_str()),
|
||||
("instance", instance.as_str()),
|
||||
],
|
||||
),
|
||||
};
|
||||
column![
|
||||
text_field(ui, field, |value| msg(JavaMessage::EditMemory(value))),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
effective,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
range,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// JVM 额外参数:文本框 + 「会拼到每次启动」的说明。
|
||||
pub(super) fn jvm_field<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
) -> Element<'a, Message> {
|
||||
let field = Field::new(&state.jvm)
|
||||
.variant(FieldVariant::Outlined)
|
||||
.label(catalog.text(Key::SettingsJavaJvm))
|
||||
.placeholder(catalog.text(Key::SettingsJavaJvmPlaceholder))
|
||||
.supporting(catalog.text(Key::SettingsJavaJvmHint));
|
||||
let field = if matches!(state.validate(), Err(Refusal::JvmQuotes)) {
|
||||
field.error()
|
||||
} else {
|
||||
field
|
||||
};
|
||||
// 已保存值:空的时候说「还没有保存任何额外参数」,而不是让「已保存的值:」后面空着。
|
||||
let saved = if state.saved_jvm.trim().is_empty() {
|
||||
catalog.text(Key::SettingsJavaJvmSavedNone).to_string()
|
||||
} else {
|
||||
catalog.render(
|
||||
Key::SettingsJavaJvmSaved,
|
||||
&[("value", state.saved_jvm.as_str())],
|
||||
)
|
||||
};
|
||||
column![
|
||||
text_field(ui, field, |value| msg(JavaMessage::EditJvm(value))),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
saved,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn actions_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
) -> Element<'a, Message> {
|
||||
let label = catalog.text(Key::SettingsJavaSave);
|
||||
if state.can_save() {
|
||||
buttons::filled(ui, Button::new(label, msg(JavaMessage::Save)))
|
||||
} else {
|
||||
buttons::filled(ui, Button::disabled(label))
|
||||
}
|
||||
}
|
||||
|
||||
/// 按钮为什么灰着 / 下一步做什么——**永远说清**。
|
||||
pub(super) fn hint_line<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
) -> Element<'a, Message> {
|
||||
let (text, error) = if !state.storage {
|
||||
(catalog.text(Key::SettingsJavaNoStorage).to_string(), true)
|
||||
} else if state.dirty {
|
||||
(catalog.text(Key::SettingsJavaUnsaved).to_string(), false)
|
||||
} else {
|
||||
(String::new(), false)
|
||||
};
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(if error {
|
||||
Role::Error
|
||||
} else {
|
||||
Role::OnSurfaceVariant
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn notice_line<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
notice: &Notice,
|
||||
) -> Element<'a, Message> {
|
||||
let (text, error) = match notice {
|
||||
Notice::Saved => (catalog.text(Key::SettingsJavaSaved).to_string(), false),
|
||||
Notice::Refused(reason) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaRefused,
|
||||
&[("reason", catalog.text(refusal_key(*reason)))],
|
||||
),
|
||||
true,
|
||||
),
|
||||
Notice::Unwritable(reason) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaRefused,
|
||||
&[("reason", catalog.text(reason.label()))],
|
||||
),
|
||||
true,
|
||||
),
|
||||
Notice::WriteFailed => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaRefused,
|
||||
&[("reason", catalog.text(Key::SettingsJavaReasonWriteFailed))],
|
||||
),
|
||||
true,
|
||||
),
|
||||
Notice::NoStorage => (catalog.text(Key::SettingsJavaNoStorage).to_string(), true),
|
||||
Notice::DefaultSet(path) => (
|
||||
catalog.render(Key::SettingsJavaDefaultSet, &[("path", path.as_str())]),
|
||||
false,
|
||||
),
|
||||
Notice::Added(path) => (
|
||||
catalog.render(Key::SettingsJavaAdded, &[("path", path.as_str())]),
|
||||
false,
|
||||
),
|
||||
};
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
text,
|
||||
ui.color(if error { Role::Error } else { Role::OnSurface }),
|
||||
)
|
||||
}
|
||||
|
||||
/// 拒绝原因 → 文案 key(`Refusal::label` 已是这张表,这里只是给 `match` 一个名字)。
|
||||
pub(super) fn refusal_key(refusal: Refusal) -> Key {
|
||||
refusal.label()
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 「Java」分组的一行运行时(M175):路径 / 版本 / 厂商 / 来源 + 上屏用的截断。
|
||||
//!
|
||||
//! 从 `state.rs` 拆出来的(那个文件加了上下文与校验之后到 424 行,越过 400 行硬线):
|
||||
//! 这里只放「一行长什么样」,状态与校验仍在 `state.rs`。
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
|
||||
/// 一个扫到的 / 手动加的运行时(页面要的那几格)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeRow {
|
||||
/// `bin/java[.exe]` 的绝对路径(也是 `java.selected_path` 的取值)。
|
||||
pub binary: String,
|
||||
/// `java -version` 的首行(探测失败时为空串)。
|
||||
pub version: String,
|
||||
/// 主版本号(探测不出为 `None`)。
|
||||
pub major: Option<u32>,
|
||||
/// 发行方(认不出为 `None`)。
|
||||
pub vendor: Option<String>,
|
||||
/// 来源 id(`managed` / `env` / `path` / `system` / `manual`)。
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
impl RuntimeRow {
|
||||
/// 版本号:**能区分 `17.0.13` 与 `17.0.9`** 的那一位,而不是只给大版本(r1 复审 P2-4)。
|
||||
///
|
||||
/// 三层取法,逐层降级(不许因为解析不出来就只显示 `17`):
|
||||
/// 1. `-version` 首行引号里的版本号(`openjdk version "21.0.11" …` → `21.0.11`);
|
||||
/// 2. 无引号形态里的第一个版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`);
|
||||
/// 3. 整行原样(原始版本串比一个大版本号有用);
|
||||
/// 4. 连首行都没有(探测失败)→ 大版本;再没有 → `?`。
|
||||
#[must_use]
|
||||
pub fn version_label(&self) -> String {
|
||||
if let Some(quoted) = quoted_version(&self.version) {
|
||||
return quoted;
|
||||
}
|
||||
if let Some(bare) = bare_version(&self.version) {
|
||||
return bare;
|
||||
}
|
||||
let line = self.version.trim();
|
||||
if !line.is_empty() {
|
||||
return line.to_string();
|
||||
}
|
||||
self.major
|
||||
.map_or_else(|| "?".to_string(), |major| major.to_string())
|
||||
}
|
||||
|
||||
/// 版本一句话:`Java 21.0.11 · Microsoft`。
|
||||
#[must_use]
|
||||
pub fn summary(&self) -> String {
|
||||
let version = self.version_label();
|
||||
match &self.vendor {
|
||||
Some(vendor) => format!("Java {version} · {vendor}"),
|
||||
None => format!("Java {version}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// JDK 安装目录(`…/bin/java.exe` 的上一级再上一级)。
|
||||
///
|
||||
/// 上屏用它而不是整条可执行文件路径:424 宽的正文列放不下一条 `C:\Program Files\…\bin\java.exe`,
|
||||
/// 而用户认的是 JDK 目录(也是他手动添加时会粘的那一段)。
|
||||
#[must_use]
|
||||
pub fn home(&self) -> String {
|
||||
let path = std::path::Path::new(self.binary.trim());
|
||||
let parent = path.parent();
|
||||
match parent.filter(|dir| {
|
||||
dir.file_name()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("bin"))
|
||||
}) {
|
||||
Some(bin) => bin
|
||||
.parent()
|
||||
.map_or_else(|| self.binary.clone(), |home| home.display().to_string()),
|
||||
None => parent.map_or_else(|| self.binary.clone(), |dir| dir.display().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 上屏用的目录:过长时保留**尾部**两段,前面用 `…` 收掉。
|
||||
///
|
||||
/// 为什么必须截:无空格的 Windows 路径在 iced 的文本排版里不可断行,一条 60 字符的路径会直接
|
||||
/// 盖住右边的「设为默认」chip(M175 出图时实测到了这个重叠)。保尾部而不是开头,因为磁盘名
|
||||
/// (`C:`)谁都知道,而 `…\Java\temurin\17.0.13` 才是能区分两条 JDK 的那一段。
|
||||
#[must_use]
|
||||
pub fn display_home(&self) -> String {
|
||||
const LIMIT: usize = 39;
|
||||
let home = self.home();
|
||||
if home.chars().count() <= LIMIT {
|
||||
return home;
|
||||
}
|
||||
let tail: String = home
|
||||
.chars()
|
||||
.rev()
|
||||
.take(LIMIT - 1)
|
||||
.collect::<Vec<char>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
// 从第一个分隔符之后开始(别让 `…` 后面接着半截目录名)。
|
||||
let tail = tail
|
||||
.find(['\\', '/'])
|
||||
.map_or(tail.clone(), |at| tail[at + 1..].to_string());
|
||||
format!("…{}{}", std::path::MAIN_SEPARATOR, tail)
|
||||
}
|
||||
|
||||
/// 来源文案 key。
|
||||
#[must_use]
|
||||
pub fn source_key(&self) -> Key {
|
||||
match self.source {
|
||||
"managed" => Key::SettingsJavaSourceManaged,
|
||||
"env" => Key::SettingsJavaSourceEnv,
|
||||
"path" => Key::SettingsJavaSourcePath,
|
||||
"manual" => Key::SettingsJavaSourceManual,
|
||||
_ => Key::SettingsJavaSourceSystem,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 引号里的版本号(`openjdk version "21.0.11" 2025-10-21 LTS` → `21.0.11`)。
|
||||
fn quoted_version(line: &str) -> Option<String> {
|
||||
let start = line.find('"')? + 1;
|
||||
let rest = &line[start..];
|
||||
let end = rest.find('"')?;
|
||||
let value = rest[..end].trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
/// 无引号形态里的版本样 token(`openjdk 25.0.3 2026-04-21` → `25.0.3`)。
|
||||
fn bare_version(line: &str) -> Option<String> {
|
||||
line.split_whitespace().find_map(|token| {
|
||||
let candidate = token.trim_matches([',', '(', ')', '"']);
|
||||
let mut parts = candidate.split('.');
|
||||
let first = parts.next()?;
|
||||
let looks_like_version = first.chars().all(|c| c.is_ascii_digit())
|
||||
&& !first.is_empty()
|
||||
&& parts.next().is_some();
|
||||
looks_like_version.then(|| candidate.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RuntimeRow;
|
||||
|
||||
fn row(version: &str, major: Option<u32>) -> RuntimeRow {
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk/bin/java.exe".to_string(),
|
||||
version: version.to_string(),
|
||||
major,
|
||||
vendor: Some("Microsoft".to_string()),
|
||||
source: "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本号必须能区分同一大版本的两份 JDK(`17.0.13` vs `17.0.9`),不许只给 `17`。
|
||||
///
|
||||
/// 变异证明:把 `version_label` 换回「只用 `major`」,这条立刻红。
|
||||
#[test]
|
||||
fn the_version_label_keeps_the_minor_digits() {
|
||||
assert_eq!(
|
||||
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).version_label(),
|
||||
"17.0.13"
|
||||
);
|
||||
assert_eq!(
|
||||
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).version_label(),
|
||||
"17.0.9"
|
||||
);
|
||||
assert_ne!(
|
||||
row("openjdk version \"17.0.13\" 2024-10-15", Some(17)).summary(),
|
||||
row("openjdk version \"17.0.9\" 2024-01-16", Some(17)).summary(),
|
||||
"两份同大版本 JDK 的摘要必须不同"
|
||||
);
|
||||
assert_eq!(
|
||||
row("openjdk 25.0.3 2026-04-21", Some(25)).version_label(),
|
||||
"25.0.3",
|
||||
"无引号形态也要认"
|
||||
);
|
||||
assert_eq!(
|
||||
row("something unexpected 21", Some(21)).version_label(),
|
||||
"something unexpected 21",
|
||||
"解析不出来时给原始版本串,不给光秃秃的大版本"
|
||||
);
|
||||
assert_eq!(row("", Some(21)).version_label(), "21");
|
||||
assert_eq!(row("", None).version_label(), "?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_carries_version_and_vendor() {
|
||||
assert_eq!(
|
||||
row("openjdk version \"21.0.11\" 2025-10-21 LTS", Some(21)).summary(),
|
||||
"Java 21.0.11 · Microsoft"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! 「Java」分组的截图种子(M175,`ULL_JAVA_STATE`):**只给 `--screenshot` 用**。
|
||||
//!
|
||||
//! 三档对应任务书要的三张图:未安装 Java 的空态 / 检测到两个 JDK 并选中其一 / 内存与 JVM 参数
|
||||
//! 的编辑态。编辑态只让**内存**越界(`validate()` 一次只报第一个错,所以图上看到的是「内存超出
|
||||
//! 允许范围」+ 内存框标红;引号那条另有单测,不靠这张图证明)。
|
||||
//!
|
||||
//! 与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` / `ULL_FONT_STATUS` 同一族取证开关:只影响这一次进程,
|
||||
//! 产品路径不设它。种子里的路径是**写死的示例路径**(真实机器上没有),所以它只在出图通道里出现;
|
||||
//! 产品路径的真值一律来自 `core::game::java::staged` 的真扫盘。
|
||||
|
||||
use super::state::{Context, Layer, Notice, Refusal, Requirement, RuntimeRow, State};
|
||||
|
||||
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用)。
|
||||
///
|
||||
/// 三档对应三张图:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与
|
||||
/// JVM 参数、还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
|
||||
#[must_use]
|
||||
pub fn build(raw: &str) -> Option<State> {
|
||||
let mut state = State {
|
||||
storage: true,
|
||||
scanned: true,
|
||||
..State::default()
|
||||
};
|
||||
match raw.trim() {
|
||||
"empty" => {
|
||||
state.memory = "2048".to_string();
|
||||
state.saved_memory = 2048;
|
||||
}
|
||||
"two" => {
|
||||
state.runtimes = vec![
|
||||
RuntimeRow {
|
||||
binary: "C:/Program Files/Eclipse Adoptium/jdk-17.0.13/bin/java.exe"
|
||||
.to_string(),
|
||||
version: "openjdk version \"17.0.13\" 2024-10-15".to_string(),
|
||||
major: Some(17),
|
||||
vendor: Some("Temurin".to_string()),
|
||||
source: "system",
|
||||
},
|
||||
RuntimeRow {
|
||||
binary: "C:/Program Files/Microsoft/jdk-21.0.4/bin/java.exe".to_string(),
|
||||
version: "openjdk version \"21.0.4\" 2024-07-16 LTS".to_string(),
|
||||
major: Some(21),
|
||||
vendor: Some("Microsoft".to_string()),
|
||||
source: "system",
|
||||
},
|
||||
];
|
||||
state.selected = state.runtimes[1].binary.clone();
|
||||
state.manual = vec!["D:/jdk/zulu-21".to_string()];
|
||||
state.memory = "4096".to_string();
|
||||
state.saved_memory = 4096;
|
||||
state.jvm = "-XX:+UseG1GC".to_string();
|
||||
state.saved_jvm = "-XX:+UseG1GC".to_string();
|
||||
state.context = Context {
|
||||
required: Some(Requirement {
|
||||
instance: "生存服".to_string(),
|
||||
version: "1.20.1".to_string(),
|
||||
minimum: 17,
|
||||
maximum: None,
|
||||
}),
|
||||
layer: Some(Layer::Global),
|
||||
effective_memory_mb: Some(4096),
|
||||
};
|
||||
}
|
||||
"editing" => {
|
||||
let mut seeded = build("two")?;
|
||||
seeded.memory = "128".to_string();
|
||||
seeded.jvm = "-XX:+UseG1GC -Xss\"512k".to_string();
|
||||
seeded.dirty = true;
|
||||
seeded.notice = Some(Notice::Refused(Refusal::MemoryRange));
|
||||
return Some(seeded);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(state)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! 「Java」分组的运行期状态(M175):扫描结论 + 手动名单 + 两个可编辑项。
|
||||
//!
|
||||
//! 状态住在 `pages::settings::Groups.java`([`State`]),由 `App` 持有。三条边界:
|
||||
//!
|
||||
//! | 东西 | 住哪 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 扫描结论 / 进度 | 本结构(由后台线程的快照 1 拍一拍灌进来) | 用户切走再回来不该重扫 |
|
||||
//! | 手动加的路径名单 | 本结构 + 一个纯文本文件(`store::manual_file`) | 它是「我加过哪些」的记忆,不是启动策略 |
|
||||
//! | `java.selected_path` / `default_memory_mb` / `extra_args` | **`config/launcher.json`** | 真值只有一份,读回也读它 |
|
||||
//!
|
||||
//! ## 为什么内存草稿要记两层(`memory` 文本 + `saved_memory` 数值)
|
||||
//!
|
||||
//! 输入框里的是**文本**(用户可能刚敲了半截「819」),而「现在生效多少」是**落盘的真值**。
|
||||
//! 两者混成一个字段就会重演「显示的就是生效的」这种假象:改到一半的数字不是生效值。
|
||||
//! 于是草稿只影响「有未保存改动」与「保存按钮能不能点」,生效值一律来自 `saved_*`(落盘后读回的)。
|
||||
//!
|
||||
//! ## 版本门槛从哪来
|
||||
//!
|
||||
//! [`Context::required`] 由 **app 层**(`app/update/settings/java.rs`)算好塞进来:它要读实例快照
|
||||
//! (`requirement_for(mc_version)`),而页面渲染器只拿得到 [`super::States`]。
|
||||
//! 页面自己不去读盘、也不复制版本表。
|
||||
|
||||
pub use super::row::RuntimeRow;
|
||||
|
||||
use unlucky_i18n::Key;
|
||||
|
||||
/// `java.default_memory_mb` 的下限(MB)。
|
||||
///
|
||||
/// **必须**等于 `core::setting::keys` 里那个键登记的下限(512):UI 放行一个 schema 会拒的值,
|
||||
/// 用户点保存只会得到一条「schema 拒绝」的错。一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
|
||||
pub const MIN_MEMORY_MB: u32 = 512;
|
||||
/// `java.default_memory_mb` 的上限(MB)。
|
||||
pub const MAX_MEMORY_MB: u32 = 65536;
|
||||
/// `java.default_memory_mb` 的出厂默认值(MB):与 schema 的默认值同源,
|
||||
/// 一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
|
||||
pub const DEFAULT_MEMORY_MB: u32 = 2048;
|
||||
|
||||
/// 扫描进度(`None` = 没在扫)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
/// 已探测的候选数
|
||||
pub done: usize,
|
||||
/// 候选总数
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// 当前实例对 Java 的版本要求(app 层算好塞进来)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Requirement {
|
||||
/// 实例名(「为什么选它」那句话里要指名道姓)
|
||||
pub instance: String,
|
||||
/// 实例绑的版本 id(`1.20.1`)
|
||||
pub version: String,
|
||||
/// 最低主版本
|
||||
pub minimum: u32,
|
||||
/// 最高主版本(老版本才有;`None` = 不封顶)
|
||||
pub maximum: Option<u32>,
|
||||
}
|
||||
|
||||
impl Requirement {
|
||||
/// 某个大版本满不满足。
|
||||
#[must_use]
|
||||
pub fn satisfied_by(&self, major: u32) -> bool {
|
||||
major >= self.minimum && self.maximum.is_none_or(|max| major <= max)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生效内存来自哪一层。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Layer {
|
||||
/// 全局 `java.default_memory_mb`
|
||||
Global,
|
||||
/// 某个实例的 `launch.memoryMb` 覆盖了全局
|
||||
Instance {
|
||||
/// 实例名
|
||||
instance: String,
|
||||
/// 实例层的值
|
||||
mb: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// 上下文(app 层每次 hydrate 重算)。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
/// 当前实例的版本要求(没有实例 ⇒ `None`)
|
||||
pub required: Option<Requirement>,
|
||||
/// 当前生效内存来自哪一层
|
||||
pub layer: Option<Layer>,
|
||||
/// 当前生效内存(三级回落之后的数值)
|
||||
pub effective_memory_mb: Option<u32>,
|
||||
}
|
||||
|
||||
/// 保存 / 操作的回执。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Notice {
|
||||
/// 已落盘并读回
|
||||
Saved,
|
||||
/// 本地校验拒绝(没写盘)
|
||||
Refused(Refusal),
|
||||
/// 设置文件层面拒绝:只读 / 不可信 / 没有改动(i18n key,不拼字符串)
|
||||
Unwritable(Unwritable),
|
||||
/// 写盘失败(schema / 磁盘):core 的 `Display` 是中文,不能直出给英文界面,
|
||||
/// 所以这里只带一个页面自己的 key;原始句子进 stderr 日志。
|
||||
WriteFailed,
|
||||
/// 没有可写文件(内存态运行)
|
||||
NoStorage,
|
||||
/// 默认 JDK 已写进 `java.selected_path`
|
||||
DefaultSet(String),
|
||||
/// 手动路径已加入名单
|
||||
Added(String),
|
||||
}
|
||||
|
||||
/// 设置文件层面拒绝的三档(`SettingStore::save` 的 `Skipped` 三因)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Unwritable {
|
||||
/// 没有任何脏键(我们以为改了、其实没有)
|
||||
NotDirty,
|
||||
/// 文件只读(被更新版本的启动器写过 / 被标记不可信)
|
||||
ReadOnly,
|
||||
/// 文件不可信(加载时就降级成只读)
|
||||
Unreadable,
|
||||
}
|
||||
|
||||
impl Unwritable {
|
||||
/// 文案 key。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> Key {
|
||||
match self {
|
||||
Self::NotDirty => Key::SettingsJavaSkipNotDirty,
|
||||
Self::ReadOnly => Key::SettingsJavaSkipReadOnly,
|
||||
Self::Unreadable => Key::SettingsJavaSkipUnreadable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地校验的拒绝原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// 内存草稿为空
|
||||
MemoryEmpty,
|
||||
/// 内存草稿不是整数
|
||||
MemoryNotNumber,
|
||||
/// 内存越界
|
||||
MemoryRange,
|
||||
/// JVM 参数里的引号没配成对
|
||||
JvmQuotes,
|
||||
/// 手动路径为空
|
||||
AddEmpty,
|
||||
/// 手动路径在盘上找不到
|
||||
AddMissing,
|
||||
/// 有这个东西,但不是 java 可执行文件
|
||||
AddNotJava,
|
||||
/// 跑不出 `-version`
|
||||
AddNoVersion,
|
||||
/// 路径太长(粘了一整段日志)
|
||||
AddTooLong,
|
||||
/// 名单文件写不进去(磁盘 / 权限)
|
||||
AddWriteFailed,
|
||||
/// 已经加过了
|
||||
AddDuplicate,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// 文案 key。
|
||||
#[must_use]
|
||||
pub const fn label(self) -> Key {
|
||||
match self {
|
||||
Self::MemoryEmpty => Key::SettingsJavaReasonMemoryEmpty,
|
||||
Self::MemoryNotNumber => Key::SettingsJavaReasonMemoryNotNumber,
|
||||
Self::MemoryRange => Key::SettingsJavaReasonMemoryRange,
|
||||
Self::JvmQuotes => Key::SettingsJavaReasonJvmQuotes,
|
||||
Self::AddEmpty => Key::SettingsJavaReasonAddEmpty,
|
||||
Self::AddMissing => Key::SettingsJavaReasonAddMissing,
|
||||
Self::AddNotJava => Key::SettingsJavaReasonAddNotJava,
|
||||
Self::AddNoVersion => Key::SettingsJavaReasonAddNoVersion,
|
||||
Self::AddTooLong => Key::SettingsJavaReasonAddTooLong,
|
||||
Self::AddWriteFailed => Key::SettingsJavaReasonAddWriteFailed,
|
||||
Self::AddDuplicate => Key::SettingsJavaReasonAddDuplicate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 「Java」分组的运行期状态。
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
/// 扫到的运行时(顺序 = 大版本升序,探测不到主版本的排最后)
|
||||
pub runtimes: Vec<RuntimeRow>,
|
||||
/// 至少完整扫过一次(区分「未检测到」与「还没扫」)
|
||||
pub scanned: bool,
|
||||
/// 扫描进度
|
||||
pub scanning: Option<Progress>,
|
||||
/// 上一次扫描被用户取消(界面上要说一句,否则「列表没变」会被当成没扫)
|
||||
pub cancelled: bool,
|
||||
/// 扫描状态槽(快照 + 取消位;线程与界面共享同一份)
|
||||
pub scan: super::store::ScanSlot,
|
||||
/// 用户手动加的路径(原样,持久化在 `manual_file`)
|
||||
pub manual: Vec<String>,
|
||||
/// `java.selected_path` 的真值(空 = 没指定,启动时自动挑)
|
||||
pub selected: String,
|
||||
/// 内存草稿(MB 文本)
|
||||
pub memory: String,
|
||||
/// 落盘的内存真值(MB)
|
||||
pub saved_memory: u32,
|
||||
/// JVM 参数草稿
|
||||
pub jvm: String,
|
||||
/// 落盘的 JVM 参数真值
|
||||
pub saved_jvm: String,
|
||||
/// 手动路径输入框
|
||||
pub add_draft: String,
|
||||
/// 有未保存的改动
|
||||
pub dirty: bool,
|
||||
/// 这一次运行有没有可写的设置文件
|
||||
pub storage: bool,
|
||||
/// 上一次操作的回执
|
||||
pub notice: Option<Notice>,
|
||||
/// 当前实例的上下文(版本门槛 / 生效内存层级)
|
||||
pub context: Context,
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
runtimes: Vec::new(),
|
||||
scanned: false,
|
||||
scanning: None,
|
||||
cancelled: false,
|
||||
scan: super::store::ScanSlot::default(),
|
||||
manual: Vec::new(),
|
||||
selected: String::new(),
|
||||
memory: String::new(),
|
||||
saved_memory: DEFAULT_MEMORY_MB,
|
||||
jvm: String::new(),
|
||||
saved_jvm: String::new(),
|
||||
add_draft: String::new(),
|
||||
dirty: false,
|
||||
storage: false,
|
||||
notice: None,
|
||||
context: Context::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `launcher.json` 读回来的三键真值。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Snapshot {
|
||||
/// `java.selected_path`
|
||||
pub selected: String,
|
||||
/// `java.default_memory_mb`
|
||||
pub memory_mb: u32,
|
||||
/// `java.extra_args`
|
||||
pub jvm: String,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// 用真值刷新(**脏草稿不覆盖**,否则外部一改文件就冲掉用户正在输入的内容)。
|
||||
///
|
||||
/// 返回是否改了「生效值」那一半(调用方据此决定要不要重建视图模型)。
|
||||
pub fn hydrate(&mut self, snapshot: Snapshot) -> bool {
|
||||
self.storage = true;
|
||||
let Snapshot {
|
||||
selected,
|
||||
memory_mb,
|
||||
jvm,
|
||||
} = snapshot;
|
||||
let changed =
|
||||
self.selected != selected || self.saved_memory != memory_mb || self.saved_jvm != jvm;
|
||||
self.selected = selected;
|
||||
self.saved_memory = memory_mb;
|
||||
if !self.dirty {
|
||||
self.memory = memory_mb.to_string();
|
||||
self.jvm = jvm.clone();
|
||||
}
|
||||
self.saved_jvm = jvm;
|
||||
changed
|
||||
}
|
||||
|
||||
/// 灌一次扫描结论(后台线程的快照)。返回是否变了。
|
||||
pub fn apply_scan(&mut self, report: &super::store::ScanReport) -> bool {
|
||||
let scanning = report.running.then_some(Progress {
|
||||
done: report.done,
|
||||
total: report.total,
|
||||
});
|
||||
let changed = self.scanning != scanning
|
||||
|| self.cancelled != report.cancelled
|
||||
|| (report.finished && !self.scanned)
|
||||
|| (report.finished && self.runtimes != report.runtimes);
|
||||
self.scanning = scanning;
|
||||
self.cancelled = report.cancelled;
|
||||
if report.finished {
|
||||
self.scanned = true;
|
||||
self.runtimes = report.runtimes.clone();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 某个大版本够不够当前实例用。
|
||||
#[must_use]
|
||||
pub fn satisfied(&self, major: Option<u32>) -> Option<bool> {
|
||||
let (Some(required), Some(major)) = (self.context.required.as_ref(), major) else {
|
||||
return None;
|
||||
};
|
||||
Some(required.satisfied_by(major))
|
||||
}
|
||||
|
||||
/// 保存前的本地校验(过不了就不写盘)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 内存不是整数 / 越界,或 JVM 参数的引号没配成对时返回原因。
|
||||
pub fn validate(&self) -> Result<u32, Refusal> {
|
||||
let text = self.memory.trim();
|
||||
if text.is_empty() {
|
||||
return Err(Refusal::MemoryEmpty);
|
||||
}
|
||||
let Ok(mb) = text.parse::<u32>() else {
|
||||
return Err(Refusal::MemoryNotNumber);
|
||||
};
|
||||
if !(MIN_MEMORY_MB..=MAX_MEMORY_MB).contains(&mb) {
|
||||
return Err(Refusal::MemoryRange);
|
||||
}
|
||||
if unbalanced_quotes(&self.jvm) {
|
||||
return Err(Refusal::JvmQuotes);
|
||||
}
|
||||
Ok(mb)
|
||||
}
|
||||
|
||||
/// 界面上「现在生效多少」的那句话用哪一层(没有上下文时按全局)。
|
||||
#[must_use]
|
||||
pub fn layer(&self) -> Layer {
|
||||
self.context.layer.clone().unwrap_or(Layer::Global)
|
||||
}
|
||||
|
||||
/// 现在能不能点保存。
|
||||
#[must_use]
|
||||
pub fn can_save(&self) -> bool {
|
||||
self.storage && self.dirty
|
||||
}
|
||||
|
||||
/// 当前默认运行时那一行(`selected` 指向的)。
|
||||
#[must_use]
|
||||
pub fn default_row(&self) -> Option<&RuntimeRow> {
|
||||
self.runtimes
|
||||
.iter()
|
||||
.find(|row| same_path(&row.binary, &self.selected))
|
||||
}
|
||||
|
||||
/// 取证开关 `ULL_JAVA_STATE`(只给 `--screenshot` 用;三档的实现在 `java/seed.rs`)。
|
||||
///
|
||||
/// 三档:`empty`(未检测到)/ `two`(两个 JDK 并选中其一)/ `editing`(改了内存与 JVM 参数、
|
||||
/// 还没保存)。与 `ULL_AI_STATE` / `ULL_UPDATE_STATE` 同一族,只影响这一次进程。
|
||||
#[must_use]
|
||||
pub fn screenshot_seed(raw: &str) -> Option<Self> {
|
||||
super::seed::build(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// 两条路径是不是同一个(`/` 与 `\` 视为同一分隔符;Windows 折大小写)。
|
||||
#[must_use]
|
||||
pub fn same_path(left: &str, right: &str) -> bool {
|
||||
let key = |text: &str| {
|
||||
let text = text.replace('\\', "/");
|
||||
if cfg!(windows) {
|
||||
text.to_ascii_lowercase()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
};
|
||||
!left.trim().is_empty() && key(left.trim()) == key(right.trim())
|
||||
}
|
||||
|
||||
/// 引号(`"` 与 `'`)数量是不是奇数——奇数就意味着用户漏了一半。
|
||||
#[must_use]
|
||||
pub fn unbalanced_quotes(text: &str) -> bool {
|
||||
text.matches('"').count() % 2 == 1 || text.matches('\'').count() % 2 == 1
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! 「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)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! 「Java」分组的真数据源断言(M175):扫描 / 手动路径 / 内存 / JVM 参数 / 真命令行。
|
||||
//!
|
||||
//! 每一条都**能变红**(报告的「变异证明」逐条给了改哪一行会红)。四条守卫:
|
||||
//!
|
||||
//! | 守卫 | 断言 | 改回什么会红 |
|
||||
//! |---|---|---|
|
||||
//! | 列表来自真扫盘 | [`the_runtime_list_comes_from_the_disk`] | 把行换成写死的字符串 / 不读候选表 |
|
||||
//! | 非法路径不污染设置 | [`an_invalid_manual_path_is_refused_and_writes_nothing`] | 删掉 `manual::probe_manual` 的校验 |
|
||||
//! | 内存越界被拒 | [`the_memory_bounds_are_enforced_before_writing`] | 删掉范围判断 / 允许非数字 |
|
||||
//! | 内存真进命令行 | [`the_saved_memory_reaches_the_real_command_line`] | 把 `-Xmx` 的来源改回常量 |
|
||||
//!
|
||||
//! 前三条不依赖本机装没装 JDK(假的 `bin/java` 空文件就够:它扫得到、但答不出版本),
|
||||
//! 第四条(`tests/cmdline.rs`)走 core 的**真组装链**(`launch::args_table::assemble`)——
|
||||
//! 不是读配置回显。落盘 / 读回 / 版本门槛 / 截图种子也在那个文件里(同一族「真值」断言)。
|
||||
|
||||
#[path = "tests/cmdline.rs"]
|
||||
mod cmdline;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::state::{Refusal, RuntimeRow, State, MAX_MEMORY_MB, MIN_MEMORY_MB};
|
||||
use super::store;
|
||||
|
||||
/// 每个用例一个临时数据根(`<dir>/config/launcher.json` 的形状与真实数据根一致)。
|
||||
pub(super) fn root(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-java-page-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("config")).expect("config");
|
||||
dir
|
||||
}
|
||||
|
||||
pub(super) fn config(dir: &Path) -> PathBuf {
|
||||
dir.join("config").join("launcher.json")
|
||||
}
|
||||
|
||||
/// 造一个「看起来像 JDK」的目录:`bin/java` 是空文件(扫得到、但 `-version` 答不出)。
|
||||
pub(super) fn fake_jdk(dir: &Path, name: &str) -> PathBuf {
|
||||
let jdk = dir.join(name);
|
||||
std::fs::create_dir_all(jdk.join("bin")).expect("bin");
|
||||
std::fs::write(jdk.join("bin").join(java_bin()), b"").expect("写 java");
|
||||
jdk
|
||||
}
|
||||
|
||||
pub(super) fn java_bin() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"java.exe"
|
||||
} else {
|
||||
"java"
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫①:列表来自真扫盘
|
||||
|
||||
/// 扫描列表必须来自**盘上真有的目录**:造两个 → 两项;删一个 → 一项。
|
||||
///
|
||||
/// 变异证明:把 `store::start` 里的候选表换成一个写死的两行常量,删目录之后仍是两项 ⇒ 红。
|
||||
#[test]
|
||||
fn the_runtime_list_comes_from_the_disk() {
|
||||
let dir = root("scan");
|
||||
let one = fake_jdk(&dir, "jdk-a");
|
||||
let two = fake_jdk(&dir, "jdk-b");
|
||||
|
||||
let found =
|
||||
unlucky_core::game::java::staged::scan_roots(&[], &[one.clone(), two.clone()], false);
|
||||
assert_eq!(found.len(), 2, "两个假 JDK 目录必须扫出两项:{found:?}");
|
||||
|
||||
std::fs::remove_dir_all(&one).expect("删 jdk-a");
|
||||
let after = unlucky_core::game::java::staged::scan_roots(&[], &[one, two], false);
|
||||
assert_eq!(after.len(), 1, "删掉一个之后必须少一项:{after:?}");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 扫描结论灌进页面状态之后,**行数与路径**都来自那次扫描(不是常量)。
|
||||
#[test]
|
||||
fn applying_a_scan_report_replaces_every_row() {
|
||||
let mut state = State::default();
|
||||
let report = store::ScanReport {
|
||||
running: false,
|
||||
done: 2,
|
||||
total: 2,
|
||||
finished: true,
|
||||
cancelled: false,
|
||||
runtimes: vec![
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk-a/bin/java.exe".to_string(),
|
||||
version: String::new(),
|
||||
major: None,
|
||||
vendor: None,
|
||||
source: "manual",
|
||||
},
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk-b/bin/java.exe".to_string(),
|
||||
version: String::new(),
|
||||
major: Some(21),
|
||||
vendor: Some("Temurin".to_string()),
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(state.apply_scan(&report), "第一次灌结论必须报告「变了」");
|
||||
assert_eq!(state.runtimes.len(), 2);
|
||||
assert!(state.scanned, "扫完之后「未检测到」与「还没扫」必须能分开");
|
||||
assert_eq!(state.runtimes[1].summary(), "Java 21 · Temurin");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫②:非法路径不污染设置
|
||||
|
||||
/// 非法路径被拒、**设置与名单一个字节都不写**。
|
||||
///
|
||||
/// 变异证明:删掉 `store::add_manual` 里的 `manual::probe_manual`(改成只看 `is_dir()`),
|
||||
/// 「不存在的路径」就会被加进名单 ⇒ 红。
|
||||
#[test]
|
||||
fn an_invalid_manual_path_is_refused_and_writes_nothing() {
|
||||
let dir = root("manual");
|
||||
let file = config(&dir);
|
||||
|
||||
assert_eq!(
|
||||
store::add_manual(&file, "").err(),
|
||||
Some(Refusal::AddEmpty),
|
||||
"空输入要被拒"
|
||||
);
|
||||
assert_eq!(
|
||||
store::add_manual(&file, "Z:/definitely/not/here").err(),
|
||||
Some(Refusal::AddMissing),
|
||||
"不存在的路径要被拒"
|
||||
);
|
||||
let empty = dir.join("empty-jdk");
|
||||
std::fs::create_dir_all(&empty).expect("空目录");
|
||||
assert_eq!(
|
||||
store::add_manual(&file, &empty.display().to_string()).err(),
|
||||
Some(Refusal::AddNotJava),
|
||||
"目录里没有 bin/java 要被拒"
|
||||
);
|
||||
let broken = fake_jdk(&dir, "broken-jdk");
|
||||
assert_eq!(
|
||||
store::add_manual(&file, &broken.display().to_string()).err(),
|
||||
Some(Refusal::AddNoVersion),
|
||||
"跑不出 java -version 要被拒"
|
||||
);
|
||||
|
||||
assert!(
|
||||
store::load_manual(&file).is_empty(),
|
||||
"被拒的路径不许进名单:{:?}",
|
||||
store::load_manual(&file)
|
||||
);
|
||||
let snapshot = store::load(&file);
|
||||
assert!(
|
||||
snapshot.selected.is_empty(),
|
||||
"被拒的路径不许污染 java.selected_path:{snapshot:?}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫③:内存越界被拒
|
||||
|
||||
/// 内存必须落在 schema 登记的区间里,非数字也要拒,而且**不写盘**。
|
||||
///
|
||||
/// 变异证明:删掉 `State::validate` 里的范围判断(只留 `parse`),128 与 999999 会落盘 ⇒ 红;
|
||||
/// 把 `parse::<u32>` 换成 `unwrap_or(2048)`,`"abc"` 不再报错 ⇒ 红。
|
||||
#[test]
|
||||
fn the_memory_bounds_are_enforced_before_writing() {
|
||||
let mut state = State {
|
||||
storage: true,
|
||||
..State::default()
|
||||
};
|
||||
|
||||
state.memory = (MIN_MEMORY_MB - 1).to_string();
|
||||
assert_eq!(
|
||||
state.validate().err(),
|
||||
Some(Refusal::MemoryRange),
|
||||
"低于下限"
|
||||
);
|
||||
state.memory = (MAX_MEMORY_MB + 1).to_string();
|
||||
assert_eq!(
|
||||
state.validate().err(),
|
||||
Some(Refusal::MemoryRange),
|
||||
"高于上限"
|
||||
);
|
||||
state.memory = "abc".to_string();
|
||||
assert_eq!(
|
||||
state.validate().err(),
|
||||
Some(Refusal::MemoryNotNumber),
|
||||
"非数字"
|
||||
);
|
||||
state.memory = " ".to_string();
|
||||
assert_eq!(state.validate().err(), Some(Refusal::MemoryEmpty), "空");
|
||||
state.memory = "4096".to_string();
|
||||
assert!(state.validate().is_ok());
|
||||
state.jvm = "-XX:+UseG1GC".to_string();
|
||||
assert!(state.validate().is_ok());
|
||||
state.jvm = "-Xss\"512k".to_string();
|
||||
assert_eq!(
|
||||
state.validate().err(),
|
||||
Some(Refusal::JvmQuotes),
|
||||
"引号没配对"
|
||||
);
|
||||
|
||||
// 真的不写盘:一次越界保存之后,文件里还是出厂默认值。
|
||||
let dir = root("memory");
|
||||
let file = config(&dir);
|
||||
let state = State {
|
||||
storage: true,
|
||||
memory: "128".to_string(),
|
||||
..State::default()
|
||||
};
|
||||
assert!(store::save(&file, &state).is_err(), "越界必须写不进去");
|
||||
assert_eq!(
|
||||
store::load(&file).memory_mb,
|
||||
super::state::DEFAULT_MEMORY_MB,
|
||||
"被拒的保存不许落盘"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 两个数字常量必须与 schema 登记的一致(否则 UI 放行的值会被 schema 拒)。
|
||||
///
|
||||
/// 变异证明:把 `MIN_MEMORY_MB` 改成 256(core 的 `launch_overrides` 那一档),这条立刻红。
|
||||
#[test]
|
||||
fn the_memory_bounds_mirror_the_schema() {
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::spec::ValueKind;
|
||||
let schema = launcher_schema();
|
||||
let kind = schema
|
||||
.spec("java.default_memory_mb")
|
||||
.map(|spec| &spec.kind)
|
||||
.expect("schema 里必须有 java.default_memory_mb");
|
||||
let ValueKind::Int { min, max } = kind else {
|
||||
panic!("java.default_memory_mb 应该是 Int:{kind:?}");
|
||||
};
|
||||
assert_eq!(*min, i64::from(MIN_MEMORY_MB));
|
||||
assert_eq!(*max, i64::from(MAX_MEMORY_MB));
|
||||
}
|
||||
#[path = "tests/runtime.rs"]
|
||||
mod runtime;
|
||||
@@ -0,0 +1,262 @@
|
||||
//! `tests.rs` 的后半:真命令行 / 落盘读回 / 版本门槛 / 截图种子。
|
||||
//!
|
||||
//! 拆出来的理由:`tests.rs` 加上守卫之后 434 行,越过 400 行硬线;这一半是「真值走完整条生产链」
|
||||
//! 的一族(`launcher.json` → 组装链 / 重启后读回),与前半的「输入校验」不是一件事。
|
||||
//! 公共的临时目录与假 JDK 构造在父模块(`super::root` / `super::fake_jdk`)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::super::state::{Layer, Notice, Requirement, RuntimeRow, State};
|
||||
use super::super::store;
|
||||
use super::{config, fake_jdk, root};
|
||||
|
||||
// --------------------------------------------------------------- 守卫④:内存真进命令行
|
||||
|
||||
/// 写进 `java.default_memory_mb` 的值必须出现在**真组装出来的 JVM 参数**里。
|
||||
///
|
||||
/// 走的是生产代码那条链:`launcher.json` → `GlobalLaunchDefaults` → `launch_overrides::resolve`
|
||||
/// → `launch::overrides::apply` → `launch::args_table::assemble`(`ull launch --dry-run` 用的是同一条)。
|
||||
/// 不是读配置回显:断言的是 `-Xmx<新值>m` 这个 argv 元素。
|
||||
///
|
||||
/// 变异证明:把 `store::save` 里写 `java.default_memory_mb` 的那一行删掉(或写死 2048),
|
||||
/// 这条立刻红(argv 里还是 `-Xmx2048m`)。
|
||||
#[test]
|
||||
fn the_saved_memory_reaches_the_real_command_line() {
|
||||
let dir = root("cmdline");
|
||||
let file = config(&dir);
|
||||
let state = State {
|
||||
storage: true,
|
||||
memory: "6144".to_string(),
|
||||
jvm: "-Dfile.encoding=UTF-8".to_string(),
|
||||
..State::default()
|
||||
};
|
||||
assert!(state.validate().is_ok());
|
||||
let saved = store::save(&file, &state).expect("保存应成功");
|
||||
assert_eq!(saved.memory_mb, 6144, "落盘后读回的值");
|
||||
assert_eq!(saved.jvm, "-Dfile.encoding=UTF-8");
|
||||
|
||||
let args = real_argv(&file);
|
||||
assert!(
|
||||
args.iter().any(|arg| arg == "-Xmx6144m"),
|
||||
"内存字段必须长成 -Xmx6144m:{args:?}"
|
||||
);
|
||||
assert!(
|
||||
args.iter().any(|arg| arg == "-Dfile.encoding=UTF-8"),
|
||||
"java.extra_args 必须进命令行:{args:?}"
|
||||
);
|
||||
assert!(
|
||||
!args.iter().any(|arg| arg == "-Xmx2048m"),
|
||||
"出厂默认值不许还在里面:{args:?}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 用生产链组装一次命令行(`launch.json` 之外什么都不读)。
|
||||
fn real_argv(config: &Path) -> Vec<String> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::launch_overrides::{resolve, GlobalLaunchDefaults};
|
||||
use unlucky_core::launch::args_table::assemble;
|
||||
use unlucky_core::launch::overrides;
|
||||
use unlucky_core::launch::platform::{OsFamily, PlatformCtx};
|
||||
use unlucky_core::launch::request::{
|
||||
InstanceInfo, JavaSelection, LaunchDirs, ManifestArgs, MemoryOptions,
|
||||
};
|
||||
use unlucky_core::launch::request::{
|
||||
LaunchRequest, LauncherInfo, Log4jOptions, OptimizationOptions,
|
||||
};
|
||||
|
||||
let snapshot = store::load(config);
|
||||
let resolved = resolve(
|
||||
None,
|
||||
&GlobalLaunchDefaults {
|
||||
jvm_args: Some(snapshot.jvm.clone()),
|
||||
memory_mb: Some(snapshot.memory_mb),
|
||||
..GlobalLaunchDefaults::default()
|
||||
},
|
||||
);
|
||||
let mut request = LaunchRequest {
|
||||
instance: InstanceInfo {
|
||||
id: "inst-1".to_string(),
|
||||
name: "1.20.1".to_string(),
|
||||
version: unlucky_core::launch::game_version::GameVersion::parse("1.20.1"),
|
||||
version_type: "release".to_string(),
|
||||
},
|
||||
launcher: LauncherInfo {
|
||||
name: "UnluckyLauncher".to_string(),
|
||||
version: "0.2.3".to_string(),
|
||||
},
|
||||
java: JavaSelection::new("C:/java/bin/java.exe", 21, 64),
|
||||
memory: MemoryOptions {
|
||||
min_mb: None,
|
||||
max_mb: None,
|
||||
},
|
||||
manifest: ManifestArgs {
|
||||
main_class: Some("net.minecraft.client.main.Main".to_string()),
|
||||
..ManifestArgs::default()
|
||||
},
|
||||
dirs: LaunchDirs {
|
||||
game_dir: PathBuf::from("C:/mc"),
|
||||
primary_jar: PathBuf::from("C:/mc/versions/x/x.jar"),
|
||||
..LaunchDirs::default()
|
||||
},
|
||||
classpath: vec![PathBuf::from("C:/mc/libraries/a.jar")],
|
||||
log4j: Log4jOptions {
|
||||
enabled: false,
|
||||
..Log4jOptions::default()
|
||||
},
|
||||
optimization: OptimizationOptions {
|
||||
enabled: false,
|
||||
physical_memory_mb: None,
|
||||
},
|
||||
language: "zh-CN".to_string(),
|
||||
..LaunchRequest::default()
|
||||
};
|
||||
overrides::apply(&mut request, &resolved);
|
||||
assemble(&request, &PlatformCtx::new(OsFamily::Windows, "x86_64"))
|
||||
.expect("组装应成功")
|
||||
.args
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 落盘 / 读回 / 门槛
|
||||
|
||||
/// 三键都能落盘、能在「重启后」读回来(新的 `State` + `hydrate` 就是重启的等价物)。
|
||||
#[test]
|
||||
fn the_settings_survive_a_restart() {
|
||||
let dir = root("restart");
|
||||
let file = config(&dir);
|
||||
let state = State {
|
||||
storage: true,
|
||||
memory: "8192".to_string(),
|
||||
jvm: "-XX:+UseG1GC".to_string(),
|
||||
..State::default()
|
||||
};
|
||||
store::save(&file, &state).expect("保存");
|
||||
let selected = store::set_selected(&file, "C:/jdk-17/bin/java.exe").expect("选默认");
|
||||
|
||||
// 「重启」:全新的 State 从盘上灌一次。
|
||||
let mut reborn = State::default();
|
||||
assert!(reborn.hydrate(store::load(&file)));
|
||||
assert_eq!(reborn.memory, "8192");
|
||||
assert_eq!(reborn.saved_memory, 8192);
|
||||
assert_eq!(reborn.jvm, "-XX:+UseG1GC");
|
||||
assert_eq!(reborn.selected, "C:/jdk-17/bin/java.exe");
|
||||
assert_eq!(selected.selected, "C:/jdk-17/bin/java.exe");
|
||||
|
||||
// 手动名单也在(与设置同一个 config/ 目录下的纯文本)。
|
||||
let jdk = fake_jdk(&dir, "keep-me");
|
||||
std::fs::write(store::manual_file(&file), format!("{}\n", jdk.display())).expect("写名单");
|
||||
assert_eq!(store::load_manual(&file).len(), 1);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 「为什么选它」那一行:版本门槛来自实例,选中的运行时够不够要能算出来。
|
||||
#[test]
|
||||
fn the_requirement_explains_why_a_runtime_fits_or_not() {
|
||||
let mut state = State {
|
||||
scanned: true,
|
||||
runtimes: vec![
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk-8/bin/java.exe".to_string(),
|
||||
version: String::new(),
|
||||
major: Some(8),
|
||||
vendor: None,
|
||||
source: "system",
|
||||
},
|
||||
RuntimeRow {
|
||||
binary: "C:/jdk-21/bin/java.exe".to_string(),
|
||||
version: String::new(),
|
||||
major: Some(21),
|
||||
vendor: None,
|
||||
source: "system",
|
||||
},
|
||||
],
|
||||
selected: "C:/jdk-8/bin/java.exe".to_string(),
|
||||
..State::default()
|
||||
};
|
||||
assert_eq!(state.satisfied(Some(8)), None, "没有要求时不下结论");
|
||||
|
||||
state.context.required = Some(Requirement {
|
||||
instance: "生存服".to_string(),
|
||||
version: "1.20.1".to_string(),
|
||||
minimum: 17,
|
||||
maximum: None,
|
||||
});
|
||||
assert_eq!(state.satisfied(Some(8)), Some(false));
|
||||
assert_eq!(state.satisfied(Some(21)), Some(true));
|
||||
assert_eq!(
|
||||
state.default_row().map(|row| row.binary.clone()),
|
||||
Some("C:/jdk-8/bin/java.exe".to_string()),
|
||||
"默认那一行按 selected 找出来"
|
||||
);
|
||||
|
||||
state.context.layer = Some(Layer::Instance {
|
||||
instance: "生存服".to_string(),
|
||||
mb: 8192,
|
||||
});
|
||||
assert!(matches!(state.layer(), Layer::Instance { mb: 8192, .. }));
|
||||
}
|
||||
|
||||
/// 截图种子:三档都得给得出来(`--screenshot` 的取证开关)。
|
||||
#[test]
|
||||
fn the_screenshot_seeds_cover_the_three_states() {
|
||||
let empty = State::screenshot_seed("empty").expect("empty 档");
|
||||
assert!(empty.runtimes.is_empty() && empty.scanned);
|
||||
|
||||
let two = State::screenshot_seed("two").expect("two 档");
|
||||
assert_eq!(two.runtimes.len(), 2);
|
||||
assert!(two.default_row().is_some(), "两个 JDK 里要选中其一");
|
||||
|
||||
let editing = State::screenshot_seed("editing").expect("editing 档");
|
||||
assert!(editing.dirty, "编辑态要有未保存改动");
|
||||
assert!(matches!(editing.notice, Some(Notice::Refused(_))));
|
||||
assert!(State::screenshot_seed("nonsense").is_none());
|
||||
}
|
||||
|
||||
/// 自动扫描的判据:首屏扫一次,扫过就不重扫,截图通道永不扫。
|
||||
///
|
||||
/// 变异证明:把 `auto_scan_due` 改成恒 `true`,第二、三档立刻红(每次心跳都发起扫描);
|
||||
/// 改成恒 `false`,第一档红(用户实测的头一条抱怨就是「设置页从来自动检测」)。
|
||||
#[test]
|
||||
fn the_auto_scan_predicate_runs_once_and_never_in_screenshots() {
|
||||
let fresh = store::ScanReport::default();
|
||||
assert!(
|
||||
store::auto_scan_due(&fresh, false),
|
||||
"首屏(没扫过、没在扫)应该自动扫一次"
|
||||
);
|
||||
assert!(
|
||||
!store::auto_scan_due(&fresh, true),
|
||||
"截图通道不许读本机装了什么"
|
||||
);
|
||||
|
||||
let running = store::ScanReport {
|
||||
running: true,
|
||||
..store::ScanReport::default()
|
||||
};
|
||||
assert!(!store::auto_scan_due(&running, false), "正在扫就不重入");
|
||||
|
||||
let finished = store::ScanReport {
|
||||
finished: true,
|
||||
..store::ScanReport::default()
|
||||
};
|
||||
assert!(
|
||||
!store::auto_scan_due(&finished, false),
|
||||
"扫过一次就不再自动扫(手动「立即扫描」仍可再扫)"
|
||||
);
|
||||
|
||||
// 被取消过的扫描:`finished` 仍是 false(结论作废),但**不许**自动重来——否则
|
||||
// `launcher.json` 每变一次,心跳就会把用户刚做的取消无声撤销(r2 复审 P2-2)。
|
||||
let cancelled = store::ScanReport {
|
||||
cancelled: true,
|
||||
..store::ScanReport::default()
|
||||
};
|
||||
assert!(
|
||||
!store::auto_scan_due(&cancelled, false),
|
||||
"取消过一次之后,本进程不再自动扫描"
|
||||
);
|
||||
// 手动「立即扫描」走的是 `ScanSlot::start`,不看这个判据 ⇒ 取消不是永久锁死。
|
||||
assert!(
|
||||
store::auto_scan_due(&store::ScanReport::default(), false),
|
||||
"全新进程(没扫过、没取消)仍然自动扫一次"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! `tests.rs` 的第三块(M175 r2):手动名单的落盘 / 扫描槽的并发与取消 / 错误文案的 i18n。
|
||||
//!
|
||||
//! 守卫①~④(列表来自真扫盘、非法路径被拒、内存越界、内存真进命令行)在 `tests.rs` 与
|
||||
//! `tests/cmdline.rs`;这一块是 r1 复审补的三条(P2-3 / P2-5 / P2-2)。
|
||||
|
||||
use super::super::state::{Refusal, RuntimeRow, State, Unwritable};
|
||||
use super::super::store;
|
||||
use super::{config, fake_jdk, java_bin, root};
|
||||
|
||||
// --------------------------------------------------------------- 守卫⑤:手动名单真落盘(P2-3)
|
||||
|
||||
/// 手动加一条合法路径 → **从盘上新读一份** → 名单里有它;「重启」之后仍在。
|
||||
///
|
||||
/// 为什么用注入的探测函数:`probe_manual` 要真跑 `java -version`,用例不能依赖本机装没装 JDK;
|
||||
/// 但**落盘那一段走的是生产代码**(`manual::save_paths` + `manual::load_paths`),所以这条
|
||||
/// 是「持久化」的正面断言,而不是「内存里的 vec 有它」。
|
||||
///
|
||||
/// 变异证明:删掉 `store::add_manual_with` 里的 `manual::save_paths(...)` 那一行 ⇒ 这条红
|
||||
/// (`load_manual` 从盘上读回 0 条);把去重那段删掉 ⇒ 末尾那条 `AddDuplicate` 断言红。
|
||||
#[test]
|
||||
fn a_manual_path_is_written_to_disk_and_survives_a_restart() {
|
||||
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
|
||||
|
||||
let dir = root("manual-ok");
|
||||
let file = config(&dir);
|
||||
let jdk = fake_jdk(&dir, "manual-jdk");
|
||||
let binary = jdk.join("bin").join(java_bin());
|
||||
let runtime = JavaRuntime {
|
||||
binary: binary.clone(),
|
||||
origin: JavaOrigin::System,
|
||||
version: Some("openjdk version \"21.0.4\" 2024-07-16 LTS".to_string()),
|
||||
major: Some(21),
|
||||
bits: 64,
|
||||
vendor: Some("Temurin".to_string()),
|
||||
};
|
||||
let probe = |_input: &str| Ok(runtime.clone());
|
||||
|
||||
// 刚加之前:盘上什么都没有。
|
||||
assert!(store::load_manual(&file).is_empty());
|
||||
|
||||
let (added, row) = store::add_manual_with(&file, &jdk.display().to_string(), &probe)
|
||||
.expect("合法路径必须加得进去");
|
||||
assert_eq!(added, binary.display().to_string());
|
||||
assert_eq!(row.major, Some(21));
|
||||
|
||||
// **从盘上新读一份**(不是看内存里的 vec)。
|
||||
let listed = store::load_manual(&file);
|
||||
assert_eq!(listed.len(), 1, "名单必须真的写到了盘上:{listed:?}");
|
||||
assert_eq!(listed[0], binary.display().to_string());
|
||||
assert!(
|
||||
store::manual_file(&file).is_file(),
|
||||
"名单文件必须真的存在:{:?}",
|
||||
store::manual_file(&file)
|
||||
);
|
||||
|
||||
// 「重启」:一个全新的 State 从盘上灌,名单还在。
|
||||
let reborn = State {
|
||||
manual: store::load_manual(&file),
|
||||
..State::default()
|
||||
};
|
||||
assert_eq!(reborn.manual.len(), 1);
|
||||
|
||||
// 再加同一份 ⇒ 重复(去重也走盘上的名单)。
|
||||
assert_eq!(
|
||||
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
|
||||
Some(Refusal::AddDuplicate)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫⑥:扫描只许一个线程(P2-5)
|
||||
|
||||
static JOB_LIVE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
static JOB_PEAK: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
static JOB_RELEASE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
/// 一个「卡住」的扫描作业:直到被取消或 `JOB_RELEASE` 放开才返回。
|
||||
fn blocking_job(
|
||||
_candidates: &[unlucky_core::game::java::staged::Candidate],
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
_progress: &mut dyn FnMut(usize, usize),
|
||||
) -> Vec<RuntimeRow> {
|
||||
use std::sync::atomic::Ordering;
|
||||
let live = JOB_LIVE.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
JOB_PEAK.fetch_max(live, Ordering::SeqCst);
|
||||
while !JOB_RELEASE.load(Ordering::SeqCst) && !cancelled() {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
JOB_LIVE.fetch_sub(1, Ordering::SeqCst);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 扫描进行中点「立即扫描」不许起第二个线程;「取消」必须让线程**真的退出**(之后才允许再扫)。
|
||||
///
|
||||
/// 变异证明:把 `start_with` 开头那道 `if report.running { return false }` 删掉 ⇒ 第二次
|
||||
/// `start_with` 返回 `true`、`JOB_PEAK` 变成 2,这条红;把 `cancel()` 里置 `cancelled` 那两行
|
||||
/// 删掉 ⇒ 作业永远看不到取消、`report().running` 不退,超时断言红。
|
||||
#[test]
|
||||
fn only_one_scan_thread_can_run_and_cancel_finishes_it() {
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
JOB_RELEASE.store(false, Ordering::SeqCst);
|
||||
JOB_PEAK.store(0, Ordering::SeqCst);
|
||||
let slot = store::ScanSlot::default();
|
||||
|
||||
assert!(
|
||||
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
|
||||
"第一次扫描应该能起来"
|
||||
);
|
||||
assert!(
|
||||
!slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
|
||||
"扫描进行中不许起第二个线程"
|
||||
);
|
||||
assert!(slot.report().running, "线程活着时 running 必须为真");
|
||||
|
||||
slot.cancel();
|
||||
assert!(slot.report().cancelled, "取消之后要标记 cancelled");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while slot.report().running && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
!slot.report().running,
|
||||
"取消必须让扫描线程在预算内真的退出(不是只把界面翻成「已取消」)"
|
||||
);
|
||||
assert!(!slot.report().finished, "被取消的扫描不许把结论当真");
|
||||
assert_eq!(
|
||||
JOB_PEAK.load(Ordering::SeqCst),
|
||||
1,
|
||||
"任何时刻只有一个扫描线程"
|
||||
);
|
||||
|
||||
// 线程真退出之后允许重新扫(这就是「取消 ≠ 永久锁死」)。
|
||||
JOB_RELEASE.store(true, Ordering::SeqCst);
|
||||
assert!(
|
||||
slot.start_with(Vec::new(), Vec::new(), false, blocking_job),
|
||||
"上一个线程退出之后应该能重新扫"
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while slot.report().running && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(slot.report().finished, "这次应该正常扫完");
|
||||
JOB_RELEASE.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 守卫⑦:错误文案是 i18n 的(P2-2)
|
||||
|
||||
/// 用户可见的失败原因**全部**来自 catalog:三档「写不进去」各有各的一句,手动路径的
|
||||
/// 「太长 / 写不进名单」也不再顶替「路径是空的 / 路径上没有东西」。
|
||||
///
|
||||
/// 抓得住:①三档文案串台(只读说成「没有改动」);②新加的原因复用旧 key;③把中文硬编码回
|
||||
/// `store.rs`(源码扫描那一段)。
|
||||
/// 抓不住:译文翻得好不好(那是金表与人工的事)。
|
||||
///
|
||||
/// 变异证明:`Unwritable::ReadOnly` 的 key 改成 `SettingsJavaSkipNotDirty` ⇒ 这条红;
|
||||
/// 把 `store.rs` 里加回一句硬编码中文 ⇒ 源码扫描那段红。
|
||||
#[test]
|
||||
fn the_unwritable_reasons_have_distinct_text() {
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
|
||||
let en = Lang::En.catalog();
|
||||
let zh = Lang::ZhCn.catalog();
|
||||
let reasons = [
|
||||
Unwritable::NotDirty,
|
||||
Unwritable::ReadOnly,
|
||||
Unwritable::Unreadable,
|
||||
];
|
||||
|
||||
for reason in reasons {
|
||||
assert_ne!(
|
||||
en.text(reason.label()),
|
||||
zh.text(reason.label()),
|
||||
"{} 未翻译",
|
||||
reason.label().name()
|
||||
);
|
||||
}
|
||||
for (index, left) in reasons.iter().enumerate() {
|
||||
for right in reasons.iter().skip(index + 1) {
|
||||
assert_ne!(
|
||||
en.text(left.label()),
|
||||
en.text(right.label()),
|
||||
"「写不进去」的两档用了同一句文案:{} / {}",
|
||||
left.label().name(),
|
||||
right.label().name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 手动路径的两档新原因不许顶替旧原因(P2-2 的第三条与第四条)。
|
||||
assert_eq!(
|
||||
Refusal::AddTooLong.label(),
|
||||
Key::SettingsJavaReasonAddTooLong
|
||||
);
|
||||
assert_eq!(
|
||||
Refusal::AddWriteFailed.label(),
|
||||
Key::SettingsJavaReasonAddWriteFailed
|
||||
);
|
||||
assert_ne!(Refusal::AddTooLong.label(), Refusal::AddEmpty.label());
|
||||
assert_ne!(Refusal::AddWriteFailed.label(), Refusal::AddMissing.label());
|
||||
|
||||
// 源码扫描:`store.rs` 里不许再有「把 Rust 枚举名或中文字符串当人话」的写法。
|
||||
let source = include_str!("../store.rs");
|
||||
assert!(
|
||||
!source.contains("format!(\"{refusal:?}\")"),
|
||||
"不许把 Rust 枚举名(Refusal 的 Debug 串)当用户文案"
|
||||
);
|
||||
for hardcoded in ["没有改动", "配置文件只读", "配置文件不可信"] {
|
||||
// 只认**字符串字面量**(带引号):文档注释里出现这几个词是正常的说明。
|
||||
let literal = format!("\"{hardcoded}\"");
|
||||
assert!(
|
||||
!source.contains(&literal),
|
||||
"store.rs 里还有硬编码中文字符串:{literal}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `core` 的每种拒绝原因都要落到**自己的**页面原因上(P2-2 的第三、四条)。
|
||||
///
|
||||
/// 抓得住:`ManualFailure::TooLong` 被写成 `Refusal::AddEmpty`(界面会对一条 600 字符的路径说
|
||||
/// 「路径是空的」)、名单写盘失败被写成 `Refusal::AddMissing`(磁盘写不进去却说「路径上没有东西」)。
|
||||
/// 抓不住:文案翻得好不好(那是金表与人工的事)。
|
||||
///
|
||||
/// 变异证明:`failure_refusal` 里 `TooLong => AddTooLong` 改成 `AddEmpty` ⇒ 本条红;
|
||||
/// 写盘失败那行 `map_err(|_| AddWriteFailed)` 改成 `AddMissing` ⇒ 本条红。
|
||||
#[test]
|
||||
fn every_manual_failure_maps_to_its_own_reason() {
|
||||
use unlucky_core::game::java::manual::ManualFailure;
|
||||
|
||||
let dir = root("manual-map");
|
||||
let file = config(&dir);
|
||||
for (failure, expected) in [
|
||||
(ManualFailure::Empty, Refusal::AddEmpty),
|
||||
(ManualFailure::TooLong, Refusal::AddTooLong),
|
||||
(ManualFailure::Missing, Refusal::AddMissing),
|
||||
(ManualFailure::NotJava, Refusal::AddNotJava),
|
||||
(ManualFailure::NoVersion, Refusal::AddNoVersion),
|
||||
] {
|
||||
let probe = move |_input: &str| Err(failure);
|
||||
assert_eq!(
|
||||
store::add_manual_with(&file, "whatever", &probe).err(),
|
||||
Some(expected),
|
||||
"{failure:?} 应该映射到 {expected:?}"
|
||||
);
|
||||
}
|
||||
// 一个都没写进去。
|
||||
assert!(store::load_manual(&file).is_empty());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// 名单文件写不进去(父路径是个文件)时,报的是「写不进名单」而不是「路径上没有东西」。
|
||||
#[test]
|
||||
fn a_failed_list_write_reports_the_write_not_the_path() {
|
||||
use unlucky_core::game::java::{JavaOrigin, JavaRuntime};
|
||||
|
||||
let dir = root("manual-write-fail");
|
||||
// `<dir>/config` 做成**文件**:`manual_file` 的父目录建不出来 ⇒ save_paths 失败。
|
||||
// (`root()` 会先建好 `config/`,所以这里先删掉再放一个同名文件。)
|
||||
let blocker = dir.join("config");
|
||||
std::fs::remove_dir_all(&blocker).expect("删掉空 config 目录");
|
||||
std::fs::write(&blocker, b"not a directory").expect("写占位文件");
|
||||
let file = blocker.join("launcher.json");
|
||||
let jdk = fake_jdk(&dir, "manual-jdk");
|
||||
let binary = jdk.join("bin").join(java_bin());
|
||||
let probe = |_input: &str| {
|
||||
Ok(JavaRuntime {
|
||||
binary: binary.clone(),
|
||||
origin: JavaOrigin::System,
|
||||
version: Some("openjdk version \"17.0.13\" 2024-10-15".to_string()),
|
||||
major: Some(17),
|
||||
bits: 64,
|
||||
vendor: Some("Temurin".to_string()),
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
store::add_manual_with(&file, &jdk.display().to_string(), &probe).err(),
|
||||
Some(Refusal::AddWriteFailed),
|
||||
"磁盘写不进去要说「写不进名单」,不能说「路径不存在」"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- r2 P2-1:空态不许指向不存在的页
|
||||
|
||||
/// 空态给的那条「还有别的办法」必须是**真的存在**的入口。
|
||||
///
|
||||
/// 现场(r2 复审 P2-1):原文写「或者去 Java 页装一个」,而 GUI 里根本没有 Java 页
|
||||
/// (`presenter::router::Route` 只有 Instances / Download / Content / Logs / Settings /
|
||||
/// Accounts / Detail / Agent,`spawn_install` 在 `crates/app-iced/src` 里零消费者)。
|
||||
/// 于是「一个 JDK 都没装」的用户——正是这个 mission 要救的那位——被指到一个不存在的入口。
|
||||
///
|
||||
/// 抓得住:文案被改回「Java 页 / the Java page」,或把命令行入口那半句删掉。
|
||||
/// 抓不住:命令行的措辞好不好读(那是人工的事)。
|
||||
///
|
||||
/// 变异证明:把 `SettingsJavaEmptyBody` 改回「或者去 Java 页装一个」⇒ 本条红。
|
||||
#[test]
|
||||
fn the_empty_state_points_at_a_real_entry() {
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
for lang in [Lang::En, Lang::ZhCn] {
|
||||
let text = lang
|
||||
.catalog()
|
||||
.text(unlucky_i18n::Key::SettingsJavaEmptyBody);
|
||||
assert!(
|
||||
text.contains("ull java install"),
|
||||
"{}:空态要给一条真能走的路(命令行)——{text}",
|
||||
lang.tag()
|
||||
);
|
||||
for dead in ["Java page", "Java 页"] {
|
||||
assert!(
|
||||
!text.contains(dead),
|
||||
"{}:空态指向了不存在的入口「{dead}」——{text}",
|
||||
lang.tag()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! 「Java」分组的一张卡片(M175)。
|
||||
//!
|
||||
//! ## 版面(自上而下)
|
||||
//!
|
||||
//! | 行 | 内容 | 数值 / 证据 |
|
||||
//! |---|---|---|
|
||||
//! | 状态 | 未检测到 / N 个 JDK / 正在扫描 3/7 | 扫描按钮与取消按钮同步禁用 |
|
||||
//! | 运行时列表 | 每个 JDK 一行:路径 + 版本 + 发行方 + 来源 | 「默认」标记 / 「设为默认」chip |
|
||||
//! | 版本门槛 | 当前实例需要 Java ≥ N,哪一行不满足 | 来自 app 层算好的 [`Context`] |
|
||||
//! | 手动添加 | 输入框 + 「添加」 | 校验不过时下面直接说原因 |
|
||||
//! | 默认内存 | 数字框 + 范围提示 + 「现在生效多少来自哪一层」 | 落盘值(不是草稿) |
|
||||
//! | JVM 参数 | 文本框 + 拼命令行提示 | 落盘的 `java.extra_args` |
|
||||
//! | 动作 | 保存 | 灰掉时下面是原因 |
|
||||
//!
|
||||
//! 文案一律走 [`unlucky_i18n::Catalog`]:本组要 `Lang`,所以走 `settings/mod.rs` 的按下标分发
|
||||
//! (与 General / Update / Assistant 同一条路),`Group.body` 保持 `None`。
|
||||
|
||||
use iced::widget::{column, container, row};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::app::components::selection::{chip, Chip};
|
||||
use crate::pages::frame::{card, divider, text_role_owned};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::fields::{actions_row, add_row, hint_line, jvm_field, memory_field, notice_line};
|
||||
use super::state::{Progress, RuntimeRow, State};
|
||||
use super::Message as JavaMessage;
|
||||
use crate::pages::settings::{GroupMessage, States};
|
||||
|
||||
/// 一条本组消息 → 页面消息。
|
||||
pub(super) fn msg(message: JavaMessage) -> Message {
|
||||
Message::Settings(GroupMessage::Java(message))
|
||||
}
|
||||
|
||||
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来)。
|
||||
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
|
||||
body_of(ui, &states.groups.java, lang)
|
||||
}
|
||||
|
||||
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
|
||||
let catalog = lang.catalog();
|
||||
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
|
||||
|
||||
stacked = stacked.push(status_row(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(runtime_list(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(add_row(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(memory_field(ui, catalog, state));
|
||||
stacked = stacked.push(jvm_field(ui, catalog, state));
|
||||
stacked = stacked.push(divider(ui));
|
||||
stacked = stacked.push(actions_row(ui, catalog, state));
|
||||
stacked = stacked.push(hint_line(ui, catalog, state));
|
||||
|
||||
if let Some(notice) = &state.notice {
|
||||
stacked = stacked.push(notice_line(ui, catalog, notice));
|
||||
}
|
||||
|
||||
card(ui, stacked)
|
||||
}
|
||||
|
||||
/// 状态行:未检测到 / N 个 JDK / 正在扫描 / 正在取消 + 两个按钮。
|
||||
///
|
||||
/// 「取消」的语义(r1 复审 P2-5):按下之后到线程真退出之间,界面显示的是**正在取消…**,
|
||||
/// 「立即扫描」保持禁用——否则用户能在旧线程还活着的时候起第二个。
|
||||
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
|
||||
let (text, scanning) = match (state.scanning, state.scanned) {
|
||||
(Some(_), _) if state.cancelled => {
|
||||
(catalog.text(Key::SettingsJavaCancelling).to_string(), true)
|
||||
}
|
||||
// 候选表还在收集(`total == 0`):不显示 `0/0`,给一句「正在扫描…」。
|
||||
(Some(Progress { total: 0, .. }), _) => (
|
||||
catalog.text(Key::SettingsJavaScanningStart).to_string(),
|
||||
true,
|
||||
),
|
||||
(Some(Progress { done, total }), _) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaScanning,
|
||||
&[
|
||||
("done", done.to_string().as_str()),
|
||||
("total", total.to_string().as_str()),
|
||||
],
|
||||
),
|
||||
true,
|
||||
),
|
||||
(None, true) if state.runtimes.is_empty() => {
|
||||
(catalog.text(Key::SettingsJavaEmptyTitle).to_string(), false)
|
||||
}
|
||||
(None, true) => (
|
||||
catalog.render(
|
||||
Key::SettingsJavaScanDone,
|
||||
&[("count", state.runtimes.len().to_string().as_str())],
|
||||
),
|
||||
false,
|
||||
),
|
||||
(None, false) => (
|
||||
catalog.text(Key::SettingsJavaNeverScanned).to_string(),
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
let scan_label = catalog.text(Key::SettingsJavaScan);
|
||||
let scan = if scanning || !state.storage {
|
||||
buttons::filled(ui, Button::disabled(scan_label))
|
||||
} else {
|
||||
buttons::filled(ui, Button::new(scan_label, msg(JavaMessage::Scan)))
|
||||
};
|
||||
// 已经按过取消(线程还没退出)时,「取消」也灰掉:再按一次没有任何意义。
|
||||
let cancel = buttons::tonal(
|
||||
ui,
|
||||
if scanning && !state.cancelled {
|
||||
Button::new(
|
||||
catalog.text(Key::SettingsJavaCancel),
|
||||
msg(JavaMessage::CancelScan),
|
||||
)
|
||||
} else {
|
||||
Button::disabled(catalog.text(Key::SettingsJavaCancel))
|
||||
},
|
||||
);
|
||||
|
||||
// 「扫描已取消」那句只在线程**真退出之后**出现;退出之前状态行本身写着「正在取消…」。
|
||||
let cancelled = (state.cancelled && state.scanning.is_none()).then(|| {
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::SettingsJavaCancelled).to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
)
|
||||
});
|
||||
|
||||
let mut block = column![row![
|
||||
container(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
true,
|
||||
text,
|
||||
ui.color(if state.scanning.is_some() {
|
||||
Role::Primary
|
||||
} else {
|
||||
Role::OnSurface
|
||||
}),
|
||||
))
|
||||
.width(Length::Fill),
|
||||
scan,
|
||||
cancel,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center),]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
if let Some(line) = cancelled {
|
||||
block = block.push(line);
|
||||
}
|
||||
block.into()
|
||||
}
|
||||
|
||||
/// 运行时列表:每行一个 JDK,带「默认」标记与「设为默认」chip。
|
||||
fn runtime_list<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
|
||||
let mut list = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
|
||||
|
||||
if state.runtimes.is_empty() {
|
||||
let body = if state.scanned {
|
||||
catalog.text(Key::SettingsJavaEmptyBody)
|
||||
} else {
|
||||
catalog.text(Key::SettingsJavaNeverScannedBody)
|
||||
};
|
||||
return text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
body.to_string(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
);
|
||||
}
|
||||
|
||||
for runtime in &state.runtimes {
|
||||
list = list.push(runtime_row(ui, catalog, state, runtime));
|
||||
}
|
||||
list.into()
|
||||
}
|
||||
|
||||
fn runtime_row<'a>(
|
||||
ui: &'a Ui,
|
||||
catalog: Catalog,
|
||||
state: &'a State,
|
||||
runtime: &'a RuntimeRow,
|
||||
) -> Element<'a, Message> {
|
||||
let is_default = super::state::same_path(&runtime.binary, &state.selected);
|
||||
let satisfied = state.satisfied(runtime.major);
|
||||
|
||||
let (verdict, verdict_color) = match satisfied {
|
||||
Some(true) => (
|
||||
catalog.text(Key::SettingsJavaFits).to_string(),
|
||||
ui.color(Role::Primary),
|
||||
),
|
||||
Some(false) => {
|
||||
let required = state
|
||||
.context
|
||||
.required
|
||||
.as_ref()
|
||||
.map(|required| required.minimum.to_string())
|
||||
.unwrap_or_default();
|
||||
(
|
||||
catalog.render(Key::SettingsJavaTooOld, &[("minimum", required.as_str())]),
|
||||
ui.color(Role::Error),
|
||||
)
|
||||
}
|
||||
None => (String::new(), ui.color(Role::OnSurfaceVariant)),
|
||||
};
|
||||
|
||||
let action = if is_default {
|
||||
chip(
|
||||
ui,
|
||||
// 已选中那颗是**纯展示**的 badge(没有消息,不可交互):r2 复审要求清掉
|
||||
// 「禁用态 chip 上还挂着一条真消息」的残留。
|
||||
Chip::badge(catalog.text(Key::SettingsJavaDefault)),
|
||||
)
|
||||
} else {
|
||||
chip(
|
||||
ui,
|
||||
Chip::filter(
|
||||
catalog.text(Key::SettingsJavaSetDefault),
|
||||
false,
|
||||
msg(JavaMessage::SelectRuntime(runtime.binary.clone())),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let mut block = column![row![
|
||||
container(
|
||||
column![
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodyMedium,
|
||||
is_default,
|
||||
runtime.display_home(),
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
format!(
|
||||
"{} · {}",
|
||||
runtime.summary(),
|
||||
catalog.text(runtime.source_key())
|
||||
),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
action,
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.align_y(Alignment::Center)]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
|
||||
if !verdict.is_empty() {
|
||||
block = block.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
verdict,
|
||||
verdict_color,
|
||||
));
|
||||
}
|
||||
block.into()
|
||||
}
|
||||
Reference in new issue
Block a user