feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user