Compare commits

...
Author SHA1 Message Date
AxTps 13a56f4200 fix: Linux 系统安装时游戏目录默认回退到用户主目录
Sync source to CNB / Sync Git ref (push) Skipped
从 deb 等系统包安装时,启动器可执行文件位于 /usr/bin,此前一键安装
StarLight 实例的"选择游戏目录"弹窗会默认到这个系统目录,普通用户既
无写权限,也不适合存放游戏数据。

将默认目录计算移入 theseus::default_game_dir_root():Linux 下若可执行
文件位于 /usr、/opt、/snap 等系统路径,回退到用户主目录;便携版与其
他平台仍使用可执行文件所在目录。
2026-10-17 16:12:28 +08:00
Disy 0f35a5f707 fix: 修复实时日志积压并更新至 1.0.2-beta7
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-12 20:00:25 +08:00
8 changed files with 172 additions and 38 deletions

No files matched your search

Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "1.0.2-beta6" version = "1.0.2-beta7"
dependencies = [ dependencies = [
"ariadne", "ariadne",
"async-minecraft-ping", "async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "1.0.2-beta6" version = "1.0.2-beta7"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"async_zip", "async_zip",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.2-beta6", "version": "1.0.2-beta7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging # The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta6" version = "1.0.2-beta7"
edition.workspace = true edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher" description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red" homepage = "https://www.ghs.red"
+2 -5
View File
@@ -324,12 +324,9 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
/// external game-directory root for the one-click StarLight install flow. /// external game-directory root for the one-click StarLight install flow.
#[tauri::command] #[tauri::command]
fn get_launcher_root_dir() -> api::Result<String> { fn get_launcher_root_dir() -> api::Result<String> {
let exe = std::env::current_exe().map_err(|error| { let dir = theseus::default_game_dir_root().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::FSError(error.to_string()))
})?;
let dir = exe.parent().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::FSError( theseus::Error::from(theseus::ErrorKind::FSError(
"Launcher executable has no parent directory".to_string(), "Could not determine a default game directory root".to_string(),
)) ))
})?; })?;
Ok(dir.to_string_lossy().into_owned()) Ok(dir.to_string_lossy().into_owned())
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus" name = "theseus"
# The actual version is set by the theseus-build workflow on tagging # The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta6" version = "1.0.2-beta7"
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
+1 -1
View File
@@ -39,7 +39,7 @@ pub use state::{DirectoryInfo, State};
pub use storage::*; pub use storage::*;
pub use util::fetch::build_proxied_client; pub use util::fetch::build_proxied_client;
pub use util::file_lock::{LockingProcess, get_locking_processes}; pub use util::file_lock::{LockingProcess, get_locking_processes};
pub use util::platform::is_process_elevated; pub use util::platform::{default_game_dir_root, is_process_elevated};
pub use util::proxy::{ProxyConfig, ProxyMode, ProxyTestResult}; pub use util::proxy::{ProxyConfig, ProxyMode, ProxyTestResult};
pub use util::symlink::SymlinkCapability; pub use util::symlink::SymlinkCapability;
+135 -27
View File
@@ -713,16 +713,37 @@ impl Process {
pending.push_str(&String::from_utf8_lossy(&chunk[..read])); pending.push_str(&String::from_utf8_lossy(&chunk[..read]));
// Drain every complete frame currently buffered. // Mods can write plain text to either pipe even when the
while let Some(frame) = take_next_log4j_frame(&mut pending) { // launcher selected XML logging. Forward it as soon as a line
Self::handle_log4j_frame( // is complete instead of retaining it until the game exits.
instance_id, while let Some(output) = take_next_log_output(&mut pending) {
instance_name, match output {
process_id, LogOutput::Xml(frame) => {
log_path.as_ref(), Self::handle_log4j_frame(
&frame, instance_id,
) instance_name,
.await; 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 // Guard against a runaway buffer if no frame delimiters ever
@@ -1360,32 +1381,119 @@ impl Process {
Ok(()) Ok(())
} }
} }
/// Cuts the next complete `<log4j:Event …>…</log4j:Event>` frame out of the #[derive(Debug, Eq, PartialEq)]
/// live buffer and returns it as an owned string, leaving any trailing partial enum LogOutput {
/// frame in place. Xml(String),
/// Legacy(String),
/// 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. /// Drains complete XML frames or plain text in stream order. Partial frames
fn take_next_log4j_frame(buffer: &mut String) -> Option<String> { /// 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 OPEN: &str = "<log4j:Event";
const CLOSE: &str = "</log4j:Event>"; const CLOSE: &str = "</log4j:Event>";
let start = buffer.find(OPEN)?; match buffer.find(OPEN) {
// Discard anything before the frame (raw text, XML prolog, …). Some(0) => {
if start > 0 { let close = buffer.find(CLOSE)?;
buffer.drain(..start); 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)] #[cfg(test)]
mod post_upgrade_tests { mod post_upgrade_tests {
use super::*; 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] #[test]
fn live_log_snapshot_sequences_batches_and_preserves_position_after_clear() fn live_log_snapshot_sequences_batches_and_preserves_position_after_clear()
{ {
+29
View File
@@ -1,5 +1,6 @@
//! Platform-related code //! Platform-related code
use daedalus::minecraft::{Os, OsRule}; use daedalus::minecraft::{Os, OsRule};
use std::path::PathBuf;
/// (e.g. "Run as administrator" on Windows). /// (e.g. "Run as administrator" on Windows).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -143,3 +144,31 @@ pub fn classpath_separator(java_arch: &str) -> &'static str {
Os::Windows | Os::WindowsArm64 => ";", Os::Windows | Os::WindowsArm64 => ";",
} }
} }
/// Root directory used as the default for one-click StarLight instance
/// installs. Portable builds live next to their executable, which is a fine
/// place for game data. System installs (e.g. a Linux `.deb` under `/usr/bin`)
/// do not, so fall back to the user's home directory there.
pub fn default_game_dir_root() -> Option<PathBuf> {
let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
if cfg!(target_os = "linux") && is_system_install_dir(&exe_dir) {
return dirs::home_dir();
}
Some(exe_dir)
}
#[cfg(target_os = "linux")]
fn is_system_install_dir(path: &std::path::Path) -> bool {
const SYSTEM_PREFIXES: &[&str] = &[
"/usr/", "/opt/", "/snap/", "/bin/", "/sbin/", "/lib/", "/lib64/",
];
let display = path.to_string_lossy();
SYSTEM_PREFIXES
.iter()
.any(|prefix| display.starts_with(prefix))
}
#[cfg(not(target_os = "linux"))]
fn is_system_install_dir(_path: &std::path::Path) -> bool {
false
}