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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user