Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+377
View File
@@ -0,0 +1,377 @@
//! `ull launch <instance> [--dry-run]`(D18:`--dry-run` 只打印组装后的命令行)。
//!
//! D20 的隔离档位在**这里**落成 `--gameDir`:本命令按覆盖链
//! (临时参数 > 实例 `isolation` > 全局 `instances.mode`)派生出运行目录,
//! 交给 [`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::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;
use crate::error::CliError;
use crate::output::Report;
use super::identity::{self, Identity};
use super::snapshot::{self, PlanInput};
/// 执行 `launch`。
///
/// # Errors
///
/// 实例/版本不存在、版本 JSON 不可用、命令行组装失败、进程起不来时返回用户错误。
pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
let profiles = super::instances::read_profiles(ctx)?;
let game = ctx.game();
let profile = profiles
.profiles
.iter()
.find(|profile| profile.id.as_str() == args.instance || profile.name == args.instance);
let (input, instance_found) = match profile {
Some(profile) => (
PlanInput {
version_id: profile.version_id.clone(),
instance_id: profile.id.as_str().to_string(),
instance_name: profile.name.clone(),
game_dir: profile.game_dir.clone(),
java: args.java.clone().or_else(|| profile.java_path.clone()),
username: args.username.clone(),
max_memory: args.max_memory,
..PlanInput::default()
},
true,
),
None => {
// 也接受「直接给版本 id」:没有实例档案时按版本跑一次(脚本化常见)。
if !game.is_version_installed(&args.instance) {
return Err(CliError::user(
"not_found",
format!(
"既没有实例 `{}`,也没有版本 `{}`",
args.instance, args.instance
),
)
.with_hint(instance_hint(&profiles)));
}
(
PlanInput {
version_id: args.instance.clone(),
instance_name: args.instance.clone(),
java: args.java.clone(),
username: args.username.clone(),
max_memory: args.max_memory,
..PlanInput::default()
},
false,
)
}
};
// 实例存在性先确认,再解析身份:微软轨解析身份会联网刷新令牌(有副作用),
// 不该在一个注定 not_found 的启动上白跑一遍、更不该顺手改写凭据。
let identity = identity::resolve(
ctx,
args.account.as_deref(),
args.username.as_deref(),
args.dry_run,
)?;
let input = PlanInput {
auth: Some(identity.placeholders.clone()),
..input
};
let isolation = resolve_isolation(ctx, profile, &input, args.dry_run)?;
let input = PlanInput {
// 派生结果就是 `--gameDir` 的取值(覆盖链已求值完)。
game_dir: Some(isolation.game_dir.clone()),
..input
};
// D13:实例覆盖 > 托管匹配 > 系统。托管命中时把路径钉死交给 `snapshot::prepare`。
let java = super::java::resolve_for_launch(ctx, &input.version_id, input.java.as_deref());
let input = PlanInput {
java: java.override_path.clone(),
..input
};
let plan = snapshot::prepare(ctx, &input)?;
let config = LauncherConfig::new(ctx.sessions_root())
.with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
let launcher = Launcher::new(config);
let mut human = Vec::new();
human.push(format!(
"隔离档位 {}(来源 {})· 运行目录 {}",
isolation.isolation.as_str(),
isolation.source.as_str(),
isolation.game_dir.display()
));
if let Some(warning) = &isolation.warning {
human.push(format!("提示: {warning}"));
}
human.push(identity_line(&identity));
for note in &identity.notes {
human.push(format!("提示: {note}"));
}
human.push(java.summary());
for warning in java.warnings() {
human.push(format!("告警: {warning}"));
}
if args.dry_run {
let dry = launcher
.dry_run(&plan.request)
.map_err(|error| map_launch_error(&error, ctx))?;
let mut lines = vec![dry.line().to_string()];
lines.push(format!(
"(--dry-run:未启动进程 · classpath {} 项 · 编码 {})",
dry.classpath_entries, dry.encoding
));
if let Some(natives) = &dry.natives {
lines.push(format!("natives:{}", natives.summary()));
}
let mut warnings = dry.warnings.clone();
warnings.extend(java.warnings());
// 人读行里 Java 告警已经在上面打过一次,这里只补 `dry_run` 自己新发现的。
for warning in &dry.warnings {
lines.push(format!("告警: {warning}"));
}
human.extend(lines);
let natives = dry.natives.as_ref().map(|natives| {
json!({
"dir": natives.dir.display().to_string(),
"files": natives.files,
"skipped": natives.skipped,
"ignored": natives.ignored,
"jars": natives.jars,
"custom": natives.custom,
"up_to_date": natives.up_to_date,
"era": natives.era.as_str(),
})
});
let data = json!({
"dry_run": true,
"instance_found": instance_found,
"version_id": plan.version_id,
"isolation": isolation.isolation.as_str(),
"isolation_source": isolation.source.as_str(),
"game_dir": isolation.game_dir.display().to_string(),
"mode_change_warning": isolation.warning,
"account": account_json(&identity),
"java": java.to_json(),
"program": dry.program.display().to_string(),
"argv": dry.argv,
"display": dry.display,
"classpath_entries": dry.classpath_entries,
"encoding": dry.encoding,
"warnings": warnings,
"natives": natives,
"adapter_warnings": plan.warnings,
});
return Ok(Report::new(data, human));
}
let handle = launcher
.launch(&plan.request)
.map_err(|error| map_launch_error(&error, ctx))?;
let data = json!({
"dry_run": false,
"instance_found": instance_found,
"isolation": isolation.isolation.as_str(),
"isolation_source": isolation.source.as_str(),
"game_dir": isolation.game_dir.display().to_string(),
"mode_change_warning": isolation.warning,
"account": account_json(&identity),
"java": java.to_json(),
"warnings": java.warnings(),
"session_id": handle.session_id,
"log_path": handle.log_path.display().to_string(),
"exit_code": handle.exit.code,
"exit_type": handle.exit.exit_type.as_str(),
"uptime_ms": handle.uptime_ms,
"summary": handle.exit.summary(),
"artifacts": handle.artifacts.iter().map(|artifact| artifact.path.display().to_string()).collect::<Vec<_>>(),
"adapter_warnings": plan.warnings,
});
human.push(format!(
"会话 {} 结束:{}",
handle.session_id,
handle.exit.summary()
));
human.push(format!("事件流: {}", handle.log_path.display()));
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 {
let hint = format!("版本本体目录:{}", ctx.game().versions_dir().display());
CliError::user("launch", error.to_string()).with_hint(hint)
}
fn instance_hint(profiles: &ProfileList) -> String {
if profiles.profiles.is_empty() {
"用 `ull instances list` 查看实例,或 `ull install <version>` 登记一个".to_string()
} else {
let names: Vec<&str> = profiles
.profiles
.iter()
.map(|profile| profile.name.as_str())
.collect();
format!("可用实例:{}", names.join("、"))
}
}
/// 人读一行:本次用谁的身份启动。
fn identity_line(identity: &Identity) -> String {
match &identity.account {
Some(meta) => format!(
"账户 {}({})· UUID {}",
meta.display_name(),
meta.kind.label(),
identity.placeholders.uuid
),
None => format!(
"账户 离线占位 {}(未登记账户)",
identity.placeholders.player_name
),
}
}
/// `--json` 里的身份块(**只有可公开字段**,token 永远不出现)。
fn account_json(identity: &Identity) -> serde_json::Value {
match &identity.account {
Some(meta) => json!({
"name": meta.display_name(),
"kind": meta.kind.as_str(),
"profile_id": identity.placeholders.uuid,
"account_id": meta.id.as_str(),
"registered": true,
}),
None => json!({
"name": identity.placeholders.player_name,
"kind": "offline-placeholder",
"profile_id": identity.placeholders.uuid,
"registered": false,
}),
}
}