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

+26
View File
@@ -0,0 +1,26 @@
[package]
name = "scopenet-core"
description = "Minecraft install / launch engine used by the ScopeNet launcher (and meta lookups in the panel)"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
scopenet-shared.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
futures.workspace = true
sha1.workspace = true
hex.workspace = true
zip.workspace = true
tracing.workspace = true
fastnbt = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_System_SystemInformation"] }
[dev-dependencies]
tempfile = "3"
+83
View File
@@ -0,0 +1,83 @@
//! Game assets (sounds, languages, textures) — content-addressed objects.
use crate::http::{self, Download};
use crate::paths::Layout;
use crate::version::AssetIndexRef;
use anyhow::Result;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub const RESOURCES: &str = "https://resources.download.minecraft.net";
#[derive(Debug, Deserialize)]
pub struct AssetIndex {
pub objects: HashMap<String, AssetObject>,
#[serde(default)]
pub map_to_resources: bool,
#[serde(default, rename = "virtual")]
pub is_virtual: bool,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AssetObject {
pub hash: String,
pub size: u64,
}
pub fn object_path(layout: &Layout, hash: &str) -> PathBuf {
layout.assets().join("objects").join(&hash[..2]).join(hash)
}
/// Load (downloading if needed) the asset index.
pub async fn load_index(client: &reqwest::Client, layout: &Layout, index: &AssetIndexRef) -> Result<AssetIndex> {
let path = layout.assets().join("indexes").join(format!("{}.json", index.id));
if !http::file_ok(&path, index.sha1.as_deref(), index.size, true) {
http::download_one(client, &Download::new(index.url.clone(), path.clone(), index.sha1.clone(), index.size), &|_| {})
.await?;
}
Ok(serde_json::from_slice(&std::fs::read(&path)?)?)
}
/// Downloads needed for missing/corrupt objects.
pub fn missing_objects(layout: &Layout, index: &AssetIndex, deep: bool) -> Vec<Download> {
let mut seen = std::collections::HashSet::new();
index
.objects
.values()
.filter(|o| o.hash.len() > 2 && seen.insert(o.hash.clone()))
.filter_map(|o| {
let path = object_path(layout, &o.hash);
if http::file_ok(&path, Some(&o.hash), Some(o.size), deep) {
None
} else {
Some(Download::new(format!("{RESOURCES}/{}/{}", &o.hash[..2], o.hash), path, Some(o.hash.clone()), Some(o.size)))
}
})
.collect()
}
/// Pre-1.7 versions read assets by name, not hash. Copy them into the
/// virtual (or `resources/`) directory. Returns the directory to pass as
/// `game_assets`.
pub fn materialize_legacy(layout: &Layout, index_id: &str, index: &AssetIndex, game_dir: &Path) -> Result<Option<PathBuf>> {
if !index.is_virtual && !index.map_to_resources {
return Ok(None);
}
let target = if index.map_to_resources {
game_dir.join("resources")
} else {
layout.assets().join("virtual").join(index_id)
};
for (name, obj) in &index.objects {
let Some(dest) = crate::paths::safe_join(&target, name) else { continue };
if dest.exists() {
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(object_path(layout, &obj.hash), &dest).ok();
}
Ok(Some(target))
}
+223
View File
@@ -0,0 +1,223 @@
//! HTTP helpers: a shared client, JSON fetches and verified, resumable-safe
//! parallel downloads.
use crate::progress::{Event, Reporter, Stage};
use anyhow::{bail, Context, Result};
use futures::StreamExt;
use serde::de::DeserializeOwned;
use sha1::{Digest, Sha1};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
pub const USER_AGENT: &str = concat!("ScopeNet-Launcher/", env!("CARGO_PKG_VERSION"), " (+https://github.com/scopeddlol/SCOPENET-MC)");
pub fn client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(60))
.pool_max_idle_per_host(16)
.build()
.expect("http client")
}
pub async fn get_json<T: DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
let resp = client.get(url).send().await.with_context(|| format!("GET {url}"))?;
let status = resp.status();
if !status.is_success() {
bail!("GET {url} returned {status}");
}
let bytes = resp.bytes().await?;
serde_json::from_slice(&bytes).with_context(|| format!("parsing JSON from {url}"))
}
/// Fetch JSON, caching it at `cache`. Falls back to the cached copy when
/// offline so the game can still launch without internet.
pub async fn get_json_cached<T: DeserializeOwned>(client: &reqwest::Client, url: &str, cache: &Path) -> Result<T> {
match client.get(url).send().await.and_then(|r| r.error_for_status()) {
Ok(resp) => {
let bytes = resp.bytes().await?;
let parsed = serde_json::from_slice(&bytes).with_context(|| format!("parsing JSON from {url}"))?;
if let Some(dir) = cache.parent() {
tokio::fs::create_dir_all(dir).await.ok();
}
tokio::fs::write(cache, &bytes).await.ok();
Ok(parsed)
}
Err(err) => {
let bytes = tokio::fs::read(cache).await.map_err(|_| anyhow::anyhow!("{err} (and no cached copy)"))?;
tracing::warn!("{url} unreachable, using cached copy: {err}");
Ok(serde_json::from_slice(&bytes)?)
}
}
}
pub fn sha1_file(path: &Path) -> Result<String> {
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha1::new();
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
pub fn sha1_bytes(bytes: &[u8]) -> String {
hex::encode(Sha1::digest(bytes))
}
/// Cheap check used before every launch: size match (and hash match when
/// `deep` is set).
pub fn file_ok(path: &Path, sha1: Option<&str>, size: Option<u64>, deep: bool) -> bool {
let Ok(meta) = std::fs::metadata(path) else { return false };
if let Some(size) = size {
if size > 0 && meta.len() != size {
return false;
}
}
if deep {
if let Some(expected) = sha1.filter(|s| !s.is_empty()) {
return sha1_file(path).map(|h| h.eq_ignore_ascii_case(expected)).unwrap_or(false);
}
}
true
}
#[derive(Debug, Clone)]
pub struct Download {
pub url: String,
pub dest: PathBuf,
pub sha1: Option<String>,
pub size: u64,
}
impl Download {
pub fn new(url: impl Into<String>, dest: PathBuf, sha1: Option<String>, size: Option<u64>) -> Self {
Self { url: url.into(), dest, sha1: sha1.filter(|s| !s.is_empty()), size: size.unwrap_or(0) }
}
}
/// Download a single file to `dest` atomically (via a `.part` file) and
/// verify its SHA-1. Retries transient failures.
pub async fn download_one(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn Fn(u64) + Sync)) -> Result<()> {
let mut last_err = None;
for attempt in 0..4u32 {
if attempt > 0 {
tokio::time::sleep(Duration::from_millis(400 * 2u64.pow(attempt))).await;
}
match try_download(client, dl, on_bytes).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::warn!("download {} failed (attempt {}): {e:#}", dl.url, attempt + 1);
last_err = Some(e);
}
}
}
Err(last_err.unwrap()).with_context(|| format!("downloading {}", dl.url))
}
async fn try_download(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn Fn(u64) + Sync)) -> Result<()> {
if let Some(dir) = dl.dest.parent() {
tokio::fs::create_dir_all(dir).await?;
}
let resp = client.get(&dl.url).send().await?.error_for_status()?;
let part = dl.dest.with_extension(format!(
"{}part",
dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()
));
let mut file = tokio::fs::File::create(&part).await?;
let mut hasher = Sha1::new();
let mut stream = resp.bytes_stream();
let mut written = 0u64;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
hasher.update(&chunk);
file.write_all(&chunk).await?;
written += chunk.len() as u64;
on_bytes(chunk.len() as u64);
}
file.flush().await?;
drop(file);
if let Some(expected) = &dl.sha1 {
let got = hex::encode(hasher.finalize());
if !got.eq_ignore_ascii_case(expected) {
tokio::fs::remove_file(&part).await.ok();
bail!("checksum mismatch for {} (expected {expected}, got {got}, {written} bytes)", dl.url);
}
}
tokio::fs::rename(&part, &dl.dest).await?;
Ok(())
}
/// Download many files in parallel, reporting aggregate byte progress.
pub async fn download_all(
client: &reqwest::Client,
downloads: Vec<Download>,
concurrency: usize,
stage: Stage,
reporter: &Reporter,
) -> Result<()> {
if downloads.is_empty() {
return Ok(());
}
let total: u64 = downloads.iter().map(|d| d.size).sum();
let files_total = downloads.len() as u32;
let done = Arc::new(AtomicU64::new(0));
let files_done = Arc::new(AtomicU32::new(0));
let last_emit = Arc::new(std::sync::Mutex::new(Instant::now() - Duration::from_secs(1)));
let emit = {
let done = done.clone();
let files_done = files_done.clone();
let reporter = reporter.clone();
move |force: bool| {
let mut last = last_emit.lock().unwrap();
if force || last.elapsed() >= Duration::from_millis(100) {
*last = Instant::now();
let d = done.load(Ordering::Relaxed);
reporter(Event::Progress {
stage,
done: d,
total: total.max(d),
files_done: files_done.load(Ordering::Relaxed),
files_total,
});
}
}
};
emit(true);
let results: Vec<Result<()>> = futures::stream::iter(downloads.into_iter().map(|dl| {
let done = done.clone();
let files_done = files_done.clone();
let emit = &emit;
async move {
let on_bytes = |n: u64| {
done.fetch_add(n, Ordering::Relaxed);
emit(false);
};
let r = download_one(client, &dl, &on_bytes).await;
files_done.fetch_add(1, Ordering::Relaxed);
emit(false);
r
}
}))
.buffer_unordered(concurrency.clamp(1, 64))
.collect()
.await;
emit(true);
let errors: Vec<_> = results.into_iter().filter_map(|r| r.err()).collect();
if let Some(first) = errors.into_iter().next() {
return Err(first);
}
Ok(())
}
+222
View File
@@ -0,0 +1,222 @@
//! Installs everything needed to launch a version: vanilla game, loader,
//! libraries, assets and Java.
use crate::assets;
use crate::http::{self, Download};
use crate::java;
use crate::libraries::{self, ResolvedLib};
use crate::loaders;
use crate::meta;
use crate::paths::Layout;
use crate::progress::{self, Reporter, Stage};
use crate::rules::Env;
use crate::version::{self, VersionJson};
use anyhow::{anyhow, bail, Context, Result};
use scopenet_shared::Loader;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct InstallSpec {
pub mc_version: String,
pub loader: Loader,
pub loader_version: Option<String>,
/// User-selected Java executable (skips the automatic runtime).
pub java_override: Option<PathBuf>,
pub game_dir: PathBuf,
pub concurrency: usize,
/// Re-hash every file instead of trusting sizes (Repair).
pub deep_verify: bool,
}
#[derive(Debug, Clone)]
pub struct Installed {
pub version: VersionJson,
/// Windowed Java (javaw on Windows).
pub java: PathBuf,
pub java_major: u32,
pub client_jar: PathBuf,
pub natives_dir: PathBuf,
pub libraries: Vec<ResolvedLib>,
pub assets_root: PathBuf,
pub game_assets: PathBuf,
pub assets_index: String,
pub logging_arg: Option<String>,
}
impl Installed {
pub fn classpath(&self, layout: &Layout) -> Vec<PathBuf> {
let libs_dir = layout.libraries();
let mut seen = std::collections::HashSet::new();
let mut cp: Vec<PathBuf> = self
.libraries
.iter()
.filter(|l| l.classpath)
.map(|l| l.file(&libs_dir))
.filter(|p| seen.insert(p.clone()))
.collect();
cp.push(self.client_jar.clone());
cp
}
}
/// Vanilla version JSON: from Mojang (cached), falling back to disk.
async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Result<VersionJson> {
let path = layout.version_json(mc);
let manifest = match meta::mojang_manifest(client).await {
Ok(m) => m,
Err(e) => {
let bytes = std::fs::read(&path).map_err(|_| anyhow!("can't reach Mojang ({e}) and {mc} isn't installed yet"))?;
return Ok(serde_json::from_slice(&bytes)?);
}
};
let entry = manifest
.versions
.iter()
.find(|v| v.id == mc)
.ok_or_else(|| anyhow!("Minecraft {mc} doesn't exist in Mojang's version list"))?;
if !http::file_ok(&path, entry.sha1.as_deref(), None, true) {
http::download_one(client, &Download::new(entry.url.clone(), path.clone(), entry.sha1.clone(), None), &|_| {}).await?;
}
Ok(serde_json::from_slice(&std::fs::read(&path)?)?)
}
fn library_downloads(libs: &[ResolvedLib], layout: &Layout, deep: bool) -> Vec<Download> {
let dir = layout.libraries();
let mut out: Vec<Download> = libs
.iter()
.filter(|l| !http::file_ok(&l.file(&dir), l.sha1.as_deref(), l.size, deep))
.filter_map(|l| l.download(&dir))
.collect();
out.sort_by(|a, b| a.dest.cmp(&b.dest));
out.dedup_by(|a, b| a.dest == b.dest);
out
}
pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSpec, reporter: &Reporter) -> Result<Installed> {
let env = Env::current();
let mc = spec.mc_version.as_str();
progress::stage(reporter, Stage::Preparing, format!("Preparing Minecraft {mc}"));
// 1. Vanilla metadata ----------------------------------------------------
let vanilla = vanilla_json(client, layout, mc).await?;
// 2. Java ----------------------------------------------------------------
let (java_dir, java_major) = match &spec.java_override {
Some(p) if p.exists() => (None, java::detect_major(p).await.unwrap_or(vanilla.java_major())),
Some(p) => bail!("the Java path in your settings doesn't exist: {}", p.display()),
None => {
let component = vanilla.java_component();
progress::stage(reporter, Stage::Java, "Checking Java");
(Some(java::ensure_runtime(client, layout, &component, spec.concurrency, reporter).await?), vanilla.java_major())
}
};
let (java_windowed, java_console) = match (&java_dir, &spec.java_override) {
(Some(dir), _) => (java::java_exe(dir, true), java::java_exe(dir, false)),
(None, Some(p)) => (java::sibling_exe(p, true), java::sibling_exe(p, false)),
_ => unreachable!(),
};
// 3. Vanilla files (client jar, libraries, assets, log config) ---------
progress::stage(reporter, Stage::Game, format!("Downloading Minecraft {mc}"));
let vanilla_client = layout.version_jar(mc);
let mut downloads = Vec::new();
if let Some(client_dl) = vanilla.downloads.as_ref().and_then(|d| d.client.as_ref()) {
if !http::file_ok(&vanilla_client, client_dl.sha1.as_deref(), client_dl.size, spec.deep_verify) {
downloads.push(Download::new(client_dl.url.clone(), vanilla_client.clone(), client_dl.sha1.clone(), client_dl.size));
}
}
downloads.extend(library_downloads(&libraries::resolve(&vanilla.libraries, &env), layout, spec.deep_verify));
let asset_ref = vanilla.asset_index.clone().ok_or_else(|| anyhow!("version {mc} has no asset index"))?;
let asset_index = assets::load_index(client, layout, &asset_ref).await?;
downloads.extend(assets::missing_objects(layout, &asset_index, spec.deep_verify));
let mut logging_arg = None;
if let Some(log) = vanilla.logging.as_ref().and_then(|l| l.client.as_ref()) {
let path = layout.assets().join("log_configs").join(&log.file.id);
if !http::file_ok(&path, log.file.sha1.as_deref(), log.file.size, spec.deep_verify) {
downloads.push(Download::new(log.file.url.clone(), path.clone(), log.file.sha1.clone(), log.file.size));
}
logging_arg = Some(log.argument.replace("${path}", &path.to_string_lossy()));
}
http::download_all(client, downloads, spec.concurrency, Stage::Game, reporter).await?;
// 4. Loader --------------------------------------------------------------
let child: Option<VersionJson> = match spec.loader {
Loader::Vanilla => None,
Loader::Fabric | Loader::Quilt => {
let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap();
progress::stage(reporter, Stage::Loader, format!("Installing {} {lv}", if spec.loader == Loader::Fabric { "Fabric" } else { "Quilt" }));
Some(loaders::fabric::profile(client, layout, spec.loader, mc, &lv).await?)
}
Loader::Forge | Loader::NeoForge => {
let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap();
Some(
loaders::forge::install(loaders::forge::ForgeInstall {
client,
layout,
loader: spec.loader,
mc,
version: &lv,
java: &java_console,
client_jar: &vanilla_client,
concurrency: spec.concurrency,
reporter,
})
.await
.with_context(|| format!("installing {} {lv}", spec.loader.as_str()))?,
)
}
};
let version = match child {
Some(child) => version::merge(vanilla, child),
None => vanilla,
};
// 5. Remaining (loader) libraries -----------------------------------------
let libs = libraries::resolve(&version.libraries, &env);
let lib_downloads = library_downloads(&libs, layout, false);
if !lib_downloads.is_empty() {
progress::stage(reporter, Stage::Loader, "Downloading loader libraries");
http::download_all(client, lib_downloads, spec.concurrency, Stage::Loader, reporter).await?;
}
let libs_dir = layout.libraries();
if let Some(missing) = libs.iter().find(|l| l.classpath && !l.file(&libs_dir).exists()) {
bail!("library {} is missing and has no download URL", missing.name);
}
// 6. Natives, client jar, legacy assets ---------------------------------
let natives_dir = layout.natives(&version.id);
libraries::extract_natives(&libs, &libs_dir, &natives_dir)?;
// Forge's module system ignores the game jar by *file name*
// (`${version_name}.jar`), so the jar must be named after the final id.
let client_jar = if version.id != mc {
let jar = layout.version_jar(&version.id);
let src_len = std::fs::metadata(&vanilla_client).map(|m| m.len()).unwrap_or(0);
if std::fs::metadata(&jar).map(|m| m.len()).unwrap_or(u64::MAX) != src_len {
std::fs::create_dir_all(jar.parent().unwrap())?;
std::fs::copy(&vanilla_client, &jar)?;
}
jar
} else {
vanilla_client
};
let assets_root = layout.assets();
let game_assets = assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)?
.unwrap_or_else(|| assets_root.clone());
Ok(Installed {
java: java_windowed,
java_major,
client_jar,
natives_dir,
libraries: libs,
assets_root,
game_assets,
assets_index: asset_ref.id.clone(),
logging_arg,
version,
})
}
+204
View File
@@ -0,0 +1,204 @@
//! Automatic Java: Mojang publishes the exact runtimes its launcher uses.
//! We download the one each Minecraft version asks for, so players never
//! have to install Java themselves.
use crate::http::{self, Download};
use crate::paths::Layout;
use crate::progress::{self, Reporter, Stage};
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub const RUNTIME_INDEX: &str =
"https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json";
#[derive(Deserialize)]
struct RuntimeEntry {
manifest: ManifestRef,
version: RuntimeVersion,
}
#[derive(Deserialize)]
struct ManifestRef {
sha1: String,
url: String,
}
#[derive(Deserialize)]
struct RuntimeVersion {
name: String,
}
#[derive(Deserialize)]
struct RuntimeManifest {
files: HashMap<String, RuntimeFile>,
}
#[derive(Deserialize)]
struct RuntimeFile {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
executable: bool,
#[serde(default)]
downloads: Option<RuntimeDownloads>,
#[serde(default)]
target: Option<String>,
}
#[derive(Deserialize)]
struct RuntimeDownloads {
raw: RawDownload,
}
#[derive(Deserialize)]
struct RawDownload {
sha1: String,
size: u64,
url: String,
}
pub fn platform_key() -> &'static str {
match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86") => "windows-x86",
("windows", "aarch64") => "windows-arm64",
("windows", _) => "windows-x64",
("macos", "aarch64") => "mac-os-arm64",
("macos", _) => "mac-os",
("linux", "x86") => "linux-i386",
_ => "linux",
}
}
/// Path to the Java executable inside a runtime directory.
/// `windowed` picks `javaw.exe` on Windows so no console window appears.
pub fn java_exe(dir: &Path, windowed: bool) -> PathBuf {
if cfg!(windows) {
dir.join("bin").join(if windowed { "javaw.exe" } else { "java.exe" })
} else if cfg!(target_os = "macos") {
dir.join("jre.bundle/Contents/Home/bin/java")
} else {
dir.join("bin/java")
}
}
/// For a user-supplied path, derive the console/windowed sibling.
pub fn sibling_exe(java: &Path, windowed: bool) -> PathBuf {
if !cfg!(windows) {
return java.to_path_buf();
}
let name = if windowed { "javaw.exe" } else { "java.exe" };
let candidate = java.with_file_name(name);
if candidate.exists() { candidate } else { java.to_path_buf() }
}
/// Make sure the Mojang runtime `component` (e.g. `java-runtime-delta`) is
/// installed. Returns its directory.
pub async fn ensure_runtime(
client: &reqwest::Client,
layout: &Layout,
component: &str,
concurrency: usize,
reporter: &Reporter,
) -> Result<PathBuf> {
let dir = layout.runtimes().join(component);
let marker = dir.join(".scopenet-runtime");
let installed = std::fs::read_to_string(&marker).ok();
let index: HashMap<String, HashMap<String, Vec<RuntimeEntry>>> =
match http::get_json(client, RUNTIME_INDEX).await {
Ok(i) => i,
Err(e) if installed.is_some() && java_exe(&dir, false).exists() => {
tracing::warn!("java index unreachable ({e}); using installed {component}");
return Ok(dir);
}
Err(e) => return Err(e).context("fetching Java runtime index"),
};
let entry = index
.get(platform_key())
.and_then(|p| p.get(component))
.and_then(|v| v.first())
.ok_or_else(|| anyhow!("Mojang has no '{component}' Java runtime for {}", platform_key()))?;
if installed.as_deref() == Some(entry.manifest.sha1.as_str()) && java_exe(&dir, false).exists() {
return Ok(dir);
}
progress::stage(reporter, Stage::Java, format!("Installing Java {}", entry.version.name));
let manifest: RuntimeManifest = http::get_json(client, &entry.manifest.url).await?;
let mut downloads = Vec::new();
let mut executables = Vec::new();
let mut links = Vec::new();
for (rel, file) in &manifest.files {
let Some(path) = crate::paths::safe_join(&dir, rel) else { continue };
match file.kind.as_str() {
"directory" => std::fs::create_dir_all(&path)?,
"file" => {
let Some(dl) = &file.downloads else { continue };
if file.executable {
executables.push(path.clone());
}
if !http::file_ok(&path, Some(&dl.raw.sha1), Some(dl.raw.size), true) {
downloads.push(Download::new(dl.raw.url.clone(), path, Some(dl.raw.sha1.clone()), Some(dl.raw.size)));
}
}
"link" => {
if let Some(target) = &file.target {
links.push((path, target.clone()));
}
}
_ => {}
}
}
http::download_all(client, downloads, concurrency, Stage::Java, reporter).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for exe in executables {
if let Ok(meta) = std::fs::metadata(&exe) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&exe, perms).ok();
}
}
for (path, target) in links {
std::fs::remove_file(&path).ok();
std::os::unix::fs::symlink(target, &path).ok();
}
}
#[cfg(not(unix))]
let _ = (executables, links);
std::fs::write(&marker, &entry.manifest.sha1)?;
Ok(dir)
}
/// Best-effort `java -version` parse for a custom Java path.
pub async fn detect_major(java: &Path) -> Option<u32> {
let mut cmd = tokio::process::Command::new(sibling_exe(java, false));
cmd.arg("-version");
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
let out = cmd.output().await.ok()?;
let text = String::from_utf8_lossy(&out.stderr).to_string() + &String::from_utf8_lossy(&out.stdout);
parse_java_version(&text)
}
pub fn parse_java_version(text: &str) -> Option<u32> {
let start = text.find('"')? + 1;
let rest = &text[start..];
let ver = &rest[..rest.find('"')?];
let mut parts = ver.split(['.', '_', '-', '+']);
let first: u32 = parts.next()?.parse().ok()?;
if first == 1 { parts.next()?.parse().ok() } else { Some(first) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_versions() {
assert_eq!(parse_java_version("java version \"1.8.0_381\""), Some(8));
assert_eq!(parse_java_version("openjdk version \"21.0.2\" 2024-01-16"), Some(21));
assert_eq!(parse_java_version("openjdk version \"17\" 2021-09-14"), Some(17));
}
}
+387
View File
@@ -0,0 +1,387 @@
//! Building the `java ...` command line and starting the game.
use crate::install::Installed;
use crate::paths::Layout;
use crate::rules::{self, Env};
use crate::version::Arg;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum GcPreset {
/// Let the JVM decide.
Default,
/// Tuned G1 (Aikar-style) — smooth frame times on most machines.
#[default]
G1,
/// ZGC — lowest pauses, needs Java 17+ and a bit more RAM.
Zgc,
}
#[derive(Debug, Clone)]
pub struct Auth {
pub username: String,
/// Dashed or undashed UUID.
pub uuid: String,
pub access_token: String,
/// "msa" for Microsoft accounts, "legacy" for offline.
pub user_type: String,
pub xuid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub auth: Auth,
pub game_dir: PathBuf,
pub memory_min_mb: u32,
pub memory_max_mb: u32,
pub gc: GcPreset,
pub extra_jvm_args: Vec<String>,
pub resolution: Option<(u32, u32)>,
pub fullscreen: bool,
/// Join this server right after the game starts.
pub join_server: Option<(String, u16)>,
pub version_label: String,
pub launcher_name: String,
pub launcher_version: String,
}
/// Split a user-entered argument string, honouring quotes.
pub fn split_args(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut has_token = false;
for c in s.chars() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), c) => cur.push(c),
(None, '"') | (None, '\'') => {
quote = Some(c);
has_token = true;
}
(None, c) if c.is_whitespace() => {
if has_token || !cur.is_empty() {
out.push(std::mem::take(&mut cur));
has_token = false;
}
}
(None, c) => cur.push(c),
}
}
if has_token || !cur.is_empty() {
out.push(cur);
}
out
}
pub fn gc_args(preset: GcPreset, java_major: u32) -> Vec<String> {
let g1 = [
"-XX:+UseG1GC",
"-XX:+ParallelRefProcEnabled",
"-XX:MaxGCPauseMillis=200",
"-XX:+UnlockExperimentalVMOptions",
"-XX:+DisableExplicitGC",
"-XX:+AlwaysPreTouch",
"-XX:G1NewSizePercent=30",
"-XX:G1MaxNewSizePercent=40",
"-XX:G1HeapRegionSize=8M",
"-XX:G1ReservePercent=20",
"-XX:G1HeapWastePercent=5",
"-XX:G1MixedGCCountTarget=4",
"-XX:InitiatingHeapOccupancyPercent=15",
"-XX:G1MixedGCLiveThresholdPercent=90",
"-XX:G1RSetUpdatingPauseTimePercent=5",
"-XX:SurvivorRatio=32",
"-XX:+PerfDisableSharedMem",
"-XX:MaxTenuringThreshold=1",
];
match preset {
GcPreset::Default => vec![],
GcPreset::Zgc if java_major >= 17 => {
let mut v = vec!["-XX:+UseZGC".to_string()];
// Generational ZGC: opt-in on 21-22, default (flag obsolete) from 23.
if (21..23).contains(&java_major) {
v.push("-XX:+ZGenerational".into());
}
v
}
_ => g1.iter().map(|s| s.to_string()).collect(),
}
}
fn substitute(arg: &str, vars: &HashMap<&str, String>) -> String {
let mut out = String::with_capacity(arg.len());
let mut rest = arg;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
match after.find('}') {
Some(end) => {
let key = &after[..end];
match vars.get(key) {
Some(v) => out.push_str(v),
None => {
out.push_str("${");
out.push_str(key);
out.push('}');
}
}
rest = &after[end + 1..];
}
None => {
out.push_str(&rest[start..]);
rest = "";
}
}
}
out.push_str(rest);
out
}
fn expand(args: &[Arg], env: &Env, vars: &HashMap<&str, String>) -> Vec<String> {
let mut out = Vec::new();
for arg in args {
match arg {
Arg::Plain(s) => out.push(substitute(s, vars)),
Arg::Ruled { rules: r, value } => {
if rules::allowed(r, env) {
out.extend(value.values().iter().map(|s| substitute(s, vars)));
}
}
}
}
out
}
#[derive(Debug, Clone)]
pub struct Command {
pub java: PathBuf,
pub args: Vec<String>,
pub cwd: PathBuf,
}
pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Command {
let v = &installed.version;
let sep = if cfg!(windows) { ";" } else { ":" };
let classpath = installed
.classpath(layout)
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(sep);
let quick_play = v.supports_quick_play();
let env = Env::current()
.with_feature("has_custom_resolution", opts.resolution.is_some())
.with_feature("is_quick_play_multiplayer", quick_play && opts.join_server.is_some());
let uuid = opts.auth.uuid.replace('-', "");
let (w, h) = opts.resolution.unwrap_or((854, 480));
let mut vars: HashMap<&str, String> = HashMap::new();
vars.insert("auth_player_name", opts.auth.username.clone());
vars.insert("version_name", v.id.clone());
vars.insert("game_directory", opts.game_dir.to_string_lossy().into_owned());
vars.insert("assets_root", installed.assets_root.to_string_lossy().into_owned());
vars.insert("game_assets", installed.game_assets.to_string_lossy().into_owned());
vars.insert("assets_index_name", installed.assets_index.clone());
vars.insert("auth_uuid", uuid.clone());
vars.insert("auth_access_token", opts.auth.access_token.clone());
vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid));
vars.insert("clientid", String::new());
vars.insert("auth_xuid", opts.auth.xuid.clone().unwrap_or_default());
vars.insert("user_type", opts.auth.user_type.clone());
vars.insert("user_properties", "{}".into());
vars.insert("version_type", opts.version_label.clone());
vars.insert("resolution_width", w.to_string());
vars.insert("resolution_height", h.to_string());
vars.insert("natives_directory", installed.natives_dir.to_string_lossy().into_owned());
vars.insert("launcher_name", opts.launcher_name.clone());
vars.insert("launcher_version", opts.launcher_version.clone());
vars.insert("classpath", classpath);
vars.insert("classpath_separator", sep.into());
vars.insert("library_directory", layout.libraries().to_string_lossy().into_owned());
if let Some((host, port)) = &opts.join_server {
vars.insert("quickPlayMultiplayer", format!("{host}:{port}"));
}
let mut args = Vec::new();
args.push(format!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb)));
args.push(format!("-Xmx{}M", opts.memory_max_mb));
args.extend(gc_args(opts.gc, installed.java_major));
args.push("-Dlog4j2.formatMsgNoLookups=true".into());
if let Some(log) = &installed.logging_arg {
args.push(log.clone());
}
args.extend(opts.extra_jvm_args.iter().cloned());
match &v.arguments {
Some(a) if !a.jvm.is_empty() => args.extend(expand(&a.jvm, &env, &vars)),
_ => {
args.push(format!("-Djava.library.path={}", vars["natives_directory"]));
args.push("-cp".into());
args.push(vars["classpath"].clone());
}
}
args.push(v.main_class.clone().unwrap_or_else(|| "net.minecraft.client.main.Main".into()));
let mut game_args = match (&v.arguments, &v.minecraft_arguments) {
(_, Some(legacy)) if v.arguments.as_ref().map(|a| a.game.is_empty()).unwrap_or(true) => {
legacy.split_whitespace().map(|s| substitute(s, &vars)).collect()
}
(Some(a), _) => expand(&a.game, &env, &vars),
_ => vec![],
};
let has = |args: &[String], flag: &str| args.iter().any(|a| a == flag);
if let Some((w, h)) = opts.resolution {
if !has(&game_args, "--width") {
game_args.extend(["--width".into(), w.to_string(), "--height".into(), h.to_string()]);
}
}
if opts.fullscreen && !has(&game_args, "--fullscreen") {
game_args.push("--fullscreen".into());
}
if let Some((host, port)) = &opts.join_server {
if !quick_play {
game_args.extend(["--server".into(), host.clone(), "--port".into(), port.to_string()]);
}
}
args.extend(game_args);
Command { java: installed.java.clone(), args, cwd: opts.game_dir.clone() }
}
/// Java 9+ can read arguments from a file, which sidesteps Windows'
/// 32k command-line limit on huge modpacks.
fn write_argfile(path: &Path, args: &[String]) -> Result<()> {
let body = args
.iter()
.map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\"")))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, body)?;
Ok(())
}
pub fn spawn(cmd: &Command, java_major: u32) -> Result<tokio::process::Child> {
std::fs::create_dir_all(&cmd.cwd)?;
let mut proc = tokio::process::Command::new(&cmd.java);
let total_len: usize = cmd.args.iter().map(|a| a.len() + 3).sum();
if java_major >= 9 && total_len > 8000 {
let argfile = cmd.cwd.join(".scopenet").join("launch.args");
std::fs::create_dir_all(argfile.parent().unwrap())?;
write_argfile(&argfile, &cmd.args)?;
proc.arg(format!("@{}", argfile.display()));
} else {
proc.args(&cmd.args);
}
proc.current_dir(&cmd.cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(windows)]
proc.creation_flags(0x0800_0000);
proc.spawn().with_context(|| format!("starting {}", cmd.java.display()))
}
/// Hide the access token when showing the command to the user.
pub fn redact(args: &[String], token: &str) -> Vec<String> {
if token.len() < 8 {
return args.to_vec();
}
args.iter().map(|a| a.replace(token, "********")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::version::VersionJson;
#[test]
fn splits_quoted_args() {
assert_eq!(split_args(r#"-Xss2M -Dfoo="a b" '' -Dx=1"#), vec!["-Xss2M", "-Dfoo=a b", "", "-Dx=1"]);
assert!(split_args(" ").is_empty());
}
#[test]
fn substitutes_vars() {
let mut vars = HashMap::new();
vars.insert("a", "1".to_string());
assert_eq!(substitute("x${a}y${b}z", &vars), "x1y${b}z");
}
#[test]
fn zgc_falls_back_on_java8() {
assert!(gc_args(GcPreset::Zgc, 8).contains(&"-XX:+UseG1GC".to_string()));
assert_eq!(gc_args(GcPreset::Zgc, 21), vec!["-XX:+UseZGC", "-XX:+ZGenerational"]);
assert_eq!(gc_args(GcPreset::Zgc, 25), vec!["-XX:+UseZGC"]);
}
fn installed(json: &str) -> Installed {
Installed {
version: serde_json::from_str::<VersionJson>(json).unwrap(),
java: "java".into(),
java_major: 17,
client_jar: "/v/c.jar".into(),
natives_dir: "/n".into(),
libraries: vec![],
assets_root: "/a".into(),
game_assets: "/a".into(),
assets_index: "5".into(),
logging_arg: None,
}
}
fn opts(join: bool) -> LaunchOptions {
LaunchOptions {
auth: Auth { username: "Steve".into(), uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(), access_token: "0".into(), user_type: "legacy".into(), xuid: None },
game_dir: "/g".into(),
memory_min_mb: 1024,
memory_max_mb: 4096,
gc: GcPreset::Default,
extra_jvm_args: vec!["-Dcustom=1".into()],
resolution: Some((1280, 720)),
fullscreen: false,
join_server: join.then(|| ("play.example.net".to_string(), 25565)),
version_label: "ScopeNet".into(),
launcher_name: "scopenet".into(),
launcher_version: "0.1.0".into(),
}
}
#[test]
fn builds_modern_command_with_quick_play() {
let json = r#"{"id":"1.20.1","mainClass":"net.minecraft.client.main.Main","arguments":{
"game":["--username","${auth_player_name}","--uuid","${auth_uuid}","--versionType","${version_type}",
{"rules":[{"action":"allow","features":{"has_custom_resolution":true}}],"value":["--width","${resolution_width}","--height","${resolution_height}"]},
{"rules":[{"action":"allow","features":{"is_quick_play_multiplayer":true}}],"value":["--quickPlayMultiplayer","${quickPlayMultiplayer}"]}],
"jvm":["-Djava.library.path=${natives_directory}","-cp","${classpath}"]}}"#;
let layout = Layout::new("/root");
let cmd = build(&installed(json), &layout, &opts(true));
let a = cmd.args.join(" ");
assert!(a.starts_with("-Xms1024M -Xmx4096M"));
assert!(a.contains("-Dcustom=1"));
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
assert!(a.contains("--width 1280 --height 720"));
assert!(a.contains("--quickPlayMultiplayer play.example.net:25565"));
assert!(!a.contains("--server"));
assert!(a.contains("--versionType ScopeNet"));
assert!(a.contains("-Djava.library.path=/n"));
}
#[test]
fn builds_legacy_command_with_server_flag() {
let json = r#"{"id":"1.12.2","mainClass":"net.minecraft.launchwrapper.Launch",
"minecraftArguments":"--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker"}"#;
let cmd = build(&installed(json), &Layout::new("/root"), &opts(true));
let a = cmd.args.join(" ");
assert!(a.contains("-Djava.library.path=/n -cp /v/c.jar net.minecraft.launchwrapper.Launch --username Steve"));
assert!(a.contains("--width 1280 --height 720"));
assert!(a.ends_with("--server play.example.net --port 25565"));
}
}
+28
View File
@@ -0,0 +1,28 @@
//! ScopeNet launcher engine.
//!
//! The Tauri app is a thin UI shell around this crate: everything that
//! downloads, installs or launches Minecraft lives here so it can be unit
//! tested without a window.
pub mod assets;
pub mod http;
pub mod install;
pub mod java;
pub mod launch;
pub mod libraries;
pub mod loaders;
pub mod maven;
pub mod meta;
pub mod msa;
pub mod options;
pub mod paths;
pub mod ping;
pub mod progress;
pub mod rules;
pub mod servers_dat;
pub mod sync;
pub mod sys;
pub mod version;
pub use paths::Layout;
pub use progress::{Event, Reporter, Stage};
+216
View File
@@ -0,0 +1,216 @@
//! Turning version-JSON libraries into files on disk + a classpath.
use crate::http::Download;
use crate::maven::Coord;
use crate::rules::{self, Env};
use crate::version::Library;
use anyhow::Result;
use std::io::Read;
use std::path::{Path, PathBuf};
pub const MOJANG_LIBRARIES: &str = "https://libraries.minecraft.net/";
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedLib {
pub name: String,
/// Path relative to the libraries dir (`/`-separated).
pub path: String,
/// `None` when the file must come from somewhere else (e.g. a Forge installer).
pub url: Option<String>,
pub sha1: Option<String>,
pub size: Option<u64>,
/// Goes on the classpath.
pub classpath: bool,
/// Contains native libraries that must be extracted.
pub native: bool,
pub exclude: Vec<String>,
}
impl ResolvedLib {
pub fn file(&self, libraries_dir: &Path) -> PathBuf {
libraries_dir.join(&self.path)
}
pub fn download(&self, libraries_dir: &Path) -> Option<Download> {
self.url.as_ref().map(|url| Download::new(url.clone(), self.file(libraries_dir), self.sha1.clone(), self.size))
}
}
fn join_url(base: &str, path: &str) -> String {
if base.ends_with('/') { format!("{base}{path}") } else { format!("{base}/{path}") }
}
pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
let mut out = Vec::new();
for lib in libs {
if let Some(rules) = &lib.rules {
if !rules::allowed(rules, env) {
continue;
}
}
let Some(coord) = Coord::parse(&lib.name) else { continue };
let exclude = lib.extract.as_ref().map(|e| e.exclude.clone()).unwrap_or_default();
// Legacy natives (LWJGL 2 / pre-1.19): a separate classifier jar
// that is extracted but never put on the classpath.
if let Some(natives) = &lib.natives {
if let Some(classifier) = natives.get(env.os) {
let classifier = classifier.replace("${arch}", env.bits());
let from_downloads = lib
.downloads
.as_ref()
.and_then(|d| d.classifiers.as_ref())
.and_then(|c| c.get(&classifier));
let native_coord = coord.with_classifier(&classifier);
let (path, url, sha1, size) = match from_downloads {
Some(a) => (
a.path.clone().unwrap_or_else(|| native_coord.path()),
Some(a.url.clone()).filter(|u| !u.is_empty()),
a.sha1.clone(),
a.size,
),
None => {
let p = native_coord.path();
let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES);
(p.clone(), Some(join_url(base, &p)), None, None)
}
};
out.push(ResolvedLib {
name: format!("{}:{}", lib.name, classifier),
path,
url,
sha1,
size,
classpath: false,
native: true,
exclude: exclude.clone(),
});
}
// A natives-map library only has a main artifact if downloads.artifact exists.
let Some(artifact) = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) else { continue };
out.push(ResolvedLib {
name: lib.name.clone(),
path: artifact.path.clone().unwrap_or_else(|| coord.path()),
url: Some(artifact.url.clone()).filter(|u| !u.is_empty()),
sha1: artifact.sha1.clone(),
size: artifact.size,
classpath: true,
native: false,
exclude,
});
continue;
}
let is_native_jar = coord.classifier.as_deref().is_some_and(|c| c.starts_with("natives-"));
let resolved = match lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) {
Some(a) => ResolvedLib {
name: lib.name.clone(),
path: a.path.clone().filter(|p| !p.is_empty()).unwrap_or_else(|| coord.path()),
url: Some(a.url.clone()).filter(|u| !u.is_empty()),
sha1: a.sha1.clone(),
size: a.size,
classpath: true,
native: is_native_jar,
exclude,
},
None => {
if lib.downloads.is_some() {
// `downloads` present but no artifact: classifier-only entry we don't need.
continue;
}
let p = coord.path();
let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES);
ResolvedLib {
name: lib.name.clone(),
path: p.clone(),
url: Some(join_url(base, &p)),
sha1: lib.sha1.clone(),
size: lib.size,
classpath: true,
native: is_native_jar,
exclude,
}
}
};
out.push(resolved);
}
out
}
fn is_native_file(name: &str) -> bool {
let n = name.to_ascii_lowercase();
n.ends_with(".dll") || n.ends_with(".so") || n.ends_with(".dylib") || n.ends_with(".jnilib")
}
/// Extract native libraries (flattened) into `dest`.
pub fn extract_natives(libs: &[ResolvedLib], libraries_dir: &Path, dest: &Path) -> Result<()> {
std::fs::create_dir_all(dest)?;
for lib in libs.iter().filter(|l| l.native) {
let path = lib.file(libraries_dir);
let Ok(file) = std::fs::File::open(&path) else {
tracing::warn!("missing native jar {}", path.display());
continue;
};
let mut zip = zip::ZipArchive::new(file)?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
if name.starts_with("META-INF") || lib.exclude.iter().any(|e| name.starts_with(e.as_str())) {
continue;
}
if !is_native_file(&name) {
continue;
}
let file_name = name.rsplit('/').next().unwrap_or(&name);
// Skip natives for other architectures (LWJGL 3.3 ships several).
let lower = name.to_ascii_lowercase();
if cfg!(target_arch = "x86_64") && (lower.contains("arm64") || lower.contains("aarch64") || lower.contains("/x86/")) {
continue;
}
let target = dest.join(file_name);
if target.exists() && std::fs::metadata(&target).map(|m| m.len() == entry.size()).unwrap_or(false) {
continue;
}
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
std::fs::write(&target, buf)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::version::VersionJson;
use std::collections::HashMap;
#[test]
fn resolves_legacy_natives_and_maven_libs() {
let v: VersionJson = serde_json::from_str(
r#"{"id":"x","libraries":[
{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209",
"natives":{"linux":"natives-linux","windows":"natives-windows-${arch}"},
"extract":{"exclude":["META-INF/"]},
"downloads":{"classifiers":{"natives-windows-64":{"path":"p/w64.jar","sha1":"s","size":5,"url":"https://l/w64.jar"}}}},
{"name":"net.fabricmc:intermediary:1.20.1","url":"https://maven.fabricmc.net/"},
{"name":"com.mojang:only-mac:1","rules":[{"action":"allow","os":{"name":"osx"}}]},
{"name":"net.minecraftforge:forge:1.20.1-47.2.0:universal","downloads":{"artifact":{"path":"net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-universal.jar","url":"","sha1":"u","size":9}}}
]}"#,
)
.unwrap();
let env = Env { os: "windows", arch: "x86_64", features: HashMap::new() };
let libs = resolve(&v.libraries, &env);
assert_eq!(libs.len(), 3);
assert!(libs[0].native && !libs[0].classpath);
assert_eq!(libs[0].path, "p/w64.jar");
assert_eq!(
libs[1].url.as_deref(),
Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar")
);
assert!(libs[2].url.is_none(), "empty url = bundled in installer");
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Fabric and Quilt both publish a ready-made launcher profile.
use crate::http;
use crate::meta::{FABRIC_META, QUILT_META};
use crate::paths::Layout;
use crate::version::VersionJson;
use anyhow::{Context, Result};
use scopenet_shared::Loader;
pub async fn profile(client: &reqwest::Client, layout: &Layout, loader: Loader, mc: &str, loader_version: &str) -> Result<VersionJson> {
let url = match loader {
Loader::Quilt => format!("{QUILT_META}/versions/loader/{mc}/{loader_version}/profile/json"),
_ => format!("{FABRIC_META}/versions/loader/{mc}/{loader_version}/profile/json"),
};
let cache = layout.cache().join("profiles").join(format!("{}-{mc}-{loader_version}.json", loader.as_str()));
let v: VersionJson = http::get_json_cached(client, &url, &cache)
.await
.with_context(|| format!("fetching {} {loader_version} profile for {mc}", loader.as_str()))?;
Ok(v)
}
+360
View File
@@ -0,0 +1,360 @@
//! Forge / NeoForge. Their installers ship a version JSON plus a list of
//! "processors" (small Java programs that patch the game jar). We replay
//! exactly what the official installer does, headlessly.
use crate::http::{self, Download};
use crate::libraries;
use crate::maven::Coord;
use crate::meta::forge_installer_url;
use crate::paths::Layout;
use crate::progress::{self, Event, Reporter, Stage};
use crate::rules::Env;
use crate::version::{Library, VersionJson};
use anyhow::{anyhow, bail, Context, Result};
use scopenet_shared::Loader;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, BufReader};
pub struct ForgeInstall<'a> {
pub client: &'a reqwest::Client,
pub layout: &'a Layout,
pub loader: Loader,
pub mc: &'a str,
/// Maven version, e.g. `1.20.1-47.2.0` (Forge) or `21.1.77` (NeoForge).
pub version: &'a str,
/// Console Java executable used to run processors.
pub java: &'a Path,
pub client_jar: &'a Path,
pub concurrency: usize,
pub reporter: &'a Reporter,
}
#[derive(Deserialize)]
struct InstallProfile {
#[serde(default = "default_json")]
json: String,
#[serde(default)]
data: HashMap<String, DataEntry>,
#[serde(default)]
processors: Vec<Processor>,
#[serde(default)]
libraries: Vec<Library>,
}
fn default_json() -> String {
"/version.json".into()
}
#[derive(Deserialize)]
struct DataEntry {
client: String,
}
#[derive(Deserialize)]
struct Processor {
#[serde(default)]
sides: Option<Vec<String>>,
jar: String,
#[serde(default)]
classpath: Vec<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
outputs: HashMap<String, String>,
}
type Zip = zip::ZipArchive<std::fs::File>;
fn read_entry(zip: &mut Zip, name: &str) -> Result<Vec<u8>> {
let mut entry = zip.by_name(name.trim_start_matches('/')).with_context(|| format!("installer is missing {name}"))?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
Ok(buf)
}
fn extract_entry(zip: &mut Zip, name: &str, dest: &Path) -> Result<bool> {
let Ok(mut entry) = zip.by_name(name.trim_start_matches('/')) else { return Ok(false) };
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
std::fs::write(dest, buf)?;
Ok(true)
}
fn marker_path(layout: &Layout, id: &str) -> PathBuf {
layout.version_dir(id).join(".scopenet-installed")
}
pub async fn install(ctx: ForgeInstall<'_>) -> Result<VersionJson> {
let label = if ctx.loader == Loader::NeoForge { "NeoForge" } else { "Forge" };
progress::stage(ctx.reporter, Stage::Loader, format!("Installing {label} {}", ctx.version));
let installer = ctx.layout.cache().join("installers").join(format!("{}-{}-installer.jar", ctx.loader.as_str(), ctx.version));
if !installer.exists() {
let url = forge_installer_url(ctx.loader, ctx.version);
http::download_one(ctx.client, &Download::new(url, installer.clone(), None, None), &|_| {}).await?;
}
let mut zip = zip::ZipArchive::new(std::fs::File::open(&installer)?).context("opening installer")?;
let profile_raw: serde_json::Value = serde_json::from_slice(&read_entry(&mut zip, "install_profile.json")?)?;
if profile_raw.get("versionInfo").is_some() {
return install_legacy(&ctx, &mut zip, profile_raw);
}
let profile: InstallProfile = serde_json::from_value(profile_raw)?;
let mut version: VersionJson = serde_json::from_slice(&read_entry(&mut zip, &profile.json)?)?;
let libs_dir = ctx.layout.libraries();
// Files bundled in the installer's maven/ folder.
let names: Vec<String> = zip.file_names().filter(|n| n.starts_with("maven/") && !n.ends_with('/')).map(String::from).collect();
for name in names {
let dest = libs_dir.join(name.trim_start_matches("maven/"));
if !dest.exists() {
extract_entry(&mut zip, &name, &dest)?;
}
}
// Libraries needed by the processors and by the game.
let env = Env::current();
let mut all_libs = libraries::resolve(&profile.libraries, &env);
all_libs.extend(libraries::resolve(&version.libraries, &env));
let mut downloads = Vec::new();
for lib in &all_libs {
let file = lib.file(&libs_dir);
if http::file_ok(&file, lib.sha1.as_deref(), lib.size, false) {
continue;
}
match lib.download(&libs_dir) {
Some(dl) => downloads.push(dl),
None => {
extract_entry(&mut zip, &format!("maven/{}", lib.path), &file)?;
}
}
}
downloads.dedup_by(|a, b| a.dest == b.dest);
http::download_all(ctx.client, downloads, ctx.concurrency, Stage::Loader, ctx.reporter).await?;
let marker = marker_path(ctx.layout, &version.id);
if std::fs::read_to_string(&marker).ok().as_deref() != Some(ctx.version) {
run_processors(&ctx, &mut zip, &profile, &installer).await?;
std::fs::create_dir_all(marker.parent().unwrap())?;
std::fs::write(&marker, ctx.version)?;
}
version.inherits_from = Some(ctx.mc.to_string());
save_version(ctx.layout, &version)?;
Ok(version)
}
fn save_version(layout: &Layout, version: &VersionJson) -> Result<()> {
let path = layout.version_json(&version.id);
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, serde_json::to_vec_pretty(version)?)?;
Ok(())
}
/// Forge ≤ 1.12: `install_profile.json` holds `install` + `versionInfo`.
fn install_legacy(ctx: &ForgeInstall<'_>, zip: &mut Zip, raw: serde_json::Value) -> Result<VersionJson> {
let info = raw.get("versionInfo").cloned().unwrap_or_default();
// Old profiles point at the long-dead files.minecraftforge.net/maven.
let fixed = serde_json::to_string(&info)?
.replace("http://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/")
.replace("https://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/");
let mut version: VersionJson = serde_json::from_str(&fixed)?;
let install = raw.get("install").ok_or_else(|| anyhow!("legacy installer missing 'install'"))?;
let coord = install.get("path").and_then(|v| v.as_str()).and_then(Coord::parse).ok_or_else(|| anyhow!("bad install.path"))?;
let file_path = install.get("filePath").and_then(|v| v.as_str()).ok_or_else(|| anyhow!("bad install.filePath"))?;
let dest = ctx.layout.libraries().join(coord.path());
if !dest.exists() {
extract_entry(zip, file_path, &dest)?;
}
version.inherits_from = Some(ctx.mc.to_string());
save_version(ctx.layout, &version)?;
Ok(version)
}
fn resolve_data_value(value: &str, libs_dir: &Path, tmp: &Path, zip: &mut Zip) -> Result<String> {
if value.starts_with('[') && value.ends_with(']') {
let coord = Coord::parse(value).ok_or_else(|| anyhow!("bad coordinate {value}"))?;
return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned());
}
if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') {
return Ok(value[1..value.len() - 1].to_string());
}
if value.starts_with('/') {
let dest = tmp.join(value.trim_start_matches('/'));
extract_entry(zip, value, &dest)?;
return Ok(dest.to_string_lossy().into_owned());
}
Ok(value.to_string())
}
fn resolve_arg(arg: &str, data: &HashMap<String, String>, libs_dir: &Path) -> Result<String> {
if arg.starts_with('[') && arg.ends_with(']') {
let coord = Coord::parse(arg).ok_or_else(|| anyhow!("bad coordinate {arg}"))?;
return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned());
}
if arg.starts_with('{') && arg.ends_with('}') {
let key = &arg[1..arg.len() - 1];
return data.get(key).cloned().ok_or_else(|| anyhow!("installer references unknown data key {key}"));
}
if arg.len() >= 2 && arg.starts_with('\'') && arg.ends_with('\'') {
return Ok(arg[1..arg.len() - 1].to_string());
}
let mut out = arg.to_string();
for (k, v) in data {
let needle = format!("{{{k}}}");
if out.contains(&needle) {
out = out.replace(&needle, v);
}
}
Ok(out)
}
pub fn jar_main_class(jar: &Path) -> Result<String> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).with_context(|| format!("opening {}", jar.display()))?)?;
let manifest = String::from_utf8_lossy(&read_entry(&mut zip, "META-INF/MANIFEST.MF")?).into_owned();
parse_main_class(&manifest).ok_or_else(|| anyhow!("{} has no Main-Class", jar.display()))
}
fn parse_main_class(manifest: &str) -> Option<String> {
// Manifest lines wrap at 72 bytes; continuation lines start with a space.
let mut unwrapped = String::new();
for line in manifest.lines() {
if let Some(rest) = line.strip_prefix(' ') {
unwrapped.push_str(rest);
} else {
unwrapped.push('\n');
unwrapped.push_str(line.trim_end_matches('\r'));
}
}
unwrapped.lines().find_map(|l| l.strip_prefix("Main-Class:").map(|v| v.trim().to_string()))
}
async fn run_processors(ctx: &ForgeInstall<'_>, zip: &mut Zip, profile: &InstallProfile, installer: &Path) -> Result<()> {
let libs_dir = ctx.layout.libraries();
let tmp = ctx.layout.cache().join("forge-tmp").join(ctx.version);
std::fs::create_dir_all(&tmp)?;
let mut data = HashMap::new();
for (key, entry) in &profile.data {
data.insert(key.clone(), resolve_data_value(&entry.client, &libs_dir, &tmp, zip)?);
}
data.insert("SIDE".into(), "client".into());
data.insert("MINECRAFT_JAR".into(), ctx.client_jar.to_string_lossy().into_owned());
data.insert("MINECRAFT_VERSION".into(), ctx.mc.to_string());
data.insert("ROOT".into(), ctx.layout.root.to_string_lossy().into_owned());
data.insert("INSTALLER".into(), installer.to_string_lossy().into_owned());
data.insert("LIBRARY_DIR".into(), libs_dir.to_string_lossy().into_owned());
let sep = if cfg!(windows) { ";" } else { ":" };
let processors: Vec<&Processor> = profile
.processors
.iter()
.filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true))
.collect();
let total = processors.len() as u32;
for (i, proc) in processors.into_iter().enumerate() {
ctx.reporter.as_ref()(Event::Progress {
stage: Stage::Loader,
done: i as u64,
total: total as u64,
files_done: i as u32,
files_total: total,
});
// Skip processors whose outputs already exist with the right hash.
if !proc.outputs.is_empty() {
let mut all_ok = true;
for (k, v) in &proc.outputs {
let path = resolve_arg(k, &data, &libs_dir)?;
let sha = resolve_arg(v, &data, &libs_dir)?;
if !http::file_ok(Path::new(&path), Some(&sha), None, true) {
all_ok = false;
break;
}
}
if all_ok {
continue;
}
}
let jar = libs_dir.join(Coord::parse(&proc.jar).ok_or_else(|| anyhow!("bad processor jar {}", proc.jar))?.path());
let main_class = jar_main_class(&jar)?;
let mut cp = vec![jar.to_string_lossy().into_owned()];
for c in &proc.classpath {
let coord = Coord::parse(c).ok_or_else(|| anyhow!("bad classpath entry {c}"))?;
cp.push(libs_dir.join(coord.path()).to_string_lossy().into_owned());
}
let args = proc.args.iter().map(|a| resolve_arg(a, &data, &libs_dir)).collect::<Result<Vec<_>>>()?;
let mut cmd = tokio::process::Command::new(ctx.java);
cmd.arg("-cp").arg(cp.join(sep)).arg(&main_class).args(&args);
cmd.current_dir(&tmp);
cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).kill_on_drop(true);
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
let mut child = cmd.spawn().with_context(|| format!("starting processor {main_class}"))?;
let tail = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<String>::new()));
let pump = |stream: Option<Box<dyn tokio::io::AsyncRead + Unpin + Send>>| {
let reporter = ctx.reporter.clone();
let tail = tail.clone();
async move {
let Some(stream) = stream else { return };
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
{
let mut t = tail.lock().unwrap();
t.push_back(line.clone());
if t.len() > 30 {
t.pop_front();
}
}
reporter(Event::Log { line });
}
}
};
let out = child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
let err = child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
let (status, _, _) = tokio::join!(child.wait(), pump(out), pump(err));
let status = status?;
if !status.success() {
let tail = tail.lock().unwrap().iter().cloned().collect::<Vec<_>>().join("\n");
bail!("{main_class} failed ({status}):\n{tail}");
}
}
std::fs::remove_dir_all(&tmp).ok();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_main_class_with_wrapping() {
let mf = "Manifest-Version: 1.0\r\nMain-Class: net.minecraftforge.binarypatcher.Consol\r\n eTool\r\nOther: x\r\n";
assert_eq!(parse_main_class(mf).as_deref(), Some("net.minecraftforge.binarypatcher.ConsoleTool"));
}
#[test]
fn resolves_processor_args() {
let libs = Path::new("/libs");
let mut data = HashMap::new();
data.insert("MC_SLIM".to_string(), "/libs/slim.jar".to_string());
assert_eq!(resolve_arg("{MC_SLIM}", &data, libs).unwrap(), "/libs/slim.jar");
assert_eq!(resolve_arg("--flag", &data, libs).unwrap(), "--flag");
assert_eq!(
resolve_arg("[net.minecraft:client:1.20.1:srg]", &data, libs).unwrap(),
Path::new("/libs").join("net/minecraft/client/1.20.1/client-1.20.1-srg.jar").to_string_lossy()
);
assert!(resolve_arg("{MISSING}", &data, libs).is_err());
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Mod loader installation. Each loader produces a child version JSON that
//! is merged on top of vanilla.
pub mod fabric;
pub mod forge;
+74
View File
@@ -0,0 +1,74 @@
/// A Maven coordinate: `group:artifact:version[:classifier][@ext]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Coord {
pub group: String,
pub artifact: String,
pub version: String,
pub classifier: Option<String>,
pub ext: String,
}
impl Coord {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('[').trim_end_matches(']');
let (main, ext) = match s.split_once('@') {
Some((m, e)) => (m, e.to_string()),
None => (s, "jar".to_string()),
};
let parts: Vec<&str> = main.split(':').collect();
if parts.len() < 3 {
return None;
}
Some(Self {
group: parts[0].into(),
artifact: parts[1].into(),
version: parts[2].into(),
classifier: parts.get(3).map(|c| c.to_string()),
ext,
})
}
/// Relative repository path (`/`-separated).
pub fn path(&self) -> String {
let classifier = self.classifier.as_ref().map(|c| format!("-{c}")).unwrap_or_default();
format!(
"{}/{}/{}/{}-{}{}.{}",
self.group.replace('.', "/"),
self.artifact,
self.version,
self.artifact,
self.version,
classifier,
self.ext
)
}
/// Identity used to de-duplicate libraries (version-less).
pub fn key(&self) -> String {
match &self.classifier {
Some(c) => format!("{}:{}:{}", self.group, self.artifact, c),
None => format!("{}:{}", self.group, self.artifact),
}
}
pub fn with_classifier(&self, classifier: &str) -> Self {
Self { classifier: Some(classifier.to_string()), ..self.clone() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_coords() {
let c = Coord::parse("net.fabricmc:fabric-loader:0.16.9").unwrap();
assert_eq!(c.path(), "net/fabricmc/fabric-loader/0.16.9/fabric-loader-0.16.9.jar");
let c = Coord::parse("de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip").unwrap();
assert_eq!(c.path(), "de/oceanlabs/mcp/mcp_config/1.20.1-20230612.114412/mcp_config-1.20.1-20230612.114412.zip");
let c = Coord::parse("[net.minecraft:client:1.20.1-20230612.114412:slim]").unwrap();
assert_eq!(c.path(), "net/minecraft/client/1.20.1-20230612.114412/client-1.20.1-20230612.114412-slim.jar");
assert_eq!(c.key(), "net.minecraft:client:slim");
assert!(Coord::parse("nope").is_none());
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Version metadata from Mojang and the mod-loader projects. Used by the
//! launcher (to install) and by the panel (to populate version pickers).
use crate::http::get_json;
use anyhow::{bail, Context, Result};
use scopenet_shared::Loader;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const MOJANG_MANIFEST: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2";
pub const QUILT_META: &str = "https://meta.quiltmc.org/v3";
pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net";
pub const FORGE_FILES: &str = "https://files.minecraftforge.net/net/minecraftforge/forge";
pub const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases";
pub const NEOFORGE_VERSIONS: &str = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionManifest {
pub latest: Latest,
pub versions: Vec<ManifestVersion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Latest {
pub release: String,
pub snapshot: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ManifestVersion {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub url: String,
#[serde(default)]
pub sha1: Option<String>,
#[serde(default)]
pub release_time: Option<String>,
}
pub async fn mojang_manifest(client: &reqwest::Client) -> Result<VersionManifest> {
get_json(client, MOJANG_MANIFEST).await
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LoaderVersion {
pub version: String,
pub stable: bool,
}
#[derive(Deserialize)]
struct FabricLoaderEntry {
loader: FabricLoaderInfo,
}
#[derive(Deserialize)]
struct FabricLoaderInfo {
version: String,
#[serde(default)]
stable: Option<bool>,
}
#[derive(Deserialize)]
struct NeoVersions {
versions: Vec<String>,
}
#[derive(Deserialize)]
struct ForgePromos {
promos: HashMap<String, String>,
}
/// Map a NeoForge version to the Minecraft version it targets.
/// `21.1.77` → `1.21.1`, `21.0.5` → `1.21`, `26.1.0.3` → `26.1` (new
/// year-based Minecraft numbering).
pub fn neoforge_mc_version(neo: &str) -> Option<String> {
let core = neo.split('-').next()?;
let parts: Vec<&str> = core.split('.').collect();
let major: u32 = parts.first()?.parse().ok()?;
let minor: u32 = parts.get(1)?.parse().ok()?;
if major >= 26 {
let patch: u32 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
return Some(if patch == 0 { format!("{major}.{minor}") } else { format!("{major}.{minor}.{patch}") });
}
Some(if minor == 0 { format!("1.{major}") } else { format!("1.{major}.{minor}") })
}
/// All loader versions for a Minecraft version, newest first.
pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str) -> Result<Vec<LoaderVersion>> {
Ok(match loader {
Loader::Vanilla => vec![],
Loader::Fabric | Loader::Quilt => {
let url = if loader == Loader::Fabric {
format!("{FABRIC_META}/versions/loader/{mc}")
} else {
format!("{QUILT_META}/versions/loader/{mc}")
};
let entries: Vec<FabricLoaderEntry> = get_json(client, &url).await?;
entries
.into_iter()
.map(|e| {
let stable = e.loader.stable.unwrap_or_else(|| !e.loader.version.contains("beta"));
LoaderVersion { version: e.loader.version, stable }
})
.collect()
}
Loader::Forge => {
let all: HashMap<String, Vec<String>> =
get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?;
let promos: ForgePromos = get_json(client, &format!("{FORGE_FILES}/promotions_slim.json"))
.await
.unwrap_or(ForgePromos { promos: HashMap::new() });
let recommended = promos.promos.get(&format!("{mc}-recommended")).map(|v| format!("{mc}-{v}"));
let mut list: Vec<LoaderVersion> = all
.get(mc)
.cloned()
.unwrap_or_default()
.into_iter()
.rev()
.map(|v| LoaderVersion { stable: Some(&v) == recommended.as_ref(), version: v })
.collect();
// Put the recommended build first.
list.sort_by_key(|v| !v.stable);
list
}
Loader::NeoForge => {
let all: NeoVersions = get_json(client, NEOFORGE_VERSIONS).await?;
all.versions
.into_iter()
.rev()
.filter(|v| neoforge_mc_version(v).as_deref() == Some(mc))
.map(|v| LoaderVersion { stable: !v.contains("beta") && !v.contains("alpha"), version: v })
.collect()
}
})
}
/// Turn "latest"/"recommended"/empty into a concrete loader version.
pub async fn resolve_loader_version(
client: &reqwest::Client,
loader: Loader,
mc: &str,
requested: Option<&str>,
) -> Result<Option<String>> {
if loader == Loader::Vanilla {
return Ok(None);
}
if let Some(v) = requested.map(str::trim).filter(|v| !v.is_empty() && *v != "latest" && *v != "recommended") {
return Ok(Some(normalize_forge_version(loader, mc, v)));
}
let versions = loader_versions(client, loader, mc).await.context("fetching loader versions")?;
let pick = versions.iter().find(|v| v.stable).or_else(|| versions.first());
match pick {
Some(v) => Ok(Some(v.version.clone())),
None => bail!("no {} versions available for Minecraft {mc}", loader.as_str()),
}
}
/// Forge versions are stored as `<mc>-<forge>` (the Maven version);
/// accept a bare `47.2.0` too.
pub fn normalize_forge_version(loader: Loader, mc: &str, v: &str) -> String {
if loader == Loader::Forge && !v.contains('-') {
format!("{mc}-{v}")
} else {
v.to_string()
}
}
pub fn forge_installer_url(loader: Loader, version: &str) -> String {
match loader {
Loader::NeoForge => format!("{NEOFORGE_MAVEN}/net/neoforged/neoforge/{version}/neoforge-{version}-installer.jar"),
_ => format!("{FORGE_MAVEN}/net/minecraftforge/forge/{version}/forge-{version}-installer.jar"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neoforge_mapping() {
assert_eq!(neoforge_mc_version("21.1.77").as_deref(), Some("1.21.1"));
assert_eq!(neoforge_mc_version("20.4.237").as_deref(), Some("1.20.4"));
assert_eq!(neoforge_mc_version("21.0.5-beta").as_deref(), Some("1.21"));
assert_eq!(neoforge_mc_version("26.1.0.3").as_deref(), Some("26.1"));
assert_eq!(neoforge_mc_version("26.1.2.1").as_deref(), Some("26.1.2"));
}
#[test]
fn forge_urls() {
assert_eq!(normalize_forge_version(Loader::Forge, "1.20.1", "47.2.0"), "1.20.1-47.2.0");
assert_eq!(
forge_installer_url(Loader::Forge, "1.20.1-47.2.0"),
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar"
);
}
}
+231
View File
@@ -0,0 +1,231 @@
//! Microsoft account sign-in (device-code flow → Xbox Live → Minecraft).
//!
//! Requires an Azure app registration ("client id") that Mojang has approved
//! for the Minecraft API. The admin configures it in the panel.
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::Duration;
const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const SCOPE: &str = "XboxLive.signin offline_access";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
#[serde(default = "default_interval")]
pub interval: u64,
#[serde(default)]
pub message: String,
}
fn default_interval() -> u64 {
5
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MsaSession {
pub refresh_token: String,
pub mc_access_token: String,
/// Unix seconds.
pub mc_expires_at: i64,
pub profile: McProfile,
pub xuid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McProfile {
pub id: String,
pub name: String,
#[serde(default)]
pub skins: Vec<McSkin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McSkin {
pub url: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub variant: Option<String>,
}
pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> {
let resp = client
.post(DEVICE_CODE_URL)
.form(&[("client_id", client_id), ("scope", SCOPE)])
.send()
.await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("Microsoft rejected the sign-in request: {body}");
}
Ok(resp.json().await?)
}
/// Poll until the user finishes signing in, then complete the full chain.
pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code: &DeviceCode) -> Result<MsaSession> {
let mut interval = code.interval.max(1);
let deadline = std::time::Instant::now() + Duration::from_secs(code.expires_in);
loop {
if std::time::Instant::now() > deadline {
bail!("the sign-in code expired, please try again");
}
tokio::time::sleep(Duration::from_secs(interval)).await;
let resp: TokenResponse = client
.post(TOKEN_URL)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("client_id", client_id),
("device_code", code.device_code.as_str()),
])
.send()
.await?
.json()
.await?;
match resp.error.as_deref() {
None => {
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
let refresh = resp.refresh_token.ok_or_else(|| anyhow!("no refresh token"))?;
return complete(client, &access, refresh).await;
}
Some("authorization_pending") => continue,
Some("slow_down") => interval += 5,
Some("authorization_declined") => bail!("sign-in was cancelled"),
Some("expired_token") => bail!("the sign-in code expired, please try again"),
Some(other) => bail!("Microsoft sign-in failed: {other} {}", resp.error_description.unwrap_or_default()),
}
}
}
/// Refresh a saved session (used before each launch when the Minecraft
/// token is close to expiring).
pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result<MsaSession> {
let resp: TokenResponse = client
.post(TOKEN_URL)
.form(&[
("grant_type", "refresh_token"),
("client_id", client_id),
("refresh_token", refresh_token),
("scope", SCOPE),
])
.send()
.await?
.json()
.await?;
if let Some(err) = resp.error {
bail!("your Microsoft session expired ({err}); please sign in again");
}
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
let refresh = resp.refresh_token.unwrap_or_else(|| refresh_token.to_string());
complete(client, &access, refresh).await
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct XboxResponse {
token: String,
display_claims: DisplayClaims,
}
#[derive(Deserialize)]
struct DisplayClaims {
xui: Vec<Xui>,
}
#[derive(Deserialize)]
struct Xui {
uhs: String,
#[serde(default)]
xid: Option<String>,
}
#[derive(Deserialize)]
struct McLogin {
access_token: String,
expires_in: i64,
}
async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result<MsaSession> {
// Xbox Live
let xbl: XboxResponse = client
.post("https://user.auth.xboxlive.com/user/authenticate")
.json(&json!({
"Properties": {"AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": format!("d={ms_access}")},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT"
}))
.send()
.await?
.error_for_status()
.context("Xbox Live authentication failed")?
.json()
.await?;
// XSTS
let resp = client
.post("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(&json!({
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl.token]},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT"
}))
.send()
.await?;
if resp.status().as_u16() == 401 {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let msg = match body.get("XErr").and_then(|v| v.as_u64()) {
Some(2148916233) => "this Microsoft account has no Xbox profile yet — sign in once at xbox.com, then try again",
Some(2148916235) => "Xbox Live isn't available in your country",
Some(2148916236) | Some(2148916237) => "this account needs adult verification on xbox.com",
Some(2148916238) => "this is a child account — an adult must add it to a Microsoft family first",
_ => "Xbox Live refused the sign-in",
};
bail!("{msg}");
}
let xsts: XboxResponse = resp.error_for_status()?.json().await?;
let claims = xsts.display_claims.xui.first().ok_or_else(|| anyhow!("missing Xbox user hash"))?;
let uhs = claims.uhs.clone();
let xuid = claims.xid.clone();
// Minecraft
let mc: McLogin = client
.post("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(&json!({ "identityToken": format!("XBL3.0 x={uhs};{}", xsts.token) }))
.send()
.await?
.error_for_status()
.context("Minecraft services rejected the Xbox token (is the Azure app approved for Minecraft?)")?
.json()
.await?;
let resp = client
.get("https://api.minecraftservices.com/minecraft/profile")
.bearer_auth(&mc.access_token)
.send()
.await?;
if resp.status().as_u16() == 404 {
bail!("this Microsoft account doesn't own Minecraft: Java Edition");
}
let profile: McProfile = resp.error_for_status()?.json().await?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
Ok(MsaSession {
refresh_token,
mc_access_token: mc.access_token,
mc_expires_at: now + mc.expires_in,
profile,
xuid,
})
}
+54
View File
@@ -0,0 +1,54 @@
//! Writes player keybinds / preferences into Minecraft's `options.txt`.
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::Path;
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
/// else the game has written. Only for 1.13+ (named key codes).
pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> {
let mut values: BTreeMap<String, String> = binds.iter().map(|(k, v)| (format!("key_{k}"), v.clone())).collect();
apply(game_dir, &mut values)
}
/// Merge arbitrary `name:value` options.
pub fn apply(game_dir: &Path, values: &mut BTreeMap<String, String>) -> Result<()> {
if values.is_empty() {
return Ok(());
}
let path = game_dir.join("options.txt");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut lines: Vec<String> = Vec::new();
for line in existing.lines() {
match line.split_once(':') {
Some((k, _)) if values.contains_key(k) => {
let v = values.remove(k).unwrap();
lines.push(format!("{k}:{v}"));
}
_ => lines.push(line.to_string()),
}
}
for (k, v) in values.iter() {
lines.push(format!("{k}:{v}"));
}
std::fs::create_dir_all(game_dir)?;
std::fs::write(path, lines.join("\n") + "\n")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_keybinds() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("options.txt"), "version:3465\nkey_key.forward:key.keyboard.w\nfov:0.0\n").unwrap();
let mut binds = BTreeMap::new();
binds.insert("key.forward".to_string(), "key.keyboard.up".to_string());
binds.insert("key.sprint".to_string(), "key.keyboard.left.control".to_string());
apply_keybinds(dir.path(), &binds).unwrap();
let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap();
assert_eq!(out, "version:3465\nkey_key.forward:key.keyboard.up\nfov:0.0\nkey_key.sprint:key.keyboard.left.control\n");
}
}
+99
View File
@@ -0,0 +1,99 @@
use std::path::{Path, PathBuf};
/// On-disk layout. Versions, libraries, assets and Java runtimes are shared
/// between instances so a second instance of the same version costs almost
/// no disk space.
#[derive(Debug, Clone)]
pub struct Layout {
pub root: PathBuf,
}
impl Layout {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn libraries(&self) -> PathBuf {
self.root.join("libraries")
}
pub fn versions(&self) -> PathBuf {
self.root.join("versions")
}
pub fn version_dir(&self, id: &str) -> PathBuf {
self.versions().join(id)
}
pub fn version_json(&self, id: &str) -> PathBuf {
self.version_dir(id).join(format!("{id}.json"))
}
pub fn version_jar(&self, id: &str) -> PathBuf {
self.version_dir(id).join(format!("{id}.jar"))
}
pub fn assets(&self) -> PathBuf {
self.root.join("assets")
}
pub fn runtimes(&self) -> PathBuf {
self.root.join("runtimes")
}
pub fn natives(&self, version_id: &str) -> PathBuf {
self.root.join("natives").join(version_id)
}
pub fn instances(&self) -> PathBuf {
self.root.join("instances")
}
pub fn instance_dir(&self, id: &str) -> PathBuf {
self.instances().join(sanitize_id(id))
}
pub fn cache(&self) -> PathBuf {
self.root.join("cache")
}
}
/// Keep instance ids filesystem-safe no matter what the panel sends.
pub fn sanitize_id(id: &str) -> String {
let s: String = id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect();
let s = s.trim_matches('.').to_string();
if s.is_empty() { "_".into() } else { s }
}
/// Join a server-provided relative path onto `base`, refusing anything that
/// could escape it (`..`, absolute paths, drive letters).
pub fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
let rel = rel.replace('\\', "/");
if rel.starts_with('/') || rel.contains(':') {
return None;
}
let mut out = base.to_path_buf();
for part in rel.split('/') {
match part {
"" | "." => continue,
".." => return None,
p => out.push(p),
}
}
if out == base { None } else { Some(out) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_join_blocks_traversal() {
let base = Path::new("/game");
assert_eq!(safe_join(base, "mods/a.jar"), Some(PathBuf::from("/game/mods/a.jar")));
assert_eq!(safe_join(base, "config\\x.toml"), Some(PathBuf::from("/game/config/x.toml")));
assert!(safe_join(base, "../evil").is_none());
assert!(safe_join(base, "mods/../../evil").is_none());
assert!(safe_join(base, "/etc/passwd").is_none());
assert!(safe_join(base, "C:/Windows").is_none());
assert!(safe_join(base, "").is_none());
}
#[test]
fn ids_are_sanitized() {
assert_eq!(sanitize_id("my pack/../x"), "my_pack_.._x");
assert_eq!(sanitize_id(".."), "_");
}
}
+264
View File
@@ -0,0 +1,264 @@
//! Minecraft Server List Ping: online players, MOTD, version, latency.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[derive(Debug, Clone, Serialize, Default)]
pub struct ServerStatus {
pub online: bool,
pub version: String,
pub protocol: i64,
pub players_online: i64,
pub players_max: i64,
pub sample: Vec<String>,
/// MOTD using legacy `§` colour codes (rendered by the UI).
pub motd: String,
/// `data:image/png;base64,...`
pub favicon: Option<String>,
pub latency_ms: u64,
}
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
loop {
let mut byte = (value & 0x7f) as u8;
value = ((value as u32) >> 7) as i32;
if value != 0 {
byte |= 0x80;
}
buf.push(byte);
if value == 0 {
break;
}
}
}
async fn read_varint(stream: &mut TcpStream) -> Result<i32> {
let mut result = 0i32;
for i in 0..5 {
let byte = stream.read_u8().await?;
result |= ((byte & 0x7f) as i32) << (7 * i);
if byte & 0x80 == 0 {
return Ok(result);
}
}
bail!("varint too long")
}
fn packet(id: i32, body: &[u8]) -> Vec<u8> {
let mut inner = Vec::new();
write_varint(&mut inner, id);
inner.extend_from_slice(body);
let mut out = Vec::new();
write_varint(&mut out, inner.len() as i32);
out.extend(inner);
out
}
#[derive(Deserialize)]
struct RawStatus {
#[serde(default)]
version: Option<RawVersion>,
#[serde(default)]
players: Option<RawPlayers>,
#[serde(default)]
description: Option<serde_json::Value>,
#[serde(default)]
favicon: Option<String>,
}
#[derive(Deserialize)]
struct RawVersion {
#[serde(default)]
name: String,
#[serde(default)]
protocol: i64,
}
#[derive(Deserialize)]
struct RawPlayers {
#[serde(default)]
max: i64,
#[serde(default)]
online: i64,
#[serde(default)]
sample: Vec<RawSample>,
}
#[derive(Deserialize)]
struct RawSample {
#[serde(default)]
name: String,
}
fn color_code(name: &str) -> Option<char> {
Some(match name {
"black" => '0',
"dark_blue" => '1',
"dark_green" => '2',
"dark_aqua" => '3',
"dark_red" => '4',
"dark_purple" => '5',
"gold" => '6',
"gray" => '7',
"dark_gray" => '8',
"blue" => '9',
"green" => 'a',
"aqua" => 'b',
"red" => 'c',
"light_purple" => 'd',
"yellow" => 'e',
"white" => 'f',
_ => return None,
})
}
/// Flatten a chat component into a `§`-coded string.
pub fn flatten_chat(value: &serde_json::Value) -> String {
let mut out = String::new();
fn walk(v: &serde_json::Value, out: &mut String) {
match v {
serde_json::Value::String(s) => out.push_str(s),
serde_json::Value::Array(items) => items.iter().for_each(|i| walk(i, out)),
serde_json::Value::Object(map) => {
if let Some(c) = map.get("color").and_then(|c| c.as_str()) {
if let Some(code) = color_code(c) {
out.push('§');
out.push(code);
} else if c.starts_with('#') && c.len() == 7 {
// Hex colours: emit a custom marker the UI understands.
out.push_str("§#");
out.push_str(&c[1..]);
}
}
for (key, code) in [("bold", 'l'), ("italic", 'o'), ("underlined", 'n'), ("strikethrough", 'm')] {
if map.get(key).and_then(|b| b.as_bool()) == Some(true) {
out.push('§');
out.push(code);
}
}
if let Some(t) = map.get("text").and_then(|t| t.as_str()) {
out.push_str(t);
}
if let Some(extra) = map.get("extra") {
walk(extra, out);
}
if map.contains_key("color") {
out.push_str("§r");
}
}
_ => {}
}
}
walk(value, &mut out);
out
}
pub async fn ping(host: &str, port: u16) -> Result<ServerStatus> {
tokio::time::timeout(Duration::from_secs(5), ping_inner(host, port)).await.context("server did not respond in time")?
}
async fn ping_inner(host: &str, port: u16) -> Result<ServerStatus> {
let started = Instant::now();
let mut stream = TcpStream::connect((host, port)).await.with_context(|| format!("connecting to {host}:{port}"))?;
stream.set_nodelay(true).ok();
let connect_ms = started.elapsed().as_millis() as u64;
let mut body = Vec::new();
write_varint(&mut body, -1); // protocol: "whatever you are"
write_varint(&mut body, host.len() as i32);
body.extend_from_slice(host.as_bytes());
body.extend_from_slice(&port.to_be_bytes());
write_varint(&mut body, 1); // next state: status
stream.write_all(&packet(0x00, &body)).await?;
stream.write_all(&packet(0x00, &[])).await?;
let _len = read_varint(&mut stream).await?;
let id = read_varint(&mut stream).await?;
if id != 0x00 {
bail!("unexpected packet {id}");
}
let str_len = read_varint(&mut stream).await? as usize;
if str_len > 1 << 21 {
bail!("status response too large");
}
let mut json = vec![0u8; str_len];
stream.read_exact(&mut json).await?;
// Latency via ping/pong (fallback: TCP connect time).
let t = Instant::now();
let mut latency_ms = connect_ms;
if stream.write_all(&packet(0x01, &42i64.to_be_bytes())).await.is_ok() {
if let Ok(Ok(_)) = tokio::time::timeout(Duration::from_secs(2), async {
let _ = read_varint(&mut stream).await?;
let _ = read_varint(&mut stream).await?;
stream.read_i64().await.map_err(anyhow::Error::from)
})
.await
{
latency_ms = t.elapsed().as_millis() as u64;
}
}
let raw: RawStatus = serde_json::from_slice(&json).context("invalid status JSON")?;
let players = raw.players.unwrap_or(RawPlayers { max: 0, online: 0, sample: vec![] });
let version = raw.version.unwrap_or(RawVersion { name: String::new(), protocol: 0 });
Ok(ServerStatus {
online: true,
version: version.name,
protocol: version.protocol,
players_online: players.online,
players_max: players.max,
sample: players.sample.into_iter().map(|s| s.name).filter(|n| !n.is_empty()).take(12).collect(),
motd: raw.description.as_ref().map(flatten_chat).unwrap_or_default(),
favicon: raw.favicon,
latency_ms,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varints() {
let mut b = Vec::new();
write_varint(&mut b, 300);
assert_eq!(b, vec![0xac, 0x02]);
let mut b = Vec::new();
write_varint(&mut b, -1);
assert_eq!(b, vec![0xff, 0xff, 0xff, 0xff, 0x0f]);
}
#[test]
fn flattens_motd() {
let v: serde_json::Value = serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap();
assert_eq!(flatten_chat(&v), "§b§lScope§rNet");
assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello");
}
#[tokio::test]
async fn pings_a_fake_server() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
// read handshake + request (we don't validate them here)
let mut buf = [0u8; 256];
let _ = sock.read(&mut buf).await.unwrap();
let json = br#"{"version":{"name":"1.21.1","protocol":767},"players":{"max":100,"online":7,"sample":[{"name":"Steve","id":"x"}]},"description":"A server"}"#;
let mut body = Vec::new();
write_varint(&mut body, json.len() as i32);
body.extend_from_slice(json);
sock.write_all(&packet(0, &body)).await.unwrap();
let mut ping = [0u8; 64];
let _ = sock.read(&mut ping).await;
sock.write_all(&packet(1, &42i64.to_be_bytes())).await.ok();
});
let s = ping("127.0.0.1", port).await.unwrap();
assert!(s.online);
assert_eq!(s.players_online, 7);
assert_eq!(s.version, "1.21.1");
assert_eq!(s.sample, vec!["Steve"]);
assert_eq!(s.motd, "A server");
}
}
+33
View File
@@ -0,0 +1,33 @@
use serde::Serialize;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Stage {
Preparing,
Java,
Game,
Loader,
Files,
Launching,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Event {
Stage { stage: Stage, label: String },
Progress { stage: Stage, done: u64, total: u64, files_done: u32, files_total: u32 },
Log { line: String },
}
/// Callback the engine uses to report progress. Must be cheap: it is called
/// from download tasks (throttled to ~10 Hz).
pub type Reporter = Arc<dyn Fn(Event) + Send + Sync>;
pub fn noop() -> Reporter {
Arc::new(|_| {})
}
pub fn stage(r: &Reporter, stage: Stage, label: impl Into<String>) {
r(Event::Stage { stage, label: label.into() });
}
+147
View File
@@ -0,0 +1,147 @@
//! Mojang's `rules` mini-language used by libraries and arguments.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Rule {
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os: Option<OsRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<HashMap<String, bool>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OsRule {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Env {
/// "windows" | "osx" | "linux"
pub os: &'static str,
/// "x86_64" | "x86" | "arm64"
pub arch: &'static str,
pub features: HashMap<String, bool>,
}
impl Env {
pub fn current() -> Self {
let os = if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"osx"
} else {
"linux"
};
let arch = if cfg!(target_arch = "x86") {
"x86"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"x86_64"
};
Self { os, arch, features: HashMap::new() }
}
pub fn with_feature(mut self, name: &str, on: bool) -> Self {
self.features.insert(name.to_string(), on);
self
}
/// `${arch}` substitution used in legacy native classifiers.
pub fn bits(&self) -> &'static str {
if self.arch == "x86" { "32" } else { "64" }
}
}
fn os_matches(os: &OsRule, env: &Env) -> bool {
if let Some(name) = &os.name {
if name != env.os {
return false;
}
}
if let Some(arch) = &os.arch {
// Mojang only ever uses "x86" here, meaning 32-bit.
let matches = match arch.as_str() {
"x86" => env.arch == "x86",
"x86_64" | "amd64" => env.arch == "x86_64",
"arm64" | "aarch64" => env.arch == "arm64",
_ => false,
};
if !matches {
return false;
}
}
// `version` is a regex against the OS version (e.g. "^10\\."). We can't
// evaluate it reliably, and every use is a harmless JVM hint, so treat
// it as matching.
true
}
pub fn rule_matches(rule: &Rule, env: &Env) -> bool {
if let Some(os) = &rule.os {
if !os_matches(os, env) {
return false;
}
}
if let Some(features) = &rule.features {
for (name, want) in features {
if env.features.get(name).copied().unwrap_or(false) != *want {
return false;
}
}
}
true
}
/// No rules → allowed. Otherwise start disallowed and let the last matching
/// rule decide.
pub fn allowed(rules: &[Rule], env: &Env) -> bool {
if rules.is_empty() {
return true;
}
let mut allow = false;
for rule in rules {
if rule_matches(rule, env) {
allow = rule.action == "allow";
}
}
allow
}
#[cfg(test)]
mod tests {
use super::*;
fn env(os: &'static str) -> Env {
Env { os, arch: "x86_64", features: HashMap::new() }
}
#[test]
fn evaluates_rules() {
let rules: Vec<Rule> = serde_json::from_str(
r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#,
)
.unwrap();
assert!(allowed(&rules, &env("windows")));
assert!(!allowed(&rules, &env("osx")));
let only_win: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow","os":{"name":"windows"}}]"#).unwrap();
assert!(allowed(&only_win, &env("windows")));
assert!(!allowed(&only_win, &env("linux")));
let x86: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow","os":{"arch":"x86"}}]"#).unwrap();
assert!(!allowed(&x86, &env("windows")));
let feat: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow","features":{"has_custom_resolution":true}}]"#).unwrap();
assert!(!allowed(&feat, &env("windows")));
assert!(allowed(&feat, &env("windows").with_feature("has_custom_resolution", true)));
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Adds the instance's server to Minecraft's multiplayer list.
use anyhow::Result;
use fastnbt::Value;
use std::collections::HashMap;
use std::path::Path;
/// Insert (or update) `name`/`address` at the top of `servers.dat`,
/// preserving every other entry the player has added.
pub fn upsert(game_dir: &Path, name: &str, address: &str) -> Result<()> {
let path = game_dir.join("servers.dat");
let mut root: HashMap<String, Value> = match std::fs::read(&path) {
Ok(bytes) => match fastnbt::from_bytes::<Value>(&bytes) {
Ok(Value::Compound(map)) => map,
_ => HashMap::new(),
},
Err(_) => HashMap::new(),
};
let mut servers = match root.remove("servers") {
Some(Value::List(list)) => list,
_ => Vec::new(),
};
let same = |v: &Value| match v {
Value::Compound(m) => matches!(m.get("ip"), Some(Value::String(ip)) if ip.eq_ignore_ascii_case(address)),
_ => false,
};
let existing = servers.iter().position(same);
let mut entry = match existing {
Some(i) => match servers.remove(i) {
Value::Compound(m) => m,
_ => HashMap::new(),
},
None => HashMap::new(),
};
entry.insert("name".into(), Value::String(name.to_string()));
entry.insert("ip".into(), Value::String(address.to_string()));
entry.entry("acceptTextures".into()).or_insert(Value::Byte(1));
servers.insert(0, Value::Compound(entry));
root.insert("servers".into(), Value::List(servers));
std::fs::create_dir_all(game_dir)?;
std::fs::write(&path, fastnbt::to_bytes(&Value::Compound(root))?)?;
Ok(())
}
pub fn format_address(host: &str, port: u16) -> String {
if port == 25565 { host.to_string() } else { format!("{host}:{port}") }
}
#[cfg(test)]
mod tests {
use super::*;
fn names(dir: &Path) -> Vec<(String, String)> {
let v: Value = fastnbt::from_bytes(&std::fs::read(dir.join("servers.dat")).unwrap()).unwrap();
let Value::Compound(root) = v else { panic!() };
let Some(Value::List(list)) = root.get("servers") else { panic!() };
list.iter()
.map(|s| match s {
Value::Compound(m) => match (m.get("name"), m.get("ip")) {
(Some(Value::String(n)), Some(Value::String(i))) => (n.clone(), i.clone()),
_ => panic!(),
},
_ => panic!(),
})
.collect()
}
#[test]
fn upserts_without_losing_player_servers() {
let dir = tempfile::tempdir().unwrap();
upsert(dir.path(), "Friend", "friend.net").unwrap();
upsert(dir.path(), "ScopeNet", "play.scopenet.gg").unwrap();
upsert(dir.path(), "ScopeNet SMP", "play.scopenet.gg").unwrap();
assert_eq!(
names(dir.path()),
vec![("ScopeNet SMP".into(), "play.scopenet.gg".into()), ("Friend".into(), "friend.net".into())]
);
assert_eq!(format_address("a.b", 25565), "a.b");
assert_eq!(format_address("a.b", 25570), "a.b:25570");
}
}
+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);
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Tiny system probes (kept dependency-free to stay lightweight).
/// Total physical memory in MiB (best effort; 8 GiB if unknown).
pub fn total_memory_mb() -> u64 {
imp().unwrap_or(8192)
}
#[cfg(windows)]
fn imp() -> Option<u64> {
use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
status.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
let ok = unsafe { GlobalMemoryStatusEx(&mut status) };
(ok != 0).then(|| status.ullTotalPhys / 1024 / 1024)
}
#[cfg(target_os = "linux")]
fn imp() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let line = info.lines().find(|l| l.starts_with("MemTotal:"))?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
Some(kb / 1024)
}
#[cfg(not(any(windows, target_os = "linux")))]
fn imp() -> Option<u64> {
None
}
/// Recursively sum file sizes under `path`.
pub fn dir_size(path: &std::path::Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else { return 0 };
entries
.filter_map(|e| e.ok())
.map(|e| match e.file_type() {
Ok(t) if t.is_dir() => dir_size(&e.path()),
Ok(t) if t.is_file() => e.metadata().map(|m| m.len()).unwrap_or(0),
_ => 0,
})
.sum()
}
+293
View File
@@ -0,0 +1,293 @@
//! Mojang version JSON (also used by Fabric/Quilt/Forge profiles).
use crate::rules::Rule;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct VersionJson {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inherits_from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jar: Option<String>,
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub main_class: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minecraft_arguments: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arguments: Option<Arguments>,
#[serde(default)]
pub libraries: Vec<Library>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_index: Option<AssetIndexRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub downloads: Option<VersionDownloads>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub java_version: Option<JavaVersion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<Logging>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_time: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Arguments {
#[serde(default)]
pub game: Vec<Arg>,
#[serde(default)]
pub jvm: Vec<Arg>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum Arg {
Plain(String),
Ruled { rules: Vec<Rule>, value: ArgValue },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ArgValue {
One(String),
Many(Vec<String>),
}
impl ArgValue {
pub fn values(&self) -> Vec<String> {
match self {
ArgValue::One(s) => vec![s.clone()],
ArgValue::Many(v) => v.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Library {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub downloads: Option<LibraryDownloads>,
/// Maven repository base (Fabric/Quilt/legacy Forge style).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rules: Option<Vec<Rule>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub natives: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extract: Option<Extract>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha1: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LibraryDownloads {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact: Option<Artifact>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classifiers: Option<HashMap<String, Artifact>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Artifact {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha1: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(default)]
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Extract {
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AssetIndexRef {
pub id: String,
#[serde(default)]
pub sha1: Option<String>,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub total_size: Option<u64>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VersionDownloads {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<Artifact>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct JavaVersion {
#[serde(default)]
pub component: String,
#[serde(default)]
pub major_version: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Logging {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client: Option<LoggingClient>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LoggingClient {
pub argument: String,
pub file: LogFile,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LogFile {
pub id: String,
#[serde(default)]
pub sha1: Option<String>,
#[serde(default)]
pub size: Option<u64>,
pub url: String,
}
impl VersionJson {
/// Java major version this version wants (8 for anything old).
pub fn java_major(&self) -> u32 {
self.java_version.as_ref().map(|j| j.major_version).filter(|m| *m > 0).unwrap_or(8)
}
pub fn java_component(&self) -> String {
self.java_version
.as_ref()
.map(|j| j.component.clone())
.filter(|c| !c.is_empty())
.unwrap_or_else(|| "jre-legacy".into())
}
/// True for 1.13+ style argument lists.
pub fn is_modern(&self) -> bool {
self.arguments.is_some()
}
/// Does this version understand `--quickPlayMultiplayer` (1.20+)?
pub fn supports_quick_play(&self) -> bool {
self.arguments
.as_ref()
.map(|a| {
a.game.iter().any(|arg| match arg {
Arg::Plain(s) => s.contains("quickPlayMultiplayer"),
Arg::Ruled { value, .. } => value.values().iter().any(|v| v.contains("quickPlayMultiplayer")),
})
})
.unwrap_or(false)
}
}
fn lib_key(name: &str) -> String {
crate::maven::Coord::parse(name).map(|c| c.key()).unwrap_or_else(|| name.to_string())
}
/// Merge a child profile (Fabric/Forge/...) onto its parent (vanilla).
/// Child values win; argument lists are concatenated; libraries are
/// de-duplicated with the child's copy taking precedence.
pub fn merge(parent: VersionJson, child: VersionJson) -> VersionJson {
let mut seen = HashSet::new();
let mut libraries = Vec::new();
for lib in child.libraries.into_iter().chain(parent.libraries) {
// Natives-map libraries (LWJGL 2) share a key with their jar entry.
let key = if lib.natives.is_some() { format!("{}#natives", lib_key(&lib.name)) } else { lib_key(&lib.name) };
if seen.insert(key) {
libraries.push(lib);
}
}
let arguments = match (parent.arguments, child.arguments) {
(Some(mut p), Some(c)) => {
p.game.extend(c.game);
p.jvm.extend(c.jvm);
Some(p)
}
(p, c) => c.or(p),
};
VersionJson {
id: child.id,
inherits_from: None,
jar: child.jar.or(parent.jar),
kind: child.kind.or(parent.kind),
main_class: child.main_class.or(parent.main_class),
minecraft_arguments: child.minecraft_arguments.or(parent.minecraft_arguments),
arguments,
libraries,
asset_index: child.asset_index.or(parent.asset_index),
assets: child.assets.or(parent.assets),
downloads: child.downloads.or(parent.downloads),
java_version: child.java_version.or(parent.java_version),
logging: child.logging.or(parent.logging),
release_time: parent.release_time.or(child.release_time),
}
}
#[cfg(test)]
mod tests {
use super::*;
pub const VANILLA: &str = r#"{
"id": "1.20.1", "type": "release", "mainClass": "net.minecraft.client.main.Main",
"javaVersion": {"component": "java-runtime-gamma", "majorVersion": 17},
"assetIndex": {"id": "5", "sha1": "abc", "size": 1, "totalSize": 2, "url": "https://x/5.json"},
"assets": "5",
"downloads": {"client": {"sha1": "c", "size": 3, "url": "https://x/client.jar"}},
"arguments": {
"game": ["--username", "${auth_player_name}",
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}], "value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]},
{"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}], "value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}],
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]}, "-cp", "${classpath}"]
},
"libraries": [
{"name": "org.ow2.asm:asm:9.3", "downloads": {"artifact": {"path": "org/ow2/asm/asm/9.3/asm-9.3.jar", "sha1": "a", "size": 1, "url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar"}}},
{"name": "org.lwjgl:lwjgl:3.3.1:natives-windows", "downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar", "sha1": "n", "size": 1, "url": "https://libraries.minecraft.net/n.jar"}}, "rules": [{"action": "allow", "os": {"name": "windows"}}]}
]
}"#;
const FABRIC: &str = r#"{
"id": "fabric-loader-0.16.9-1.20.1", "inheritsFrom": "1.20.1", "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
"libraries": [
{"name": "org.ow2.asm:asm:9.7.1", "url": "https://maven.fabricmc.net/", "sha1": "f"},
{"name": "net.fabricmc:fabric-loader:0.16.9", "url": "https://maven.fabricmc.net/"}
]
}"#;
#[test]
fn merges_fabric_onto_vanilla() {
let parent: VersionJson = serde_json::from_str(VANILLA).unwrap();
let child: VersionJson = serde_json::from_str(FABRIC).unwrap();
assert!(parent.supports_quick_play());
let merged = merge(parent, child);
assert_eq!(merged.id, "fabric-loader-0.16.9-1.20.1");
assert_eq!(merged.main_class.as_deref(), Some("net.fabricmc.loader.impl.launch.knot.KnotClient"));
assert_eq!(merged.java_major(), 17);
// asm de-duplicated, Fabric's newer copy wins
let asm: Vec<_> = merged.libraries.iter().filter(|l| l.name.starts_with("org.ow2.asm:asm:")).collect();
assert_eq!(asm.len(), 1);
assert_eq!(asm[0].name, "org.ow2.asm:asm:9.7.1");
assert_eq!(merged.libraries.len(), 3);
let args = merged.arguments.unwrap();
assert_eq!(args.jvm.len(), 4);
assert!(merged.asset_index.is_some());
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "scopenet-shared"
description = "Types shared between the ScopeNet launcher and admin panel"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
md-5.workspace = true
+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);
}
}