mchost / src/server.rs

raw file 5748 bytes
//! 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))
}