diff --git a/crates/cli/src/commands/java_launch.rs b/crates/cli/src/commands/java_launch.rs index 16f374d..558358a 100644 --- a/crates/cli/src/commands/java_launch.rs +++ b/crates/cli/src/commands/java_launch.rs @@ -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, /// 实际选中的运行时。 pub runtime: Option, /// 目标版本对 Java 的要求。 pub requirement: JavaRequirement, - /// 不符说明(只提示,不阻断)。 + /// 不符说明(`None` = 满足要求,或压根没探测到 Java)。 + /// + /// `--dry-run` 只把它当告警打出来;真启动时先过 [`unlucky_core::game::java::select::decide`]: + /// 不符且没人显式指定 Java 就直接拦下、不 spawn(D4)。 pub mismatch: Option, } 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 { + pub fn warnings(&self, launched: &Path) -> Vec { 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 { + 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()); } } diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index 8d94ea8..157fd25 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -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 { } }; - // 实例存在性先确认,再解析身份:微软轨解析身份会联网刷新令牌(有副作用), - // 不该在一个注定 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 { )?; 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 { ..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:命令行这一路也把启动器日志写进 `/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 { 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 { 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 { "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 { "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 { Ok(Report::new(data, human)) } -/// 隔离派生的结果(运行目录 + 生效档位 + 提示)。 -struct IsolationPlan { - /// 生效运行目录(喂 `--gameDir`)。 - game_dir: PathBuf, - /// 生效档位。 - isolation: Isolation, - /// 档位来自哪一层(`temporary` / `instance` / `global`)。 - source: unlucky_core::game::IsolationSource, - /// 切换检测提示(两侧都有内容时;`None` = 不必提示)。 - warning: Option, -} - -/// 按覆盖链派生运行目录,并在启动时做一次切换检测。 -/// -/// 覆盖链:临时参数(`ull launch` 暂无该 flag,留 `None`) > 实例 `isolation` > 全局 `instances.mode`。 -/// 切换检测只探测 `mods/saves/config` 的条目数并写进实例文件的 `lastModeChange`, -/// **不迁移任何数据**(D20 定案);提示只展示一次(`warned` 落盘)。 -/// -/// 遗留实例(`profiles.json` 有档案、`config/instances/.json` 还没有——M27 之前的形态) -/// 会在**首次真启动**时补建基线文件:否则这批「最可能撞上存档消失」的用户永远拿不到提示。 -/// `--dry-run` 仍然一个字节都不写。 -fn resolve_isolation( - ctx: &Ctx, - profile: Option<&unlucky_core::game::InstanceProfile>, - input: &PlanInput, - dry_run: bool, -) -> Result { - 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:看门狗超时是一个**可区分的**结局(脚本要能单独处理),且自带现场与可调项, // 不该被塞上「版本本体目录」这种无关提示。 diff --git a/crates/cli/src/commands/launch_isolation.rs b/crates/cli/src/commands/launch_isolation.rs new file mode 100644 index 0000000..da2ae52 --- /dev/null +++ b/crates/cli/src/commands/launch_isolation.rs @@ -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, +} + +/// 按覆盖链派生运行目录,并在启动时做一次切换检测。 +/// +/// 覆盖链:临时参数(`ull launch` 暂无该 flag,留 `None`) > 实例 `isolation` > 全局 `instances.mode`。 +/// 切换检测只探测 `mods/saves/config` 的条目数并写进实例文件的 `lastModeChange`, +/// **不迁移任何数据**(D20 定案);提示只展示一次(`warned` 落盘)。 +/// +/// 遗留实例(`profiles.json` 有档案、`config/instances/.json` 还没有——M27 之前的形态) +/// 会在**首次真启动**时补建基线文件:否则这批「最可能撞上存档消失」的用户永远拿不到提示。 +/// `--dry-run` 仍然一个字节都不写。 +pub(super) fn resolve_isolation( + ctx: &Ctx, + profile: Option<&unlucky_core::game::InstanceProfile>, + input: &PlanInput, + dry_run: bool, +) -> Result { + 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() +} diff --git a/crates/cli/tests/java_pin_test.rs b/crates/cli/tests/java_pin_test.rs new file mode 100644 index 0000000..5f20053 --- /dev/null +++ b/crates/cli/tests/java_pin_test.rs @@ -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 `,`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` 会过脱敏(主目录 → ``),所以按「绝对路径 + 文件名」判, + // 不按整串判;退回裸 `java[.exe]` 的老实现在这里必红。 + let program = data["program"].as_str().unwrap_or_default(); + assert!( + program.starts_with("") || 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}" + ); +} diff --git a/crates/cli/tests/java_test.rs b/crates/cli/tests/java_test.rs index c1570ae..acac21b 100644 --- a/crates/cli/tests/java_test.rs +++ b/crates/cli/tests/java_test.rs @@ -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}" + ); +} diff --git a/crates/core/src/launch/error.rs b/crates/core/src/launch/error.rs index 7e560f4..25b37c3 100644 --- a/crates/core/src/launch/error.rs +++ b/crates/core/src/launch/error.rs @@ -169,7 +169,7 @@ impl fmt::Display for LaunchError { }; write!( f, - "{what}:等了 {:.0}s(上限 {:.0}s)就停止等待了。进程 {pid} 仍在运行、**没有被结束**\ + "{what}:等了 {:.0}s(上限 {:.0}s)就停止等待了。进程 {pid} 仍在运行、没有被结束\ (可能只是加载得慢,也可能真卡住了);事件流见 {}。放宽上限:设环境变量 \ ULL_LAUNCH_STARTUP_TIMEOUT_SECS / ULL_LAUNCH_SHUTDOWN_GRACE_SECS(秒,0 = 不限)", *waited_ms as f64 / 1000.0, diff --git a/crates/core/src/launch/launcher.rs b/crates/core/src/launch/launcher.rs index e1d3089..bfd474a 100644 --- a/crates/core/src/launch/launcher.rs +++ b/crates/core/src/launch/launcher.rs @@ -84,7 +84,7 @@ impl LauncherConfig { let grace = self.process.shutdown_grace.as_secs(); format!( "看门狗:连续 {startup}s 没日志也没窗口就停止等待;出现关闭标记后 {grace}s 仍未退出也停止等待\ - (0 = 不限;停止等待**不结束进程**,可用 {} / {} 调整)", + (0 = 不限;停止等待不结束进程,可用 {} / {} 调整)", crate::launch::process::STARTUP_TIMEOUT_ENV, crate::launch::process::SHUTDOWN_GRACE_ENV, ) diff --git a/crates/core/tests/launch/watchdog_cases.rs b/crates/core/tests/launch/watchdog_cases.rs index dd2c87b..e056a52 100644 --- a/crates/core/tests/launch/watchdog_cases.rs +++ b/crates/core/tests/launch/watchdog_cases.rs @@ -212,6 +212,11 @@ fn startup_timeout_stops_waiting_and_never_kills_the_process() { } let text = error.to_string(); assert!(text.contains("没有被结束"), "{text}"); + // M220 nit1:这句话直接打到终端,残留的 `**` 标记在终端里只会原样显示成星号。 + assert!( + !text.contains("**"), + "人读错误串里不该有 Markdown 标记:{text}" + ); assert!(text.contains("ULL_LAUNCH_STARTUP_TIMEOUT_SECS"), "{text}"); // 会话照样收尾(历史里看得见这一局),原因是 still_running 而不是 exit。 @@ -309,3 +314,15 @@ fn a_quick_clean_exit_is_never_punished_by_the_watchdog() { assert_eq!(handle.exit.exit_type, ExitType::Normal); assert!(!handle.exit.terminated_externally); } + +/// M220 nit1:`launcher.log` 与会话输出里的这句「看门狗生效值」是给人看的, +/// 残留的 `**` 标记在终端里只会原样显示成星号(旧文案就是「停止等待**不结束进程**」)。 +#[test] +fn the_watchdog_summary_is_plain_text() { + let summary = LauncherConfig::new(std::env::temp_dir()).watchdog_summary(); + assert!(summary.contains("不结束进程"), "{summary}"); + assert!( + !summary.contains("**"), + "人读串里不该有 Markdown 标记:{summary}" + ); +}