feat:移除了弹窗,服务器添加sls

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 files changed
+1322534

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
//! Reading, writing, and downloading files inside a server's directory.
use std::path::{Path, PathBuf};
use futures::StreamExt;
use sha1_smol::Sha1;
use crate::event::ServerPayloadType;
use crate::event::emit::emit_server;
use crate::util::io::{self, IOError};
use crate::{ErrorKind, Result};
use super::manifest::server_path;
const DOWNLOAD_PROGRESS_STEP: u64 = 512 * 1024;
pub async fn read_file(server_id: &str, file: &str) -> Result<String> {
let path = resolve_server_file(server_id, file).await?;
let bytes = io::read(&path).await?;
let text = String::from_utf8_lossy(&bytes).into_owned();
Ok(text)
}
pub async fn write_file(
server_id: &str,
file: &str,
contents: &str,
) -> Result<()> {
let path = resolve_server_file(server_id, file).await?;
io::write(&path, contents).await?;
Ok(())
}
pub async fn download_file(
server_id: &str,
url: &str,
filename: &str,
expected_sha1: Option<String>,
) -> Result<()> {
let dir = server_path(server_id).await?;
download_to_dir(server_id, &dir, url, filename, expected_sha1).await
}
/// Downloads a URL into a nested, relative path inside the server directory
/// (for example `mods/sodium.jar`), creating parent directories as needed.
/// The relative path is validated against traversal; a plain filename behaves
/// identically to [`download_file`].
pub(super) async fn download_to_dir(
server_id: &str,
dir: &Path,
url: &str,
rel_path: &str,
expected_sha1: Option<String>,
) -> Result<()> {
let destination = safe_relative_join(dir, rel_path)?;
let partial = destination.with_extension("part");
if let Some(parent) = destination.parent() {
io::create_dir_all(parent).await?;
}
let client = reqwest::Client::builder()
.user_agent(crate::launcher_user_agent())
.build()
.map_err(|e| ErrorKind::NetworkError(e.to_string()))?;
let response = client
.get(url)
.send()
.await
.and_then(|r| r.error_for_status())?;
let total = response.content_length();
let mut stream = response.bytes_stream();
let mut file = tokio::fs::File::create(&partial)
.await
.map_err(|e| IOError::with_path(e, &partial))?;
let mut hasher = Sha1::new();
let mut downloaded: u64 = 0;
let mut last_reported: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
.await
.map_err(|e| IOError::with_path(e, &partial))?;
hasher.update(&chunk);
downloaded += chunk.len() as u64;
if downloaded - last_reported >= DOWNLOAD_PROGRESS_STEP {
last_reported = downloaded;
emit_server(
server_id,
ServerPayloadType::DownloadProgress { downloaded, total },
)
.await
.ok();
}
}
drop(file);
if let Some(expected) = expected_sha1.as_deref() {
let actual = hasher.digest().to_string();
if !actual.eq_ignore_ascii_case(expected) {
let _ = tokio::fs::remove_file(&partial).await;
return Err(ErrorKind::NetworkError(format!(
"Download checksum mismatch for {rel_path}: expected {expected}, got {actual}"
))
.as_error());
}
}
tokio::fs::rename(&partial, &destination)
.await
.map_err(|e| IOError::with_path(e, &destination))?;
emit_server(
server_id,
ServerPayloadType::DownloadProgress {
downloaded,
total: Some(downloaded.max(total.unwrap_or(0))),
},
)
.await
.ok();
Ok(())
}
async fn resolve_server_file(server_id: &str, file: &str) -> Result<PathBuf> {
let dir = server_path(server_id).await?;
safe_join(&dir, file)
}
fn safe_join(dir: &Path, file: &str) -> Result<PathBuf> {
if file.is_empty()
|| file.contains('\\')
|| file.starts_with('/')
|| file
.split('/')
.any(|segment| segment == ".." || segment.is_empty())
{
return Err(ErrorKind::InputError(format!(
"Invalid file name: {file}"
))
.as_error());
}
Ok(dir.join(file))
}
/// Joins a relative path that may contain `/` separators but never escapes the
/// server directory (no `..`, empty segments, or absolute/Windows roots).
pub(super) fn safe_relative_join(dir: &Path, rel: &str) -> Result<PathBuf> {
let is_windows_drive_root =
rel.as_bytes().get(1).is_some_and(|byte| *byte == b':')
&& rel.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
if rel.is_empty()
|| Path::new(rel).is_absolute()
|| is_windows_drive_root
|| rel.starts_with('/')
|| rel.starts_with('\\')
|| rel.split(['/', '\\']).any(|segment| {
segment.is_empty() || segment == ".." || segment == "."
})
{
return Err(ErrorKind::InputError(format!("Invalid file path: {rel}"))
.as_error());
}
Ok(dir.join(rel))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_join_rejects_traversal() {
let dir = Path::new("/tmp/servers/a");
assert!(safe_join(dir, "server.properties").is_ok());
assert!(safe_join(dir, "../secret").is_err());
assert!(safe_join(dir, "/etc/passwd").is_err());
assert!(safe_join(dir, "a//b").is_err());
assert!(safe_join(dir, "").is_err());
}
#[test]
fn safe_relative_join_allows_nested_paths() {
let dir = Path::new("/tmp/servers/a");
assert_eq!(
safe_relative_join(dir, "mods/sodium.jar").unwrap(),
dir.join("mods/sodium.jar")
);
assert_eq!(
safe_relative_join(dir, "config/foo/bar.toml").unwrap(),
dir.join("config/foo/bar.toml")
);
assert!(safe_relative_join(dir, "mods/../secret").is_err());
assert!(safe_relative_join(dir, "../secret").is_err());
assert!(safe_relative_join(dir, "/etc/passwd").is_err());
assert!(safe_relative_join(dir, "C:/windows/system32").is_err());
assert!(safe_relative_join(dir, "mods//double.jar").is_err());
assert!(safe_relative_join(dir, "mods/./dot.jar").is_err());
assert!(safe_relative_join(dir, "").is_err());
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Forge server installation: downloading the official installer and running it
//! headlessly to materialize the server launcher (run script, `@args` files, and
//! the mod loader) into a managed server directory.
//!
//! Forge ships a bootstrapper rather than a ready-to-run jar. The launcher jar is
//! produced by `java -jar forge-installer.jar --installServer <dir>`, which lays
//! down the loader, its libraries, and the `@args` launch files. The regular
//! `servers.start` flow then boots that output (see `lifecycle::forge_launch_args`).
use tokio::process::Command;
use crate::event::ServerPayloadType;
use crate::event::emit::emit_server;
use crate::util::io::IOError;
use crate::{ErrorKind, Result};
use super::files::download_to_dir;
use super::manifest::{
InstallState, read_manifest, server_path, write_manifest,
};
const FORGE_MAVEN: &str =
"https://maven.minecraftforge.net/net/minecraftforge/forge";
/// Downloads the Forge installer for `mc_version`/`build` into the server dir
/// and runs it headlessly (`--installServer`) to lay down the launcher. The
/// server is left unstarted; the caller (frontend wizard) writes `eula.txt` and
/// the regular `servers.start` flow boots it.
pub async fn install_forge(
server_id: &str,
mc_version: &str,
build: &str,
java_path: Option<String>,
) -> Result<()> {
let dir = server_path(server_id).await?;
let mut manifest = read_manifest(&dir).await?;
manifest.install_state = Some(InstallState::Incomplete);
manifest.install_error = None;
write_manifest(&dir, &manifest).await?;
drop(manifest);
let installer_name = format!("forge-{mc_version}-{build}-installer.jar");
let installer_url =
format!("{FORGE_MAVEN}/{mc_version}-{build}/{installer_name}");
log(
server_id,
&format!("Downloading Forge installer ({installer_name})"),
)
.await?;
download_to_dir(server_id, &dir, &installer_url, &installer_name, None)
.await?;
let installer_path = dir.join(&installer_name);
let java = java_path.clone().unwrap_or_else(|| "java".to_string());
log(server_id, "Running Forge installer (this may take a while)").await?;
let output = Command::new(&java)
.arg("-jar")
.arg(&installer_path)
.arg("--installServer")
.arg(&dir)
.current_dir(&dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to run Forge installer: {e}"
))
.as_error()
})?;
// The installer is verbose; surface a condensed tail so failures are diagnosable.
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines().rev().take(20) {
log(server_id, line).await.ok();
}
if !output.status.success() {
let mut manifest = read_manifest(&dir).await?;
manifest.install_state = Some(InstallState::Failed);
manifest.install_error =
Some("Forge installer exited with an error".to_string());
write_manifest(&dir, &manifest).await?;
return Err(ErrorKind::LauncherError(
"Forge installer failed. Check that the selected Java version supports this game version."
.to_string(),
)
.as_error());
}
// Ensure eula.txt exists (eula=false) so the manual-start gate can offer it
// without booting the jar.
let eula_path = dir.join("eula.txt");
if !eula_path.exists() {
tokio::fs::write(&eula_path, "eula=false\n")
.await
.map_err(|e| IOError::with_path(e, &eula_path))?;
}
let mut manifest = read_manifest(&dir).await?;
manifest.install_state = None;
manifest.install_error = None;
write_manifest(&dir, &manifest).await?;
log(server_id, "Forge server files installed").await.ok();
Ok(())
}
async fn log(server_id: &str, line: &str) -> Result<()> {
emit_server(
server_id,
ServerPayloadType::Log {
line: line.to_string(),
},
)
.await
}
@@ -0,0 +1,641 @@
//! Server process control: starting, stopping, and monitoring the JVM.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use base64::Engine;
use chrono::Utc;
use dashmap::{DashMap, DashSet};
use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system};
use std::sync::LazyLock;
use tokio::io::AsyncWriteExt;
use tokio::process::{Child, ChildStdin, Command};
use crate::event::ServerPayloadType;
use crate::event::emit::emit_server;
use crate::state::{clear_log_buffer, get_log_buffer, push_log_line};
use crate::util::io::IOError;
use crate::{ErrorKind, Result};
use super::logs::{
analyze_exit_reason, stream_server_output, stream_server_pty_output,
tail_server_log_file,
};
use super::manifest::{
read_manifest, resolve_jar_name, server_path, write_manifest,
};
const DEFAULT_MEMORY_MB: u32 = 2048;
const STOP_TIMEOUT_SECS: u64 = 60;
const MAX_CONSOLE_ROWS: u16 = 8192;
struct ServerProcess {
child: tokio::sync::Mutex<ServerChild>,
input: tokio::sync::Mutex<ServerInput>,
pty_master: Option<tokio::sync::Mutex<Box<dyn MasterPty + Send>>>,
stop_requested: AtomicBool,
}
enum ServerChild {
Piped(Child),
Pty(Box<dyn portable_pty::Child + Send + Sync>),
}
enum ServerInput {
Piped(ChildStdin),
Pty(Box<dyn std::io::Write + Send>),
}
impl ServerInput {
async fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> {
match self {
Self::Piped(stdin) => {
stdin.write_all(data).await?;
stdin.flush().await
}
Self::Pty(writer) => {
writer.write_all(data)?;
writer.flush()
}
}
}
}
impl ServerChild {
fn try_wait(&mut self) -> std::io::Result<Option<bool>> {
match self {
Self::Piped(child) => {
child.try_wait().map(|status| status.map(|s| s.success()))
}
Self::Pty(child) => {
child.try_wait().map(|status| status.map(|s| s.success()))
}
}
}
async fn kill(&mut self) -> std::io::Result<()> {
match self {
Self::Piped(child) => child.kill().await,
Self::Pty(child) => child.kill(),
}
}
}
static SERVER_PROCESSES: LazyLock<DashMap<String, Arc<ServerProcess>>> =
LazyLock::new(DashMap::new);
/// Synchronous start-in-flight guard. Reserving the slot before the first
/// `.await` prevents concurrent `start` calls (e.g. double-clicks) from both
/// passing the running check and spawning two JVMs on the same directory.
static SERVER_STARTING: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
pub(super) fn is_running(server_id: &str) -> bool {
SERVER_PROCESSES.contains_key(server_id)
}
/// Whether a server process is currently tracked. Used by the log-file tailer
/// to stop following once the server has exited.
pub(super) fn is_server_running(server_id: &str) -> bool {
SERVER_PROCESSES.contains_key(server_id)
}
pub async fn start(
server_id: &str,
java_path: Option<String>,
memory_mb: Option<u32>,
jvm_args: Option<Vec<String>>,
) -> Result<()> {
if SERVER_PROCESSES.contains_key(server_id)
|| !SERVER_STARTING.insert(server_id.to_string())
{
return Err(ErrorKind::InputError(
"Server is already running".to_string(),
)
.as_error());
}
let result = start_inner(server_id, java_path, memory_mb, jvm_args).await;
SERVER_STARTING.remove(server_id);
result
}
async fn start_inner(
server_id: &str,
java_path: Option<String>,
memory_mb: Option<u32>,
jvm_args: Option<Vec<String>>,
) -> Result<()> {
let dir = server_path(server_id).await?;
let mut manifest = read_manifest(&dir).await?;
let launch_args = if uses_pty_transport(&manifest.server_type) {
forge_launch_args(&dir)?
} else {
let jar_name = resolve_jar_name(&manifest);
let jar_path = dir.join(&jar_name);
if !jar_path.exists() {
return Err(ErrorKind::LauncherError(format!(
"Server jar not found: {jar_name}. Download the server files first."
))
.as_error());
}
vec!["-jar".to_string(), jar_name, "nogui".to_string()]
};
let java = java_path
.or_else(|| manifest.java_path.clone())
.unwrap_or_else(|| "java".to_string());
let memory = memory_mb
.or(manifest.memory_mb)
.unwrap_or(DEFAULT_MEMORY_MB);
// Ensure eula.txt exists (create with eula=false if missing)
let eula_path = dir.join("eula.txt");
let eula_created = !eula_path.exists();
if eula_created {
tokio::fs::write(&eula_path, "eula=false\n")
.await
.map_err(|e| IOError::with_path(e, &eula_path))?;
}
let mut args = vec![format!("-Xmx{memory}M")];
if uses_pty_transport(&manifest.server_type) {
args.push("-Dorg.jline.reader.props.list-max=0".to_string());
}
args.extend(jvm_args.unwrap_or_else(|| manifest.jvm_args.clone()));
args.extend(launch_args);
let removed_environment = [
"DYLD_LIBRARY_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_FRAMEWORK_PATH",
"DYLD_INSERT_LIBRARIES",
];
let (mut child, input, pty_master, stdout, stderr, pty_reader) =
if uses_pty_transport(&manifest.server_type) {
let pair = native_pty_system()
.openpty(PtySize {
rows: 12,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to create Forge console PTY: {e}"
))
.as_error()
})?;
let reader = pair.master.try_clone_reader().map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to capture Forge console output: {e}"
))
.as_error()
})?;
let writer = pair.master.take_writer().map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to capture Forge console input: {e}"
))
.as_error()
})?;
let mut command = CommandBuilder::new(&java);
command.args(&args);
command.cwd(&dir);
command.env("TERM", "xterm-256color");
for variable in removed_environment {
command.env_remove(variable);
}
let child = pair.slave.spawn_command(command).map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to start Forge server process: {e}"
))
.as_error()
})?;
(
ServerChild::Pty(child),
ServerInput::Pty(writer),
Some(tokio::sync::Mutex::new(pair.master)),
None,
None,
Some(reader),
)
} else {
let mut command = Command::new(&java);
command.args(&args);
command.current_dir(&dir);
for variable in removed_environment {
command.env_remove(variable);
}
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
command.stdin(std::process::Stdio::piped());
command.kill_on_drop(true);
let mut child = command.spawn().map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to start server process: {e}"
))
.as_error()
})?;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdin = child.stdin.take().ok_or_else(|| {
ErrorKind::LauncherError(
"Server stdin could not be captured".to_string(),
)
.as_error()
})?;
(
ServerChild::Piped(child),
ServerInput::Piped(stdin),
None,
stdout,
stderr,
None,
)
};
manifest.last_started_at = Some(Utc::now());
manifest.last_exit_crashed = false;
if let Err(error) = write_manifest(&dir, &manifest).await {
let _ = child.kill().await;
return Err(error);
}
clear_log_buffer(server_id);
// Start each run from a clean log file. Minecraft's log4j appender appends
// to logs/latest.log across launches, so without truncating it the file
// tailer would replay the previous run's history into the fresh buffer on
// every restart.
let _ = std::fs::remove_file(dir.join("logs").join("latest.log"));
// Surface every startup step in the console. A loader's first launch (e.g.
// Fabric downloading the Minecraft server) can stay silent for a long time,
// so these lines stop the console from looking frozen.
let loader_first_run =
matches!(manifest.server_type.as_str(), "fabric" | "quilt")
&& !dir
.join(format!("{}-server-launch.jar", manifest.server_type))
.exists();
log_server_step(
server_id,
&format!(
"Starting server '{}' ({} · Minecraft {})",
manifest.name, manifest.server_type, manifest.game_version,
),
)
.await;
log_server_step(server_id, &format!("Java: {java}")).await;
log_server_step(server_id, &format!("Memory: {memory} MB")).await;
if eula_created {
log_server_step(
server_id,
"eula.txt not found — created with eula=false. Accept the EULA to start the server.",
)
.await;
}
log_server_step(
server_id,
&format!(
"Launching {} server ({} · nogui)",
manifest.server_type,
resolve_jar_name(&manifest),
),
)
.await;
if loader_first_run {
log_server_step(
server_id,
"First launch: downloading Minecraft server files. This may take a few minutes — the console will keep updating as it progresses.",
)
.await;
}
let process = Arc::new(ServerProcess {
child: tokio::sync::Mutex::new(child),
input: tokio::sync::Mutex::new(input),
pty_master,
stop_requested: AtomicBool::new(false),
});
SERVER_PROCESSES.insert(server_id.to_string(), process.clone());
if let Some(stdout) = stdout {
tokio::spawn(stream_server_output(server_id.to_string(), stdout));
}
if let Some(stderr) = stderr {
tokio::spawn(stream_server_output(server_id.to_string(), stderr));
}
if let Some(reader) = pty_reader {
tokio::spawn(stream_server_pty_output(server_id.to_string(), reader));
}
// The process pipes (above) capture JVM/installer output, but the server's
// own log4j console output is normally written to logs/latest.log rather
// than the stdout pipe. Tail that file so the console always shows the
// complete, lossless server log (matching what's on disk).
tokio::spawn(tail_server_log_file(server_id.to_string(), dir.clone()));
tokio::spawn(monitor_server_process(server_id.to_string(), dir, process));
emit_server(server_id, ServerPayloadType::Started)
.await
.ok();
Ok(())
}
pub async fn send_command(server_id: &str, command: &str) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
let mut input = process.input.lock().await;
input
.write_all(&command_bytes(command))
.await
.map_err(|e| {
ErrorKind::LauncherError(format!("Failed to send command: {e}"))
.as_error()
})?;
Ok(())
}
fn uses_pty_transport(server_type: &str) -> bool {
server_type == "forge"
}
fn command_bytes(command: &str) -> Vec<u8> {
format!("{command}\n").into_bytes()
}
pub async fn send_console_input(server_id: &str, data: &str) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
if process.pty_master.is_none() {
return Err(ErrorKind::InputError(
"Raw console input is only available for Forge servers".to_string(),
)
.as_error());
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|e| {
ErrorKind::InputError(format!("Invalid console input: {e}"))
.as_error()
})?;
if bytes.len() > 64 * 1024 {
return Err(ErrorKind::InputError(
"Console input exceeds 64 KiB".to_string(),
)
.as_error());
}
let mut input = process.input.lock().await;
input.write_all(&bytes).await.map_err(|e| {
ErrorKind::LauncherError(format!("Failed to send command: {e}"))
.as_error()
})?;
Ok(())
}
pub async fn resize_console(
server_id: &str,
cols: u16,
rows: u16,
) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
let master = process.pty_master.as_ref().ok_or_else(|| {
ErrorKind::InputError(
"Console resizing is only available for Forge servers".to_string(),
)
.as_error()
})?;
master
.lock()
.await
.resize(PtySize {
rows: rows.clamp(4, MAX_CONSOLE_ROWS),
cols: cols.clamp(20, 500),
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to resize Forge console: {e}"
))
.as_error()
})?;
Ok(())
}
pub async fn stop(server_id: &str) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
process.stop_requested.store(true, Ordering::SeqCst);
let mut input = process.input.lock().await;
let _ = input.write_all(b"stop\n").await;
let watchdog = process.clone();
let server_id = server_id.to_string();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(STOP_TIMEOUT_SECS))
.await;
if let Some(current) = SERVER_PROCESSES.get(&server_id)
&& current.stop_requested.load(Ordering::SeqCst)
{
let _ = watchdog.child.lock().await.kill().await;
}
});
Ok(())
}
pub async fn kill(server_id: &str) -> Result<()> {
let process = SERVER_PROCESSES
.get(server_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
ErrorKind::InputError("Server is not running".to_string())
.as_error()
})?;
process.stop_requested.store(true, Ordering::SeqCst);
let mut child = process.child.lock().await;
child.kill().await?;
Ok(())
}
async fn monitor_server_process(
server_id: String,
dir: PathBuf,
process: Arc<ServerProcess>,
) {
loop {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let exit_status = {
let mut child = process.child.lock().await;
match child.try_wait() {
Ok(Some(success)) => Some(success),
Ok(None) => continue,
Err(_) => None,
}
};
SERVER_PROCESSES.remove(&server_id);
let stop_requested = process.stop_requested.load(Ordering::SeqCst);
let eula_accepted = read_eula_accepted(&dir).await;
let crashed = exit_status
.map(|success| !success && !stop_requested && eula_accepted)
.unwrap_or(false);
// Classify self-exits from the tail of the console output so the UI
// can react (e.g. offer the EULA dialog). User-requested stops and
// unmatched exits stay unclassified. The brief settle wait lets the
// output-stream tasks flush their final lines into the buffer first.
let reason = if stop_requested {
None
} else {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
analyze_exit_reason(&get_log_buffer(&server_id))
};
if let Ok(mut manifest) = read_manifest(&dir).await {
manifest.last_exit_crashed = crashed;
let _ = write_manifest(&dir, &manifest).await;
}
emit_server(&server_id, ServerPayloadType::Stopped { crashed, reason })
.await
.ok();
return;
}
}
/// Builds the JVM launch arguments for a Forge server. Modern Forge (1.17+)
/// ships `@args` files that enumerate the classpath and main class; legacy Forge
/// (<=1.16) produces a single runnable `forge-*.jar`.
fn forge_launch_args(dir: &Path) -> Result<Vec<String>> {
let forge_dir = dir
.join("libraries")
.join("net")
.join("minecraftforge")
.join("forge");
if let Ok(entries) = std::fs::read_dir(&forge_dir) {
let args_file = if cfg!(windows) {
"win_args.txt"
} else {
"unix_args.txt"
};
for entry in entries.flatten() {
let candidate = entry.path().join(args_file);
if candidate.is_file() {
let mut args = Vec::new();
if dir.join("user_jvm_args.txt").exists() {
args.push("@user_jvm_args.txt".to_string());
}
args.push(format!("@{}", candidate.to_string_lossy()));
args.push("nogui".to_string());
return Ok(args);
}
}
}
if let Some(jar) = find_forge_jar(dir) {
return Ok(vec!["-jar".to_string(), jar, "nogui".to_string()]);
}
Err(ErrorKind::LauncherError(
"Forge server files are missing. Reinstall the server.".to_string(),
)
.as_error())
}
fn find_forge_jar(dir: &Path) -> Option<String> {
let entry = std::fs::read_dir(dir).ok()?.flatten().find(|e| {
e.file_name().to_string_lossy().starts_with("forge-")
&& e.path().extension().is_some_and(|ext| ext == "jar")
})?;
Some(entry.file_name().to_string_lossy().into_owned())
}
async fn read_eula_accepted(dir: &Path) -> bool {
match tokio::fs::read_to_string(dir.join("eula.txt")).await {
Ok(text) => text
.lines()
.find_map(|line| line.split_once('='))
.filter(|(key, _)| key.trim() == "eula")
.is_some_and(|(_, value)| {
value.trim().eq_ignore_ascii_case("true")
}),
Err(_) => false,
}
}
/// Emits a timestamped, info-level line to the server console: it is both
/// persisted to the log buffer and pushed as a live `Log` event, so startup
/// progress is visible even before the JVM produces any output of its own.
async fn log_server_step(server_id: &str, message: &str) {
let line = format!(
"{} [Axolotl/INFO]: {}",
chrono::Local::now().format("%H:%M:%S"),
message,
);
push_log_line(server_id, line.clone());
emit_server(server_id, ServerPayloadType::Log { line })
.await
.ok();
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone)]
struct SharedWriter(Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for SharedWriter {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buffer);
Ok(buffer.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn selects_pty_only_for_forge() {
assert!(uses_pty_transport("forge"));
assert!(!uses_pty_transport("vanilla"));
assert!(!uses_pty_transport("fabric"));
assert!(!uses_pty_transport("neoforge"));
}
#[test]
fn whole_line_commands_end_with_one_newline() {
assert_eq!(command_bytes("say hello"), b"say hello\n");
}
#[tokio::test]
async fn pty_input_preserves_raw_bytes() {
let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut input =
ServerInput::Pty(Box::new(SharedWriter(captured.clone())));
input.write_all(b"\x1b[D\t\x7f").await.unwrap();
assert_eq!(*captured.lock().unwrap(), b"\x1b[D\t\x7f");
}
}
+484
View File
@@ -0,0 +1,484 @@
//! Console output buffering and streaming for servers.
use base64::Engine;
use std::path::PathBuf;
use tokio::fs::File;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncSeekExt, BufReader};
use crate::Result;
use crate::api::servers::lifecycle::is_server_running;
use crate::event::emit::emit_server;
use crate::event::{ExitReason, ServerPayloadType};
use crate::state::{clear_log_buffer, push_log_line};
const MAX_PTY_LINE_BYTES: usize = 256 * 1024;
const MAX_SERVER_LOG_LINE_BYTES: usize = 64 * 1024;
const SERVER_LOG_TRUNCATION_MARKER: &str =
" … [log output truncated by Axolotl] … ";
async fn read_bounded_server_log_line<R>(
reader: &mut R,
) -> std::io::Result<Option<String>>
where
R: AsyncBufRead + Unpin,
{
let mut line = Vec::new();
let mut saw_bytes = false;
let mut truncated = false;
loop {
let (consumed, reached_line_end) = {
let available = reader.fill_buf().await?;
if available.is_empty() {
if !saw_bytes {
return Ok(None);
}
break;
}
saw_bytes = true;
let consumed = available
.iter()
.position(|byte| *byte == b'\n')
.map_or(available.len(), |index| index + 1);
let maximum_content_bytes = MAX_SERVER_LOG_LINE_BYTES
.saturating_sub(SERVER_LOG_TRUNCATION_MARKER.len() + 1);
let remaining = maximum_content_bytes.saturating_sub(line.len());
let copied = remaining.min(consumed);
line.extend_from_slice(&available[..copied]);
truncated |= copied < consumed;
(consumed, available[consumed - 1] == b'\n')
};
reader.consume(consumed);
if reached_line_end {
break;
}
}
if truncated {
while matches!(line.last(), Some(b'\r' | b'\n')) {
line.pop();
}
line.extend_from_slice(SERVER_LOG_TRUNCATION_MARKER.as_bytes());
line.push(b'\n');
}
Ok(Some(String::from_utf8_lossy(&line).into_owned()))
}
pub async fn get_log_buffer(server_id: &str) -> Result<Vec<String>> {
Ok(crate::state::get_log_buffer(server_id))
}
pub async fn clear_log(server_id: &str) -> Result<()> {
clear_log_buffer(server_id);
Ok(())
}
pub(super) async fn stream_server_output(
server_id: String,
reader: impl tokio::io::AsyncRead + Unpin,
) {
let mut buf_reader = BufReader::new(reader);
let mut jna_hint_emitted = false;
while let Ok(Some(line)) =
read_bounded_server_log_line(&mut buf_reader).await
{
process_server_output_line(
&server_id,
line.trim_end_matches(['\r', '\n']),
&mut jna_hint_emitted,
)
.await;
}
}
pub(super) async fn stream_server_pty_output(
server_id: String,
mut reader: Box<dyn std::io::Read + Send>,
) {
let (sender, mut receiver) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
tokio::task::spawn_blocking(move || {
let mut buffer = vec![0_u8; 8192];
loop {
match reader.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => {
if sender.blocking_send(buffer[..read].to_vec()).is_err() {
break;
}
}
}
}
});
let mut pending_line = Vec::new();
let mut jna_hint_emitted = false;
while let Some(bytes) = receiver.recv().await {
emit_server(
&server_id,
ServerPayloadType::ConsoleOutput {
data: base64::engine::general_purpose::STANDARD.encode(&bytes),
},
)
.await
.ok();
for line in take_complete_pty_lines(&mut pending_line, &bytes) {
let text = String::from_utf8_lossy(&line);
process_server_output_line(
&server_id,
text.trim_end_matches(['\r', '\n']),
&mut jna_hint_emitted,
)
.await;
}
}
if !pending_line.is_empty() {
process_server_output_line(
&server_id,
&String::from_utf8_lossy(&pending_line),
&mut jna_hint_emitted,
)
.await;
}
}
fn take_complete_pty_lines(
pending: &mut Vec<u8>,
bytes: &[u8],
) -> Vec<Vec<u8>> {
pending.extend_from_slice(bytes);
let Some(last_newline) = pending.iter().rposition(|byte| *byte == b'\n')
else {
if pending.len() > MAX_PTY_LINE_BYTES {
pending.clear();
}
return Vec::new();
};
let remainder = pending.split_off(last_newline + 1);
let completed = std::mem::replace(pending, remainder);
let mut lines = Vec::new();
for line in completed.split_inclusive(|byte| *byte == b'\n') {
if line.len() <= MAX_PTY_LINE_BYTES {
lines.push(line.to_vec());
}
}
lines
}
async fn process_server_output_line(
server_id: &str,
raw_line: &str,
jna_hint_emitted: &mut bool,
) {
let cleaned = strip_ansi(raw_line);
if cleaned.is_empty()
|| is_timestamped_log_line(&cleaned)
|| cleaned.starts_with("> ")
{
return;
}
push_log_line(server_id, cleaned.clone());
emit_server(server_id, ServerPayloadType::Log { line: cleaned })
.await
.ok();
if !*jna_hint_emitted && is_jna_macos_assertion(raw_line) {
*jna_hint_emitted = true;
for hint in JNA_CRASH_HINT_LINES {
push_log_line(server_id, hint.to_string());
emit_server(
server_id,
ServerPayloadType::Log {
line: hint.to_string(),
},
)
.await
.ok();
}
}
}
/// Streams the server's `logs/latest.log` file into the console buffer. The
/// Minecraft/Fabric log4j console output is frequently not delivered through
/// the process stdout pipe (it goes to the log file instead), so tailing this
/// file is the authoritative, lossless source of the server's own logs. Lines
/// already present in the buffer (e.g. delivered via the stdout/stderr pipes)
/// are skipped to avoid duplicates.
pub(super) async fn tail_server_log_file(server_id: String, dir: PathBuf) {
let log_path = dir.join("logs").join("latest.log");
let mut reader = loop {
if !is_server_running(&server_id) {
return;
}
match File::open(&log_path).await {
Ok(file) => break BufReader::new(file),
// The file only appears once the server starts logging; poll until then.
Err(_) => {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
}
};
loop {
if !is_server_running(&server_id) {
return;
}
match read_bounded_server_log_line(&mut reader).await {
Ok(None) => {
// Caught up. Detect log rotation (file replaced/truncated) and
// otherwise wait for more output to be appended.
if let Ok(meta) = tokio::fs::metadata(&log_path).await
&& let Ok(pos) = reader.stream_position().await
&& meta.len() < pos
&& let Ok(file) = File::open(&log_path).await
{
reader = BufReader::new(file);
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
Ok(Some(line)) => {
let trimmed = line.trim_end_matches(['\r', '\n']);
let cleaned = strip_ansi(trimmed);
let already_present =
crate::state::get_log_buffer(&server_id).contains(&cleaned);
if !cleaned.is_empty() && !already_present {
push_log_line(&server_id, cleaned.clone());
emit_server(
&server_id,
ServerPayloadType::Log { line: cleaned },
)
.await
.ok();
}
}
Err(_) => break,
}
}
}
/// Matches the native abort of the known JNA (< 5.13.0) macOS bug (JNA issue
/// #1452): a failed library load overflows JNA's fixed error buffer and the
/// JVM dies with SIGABRT before any Java-level exception can be reported.
fn is_jna_macos_assertion(line: &str) -> bool {
line.contains("Assertion failed:")
&& line.contains("snprintf() output has been truncated")
&& line.contains("dispatch.c")
}
/// Detects a server log4j line by its leading `[HH:MM:SS]` timestamp, covering
/// both console (`[HH:MM:SS INFO]:`) and file (`[HH:MM:SS] [Thread/INFO]:`)
/// formats. Used to suppress the process-pipe echo of server logs so they are
/// not duplicated by `tail_server_log_file`.
fn is_timestamped_log_line(line: &str) -> bool {
let b = line.as_bytes();
b.first() == Some(&b'[')
&& b.get(1).is_some_and(|c| c.is_ascii_digit())
&& b.get(2).is_some_and(|c| c.is_ascii_digit())
&& b.get(3) == Some(&b':')
&& b.get(4).is_some_and(|c| c.is_ascii_digit())
&& b.get(5).is_some_and(|c| c.is_ascii_digit())
&& b.get(6) == Some(&b':')
&& b.get(7).is_some_and(|c| c.is_ascii_digit())
&& b.get(8).is_some_and(|c| c.is_ascii_digit())
}
/// How many lines at the end of a server's output are inspected when
/// classifying why it exited.
const EXIT_ANALYSIS_TAIL_LINES: usize = 50;
/// Classifies why a server exited on its own by scanning the tail of its
/// console output, newest lines first. Returns `None` when nothing matches:
/// no guess is better than a wrong one, and unmatched exits simply behave as
/// before.
pub(super) fn analyze_exit_reason(lines: &[String]) -> Option<ExitReason> {
lines
.iter()
.rev()
.take(EXIT_ANALYSIS_TAIL_LINES)
.find_map(|line| is_eula_refusal(line).then_some(ExitReason::Eula))
}
/// Matches the vanilla server's refusal to boot before the EULA has been
/// accepted; the process then writes `eula.txt` and exits immediately.
fn is_eula_refusal(line: &str) -> bool {
line.contains("need to agree to the EULA")
}
const JNA_CRASH_HINT_LINES: [&str; 3] = [
"[Axolotl] This crash matches a known JNA bug on macOS (java-native-access#1452):",
"[Axolotl] mods bundling JNA below 5.13.0 abort when a native library fails to load.",
"[Axolotl] Update or remove the affected mod, or ask the modpack author to bump JNA to 5.13.0+.",
];
/// Removes ANSI escape sequences (SGR colors, cursor control, OSC titles) that
/// servers emit when they assume an interactive terminal is attached.
fn strip_ansi(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.char_indices().peekable();
while let Some((_, character)) = chars.next() {
if character != '\u{1b}' {
output.push(character);
continue;
}
match chars.peek().map(|&(_, c)| c) {
// CSI sequence: parameter bytes, then a final byte in @..~
Some('[') => {
chars.next();
for (_, c) in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
// OSC sequence: terminated by BEL or ST (ESC \)
Some(']') => {
chars.next();
let mut saw_escape = false;
for (_, c) in chars.by_ref() {
if c == '\u{7}' || (saw_escape && c == '\\') {
break;
}
saw_escape = c == '\u{1b}';
}
}
// Stray escape byte without a recognized sequence
_ => {}
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_ansi_escape_sequences_from_server_output() {
let line = "[16:02:30 INFO]: \u{1b}[38;2;255;170;0m/mspt: \u{1b}[38;2;255;255;255mView server tick times\u{1b}[0m";
assert_eq!(
strip_ansi(line),
"[16:02:30 INFO]: /mspt: View server tick times"
);
assert_eq!(strip_ansi("\u{1b}]0;Server console\u{7}ready"), "ready");
assert_eq!(strip_ansi("\u{1b}]0;Server console\u{1b}\\done"), "done");
assert_eq!(strip_ansi("plain text stays"), "plain text stays");
assert_eq!(strip_ansi("h\u{e9}llo \u{1b}[31mred"), "h\u{e9}llo red");
}
#[test]
fn splits_pty_output_without_losing_partial_or_raw_bytes() {
let mut pending = Vec::new();
assert_eq!(
take_complete_pty_lines(&mut pending, b"first\r\nsecond"),
vec![b"first\r\n".to_vec()]
);
assert_eq!(pending, b"second");
assert_eq!(
take_complete_pty_lines(&mut pending, b"\nthird\n"),
vec![b"second\n".to_vec(), b"third\n".to_vec()]
);
assert!(pending.is_empty());
}
#[test]
fn bounds_unterminated_pty_output() {
let mut pending = vec![b'x'; MAX_PTY_LINE_BYTES];
assert!(take_complete_pty_lines(&mut pending, b"x").is_empty());
assert!(pending.is_empty());
let oversized = vec![b'x'; MAX_PTY_LINE_BYTES + 1];
assert!(take_complete_pty_lines(&mut pending, &oversized).is_empty());
assert!(pending.is_empty());
}
#[tokio::test]
async fn bounded_server_log_reader_consumes_oversized_line() {
let input =
format!("{}\nnext\n", "x".repeat(MAX_SERVER_LOG_LINE_BYTES * 2));
let mut reader = BufReader::new(std::io::Cursor::new(input));
let first = read_bounded_server_log_line(&mut reader)
.await
.unwrap()
.unwrap();
let second = read_bounded_server_log_line(&mut reader)
.await
.unwrap()
.unwrap();
assert!(first.contains("truncated by Axolotl"));
assert!(first.len() <= MAX_SERVER_LOG_LINE_BYTES);
assert_eq!(second, "next\n");
}
#[test]
fn detects_timestamped_log_lines() {
// Console format (no thread) — duplicated by Paper's stdout echo.
assert!(is_timestamped_log_line(
"[11:13:49 INFO]: [bootstrap] Running Java 25"
));
// File format (with thread) — authoritative source from latest.log.
assert!(is_timestamped_log_line(
"[11:13:49] [ServerMain/INFO]: [bootstrap] Running"
));
assert!(is_timestamped_log_line(
"[11:13:49] [Server thread/INFO]: Stopped IO worker!"
));
// Non-logged process output must NOT be suppressed.
assert!(!is_timestamped_log_line("Downloading mojang_26.2.jar"));
assert!(!is_timestamped_log_line("Applying patches"));
assert!(!is_timestamped_log_line(
"Starting org.bukkit.craftbukkit.Main"
));
assert!(!is_timestamped_log_line(
"WARNING: A terminally deprecated method in sun.misc.Unsafe"
));
assert!(!is_timestamped_log_line(
"2026-08-29T03:13:49.279070900Z ServerMain WARN Advanced terminal features",
));
}
#[test]
fn detects_jna_macos_assertion() {
let line = "Assertion failed: (count <= len && \"snprintf() output has been truncated\"), function LOAD_ERROR, file dispatch.c, line 74.";
assert!(is_jna_macos_assertion(line));
assert!(!is_jna_macos_assertion(
"Assertion failed: something else, file other.c, line 1."
));
assert!(!is_jna_macos_assertion("regular log output"));
}
#[test]
fn classifies_eula_refusal_from_output_tail() {
let eula_line = "[15:26:09] [main/INFO]: You need to agree to the EULA in order to run the server. Go to eula.txt for more info.".to_string();
let mut lines = vec![
"[15:26:09] [main/INFO]: Starting minecraft server version 26.2"
.to_string(),
eula_line.clone(),
];
assert_eq!(analyze_exit_reason(&lines), Some(ExitReason::Eula));
// Detected even when buried under later shutdown chatter.
lines.push(
"[16:44:23] [Server thread/INFO]: Stopped IO worker!".to_string(),
);
assert_eq!(analyze_exit_reason(&lines), Some(ExitReason::Eula));
// A normal shutdown matches nothing and stays unclassified.
let normal = vec![
"[16:44:18] [Server thread/INFO]: Stopping the server".to_string(),
"[16:44:23] [Server thread/INFO]: Stopped IO worker!".to_string(),
];
assert_eq!(analyze_exit_reason(&normal), None);
assert_eq!(analyze_exit_reason(&[]), None);
// Only the tail is inspected; ancient history does not classify a
// much-later exit.
let mut old = vec![eula_line];
old.resize(EXIT_ANALYSIS_TAIL_LINES + 10, "noise".to_string());
assert_eq!(analyze_exit_reason(&old), None);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Listing, creation, and settings management for servers.
use chrono::Utc;
use uuid::Uuid;
use crate::state::remove_log_buffer;
use crate::util::io::{self, IOError};
use crate::{ErrorKind, Result, State};
use super::lifecycle::is_running;
use super::manifest::{
ServerInfo, ServerManifest, build_server_info, read_manifest,
sanitize_folder_name, server_path, type_default_jar_name, write_manifest,
};
pub async fn list() -> Result<Vec<ServerInfo>> {
let state = State::get().await?;
let servers_dir = state.directories.servers_dir();
if !servers_dir.exists() {
return Ok(Vec::new());
}
let mut servers = Vec::new();
let mut entries = tokio::fs::read_dir(&servers_dir)
.await
.map_err(|e| IOError::with_path(e, &servers_dir))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &servers_dir))?
{
let path = entry.path();
if !path.is_dir() {
continue;
}
let Ok(manifest) = read_manifest(&path).await else {
continue;
};
servers.push(build_server_info(&manifest, &path).await);
}
servers.sort_by(|a, b| {
a.manifest
.name
.to_lowercase()
.cmp(&b.manifest.name.to_lowercase())
});
Ok(servers)
}
pub async fn get(server_id: &str) -> Result<ServerInfo> {
let path = server_path(server_id).await?;
let manifest = read_manifest(&path).await?;
Ok(build_server_info(&manifest, &path).await)
}
pub async fn create(
name: &str,
server_type: &str,
game_version: &str,
loader_version: Option<String>,
java_path: Option<String>,
memory_mb: Option<u32>,
) -> Result<ServerManifest> {
let name = name.trim();
if name.is_empty() {
return Err(ErrorKind::InputError(
"Server name cannot be empty".to_string(),
)
.as_error());
}
let state = State::get().await?;
let id = Uuid::new_v4().to_string();
let dir_name = format!("{}-{}", sanitize_folder_name(name), &id[..8]);
let dir = state.directories.servers_dir().join(&dir_name);
io::create_dir_all(&dir).await?;
let manifest = ServerManifest {
id: dir_name,
name: name.to_string(),
server_type: server_type.to_string(),
game_version: game_version.to_string(),
loader_version,
jar_name: type_default_jar_name(server_type),
java_path,
memory_mb,
icon_path: None,
modpack: None,
install_state: None,
install_error: None,
jvm_args: Vec::new(),
created_at: Utc::now(),
last_started_at: None,
last_exit_crashed: false,
};
write_manifest(&dir, &manifest).await?;
Ok(manifest)
}
/// Sets or clears the server icon. `None` resets to the default icon.
pub async fn set_icon(
server_id: &str,
icon_path: Option<String>,
) -> Result<ServerManifest> {
let path = server_path(server_id).await?;
let mut manifest = read_manifest(&path).await?;
manifest.icon_path = icon_path;
write_manifest(&path, &manifest).await?;
Ok(manifest)
}
pub async fn update_settings(
server_id: &str,
name: Option<String>,
java_path: Option<String>,
memory_mb: Option<u32>,
jvm_args: Option<Vec<String>>,
) -> Result<ServerManifest> {
let path = server_path(server_id).await?;
let mut manifest = read_manifest(&path).await?;
if let Some(name) = name {
let name = name.trim().to_string();
if name.is_empty() {
return Err(ErrorKind::InputError(
"Server name cannot be empty".to_string(),
)
.as_error());
}
manifest.name = name;
}
if let Some(java_path) = java_path {
manifest.java_path = if java_path.is_empty() {
None
} else {
Some(java_path)
};
}
if let Some(memory_mb) = memory_mb {
manifest.memory_mb = Some(memory_mb);
}
if let Some(jvm_args) = jvm_args {
manifest.jvm_args = jvm_args;
}
write_manifest(&path, &manifest).await?;
Ok(manifest)
}
pub async fn delete(server_id: &str) -> Result<()> {
if is_running(server_id) {
return Err(ErrorKind::InputError(
"Stop the server before deleting it".to_string(),
)
.as_error());
}
let path = server_path(server_id).await?;
remove_log_buffer(server_id);
tokio::fs::remove_dir_all(&path)
.await
.map_err(|e| IOError::with_path(e, &path))?;
Ok(())
}
@@ -0,0 +1,268 @@
//! Server manifests and per-server metadata: the `axolotl-server.json`
//! document, derived display info, and path helpers shared by the other
//! `servers` submodules.
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::state::State;
use crate::util::io;
use crate::{ErrorKind, Result};
use super::lifecycle::is_running;
const MANIFEST_FILE: &str = "axolotl-server.json";
const DEFAULT_JAR_NAME: &str = "server.jar";
/// Executable launcher jar downloaded from Fabric Meta; must match the
/// filename used by the frontend's `resolveServerJar('fabric')`.
const FABRIC_SERVER_JAR_NAME: &str = "fabric-server.jar";
/// Executable launcher jar downloaded from Quilt Meta; must match the
/// filename used by the frontend's `resolveServerJar('quilt')`.
const QUILT_SERVER_JAR_NAME: &str = "quilt-server.jar";
pub(super) fn type_default_jar_name(server_type: &str) -> Option<String> {
match server_type {
"fabric" => Some(FABRIC_SERVER_JAR_NAME.to_string()),
"quilt" => Some(QUILT_SERVER_JAR_NAME.to_string()),
_ => None,
}
}
/// Resolves the jar a server launches with: the manifest override, then the
/// server type default, then the generic default.
pub(super) fn resolve_jar_name(manifest: &ServerManifest) -> String {
manifest
.jar_name
.clone()
.or_else(|| type_default_jar_name(&manifest.server_type))
.unwrap_or_else(|| DEFAULT_JAR_NAME.to_string())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ServerManifest {
pub id: String,
pub name: String,
pub server_type: String,
pub game_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loader_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jar_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub java_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_mb: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modpack: Option<ModpackInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install_state: Option<InstallState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub install_error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub jvm_args: Vec<String>,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_started_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_exit_crashed: bool,
}
/// Tracks whether a modpack server has finished materializing. `Incomplete`
/// is written when an install starts (and left behind if the app exits
/// mid-download); `Failed` records an install error so the UI can offer a
/// retry. Both clear once the install succeeds.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum InstallState {
Incomplete,
Failed,
}
/// Identifies a modpack a server was created from. Populated by
/// [`super::modpack::install_modpack`] so the UI can badge and link servers
/// back to their source project.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModpackInfo {
pub project_id: String,
pub version_id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
}
#[derive(Serialize, Debug, Clone)]
pub struct ServerInfo {
#[serde(flatten)]
pub manifest: ServerManifest,
pub path: String,
pub running: bool,
pub eula_exists: bool,
pub eula_accepted: bool,
pub port: Option<u16>,
}
pub(super) async fn server_path(server_id: &str) -> Result<PathBuf> {
if server_id.contains(['/', '\\'])
|| server_id.contains("..")
|| server_id.is_empty()
{
return Err(ErrorKind::InputError(format!(
"Invalid server id: {server_id}"
))
.as_error());
}
let state = State::get().await?;
let path = state.directories.server_dir(server_id);
if !path.is_dir() {
return Err(ErrorKind::InputError(format!(
"Unknown server: {server_id}"
))
.as_error());
}
Ok(path)
}
pub(super) async fn read_manifest(dir: &Path) -> Result<ServerManifest> {
let bytes = io::read(dir.join(MANIFEST_FILE)).await?;
serde_json::from_slice(&bytes).map_err(|e| {
ErrorKind::FSError(format!("Failed to parse server manifest: {e}"))
.as_error()
})
}
pub(super) async fn write_manifest(
dir: &Path,
manifest: &ServerManifest,
) -> Result<()> {
let contents = serde_json::to_string_pretty(manifest)?;
io::write(dir.join(MANIFEST_FILE), contents).await?;
Ok(())
}
pub(super) fn sanitize_folder_name(name: &str) -> String {
let sanitized: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
let trimmed = sanitized.trim_matches('-');
if trimmed.is_empty() {
"server".to_string()
} else {
trimmed.chars().take(32).collect()
}
}
pub(super) async fn build_server_info(
manifest: &ServerManifest,
path: &Path,
) -> ServerInfo {
let eula_text = tokio::fs::read_to_string(path.join("eula.txt"))
.await
.unwrap_or_default();
let eula_exists = !eula_text.is_empty();
let eula_accepted = eula_text
.lines()
.find_map(|line| line.split_once('='))
.filter(|(key, _)| key.trim() == "eula")
.is_some_and(|(_, value)| value.trim().eq_ignore_ascii_case("true"));
let port = tokio::fs::read_to_string(path.join("server.properties"))
.await
.ok()
.and_then(|text| {
text.lines().find_map(|line| {
let (key, value) = line.split_once('=')?;
(key.trim() == "server-port")
.then(|| value.trim().parse::<u16>().ok())?
})
});
ServerInfo {
manifest: manifest.clone(),
path: path.to_string_lossy().into_owned(),
running: is_running(&manifest.id),
eula_exists,
eula_accepted,
port,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_folder_name_replaces_unsafe_characters() {
assert_eq!(sanitize_folder_name("My Server!"), "My-Server");
assert_eq!(sanitize_folder_name("../etc"), "etc");
assert_eq!(sanitize_folder_name("///"), "server");
}
#[test]
fn server_manifest_round_trips() {
let manifest = ServerManifest {
id: "test-12345678".to_string(),
name: "Test".to_string(),
server_type: "vanilla".to_string(),
game_version: "1.21.4".to_string(),
loader_version: None,
jar_name: None,
java_path: None,
memory_mb: Some(2048),
icon_path: None,
modpack: None,
install_state: None,
install_error: None,
jvm_args: Vec::new(),
created_at: Utc::now(),
last_started_at: None,
last_exit_crashed: false,
};
let json = serde_json::to_string(&manifest).unwrap();
let parsed: ServerManifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, manifest.id);
assert_eq!(parsed.name, manifest.name);
}
#[test]
fn resolves_jar_name_from_type_then_manifest() {
let mut manifest = ServerManifest {
id: "test-12345678".to_string(),
name: "Test".to_string(),
server_type: "fabric".to_string(),
game_version: "26.2".to_string(),
loader_version: Some("0.19.3".to_string()),
jar_name: None,
java_path: None,
memory_mb: None,
icon_path: None,
modpack: None,
install_state: None,
install_error: None,
jvm_args: Vec::new(),
created_at: Utc::now(),
last_started_at: None,
last_exit_crashed: false,
};
assert_eq!(resolve_jar_name(&manifest), FABRIC_SERVER_JAR_NAME);
manifest.server_type = "quilt".to_string();
assert_eq!(resolve_jar_name(&manifest), QUILT_SERVER_JAR_NAME);
manifest.server_type = "vanilla".to_string();
assert_eq!(resolve_jar_name(&manifest), DEFAULT_JAR_NAME);
manifest.jar_name = Some("custom.jar".to_string());
assert_eq!(resolve_jar_name(&manifest), "custom.jar");
}
}
File diff suppressed because it is too large. Load diff
+170
View File
@@ -0,0 +1,170 @@
//! Platform utilities for inspecting and terminating processes that hold a
//! TCP port, used to resolve "port already in use" conflicts.
use serde::Serialize;
use tokio::process::Command;
use crate::{ErrorKind, Result};
pub async fn kill_port_process(port: u16) -> Result<()> {
let pids = port_listener_pids(port).await?;
if pids.is_empty() {
return Err(ErrorKind::InputError(format!(
"No process found listening on port {port}"
))
.as_error());
}
for pid in pids {
force_terminate_pid(pid).await?;
}
Ok(())
}
#[derive(Serialize, Debug, Clone)]
pub struct PortProcessInfo {
pub pid: u32,
pub name: Option<String>,
}
/// Returns the first process listening on the given TCP port, if any.
pub async fn port_process(port: u16) -> Result<Option<PortProcessInfo>> {
let pids = port_listener_pids(port).await?;
let Some(&pid) = pids.first() else {
return Ok(None);
};
Ok(Some(PortProcessInfo {
pid,
name: process_name(pid).await,
}))
}
#[cfg(not(target_os = "windows"))]
async fn port_listener_pids(port: u16) -> Result<Vec<u32>> {
let output = Command::new("lsof")
.args(["-t", "-i", &format!("tcp:{port}"), "-s", "tcp:listen"])
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to look up processes listening on port {port}: {e}"
))
.as_error()
})?;
let mut pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect();
pids.sort_unstable();
pids.dedup();
Ok(pids)
}
#[cfg(not(target_os = "windows"))]
async fn force_terminate_pid(pid: u32) -> Result<()> {
let output = Command::new("kill")
.args(["-9", &pid.to_string()])
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to terminate process {pid}: {e}"
))
.as_error()
})?;
if !output.status.success() {
return Err(ErrorKind::LauncherError(format!(
"Failed to terminate process {pid}"
))
.as_error());
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
async fn process_name(pid: u32) -> Option<String> {
let output = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!name.is_empty()).then_some(name)
}
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
#[cfg(target_os = "windows")]
async fn port_listener_pids(port: u16) -> Result<Vec<u32>> {
let output = Command::new("netstat")
.args(["-ano", "-p", "tcp"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to look up processes listening on port {port}: {e}"
))
.as_error()
})?;
let mut pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let columns: Vec<&str> = line.split_whitespace().collect();
if columns.len() < 5
|| !columns[3].eq_ignore_ascii_case("LISTENING")
{
return None;
}
let local_address = columns[1];
let local_port = local_address.rsplit(':').next()?;
(local_port == port.to_string())
.then(|| columns[4].parse::<u32>().ok())?
})
.collect();
pids.sort_unstable();
pids.dedup();
Ok(pids)
}
#[cfg(target_os = "windows")]
async fn force_terminate_pid(pid: u32) -> Result<()> {
let output = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output()
.await
.map_err(|e| {
ErrorKind::LauncherError(format!(
"Failed to terminate process {pid}: {e}"
))
.as_error()
})?;
if !output.status.success() {
return Err(ErrorKind::LauncherError(format!(
"Failed to terminate process {pid}"
))
.as_error());
}
Ok(())
}
#[cfg(target_os = "windows")]
async fn process_name(pid: u32) -> Option<String> {
let output = Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.await
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().next()?;
if line.starts_with("INFO") {
return None;
}
let name = line.split(',').next()?.trim_matches('"').to_string();
(!name.is_empty()).then_some(name)
}