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

+403
View File
@@ -0,0 +1,403 @@
//! Wire types shared by the ScopeNet launcher and the admin panel.
//!
//! Everything the panel sends to the launcher is described here, so both
//! sides always agree on the JSON shape.
use serde::{Deserialize, Serialize};
pub const API_VERSION: u32 = 1;
// ---------------------------------------------------------------------------
// Branding / theming
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Branding {
pub name: String,
pub tagline: String,
pub logo_url: Option<String>,
pub icon_url: Option<String>,
pub background: Background,
pub colors: Palette,
/// One of the fonts bundled in the launcher: "Inter", "Outfit",
/// "Space Grotesk", "Plus Jakarta Sans".
pub font: String,
/// Corner radius in px used for cards and buttons.
pub radius: u8,
/// Frosted-glass surfaces (backdrop blur). Disable for very old GPUs.
pub glass: bool,
/// Shown on the Minecraft title screen / F3 (the `version_type` arg).
pub version_label: String,
pub news: Vec<NewsItem>,
pub links: Vec<SocialLink>,
pub features: Features,
/// Raw CSS appended to the launcher stylesheet (power users).
pub custom_css: String,
}
impl Default for Branding {
fn default() -> Self {
Self {
name: "ScopeNet".into(),
tagline: "Your worlds, one click away.".into(),
logo_url: None,
icon_url: None,
background: Background::default(),
colors: Palette::default(),
font: "Outfit".into(),
radius: 14,
glass: true,
version_label: "ScopeNet".into(),
news: vec![NewsItem {
id: "welcome".into(),
title: "Welcome to ScopeNet".into(),
body: "Your admin can customise this launcher from the panel: colours, news, instances and more.".into(),
image_url: None,
link: None,
date: None,
pinned: true,
tag: Some("News".into()),
}],
links: vec![],
features: Features::default(),
custom_css: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Background {
/// "gradient" | "image" | "video"
pub kind: String,
pub url: Option<String>,
/// Blur in px applied to image/video backgrounds.
pub blur: u8,
/// Darkening overlay 0-100.
pub dim: u8,
}
impl Default for Background {
fn default() -> Self {
Self { kind: "gradient".into(), url: None, blur: 0, dim: 45 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Palette {
pub accent: String,
pub accent_2: String,
pub background: String,
pub surface: String,
pub text: String,
pub muted: String,
pub success: String,
pub danger: String,
}
impl Default for Palette {
fn default() -> Self {
Self {
accent: "#7c5cff".into(),
accent_2: "#22d3ee".into(),
background: "#0b0d17".into(),
surface: "#141828".into(),
text: "#eef0ff".into(),
muted: "#8b90b3".into(),
success: "#34d399".into(),
danger: "#f43f5e".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct NewsItem {
pub id: String,
pub title: String,
pub body: String,
pub image_url: Option<String>,
pub link: Option<String>,
/// ISO date (YYYY-MM-DD)
pub date: Option<String>,
pub pinned: bool,
pub tag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct SocialLink {
pub label: String,
pub url: String,
/// "discord" | "website" | "youtube" | "twitter" | "github" | "store" | "twitch"
pub icon: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Features {
pub news: bool,
pub server_status: bool,
/// Let players override colours/theme in their own settings.
pub allow_user_theme: bool,
/// Let players change JVM arguments / Java path.
pub allow_advanced_java: bool,
}
impl Default for Features {
fn default() -> Self {
Self { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true }
}
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum RegistrationMode {
/// Only admins can create accounts.
#[default]
Closed,
/// Anyone can sign up from the launcher.
Open,
/// Anyone can sign up, but an admin must approve the account.
Approval,
}
/// Public auth configuration the launcher uses to decide which sign-in
/// options to show.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct AuthConfig {
pub panel_accounts: bool,
pub registration: RegistrationMode,
pub microsoft: bool,
pub microsoft_client_id: Option<String>,
pub offline_local: bool,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
panel_accounts: true,
registration: RegistrationMode::Closed,
microsoft: false,
microsoft_client_id: None,
offline_local: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterRequest {
pub username: String,
pub password: String,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PublicUser {
pub id: i64,
pub username: String,
/// Offline-mode UUID (dashed), identical to what an offline server computes.
pub uuid: String,
pub role: String,
pub groups: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse {
pub token: String,
pub user: PublicUser,
/// Set when the account exists but is waiting for admin approval.
#[serde(default)]
pub pending: bool,
}
// ---------------------------------------------------------------------------
// Instances
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, Hash)]
#[serde(rename_all = "lowercase")]
pub enum Loader {
#[default]
Vanilla,
Fabric,
Quilt,
Forge,
NeoForge,
}
impl Loader {
pub fn as_str(&self) -> &'static str {
match self {
Loader::Vanilla => "vanilla",
Loader::Fabric => "fabric",
Loader::Quilt => "quilt",
Loader::Forge => "forge",
Loader::NeoForge => "neoforge",
}
}
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"vanilla" | "" => Loader::Vanilla,
"fabric" | "fabric-loader" => Loader::Fabric,
"quilt" | "quilt-loader" => Loader::Quilt,
"forge" => Loader::Forge,
"neoforge" | "neo-forge" => Loader::NeoForge,
_ => return None,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct ServerEntry {
pub name: String,
pub address: String,
pub port: u16,
/// Connect straight to the server when the game starts.
pub auto_join: bool,
/// Add the server to the in-game multiplayer list (servers.dat).
pub inject: bool,
}
impl Default for ServerEntry {
fn default() -> Self {
Self { name: String::new(), address: String::new(), port: 25565, auto_join: false, inject: true }
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct MemoryDefaults {
pub min_mb: u32,
pub max_mb: u32,
}
impl Default for MemoryDefaults {
fn default() -> Self {
Self { min_mb: 1024, max_mb: 4096 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct InstanceSummary {
pub id: String,
pub name: String,
pub description: String,
pub icon_url: Option<String>,
pub banner_url: Option<String>,
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
/// Bumped by the panel whenever the instance changes; the launcher
/// re-syncs files when this differs from what it last installed.
pub revision: i64,
pub server: Option<ServerEntry>,
pub memory: MemoryDefaults,
pub jvm_args: String,
pub featured: bool,
/// Human description of where the instance came from ("Modrinth: Fabulously Optimized 6.2").
pub source_label: String,
pub file_count: u32,
pub total_size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct FileEntry {
/// Path relative to the instance game directory, always using `/`.
pub path: String,
/// Absolute URL, or a panel-relative URL starting with `/`.
pub url: String,
pub sha1: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct InstanceManifest {
pub instance: InstanceSummary,
pub files: Vec<FileEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct LauncherManifest {
pub api_version: u32,
pub panel_version: String,
pub branding: Branding,
pub auth: AuthConfig,
pub instances: Vec<InstanceSummary>,
/// Present when the request carried a valid panel token.
pub user: Option<PublicUser>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LaunchEvent {
pub instance_id: String,
pub kind: String,
#[serde(default)]
pub username: Option<String>,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// The UUID an offline-mode server assigns to `name`
/// (Java's `UUID.nameUUIDFromBytes("OfflinePlayer:" + name)`).
pub fn offline_uuid(name: &str) -> String {
use md5::{Digest, Md5};
let mut hash: [u8; 16] = Md5::digest(format!("OfflinePlayer:{name}").as_bytes()).into();
hash[6] = (hash[6] & 0x0f) | 0x30; // version 3
hash[8] = (hash[8] & 0x3f) | 0x80; // IETF variant
let h: String = hash.iter().map(|b| format!("{b:02x}")).collect();
format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
}
/// Minecraft usernames: 3-16 chars of `[A-Za-z0-9_]`.
pub fn valid_username(name: &str) -> bool {
(3..=16).contains(&name.len()) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offline_uuid_matches_java() {
// Reference values from java.util.UUID.nameUUIDFromBytes.
assert_eq!(offline_uuid("Notch"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
assert_eq!(offline_uuid("jeb_"), "a762f560-4fce-3236-812a-b80efff0b62b");
}
#[test]
fn usernames() {
assert!(valid_username("Steve_01"));
assert!(!valid_username("ab"));
assert!(!valid_username("has space"));
assert!(!valid_username("waytoolongusername1"));
}
#[test]
fn manifest_roundtrip_with_defaults() {
let m: LauncherManifest = serde_json::from_str("{}").unwrap();
assert_eq!(m.branding.name, "ScopeNet");
let loader: Loader = serde_json::from_str("\"neoforge\"").unwrap();
assert_eq!(loader, Loader::NeoForge);
}
}