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,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user