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

+298
View File
@@ -0,0 +1,298 @@
//! 顶层启动管线:组装 → 会话 → 进程 → 退出分析 → 诊断 → 索引。
//!
//! 对应 `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: "非组装类失败",
},
}
}