//! Launching and supervising the Minecraft server process.
use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use crate::ui;
/// Print a process output stream line by line, tagged as `[server]`.
fn pump(stream: impl io::Read + Send + 'static) {
std::thread::spawn(move || {
for line in io::BufReader::new(stream).lines().map_while(Result::ok) {
ui::server(&line);
}
});
}
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub java: String,
pub jar: PathBuf,
pub dir: PathBuf,
pub memory: Option<String>,
pub extra_args: Vec<String>,
}
pub struct MinecraftServer {
child: Arc<Mutex<Child>>,
stdin: Arc<Mutex<ChildStdin>>,
}
impl MinecraftServer {
pub fn start(opts: &LaunchOptions) -> io::Result<Self> {
let mut cmd = Command::new(&opts.java);
if let Some(mem) = &opts.memory {
cmd.arg(format!("-Xms{mem}")).arg(format!("-Xmx{mem}"));
}
cmd.arg("-jar")
.arg(&opts.jar)
.arg("nogui")
.args(&opts.extra_args)
.current_dir(&opts.dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdin = child.stdin.take().expect("stdin was piped");
Self::pump_stdio(&mut child);
Ok(Self {
child: Arc::new(Mutex::new(child)),
stdin: Arc::new(Mutex::new(stdin)),
})
}
/// Print everything the server writes, line by line.
fn pump_stdio(child: &mut Child) {
if let Some(stdout) = child.stdout.take() {
pump(stdout);
}
if let Some(stderr) = child.stderr.take() {
pump(stderr);
}
}
/// Send a line to the Minecraft server console.
pub fn send_line(&self, line: &str) {
if let Ok(mut stdin) = self.stdin.lock() {
let _ = stdin.write_all(format!("{line}\n").as_bytes());
let _ = stdin.flush();
}
}
pub fn try_wait(&self) -> io::Result<Option<ExitStatus>> {
self.child.lock().unwrap().try_wait()
}
/// Forcefully terminate the server (escalation path for a stuck shutdown).
pub fn kill(&self) {
if let Ok(mut child) = self.child.lock() {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// Read `server.properties` from the server directory, if present.
pub fn read_server_properties(dir: &Path) -> HashMap<String, String> {
let mut props = HashMap::new();
let Ok(text) = std::fs::read_to_string(dir.join("server.properties")) else {
return props;
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
props.insert(key.trim().to_string(), value.trim().to_string());
}
}
props
}
/// Make sure eula.txt exists and (optionally) accepts the Minecraft EULA.
pub fn ensure_eula(dir: &Path, accept: bool) {
let path = dir.join("eula.txt");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
if existing.lines().any(|l| l.trim() == "eula=true") {
return;
}
if !accept {
ui::warn(&format!(
"Minecraft EULA not accepted ({}). The server will exit immediately; \
re-run with --eula to accept it.",
path.display()
));
return;
}
let patched: String = existing
.lines()
.map(|l| {
if l.trim() == "eula=false" {
"eula=true".to_string()
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let content = if patched.contains("eula=true") {
format!("{patched}\n")
} else {
format!("{patched}eula=true\n")
};
match std::fs::write(&path, content) {
Ok(()) => ui::ok(&format!(
"Accepted the Minecraft EULA ({}). By running --eula you agree to \
https://aka.ms/MinecraftEULA",
path.display()
)),
Err(e) => ui::warn(&format!("could not write {}: {e}", path.display())),
}
}
/// Pick a jar to run when the user did not name one.
pub fn auto_detect_jar(dir: &Path) -> Option<PathBuf> {
let mut jars: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir)
.ok()?
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension()?.to_str()? != "jar" {
return None;
}
let modified = entry.metadata().ok()?.modified().ok()?;
Some((modified, path))
})
.collect();
jars.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
// Prefer jars that look like server jars, then any jar.
jars.iter()
.find(|(_, p)| name_looks_like_server_jar(p))
.or_else(|| jars.first())
.map(|(_, p)| p.clone())
}
fn name_looks_like_server_jar(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let server_side = [
"server",
"paper",
"spigot",
"purpur",
"velocity",
"pufferfish",
];
let client_side = ["client", "sources", "javadoc", "dev bundle"];
server_side.iter().any(|k| name.contains(k)) && !client_side.iter().any(|k| name.contains(k))
}