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:
Claude committed 2026-09-28 04:22:17 +00:00
1 parent 915f40a4c4
commit 8cdf616a94
43 files changed
+9317

No files matched your search

+134
View File
@@ -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);
}
}