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
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:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -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: "非组装类失败",
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user