Add Docker image, Compose, CI/CD workflows and docs

- Multi-arch (amd64/arm64) panel image: static musl binary on scratch,
  cross-compiled with cargo-zigbuild (no QEMU), non-root, healthcheck
- docker-compose.yml + .env.example for one-command deployment
- CI: fmt, clippy, tests, web type-checks, Windows launcher build,
  real-network installs of vanilla/Fabric/Quilt/Forge/NeoForge,
  Docker build
- Release: Windows NSIS installer with baked-in panel URL, GHCR image
  push and GitHub release (tag push or manual dispatch)
- README, admin guide, Microsoft auth setup, architecture, development
- rustfmt config and formatting pass

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
Claude committed 2026-09-28 05:19:36 +00:00
1 parent 50eb1a0cac
commit 5dc8a4f2f1
54 files changed
+1019 -219

No files matched your search

-1
View File
@@ -3,7 +3,6 @@ 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
+2 -7
View File
@@ -33,8 +33,7 @@ pub fn object_path(layout: &Layout, hash: &str) -> PathBuf {
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?;
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)?)?)
}
@@ -64,11 +63,7 @@ pub fn materialize_legacy(layout: &Layout, index_id: &str, index: &AssetIndex, g
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)
};
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() {
+2 -4
View File
@@ -129,10 +129,8 @@ async fn try_download(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn F
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 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();
+13 -20
View File
@@ -47,13 +47,8 @@ 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();
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
}
@@ -69,11 +64,8 @@ async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Re
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"))?;
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?;
}
@@ -82,11 +74,8 @@ async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Re
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();
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
@@ -146,7 +135,11 @@ pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSp
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" }));
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 => {
@@ -204,8 +197,8 @@ pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSp
};
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());
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,
+18 -11
View File
@@ -86,7 +86,11 @@ pub fn sibling_exe(java: &Path, windowed: bool) -> PathBuf {
}
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() }
if candidate.exists() {
candidate
} else {
java.to_path_buf()
}
}
/// Make sure the Mojang runtime `component` (e.g. `java-runtime-delta`) is
@@ -102,15 +106,14 @@ pub async fn ensure_runtime(
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 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))
@@ -188,7 +191,11 @@ pub fn parse_java_version(text: &str) -> Option<u32> {
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) }
if first == 1 {
parts.next()?.parse().ok()
} else {
Some(first)
}
}
#[cfg(test)]
+9 -12
View File
@@ -167,12 +167,7 @@ pub struct Command {
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 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()
@@ -259,11 +254,7 @@ pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Co
/// 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");
let body = args.iter().map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\""))).collect::<Vec<_>>().join("\n");
std::fs::write(path, body)?;
Ok(())
}
@@ -340,7 +331,13 @@ mod tests {
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 },
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,
+7 -10
View File
@@ -37,7 +37,11 @@ impl ResolvedLib {
}
fn join_url(base: &str, path: &str) -> String {
if base.ends_with('/') { format!("{base}{path}") } else { format!("{base}/{path}") }
if base.ends_with('/') {
format!("{base}{path}")
} else {
format!("{base}/{path}")
}
}
pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
@@ -56,11 +60,7 @@ pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
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 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) => (
@@ -207,10 +207,7 @@ mod tests {
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_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");
}
}
+2 -5
View File
@@ -253,11 +253,8 @@ async fn run_processors(ctx: &ForgeInstall<'_>, zip: &mut Zip, profile: &Install
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 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() {
+4 -11
View File
@@ -106,11 +106,9 @@ pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str)
.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 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)
@@ -137,12 +135,7 @@ pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str)
}
/// 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>> {
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);
}
+4 -23
View File
@@ -64,11 +64,7 @@ pub struct McSkin {
}
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?;
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}");
@@ -116,12 +112,7 @@ pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code:
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),
])
.form(&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", SCOPE)])
.send()
.await?
.json()
@@ -210,22 +201,12 @@ async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: Stri
.json()
.await?;
let resp = client
.get("https://api.minecraftservices.com/minecraft/profile")
.bearer_auth(&mc.access_token)
.send()
.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,
})
Ok(MsaSession { refresh_token, mc_access_token: mc.access_token, mc_expires_at: now + mc.expires_in, profile, xuid })
}
+11 -6
View File
@@ -49,12 +49,13 @@ impl Layout {
/// 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: 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 }
if s.is_empty() {
"_".into()
} else {
s
}
}
/// Join a server-provided relative path onto `base`, refusing anything that
@@ -72,7 +73,11 @@ pub fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
p => out.push(p),
}
}
if out == base { None } else { Some(out) }
if out == base {
None
} else {
Some(out)
}
}
#[cfg(test)]
+2 -1
View File
@@ -231,7 +231,8 @@ mod tests {
#[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();
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");
}
+6 -5
View File
@@ -57,7 +57,11 @@ impl Env {
/// `${arch}` substitution used in legacy native classifiers.
pub fn bits(&self) -> &'static str {
if self.arch == "x86" { "32" } else { "64" }
if self.arch == "x86" {
"32"
} else {
"64"
}
}
}
@@ -126,10 +130,7 @@ mod tests {
#[test]
fn evaluates_rules() {
let rules: Vec<Rule> = serde_json::from_str(
r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#,
)
.unwrap();
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")));
+6 -5
View File
@@ -46,7 +46,11 @@ pub fn upsert(game_dir: &Path, name: &str, address: &str) -> Result<()> {
}
pub fn format_address(host: &str, port: u16) -> String {
if port == 25565 { host.to_string() } else { format!("{host}:{port}") }
if port == 25565 {
host.to_string()
} else {
format!("{host}:{port}")
}
}
#[cfg(test)]
@@ -74,10 +78,7 @@ mod tests {
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!(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");
}
+1 -5
View File
@@ -76,11 +76,7 @@ pub async fn sync(
continue;
};
wanted.insert(f.path.replace('\\', "/"));
let ok = if changed {
http::file_ok(&dest, Some(&f.sha1), Some(f.size), true)
} else {
dest.exists()
};
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)));
}
+1 -5
View File
@@ -170,11 +170,7 @@ impl VersionJson {
}
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())
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.
+18 -3
View File
@@ -7,11 +7,20 @@ use scopenet_core::{progress, Layout};
use scopenet_shared::Loader;
async fn run(mc: &str, loader: Loader) {
let root = std::env::var("SCOPENET_TEST_ROOT").map(std::path::PathBuf::from).unwrap_or_else(|_| std::env::temp_dir().join("scopenet-online"));
let root =
std::env::var("SCOPENET_TEST_ROOT").map(std::path::PathBuf::from).unwrap_or_else(|_| std::env::temp_dir().join("scopenet-online"));
let layout = Layout::new(&root);
let client = scopenet_core::http::client();
let game_dir = layout.instance_dir(&format!("{mc}-{}", loader.as_str()));
let spec = InstallSpec { mc_version: mc.into(), loader, loader_version: None, java_override: None, game_dir: game_dir.clone(), concurrency: 16, deep_verify: false };
let spec = InstallSpec {
mc_version: mc.into(),
loader,
loader_version: None,
java_override: None,
game_dir: game_dir.clone(),
concurrency: 16,
deep_verify: false,
};
let installed = install(&client, &layout, &spec, &progress::noop()).await.unwrap_or_else(|e| panic!("{mc} {loader:?}: {e:#}"));
for path in installed.classpath(&layout) {
@@ -20,7 +29,13 @@ async fn run(mc: &str, loader: Loader) {
assert!(installed.java.exists(), "java missing at {}", installed.java.display());
let opts = LaunchOptions {
auth: Auth { username: "CiBot".into(), uuid: scopenet_shared::offline_uuid("CiBot"), access_token: "0".into(), user_type: "legacy".into(), xuid: None },
auth: Auth {
username: "CiBot".into(),
uuid: scopenet_shared::offline_uuid("CiBot"),
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
},
game_dir,
memory_min_mb: 512,
memory_max_mb: 2048,
-1
View File
@@ -3,7 +3,6 @@ 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