Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
1102 lines
35 KiB
Rust
1102 lines
35 KiB
Rust
use std::fmt::Write as _;
|
|
use std::io::{BufRead, Read, SeekFrom};
|
|
use std::path::Path;
|
|
use std::time::SystemTime;
|
|
|
|
use futures::TryFutureExt;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::{
|
|
fs::File,
|
|
io::{AsyncReadExt, AsyncSeekExt},
|
|
};
|
|
|
|
use crate::{
|
|
State,
|
|
prelude::Credentials,
|
|
state::instances::adapters::sqlite::instance_rows,
|
|
util::io::{self, IOError},
|
|
};
|
|
|
|
mod crash_analysis;
|
|
pub use crash_analysis::{
|
|
CrashAnalysis, CrashAnalysisAiExplanation, CrashAnalysisAiSettings,
|
|
CrashAnalysisEvidence, CrashAnalysisFinding, CrashAnalysisMod,
|
|
CrashAnalysisSource, CrashModChange, CrashModChangeCounts, analyze_crash,
|
|
explain_crash_with_ai, get_crash_analysis_ai_settings,
|
|
save_successful_mod_snapshot, undo_added_mod,
|
|
update_crash_analysis_ai_settings,
|
|
};
|
|
|
|
#[derive(Serialize, Debug)]
|
|
pub struct Logs {
|
|
pub log_type: LogType,
|
|
pub filename: String,
|
|
pub age: u64,
|
|
pub output: Option<CensoredString>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, PartialEq)]
|
|
pub enum LogType {
|
|
InfoLog,
|
|
CrashReport,
|
|
}
|
|
|
|
const LOG_COMPACTION_THRESHOLD: usize = 20;
|
|
const MAX_LOG_DISPLAY_BYTES: usize = 2 * 1024 * 1024;
|
|
const MAX_LOG_DISPLAY_LINE_BYTES: usize = 64 * 1024;
|
|
const LOG_DISPLAY_TRUNCATION_MARKER: &str =
|
|
"\n… [log display truncated by Axolotl] …\n";
|
|
|
|
#[derive(Serialize, Debug)]
|
|
pub struct LatestLogCursor {
|
|
pub cursor: u64,
|
|
pub output: CensoredString,
|
|
pub new_file: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Debug)] // Not deserialize
|
|
#[serde(transparent)]
|
|
pub struct CensoredString(String);
|
|
impl CensoredString {
|
|
pub fn censor(mut s: String, credentials_list: &[Credentials]) -> Self {
|
|
let username = whoami::username();
|
|
s = s
|
|
.replace(&format!("/{username}/"), "/{COMPUTER_USERNAME}/")
|
|
.replace(&format!("\\{username}\\"), "\\{COMPUTER_USERNAME}\\");
|
|
for credentials in credentials_list {
|
|
// Use the offline profile to guarantee that this function does not cause
|
|
// Mojang API request, and is never delayed by a network request. The offline
|
|
// profile is optimistically updated on upsert from time to time anyway
|
|
if credentials.access_token.len() >= 8 {
|
|
s = s.replace(
|
|
&credentials.access_token,
|
|
"{MINECRAFT_ACCESS_TOKEN}",
|
|
);
|
|
}
|
|
s = s
|
|
.replace(
|
|
&credentials.offline_profile.name,
|
|
"{MINECRAFT_USERNAME}",
|
|
)
|
|
.replace(
|
|
&credentials.offline_profile.id.as_simple().to_string(),
|
|
"{MINECRAFT_UUID}",
|
|
)
|
|
.replace(
|
|
&credentials.offline_profile.id.as_hyphenated().to_string(),
|
|
"{MINECRAFT_UUID}",
|
|
);
|
|
}
|
|
|
|
Self(s)
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
struct LogCompactionStats {
|
|
compacted_runs: usize,
|
|
compacted_lines: usize,
|
|
}
|
|
|
|
struct CompactedLog {
|
|
output: String,
|
|
stats: LogCompactionStats,
|
|
display_truncated: bool,
|
|
}
|
|
|
|
async fn resolve_instance_path(
|
|
instance: &str,
|
|
state: &State,
|
|
) -> crate::Result<(String, Option<String>)> {
|
|
let instance =
|
|
match instance_rows::get_instance_by_id(instance, &state.pool).await? {
|
|
Some(instance) => instance,
|
|
None => {
|
|
// Preserve the historical id-or-path lookup, with id winning.
|
|
match instance_rows::get_instance_by_path(instance, &state.pool)
|
|
.await?
|
|
{
|
|
Some(instance) => instance,
|
|
None => {
|
|
return Err(crate::ErrorKind::InputError(format!(
|
|
"Unknown instance id or path: {instance}"
|
|
))
|
|
.as_error());
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Directly associated instances have no profile folder of their own:
|
|
// their logs and crash reports live inside the externally managed
|
|
// installation, at the real game directory the launch runs from. That
|
|
// path is absolute, so `resolve_game_dir` returns it unchanged and
|
|
// `instance_logs_dir` / `crash_reports_dir` naturally land on
|
|
// `<game>/logs` / `<game>/crash-reports`.
|
|
if instance.is_direct_linked() {
|
|
if let Some(game_dir) = crate::launcher::linked_game_dir(&instance) {
|
|
return Ok((game_dir.to_string_lossy().into_owned(), None));
|
|
}
|
|
tracing::warn!(
|
|
instance_id = %instance.id,
|
|
"Directly linked instance metadata is incomplete; falling back \
|
|
to the recorded linked `.minecraft` root for log resolution"
|
|
);
|
|
if let Some(linked) = instance
|
|
.linked_dot_minecraft
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|linked| !linked.is_empty())
|
|
{
|
|
return Ok((linked.to_string(), None));
|
|
}
|
|
}
|
|
|
|
Ok((instance.path, instance.game_dir_override))
|
|
}
|
|
|
|
fn split_line_ending(line: &str) -> (&str, &str) {
|
|
if let Some(line) = line.strip_suffix("\r\n") {
|
|
(line, "\r\n")
|
|
} else if let Some(line) = line.strip_suffix('\n') {
|
|
(line, "\n")
|
|
} else if let Some(line) = line.strip_suffix('\r') {
|
|
(line, "\r")
|
|
} else {
|
|
(line, "")
|
|
}
|
|
}
|
|
|
|
fn push_compacted_log_run(
|
|
output: &mut String,
|
|
stats: &mut LogCompactionStats,
|
|
line: &str,
|
|
line_ending: &str,
|
|
count: usize,
|
|
) {
|
|
if count >= LOG_COMPACTION_THRESHOLD {
|
|
output.push_str(line);
|
|
let _ =
|
|
write!(output, " (x{count} times - compacted by Axolotl Launcher)");
|
|
output.push_str(line_ending);
|
|
stats.compacted_runs += 1;
|
|
stats.compacted_lines += count;
|
|
} else {
|
|
for _ in 0..count {
|
|
output.push_str(line);
|
|
output.push_str(line_ending);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_compacted_log<R: BufRead>(
|
|
reader: &mut R,
|
|
) -> std::io::Result<CompactedLog> {
|
|
let mut output = String::new();
|
|
let mut stats = LogCompactionStats::default();
|
|
let mut buffer = Vec::new();
|
|
let mut current_line: Option<String> = None;
|
|
let mut current_line_ending = String::new();
|
|
let mut current_count = 0usize;
|
|
|
|
loop {
|
|
buffer.clear();
|
|
let bytes_read = reader.read_until(b'\n', &mut buffer)?;
|
|
if bytes_read == 0 {
|
|
break;
|
|
}
|
|
|
|
let line = String::from_utf8_lossy(&buffer);
|
|
let (line, line_ending) = split_line_ending(&line);
|
|
|
|
match current_line.as_deref() {
|
|
Some(current) if current == line => {
|
|
current_count += 1;
|
|
if current_line_ending.is_empty() && !line_ending.is_empty() {
|
|
current_line_ending = line_ending.to_string();
|
|
}
|
|
}
|
|
_ => {
|
|
if let Some(current) = current_line.take() {
|
|
push_compacted_log_run(
|
|
&mut output,
|
|
&mut stats,
|
|
¤t,
|
|
¤t_line_ending,
|
|
current_count,
|
|
);
|
|
}
|
|
|
|
current_line = Some(line.to_string());
|
|
current_line_ending = line_ending.to_string();
|
|
current_count = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(current) = current_line {
|
|
push_compacted_log_run(
|
|
&mut output,
|
|
&mut stats,
|
|
¤t,
|
|
¤t_line_ending,
|
|
current_count,
|
|
);
|
|
}
|
|
|
|
Ok(CompactedLog {
|
|
output,
|
|
stats,
|
|
display_truncated: false,
|
|
})
|
|
}
|
|
|
|
fn compact_duplicate_lines(input: &str) -> CompactedLog {
|
|
let mut reader = std::io::Cursor::new(input.as_bytes());
|
|
read_compacted_log(&mut reader)
|
|
.expect("compacting an in-memory log should not fail")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn compact_log_for_display(input: &str) -> CompactedLog {
|
|
cap_log_for_display(compact_duplicate_lines(input), false)
|
|
}
|
|
|
|
fn truncate_display_text(value: &str, max_bytes: usize) -> String {
|
|
if value.len() <= max_bytes {
|
|
return value.to_string();
|
|
}
|
|
|
|
let retained_bytes =
|
|
max_bytes.saturating_sub(LOG_DISPLAY_TRUNCATION_MARKER.len());
|
|
let prefix_end = char_boundary_before(value, retained_bytes / 2);
|
|
let suffix_start = char_boundary_after(
|
|
value,
|
|
value.len().saturating_sub(retained_bytes - prefix_end),
|
|
);
|
|
format!(
|
|
"{}{}{}",
|
|
&value[..prefix_end],
|
|
LOG_DISPLAY_TRUNCATION_MARKER,
|
|
&value[suffix_start..]
|
|
)
|
|
}
|
|
|
|
fn char_boundary_before(value: &str, mut index: usize) -> usize {
|
|
index = index.min(value.len());
|
|
while index > 0 && !value.is_char_boundary(index) {
|
|
index -= 1;
|
|
}
|
|
index
|
|
}
|
|
|
|
fn char_boundary_after(value: &str, mut index: usize) -> usize {
|
|
index = index.min(value.len());
|
|
while index < value.len() && !value.is_char_boundary(index) {
|
|
index += 1;
|
|
}
|
|
index
|
|
}
|
|
|
|
fn cap_log_for_display(
|
|
mut compacted: CompactedLog,
|
|
source_truncated: bool,
|
|
) -> CompactedLog {
|
|
let mut output = String::with_capacity(
|
|
compacted.output.len().min(MAX_LOG_DISPLAY_BYTES),
|
|
);
|
|
let mut truncated = source_truncated;
|
|
|
|
for original_line in compacted.output.split_inclusive('\n') {
|
|
let line =
|
|
truncate_display_text(original_line, MAX_LOG_DISPLAY_LINE_BYTES);
|
|
truncated |= line.len() != original_line.len();
|
|
if output.len().saturating_add(line.len())
|
|
> MAX_LOG_DISPLAY_BYTES
|
|
.saturating_sub(LOG_DISPLAY_TRUNCATION_MARKER.len())
|
|
{
|
|
truncated = true;
|
|
break;
|
|
}
|
|
output.push_str(&line);
|
|
}
|
|
|
|
if truncated {
|
|
output.push_str(LOG_DISPLAY_TRUNCATION_MARKER);
|
|
}
|
|
compacted.output = output;
|
|
compacted.display_truncated = truncated;
|
|
compacted
|
|
}
|
|
|
|
fn read_limited_compacted_log<R: Read>(
|
|
reader: &mut R,
|
|
) -> std::io::Result<CompactedLog> {
|
|
let mut bytes = Vec::with_capacity(MAX_LOG_DISPLAY_BYTES);
|
|
reader
|
|
.take((MAX_LOG_DISPLAY_BYTES + 1) as u64)
|
|
.read_to_end(&mut bytes)?;
|
|
let source_truncated = bytes.len() > MAX_LOG_DISPLAY_BYTES;
|
|
bytes.truncate(MAX_LOG_DISPLAY_BYTES);
|
|
let input = String::from_utf8_lossy(&bytes);
|
|
Ok(cap_log_for_display(
|
|
compact_duplicate_lines(&input),
|
|
source_truncated,
|
|
))
|
|
}
|
|
|
|
fn format_count(count: usize) -> String {
|
|
let raw = count.to_string();
|
|
let mut formatted = String::with_capacity(raw.len() + raw.len() / 3);
|
|
for (index, character) in raw.chars().enumerate() {
|
|
if index > 0 && (raw.len() - index).is_multiple_of(3) {
|
|
formatted.push(',');
|
|
}
|
|
formatted.push(character);
|
|
}
|
|
formatted
|
|
}
|
|
|
|
async fn maybe_emit_log_compaction_warning(
|
|
file_name: &str,
|
|
stats: LogCompactionStats,
|
|
) {
|
|
if stats.compacted_runs == 0 {
|
|
return;
|
|
}
|
|
|
|
let _ = crate::event::emit::emit_warning(&format!(
|
|
"Axolotl Launcher has compacted {} repeated log lines in {} before displaying it for performance reasons.",
|
|
format_count(stats.compacted_lines),
|
|
file_name,
|
|
))
|
|
.await;
|
|
}
|
|
|
|
async fn maybe_emit_log_display_truncation_warning(
|
|
file_name: &str,
|
|
truncated: bool,
|
|
) {
|
|
if !truncated {
|
|
return;
|
|
}
|
|
|
|
let _ = crate::event::emit::emit_warning(&format!(
|
|
"Axolotl Launcher truncated {} before displaying it to keep the console responsive. The original log file remains unchanged.",
|
|
file_name,
|
|
))
|
|
.await;
|
|
}
|
|
|
|
impl Logs {
|
|
async fn build(
|
|
log_type: LogType,
|
|
age: SystemTime,
|
|
game_dir: &Path,
|
|
filename: String,
|
|
clear_contents: Option<bool>,
|
|
) -> crate::Result<Self> {
|
|
Ok(Self {
|
|
log_type,
|
|
age: age
|
|
.duration_since(SystemTime::UNIX_EPOCH)
|
|
.unwrap_or_else(|_| std::time::Duration::from_secs(0))
|
|
.as_secs(),
|
|
output: if clear_contents.unwrap_or(false) {
|
|
None
|
|
} else {
|
|
let state = State::get().await?;
|
|
Some(
|
|
get_output_by_filename_from_path(
|
|
&state, game_dir, log_type, &filename,
|
|
)
|
|
.await?,
|
|
)
|
|
},
|
|
filename,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn get_logs_from_type(
|
|
instance_id: &str,
|
|
log_type: LogType,
|
|
clear_contents: Option<bool>,
|
|
logs: &mut Vec<crate::Result<Logs>>,
|
|
) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let (instance_path, game_dir_override) =
|
|
resolve_instance_path(instance_id, &state).await?;
|
|
let game_dir = state
|
|
.directories
|
|
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
|
|
|
|
let logs_folder = match log_type {
|
|
LogType::InfoLog => state.directories.game_logs_dir(&game_dir),
|
|
LogType::CrashReport => {
|
|
state.directories.game_crash_reports_dir(&game_dir)
|
|
}
|
|
};
|
|
|
|
if logs_folder.exists() {
|
|
for entry in std::fs::read_dir(&logs_folder)
|
|
.map_err(|e| IOError::with_path(e, &logs_folder))?
|
|
{
|
|
let entry: std::fs::DirEntry =
|
|
entry.map_err(|e| IOError::with_path(e, &logs_folder))?;
|
|
let age = entry
|
|
.metadata()?
|
|
.modified()
|
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
if let Some(file_name) = path.file_name() {
|
|
let file_name = file_name.to_string_lossy().to_string();
|
|
logs.push(
|
|
Logs::build(
|
|
log_type,
|
|
age,
|
|
&game_dir,
|
|
file_name,
|
|
clear_contents,
|
|
)
|
|
.await,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn get_logs(
|
|
instance_id: &str,
|
|
clear_contents: Option<bool>,
|
|
) -> crate::Result<Vec<Logs>> {
|
|
let mut logs = Vec::new();
|
|
get_logs_from_type(
|
|
instance_id,
|
|
LogType::InfoLog,
|
|
clear_contents,
|
|
&mut logs,
|
|
)
|
|
.await?;
|
|
get_logs_from_type(
|
|
instance_id,
|
|
LogType::CrashReport,
|
|
clear_contents,
|
|
&mut logs,
|
|
)
|
|
.await?;
|
|
|
|
let mut logs = logs.into_iter().collect::<crate::Result<Vec<Logs>>>()?;
|
|
logs.sort_by(|a, b| b.age.cmp(&a.age).then(b.filename.cmp(&a.filename)));
|
|
Ok(logs)
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn get_logs_by_filename(
|
|
instance_id: &str,
|
|
log_type: LogType,
|
|
filename: String,
|
|
) -> crate::Result<Logs> {
|
|
let state = State::get().await?;
|
|
let (instance_path, game_dir_override) =
|
|
resolve_instance_path(instance_id, &state).await?;
|
|
let game_dir = state
|
|
.directories
|
|
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
|
|
|
|
let path = match log_type {
|
|
LogType::InfoLog => state.directories.game_logs_dir(&game_dir),
|
|
LogType::CrashReport => {
|
|
state.directories.game_crash_reports_dir(&game_dir)
|
|
}
|
|
}
|
|
.join(&filename);
|
|
|
|
let metadata = std::fs::metadata(&path)?;
|
|
let age = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
|
|
|
Logs::build(log_type, age, &game_dir, filename, Some(true)).await
|
|
}
|
|
|
|
async fn get_output_by_filename_from_path(
|
|
state: &State,
|
|
game_dir: &Path,
|
|
log_type: LogType,
|
|
file_name: &str,
|
|
) -> crate::Result<CensoredString> {
|
|
let logs_folder = match log_type {
|
|
LogType::InfoLog => state.directories.game_logs_dir(game_dir),
|
|
LogType::CrashReport => {
|
|
state.directories.game_crash_reports_dir(game_dir)
|
|
}
|
|
};
|
|
|
|
let path = logs_folder.join(file_name);
|
|
|
|
let credentials = Credentials::get_all(&state.pool)
|
|
.await?
|
|
.into_iter()
|
|
.map(|x| x.1)
|
|
.collect::<Vec<_>>();
|
|
|
|
if let Some(ext) = path.extension() {
|
|
if ext == "gz" {
|
|
let file = std::fs::File::open(&path)
|
|
.map_err(|e| IOError::with_path(e, &path))?;
|
|
let gz =
|
|
flate2::read::GzDecoder::new(std::io::BufReader::new(file));
|
|
let mut reader = std::io::BufReader::new(gz);
|
|
let compacted = read_limited_compacted_log(&mut reader)
|
|
.map_err(|e| IOError::with_path(e, &path))?;
|
|
maybe_emit_log_compaction_warning(file_name, compacted.stats).await;
|
|
maybe_emit_log_display_truncation_warning(
|
|
file_name,
|
|
compacted.display_truncated,
|
|
)
|
|
.await;
|
|
return Ok(CensoredString::censor(compacted.output, &credentials));
|
|
} else if ext == "log" || ext == "txt" {
|
|
let file = std::fs::File::open(&path)
|
|
.map_err(|e| IOError::with_path(e, &path))?;
|
|
let mut reader = std::io::BufReader::new(file);
|
|
let compacted = read_limited_compacted_log(&mut reader)
|
|
.map_err(|e| IOError::with_path(e, &path))?;
|
|
maybe_emit_log_compaction_warning(file_name, compacted.stats).await;
|
|
maybe_emit_log_display_truncation_warning(
|
|
file_name,
|
|
compacted.display_truncated,
|
|
)
|
|
.await;
|
|
return Ok(CensoredString::censor(compacted.output, &credentials));
|
|
}
|
|
}
|
|
Err(crate::ErrorKind::OtherError(format!(
|
|
"File extension not supported: {}",
|
|
path.display()
|
|
))
|
|
.into())
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn get_output_by_filename(
|
|
instance_id: &str,
|
|
log_type: LogType,
|
|
file_name: &str,
|
|
) -> crate::Result<CensoredString> {
|
|
let state = State::get().await?;
|
|
let (instance_path, game_dir_override) =
|
|
resolve_instance_path(instance_id, &state).await?;
|
|
let game_dir = state
|
|
.directories
|
|
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
|
|
get_output_by_filename_from_path(&state, &game_dir, log_type, file_name)
|
|
.await
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn delete_logs(instance_id: &str) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let (instance_path, game_dir_override) =
|
|
resolve_instance_path(instance_id, &state).await?;
|
|
let game_dir = state
|
|
.directories
|
|
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
|
|
|
|
let logs_folder = state.directories.game_logs_dir(&game_dir);
|
|
for entry in std::fs::read_dir(&logs_folder)
|
|
.map_err(|e| IOError::with_path(e, &logs_folder))?
|
|
{
|
|
let entry = entry.map_err(|e| IOError::with_path(e, &logs_folder))?;
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
io::remove_dir_all(&path).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn delete_logs_by_filename(
|
|
instance_id: &str,
|
|
log_type: LogType,
|
|
filename: &str,
|
|
) -> crate::Result<()> {
|
|
let state = State::get().await?;
|
|
let (instance_path, game_dir_override) =
|
|
resolve_instance_path(instance_id, &state).await?;
|
|
let game_dir = state
|
|
.directories
|
|
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
|
|
|
|
let logs_folder = match log_type {
|
|
LogType::InfoLog => state.directories.game_logs_dir(&game_dir),
|
|
LogType::CrashReport => {
|
|
state.directories.game_crash_reports_dir(&game_dir)
|
|
}
|
|
};
|
|
|
|
let path = logs_folder.join(filename);
|
|
io::remove_file(&path).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn get_live_log_buffer(
|
|
instance_id: &str,
|
|
) -> crate::Result<CensoredString> {
|
|
let state = State::get().await?;
|
|
let lines = crate::state::get_log_buffer(instance_id);
|
|
let joined = lines.join("\n");
|
|
let compacted =
|
|
cap_log_for_display(compact_duplicate_lines(&joined), false);
|
|
|
|
let credentials = Credentials::get_all(&state.pool)
|
|
.await?
|
|
.into_iter()
|
|
.map(|x| x.1)
|
|
.collect::<Vec<_>>();
|
|
maybe_emit_log_compaction_warning("live log", compacted.stats).await;
|
|
maybe_emit_log_display_truncation_warning(
|
|
"live log",
|
|
compacted.display_truncated,
|
|
)
|
|
.await;
|
|
Ok(CensoredString::censor(compacted.output, &credentials))
|
|
}
|
|
|
|
pub fn clear_live_log_buffer(instance_id: &str) {
|
|
crate::state::remove_log_buffer(instance_id);
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn get_latest_log_cursor(
|
|
instance_id: &str,
|
|
cursor: u64, // 0 to start at beginning of file
|
|
) -> crate::Result<LatestLogCursor> {
|
|
get_generic_live_log_cursor(instance_id, "launcher_log.txt", cursor).await
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn get_generic_live_log_cursor(
|
|
instance_id: &str,
|
|
log_file_name: &str,
|
|
mut cursor: u64, // 0 to start at beginning of file
|
|
) -> crate::Result<LatestLogCursor> {
|
|
let state = State::get().await?;
|
|
let (instance_path, game_dir_override) =
|
|
resolve_instance_path(instance_id, &state).await?;
|
|
let game_dir = state
|
|
.directories
|
|
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
|
|
let logs_folder = state.directories.game_logs_dir(&game_dir);
|
|
let path = logs_folder.join(log_file_name);
|
|
if !path.exists() {
|
|
// Allow silent failure if latest.log doesn't exist (as the instance may have been launched, but not yet created the file)
|
|
return Ok(LatestLogCursor {
|
|
cursor: 0,
|
|
new_file: false,
|
|
output: CensoredString("".to_string()),
|
|
});
|
|
}
|
|
|
|
let mut file = File::open(&path)
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, &path))?;
|
|
let metadata = file
|
|
.metadata()
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, &path))?;
|
|
|
|
let mut new_file = false;
|
|
if cursor > metadata.len() {
|
|
// Cursor is greater than file length, reset cursor to 0
|
|
// Likely cause is that the file was rotated while the log was being read
|
|
cursor = 0;
|
|
new_file = true;
|
|
}
|
|
|
|
let requested_start = cursor;
|
|
let unread_bytes = metadata.len().saturating_sub(cursor);
|
|
let display_start = if unread_bytes > MAX_LOG_DISPLAY_BYTES as u64 {
|
|
metadata.len().saturating_sub(MAX_LOG_DISPLAY_BYTES as u64)
|
|
} else {
|
|
cursor
|
|
};
|
|
let source_truncated = display_start != requested_start;
|
|
let mut buffer = Vec::with_capacity(MAX_LOG_DISPLAY_BYTES);
|
|
file.seek(SeekFrom::Start(display_start))
|
|
.map_err(|e| IOError::with_path(e, &path))
|
|
.await?; // Seek to cursor
|
|
// Snapshot the read length: bytes appended during this read belong to the
|
|
// next request, not to a cursor captured before they existed.
|
|
file.take(metadata.len().saturating_sub(display_start))
|
|
.read_to_end(&mut buffer)
|
|
.map_err(|e| IOError::with_path(e, &path))
|
|
.await?; // Read to end of file
|
|
// Censor complete records, not halves of a credential split across file
|
|
// writes. After the process exits, also expose its unterminated final line.
|
|
if state.process_manager.get_all().iter().any(|process| {
|
|
process.instance_id == instance_id
|
|
|| process.instance_path == instance_path
|
|
}) {
|
|
if let Some(end) = buffer.iter().rposition(|byte| *byte == b'\n') {
|
|
buffer.truncate(end + 1);
|
|
} else if buffer.len() < MAX_LOG_DISPLAY_BYTES {
|
|
buffer.clear();
|
|
}
|
|
}
|
|
// Leave an incomplete UTF-8 character for the next read (e.g. a split CJK
|
|
// character). Invalid complete sequences still use lossy decoding.
|
|
if let Err(error) = std::str::from_utf8(&buffer)
|
|
&& error.error_len().is_none()
|
|
{
|
|
buffer.truncate(error.valid_up_to());
|
|
}
|
|
let cursor = display_start + buffer.len() as u64;
|
|
let output = String::from_utf8_lossy(&buffer); // Convert to String
|
|
let compacted =
|
|
cap_log_for_display(compact_duplicate_lines(&output), source_truncated);
|
|
|
|
let credentials = Credentials::get_all(&state.pool)
|
|
.await?
|
|
.into_iter()
|
|
.map(|x| x.1)
|
|
.collect::<Vec<_>>();
|
|
maybe_emit_log_compaction_warning(log_file_name, compacted.stats).await;
|
|
maybe_emit_log_display_truncation_warning(
|
|
log_file_name,
|
|
compacted.display_truncated,
|
|
)
|
|
.await;
|
|
let output = CensoredString::censor(compacted.output, &credentials);
|
|
Ok(LatestLogCursor {
|
|
cursor,
|
|
new_file,
|
|
output,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::state::CreateDirectLinkInstance;
|
|
use std::sync::Arc;
|
|
use tempfile::TempDir;
|
|
|
|
/// The launcher state is a process-wide singleton; initialize it once and
|
|
/// reuse it so `State::get()` resolves inside these APIs. The state root
|
|
/// is intentionally leaked (`.keep()`) because the shared state outlives
|
|
/// this function.
|
|
async fn global_state() -> Arc<State> {
|
|
if !State::initialized() {
|
|
let root = TempDir::new().unwrap().keep();
|
|
let _ =
|
|
State::init_for_test(root.to_string_lossy().to_string()).await;
|
|
}
|
|
State::get().await.unwrap()
|
|
}
|
|
|
|
/// Creates a directly associated instance whose linked `.minecraft`
|
|
/// lives in a fresh temp dir (generic dialect: launches from the linked
|
|
/// `.minecraft/versions/<id>` directory).
|
|
async fn create_direct_link_fixture(
|
|
label: &str,
|
|
) -> (TempDir, crate::state::InstanceMetadata) {
|
|
let state = global_state().await;
|
|
let minecraft = TempDir::new().unwrap();
|
|
let version_dir = minecraft.path().join("versions").join(label);
|
|
std::fs::create_dir_all(&version_dir).unwrap();
|
|
std::fs::write(
|
|
version_dir.join(format!("{label}.json")),
|
|
serde_json::to_vec_pretty(&serde_json::json!({
|
|
"id": label,
|
|
"inheritsFrom": "1.20.1",
|
|
"mainClass": "net.minecraft.client.main.Main"
|
|
}))
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let instance = crate::state::create_direct_link_instance(
|
|
CreateDirectLinkInstance {
|
|
name: None,
|
|
launcher_type:
|
|
crate::api::pack::import::ImportLauncherType::Generic,
|
|
base_path: minecraft.path().to_path_buf(),
|
|
instance_folder: format!("versions/{label}"),
|
|
instance_path: None,
|
|
game_dir_mode: None,
|
|
},
|
|
&state,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let metadata = crate::state::get_instance(&instance.id, &state.pool)
|
|
.await
|
|
.unwrap()
|
|
.expect("metadata for created fixture");
|
|
(minecraft, metadata)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn live_cursor_follows_file_appends_and_split_utf8() {
|
|
let (_minecraft, metadata) =
|
|
create_direct_link_fixture("logs-live-cursor").await;
|
|
let state = global_state().await;
|
|
let (path, game_dir_override) =
|
|
resolve_instance_path(&metadata.instance.id, &state)
|
|
.await
|
|
.unwrap();
|
|
let game_dir = state
|
|
.directories
|
|
.resolve_game_dir(&path, game_dir_override.as_deref());
|
|
let logs = state.directories.game_logs_dir(&game_dir);
|
|
std::fs::create_dir_all(&logs).unwrap();
|
|
let path = logs.join("latest.log");
|
|
std::fs::write(&path, b"startup\n").unwrap();
|
|
let first =
|
|
get_generic_live_log_cursor(&metadata.instance.id, "latest.log", 0)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.output.0, "startup\n");
|
|
|
|
let mut output = std::fs::OpenOptions::new()
|
|
.append(true)
|
|
.open(&path)
|
|
.unwrap();
|
|
let message = "进入世界\n".as_bytes();
|
|
// A file write ends in the middle of a Chinese character.
|
|
std::io::Write::write_all(&mut output, &message[..2]).unwrap();
|
|
let partial = get_generic_live_log_cursor(
|
|
&metadata.instance.id,
|
|
"latest.log",
|
|
first.cursor,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(partial.cursor, first.cursor);
|
|
assert!(partial.output.0.is_empty());
|
|
std::io::Write::write_all(&mut output, &message[2..]).unwrap();
|
|
let next = get_generic_live_log_cursor(
|
|
&metadata.instance.id,
|
|
"latest.log",
|
|
partial.cursor,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(next.output.0, "进入世界\n");
|
|
let idle = get_generic_live_log_cursor(
|
|
&metadata.instance.id,
|
|
"latest.log",
|
|
next.cursor,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(idle.output.0.is_empty());
|
|
drop(output);
|
|
|
|
std::fs::write(&path, b"restart\n").unwrap();
|
|
let restarted = get_generic_live_log_cursor(
|
|
&metadata.instance.id,
|
|
"latest.log",
|
|
next.cursor,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(restarted.new_file);
|
|
assert_eq!(restarted.output.0, "restart\n");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_link_instance_resolves_to_linked_game_dir() {
|
|
let state = global_state().await;
|
|
let (minecraft, metadata) =
|
|
create_direct_link_fixture("logs-resolve").await;
|
|
|
|
let (resolved, game_dir_override) =
|
|
resolve_instance_path(&metadata.instance.id, &state)
|
|
.await
|
|
.unwrap();
|
|
|
|
// The generic dialect resolves to the isolated version directory; the
|
|
// absolute path keeps helpers inside the linked installation instead
|
|
// of a ghost `profiles/<path>` folder.
|
|
assert_eq!(
|
|
resolved,
|
|
minecraft
|
|
.path()
|
|
.join("versions")
|
|
.join("logs-resolve")
|
|
.to_string_lossy()
|
|
);
|
|
assert!(game_dir_override.is_none());
|
|
assert_eq!(
|
|
state.directories.instance_logs_dir(&resolved),
|
|
minecraft
|
|
.path()
|
|
.join("versions")
|
|
.join("logs-resolve")
|
|
.join("logs")
|
|
);
|
|
assert_eq!(
|
|
state.directories.crash_reports_dir(&resolved),
|
|
minecraft
|
|
.path()
|
|
.join("versions")
|
|
.join("logs-resolve")
|
|
.join("crash-reports")
|
|
);
|
|
assert!(
|
|
!state
|
|
.directories
|
|
.instances_dir()
|
|
.join(&metadata.instance.path)
|
|
.exists(),
|
|
"no profile directory may be created for a direct-link instance"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn display_compaction_bounds_oversized_nbt_line() {
|
|
let log = format!("NBT: {}", "你".repeat(100_000));
|
|
|
|
let compacted = compact_log_for_display(&log);
|
|
|
|
assert!(compacted.display_truncated);
|
|
assert!(compacted.output.len() <= MAX_LOG_DISPLAY_BYTES);
|
|
assert!(compacted.output.contains("truncated by Axolotl"));
|
|
assert!(compacted.output.is_char_boundary(compacted.output.len()));
|
|
}
|
|
|
|
#[test]
|
|
fn display_compaction_bounds_many_distinct_lines() {
|
|
let log = (0..300_000)
|
|
.map(|index| format!("line-{index}\n"))
|
|
.collect::<String>();
|
|
assert!(log.len() > MAX_LOG_DISPLAY_BYTES);
|
|
|
|
let compacted = compact_log_for_display(&log);
|
|
|
|
assert!(compacted.display_truncated);
|
|
assert!(compacted.output.len() <= MAX_LOG_DISPLAY_BYTES);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ordinary_instance_still_resolves_to_relative_path() {
|
|
let state = global_state().await;
|
|
let metadata = crate::api::instance::create(
|
|
format!("logs-normal {}", uuid::Uuid::new_v4()),
|
|
"1.20.1".to_string(),
|
|
crate::state::ModLoader::Vanilla,
|
|
None,
|
|
None,
|
|
crate::state::InstanceLink::Unmanaged,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let (resolved, game_dir_override) =
|
|
resolve_instance_path(&metadata.instance.id, &state)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolved, metadata.instance.path,
|
|
"ordinary instances keep resolving through their relative profile path"
|
|
);
|
|
assert!(game_dir_override.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_link_instance_logs_are_enumerated_from_linked_root() {
|
|
let state = global_state().await;
|
|
let (minecraft, metadata) =
|
|
create_direct_link_fixture("logs-enum").await;
|
|
|
|
let version_dir = minecraft.path().join("versions").join("logs-enum");
|
|
std::fs::create_dir_all(version_dir.join("logs")).unwrap();
|
|
std::fs::write(
|
|
version_dir.join("logs").join("latest.log"),
|
|
b"[12:00:00] [Render thread/INFO]: Setting user\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::create_dir_all(version_dir.join("crash-reports")).unwrap();
|
|
std::fs::write(
|
|
version_dir
|
|
.join("crash-reports")
|
|
.join("crash-2026-01-02_03.04.05-server.txt"),
|
|
b"---- Minecraft Crash Report ----\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let logs = get_logs(&metadata.instance.id, None).await.unwrap();
|
|
let filenames = logs
|
|
.iter()
|
|
.map(|log| log.filename.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
filenames.contains(&"latest.log"),
|
|
"logs must be enumerated from the linked root: {filenames:?}"
|
|
);
|
|
assert!(
|
|
filenames.contains(&"crash-2026-01-02_03.04.05-server.txt"),
|
|
"crash reports must be enumerated from the linked root: {filenames:?}"
|
|
);
|
|
// The log browser must never consult the nonexistent profile folder.
|
|
assert!(
|
|
!state
|
|
.directories
|
|
.instances_dir()
|
|
.join(&metadata.instance.path)
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn direct_link_crash_analysis_finds_linked_crash_reports() {
|
|
let _state = global_state().await;
|
|
let (minecraft, metadata) =
|
|
create_direct_link_fixture("logs-crash").await;
|
|
|
|
let version_dir = minecraft.path().join("versions").join("logs-crash");
|
|
std::fs::create_dir_all(version_dir.join("logs")).unwrap();
|
|
std::fs::write(
|
|
version_dir.join("logs").join("latest.log"),
|
|
b"[12:00:00] [Render thread/INFO]: Setting user\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::create_dir_all(version_dir.join("crash-reports")).unwrap();
|
|
std::fs::write(
|
|
version_dir
|
|
.join("crash-reports")
|
|
.join("crash-2026-01-02_03.04.05-server.txt"),
|
|
b"---- Minecraft Crash Report ----\n\
|
|
// Who set us up the TNT?\n\
|
|
java.lang.OutOfMemoryError: Java heap space\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let analysis = crate::api::logs::analyze_crash(&metadata.instance.id)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
analysis
|
|
.sources
|
|
.iter()
|
|
.any(|source| source.source_type == "crash_report"),
|
|
"crash analysis must discover reports under the linked root"
|
|
);
|
|
}
|
|
}
|