//! SteamCMD update integration for process-managed game instances. //! //! Wraps the `steamcmd` binary to perform an `+app_update` for a given game //! instance, streaming stdout lines to a caller-supplied progress callback so //! the panel can display live update output. The agent already runs a task per //! command in a separate `tokio::spawn`, so the blocking-until-done semantics //! here are intentional — the NATS reply is sent only when SteamCMD exits. //! //! Dune is Docker-image-based and explicitly has no SteamCMD integration — any //! attempt to invoke `update` on a Dune instance returns a clear error rather //! than a silent no-op. use std::path::Path; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; /// Return the Steam app ID for a given game name, or `None` for Dune (Docker). /// /// Soulmask returns the Windows or Linux server app ID depending on the compile /// target so this function is `#[cfg]`-gated at the platform level. pub fn app_id_for_game(game: &str) -> Option { match game { "rust" => Some(258550), "conan" => Some(443030), "soulmask" => { #[cfg(windows)] { Some(3017310) } #[cfg(not(windows))] { Some(3017300) } } // Dune uses Docker images — SteamCMD has no role here. "dune" => None, _ => None, } } /// Configuration controlling SteamCMD behaviour for one instance. /// Serialised as `[instance.steamcmd]` in agent.toml. #[derive(Debug, Clone, serde::Deserialize, Default)] pub struct SteamcmdConfig { /// Absolute or relative path to the `steamcmd` binary. /// Defaults to `"steamcmd"` (resolved via `PATH`) when absent. #[serde(default)] pub steamcmd_path: Option, /// Whether to pass `validate` to `+app_update`. Adds a file-hash check /// pass that catches corruption at the cost of a longer update time. #[serde(default)] pub validate: bool, } /// Run a SteamCMD update for `game` into `install_dir`. /// /// - `steamcmd_path`: path to the binary (or `"steamcmd"` to use PATH). /// - `validate`: appends `validate` to the `+app_update` call. /// - `on_progress`: receives each stdout line as it arrives so callers can /// forward progress to the panel in real time. /// /// Returns `Ok(())` on a zero exit code, otherwise an error describing the /// failure. Dune is rejected before any process is spawned. pub async fn update( game: &str, install_dir: &Path, steamcmd_path: &str, validate: bool, on_progress: impl Fn(&str), ) -> anyhow::Result<()> { use anyhow::Context; let app_id = app_id_for_game(game).ok_or_else(|| { anyhow::anyhow!( "dune uses Docker images, not SteamCMD — cannot run app_update for game '{game}'" ) })?; let install_dir_str = install_dir .to_str() .with_context(|| format!("install_dir '{}' is not valid UTF-8", install_dir.display()))?; let mut args: Vec = vec![ "+force_install_dir".to_string(), install_dir_str.to_string(), "+login".to_string(), "anonymous".to_string(), "+app_update".to_string(), app_id.to_string(), ]; if validate { args.push("validate".to_string()); } args.push("+quit".to_string()); tracing::info!( "steamcmd: starting update for game={game} app_id={app_id} install_dir={} validate={validate}", install_dir.display() ); let mut child = Command::new(steamcmd_path) .args(&args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() .with_context(|| format!("spawning steamcmd binary '{steamcmd_path}'"))?; let stdout = child.stdout.take().expect("stdout was piped"); let mut lines = BufReader::new(stdout).lines(); while let Some(line) = lines.next_line().await.context("reading steamcmd stdout")? { tracing::debug!("steamcmd: {line}"); on_progress(&line); } let status = child.wait().await.context("waiting for steamcmd to exit")?; if status.success() { tracing::info!("steamcmd: update completed successfully for game={game}"); Ok(()) } else { let code = status.code().unwrap_or(-1); anyhow::bail!("steamcmd exited with non-zero status {code} for game={game}") } }