Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0195cbf5ac | ||
|
|
ccb96a8cf1 | ||
|
|
0f35a5f707 | ||
|
|
6fb73490d3 | ||
|
|
2dcf26c9d9 |
No files matched your search
Generated
+2
-2
@@ -6841,7 +6841,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus"
|
||||
version = "1.0.2-beta4"
|
||||
version = "1.0.2-beta7"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"async-minecraft-ping",
|
||||
@@ -6945,7 +6945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_gui"
|
||||
version = "1.0.2-beta4"
|
||||
version = "1.0.2-beta7"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"async_zip",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.2-beta4",
|
||||
"version": "1.0.2-beta7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -32,6 +32,55 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
|
||||
]
|
||||
|
||||
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||
{
|
||||
id: 'launcher-1.0.2-beta6',
|
||||
version: '1.0.2-beta6',
|
||||
publishedAt: '2026-10-11',
|
||||
title: {
|
||||
'en-US': 'Starlight Launcher 1.0.2-beta6',
|
||||
'zh-CN': 'Starlight Launcher 1.0.2-beta6',
|
||||
},
|
||||
changes: {
|
||||
added: [
|
||||
{
|
||||
'en-US': 'Added keyboard shortcut settings: enable and rebind navigation and quick-scrolling shortcuts.',
|
||||
'zh-CN': '新增键盘快捷键设置:可启用并自定义导航与快速滚动快捷键。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Added crash log sharing and AI analysis: share crash diagnostics to LogShare for a structured summary and optional AI analysis, with a rebuilt crash window that browses log files and streams the analysis.',
|
||||
'zh-CN': '新增崩溃日志分享与 AI 分析:可将崩溃诊断分享到 LogShare 获取结构化摘要与 AI 分析;崩溃窗口重做为双栏布局,可浏览日志文件并流式查看分析结果。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Crash logs now also collect JVM crash reports (hs_err_pid*.log) and launcher logs.',
|
||||
'zh-CN': '崩溃日志现在也会收集 JVM 崩溃报告(hs_err_pid*.log)与启动器日志。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Added a launcher log export entry.',
|
||||
'zh-CN': '新增启动器日志导出入口。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Instance groups can now be reordered by dragging.',
|
||||
'zh-CN': '实例分组支持拖拽重排。',
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
'en-US': 'Synced the latest supported AI chat model list from LobeHub.',
|
||||
'zh-CN': '同步 LobeHub 最新的 AI 聊天模型列表。',
|
||||
},
|
||||
],
|
||||
fixed: [
|
||||
{
|
||||
'en-US': 'Fixed remote announcements not refreshing.',
|
||||
'zh-CN': '修复远端公告不刷新的问题。',
|
||||
},
|
||||
{
|
||||
'en-US': 'Completed the Chinese translations for the armor preview feature.',
|
||||
'zh-CN': '补齐盔甲预览功能的中文翻译。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'launcher-1.0.2-beta5',
|
||||
version: '1.0.2-beta5',
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
# The actual version is set by the theseus-build workflow on tagging
|
||||
version = "1.0.2-beta4"
|
||||
version = "1.0.2-beta7"
|
||||
edition.workspace = true
|
||||
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
|
||||
homepage = "https://www.ghs.red"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "theseus"
|
||||
# The actual version is set by the theseus-build workflow on tagging
|
||||
version = "1.0.2-beta4"
|
||||
version = "1.0.2-beta7"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -109,6 +109,7 @@ pub struct CrashAnalysisMod {
|
||||
struct SourceCandidate {
|
||||
path: PathBuf,
|
||||
filename: String,
|
||||
upload_name: String,
|
||||
source_type: &'static str,
|
||||
modified: SystemTime,
|
||||
}
|
||||
@@ -116,10 +117,25 @@ struct SourceCandidate {
|
||||
#[derive(Debug)]
|
||||
struct SourceText {
|
||||
filename: String,
|
||||
upload_name: String,
|
||||
source_type: &'static str,
|
||||
modified: SystemTime,
|
||||
content: String,
|
||||
}
|
||||
pub(super) struct CollectedCrashLog {
|
||||
pub name: String,
|
||||
pub content: CensoredString,
|
||||
}
|
||||
|
||||
pub(super) struct CollectedCrashLogs {
|
||||
pub combined_log: CensoredString,
|
||||
pub sources: Vec<CollectedCrashLog>,
|
||||
}
|
||||
|
||||
struct CrashContext {
|
||||
sources: Vec<SourceText>,
|
||||
credentials: Vec<Credentials>,
|
||||
}
|
||||
|
||||
struct Rule {
|
||||
id: &'static str,
|
||||
@@ -1185,20 +1201,25 @@ async fn collect_candidates(
|
||||
) -> crate::Result<Vec<SourceCandidate>> {
|
||||
let mut candidates = Vec::new();
|
||||
let mut locations = vec![
|
||||
(directories.game_logs_dir(instance_root), "minecraft_log"),
|
||||
(
|
||||
directories.game_logs_dir(instance_root),
|
||||
"minecraft_log",
|
||||
None,
|
||||
),
|
||||
(
|
||||
directories.game_crash_reports_dir(instance_root),
|
||||
"crash_report",
|
||||
Some("crash-reports"),
|
||||
),
|
||||
(instance_root.to_path_buf(), "instance_log"),
|
||||
(instance_root.to_path_buf(), "instance_log", None),
|
||||
];
|
||||
if let Some(dot_minecraft) = isolated_minecraft_root(instance_root) {
|
||||
// PCL-CE also searches the shared `.minecraft` root for JVM crash
|
||||
// logs when a version-isolated instance is active.
|
||||
locations.push((dot_minecraft, "minecraft_root_log"));
|
||||
locations.push((dot_minecraft, "minecraft_root_log", None));
|
||||
}
|
||||
|
||||
for (directory, default_type) in locations {
|
||||
for (directory, default_type, upload_prefix) in locations {
|
||||
if !tokio::fs::try_exists(&directory).await? {
|
||||
continue;
|
||||
}
|
||||
@@ -1248,6 +1269,9 @@ async fn collect_candidates(
|
||||
};
|
||||
candidates.push(SourceCandidate {
|
||||
path: entry.path(),
|
||||
upload_name: upload_prefix
|
||||
.map(|prefix| format!("{prefix}/{filename}"))
|
||||
.unwrap_or_else(|| filename.clone()),
|
||||
filename,
|
||||
source_type,
|
||||
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
@@ -1257,6 +1281,53 @@ async fn collect_candidates(
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
async fn collect_crash_context(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<CrashContext> {
|
||||
let state = State::get().await?;
|
||||
let (instance_path, game_dir_override) =
|
||||
resolve_instance_path(instance_id, &state).await?;
|
||||
let instance_root = state
|
||||
.directories
|
||||
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
|
||||
let candidates =
|
||||
collect_candidates(&instance_root, &state.directories).await?;
|
||||
let selected = select_run_candidates(candidates);
|
||||
let sources = read_sources(selected).await;
|
||||
let credentials = Credentials::get_all(&state.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|credential| credential.1)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(CrashContext {
|
||||
sources,
|
||||
credentials,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn collect_crash_logs(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<CollectedCrashLogs> {
|
||||
let CrashContext {
|
||||
sources,
|
||||
credentials,
|
||||
..
|
||||
} = collect_crash_context(instance_id).await?;
|
||||
let combined_log =
|
||||
CensoredString::censor(build_combined_log(&sources), &credentials);
|
||||
let sources = sources
|
||||
.into_iter()
|
||||
.map(|source| CollectedCrashLog {
|
||||
name: source.upload_name,
|
||||
content: CensoredString::censor(source.content, &credentials),
|
||||
})
|
||||
.collect();
|
||||
Ok(CollectedCrashLogs {
|
||||
combined_log,
|
||||
sources,
|
||||
})
|
||||
}
|
||||
|
||||
fn isolated_minecraft_root(instance_root: &Path) -> Option<PathBuf> {
|
||||
let versions_dir = instance_root.parent()?;
|
||||
if versions_dir
|
||||
@@ -1320,6 +1391,7 @@ async fn read_sources(candidates: Vec<SourceCandidate>) -> Vec<SourceText> {
|
||||
};
|
||||
sources.push(SourceText {
|
||||
filename: candidate.filename,
|
||||
upload_name: candidate.upload_name,
|
||||
source_type: candidate.source_type,
|
||||
modified: candidate.modified,
|
||||
content: String::from_utf8_lossy(bytes).into_owned(),
|
||||
@@ -1779,6 +1851,7 @@ mod tests {
|
||||
fn source(filename: &str, content: &str) -> SourceText {
|
||||
SourceText {
|
||||
filename: filename.to_string(),
|
||||
upload_name: filename.to_string(),
|
||||
source_type: "minecraft_log",
|
||||
modified: SystemTime::now(),
|
||||
content: content.to_string(),
|
||||
@@ -1802,6 +1875,7 @@ mod tests {
|
||||
let candidates = vec![SourceCandidate {
|
||||
path: PathBuf::from("launcher_log.txt"),
|
||||
filename: "launcher_log.txt".to_string(),
|
||||
upload_name: "launcher_log.txt".to_string(),
|
||||
source_type: "minecraft_log",
|
||||
modified: SystemTime::now() - Duration::from_secs(181),
|
||||
}];
|
||||
|
||||
@@ -4,8 +4,7 @@ use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CensoredString;
|
||||
use super::crash_analysis::analyze_crash;
|
||||
use super::crash_analysis::collect_crash_logs;
|
||||
use crate::State;
|
||||
use crate::emit_logshare_ai_event;
|
||||
|
||||
@@ -200,32 +199,6 @@ fn source_identifier() -> String {
|
||||
format!("{LOGSHARE_SOURCE_PREFIX}/{}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
struct CollectedCrashLog {
|
||||
name: String,
|
||||
content: CensoredString,
|
||||
}
|
||||
|
||||
struct CollectedCrashLogs {
|
||||
combined_log: CensoredString,
|
||||
sources: Vec<CollectedCrashLog>,
|
||||
}
|
||||
|
||||
async fn collect_crash_logs(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<CollectedCrashLogs> {
|
||||
let analysis = analyze_crash(instance_id).await?;
|
||||
Ok(CollectedCrashLogs {
|
||||
combined_log: analysis.combined_log,
|
||||
sources: analysis
|
||||
.sources
|
||||
.into_iter()
|
||||
.map(|source| CollectedCrashLog {
|
||||
name: source.filename,
|
||||
content: source.content,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
async fn prepare_upload(instance_id: &str) -> crate::Result<UploadBody> {
|
||||
let settings = get_log_share_settings().await?;
|
||||
let logs = collect_crash_logs(instance_id).await?;
|
||||
|
||||
@@ -713,16 +713,37 @@ impl Process {
|
||||
|
||||
pending.push_str(&String::from_utf8_lossy(&chunk[..read]));
|
||||
|
||||
// Drain every complete frame currently buffered.
|
||||
while let Some(frame) = take_next_log4j_frame(&mut pending) {
|
||||
Self::handle_log4j_frame(
|
||||
instance_id,
|
||||
instance_name,
|
||||
process_id,
|
||||
log_path.as_ref(),
|
||||
&frame,
|
||||
)
|
||||
.await;
|
||||
// Mods can write plain text to either pipe even when the
|
||||
// launcher selected XML logging. Forward it as soon as a line
|
||||
// is complete instead of retaining it until the game exits.
|
||||
while let Some(output) = take_next_log_output(&mut pending) {
|
||||
match output {
|
||||
LogOutput::Xml(frame) => {
|
||||
Self::handle_log4j_frame(
|
||||
instance_id,
|
||||
instance_name,
|
||||
process_id,
|
||||
log_path.as_ref(),
|
||||
&frame,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
LogOutput::Legacy(text) => {
|
||||
if let Err(e) =
|
||||
Self::append_to_log_file(&log_path, &text)
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to write to log file: {e}"
|
||||
);
|
||||
}
|
||||
if !text.trim_end().is_empty() {
|
||||
Self::emit_legacy_log(
|
||||
instance_id,
|
||||
text.trim_end(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against a runaway buffer if no frame delimiters ever
|
||||
@@ -1360,32 +1381,119 @@ impl Process {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// Cuts the next complete `<log4j:Event …>…</log4j:Event>` frame out of the
|
||||
/// live buffer and returns it as an owned string, leaving any trailing partial
|
||||
/// frame in place.
|
||||
///
|
||||
/// Returns `None` when the buffer does not yet contain a full frame. This is a
|
||||
/// plain string operation on purpose: it never poisons any parser state, so a
|
||||
/// split or malformed frame on the live pipe cannot stop log forwarding.
|
||||
fn take_next_log4j_frame(buffer: &mut String) -> Option<String> {
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
enum LogOutput {
|
||||
Xml(String),
|
||||
Legacy(String),
|
||||
}
|
||||
|
||||
/// Drains complete XML frames or plain text in stream order. Partial frames
|
||||
/// and plain text lines remain buffered until their next chunk arrives.
|
||||
fn take_next_log_output(buffer: &mut String) -> Option<LogOutput> {
|
||||
const OPEN: &str = "<log4j:Event";
|
||||
const CLOSE: &str = "</log4j:Event>";
|
||||
|
||||
let start = buffer.find(OPEN)?;
|
||||
// Discard anything before the frame (raw text, XML prolog, …).
|
||||
if start > 0 {
|
||||
buffer.drain(..start);
|
||||
match buffer.find(OPEN) {
|
||||
Some(0) => {
|
||||
let close = buffer.find(CLOSE)?;
|
||||
let end = close + CLOSE.len();
|
||||
Some(LogOutput::Xml(buffer.drain(..end).collect()))
|
||||
}
|
||||
Some(start) => Some(LogOutput::Legacy(buffer.drain(..start).collect())),
|
||||
None => {
|
||||
let end = buffer.find('\n')? + 1;
|
||||
Some(LogOutput::Legacy(buffer.drain(..end).collect()))
|
||||
}
|
||||
}
|
||||
let close = buffer.find(CLOSE)?;
|
||||
let end = close + CLOSE.len();
|
||||
let frame = buffer[..end].to_string();
|
||||
buffer.drain(..end);
|
||||
Some(frame)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod post_upgrade_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mixed_log_output_keeps_partial_xml_and_multiline_messages() {
|
||||
let mut pending = "raw\n<log4j:Ev".to_string();
|
||||
assert_eq!(
|
||||
take_next_log_output(&mut pending),
|
||||
Some(LogOutput::Legacy("raw\n".into()))
|
||||
);
|
||||
assert_eq!(take_next_log_output(&mut pending), None);
|
||||
pending.push_str("ent><log4j:Message><![CDATA[first\nsecond");
|
||||
assert_eq!(take_next_log_output(&mut pending), None);
|
||||
pending.push_str("]]></log4j:Message></log4j:Event>\nafter\n");
|
||||
assert_eq!(take_next_log_output(&mut pending), Some(LogOutput::Xml("<log4j:Event><log4j:Message><![CDATA[first\nsecond]]></log4j:Message></log4j:Event>".into())));
|
||||
assert_eq!(
|
||||
take_next_log_output(&mut pending),
|
||||
Some(LogOutput::Legacy("\n".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
take_next_log_output(&mut pending),
|
||||
Some(LogOutput::Legacy("after\n".into()))
|
||||
);
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xml_logging_forwards_plain_text_before_pipe_closes() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let instance_id = Uuid::new_v4().to_string();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let log_path = directory.path().join("live.log");
|
||||
let (mut writer, reader) = tokio::io::duplex(1024);
|
||||
let output = Process::process_output(
|
||||
&instance_id,
|
||||
"instance",
|
||||
"Instance",
|
||||
"process",
|
||||
reader,
|
||||
&log_path,
|
||||
true,
|
||||
);
|
||||
tokio::pin!(output);
|
||||
writer.write_all(b"mod output\n").await.unwrap();
|
||||
let forwarded = async {
|
||||
loop {
|
||||
if get_log_buffer(&instance_id) == ["mod output"] {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = &mut output => panic!("reader ended while writer is still open"),
|
||||
result = tokio::time::timeout(std::time::Duration::from_secs(1), forwarded) => {
|
||||
result.expect("plain text was buffered until EOF");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(&log_path).unwrap(), "mod output\n");
|
||||
drop(writer);
|
||||
output.await;
|
||||
remove_log_buffer(&instance_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xml_logging_preserves_plain_text_before_xml_frames() {
|
||||
let instance_id = Uuid::new_v4().to_string();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let log_path = directory.path().join("mixed.log");
|
||||
let input = "mod output\n<log4j:Event level=\"INFO\"><log4j:Message><![CDATA[xml output]]></log4j:Message></log4j:Event>\n";
|
||||
Process::process_output(
|
||||
&instance_id,
|
||||
"instance",
|
||||
"Instance",
|
||||
"process",
|
||||
std::io::Cursor::new(input),
|
||||
&log_path,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let lines = get_log_buffer(&instance_id);
|
||||
assert_eq!(lines.first().map(String::as_str), Some("mod output"));
|
||||
assert!(lines.iter().any(|line| line.contains("xml output")));
|
||||
remove_log_buffer(&instance_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_log_snapshot_sequences_batches_and_preserves_position_after_clear()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user