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

+375
View File
@@ -0,0 +1,375 @@
//! JSONL 会话事件流:`<sessions_root>/game/<session-id>.jsonl`。
//!
//! schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§3(v1)——M15 诊断采集器与
//! M16 `read_game_log`/`list_game_sessions` 消费同一份文件,是**单一真相源**。
//!
//! 两条纪律写死在实现里:
//! 1. 脱敏在写入前做([`Redactor`]),`cmd_built.argv` 也不例外(命令行必含 accessToken);
//! 2. 「launch 报事实、M15 下结论」:`diagnosis` 事件由 M15 产出、由本模块落盘。
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::launch::args_table::AssembledCommand;
use crate::launch::error::{AssemblyError, LaunchError, Result};
use crate::launch::exit_analysis::ExitAnalysis;
use crate::launch::natives::NativesReport;
use crate::launch::pump::{LogLine, Stream};
use crate::launch::redact::Redactor;
use crate::launch::session_index::{self, SessionIndexEntry};
use crate::launch::session_payload as payload;
use crate::launch::time::{self, SessionClock};
use crate::launch::window_detect::Detection;
/// 事件 schema 版本。
pub const SCHEMA_VERSION: u8 = 1;
/// 事件类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventKind {
/// 会话开始。
SessionStart,
/// Java 选择结果。
JavaSelected,
/// 命令行组装完成。
CmdBuilt,
/// natives 解压结果。
NativesExtracted,
/// 启动前失败(没有进程)。
AssemblyFailed,
/// 进程已启动。
ProcStart,
/// stdout 行。
Stdout,
/// stderr 行。
Stderr,
/// 检测到游戏窗口。
WindowDetected,
/// 进程退出。
Exit,
/// 发现崩溃报告 / hs_err。
CrashFound,
/// 诊断结论(M15 产出)。
Diagnosis,
/// 被主动结束。
Killed,
/// 会话结束。
SessionEnd,
}
impl EventKind {
/// JSONL 里的 `kind` 字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::SessionStart => "session_start",
Self::JavaSelected => "java_selected",
Self::CmdBuilt => "cmd_built",
Self::NativesExtracted => "natives_extracted",
Self::AssemblyFailed => "assembly_failed",
Self::ProcStart => "proc_start",
Self::Stdout => "stdout",
Self::Stderr => "stderr",
Self::WindowDetected => "window_detected",
Self::Exit => "exit",
Self::CrashFound => "crash_found",
Self::Diagnosis => "diagnosis",
Self::Killed => "killed",
Self::SessionEnd => "session_end",
}
}
}
/// 会话结束原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndReason {
/// 进程自然退出。
Exit,
/// 被主动结束。
Killed,
/// 启动前失败(组装/组件检查)。
AssemblyFailed,
/// 进程派生失败。
SpawnFailed,
}
impl EndReason {
/// JSONL 里的 `reason` 字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::Exit => "exit",
Self::Killed => "killed",
Self::AssemblyFailed => "assembly_failed",
Self::SpawnFailed => "spawn_failed",
}
}
}
pub use crate::launch::session_meta::SessionMeta;
pub use crate::launch::session_sink::SessionSink;
#[derive(Serialize)]
struct EventLine<'a> {
v: u8,
seq: u64,
ts: String,
t_ms: u64,
kind: &'a str,
session_id: &'a str,
payload: &'a Value,
}
/// 会话事件流写入器。
#[derive(Debug)]
pub struct SessionWriter {
session_id: String,
clock: SessionClock,
file: BufWriter<File>,
path: PathBuf,
index_path: PathBuf,
seq: u64,
event_count: u64,
redactor: Redactor,
meta: SessionMeta,
started_at: String,
window_detected: bool,
crash_found: bool,
exit_code: Option<i32>,
exit_type: Option<String>,
silent_failure: bool,
diagnosis_top: Option<String>,
finished: bool,
}
impl SessionWriter {
/// 新建会话(id 由时间 + pid 生成)。
pub fn create(sessions_root: &Path, meta: SessionMeta, redactor: Redactor) -> Result<Self> {
let session_id = time::make_session_id(time::now(), std::process::id());
Self::create_with_id(sessions_root, &session_id, meta, redactor)
}
/// 用指定 id 新建会话(测试与“续写”场景)。
pub fn create_with_id(
sessions_root: &Path,
session_id: &str,
meta: SessionMeta,
redactor: Redactor,
) -> Result<Self> {
let dir = sessions_root.join("game");
fs::create_dir_all(&dir).map_err(|source| LaunchError::Io {
op: "create_session_dir",
path: dir.clone(),
source,
})?;
let path = dir.join(format!("{session_id}.jsonl"));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|source| LaunchError::Io {
op: "create_session_file",
path: path.clone(),
source,
})?;
let clock = SessionClock::start_new();
let started_at = clock.started_at_iso();
Ok(Self {
session_id: session_id.to_string(),
clock,
file: BufWriter::new(file),
path,
index_path: session_index::index_path(sessions_root),
seq: 0,
event_count: 0,
redactor,
meta,
started_at,
window_detected: false,
crash_found: false,
exit_code: None,
exit_type: None,
silent_failure: false,
diagnosis_top: None,
finished: false,
})
}
/// 会话 id。
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 事件文件路径。
pub fn path(&self) -> &Path {
&self.path
}
/// 已写入事件数。
pub fn event_count(&self) -> u64 {
self.event_count
}
/// 是否已结束。
pub fn is_finished(&self) -> bool {
self.finished
}
/// 写一条事件。
pub fn record(&mut self, kind: EventKind, payload: Value) -> Result<()> {
let seq = self.seq;
let line = EventLine {
v: SCHEMA_VERSION,
seq,
ts: self.clock.now_iso(),
t_ms: self.clock.elapsed_ms(),
kind: kind.as_str(),
session_id: &self.session_id,
payload: &payload,
};
let serialized = serde_json::to_string(&line).map_err(|err| LaunchError::Session {
path: self.path.clone(),
reason: err.to_string(),
})?;
self.file
.write_all(serialized.as_bytes())
.and_then(|()| self.file.write_all(b"\n"))
.and_then(|()| self.file.flush())
.map_err(|source| LaunchError::Io {
op: "write_session_event",
path: self.path.clone(),
source,
})?;
self.seq += 1;
self.event_count += 1;
Ok(())
}
/// `session_start`。
pub fn session_start(&mut self) -> Result<()> {
let payload = payload::session_start(&self.meta, SCHEMA_VERSION);
self.record(EventKind::SessionStart, payload)
}
/// `cmd_built`:完整 argv(已脱敏)+ classpath 摘要。
pub fn cmd_built(&mut self, command: &AssembledCommand) -> Result<()> {
let payload = payload::cmd_built(command, &self.redactor);
self.record(EventKind::CmdBuilt, payload)
}
/// `assembly_failed`:启动前失败,附结构化缺失清单(M15 据此下结论)。
pub fn assembly_failed(&mut self, error: &AssemblyError) -> Result<()> {
let payload = payload::assembly_failed(error);
self.record(EventKind::AssemblyFailed, payload)
}
/// `proc_start`。
pub fn proc_start(&mut self, pid: u32) -> Result<()> {
let payload = payload::proc_start(pid, &self.clock.now_iso());
self.record(EventKind::ProcStart, payload)
}
/// `natives_extracted`:natives 解压结果(启动期 `UnsatisfiedLinkError` 的第一嫌疑)。
pub fn natives_extracted(&mut self, report: &NativesReport) -> Result<()> {
let payload = payload::natives_extracted(report, &self.redactor);
self.record(EventKind::NativesExtracted, payload)
}
/// `stdout` / `stderr` 行事件。
pub fn line(&mut self, line: &LogLine) -> Result<()> {
let kind = match line.stream {
Stream::Stdout => EventKind::Stdout,
Stream::Stderr => EventKind::Stderr,
};
let text = self.redactor.scrub(&line.text).into_owned();
let payload = payload::log_line(&text, line.level());
self.record(kind, payload)
}
/// `window_detected`。
pub fn window_detected(&mut self, detection: &Detection) -> Result<()> {
self.window_detected = true;
let payload = payload::window_detected(detection, &self.redactor);
self.record(EventKind::WindowDetected, payload)
}
/// 是否已检测到游戏窗口(索引摘要用)。
pub fn is_window_detected(&self) -> bool {
self.window_detected
}
/// `killed`。
pub fn killed(&mut self, by: &str, signal: Option<&str>) -> Result<()> {
let payload = payload::killed(by, signal);
self.record(EventKind::Killed, payload)
}
/// `exit`。
pub fn exit(&mut self, analysis: &ExitAnalysis) -> Result<()> {
self.exit_code = Some(analysis.code);
self.exit_type = Some(analysis.exit_type.as_str().to_string());
self.silent_failure = analysis.silent_failure.is_some();
let payload = payload::exit(analysis);
self.record(EventKind::Exit, payload)
}
/// `crash_found`:以 `path` 为权威字段(M15 自己读盘)。
pub fn crash_found(&mut self, source: &str, path: &Path, excerpt: Option<&str>) -> Result<()> {
self.crash_found = true;
let payload = payload::crash_found(source, path, excerpt, &self.redactor);
self.record(EventKind::CrashFound, payload)
}
/// `diagnosis`:M15 产出的结构化结论,由 launch 脱敏后落盘(单一真相源)。
///
/// M15 会按 `crash_found.path` 自己读盘再组 payload,崩溃报告/`latest.log` 里的
/// 用户主目录路径与 token 会由此带进结论,所以这里必须再过一遍 [`Redactor`]
/// (只改字符串,字段名/数值/布尔不动——`launch-接口约定.md` §9.3)。
pub fn diagnosis(&mut self, record: Value) -> Result<()> {
let record = self.redactor.scrub_json(&record);
if self.diagnosis_top.is_none() {
self.diagnosis_top = record
.get("candidates")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("id"))
.and_then(Value::as_str)
.map(str::to_string);
}
self.record(EventKind::Diagnosis, record)
}
/// `session_end` + 追加索引,返回索引条目。
pub fn finish(&mut self, reason: EndReason) -> Result<SessionIndexEntry> {
let duration_ms = self.clock.elapsed_ms();
let payload = payload::session_end(reason.as_str(), duration_ms);
self.record(EventKind::SessionEnd, payload)?;
self.finished = true;
let entry = SessionIndexEntry {
v: SCHEMA_VERSION,
session_id: self.session_id.clone(),
instance_id: self.meta.instance_id.clone(),
instance_name: self.meta.instance_name.clone(),
game_version: self.meta.game_version.clone(),
started_at: self.started_at.clone(),
ended_at: self.clock.now_iso(),
duration_ms,
exit_code: self.exit_code,
exit_type: self.exit_type.clone(),
silent_failure: self.silent_failure,
window_detected: self.window_detected,
event_count: self.event_count,
log_path: format!("game/{}.jsonl", self.session_id),
crash_found: self.crash_found,
diagnosis_top: self.diagnosis_top.clone(),
};
session_index::append(&self.index_path, &entry).map_err(|source| LaunchError::Io {
op: "append_session_index",
path: self.index_path.clone(),
source,
})?;
Ok(entry)
}
}