Add launcher engine (scopenet-core) and admin panel server
- scopenet-shared: wire types for branding, auth, instances, manifests - scopenet-core: Mojang versions/libraries/assets, automatic Java runtimes, Fabric/Quilt/Forge/NeoForge installation, file sync, launch command builder, Microsoft auth, servers.dat injection, server list ping - scopenet-panel: Axum + SQLite admin API with JWT auth, users/groups, branding, settings, instances, Modrinth/CurseForge/zip import, file hosting and launch stats Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
43 files changed
+9317
No files matched your search
@@ -0,0 +1,134 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user