fix(cli): 启动用的 Java 钉死 D4 校验过的那个 + M214 复审 nit1/nit2/nit3

- override_path 五来源统一钉住 D4 选中的运行时:以前只有 managed 钉路径,
  JAVA_HOME/PATH/system 三种会被 snapshot::prepare 的 selection() 按环境再挑一遍,
  D4 判过的 Java 根本不是真启动的那个。现在 `plan_java` 只做「选中 → 钉路径」,
  人读行写出「版本(来源 · 具体可执行文件)」,--json 的 java 块并排给出
  binary(校验过的)/ launch_binary(装配落地的)/ source,两者不一致时当场告警。
  实例设置/全局设置里的 javaPath 也并入「谁指定了 Java」,不会被自动挑的结果顶掉。
- nit3:D4 的 Java 不符拦截挪到 identity::resolve 之前(微软轨解析身份会联网刷新
  令牌、还会回写凭据),拦下的启动不再先触发一次刷新;不符提示补上「挑中的是哪个
  java」,文案只加不减。
- nit1:error.rs / launcher.rs 的人读串里残留的 ** 标记去掉,并补两条回归断言。
- nit2:java_launch.rs 里「只提示不阻断」等旧注释对齐 D4 现状。
- launch.rs(399 行零余量):D20 的隔离档位派生与切换检测整体搬到新文件
  launch_isolation.rs,纯挪函数体(逐行 diff 只多 6 处 pub(super)),
  用 #[path] 挂载以免改 commands/mod.rs。

用例:钉路径纯函数单测 3 条 + 端到端 2 条(JAVA_HOME 置空证明非 managed 来源也钉住;
数据根放两个账户证明 D4 拦在身份解析之前)+ 人读串无 ** 的 2 条回归。
两条变异(①退回只钉 Managed ②退回身份解析之后)各自让新用例变红,已实测。
This commit is contained in:
AskaEth committed 2026-11-09 21:30:55 +08:00
1 parent 1f6e15127a
commit 75aa5ee82c
8 files changed
+577 -196

No files matched your search

