Files
Unlucky-Launcher/crates/core/src/launch/launcher.rs
T
AskaEth bc41dfcf7d
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
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

299 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 顶层启动管线:组装 → 会话 → 进程 → 退出分析 → 诊断 → 索引。
//!
//! 对应 `DevDocs/PlanDoc/06-launch-and-diagnostics.md` §1 的 10 阶段:
//! 1~2(快照/账户)由调用方与 `adapter` 完成,3(Java 选择)由 D13 给出 `JavaSelection`,
//! 4(组件就绪检查)由 download/game 给出 `missing_components`,6(环境准备的 natives 段)
//! 由 [`crate::launch::natives`] 在本模块收口,5、7~10 见下。
//!
//! 两个入口:
//! - [`Launcher::dry_run`](D18 接线点):只组装、不 spawn、不写会话文件,返回完整命令行;
//! - [`Launcher::launch`]:完整跑一局,返回 [`SessionHandle`](退出判定 + 诊断结论 + 索引条目)。
//!
//! 阶段 B 说明:diagnose(M15)尚未合并,诊断走 [`DiagnosisHook`] 接缝,默认 [`NoopDiagnosis`]。
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use crate::launch::args_table::{assemble, AssembledCommand};
use crate::launch::diagnosis_hook::{DiagnosisHook, NoopDiagnosis};
use crate::launch::error::{AssemblyError, Result};
use crate::launch::launch_session::{LaunchSession, SessionHandle};
use crate::launch::natives::{self, NativesReport};
use crate::launch::platform::PlatformCtx;
use crate::launch::process::{ProcessConfig, RunningProcess};
use crate::launch::redact::Redactor;
use crate::launch::request::LaunchRequest;
use crate::launch::session::{EndReason, SessionMeta, SessionSink, SessionWriter};
/// 启动器配置。
#[derive(Debug, Clone)]
pub struct LauncherConfig {
/// 会话文件根目录(`<data_root>/agent/sessions`)。
pub sessions_root: PathBuf,
/// 平台上下文(命令行分支 + 退出码语义)。
pub platform: PlatformCtx,
/// 进程管理配置(看门狗/宽限期/环形缓冲)。
pub process: ProcessConfig,
/// 启动器名(`${launcher_name}`,请求里非空时以请求为准)。
pub launcher_name: String,
/// 启动器版本。
pub launcher_version: String,
/// 用户主目录(脱敏用)。
pub home_dir: Option<PathBuf>,
/// 结束后是否扫描崩溃现场文件。
pub scan_crash_reports: bool,
/// 现场文件最多收几个。
pub max_crash_artifacts: usize,
}
impl LauncherConfig {
/// 以会话根目录构造(其余取默认)。
pub fn new(sessions_root: impl Into<PathBuf>) -> Self {
let platform = PlatformCtx::current();
Self {
sessions_root: sessions_root.into(),
home_dir: platform.home_dir.clone(),
platform,
process: ProcessConfig::default(),
launcher_name: "UnluckyLauncher".to_string(),
launcher_version: "0.1.0".to_string(),
scan_crash_reports: true,
max_crash_artifacts: 4,
}
}
/// 补启动器名/版本。
pub fn with_launcher(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
self.launcher_name = name.into();
self.launcher_version = version.into();
self
}
}
/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件**。
///
/// 会做的事与真实启动**一致到「环境准备」为止**:natives 解压照做(幂等,`natives` 字段
/// 回报结果),因为「装完能不能起」的一半答案就在「native 库有没有落地」上。
#[derive(Debug, Clone)]
pub struct DryRun {
/// 实际要执行的程序。
pub program: PathBuf,
/// 参数(已脱敏:accessToken 一定是 `<redacted:token>`)。
pub argv: Vec<String>,
/// 人读单行(可直接 `println!`,含空格的参数带引号)。
pub display: String,
/// 组装告警。
pub warnings: Vec<String>,
/// classpath 条目数。
pub classpath_entries: usize,
/// 日志解码编码。
pub encoding: String,
/// natives 解压结果(没有 natives 目录、即请求未配置时为 `None`)。
pub natives: Option<NativesReport>,
}
impl DryRun {
/// 一行命令行(D18 打印用)。
pub fn line(&self) -> &str {
&self.display
}
}
/// 启动器。
#[derive(Clone)]
pub struct Launcher {
config: LauncherConfig,
hook: Arc<dyn DiagnosisHook>,
}
impl std::fmt::Debug for Launcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Launcher")
.field("config", &self.config)
.field("hook", &"<diagnosis hook>")
.finish()
}
}
impl Launcher {
/// 构造(无诊断挂钩)。
pub fn new(config: LauncherConfig) -> Self {
Self {
config,
hook: Arc::new(NoopDiagnosis),
}
}
/// 接上诊断挂钩(M15 适配器)。
pub fn with_diagnosis_hook(mut self, hook: Arc<dyn DiagnosisHook>) -> Self {
self.hook = hook;
self
}
/// 配置。
pub fn config(&self) -> &LauncherConfig {
&self.config
}
/// 只组装命令行(纯计算,可单测/可透传)。
pub fn plan(&self, request: &LaunchRequest) -> Result<AssembledCommand> {
assemble(request, &self.config.platform)
}
/// 环境准备(管线第 6 段):natives 解压。
///
/// 目标目录取 `request.dirs.natives_dir`(与 `${natives_directory}` 同一真相源);
/// 目录为空视为调用方不需要,返回 `Ok(None)`。
fn prepare_environment(&self, request: &LaunchRequest) -> Result<Option<NativesReport>> {
if request.dirs.natives_dir.as_os_str().is_empty() {
return Ok(None);
}
natives::extract(&request.dirs.natives_dir, &request.natives)
.map(Some)
.map_err(|err| AssemblyError::NativesExtraction {
message: err.message(),
})
.map_err(Into::into)
}
/// D18:组装完整命令行并返回,**不 spawn 进程、不写会话文件**。
///
/// 供 `unlucky-launcher --dry-run <instance>` 打印一行命令行后退出;
/// accessToken 已脱敏(绝不把凭据打到终端/日志里)。
pub fn dry_run(&self, request: &LaunchRequest) -> Result<DryRun> {
let natives = self.prepare_environment(request)?;
let command = self.plan(request)?;
let redactor = self.redactor(request);
let warnings = command
.warnings
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
Ok(DryRun {
program: command.program.clone(),
argv: redactor.scrub_all(&command.argv()),
display: redactor.scrub(&command.display()).into_owned(),
warnings,
classpath_entries: command.classpath.len(),
encoding: command.encoding.clone(),
natives,
})
}
/// 启动一局并返回会话(可中途 [`LaunchSession::stop`])。
pub fn spawn(&self, request: &LaunchRequest) -> Result<LaunchSession> {
let redactor = self.redactor(request);
let meta = self.session_meta(request);
let mut writer = SessionWriter::create(&self.config.sessions_root, meta, redactor.clone())?;
let session_id = writer.session_id().to_string();
let log_path = writer.path().to_path_buf();
writer.session_start()?;
// 阶段 6「环境准备」:natives 必须落到 java.library.path 指向的目录,且在组装前完成
// (HMCL 同序),失败即启动前失败——native 库缺了游戏只会给 UnsatisfiedLinkError。
let natives = match self.prepare_environment(request) {
Ok(report) => report,
Err(err) => {
let _ = writer.assembly_failed(&launch_error_assembly(&err));
let _ = writer.finish(EndReason::AssemblyFailed);
return Err(err);
}
};
if let Some(report) = &natives {
writer.natives_extracted(report)?;
}
let command = match self.plan(request) {
Ok(command) => command,
Err(err) => {
// 启动前失败也要留下证据:assembly_failed + session_end + 索引。
let _ = writer.assembly_failed(&launch_error_assembly(&err));
let _ = writer.finish(EndReason::AssemblyFailed);
return Err(err);
}
};
writer.cmd_built(&command)?;
let sink = Arc::new(SessionSink::with_start(writer, Instant::now()));
let working_dir = if request.dirs.game_dir.as_os_str().is_empty() {
None
} else {
Some(request.dirs.game_dir.as_path())
};
let process =
match RunningProcess::spawn(&command, working_dir, sink.clone(), self.config.process) {
Ok(process) => process,
Err(err) => {
let _ = sink.with_writer(|writer| writer.finish(EndReason::SpawnFailed));
return Err(err);
}
};
let pid = process.pid();
if let Some(result) = sink.with_writer(|writer| writer.proc_start(pid)) {
result?;
}
Ok(LaunchSession::new(
self.config.clone(),
self.hook.clone(),
redactor,
request.clone(),
session_id,
log_path,
command,
pid,
process,
sink,
))
}
/// 启动并阻塞到进程结束(P0 主入口)。
pub fn launch(&self, request: &LaunchRequest) -> Result<SessionHandle> {
self.spawn(request)?.finish()
}
fn redactor(&self, request: &LaunchRequest) -> Redactor {
let mut redactor = Redactor::new().with_secret(request.auth.access_token.clone());
if let Some(home) = &self.config.home_dir {
redactor = redactor.with_home(home);
}
if !request.auth.uuid.is_empty() {
redactor = redactor.with_secret(request.auth.uuid.clone());
}
redactor
}
fn session_meta(&self, request: &LaunchRequest) -> SessionMeta {
let launcher_name = if request.launcher.name.is_empty() {
self.config.launcher_name.clone()
} else {
request.launcher.name.clone()
};
let launcher_version = if request.launcher.version.is_empty() {
self.config.launcher_version.clone()
} else {
request.launcher.version.clone()
};
SessionMeta::new(
request.instance.id.clone(),
request.instance.name.clone(),
request.instance.version.raw().to_string(),
)
.with_launcher(launcher_name, launcher_version)
.with_platform(&self.config.platform)
}
}
/// 把错误拆成 `assembly_failed` 事件需要的结构化原因。
pub(crate) fn launch_error_assembly(
error: &crate::launch::error::LaunchError,
) -> crate::launch::error::AssemblyError {
match error {
crate::launch::error::LaunchError::Assembly(assembly) => assembly.clone(),
other => crate::launch::error::AssemblyError::MalformedArgument {
entry: other.to_string(),
reason: "非组装类失败",
},
}
}