//! Keeps an instance's mods/configs in sync with what the admin published. //! //! Files the panel sends are "managed": they get updated and removed when //! the admin changes the instance. Anything the player adds themselves is //! never touched. use crate::http::{self, Download}; use crate::paths::safe_join; use crate::progress::{self, Reporter, Stage}; use anyhow::Result; use scopenet_shared::FileEntry; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::path::{Path, PathBuf}; #[derive(Debug, Default, Serialize, Deserialize)] pub struct SyncState { pub revision: i64, pub managed: Vec, } fn state_path(game_dir: &Path) -> PathBuf { game_dir.join(".scopenet").join("state.json") } pub fn load_state(game_dir: &Path) -> SyncState { std::fs::read(state_path(game_dir)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() } fn save_state(game_dir: &Path, state: &SyncState) -> Result<()> { let path = state_path(game_dir); std::fs::create_dir_all(path.parent().unwrap())?; std::fs::write(path, serde_json::to_vec_pretty(state)?)?; Ok(()) } pub fn resolve_url(base: &str, url: &str) -> String { if url.starts_with("http://") || url.starts_with("https://") { url.to_string() } else { format!("{}/{}", base.trim_end_matches('/'), url.trim_start_matches('/')) } } #[derive(Debug, Default, Serialize, Clone)] pub struct SyncReport { pub downloaded: usize, pub removed: usize, } /// Sync `files` into `game_dir`. /// /// Fast path: when the revision hasn't changed we only check that files /// exist, so launching an up-to-date instance costs a few `stat` calls. #[allow(clippy::too_many_arguments)] pub async fn sync( client: &reqwest::Client, panel_base: &str, game_dir: &Path, revision: i64, files: &[FileEntry], concurrency: usize, deep: bool, reporter: &Reporter, ) -> Result { std::fs::create_dir_all(game_dir)?; let state = load_state(game_dir); let changed = state.revision != revision || deep; progress::stage(reporter, Stage::Files, if changed { "Syncing instance files" } else { "Checking instance files" }); let mut wanted = HashSet::new(); let mut downloads = Vec::new(); for f in files { let Some(dest) = safe_join(game_dir, &f.path) else { tracing::warn!("skipping unsafe path from panel: {}", f.path); continue; }; wanted.insert(f.path.replace('\\', "/")); let ok = if changed { http::file_ok(&dest, Some(&f.sha1), Some(f.size), true) } else { dest.exists() }; if !ok { downloads.push(Download::new(resolve_url(panel_base, &f.url), dest, Some(f.sha1.clone()), Some(f.size))); } } let downloaded = downloads.len(); http::download_all(client, downloads, concurrency, Stage::Files, reporter).await?; let mut removed = 0; for old in &state.managed { if !wanted.contains(old) { if let Some(path) = safe_join(game_dir, old) { if std::fs::remove_file(&path).is_ok() { removed += 1; } } } } let mut managed: Vec = wanted.into_iter().collect(); managed.sort(); save_state(game_dir, &SyncState { revision, managed })?; Ok(SyncReport { downloaded, removed }) } #[cfg(test)] mod tests { use super::*; #[test] fn urls() { assert_eq!(resolve_url("https://panel.example/", "/files/a/mods/x.jar"), "https://panel.example/files/a/mods/x.jar"); assert_eq!(resolve_url("https://panel.example", "https://cdn.modrinth.com/x.jar"), "https://cdn.modrinth.com/x.jar"); } #[tokio::test] async fn removes_files_the_admin_dropped() { let dir = tempfile::tempdir().unwrap(); let game = dir.path(); std::fs::create_dir_all(game.join("mods")).unwrap(); std::fs::write(game.join("mods/old.jar"), b"old").unwrap(); std::fs::write(game.join("mods/mine.jar"), b"player added").unwrap(); save_state(game, &SyncState { revision: 1, managed: vec!["mods/old.jar".into()] }).unwrap(); let client = crate::http::client(); let report = sync(&client, "http://unused", game, 2, &[], 4, false, &crate::progress::noop()).await.unwrap(); assert_eq!(report.removed, 1); assert!(!game.join("mods/old.jar").exists()); assert!(game.join("mods/mine.jar").exists(), "player files are never touched"); assert_eq!(load_state(game).revision, 2); } }