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