+172 -49
View File
@@ -1,7 +1,8 @@
//! 启动期的 Java 解析(`ull launch` 用):实例覆盖 > 托管匹配 > 系统。
//!
//! 从 `commands/java.rs` 拆出来(那边还要放 list/install/vendors,超 400 行硬上限)。
//! 这里的职责只有两条:①按 D13 的优先链挑一个 Java;②把结果整理成 `--json` 块与告警行。
//! 这里的职责是三条:①按 D13 的优先链挑一个 Java;②把选中的那个**钉成路径**交给装配
//! (D4:校验过的就是启动用的,不许装配期再重选一遍);③把结果整理成 `--json` 块与告警行。
//! 真正「谁优先」的判定在 `core::game::java::probe` / `select`(纯函数),本文件只做装配。
use std::path::{Path, PathBuf};
@@ -14,23 +15,36 @@ use crate::context::Ctx;
/// 启动期的 Java 解析结果。
#[derive(Debug, Clone)]
pub struct LaunchJava {
/// 喂给 `PlanInput.java` 的显式覆盖;`None` = 让 `snapshot::prepare` 按系统兜底。
/// 交给 `PlanInput.java` 的**钉死路径**:D4 校验过的那个 java。
///
/// 五个来源一视同仁(M220 修的就是这条):以前只有 `managed` 会钉住,
/// `JAVA_HOME` / `PATH` / 标准安装位置的命中会被 `snapshot::prepare` 的
/// `selection()` 按环境**再挑一遍**——于是 D4 判过的那个不是真启动的那个。
/// `None` 只可能是「一个候选都没探到」,此时只能让 `snapshot::prepare` 兜底。
pub override_path: Option<PathBuf>,
/// 实际选中的运行时。
pub runtime: Option<JavaRuntime>,
/// 目标版本对 Java 的要求。
pub requirement: JavaRequirement,
/// 不符说明(只提示,不阻断)。
/// 不符说明(`None` = 满足要求,或压根没探测到 Java)。
///
/// `--dry-run` 只把它当告警打出来;真启动时先过 [`unlucky_core::game::java::select::decide`]:
/// 不符且没人显式指定 Java 就直接拦下、不 spawn(D4)。
pub mismatch: Option<JavaMismatch>,
}
impl LaunchJava {
/// `--json` 的 `java` 块(app 层 GUI 弹窗 P1 后直接吃这份数据)。
///
/// `launched` 是装配之后**真正要执行的** java 路径(`plan.request.java.binary`)。
/// 它作为参数传进来而不是在这里自己算:读的人不该去猜「`binary` 是不是真跑的那个」,
/// 所以两个事实并排给出(`binary` = D4 校验过的,`launch_binary` = 装配落地的)。
#[must_use]
pub fn to_json(&self) -> Value {
pub fn to_json(&self, launched: &Path) -> Value {
let runtime = self.runtime.as_ref();
json!({
"binary": runtime.map(|runtime| runtime.binary.display().to_string()),
"launch_binary": launched.display().to_string(),
"major": runtime.and_then(|runtime| runtime.major),
"bits": runtime.map(|runtime| runtime.bits),
"source": runtime.map(|runtime| runtime.origin.as_str()),
@@ -47,10 +61,14 @@ impl LaunchJava {
/// 告警行(dry-run 与真启动都输出)。
///
/// 两类:①用户指定的 Java 探测不出来(路径写错了,静默退回默认版本会很难查);
/// ②Java 与版本要求不符(D13:提示,不阻断)。
/// `launched` 是装配之后真正要执行的 java 路径,用来兜一条「钉路径失效」的自检。
///
/// 三类:①用户指定的 Java 探测不出来(路径写错了,静默退回默认版本会很难查);
/// ②Java 与版本要求不符——`--dry-run` 到此为止只提示;真启动会先过 `select::decide`,
/// 只有「用户自己指定了 Java」时才会带着这条告警继续起(D4);
/// ③装配落地的不是 D4 校验过的那个(钉路径没生效,M220 的回归闸)。
#[must_use]
pub fn warnings(&self) -> Vec<String> {
pub fn warnings(&self, launched: &Path) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(runtime) = &self.runtime {
if runtime.major.is_none() {
@@ -63,23 +81,52 @@ impl LaunchJava {
if let Some(mismatch) = self.mismatch {
warnings.push(mismatch.message());
}
if let Some(unpinned) = self.pin_mismatch(launched) {
warnings.push(unpinned);
}
warnings
}
/// 人读一行(Java 来源 + 要求)。
/// 装配落地的 java 与 D4 校验过的那个是否一致(不一致要当场喊出来)。
fn pin_mismatch(&self, launched: &Path) -> Option<String> {
match (&self.runtime, self.override_path.as_deref()) {
(Some(_), Some(validated)) if validated == launched => None,
(Some(_), Some(validated)) => Some(format!(
"装配用的 Java({})与校验过的({})不一致:这次启动没有走 D4 判过的那个",
launched.display(),
validated.display()
)),
// 选出了一个 java 却没钉住路径 ⇒ 装配期会按环境再挑一遍,D4 判的就不是真跑的那个。
(Some(runtime), None) => Some(format!(
"选中了 {}({})却没钉住路径,装配期按环境重选成了 {}:这一份没有经过 D4 校验",
runtime.binary.display(),
runtime.origin.as_str(),
launched.display()
)),
(None, _) => Some(format!(
"没探测到任何 Java,装配期按环境兜底用了 {}(这一份没有经过 D4 校验)",
launched.display()
)),
}
}
/// 人读一行:Java 版本 + 来源 + **具体可执行文件** + 该版本的要求。
///
/// 路径必须写出来(M220):五个来源都钉路径之后,「用的是哪个 java」不能再靠猜
/// ——同机装两个 21 的时候,只有路径能把两个 Jar 头日志对上的问题说清。
#[must_use]
pub fn summary(&self) -> String {
let source = self
.runtime
.as_ref()
.map_or("未探测到", |runtime| runtime.origin.as_str());
let version = self
.runtime
.as_ref()
.and_then(|runtime| runtime.major)
.map_or_else(|| "未知".to_string(), |major| major.to_string());
let (version, origin) = match &self.runtime {
Some(runtime) => (
runtime
.major
.map_or_else(|| "未知".to_string(), |major| major.to_string()),
format!("{} · {}", runtime.origin.as_str(), runtime.binary.display()),
),
None => ("未知".to_string(), "未探测到".to_string()),
};
format!(
"Java {version}({source})· 该版本要求 {}",
"Java {version}({origin})· 该版本要求 {}",
self.requirement.describe()
)
}
@@ -87,43 +134,119 @@ impl LaunchJava {
/// 解析启动用的 Java:实例覆盖 > 托管匹配 > 系统。
///
/// 覆盖优先级的求值顺序写在 `core::game::java::probe`(纯函数,可注入候选);
/// 这里只负责「实例覆盖单独成路」与「把结果钉成路径」。
/// 候选收集有代价(托管扫盘、`java -version` 子进程),所以顺序是「先便宜的、不满足才扫盘」:
/// 托管 + `PATH`/`JAVA_HOME` 不满足要求时,才把标准安装位置(Program Files 系、
/// 官方启动器的 `runtime/`、注册表)并进来重挑一次。
#[must_use]
pub fn resolve_for_launch(ctx: &Ctx, version_id: &str, explicit: Option<&Path>) -> LaunchJava {
let requirement = java::requirement_for_version(&ctx.game(), version_id);
let (runtime, mismatch) = match explicit {
Some(path) => {
let runtime = java::probe(path, JavaOrigin::Instance);
let mismatch = runtime
.major
.and_then(|major| java::check(major, &requirement));
(Some(runtime), mismatch)
}
None => {
// 先看托管 + 环境变量;都不满足要求才去扫盘(别为了一次启动白跑 N 次 java -version)。
// 托管区可能不止一个根(Portable 与 Standard/Custom 并存,D23)。
let roots = super::java::managed_roots(ctx);
let mut candidates = java::discover_managed_roots(&roots);
candidates.extend(java::system_candidates());
let mut resolution = java::resolve(&candidates, &requirement);
if resolution.mismatch.is_some() {
candidates.extend(java::standard_candidates());
resolution = java::resolve(&java::discover::dedupe(candidates), &requirement);
}
(resolution.runtime, resolution.mismatch)
let Some(path) = explicit else {
// 托管区可能不止一个根(Portable 与 Standard/Custom 并存,D23)。
let roots = super::java::managed_roots(ctx);
let mut candidates = java::discover_managed_roots(&roots);
candidates.extend(java::system_candidates());
let planned = plan_java(&candidates, &requirement);
if planned.mismatch.is_none() {
return planned;
}
candidates.extend(java::standard_candidates());
return plan_java(&java::discover::dedupe(candidates), &requirement);
};
// 托管 JDK 要钉死路径(用户 PATH 上的 java 可能正是我们要避开的那一个);
// 系统来源交回 `snapshot::prepare` 的 `selection()` 兜底,避免重复实现一遍环境判断。
let override_path = match &runtime {
Some(runtime) if runtime.origin == JavaOrigin::Managed => Some(runtime.binary.clone()),
_ => explicit.map(Path::to_path_buf),
};
// 用户自己指定了 Java:只探测这一个,不参与打分(D13:明确的选择不推翻)。
plan_java(&[java::probe(path, JavaOrigin::Instance)], &requirement)
}
/// 纯装配:从候选里挑一个,并把它**钉成路径**(候选可注入,测试不必真装 JDK)。
#[must_use]
pub fn plan_java(candidates: &[JavaRuntime], requirement: &JavaRequirement) -> LaunchJava {
let resolution = java::resolve(candidates, requirement);
// D4:`override_path` 就是 D4 校验过的那个运行时——五种来源一个口径,
// 不分「托管要钉、环境变量可以放它再挑一次」。
let override_path = resolution
.runtime
.as_ref()
.map(|runtime| runtime.binary.clone());
LaunchJava {
override_path,
runtime,
requirement,
mismatch,
runtime: resolution.runtime,
requirement: requirement.clone(),
mismatch: resolution.mismatch,
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use unlucky_core::game::java::RequirementSource;
use super::{plan_java, JavaOrigin, JavaRequirement, JavaRuntime};
fn runtime(major: u32, origin: JavaOrigin) -> JavaRuntime {
JavaRuntime {
binary: PathBuf::from(format!("/jdks/{}/{major}/bin/java", origin.as_str())),
origin,
version: Some(format!("openjdk version \"{major}.0.1\"")),
major: Some(major),
bits: 64,
vendor: None,
}
}
/// 五种来源都必须把选中项的路径钉住(M220 的核心不变量)。
///
/// 抓得住:退回「只有 `managed` 才钉路径」——那时 `JAVA_HOME` / `PATH` / `system` /
/// `instance` 四种来源的 `override_path` 会变成 `None`,装配期按环境再挑一遍,
/// D4 校验过的那个就不是真启动的那个了。
#[test]
fn every_origin_pins_the_selected_binary() {
for origin in [
JavaOrigin::Instance,
JavaOrigin::Managed,
JavaOrigin::JavaHome,
JavaOrigin::Path,
JavaOrigin::System,
] {
let chosen = runtime(21, origin);
let planned = plan_java(
std::slice::from_ref(&chosen),
&JavaRequirement::new(17, None, RequirementSource::Table),
);
assert_eq!(planned.runtime.as_ref(), Some(&chosen), "{origin:?}");
assert_eq!(
planned.override_path,
Some(chosen.binary.clone()),
"{origin:?} 的命中必须钉成路径,不能留给装配期重选"
);
}
}
/// 钉的是**选中那个**,不是候选里第一个/来源排名最高的那个。
///
/// 抓得住:把 `override_path` 写成 `candidates.first()`,或者钉住一个与
/// `runtime` 不同的运行时(D4 判 A 却启动 B)。
#[test]
fn the_pinned_binary_is_the_one_that_satisfies_the_requirement() {
let legacy = runtime(8, JavaOrigin::JavaHome);
let modern = runtime(21, JavaOrigin::Managed);
let planned = plan_java(
&[modern, legacy.clone()],
&JavaRequirement::new(8, Some(8), RequirementSource::Table),
);
assert_eq!(planned.runtime.map(|runtime| runtime.major), Some(Some(8)));
assert_eq!(planned.override_path, Some(legacy.binary));
assert!(planned.mismatch.is_none());
}
/// 一个候选都没有时不编造路径(`None` = 让装配期按环境兜底),也不误报不符。
#[test]
fn no_candidate_pins_nothing() {
let planned = plan_java(
&[],
&JavaRequirement::new(21, None, RequirementSource::Table),
);
assert!(planned.runtime.is_none());
assert_eq!(planned.override_path, None);
assert!(planned.mismatch.is_none());
}
}
+44 -145
View File
@@ -5,19 +5,10 @@
//! 交给 [`snapshot::prepare`] 装进 `LaunchDirs.game_dir`(占位符 `${game_directory}`
//! 与子进程 CWD 都取这个值),并在启动时做一次切换检测(只探测、不迁移)。
use std::path::PathBuf;
use serde_json::json;
use unlucky_core::game::install::instance::{
read_instance_config, write_instance_config, InstanceConfig, Isolation,
};
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
use unlucky_core::game::java::select;
use unlucky_core::game::ProfileList;
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::launch::{LaunchError, Launcher, LauncherConfig};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::cli::LaunchArgs;
use crate::context::Ctx;
@@ -27,6 +18,13 @@ use crate::output::Report;
use super::identity::{self, Identity};
use super::snapshot::{self, PlanInput};
// 隔离档位(D20)的派生与切换检测搬到独立模块:本文件贴着 400 行红线(M220)。
// 用 `#[path]` 而不是在 `commands/mod.rs` 里登记,是为了这次改动只碰本文件 + 新文件。
#[path = "launch_isolation.rs"]
mod launch_isolation;
use launch_isolation::resolve_isolation;
/// 执行 `launch`。
///
/// # Errors
@@ -80,8 +78,36 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
}
};
// 实例存在性先确认,再解析身份:微软轨解析身份会联网刷新令牌(有副作用),
// 不该在一个注定 not_found 的启动上白跑一遍、更不该顺手改写凭据。
// D13 选 Java:命中就把路径钉死交给 `snapshot::prepare`(五个来源一个口径,M220)。
// 顺序是有意的(M220 / nit3):D4 的拦截排在身份解析**之前**——解析身份会联网刷新微软轨
// 令牌(有副作用,还会顺手改写凭据),而 D4 判下来必被拦的启动根本用不到身份。
// 这一步只看版本 id 与「谁指定了 Java」,不依赖身份解析的结果,所以拆得开。
//
// 「谁指定了 Java」必须连实例设置/全局设置里的 `javaPath` 一起算:装配期
// (`snapshot::prepare` 的 `effective.java_path`)用的就是那一份,D4 校验的却只认
// `--java` / 档案 `javaPath` 的话,用户配过的 JDK 会被自动挑的结果顶掉。
let configured_java = super::instance_launch::resolved(ctx, &input.instance_id).java_path;
let chosen_java = input.java.clone().or(configured_java);
unlucky_core::launch::launcher_log::set_dir(ctx.logs_dir());
let explicit_java = chosen_java.is_some();
let java = super::java::resolve_for_launch(ctx, &input.version_id, chosen_java.as_deref());
if !args.dry_run {
if let Some(mismatch) = select::decide(java.mismatch, explicit_java).block() {
unlucky_core::launch::launcher_log::record("launch", &mismatch.message());
// 提示里补上「挑中的是哪个 java」(M220):不符时用户第一个问题就是「它到底用了我哪个
// JDK」,以前只能自己猜;这条文案只加不减。
return Err(
CliError::user("java_mismatch", mismatch.message()).with_hint(format!(
"{};挑中的是 {}",
mismatch.advice(),
java.summary()
)),
);
}
}
// 实例存在性先确认、Java 先判,再解析身份:微软轨解析身份会联网刷新令牌(有副作用),
// 不该在一个注定 not_found 或注定被 D4 拦下的启动上白跑一遍、更不该顺手改写凭据。
let identity = identity::resolve(
ctx,
args.account.as_deref(),
@@ -90,6 +116,8 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
)?;
let input = PlanInput {
auth: Some(identity.placeholders.clone()),
// `override_path` 已经是 D4 校验过的那个 java,装配期只照用、不再挑。
java: java.override_path.clone(),
..input
};
let isolation = resolve_isolation(ctx, profile, &input, args.dry_run)?;
@@ -99,24 +127,6 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
..input
};
// D13 选 Java(托管命中就把路径钉死交给 `snapshot::prepare`);`input.java` 下面会被换成
// 「生效路径」,先记下「用户有没有自己指定 Java」。D4:不符且没人显式指定就拦,不 spawn。
unlucky_core::launch::launcher_log::set_dir(ctx.logs_dir());
let explicit_java = input.java.is_some();
let java = super::java::resolve_for_launch(ctx, &input.version_id, input.java.as_deref());
if !args.dry_run {
if let Some(mismatch) = select::decide(java.mismatch, explicit_java).block() {
unlucky_core::launch::launcher_log::record("launch", &mismatch.message());
return Err(
CliError::user("java_mismatch", mismatch.message()).with_hint(mismatch.advice())
);
}
}
let input = PlanInput {
java: java.override_path.clone(),
..input
};
// M188/M211:命令行这一路也把启动器日志写进 `<data>/logs/launcher.log`
// (落点与格式的真相都在 core 的 `launcher_log`,GUI 写的是同一份)。
unlucky_core::launch::launcher_log::record_start(
@@ -151,7 +161,7 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
human.push(format!("提示: {note}"));
}
human.push(java.summary());
for warning in java.warnings() {
for warning in java.warnings(&plan.request.java.binary) {
human.push(format!("告警: {warning}"));
}
@@ -168,7 +178,7 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
lines.push(format!("natives:{}", natives.summary()));
}
let mut warnings = dry.warnings.clone();
warnings.extend(java.warnings());
warnings.extend(java.warnings(&plan.request.java.binary));
// 人读行里 Java 告警已经在上面打过一次,这里只补 `dry_run` 自己新发现的。
for warning in &dry.warnings {
lines.push(format!("告警: {warning}"));
@@ -184,7 +194,7 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
"game_dir": isolation.game_dir.display().to_string(),
"mode_change_warning": isolation.warning,
"account": account_json(&identity),
"java": java.to_json(),
"java": java.to_json(&plan.request.java.binary),
"program": dry.program.display().to_string(),
"argv": dry.argv,
"display": dry.display,
@@ -209,8 +219,8 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
"game_dir": isolation.game_dir.display().to_string(),
"mode_change_warning": isolation.warning,
"account": account_json(&identity),
"java": java.to_json(),
"warnings": java.warnings(),
"java": java.to_json(&plan.request.java.binary),
"warnings": java.warnings(&plan.request.java.binary),
"session_id": handle.session_id,
"log_path": handle.log_path.display().to_string(),
"exit_code": handle.exit.code,
@@ -229,117 +239,6 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
Ok(Report::new(data, human))
}
/// 隔离派生的结果(运行目录 + 生效档位 + 提示)。
struct IsolationPlan {
/// 生效运行目录(喂 `--gameDir`)。
game_dir: PathBuf,
/// 生效档位。
isolation: Isolation,
/// 档位来自哪一层(`temporary` / `instance` / `global`)。
source: unlucky_core::game::IsolationSource,
/// 切换检测提示(两侧都有内容时;`None` = 不必提示)。
warning: Option<String>,
}
/// 按覆盖链派生运行目录,并在启动时做一次切换检测。
///
/// 覆盖链:临时参数(`ull launch` 暂无该 flag,留 `None`) > 实例 `isolation` > 全局 `instances.mode`。
/// 切换检测只探测 `mods/saves/config` 的条目数并写进实例文件的 `lastModeChange`,
/// **不迁移任何数据**(D20 定案);提示只展示一次(`warned` 落盘)。
///
/// 遗留实例(`profiles.json` 有档案、`config/instances/<id>.json` 还没有——M27 之前的形态)
/// 会在**首次真启动**时补建基线文件:否则这批「最可能撞上存档消失」的用户永远拿不到提示。
/// `--dry-run` 仍然一个字节都不写。
fn resolve_isolation(
ctx: &Ctx,
profile: Option<&unlucky_core::game::InstanceProfile>,
input: &PlanInput,
dry_run: bool,
) -> Result<IsolationPlan, CliError> {
let global_mode = global_mode(ctx);
let mut config = match profile {
Some(profile) => {
read_instance_config(&ctx.layout(), profile.id.as_str()).map_err(|error| {
CliError::user(
"instance_config",
format!("读实例设置 {} 失败:{error}", profile.id.as_str()),
)
})?
}
None => None,
};
// 补建基线:有档案没有设置文件 → 先按最小配置在内存里建一份(落盘看下面的 dirty)。
let bootstrap = config.is_none() && profile.is_some();
if bootstrap {
config = Some(InstanceConfig::new(
input.instance_id.clone(),
input.version_id.clone(),
));
}
// 读兼容:M27 之前的档案只在 profiles.json 里存 `game_dir`;这种「显式指定目录」的历史语义
// 等价于 `custom`,不要静默退回全局共享。补建基线时把这条映射**写进文件**,
// 让实例文件成为唯一真相(profiles.json 的 `game_dir` 是遗留字段)。
if let Some(config) = config.as_mut() {
if config.isolation.is_none() {
if let Some(dir) = profile
.and_then(|profile| profile.game_dir.clone())
.filter(|dir| !dir.as_os_str().is_empty())
{
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(dir);
}
}
}
let derivation = derive_game_dir(
&ctx.game(),
&input.version_id,
global_mode,
config.as_ref(),
None,
)
.map_err(|error| CliError::user("isolation", error.to_string()))?;
// 切换检测:生效档位与记录里的 `to` 不同 → 记一次(探测双侧,只记事实)。
let at = format_rfc3339_millis(now());
let mut dirty = false;
let warning = match config.as_mut() {
Some(config) => {
let changed = observe_mode(config, &ctx.game(), &at, derivation.isolation)
.map_err(|error| CliError::user("isolation_probe", error.to_string()))?;
let warning = config.take_mode_change_warning();
dirty = bootstrap || changed || warning.is_some();
warning.map(|warning| warning.message())
}
None => None,
};
// `--dry-run` 不落盘(否则「只看一眼」也会吃掉一次性提示、改写实例状态)。
if !dry_run && dirty {
if let Some(config) = config.as_ref() {
write_instance_config(&ctx.layout(), config).map_err(|error| {
CliError::user("instance_config", format!("写实例设置失败:{error}"))
})?;
}
}
Ok(IsolationPlan {
game_dir: derivation.game_dir,
isolation: derivation.isolation,
source: derivation.source,
warning,
})
}
/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`,缺键/坏值一律按 D20 默认 `isolated`)。
fn global_mode(ctx: &Ctx) -> IsolationMode {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
store
.doc()
.effective_str("instances.mode")
.and_then(IsolationMode::parse)
.unwrap_or_default()
}
fn map_launch_error(error: &LaunchError, ctx: &Ctx) -> CliError {
// D6:看门狗超时是一个**可区分的**结局(脚本要能单独处理),且自带现场与可调项,
// 不该被塞上「版本本体目录」这种无关提示。
+131
View File
@@ -0,0 +1,131 @@
//! `ull launch` 的隔离档位派生(D20):运行目录 + 切换检测。
//!
//! 从 `commands/launch.rs` 原样搬出(那边贴着 400 行红线,M220 的 Java 钉路径改动要往里加行)。
//! **搬动是纯挪**:函数体、注释、判定顺序一字未改,只补了本模块的 import 与 `pub(super)`
//! (`launch.rs` 里的 `#[path = "launch_isolation.rs"] mod launch_isolation;` 挂载)。
use std::path::PathBuf;
use unlucky_core::game::install::instance::{
read_instance_config, write_instance_config, InstanceConfig, Isolation,
};
use unlucky_core::game::isolation::{derive_game_dir, observe_mode, IsolationMode};
use unlucky_core::launch::time::{format_rfc3339_millis, now};
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::context::Ctx;
use crate::error::CliError;
use super::snapshot::PlanInput;
/// 隔离派生的结果(运行目录 + 生效档位 + 提示)。
pub(super) struct IsolationPlan {
/// 生效运行目录(喂 `--gameDir`)。
pub(super) game_dir: PathBuf,
/// 生效档位。
pub(super) isolation: Isolation,
/// 档位来自哪一层(`temporary` / `instance` / `global`)。
pub(super) source: unlucky_core::game::IsolationSource,
/// 切换检测提示(两侧都有内容时;`None` = 不必提示)。
pub(super) warning: Option<String>,
}
/// 按覆盖链派生运行目录,并在启动时做一次切换检测。
///
/// 覆盖链:临时参数(`ull launch` 暂无该 flag,留 `None`) > 实例 `isolation` > 全局 `instances.mode`。
/// 切换检测只探测 `mods/saves/config` 的条目数并写进实例文件的 `lastModeChange`,
/// **不迁移任何数据**(D20 定案);提示只展示一次(`warned` 落盘)。
///
/// 遗留实例(`profiles.json` 有档案、`config/instances/<id>.json` 还没有——M27 之前的形态)
/// 会在**首次真启动**时补建基线文件:否则这批「最可能撞上存档消失」的用户永远拿不到提示。
/// `--dry-run` 仍然一个字节都不写。
pub(super) fn resolve_isolation(
ctx: &Ctx,
profile: Option<&unlucky_core::game::InstanceProfile>,
input: &PlanInput,
dry_run: bool,
) -> Result<IsolationPlan, CliError> {
let global_mode = global_mode(ctx);
let mut config = match profile {
Some(profile) => {
read_instance_config(&ctx.layout(), profile.id.as_str()).map_err(|error| {
CliError::user(
"instance_config",
format!("读实例设置 {} 失败:{error}", profile.id.as_str()),
)
})?
}
None => None,
};
// 补建基线:有档案没有设置文件 → 先按最小配置在内存里建一份(落盘看下面的 dirty)。
let bootstrap = config.is_none() && profile.is_some();
if bootstrap {
config = Some(InstanceConfig::new(
input.instance_id.clone(),
input.version_id.clone(),
));
}
// 读兼容:M27 之前的档案只在 profiles.json 里存 `game_dir`;这种「显式指定目录」的历史语义
// 等价于 `custom`,不要静默退回全局共享。补建基线时把这条映射**写进文件**,
// 让实例文件成为唯一真相(profiles.json 的 `game_dir` 是遗留字段)。
if let Some(config) = config.as_mut() {
if config.isolation.is_none() {
if let Some(dir) = profile
.and_then(|profile| profile.game_dir.clone())
.filter(|dir| !dir.as_os_str().is_empty())
{
config.isolation = Some(Isolation::Custom);
config.game_dir = Some(dir);
}
}
}
let derivation = derive_game_dir(
&ctx.game(),
&input.version_id,
global_mode,
config.as_ref(),
None,
)
.map_err(|error| CliError::user("isolation", error.to_string()))?;
// 切换检测:生效档位与记录里的 `to` 不同 → 记一次(探测双侧,只记事实)。
let at = format_rfc3339_millis(now());
let mut dirty = false;
let warning = match config.as_mut() {
Some(config) => {
let changed = observe_mode(config, &ctx.game(), &at, derivation.isolation)
.map_err(|error| CliError::user("isolation_probe", error.to_string()))?;
let warning = config.take_mode_change_warning();
dirty = bootstrap || changed || warning.is_some();
warning.map(|warning| warning.message())
}
None => None,
};
// `--dry-run` 不落盘(否则「只看一眼」也会吃掉一次性提示、改写实例状态)。
if !dry_run && dirty {
if let Some(config) = config.as_ref() {
write_instance_config(&ctx.layout(), config).map_err(|error| {
CliError::user("instance_config", format!("写实例设置失败:{error}"))
})?;
}
}
Ok(IsolationPlan {
game_dir: derivation.game_dir,
isolation: derivation.isolation,
source: derivation.source,
warning,
})
}
/// 全局默认隔离模式(`launcher.json` 的 `instances.mode`,缺键/坏值一律按 D20 默认 `isolated`)。
fn global_mode(ctx: &Ctx) -> IsolationMode {
let store = SettingStore::load(ctx.launcher_config(), launcher_schema());
store
.doc()
.effective_str("instances.mode")
.and_then(IsolationMode::parse)
.unwrap_or_default()
}
+114
View File
@@ -0,0 +1,114 @@
//! M220:`ull launch` 真正 spawn 的 Java 必须是 D4 校验过的那个。
//!
//! 钉路径的**决策**是纯函数(`commands::java_launch::plan_java`,那边有五种来源的单测);
//! 本文件补**端到端**:装配之后命令行里那个 java 也得是同一个。
//!
//! 手法:把 `JAVA_HOME` 指到一个**没有 `bin/java` 的空目录**,让「装配期按环境兜底」
//! 与「D4 选中的候选」必然不同——兜底会退回裸名字 `java[.exe]`(相对名,靠 `PATH` 找),
//! 而 D4 选中的是探出来的绝对路径。不动 `JAVA_HOME` 的话兜底恰好也落在 `JAVA_HOME`
//! 那一个文件上,「只钉 Managed」的老实现也能蒙对,正是复审抓到的那个缺陷。
//!
//! 用真子进程(`CARGO_BIN_EXE_ull` + `Command::env`)而不是 `support::run_cli`:
//! `JAVA_HOME` 要按进程注入,改本进程的环境会与同二进制里的其它用例抢。
#[path = "support/mod.rs"]
mod support;
use std::path::Path;
use std::process::Command;
use support::{parse_json, write_version, TempDir};
/// 一个可组装的版本 id:静态表要求 **Java 21+**。
///
/// 选 21+ 是有意的:「托管区里只有 17(距离 4)、环境/系统上有 21/25(距离 0)」,
/// 按 D4 的版本距离优先,选中的必然是托管的**外面**那个——正是复审抓到的
/// 「只有 managed 会钉路径」缺陷所在的那条分支。要求 17 的话托管 17 会赢,
/// 老实现也能蒙对,用例就白跑了。
const VERSION: &str = "1.21";
/// 跑一次 `ull --data-dir <root> <tail...>`,`JAVA_HOME` 注入成 `java_home`。
///
/// `json` 决定加不加全局 `--json`(加了就只有 JSON envelope,人读行被收起来)。
fn cli_with_java_home(
dir: &TempDir,
java_home: &Path,
json: bool,
tail: &[&str],
) -> (i32, String, String) {
let mut args = vec!["--data-dir".to_string(), dir.arg()];
if json {
args.push("--json".to_string());
}
args.extend(tail.iter().map(|arg| arg.to_string()));
let output = Command::new(env!("CARGO_BIN_EXE_ull"))
.args(&args)
.env("JAVA_HOME", java_home)
.output()
.expect("应能起 ull");
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
#[test]
fn the_command_line_runs_the_java_that_d4_validated() {
let dir = TempDir::new("java-pin");
write_version(dir.path(), VERSION);
// 空 JAVA_HOME:`JAVA_HOME/bin/java` 不存在 → 环境兜底这条路走不通。
let empty_home = dir.path().join("empty-java-home");
std::fs::create_dir_all(&empty_home).expect("建空 JAVA_HOME");
let (code, stdout, stderr) =
cli_with_java_home(&dir, &empty_home, true, &["launch", VERSION, "--dry-run"]);
assert_eq!(code, 0, "stderr: {stderr}");
let data = parse_json(&stdout)["data"].clone();
let java = &data["java"];
let source = java["source"].as_str().unwrap_or("none");
assert!(
["PATH", "system"].contains(&source),
"前置:`JAVA_HOME` 已置空,且这条版本要 Java 21+(托管区的 17 不满足),\
来源只该是 PATH 或标准安装位置:{data}"
);
let binary = java["binary"]
.as_str()
.unwrap_or_else(|| panic!("本机一个 Java 都探不到,这条端到端验不到东西(判红):{data}"));
assert!(
Path::new(binary).is_absolute(),
"前置:探到的 Java 必须是绝对路径(否则「钉住」与「裸名字」无从区分,判红):{data}"
);
// ① 装配落地的就是校验过的那个(`--json` 里并排给的两个事实必须相等)。
assert_eq!(
java["launch_binary"].as_str(),
Some(binary),
"装配落地的 java 必须是 D4 校验过的那个:{data}"
);
// ② 命令行里真正要跑的也是它 —— 这一条才是「spawn 的是哪个」。
// 注意 `program` 会过脱敏(主目录 → `<home>`),所以按「绝对路径 + 文件名」判,
// 不按整串判;退回裸 `java[.exe]` 的老实现在这里必红。
let program = data["program"].as_str().unwrap_or_default();
assert!(
program.starts_with("<home>") || Path::new(program).is_absolute(),
"命令行里的 java 必须是钉住的绝对路径(裸 `java` = 又退回装配期重选了):{data}"
);
assert_eq!(
Path::new(program).file_name(),
Path::new(binary).file_name(),
"命令行里的 java 与 D4 校验的不是同一个:{data}"
);
// ③ 人读输出也要说清「实际用哪个、来源是什么」(终端里对着日志排查时只有一个依据)。
let (code, stdout, stderr) =
cli_with_java_home(&dir, &empty_home, false, &["launch", VERSION, "--dry-run"]);
assert_eq!(code, 0, "stderr: {stderr}");
assert!(
stdout.contains(&format!("{source} · ")),
"人读行要写出 Java 来源:{stdout}"
);
assert!(
stdout.contains(binary),
"人读行要写出实际使用的 java 路径({binary}):{stdout}"
);
}
+97
View File
@@ -138,6 +138,11 @@ fn launch_dry_run_reports_the_java_requirement_of_the_version() {
["instance", "managed", "JAVA_HOME", "PATH", "none"].contains(&source),
"意外的来源:{source}"
);
// M220:`binary` 是 D4 校验过的那个,`launch_binary` 是装配落地的那个——两者都得给出来。
assert!(
java["launch_binary"].as_str().is_some(),
"`--json` 的 java 块要写明实际装配的 java 路径:{json}"
);
}
#[test]
@@ -195,3 +200,95 @@ fn legacy_version_requires_java_8_and_flags_a_newer_runtime() {
);
}
}
/// 写一份「清单自己声明要 Java `required`」的版本 JSON(其余字段与 `support::write_version` 同)。
///
/// 本机任何一个 Java 都不满足 `required = 99` ⇒ D4 必然要拦(`java_too_old`),
/// 与「这台机器装了哪几个 JDK」无关、也不需要联网:用例的观察点是**拦截发生在哪一步**。
fn write_version_requiring_java(root: &std::path::Path, required: u32) {
let dir = root.join(".minecraft").join("versions").join(VERSION);
std::fs::create_dir_all(&dir).expect("创建版本目录");
let version = serde_json::json!({
"id": VERSION,
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"javaVersion": { "majorVersion": required },
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}"],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } }
});
std::fs::write(
dir.join(format!("{VERSION}.json")),
serde_json::to_vec_pretty(&version).expect("序列化版本 JSON"),
)
.expect("写版本 JSON");
std::fs::write(dir.join(format!("{VERSION}.jar")), b"junk").expect("写 client jar");
}
/// M220 nit3:D4 的「Java 不符」拦截必须排在身份解析**之前**。
///
/// 身份解析有副作用(微软轨会联网刷新令牌、刷新成功还会回写凭据),一个注定要被拦下的
/// 启动不该先跑那一遍。观察点:数据根里放两个离线账户 ⇒ 解析身份必然报 `account_ambiguous`,
/// 而这次启动注定被 D4 拦下 ⇒ 报出来的必须是 `java_mismatch`。
/// 老实现(拦在身份之后)在这里会先撞上 `account_ambiguous`,用例必红。
///
/// 对照组:`--dry-run` 本来就只提示不拦(D4 只管真启动),所以同一条版本在 dry-run 下
/// 会走到身份解析那一步——这一对断言把「拦截在哪一步」钉死。
#[test]
fn the_java_mismatch_block_runs_before_identity_resolution() {
let dir = TempDir::new("java-block-order");
write_version_requiring_java(dir.path(), 99);
for name in ["alpha", "beta"] {
let (code, _, stderr) =
run_cli(&["accounts", "add", "offline", name, "--data-dir", &dir.arg()]);
assert_eq!(code, 0, "建离线账户失败:{stderr}");
}
// 前置:这条版本的要求确实没人满足(否则「注定被拦」这个前提不成立,直接判红)。
let (code, stdout, stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
VERSION,
"--dry-run",
"--account",
"alpha",
]);
assert_eq!(code, 0, "stderr: {stderr}");
let envelope = parse_json(&stdout);
assert_eq!(
envelope["data"]["java"]["mismatch"]["code"], "java_too_old",
"前置:本机探测到的 Java 必须都不满足 Java 99:{envelope}"
);
// 真启动(不指定账户):两个账户 ⇒ 解析身份会报 account_ambiguous,但 D4 拦得更早。
let (code, stdout, _stderr) = run_cli(&["--json", "--data-dir", &dir.arg(), "launch", VERSION]);
assert_eq!(code, 1);
let envelope = parse_json(&stdout);
assert_eq!(
envelope["error"]["code"], "java_mismatch",
"D4 拦截必须排在身份解析之前(否则注定被拦的启动也先刷了一遍令牌):{envelope}"
);
// 对照:dry-run 不拦(D4 只管真启动)⇒ 它会走到身份解析,报的是账户歧义。
let (code, stdout, _stderr) = run_cli(&[
"--json",
"--data-dir",
&dir.arg(),
"launch",
VERSION,
"--dry-run",
]);
assert_eq!(code, 1);
let envelope = parse_json(&stdout);
assert_eq!(
envelope["error"]["code"], "account_ambiguous",
"dry-run 不该被 D4 拦住(它只提示),应当走到身份解析:{envelope}"
);
}