- Multi-arch (amd64/arm64) panel image: static musl binary on scratch, cross-compiled with cargo-zigbuild (no QEMU), non-root, healthcheck - docker-compose.yml + .env.example for one-command deployment - CI: fmt, clippy, tests, web type-checks, Windows launcher build, real-network installs of vanilla/Fabric/Quilt/Forge/NeoForge, Docker build - Release: Windows NSIS installer with baked-in panel URL, GHCR image push and GitHub release (tag push or manual dispatch) - README, admin guide, Microsoft auth setup, architecture, development - rustfmt config and formatting pass Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
131 lines
4.5 KiB
Rust
131 lines
4.5 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
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<SyncReport> {
|
|
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<String> = 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);
|
|
}
|
|
}
|