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()
}