Merge pull request #1 from scopeddlol/claude/minecraft-launcher-customizable-h5tojv
Release / version (push) Successful in 7s
Release / Windows installer (push) Failing after 8s
Release / Panel image (GHCR) (push) Canceled after 0s
Release / GitHub release (push) Canceled after 0s

Add launcher engine (scopenet-core) and admin panel server
This commit is contained in:
SCOPEDD authored and GitHub committed 2026-09-28 02:01:25 -04:00
commit 1577c25567
153 files changed
+23533 -2

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
target
**/node_modules
**/dist
.git
.github
data
docs
launcher/src
launcher/src-tauri/icons
launcher/src-tauri/gen
+8
View File
@@ -0,0 +1,8 @@
# Copy to .env and adjust. Only ADMIN_PASSWORD really matters.
PANEL_PORT=8080
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-please
JWT_SECRET=
CURSEFORGE_API_KEY=
MAX_UPLOAD_MB=2048
RUST_LOG=info
+100
View File
@@ -0,0 +1,100 @@
name: CI
on:
push:
branches: ['**']
tags-ignore: ['**']
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
rust:
name: Rust (fmt, clippy, tests)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Install Tauri system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: Placeholder launcher frontend (tauri::generate_context needs it)
run: mkdir -p launcher/dist && echo '<!doctype html>' > launcher/dist/index.html
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
- run: cargo test --workspace --locked
web:
name: Web UIs (type-check + build)
runs-on: ubuntu-24.04
strategy:
matrix:
app: [panel/web, launcher]
defaults:
run:
working-directory: ${{ matrix.app }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: ${{ matrix.app }}/package-lock.json
- run: npm ci --no-audit --no-fund
- run: npm run check
- run: npm run build
launcher-windows:
name: Launcher (Windows build check)
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: launcher/package-lock.json
- run: npm ci --no-audit --no-fund
working-directory: launcher
- run: npm run build
working-directory: launcher
- run: cargo clippy -p scopenet-launcher --locked -- -D warnings
- run: cargo test -p scopenet-launcher -p scopenet-core -p scopenet-shared --locked
engine-online:
name: Real installs (Mojang, Fabric, Quilt, Forge, NeoForge)
runs-on: ubuntu-24.04
# Talks to third-party servers; a hiccup there shouldn't block merges.
continue-on-error: true
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install games (vanilla, legacy, Fabric, Quilt, Forge, NeoForge)
run: cargo test -p scopenet-core --test online --locked -- --ignored --test-threads=2 --nocapture
docker:
name: Docker image builds
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
+165
View File
@@ -0,0 +1,165 @@
name: Release
# Publish a release by pushing a tag (`git tag v0.2.0 && git push origin v0.2.0`)
# or from the Actions tab → Release → Run workflow (enter a version).
#
# Optional repository variables (Settings → Secrets and variables → Actions → Variables):
# PANEL_URL https://panel.example.com — baked into the launcher as the default server
# LOCK_PANEL "1" to stop players from changing the server
# LAUNCHER_NAME Installer / window name (default "SCOPENET Launcher")
on:
push:
tags: ['v*.*.*']
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g. 0.2.0)'
required: true
permissions:
contents: write
packages: write
env:
CARGO_TERM_COLOR: always
jobs:
version:
runs-on: ubuntu-24.04
outputs:
version: ${{ steps.v.outputs.version }}
tag: ${{ steps.v.outputs.tag }}
steps:
- id: v
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then v="${{ inputs.version }}"; else v="${GITHUB_REF_NAME}"; fi
v="${v#v}"
if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "::error::'$v' is not a semantic version"; exit 1; fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT"
launcher:
name: Windows installer
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: launcher/package-lock.json
- name: Stamp version
shell: bash
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" Cargo.toml
node -e "const f='launcher/src-tauri/tauri.conf.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))"
node -e "const f='launcher/package.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))"
- run: npm ci --no-audit --no-fund
working-directory: launcher
- name: Build installer
working-directory: launcher
shell: bash
env:
SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }}
SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }}
SCOPENET_REPO: ${{ github.repository }}
LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }}
run: |
if [ -n "$LAUNCHER_NAME" ]; then
CONFIG=$(node -e "console.log(JSON.stringify({productName: process.env.LAUNCHER_NAME, app: {windows: [{...require('./src-tauri/tauri.conf.json').app.windows[0], title: process.env.LAUNCHER_NAME}]}}))")
npx tauri build --bundles nsis --config "$CONFIG"
else
npx tauri build --bundles nsis
fi
- name: Collect installer
shell: bash
run: |
mkdir -p out
cp target/release/bundle/nsis/*-setup.exe out/
ls -la out
- uses: actions/upload-artifact@v4
with:
name: windows-installer
path: out/*-setup.exe
if-no-files-found: error
panel-image:
name: Panel image (GHCR)
needs: version
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- name: Image name (GHCR needs lowercase)
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" >> "$GITHUB_ENV"
- name: Stamp version
run: sed -i "0,/^version = \".*\"/s//version = \"${{ needs.version.outputs.version }}\"/" Cargo.toml
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}},value=${{ needs.version.outputs.tag }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.tag }}
type=raw,value=latest,enable=${{ !contains(needs.version.outputs.version, '-') }}
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
release:
name: GitHub release
needs: [version, launcher, panel-image]
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
name: windows-installer
path: dist
- name: Release notes
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
cat > notes.md <<NOTES
## Downloads
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically.
**Admin panel (Docker):**
\`\`\`bash
docker pull ${IMAGE}:${{ needs.version.outputs.version }}
\`\`\`
Or with Compose: set \`image: ${IMAGE}:${{ needs.version.outputs.version }}\` in \`docker-compose.yml\` and run \`docker compose up -d\`.
NOTES
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: ${{ needs.version.outputs.tag }}
target_commitish: ${{ github.sha }}
body_path: notes.md
generate_release_notes: true
prerelease: ${{ contains(needs.version.outputs.version, '-') }}
files: dist/*-setup.exe
+12
View File
@@ -0,0 +1,12 @@
/target
node_modules/
dist/
build/
.svelte-kit/
*.log
.env
!.env.example
/data
/panel/server/data
launcher/src-tauri/gen/schemas
.DS_Store
Generated
+6494
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
[workspace]
resolver = "2"
members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
[workspace.package]
version = "0.1.0"
edition = "2021"
repository = "https://github.com/scopeddlol/SCOPENET-MC"
[workspace.dependencies]
scopenet-shared = { path = "crates/shared" }
scopenet-core = { path = "crates/core" }
anyhow = "1"
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "net", "time", "sync", "signal"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] }
futures = "0.3"
sha1 = "0.10"
md-5 = "0.10"
hex = "0.4"
uuid = { version = "1", features = ["v4", "serde"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
tracing = "0.1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
base64 = "0.22"
rand = "0.8"
# Small, fast binaries: the launcher and the panel both ship lean.
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
[profile.dev.package."*"]
opt-level = 1
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1.7
#
# SCOPENET admin panel — a single static binary + the web UI on `scratch`.
# Multi-arch (amd64/arm64) is cross-compiled with cargo-zigbuild on the
# build machine's native arch, so no slow QEMU emulation is needed.
# ---- 1. Web UI ---------------------------------------------------------------
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /src/panel/web
COPY panel/web/package.json panel/web/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY panel/web/ ./
RUN npm run build
# ---- 2. Server binary --------------------------------------------------------
FROM --platform=$BUILDPLATFORM rust:1-slim-bookworm AS build
ARG TARGETARCH
# zig (via the cargo-zigbuild wheel) is the cross C toolchain for musl targets.
RUN apt-get update && apt-get install -y --no-install-recommends python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir --break-system-packages cargo-zigbuild
RUN case "$TARGETARCH" in \
amd64) echo x86_64-unknown-linux-musl ;; \
arm64) echo aarch64-unknown-linux-musl ;; \
*) echo "unsupported arch $TARGETARCH" >&2; exit 1 ;; \
esac > /rust-target \
&& rustup target add "$(cat /rust-target)"
WORKDIR /src
COPY . .
# No --locked: release builds stamp the version into Cargo.toml. Dependencies
# stay pinned by Cargo.lock either way (CI enforces --locked).
RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry,sharing=locked \
--mount=type=cache,target=/src/target,id=scopenet-target-$TARGETARCH \
cargo zigbuild --release -p scopenet-panel --target "$(cat /rust-target)" \
&& cp "target/$(cat /rust-target)/release/scopenet-panel" /scopenet-panel
# Data dir owned by the non-root runtime user.
RUN mkdir -p /out/data && chown 65532:65532 /out/data
# ---- 3. Runtime --------------------------------------------------------------
FROM scratch
LABEL org.opencontainers.image.title="SCOPENET Panel" \
org.opencontainers.image.description="Admin panel for the SCOPENET Minecraft launcher" \
org.opencontainers.image.source="https://github.com/scopeddlol/SCOPENET-MC"
COPY --from=build /scopenet-panel /scopenet-panel
COPY --from=web /src/panel/web/dist /web
COPY --from=build --chown=65532:65532 /out/data /data
ENV SCOPENET_BIND=0.0.0.0:8080 \
SCOPENET_DATA_DIR=/data \
SCOPENET_WEB_DIR=/web \
RUST_LOG=info
USER 65532:65532
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/scopenet-panel", "healthcheck"]
ENTRYPOINT ["/scopenet-panel"]
+121 -2
View File
@@ -1,2 +1,121 @@
# SCOPENET-MC
A Windows/Linux Minecraft Launcher with a Docker-hosted admin panel.
<div align="center">
<img src="launcher/src-tauri/icons/128x128.png" width="96" alt="" />
# SCOPENET
**A fast, fully customisable Minecraft launcher — and the self-hosted admin panel that controls it.**
Brand it, publish vanilla versions or modpacks, manage players, and push changes to every launcher instantly.
</div>
<p align="center">
<img src="docs/screenshots/launcher-home.webp" width="49%" alt="Launcher home" />
<img src="docs/screenshots/panel-design.webp" width="49%" alt="Panel launcher designer" />
</p>
## What's inside
| | |
|---|---|
| **Launcher** (Windows) | Tauri 2 + Rust + Svelte. Uses the system WebView2 instead of bundling Chromium, so the installer stays small and memory use low. |
| **Admin panel** (Docker) | Rust (Axum) + SQLite + Svelte. 14 MB image; measured ~2 MB RAM at idle. |
| **Engine** | `scopenet-core`: installs Minecraft, Java, Fabric/Quilt/Forge/NeoForge and launches the game. |
### Launcher features
- **Accounts** — panel accounts (username + password, offline-mode play), **Microsoft** sign-in, and local **offline** usernames. Tokens are encrypted with a key stored in Windows Credential Manager.
- **Instances** — every instance the admin publishes shows up in the sidebar; group-restricted ones only for the right players.
- **One-click play** — downloads the right **Java automatically**, the game, the mod loader and the modpack, with live speed/ETA. Files are shared between instances and verified by SHA-1.
- **Built-in server** — the instance's server is added to the multiplayer list, **auto-joined** (Quick Play on 1.20+), and its live status (players, MOTD, ping) is shown on the home screen.
- **Settings** — memory with a recommendation for your PC, resolution, fullscreen, what happens on launch, Java path, GC presets (G1 / ZGC), JVM args, **Minecraft keybind editor** (applied to every instance), custom accent colour, glass/animation toggles, UI scale, storage manager, per-instance overrides.
- **Quality of life** — game console, crash dialog with plain-English hints, repair files, offline launch from cache, self-updates from GitHub Releases, single-instance window, `Ctrl+Enter` to play.
- **Branding from the panel** — name, logo, colours, font, corner radius, glass, image/video background, news feed, social links, custom CSS. No rebuild needed.
### Admin panel features
- **Instances** — pick a Minecraft version + loader, or import a modpack from **Modrinth**, **CurseForge** or a **.zip** (`.mrpack`, CurseForge export, or any instance folder). Upload extra mods/configs. CurseForge mods that block third-party downloads are flagged with a download link.
- **Access control** — public, signed-in players, or specific **groups**.
- **Players** — create/approve/disable accounts, roles, groups; sign-ups closed / approval / open.
- **Launcher design** — every branding option with a **live preview** and 7 colour presets.
- **Dashboard** — launches per day, active players, popular instances, recent activity.
<p align="center">
<img src="docs/screenshots/launcher-install.webp" width="32%" alt="" />
<img src="docs/screenshots/launcher-controls.webp" width="32%" alt="" />
<img src="docs/screenshots/panel-dashboard.webp" width="32%" alt="" />
</p>
## Quick start
### 1. Run the panel
```bash
curl -O https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/docker-compose.yml
curl -o .env https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/.env.example
# edit .env → set ADMIN_PASSWORD
docker compose up -d
```
Open `http://your-server:8080`, sign in as `admin`, then:
1. **Launcher design** → name, logo, colours, news.
2. **Instances** → *New instance* → a version, or a Modrinth / CurseForge / .zip modpack.
3. **Settings** → how players sign in.
> Put the panel behind HTTPS (Caddy, Traefik, nginx, Cloudflare Tunnel…) before sharing it. See [docs/admin-guide.md](docs/admin-guide.md).
### 2. Build your branded launcher
1. In GitHub → **Settings → Secrets and variables → Actions → Variables**, add:
- `PANEL_URL` = `https://panel.example.com` (players connect automatically)
- `LOCK_PANEL` = `1` (optional: hide the "change server" option)
- `LAUNCHER_NAME` = `MyServer Launcher` (optional)
2. **Actions → Release → Run workflow**, enter a version like `1.0.0` — or push a tag `v1.0.0`.
The workflow builds the **Windows installer**, pushes the **panel image to GHCR** (`ghcr.io/<owner>/scopenet-mc-panel`, amd64 + arm64) and publishes a **GitHub release**. Installed launchers pick up new releases automatically.
> The GHCR package is private by default the first time — make it public under your GitHub profile → Packages if you want `docker pull` without logging in.
## Repository layout
```
crates/shared Wire types shared by launcher and panel (manifest, branding, auth)
crates/core Launcher engine: Mojang/Java/loaders, file sync, launch, MS auth, ping
panel/server Admin panel API (Axum + SQLite)
panel/web Admin panel UI (Svelte 5)
launcher/ Desktop launcher UI (Svelte 5)
launcher/src-tauri Desktop launcher shell (Tauri 2)
Dockerfile Panel image (static binary on scratch)
docker-compose.yml Panel deployment
.github/workflows CI + release (installer, GitHub release, GHCR image)
```
## Documentation
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
- [Microsoft sign-in setup](docs/microsoft-auth.md) — Azure app registration
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
- [Development](docs/development.md) — running everything locally, tests
## Configuration reference
**Panel (environment variables)**
| Variable | Default | |
|---|---|---|
| `ADMIN_USERNAME` | `admin` | First admin account (created on first start) |
| `ADMIN_PASSWORD` | random, printed in logs | First admin password |
| `JWT_SECRET` | auto-generated in `/data` | Signs login tokens |
| `CURSEFORGE_API_KEY` | – | Also settable in the panel |
| `MAX_UPLOAD_MB` | `2048` | Largest modpack upload |
| `SCOPENET_BIND` | `0.0.0.0:8080` | Listen address |
| `SCOPENET_DATA_DIR` | `/data` | Database, hosted files, uploads |
**Launcher (build-time, via repository variables)** — `PANEL_URL`, `LOCK_PANEL`, `LAUNCHER_NAME`.
## Notes
- Minecraft is a trademark of Mojang Studios. SCOPENET is not affiliated with Mojang or Microsoft. Offline mode is intended for your own community servers.
- No license has been chosen yet — add a `LICENSE` file before distributing.
+25
View File
@@ -0,0 +1,25 @@
[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
[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"
+78
View File
@@ -0,0 +1,78 @@
//! 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))
}
+221
View File
@@ -0,0 +1,221 @@
//! 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(())
}
+215
View File
@@ -0,0 +1,215 @@
//! 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,
})
}
+211
View File
@@ -0,0 +1,211 @@
//! 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));
}
}
+385
View File
@@ -0,0 +1,385 @@
//! 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(())
}
/// Start the game. With `capture` the game's stdout/stderr are piped back to
/// us (for the console); without it they're discarded, which is required
/// when the launcher exits while the game keeps running.
pub fn spawn(cmd: &Command, java_major: u32, capture: bool) -> 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);
}
let out = || if capture { std::process::Stdio::piped() } else { std::process::Stdio::null() };
proc.current_dir(&cmd.cwd).stdin(std::process::Stdio::null()).stdout(out()).stderr(out());
#[cfg(windows)]
proc.creation_flags(0x0800_0000);
proc.spawn().with_context(|| format!("starting {}", cmd.java.display()))
}
/// Hide the access token when showing the command to the user.
pub fn redact(args: &[String], token: &str) -> Vec<String> {
if token.len() < 8 {
return args.to_vec();
}
args.iter().map(|a| a.replace(token, "********")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::version::VersionJson;
#[test]
fn splits_quoted_args() {
assert_eq!(split_args(r#"-Xss2M -Dfoo="a b" '' -Dx=1"#), vec!["-Xss2M", "-Dfoo=a b", "", "-Dx=1"]);
assert!(split_args(" ").is_empty());
}
#[test]
fn substitutes_vars() {
let mut vars = HashMap::new();
vars.insert("a", "1".to_string());
assert_eq!(substitute("x${a}y${b}z", &vars), "x1y${b}z");
}
#[test]
fn zgc_falls_back_on_java8() {
assert!(gc_args(GcPreset::Zgc, 8).contains(&"-XX:+UseG1GC".to_string()));
assert_eq!(gc_args(GcPreset::Zgc, 21), vec!["-XX:+UseZGC", "-XX:+ZGenerational"]);
assert_eq!(gc_args(GcPreset::Zgc, 25), vec!["-XX:+UseZGC"]);
}
fn installed(json: &str) -> Installed {
Installed {
version: serde_json::from_str::<VersionJson>(json).unwrap(),
java: "java".into(),
java_major: 17,
client_jar: "/v/c.jar".into(),
natives_dir: "/n".into(),
libraries: vec![],
assets_root: "/a".into(),
game_assets: "/a".into(),
assets_index: "5".into(),
logging_arg: None,
}
}
fn opts(join: bool) -> LaunchOptions {
LaunchOptions {
auth: Auth {
username: "Steve".into(),
uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(),
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
},
game_dir: "/g".into(),
memory_min_mb: 1024,
memory_max_mb: 4096,
gc: GcPreset::Default,
extra_jvm_args: vec!["-Dcustom=1".into()],
resolution: Some((1280, 720)),
fullscreen: false,
join_server: join.then(|| ("play.example.net".to_string(), 25565)),
version_label: "SCOPENET".into(),
launcher_name: "scopenet".into(),
launcher_version: "0.1.0".into(),
}
}
#[test]
fn builds_modern_command_with_quick_play() {
let json = r#"{"id":"1.20.1","mainClass":"net.minecraft.client.main.Main","arguments":{
"game":["--username","${auth_player_name}","--uuid","${auth_uuid}","--versionType","${version_type}",
{"rules":[{"action":"allow","features":{"has_custom_resolution":true}}],"value":["--width","${resolution_width}","--height","${resolution_height}"]},
{"rules":[{"action":"allow","features":{"is_quick_play_multiplayer":true}}],"value":["--quickPlayMultiplayer","${quickPlayMultiplayer}"]}],
"jvm":["-Djava.library.path=${natives_directory}","-cp","${classpath}"]}}"#;
let layout = Layout::new("/root");
let cmd = build(&installed(json), &layout, &opts(true));
let a = cmd.args.join(" ");
assert!(a.starts_with("-Xms1024M -Xmx4096M"));
assert!(a.contains("-Dcustom=1"));
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
assert!(a.contains("--width 1280 --height 720"));
assert!(a.contains("--quickPlayMultiplayer play.example.net:25565"));
assert!(!a.contains("--server"));
assert!(a.contains("--versionType SCOPENET"));
assert!(a.contains("-Djava.library.path=/n"));
}
#[test]
fn builds_legacy_command_with_server_flag() {
let json = r#"{"id":"1.12.2","mainClass":"net.minecraft.launchwrapper.Launch",
"minecraftArguments":"--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker"}"#;
let cmd = build(&installed(json), &Layout::new("/root"), &opts(true));
let a = cmd.args.join(" ");
assert!(a.contains("-Djava.library.path=/n -cp /v/c.jar net.minecraft.launchwrapper.Launch --username Steve"));
assert!(a.contains("--width 1280 --height 720"));
assert!(a.ends_with("--server play.example.net --port 25565"));
}
}
+28
View File
@@ -0,0 +1,28 @@
//! SCOPENET launcher engine.
//!
//! The Tauri app is a thin UI shell around this crate: everything that
//! downloads, installs or launches Minecraft lives here so it can be unit
//! tested without a window.
pub mod assets;
pub mod http;
pub mod install;
pub mod java;
pub mod launch;
pub mod libraries;
pub mod loaders;
pub mod maven;
pub mod meta;
pub mod msa;
pub mod options;
pub mod paths;
pub mod ping;
pub mod progress;
pub mod rules;
pub mod servers_dat;
pub mod sync;
pub mod sys;
pub mod version;
pub use paths::Layout;
pub use progress::{Event, Reporter, Stage};
+213
View File
@@ -0,0 +1,213 @@
//! Turning version-JSON libraries into files on disk + a classpath.
use crate::http::Download;
use crate::maven::Coord;
use crate::rules::{self, Env};
use crate::version::Library;
use anyhow::Result;
use std::io::Read;
use std::path::{Path, PathBuf};
pub const MOJANG_LIBRARIES: &str = "https://libraries.minecraft.net/";
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedLib {
pub name: String,
/// Path relative to the libraries dir (`/`-separated).
pub path: String,
/// `None` when the file must come from somewhere else (e.g. a Forge installer).
pub url: Option<String>,
pub sha1: Option<String>,
pub size: Option<u64>,
/// Goes on the classpath.
pub classpath: bool,
/// Contains native libraries that must be extracted.
pub native: bool,
pub exclude: Vec<String>,
}
impl ResolvedLib {
pub fn file(&self, libraries_dir: &Path) -> PathBuf {
libraries_dir.join(&self.path)
}
pub fn download(&self, libraries_dir: &Path) -> Option<Download> {
self.url.as_ref().map(|url| Download::new(url.clone(), self.file(libraries_dir), self.sha1.clone(), self.size))
}
}
fn join_url(base: &str, path: &str) -> String {
if base.ends_with('/') {
format!("{base}{path}")
} else {
format!("{base}/{path}")
}
}
pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
let mut out = Vec::new();
for lib in libs {
if let Some(rules) = &lib.rules {
if !rules::allowed(rules, env) {
continue;
}
}
let Some(coord) = Coord::parse(&lib.name) else { continue };
let exclude = lib.extract.as_ref().map(|e| e.exclude.clone()).unwrap_or_default();
// Legacy natives (LWJGL 2 / pre-1.19): a separate classifier jar
// that is extracted but never put on the classpath.
if let Some(natives) = &lib.natives {
if let Some(classifier) = natives.get(env.os) {
let classifier = classifier.replace("${arch}", env.bits());
let from_downloads = lib.downloads.as_ref().and_then(|d| d.classifiers.as_ref()).and_then(|c| c.get(&classifier));
let native_coord = coord.with_classifier(&classifier);
let (path, url, sha1, size) = match from_downloads {
Some(a) => (
a.path.clone().unwrap_or_else(|| native_coord.path()),
Some(a.url.clone()).filter(|u| !u.is_empty()),
a.sha1.clone(),
a.size,
),
None => {
let p = native_coord.path();
let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES);
(p.clone(), Some(join_url(base, &p)), None, None)
}
};
out.push(ResolvedLib {
name: format!("{}:{}", lib.name, classifier),
path,
url,
sha1,
size,
classpath: false,
native: true,
exclude: exclude.clone(),
});
}
// A natives-map library only has a main artifact if downloads.artifact exists.
let Some(artifact) = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) else { continue };
out.push(ResolvedLib {
name: lib.name.clone(),
path: artifact.path.clone().unwrap_or_else(|| coord.path()),
url: Some(artifact.url.clone()).filter(|u| !u.is_empty()),
sha1: artifact.sha1.clone(),
size: artifact.size,
classpath: true,
native: false,
exclude,
});
continue;
}
let is_native_jar = coord.classifier.as_deref().is_some_and(|c| c.starts_with("natives-"));
let resolved = match lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) {
Some(a) => ResolvedLib {
name: lib.name.clone(),
path: a.path.clone().filter(|p| !p.is_empty()).unwrap_or_else(|| coord.path()),
url: Some(a.url.clone()).filter(|u| !u.is_empty()),
sha1: a.sha1.clone(),
size: a.size,
classpath: true,
native: is_native_jar,
exclude,
},
None => {
if lib.downloads.is_some() {
// `downloads` present but no artifact: classifier-only entry we don't need.
continue;
}
let p = coord.path();
let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES);
ResolvedLib {
name: lib.name.clone(),
path: p.clone(),
url: Some(join_url(base, &p)),
sha1: lib.sha1.clone(),
size: lib.size,
classpath: true,
native: is_native_jar,
exclude,
}
}
};
out.push(resolved);
}
out
}
fn is_native_file(name: &str) -> bool {
let n = name.to_ascii_lowercase();
n.ends_with(".dll") || n.ends_with(".so") || n.ends_with(".dylib") || n.ends_with(".jnilib")
}
/// Extract native libraries (flattened) into `dest`.
pub fn extract_natives(libs: &[ResolvedLib], libraries_dir: &Path, dest: &Path) -> Result<()> {
std::fs::create_dir_all(dest)?;
for lib in libs.iter().filter(|l| l.native) {
let path = lib.file(libraries_dir);
let Ok(file) = std::fs::File::open(&path) else {
tracing::warn!("missing native jar {}", path.display());
continue;
};
let mut zip = zip::ZipArchive::new(file)?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
if name.starts_with("META-INF") || lib.exclude.iter().any(|e| name.starts_with(e.as_str())) {
continue;
}
if !is_native_file(&name) {
continue;
}
let file_name = name.rsplit('/').next().unwrap_or(&name);
// Skip natives for other architectures (LWJGL 3.3 ships several).
let lower = name.to_ascii_lowercase();
if cfg!(target_arch = "x86_64") && (lower.contains("arm64") || lower.contains("aarch64") || lower.contains("/x86/")) {
continue;
}
let target = dest.join(file_name);
if target.exists() && std::fs::metadata(&target).map(|m| m.len() == entry.size()).unwrap_or(false) {
continue;
}
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
std::fs::write(&target, buf)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::version::VersionJson;
use std::collections::HashMap;
#[test]
fn resolves_legacy_natives_and_maven_libs() {
let v: VersionJson = serde_json::from_str(
r#"{"id":"x","libraries":[
{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209",
"natives":{"linux":"natives-linux","windows":"natives-windows-${arch}"},
"extract":{"exclude":["META-INF/"]},
"downloads":{"classifiers":{"natives-windows-64":{"path":"p/w64.jar","sha1":"s","size":5,"url":"https://l/w64.jar"}}}},
{"name":"net.fabricmc:intermediary:1.20.1","url":"https://maven.fabricmc.net/"},
{"name":"com.mojang:only-mac:1","rules":[{"action":"allow","os":{"name":"osx"}}]},
{"name":"net.minecraftforge:forge:1.20.1-47.2.0:universal","downloads":{"artifact":{"path":"net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-universal.jar","url":"","sha1":"u","size":9}}}
]}"#,
)
.unwrap();
let env = Env { os: "windows", arch: "x86_64", features: HashMap::new() };
let libs = resolve(&v.libraries, &env);
assert_eq!(libs.len(), 3);
assert!(libs[0].native && !libs[0].classpath);
assert_eq!(libs[0].path, "p/w64.jar");
assert_eq!(libs[1].url.as_deref(), Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar"));
assert!(libs[2].url.is_none(), "empty url = bundled in installer");
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Fabric and Quilt both publish a ready-made launcher profile.
use crate::http;
use crate::meta::{FABRIC_META, QUILT_META};
use crate::paths::Layout;
use crate::version::VersionJson;
use anyhow::{Context, Result};
use scopenet_shared::Loader;
pub async fn profile(client: &reqwest::Client, layout: &Layout, loader: Loader, mc: &str, loader_version: &str) -> Result<VersionJson> {
let url = match loader {
Loader::Quilt => format!("{QUILT_META}/versions/loader/{mc}/{loader_version}/profile/json"),
_ => format!("{FABRIC_META}/versions/loader/{mc}/{loader_version}/profile/json"),
};
let cache = layout.cache().join("profiles").join(format!("{}-{mc}-{loader_version}.json", loader.as_str()));
let v: VersionJson = http::get_json_cached(client, &url, &cache)
.await
.with_context(|| format!("fetching {} {loader_version} profile for {mc}", loader.as_str()))?;
Ok(v)
}
+357
View File
@@ -0,0 +1,357 @@
//! Forge / NeoForge. Their installers ship a version JSON plus a list of
//! "processors" (small Java programs that patch the game jar). We replay
//! exactly what the official installer does, headlessly.
use crate::http::{self, Download};
use crate::libraries;
use crate::maven::Coord;
use crate::meta::forge_installer_url;
use crate::paths::Layout;
use crate::progress::{self, Event, Reporter, Stage};
use crate::rules::Env;
use crate::version::{Library, VersionJson};
use anyhow::{anyhow, bail, Context, Result};
use scopenet_shared::Loader;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, BufReader};
pub struct ForgeInstall<'a> {
pub client: &'a reqwest::Client,
pub layout: &'a Layout,
pub loader: Loader,
pub mc: &'a str,
/// Maven version, e.g. `1.20.1-47.2.0` (Forge) or `21.1.77` (NeoForge).
pub version: &'a str,
/// Console Java executable used to run processors.
pub java: &'a Path,
pub client_jar: &'a Path,
pub concurrency: usize,
pub reporter: &'a Reporter,
}
#[derive(Deserialize)]
struct InstallProfile {
#[serde(default = "default_json")]
json: String,
#[serde(default)]
data: HashMap<String, DataEntry>,
#[serde(default)]
processors: Vec<Processor>,
#[serde(default)]
libraries: Vec<Library>,
}
fn default_json() -> String {
"/version.json".into()
}
#[derive(Deserialize)]
struct DataEntry {
client: String,
}
#[derive(Deserialize)]
struct Processor {
#[serde(default)]
sides: Option<Vec<String>>,
jar: String,
#[serde(default)]
classpath: Vec<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
outputs: HashMap<String, String>,
}
type Zip = zip::ZipArchive<std::fs::File>;
fn read_entry(zip: &mut Zip, name: &str) -> Result<Vec<u8>> {
let mut entry = zip.by_name(name.trim_start_matches('/')).with_context(|| format!("installer is missing {name}"))?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
Ok(buf)
}
fn extract_entry(zip: &mut Zip, name: &str, dest: &Path) -> Result<bool> {
let Ok(mut entry) = zip.by_name(name.trim_start_matches('/')) else { return Ok(false) };
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf)?;
std::fs::write(dest, buf)?;
Ok(true)
}
fn marker_path(layout: &Layout, id: &str) -> PathBuf {
layout.version_dir(id).join(".scopenet-installed")
}
pub async fn install(ctx: ForgeInstall<'_>) -> Result<VersionJson> {
let label = if ctx.loader == Loader::NeoForge { "NeoForge" } else { "Forge" };
progress::stage(ctx.reporter, Stage::Loader, format!("Installing {label} {}", ctx.version));
let installer = ctx.layout.cache().join("installers").join(format!("{}-{}-installer.jar", ctx.loader.as_str(), ctx.version));
if !installer.exists() {
let url = forge_installer_url(ctx.loader, ctx.version);
http::download_one(ctx.client, &Download::new(url, installer.clone(), None, None), &|_| {}).await?;
}
let mut zip = zip::ZipArchive::new(std::fs::File::open(&installer)?).context("opening installer")?;
let profile_raw: serde_json::Value = serde_json::from_slice(&read_entry(&mut zip, "install_profile.json")?)?;
if profile_raw.get("versionInfo").is_some() {
return install_legacy(&ctx, &mut zip, profile_raw);
}
let profile: InstallProfile = serde_json::from_value(profile_raw)?;
let mut version: VersionJson = serde_json::from_slice(&read_entry(&mut zip, &profile.json)?)?;
let libs_dir = ctx.layout.libraries();
// Files bundled in the installer's maven/ folder.
let names: Vec<String> = zip.file_names().filter(|n| n.starts_with("maven/") && !n.ends_with('/')).map(String::from).collect();
for name in names {
let dest = libs_dir.join(name.trim_start_matches("maven/"));
if !dest.exists() {
extract_entry(&mut zip, &name, &dest)?;
}
}
// Libraries needed by the processors and by the game.
let env = Env::current();
let mut all_libs = libraries::resolve(&profile.libraries, &env);
all_libs.extend(libraries::resolve(&version.libraries, &env));
let mut downloads = Vec::new();
for lib in &all_libs {
let file = lib.file(&libs_dir);
if http::file_ok(&file, lib.sha1.as_deref(), lib.size, false) {
continue;
}
match lib.download(&libs_dir) {
Some(dl) => downloads.push(dl),
None => {
extract_entry(&mut zip, &format!("maven/{}", lib.path), &file)?;
}
}
}
downloads.dedup_by(|a, b| a.dest == b.dest);
http::download_all(ctx.client, downloads, ctx.concurrency, Stage::Loader, ctx.reporter).await?;
let marker = marker_path(ctx.layout, &version.id);
if std::fs::read_to_string(&marker).ok().as_deref() != Some(ctx.version) {
run_processors(&ctx, &mut zip, &profile, &installer).await?;
std::fs::create_dir_all(marker.parent().unwrap())?;
std::fs::write(&marker, ctx.version)?;
}
version.inherits_from = Some(ctx.mc.to_string());
save_version(ctx.layout, &version)?;
Ok(version)
}
fn save_version(layout: &Layout, version: &VersionJson) -> Result<()> {
let path = layout.version_json(&version.id);
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, serde_json::to_vec_pretty(version)?)?;
Ok(())
}
/// Forge ≤ 1.12: `install_profile.json` holds `install` + `versionInfo`.
fn install_legacy(ctx: &ForgeInstall<'_>, zip: &mut Zip, raw: serde_json::Value) -> Result<VersionJson> {
let info = raw.get("versionInfo").cloned().unwrap_or_default();
// Old profiles point at the long-dead files.minecraftforge.net/maven.
let fixed = serde_json::to_string(&info)?
.replace("http://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/")
.replace("https://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/");
let mut version: VersionJson = serde_json::from_str(&fixed)?;
let install = raw.get("install").ok_or_else(|| anyhow!("legacy installer missing 'install'"))?;
let coord = install.get("path").and_then(|v| v.as_str()).and_then(Coord::parse).ok_or_else(|| anyhow!("bad install.path"))?;
let file_path = install.get("filePath").and_then(|v| v.as_str()).ok_or_else(|| anyhow!("bad install.filePath"))?;
let dest = ctx.layout.libraries().join(coord.path());
if !dest.exists() {
extract_entry(zip, file_path, &dest)?;
}
version.inherits_from = Some(ctx.mc.to_string());
save_version(ctx.layout, &version)?;
Ok(version)
}
fn resolve_data_value(value: &str, libs_dir: &Path, tmp: &Path, zip: &mut Zip) -> Result<String> {
if value.starts_with('[') && value.ends_with(']') {
let coord = Coord::parse(value).ok_or_else(|| anyhow!("bad coordinate {value}"))?;
return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned());
}
if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') {
return Ok(value[1..value.len() - 1].to_string());
}
if value.starts_with('/') {
let dest = tmp.join(value.trim_start_matches('/'));
extract_entry(zip, value, &dest)?;
return Ok(dest.to_string_lossy().into_owned());
}
Ok(value.to_string())
}
fn resolve_arg(arg: &str, data: &HashMap<String, String>, libs_dir: &Path) -> Result<String> {
if arg.starts_with('[') && arg.ends_with(']') {
let coord = Coord::parse(arg).ok_or_else(|| anyhow!("bad coordinate {arg}"))?;
return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned());
}
if arg.starts_with('{') && arg.ends_with('}') {
let key = &arg[1..arg.len() - 1];
return data.get(key).cloned().ok_or_else(|| anyhow!("installer references unknown data key {key}"));
}
if arg.len() >= 2 && arg.starts_with('\'') && arg.ends_with('\'') {
return Ok(arg[1..arg.len() - 1].to_string());
}
let mut out = arg.to_string();
for (k, v) in data {
let needle = format!("{{{k}}}");
if out.contains(&needle) {
out = out.replace(&needle, v);
}
}
Ok(out)
}
pub fn jar_main_class(jar: &Path) -> Result<String> {
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).with_context(|| format!("opening {}", jar.display()))?)?;
let manifest = String::from_utf8_lossy(&read_entry(&mut zip, "META-INF/MANIFEST.MF")?).into_owned();
parse_main_class(&manifest).ok_or_else(|| anyhow!("{} has no Main-Class", jar.display()))
}
fn parse_main_class(manifest: &str) -> Option<String> {
// Manifest lines wrap at 72 bytes; continuation lines start with a space.
let mut unwrapped = String::new();
for line in manifest.lines() {
if let Some(rest) = line.strip_prefix(' ') {
unwrapped.push_str(rest);
} else {
unwrapped.push('\n');
unwrapped.push_str(line.trim_end_matches('\r'));
}
}
unwrapped.lines().find_map(|l| l.strip_prefix("Main-Class:").map(|v| v.trim().to_string()))
}
async fn run_processors(ctx: &ForgeInstall<'_>, zip: &mut Zip, profile: &InstallProfile, installer: &Path) -> Result<()> {
let libs_dir = ctx.layout.libraries();
let tmp = ctx.layout.cache().join("forge-tmp").join(ctx.version);
std::fs::create_dir_all(&tmp)?;
let mut data = HashMap::new();
for (key, entry) in &profile.data {
data.insert(key.clone(), resolve_data_value(&entry.client, &libs_dir, &tmp, zip)?);
}
data.insert("SIDE".into(), "client".into());
data.insert("MINECRAFT_JAR".into(), ctx.client_jar.to_string_lossy().into_owned());
data.insert("MINECRAFT_VERSION".into(), ctx.mc.to_string());
data.insert("ROOT".into(), ctx.layout.root.to_string_lossy().into_owned());
data.insert("INSTALLER".into(), installer.to_string_lossy().into_owned());
data.insert("LIBRARY_DIR".into(), libs_dir.to_string_lossy().into_owned());
let sep = if cfg!(windows) { ";" } else { ":" };
let processors: Vec<&Processor> =
profile.processors.iter().filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true)).collect();
let total = processors.len() as u32;
for (i, proc) in processors.into_iter().enumerate() {
ctx.reporter.as_ref()(Event::Progress {
stage: Stage::Loader,
done: i as u64,
total: total as u64,
files_done: i as u32,
files_total: total,
});
// Skip processors whose outputs already exist with the right hash.
if !proc.outputs.is_empty() {
let mut all_ok = true;
for (k, v) in &proc.outputs {
let path = resolve_arg(k, &data, &libs_dir)?;
let sha = resolve_arg(v, &data, &libs_dir)?;
if !http::file_ok(Path::new(&path), Some(&sha), None, true) {
all_ok = false;
break;
}
}
if all_ok {
continue;
}
}
let jar = libs_dir.join(Coord::parse(&proc.jar).ok_or_else(|| anyhow!("bad processor jar {}", proc.jar))?.path());
let main_class = jar_main_class(&jar)?;
let mut cp = vec![jar.to_string_lossy().into_owned()];
for c in &proc.classpath {
let coord = Coord::parse(c).ok_or_else(|| anyhow!("bad classpath entry {c}"))?;
cp.push(libs_dir.join(coord.path()).to_string_lossy().into_owned());
}
let args = proc.args.iter().map(|a| resolve_arg(a, &data, &libs_dir)).collect::<Result<Vec<_>>>()?;
let mut cmd = tokio::process::Command::new(ctx.java);
cmd.arg("-cp").arg(cp.join(sep)).arg(&main_class).args(&args);
cmd.current_dir(&tmp);
cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).kill_on_drop(true);
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
let mut child = cmd.spawn().with_context(|| format!("starting processor {main_class}"))?;
let tail = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<String>::new()));
let pump = |stream: Option<Box<dyn tokio::io::AsyncRead + Unpin + Send>>| {
let reporter = ctx.reporter.clone();
let tail = tail.clone();
async move {
let Some(stream) = stream else { return };
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
{
let mut t = tail.lock().unwrap();
t.push_back(line.clone());
if t.len() > 30 {
t.pop_front();
}
}
reporter(Event::Log { line });
}
}
};
let out = child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
let err = child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
let (status, _, _) = tokio::join!(child.wait(), pump(out), pump(err));
let status = status?;
if !status.success() {
let tail = tail.lock().unwrap().iter().cloned().collect::<Vec<_>>().join("\n");
bail!("{main_class} failed ({status}):\n{tail}");
}
}
std::fs::remove_dir_all(&tmp).ok();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_main_class_with_wrapping() {
let mf = "Manifest-Version: 1.0\r\nMain-Class: net.minecraftforge.binarypatcher.Consol\r\n eTool\r\nOther: x\r\n";
assert_eq!(parse_main_class(mf).as_deref(), Some("net.minecraftforge.binarypatcher.ConsoleTool"));
}
#[test]
fn resolves_processor_args() {
let libs = Path::new("/libs");
let mut data = HashMap::new();
data.insert("MC_SLIM".to_string(), "/libs/slim.jar".to_string());
assert_eq!(resolve_arg("{MC_SLIM}", &data, libs).unwrap(), "/libs/slim.jar");
assert_eq!(resolve_arg("--flag", &data, libs).unwrap(), "--flag");
assert_eq!(
resolve_arg("[net.minecraft:client:1.20.1:srg]", &data, libs).unwrap(),
Path::new("/libs").join("net/minecraft/client/1.20.1/client-1.20.1-srg.jar").to_string_lossy()
);
assert!(resolve_arg("{MISSING}", &data, libs).is_err());
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Mod loader installation. Each loader produces a child version JSON that
//! is merged on top of vanilla.
pub mod fabric;
pub mod forge;
+74
View File
@@ -0,0 +1,74 @@
/// A Maven coordinate: `group:artifact:version[:classifier][@ext]`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Coord {
pub group: String,
pub artifact: String,
pub version: String,
pub classifier: Option<String>,
pub ext: String,
}
impl Coord {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('[').trim_end_matches(']');
let (main, ext) = match s.split_once('@') {
Some((m, e)) => (m, e.to_string()),
None => (s, "jar".to_string()),
};
let parts: Vec<&str> = main.split(':').collect();
if parts.len() < 3 {
return None;
}
Some(Self {
group: parts[0].into(),
artifact: parts[1].into(),
version: parts[2].into(),
classifier: parts.get(3).map(|c| c.to_string()),
ext,
})
}
/// Relative repository path (`/`-separated).
pub fn path(&self) -> String {
let classifier = self.classifier.as_ref().map(|c| format!("-{c}")).unwrap_or_default();
format!(
"{}/{}/{}/{}-{}{}.{}",
self.group.replace('.', "/"),
self.artifact,
self.version,
self.artifact,
self.version,
classifier,
self.ext
)
}
/// Identity used to de-duplicate libraries (version-less).
pub fn key(&self) -> String {
match &self.classifier {
Some(c) => format!("{}:{}:{}", self.group, self.artifact, c),
None => format!("{}:{}", self.group, self.artifact),
}
}
pub fn with_classifier(&self, classifier: &str) -> Self {
Self { classifier: Some(classifier.to_string()), ..self.clone() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_coords() {
let c = Coord::parse("net.fabricmc:fabric-loader:0.16.9").unwrap();
assert_eq!(c.path(), "net/fabricmc/fabric-loader/0.16.9/fabric-loader-0.16.9.jar");
let c = Coord::parse("de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip").unwrap();
assert_eq!(c.path(), "de/oceanlabs/mcp/mcp_config/1.20.1-20230612.114412/mcp_config-1.20.1-20230612.114412.zip");
let c = Coord::parse("[net.minecraft:client:1.20.1-20230612.114412:slim]").unwrap();
assert_eq!(c.path(), "net/minecraft/client/1.20.1-20230612.114412/client-1.20.1-20230612.114412-slim.jar");
assert_eq!(c.key(), "net.minecraft:client:slim");
assert!(Coord::parse("nope").is_none());
}
}
+191
View File
@@ -0,0 +1,191 @@
//! 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"
);
}
}
+212
View File
@@ -0,0 +1,212 @@
//! Microsoft account sign-in (device-code flow → Xbox Live → Minecraft).
//!
//! Requires an Azure app registration ("client id") that Mojang has approved
//! for the Minecraft API. The admin configures it in the panel.
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::time::Duration;
const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const SCOPE: &str = "XboxLive.signin offline_access";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
#[serde(default = "default_interval")]
pub interval: u64,
#[serde(default)]
pub message: String,
}
fn default_interval() -> u64 {
5
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: Option<String>,
refresh_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MsaSession {
pub refresh_token: String,
pub mc_access_token: String,
/// Unix seconds.
pub mc_expires_at: i64,
pub profile: McProfile,
pub xuid: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McProfile {
pub id: String,
pub name: String,
#[serde(default)]
pub skins: Vec<McSkin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McSkin {
pub url: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub variant: Option<String>,
}
pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> {
let resp = client.post(DEVICE_CODE_URL).form(&[("client_id", client_id), ("scope", SCOPE)]).send().await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("Microsoft rejected the sign-in request: {body}");
}
Ok(resp.json().await?)
}
/// Poll until the user finishes signing in, then complete the full chain.
pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code: &DeviceCode) -> Result<MsaSession> {
let mut interval = code.interval.max(1);
let deadline = std::time::Instant::now() + Duration::from_secs(code.expires_in);
loop {
if std::time::Instant::now() > deadline {
bail!("the sign-in code expired, please try again");
}
tokio::time::sleep(Duration::from_secs(interval)).await;
let resp: TokenResponse = client
.post(TOKEN_URL)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("client_id", client_id),
("device_code", code.device_code.as_str()),
])
.send()
.await?
.json()
.await?;
match resp.error.as_deref() {
None => {
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
let refresh = resp.refresh_token.ok_or_else(|| anyhow!("no refresh token"))?;
return complete(client, &access, refresh).await;
}
Some("authorization_pending") => continue,
Some("slow_down") => interval += 5,
Some("authorization_declined") => bail!("sign-in was cancelled"),
Some("expired_token") => bail!("the sign-in code expired, please try again"),
Some(other) => bail!("Microsoft sign-in failed: {other} {}", resp.error_description.unwrap_or_default()),
}
}
}
/// Refresh a saved session (used before each launch when the Minecraft
/// token is close to expiring).
pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result<MsaSession> {
let resp: TokenResponse = client
.post(TOKEN_URL)
.form(&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", SCOPE)])
.send()
.await?
.json()
.await?;
if let Some(err) = resp.error {
bail!("your Microsoft session expired ({err}); please sign in again");
}
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
let refresh = resp.refresh_token.unwrap_or_else(|| refresh_token.to_string());
complete(client, &access, refresh).await
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct XboxResponse {
token: String,
display_claims: DisplayClaims,
}
#[derive(Deserialize)]
struct DisplayClaims {
xui: Vec<Xui>,
}
#[derive(Deserialize)]
struct Xui {
uhs: String,
#[serde(default)]
xid: Option<String>,
}
#[derive(Deserialize)]
struct McLogin {
access_token: String,
expires_in: i64,
}
async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result<MsaSession> {
// Xbox Live
let xbl: XboxResponse = client
.post("https://user.auth.xboxlive.com/user/authenticate")
.json(&json!({
"Properties": {"AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": format!("d={ms_access}")},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT"
}))
.send()
.await?
.error_for_status()
.context("Xbox Live authentication failed")?
.json()
.await?;
// XSTS
let resp = client
.post("https://xsts.auth.xboxlive.com/xsts/authorize")
.json(&json!({
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl.token]},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT"
}))
.send()
.await?;
if resp.status().as_u16() == 401 {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let msg = match body.get("XErr").and_then(|v| v.as_u64()) {
Some(2148916233) => "this Microsoft account has no Xbox profile yet — sign in once at xbox.com, then try again",
Some(2148916235) => "Xbox Live isn't available in your country",
Some(2148916236) | Some(2148916237) => "this account needs adult verification on xbox.com",
Some(2148916238) => "this is a child account — an adult must add it to a Microsoft family first",
_ => "Xbox Live refused the sign-in",
};
bail!("{msg}");
}
let xsts: XboxResponse = resp.error_for_status()?.json().await?;
let claims = xsts.display_claims.xui.first().ok_or_else(|| anyhow!("missing Xbox user hash"))?;
let uhs = claims.uhs.clone();
let xuid = claims.xid.clone();
// Minecraft
let mc: McLogin = client
.post("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(&json!({ "identityToken": format!("XBL3.0 x={uhs};{}", xsts.token) }))
.send()
.await?
.error_for_status()
.context("Minecraft services rejected the Xbox token (is the Azure app approved for Minecraft?)")?
.json()
.await?;
let resp = client.get("https://api.minecraftservices.com/minecraft/profile").bearer_auth(&mc.access_token).send().await?;
if resp.status().as_u16() == 404 {
bail!("this Microsoft account doesn't own Minecraft: Java Edition");
}
let profile: McProfile = resp.error_for_status()?.json().await?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
Ok(MsaSession { refresh_token, mc_access_token: mc.access_token, mc_expires_at: now + mc.expires_in, profile, xuid })
}
+54
View File
@@ -0,0 +1,54 @@
//! Writes player keybinds / preferences into Minecraft's `options.txt`.
use anyhow::Result;
use std::collections::BTreeMap;
use std::path::Path;
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
/// else the game has written. Only for 1.13+ (named key codes).
pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> {
let mut values: BTreeMap<String, String> = binds.iter().map(|(k, v)| (format!("key_{k}"), v.clone())).collect();
apply(game_dir, &mut values)
}
/// Merge arbitrary `name:value` options.
pub fn apply(game_dir: &Path, values: &mut BTreeMap<String, String>) -> Result<()> {
if values.is_empty() {
return Ok(());
}
let path = game_dir.join("options.txt");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut lines: Vec<String> = Vec::new();
for line in existing.lines() {
match line.split_once(':') {
Some((k, _)) if values.contains_key(k) => {
let v = values.remove(k).unwrap();
lines.push(format!("{k}:{v}"));
}
_ => lines.push(line.to_string()),
}
}
for (k, v) in values.iter() {
lines.push(format!("{k}:{v}"));
}
std::fs::create_dir_all(game_dir)?;
std::fs::write(path, lines.join("\n") + "\n")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merges_keybinds() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("options.txt"), "version:3465\nkey_key.forward:key.keyboard.w\nfov:0.0\n").unwrap();
let mut binds = BTreeMap::new();
binds.insert("key.forward".to_string(), "key.keyboard.up".to_string());
binds.insert("key.sprint".to_string(), "key.keyboard.left.control".to_string());
apply_keybinds(dir.path(), &binds).unwrap();
let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap();
assert_eq!(out, "version:3465\nkey_key.forward:key.keyboard.up\nfov:0.0\nkey_key.sprint:key.keyboard.left.control\n");
}
}
+104
View File
@@ -0,0 +1,104 @@
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(".."), "_");
}
}
+265
View File
@@ -0,0 +1,265 @@
//! Minecraft Server List Ping: online players, MOTD, version, latency.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[derive(Debug, Clone, Serialize, Default)]
pub struct ServerStatus {
pub online: bool,
pub version: String,
pub protocol: i64,
pub players_online: i64,
pub players_max: i64,
pub sample: Vec<String>,
/// MOTD using legacy `§` colour codes (rendered by the UI).
pub motd: String,
/// `data:image/png;base64,...`
pub favicon: Option<String>,
pub latency_ms: u64,
}
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
loop {
let mut byte = (value & 0x7f) as u8;
value = ((value as u32) >> 7) as i32;
if value != 0 {
byte |= 0x80;
}
buf.push(byte);
if value == 0 {
break;
}
}
}
async fn read_varint(stream: &mut TcpStream) -> Result<i32> {
let mut result = 0i32;
for i in 0..5 {
let byte = stream.read_u8().await?;
result |= ((byte & 0x7f) as i32) << (7 * i);
if byte & 0x80 == 0 {
return Ok(result);
}
}
bail!("varint too long")
}
fn packet(id: i32, body: &[u8]) -> Vec<u8> {
let mut inner = Vec::new();
write_varint(&mut inner, id);
inner.extend_from_slice(body);
let mut out = Vec::new();
write_varint(&mut out, inner.len() as i32);
out.extend(inner);
out
}
#[derive(Deserialize)]
struct RawStatus {
#[serde(default)]
version: Option<RawVersion>,
#[serde(default)]
players: Option<RawPlayers>,
#[serde(default)]
description: Option<serde_json::Value>,
#[serde(default)]
favicon: Option<String>,
}
#[derive(Deserialize)]
struct RawVersion {
#[serde(default)]
name: String,
#[serde(default)]
protocol: i64,
}
#[derive(Deserialize)]
struct RawPlayers {
#[serde(default)]
max: i64,
#[serde(default)]
online: i64,
#[serde(default)]
sample: Vec<RawSample>,
}
#[derive(Deserialize)]
struct RawSample {
#[serde(default)]
name: String,
}
fn color_code(name: &str) -> Option<char> {
Some(match name {
"black" => '0',
"dark_blue" => '1',
"dark_green" => '2',
"dark_aqua" => '3',
"dark_red" => '4',
"dark_purple" => '5',
"gold" => '6',
"gray" => '7',
"dark_gray" => '8',
"blue" => '9',
"green" => 'a',
"aqua" => 'b',
"red" => 'c',
"light_purple" => 'd',
"yellow" => 'e',
"white" => 'f',
_ => return None,
})
}
/// Flatten a chat component into a `§`-coded string.
pub fn flatten_chat(value: &serde_json::Value) -> String {
let mut out = String::new();
fn walk(v: &serde_json::Value, out: &mut String) {
match v {
serde_json::Value::String(s) => out.push_str(s),
serde_json::Value::Array(items) => items.iter().for_each(|i| walk(i, out)),
serde_json::Value::Object(map) => {
if let Some(c) = map.get("color").and_then(|c| c.as_str()) {
if let Some(code) = color_code(c) {
out.push('§');
out.push(code);
} else if c.starts_with('#') && c.len() == 7 {
// Hex colours: emit a custom marker the UI understands.
out.push_str("§#");
out.push_str(&c[1..]);
}
}
for (key, code) in [("bold", 'l'), ("italic", 'o'), ("underlined", 'n'), ("strikethrough", 'm')] {
if map.get(key).and_then(|b| b.as_bool()) == Some(true) {
out.push('§');
out.push(code);
}
}
if let Some(t) = map.get("text").and_then(|t| t.as_str()) {
out.push_str(t);
}
if let Some(extra) = map.get("extra") {
walk(extra, out);
}
if map.contains_key("color") {
out.push_str("§r");
}
}
_ => {}
}
}
walk(value, &mut out);
out
}
pub async fn ping(host: &str, port: u16) -> Result<ServerStatus> {
tokio::time::timeout(Duration::from_secs(5), ping_inner(host, port)).await.context("server did not respond in time")?
}
async fn ping_inner(host: &str, port: u16) -> Result<ServerStatus> {
let started = Instant::now();
let mut stream = TcpStream::connect((host, port)).await.with_context(|| format!("connecting to {host}:{port}"))?;
stream.set_nodelay(true).ok();
let connect_ms = started.elapsed().as_millis() as u64;
let mut body = Vec::new();
write_varint(&mut body, -1); // protocol: "whatever you are"
write_varint(&mut body, host.len() as i32);
body.extend_from_slice(host.as_bytes());
body.extend_from_slice(&port.to_be_bytes());
write_varint(&mut body, 1); // next state: status
stream.write_all(&packet(0x00, &body)).await?;
stream.write_all(&packet(0x00, &[])).await?;
let _len = read_varint(&mut stream).await?;
let id = read_varint(&mut stream).await?;
if id != 0x00 {
bail!("unexpected packet {id}");
}
let str_len = read_varint(&mut stream).await? as usize;
if str_len > 1 << 21 {
bail!("status response too large");
}
let mut json = vec![0u8; str_len];
stream.read_exact(&mut json).await?;
// Latency via ping/pong (fallback: TCP connect time).
let t = Instant::now();
let mut latency_ms = connect_ms;
if stream.write_all(&packet(0x01, &42i64.to_be_bytes())).await.is_ok() {
if let Ok(Ok(_)) = tokio::time::timeout(Duration::from_secs(2), async {
let _ = read_varint(&mut stream).await?;
let _ = read_varint(&mut stream).await?;
stream.read_i64().await.map_err(anyhow::Error::from)
})
.await
{
latency_ms = t.elapsed().as_millis() as u64;
}
}
let raw: RawStatus = serde_json::from_slice(&json).context("invalid status JSON")?;
let players = raw.players.unwrap_or(RawPlayers { max: 0, online: 0, sample: vec![] });
let version = raw.version.unwrap_or(RawVersion { name: String::new(), protocol: 0 });
Ok(ServerStatus {
online: true,
version: version.name,
protocol: version.protocol,
players_online: players.online,
players_max: players.max,
sample: players.sample.into_iter().map(|s| s.name).filter(|n| !n.is_empty()).take(12).collect(),
motd: raw.description.as_ref().map(flatten_chat).unwrap_or_default(),
favicon: raw.favicon,
latency_ms,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varints() {
let mut b = Vec::new();
write_varint(&mut b, 300);
assert_eq!(b, vec![0xac, 0x02]);
let mut b = Vec::new();
write_varint(&mut b, -1);
assert_eq!(b, vec![0xff, 0xff, 0xff, 0xff, 0x0f]);
}
#[test]
fn flattens_motd() {
let v: serde_json::Value =
serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap();
assert_eq!(flatten_chat(&v), "§b§lScope§rNet");
assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello");
}
#[tokio::test]
async fn pings_a_fake_server() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
// read handshake + request (we don't validate them here)
let mut buf = [0u8; 256];
let _ = sock.read(&mut buf).await.unwrap();
let json = br#"{"version":{"name":"1.21.1","protocol":767},"players":{"max":100,"online":7,"sample":[{"name":"Steve","id":"x"}]},"description":"A server"}"#;
let mut body = Vec::new();
write_varint(&mut body, json.len() as i32);
body.extend_from_slice(json);
sock.write_all(&packet(0, &body)).await.unwrap();
let mut ping = [0u8; 64];
let _ = sock.read(&mut ping).await;
sock.write_all(&packet(1, &42i64.to_be_bytes())).await.ok();
});
let s = ping("127.0.0.1", port).await.unwrap();
assert!(s.online);
assert_eq!(s.players_online, 7);
assert_eq!(s.version, "1.21.1");
assert_eq!(s.sample, vec!["Steve"]);
assert_eq!(s.motd, "A server");
}
}
+33
View File
@@ -0,0 +1,33 @@
use serde::Serialize;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Stage {
Preparing,
Java,
Game,
Loader,
Files,
Launching,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Event {
Stage { stage: Stage, label: String },
Progress { stage: Stage, done: u64, total: u64, files_done: u32, files_total: u32 },
Log { line: String },
}
/// Callback the engine uses to report progress. Must be cheap: it is called
/// from download tasks (throttled to ~10 Hz).
pub type Reporter = Arc<dyn Fn(Event) + Send + Sync>;
pub fn noop() -> Reporter {
Arc::new(|_| {})
}
pub fn stage(r: &Reporter, stage: Stage, label: impl Into<String>) {
r(Event::Stage { stage, label: label.into() });
}
+148
View File
@@ -0,0 +1,148 @@
//! 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)));
}
}
+85
View File
@@ -0,0 +1,85 @@
//! 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");
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Keeps an instance's mods/configs in sync with what the admin published.
//!
//! Files the panel sends are "managed": they get updated and removed when
//! the admin changes the instance. Anything the player adds themselves is
//! never touched.
use crate::http::{self, Download};
use crate::paths::safe_join;
use crate::progress::{self, Reporter, Stage};
use anyhow::Result;
use scopenet_shared::FileEntry;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SyncState {
pub revision: i64,
pub managed: Vec<String>,
}
fn state_path(game_dir: &Path) -> PathBuf {
game_dir.join(".scopenet").join("state.json")
}
pub fn load_state(game_dir: &Path) -> SyncState {
std::fs::read(state_path(game_dir)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
fn save_state(game_dir: &Path, state: &SyncState) -> Result<()> {
let path = state_path(game_dir);
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(path, serde_json::to_vec_pretty(state)?)?;
Ok(())
}
pub fn resolve_url(base: &str, url: &str) -> String {
if url.starts_with("http://") || url.starts_with("https://") {
url.to_string()
} else {
format!("{}/{}", base.trim_end_matches('/'), url.trim_start_matches('/'))
}
}
#[derive(Debug, Default, Serialize, Clone)]
pub struct SyncReport {
pub downloaded: usize,
pub removed: usize,
}
/// Sync `files` into `game_dir`.
///
/// Fast path: when the revision hasn't changed we only check that files
/// exist, so launching an up-to-date instance costs a few `stat` calls.
#[allow(clippy::too_many_arguments)]
pub async fn sync(
client: &reqwest::Client,
panel_base: &str,
game_dir: &Path,
revision: i64,
files: &[FileEntry],
concurrency: usize,
deep: bool,
reporter: &Reporter,
) -> Result<SyncReport> {
std::fs::create_dir_all(game_dir)?;
let state = load_state(game_dir);
let changed = state.revision != revision || deep;
progress::stage(reporter, Stage::Files, if changed { "Syncing instance files" } else { "Checking instance files" });
let mut wanted = HashSet::new();
let mut downloads = Vec::new();
for f in files {
let Some(dest) = safe_join(game_dir, &f.path) else {
tracing::warn!("skipping unsafe path from panel: {}", f.path);
continue;
};
wanted.insert(f.path.replace('\\', "/"));
let ok = if changed { http::file_ok(&dest, Some(&f.sha1), Some(f.size), true) } else { dest.exists() };
if !ok {
downloads.push(Download::new(resolve_url(panel_base, &f.url), dest, Some(f.sha1.clone()), Some(f.size)));
}
}
let downloaded = downloads.len();
http::download_all(client, downloads, concurrency, Stage::Files, reporter).await?;
let mut removed = 0;
for old in &state.managed {
if !wanted.contains(old) {
if let Some(path) = safe_join(game_dir, old) {
if std::fs::remove_file(&path).is_ok() {
removed += 1;
}
}
}
}
let mut managed: Vec<String> = wanted.into_iter().collect();
managed.sort();
save_state(game_dir, &SyncState { revision, managed })?;
Ok(SyncReport { downloaded, removed })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls() {
assert_eq!(resolve_url("https://panel.example/", "/files/a/mods/x.jar"), "https://panel.example/files/a/mods/x.jar");
assert_eq!(resolve_url("https://panel.example", "https://cdn.modrinth.com/x.jar"), "https://cdn.modrinth.com/x.jar");
}
#[tokio::test]
async fn removes_files_the_admin_dropped() {
let dir = tempfile::tempdir().unwrap();
let game = dir.path();
std::fs::create_dir_all(game.join("mods")).unwrap();
std::fs::write(game.join("mods/old.jar"), b"old").unwrap();
std::fs::write(game.join("mods/mine.jar"), b"player added").unwrap();
save_state(game, &SyncState { revision: 1, managed: vec!["mods/old.jar".into()] }).unwrap();
let client = crate::http::client();
let report = sync(&client, "http://unused", game, 2, &[], 4, false, &crate::progress::noop()).await.unwrap();
assert_eq!(report.removed, 1);
assert!(!game.join("mods/old.jar").exists());
assert!(game.join("mods/mine.jar").exists(), "player files are never touched");
assert_eq!(load_state(game).revision, 2);
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Tiny system probes (kept dependency-free to stay lightweight).
/// Total physical memory in MiB (best effort; 8 GiB if unknown).
pub fn total_memory_mb() -> u64 {
imp().unwrap_or(8192)
}
#[cfg(windows)]
fn imp() -> Option<u64> {
use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
status.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
let ok = unsafe { GlobalMemoryStatusEx(&mut status) };
(ok != 0).then_some(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()
}
+289
View File
@@ -0,0 +1,289 @@
//! 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());
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Real-network installs against Mojang / Fabric / Forge / NeoForge.
//! Ignored by default; CI runs them with `cargo test -p scopenet-core --test online -- --ignored`.
use scopenet_core::install::{install, InstallSpec};
use scopenet_core::launch::{self, Auth, GcPreset, LaunchOptions};
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 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 installed = install(&client, &layout, &spec, &progress::noop()).await.unwrap_or_else(|e| panic!("{mc} {loader:?}: {e:#}"));
for path in installed.classpath(&layout) {
assert!(path.exists(), "missing classpath entry {}", path.display());
}
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,
},
game_dir,
memory_min_mb: 512,
memory_max_mb: 2048,
gc: GcPreset::G1,
extra_jvm_args: vec![],
resolution: None,
fullscreen: false,
join_server: Some(("example.org".into(), 25565)),
version_label: "ci".into(),
launcher_name: "scopenet".into(),
launcher_version: "ci".into(),
};
let cmd = launch::build(&installed, &layout, &opts);
let joined = cmd.args.join(" ");
assert!(!joined.contains("${"), "unsubstituted placeholder in: {joined}");
// The downloaded runtime must actually run.
let out = std::process::Command::new(scopenet_core::java::sibling_exe(&installed.java, false)).arg("-version").output().unwrap();
assert!(out.status.success());
println!("{mc} {loader:?}: OK ({} classpath entries, java {})", installed.classpath(&layout).len(), installed.java_major);
}
#[tokio::test]
#[ignore]
async fn vanilla_latest_release() {
let m = scopenet_core::meta::mojang_manifest(&scopenet_core::http::client()).await.unwrap();
run(&m.latest.release, Loader::Vanilla).await;
}
#[tokio::test]
#[ignore]
async fn vanilla_legacy_1_8_9() {
run("1.8.9", Loader::Vanilla).await;
}
#[tokio::test]
#[ignore]
async fn fabric_1_21_1() {
run("1.21.1", Loader::Fabric).await;
}
#[tokio::test]
#[ignore]
async fn quilt_1_20_1() {
run("1.20.1", Loader::Quilt).await;
}
#[tokio::test]
#[ignore]
async fn forge_1_20_1() {
run("1.20.1", Loader::Forge).await;
}
#[tokio::test]
#[ignore]
async fn neoforge_1_21_1() {
run("1.21.1", Loader::NeoForge).await;
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "scopenet-shared"
description = "Types shared between the SCOPENET launcher and admin panel"
version.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
md-5.workspace = true
+403
View File
@@ -0,0 +1,403 @@
//! Wire types shared by the SCOPENET launcher and the admin panel.
//!
//! Everything the panel sends to the launcher is described here, so both
//! sides always agree on the JSON shape.
use serde::{Deserialize, Serialize};
pub const API_VERSION: u32 = 1;
// ---------------------------------------------------------------------------
// Branding / theming
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Branding {
pub name: String,
pub tagline: String,
pub logo_url: Option<String>,
pub icon_url: Option<String>,
pub background: Background,
pub colors: Palette,
/// One of the fonts bundled in the launcher: "Inter", "Outfit",
/// "Space Grotesk", "Plus Jakarta Sans".
pub font: String,
/// Corner radius in px used for cards and buttons.
pub radius: u8,
/// Frosted-glass surfaces (backdrop blur). Disable for very old GPUs.
pub glass: bool,
/// Shown on the Minecraft title screen / F3 (the `version_type` arg).
pub version_label: String,
pub news: Vec<NewsItem>,
pub links: Vec<SocialLink>,
pub features: Features,
/// Raw CSS appended to the launcher stylesheet (power users).
pub custom_css: String,
}
impl Default for Branding {
fn default() -> Self {
Self {
name: "SCOPENET".into(),
tagline: "Your worlds, one click away.".into(),
logo_url: None,
icon_url: None,
background: Background::default(),
colors: Palette::default(),
font: "Inter".into(),
radius: 10,
glass: false,
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: 55 }
}
}
#[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: "#6d6af5".into(),
accent_2: "#8b88f8".into(),
background: "#0d0e12".into(),
surface: "#16181e".into(),
text: "#ecedf1".into(),
muted: "#8b8f9a".into(),
success: "#3fb97c".into(),
danger: "#e5484d".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);
}
}
+36
View File
@@ -0,0 +1,36 @@
# SCOPENET admin panel
#
# cp .env.example .env # set ADMIN_PASSWORD at least
# docker compose up -d
#
# Then open http://localhost:8080 (or your domain behind a reverse proxy).
services:
panel:
image: ghcr.io/scopeddlol/scopenet-mc-panel:latest
# Or build from source: docker compose build
build: .
container_name: scopenet-panel
restart: unless-stopped
ports:
- "${PANEL_PORT:-8080}:8080"
environment:
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
# First start only: creates the admin account. If empty, a random
# password is printed in `docker compose logs panel`.
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
# Signs login tokens. Auto-generated into the data volume if empty.
JWT_SECRET: ${JWT_SECRET:-}
# Optional — can also be set in the panel's Settings page.
CURSEFORGE_API_KEY: ${CURSEFORGE_API_KEY:-}
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048}
RUST_LOG: ${RUST_LOG:-info}
volumes:
- panel-data:/data
# Very small footprint: the panel idles at ~10 MB RAM.
deploy:
resources:
limits:
memory: 256M
volumes:
panel-data:
+78
View File
@@ -0,0 +1,78 @@
# Admin guide
## Deploying the panel
```bash
cp .env.example .env # set ADMIN_PASSWORD
docker compose up -d
docker compose logs -f panel # first start prints the admin account
```
Everything the panel stores lives in the `panel-data` volume (`/data`):
| Path | What |
|---|---|
| `panel.db` | SQLite database (users, instances, settings, stats) |
| `files/<instance>/` | Configs and uploads hosted for launchers |
| `uploads/` | Logos, backgrounds, icons |
| `jwt.secret` | Token signing key (unless `JWT_SECRET` is set) |
**Backups:** stop the container (or use `sqlite3 panel.db ".backup backup.db"`) and copy the volume.
**Updating:** `docker compose pull && docker compose up -d`. Database migrations run automatically.
### HTTPS
Launchers talk to the panel over the internet, so serve it over HTTPS. Example with Caddy:
```caddyfile
panel.example.com {
reverse_proxy localhost:8080
}
```
Large modpack uploads go through the proxy — raise its body-size limit if needed (nginx: `client_max_body_size 2g;`).
## Instances
An instance = one playable profile: a Minecraft version, an optional mod loader, files (mods, configs…) and an optional server.
- **Version** — choose any Minecraft version and Vanilla / Fabric / Quilt / Forge / NeoForge. Leave the loader version empty for the latest recommended build (it's pinned when you save).
- **Modrinth** — search, pick a version, *Use*. Mods are downloaded by players straight from Modrinth's CDN; configs (`overrides/`) are hosted by the panel.
- **CurseForge** — needs a free API key from [console.curseforge.com](https://console.curseforge.com) (Settings → Integrations, or `CURSEFORGE_API_KEY`). Some authors block third-party downloads; those files show up under **Files** with a link — download them yourself and upload them into the same folder.
- **Upload .zip** — a `.mrpack`, a CurseForge export, or any zipped instance folder (with `mods/`, `config/`, … or a `.minecraft/` inside). For plain zips, pick the Minecraft version and loader.
- **Files** — add or remove individual files at any time. Files players add themselves are never touched; files you remove are deleted from players' instances on their next launch.
Every save bumps the instance **revision**; launchers re-verify files when it changes, otherwise launching is instant.
### Built-in server
On the **Server** tab: name, address, port.
- *Add to multiplayer list* writes the server into `servers.dat` (keeping servers players added).
- *Join automatically* connects on start (Quick Play on 1.20+, `--server` on older versions).
Your server must allow the accounts you use: offline-mode (`online-mode=false`) for panel/offline accounts, or online-mode for Microsoft accounts. Protect offline-mode servers with a whitelist or an auth plugin.
### Access
- **Everyone** — any launcher, including offline and Microsoft accounts.
- **Signed-in players** — any panel account.
- **Specific groups** — members of the selected groups (create groups on the Players page). Admins see everything.
## Players
- **Sign-ups:** Settings → *Closed* (admins create accounts), *Needs approval*, or *Open*.
- Panel accounts play under their username with the offline-mode UUID an offline server computes, so inventories and permissions stay consistent.
- Disabling an account signs it out everywhere on the next request.
- Ten failed logins lock an account for five minutes.
## Launcher design
Everything on this page is pushed to launchers when they start or refresh — no reinstall. The **Features** tab lets you allow or block players from changing the theme or Java settings. **Custom CSS** is injected last; handy variables are `--accent`, `--accent-2`, `--surface`, `--radius`.
## Releasing the launcher
See the README's *Build your branded launcher*. Players get updates automatically: the launcher checks GitHub Releases on start and offers the new installer.
**Code signing:** unsigned installers trigger a Windows SmartScreen warning ("More info → Run anyway"). To avoid it, sign the installer with a code-signing certificate (e.g. Azure Trusted Signing) — Tauri supports this via `bundle.windows.signCommand`.
+60
View File
@@ -0,0 +1,60 @@
# Architecture
```
┌──────────────────────────┐ HTTPS (JSON) ┌──────────────────────────┐
│ Launcher (Tauri) │ ─────────────────────────▶ │ Panel (Axum + SQLite) │
│ Svelte UI ⇄ Rust shell │ manifest, instances, │ Svelte admin UI │
│ │ │ auth, stats │ /files, /uploads │
│ scopenet-core engine │ └──────────────────────────┘
│ ├─ Mojang / Java │ ──▶ piston-meta, libraries.minecraft.net, resources
│ ├─ Fabric/Quilt/Forge │ ──▶ meta.fabricmc.net, maven.minecraftforge.net, …
│ └─ modpack files │ ──▶ Modrinth/CurseForge CDNs, panel /files
└──────────────────────────┘
```
- **`crates/shared`** defines every JSON type exchanged, so the panel and launcher can't drift apart.
- **`crates/core`** has no UI dependencies. It's unit-tested, and CI runs real installs of vanilla, legacy, Fabric, Quilt, Forge and NeoForge against the live servers.
- The **panel** also uses `core` for version and loader lookups.
## Launch pipeline
1. Resolve the account (refreshing Microsoft tokens if needed).
2. Fetch the instance from the panel (cached for offline play).
3. `install`: vanilla version JSON → Java runtime (Mojang's own builds) → client jar, libraries, assets → loader profile (Fabric/Quilt), or replay the Forge/NeoForge installer's processors → merge → natives.
4. `sync`: download the admin's files; remove files the admin removed; never touch the player's own files. With an unchanged revision, only existence is checked.
5. Write `servers.dat` and keybinds, build the Java command (quick play / `--server`, memory, GC presets, `@argfile` for long classpaths), and start the game.
6. Stream logs to the console; on a crash, show the tail of the log with hints.
## Disk layout (launcher)
```
%APPDATA%\net.scopenet.launcher\
settings.json, accounts.json, secrets.bin (encrypted), manifest.json (cache)
minecraft\
versions\ libraries\ assets\ runtimes\ natives\ cache\
instances\<id>\ ← game directory (saves, mods, options.txt, …)
```
## HTTP API
Public (used by launchers). Send `Authorization: Bearer <token>` to see restricted instances:
| Method | Path | |
|---|---|---|
| GET | `/api/v1/launcher/manifest` | Branding, auth options, visible instances |
| GET | `/api/v1/launcher/instances/{id}` | Instance + file list |
| POST | `/api/v1/launcher/events` | Launch stats |
| POST | `/api/v1/auth/login` · `/register` | Panel accounts |
| GET | `/api/v1/auth/me` | Current user |
| GET | `/files/{instance}/{path}` | Hosted instance files |
| GET | `/healthz` | Health check |
Admin (`role = admin`): `/api/admin/{stats,users,groups,branding,settings,instances,uploads}` plus `instances/{id}/{import/modrinth,import/curseforge,import/upload,files,reset}` and meta/search proxies under `/api/admin/{meta,modrinth,curseforge}`.
## Security notes
- Passwords are hashed with Argon2id; tokens are HS256 JWTs (30 days); disabled accounts are rejected on every request.
- Server-provided paths are sanitised (`safe_join`) on both sides — no `..` or absolute paths.
- The panel never returns the CurseForge key; SVG uploads are refused (script risk).
- The launcher's CSP only allows scripts from the app itself; media may load from any HTTPS host (admin-chosen images).
- Hosted `/files` are public URLs. Don't put secrets in instance files.
+36
View File
@@ -0,0 +1,36 @@
# Development
**Requirements:** Rust (stable), Node 22. On Linux, also the Tauri deps: `libwebkit2gtk-4.1-dev librsvg2-dev libayatana-appindicator3-dev`.
## Panel
```bash
cd panel/web && npm install && npm run build && cd ../..
ADMIN_PASSWORD=adminadmin cargo run -p scopenet-panel # http://localhost:8080
# UI hot reload: (cd panel/web && npm run dev) # http://localhost:5173, proxies /api
```
## Launcher
```bash
cd launcher && npm install
npm run tauri dev # full desktop app
npm run dev # UI only in a browser, with a mock backend (http://localhost:1420)
```
Browser mock scenarios: `?mock=setup`, `?mock=login`, `?mock=progress`.
Build-time options (environment variables when building): `SCOPENET_PANEL_URL`, `SCOPENET_LOCK_PANEL=1`, `SCOPENET_REPO=owner/repo`.
## Tests
```bash
cargo test --workspace # unit + panel API tests
cargo test -p scopenet-core --test online -- --ignored # real installs (needs internet, ~2 GB)
(cd panel/web && npm run check) && (cd launcher && npm run check)
cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings
```
## Releasing
Push a tag `vX.Y.Z` or run the **Release** workflow. It stamps the version into `Cargo.toml` and `tauri.conf.json`, builds the NSIS installer on Windows, pushes the multi-arch panel image to GHCR, and publishes the release.
+25
View File
@@ -0,0 +1,25 @@
# Microsoft sign-in
Microsoft accounts give players their real skin and let them join online-mode servers. It needs an Azure app registration that Mojang has approved for the Minecraft API.
## 1. Register an app
1. Go to [portal.azure.com](https://portal.azure.com) → **Microsoft Entra ID → App registrations → New registration**.
2. Name: your launcher's name. Supported account types: **Personal Microsoft accounts only**.
3. Redirect URI: leave empty.
4. After creating it, open **Authentication** → enable **Allow public client flows** (needed for the device-code flow) → Save.
5. Copy the **Application (client) ID**.
## 2. Request Minecraft API access
New apps can't call the Minecraft services API until Mojang approves them. Submit the form at <https://aka.ms/mce-reviewappid> with your client ID. Approval can take a while; until then sign-in fails with *"Minecraft services rejected the Xbox token"*.
## 3. Enable it in the panel
**Settings → Microsoft accounts** → turn on *Sign in with Microsoft* and paste the client ID. Launchers show the Microsoft tab on their next refresh.
## How it works
The launcher uses the OAuth **device-code flow**: it shows a short code, opens `microsoft.com/link`, and waits. The chain is Microsoft → Xbox Live → XSTS → Minecraft services → profile. The refresh token is stored encrypted on the player's PC and renewed silently before launches. The panel never sees Microsoft credentials.
Common errors are translated for players (no Xbox profile yet, child account, game not owned).
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>SCOPENET Launcher</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1501
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "scopenet-launcher",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"tauri": "tauri"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
"@fontsource-variable/space-grotesk": "^5.3.0",
"@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.6.0"
},
"devDependencies": {
"@lucide/svelte": "^1.48.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tauri-apps/cli": "^2.12.0",
"@tsconfig/svelte": "^5.0.8",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"typescript": "~5.9.0",
"vite": "^8.3.1"
}
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "scopenet-launcher"
description = "SCOPENET Minecraft launcher"
version.workspace = true
edition.workspace = true
[lib]
name = "scopenet_launcher_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
scopenet-shared.workspace = true
scopenet-core.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
futures.workspace = true
uuid.workspace = true
base64.workspace = true
rand.workspace = true
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
chacha20poly1305 = "0.10"
# Secrets are encrypted with a key kept in the OS credential store.
[target.'cfg(windows)'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] }
+7
View File
@@ -0,0 +1,7 @@
fn main() {
// Re-run when build-time branding changes (set by CI).
for var in ["SCOPENET_PANEL_URL", "SCOPENET_REPO", "SCOPENET_LOCK_PANEL"] {
println!("cargo:rerun-if-env-changed={var}");
}
tauri_build::build()
}
@@ -0,0 +1,20 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissions for the main launcher window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-unminimize",
"core:window:allow-start-dragging",
"core:window:allow-is-maximized",
"dialog:allow-open",
"opener:allow-open-url"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><rect x="48" y="48" width="928" height="928" rx="208" fill="#16181e"/><circle cx="512" cy="512" r="236" fill="none" stroke="#ecedf1" stroke-width="64"/><circle cx="512" cy="512" r="72" fill="#6d6af5"/><path d="M512 196v120M512 708v120M196 512h120M708 512h120" stroke="#ecedf1" stroke-width="64" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 390 B

+259
View File
@@ -0,0 +1,259 @@
//! Player accounts: panel (username/password), Microsoft, and local offline.
use crate::secrets::Secret;
use crate::state::AppState;
use anyhow::{anyhow, bail, Context, Result};
use scopenet_shared::{offline_uuid, valid_username, AuthResponse, LoginRequest, RegisterRequest};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Account {
pub id: String,
/// "panel" | "microsoft" | "offline"
pub kind: String,
pub username: String,
pub uuid: String,
/// Panel accounts: which panel they belong to.
#[serde(default)]
pub panel_url: Option<String>,
#[serde(default)]
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AccountsFile {
pub accounts: Vec<Account>,
pub active: Option<String>,
}
impl AccountsFile {
pub fn load(path: &Path) -> Self {
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
pub fn save(&self, path: &Path) -> Result<()> {
std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
Ok(())
}
pub fn active(&self) -> Option<&Account> {
let id = self.active.as_ref()?;
self.accounts.iter().find(|a| &a.id == id)
}
/// Add or replace (same kind + uuid + panel) and make it active.
pub fn upsert(&mut self, account: Account) -> Account {
if let Some(existing) =
self.accounts.iter_mut().find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
{
let id = existing.id.clone();
*existing = Account { id: id.clone(), ..account };
self.active = Some(id);
return existing.clone();
}
self.active = Some(account.id.clone());
self.accounts.push(account.clone());
account
}
}
fn now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
}
async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body: serde_json::Value = resp.json().await.unwrap_or_default();
anyhow!("{}", body.get("error").and_then(|e| e.as_str()).map(String::from).unwrap_or_else(|| format!("panel returned {status}")))
}
pub async fn login_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
let resp = state
.http
.post(format!("{panel}/api/v1/auth/login"))
.json(&LoginRequest { username: username.trim().into(), password: password.into() })
.send()
.await
.context("couldn't reach the server")?;
if !resp.status().is_success() {
return Err(panel_error(resp).await);
}
let auth: AuthResponse = resp.json().await?;
save_panel_account(state, &panel, auth)
}
pub async fn register_panel(state: &AppState, username: &str, password: &str, email: Option<String>) -> Result<(Option<Account>, bool)> {
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
let resp = state
.http
.post(format!("{panel}/api/v1/auth/register"))
.json(&RegisterRequest { username: username.trim().into(), password: password.into(), email })
.send()
.await
.context("couldn't reach the server")?;
if !resp.status().is_success() {
return Err(panel_error(resp).await);
}
let auth: AuthResponse = resp.json().await?;
if auth.pending {
return Ok((None, true));
}
Ok((Some(save_panel_account(state, &panel, auth)?), false))
}
fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "panel".into(),
username: auth.user.username.clone(),
uuid: auth.user.uuid.clone(),
panel_url: Some(panel.to_string()),
role: Some(auth.user.role.clone()),
};
let account = state.accounts.write().unwrap().upsert(account);
state.secrets.set(&account.id, Secret { panel_token: Some(auth.token), ..Default::default() })?;
state.save_accounts()?;
Ok(account)
}
pub fn add_offline(state: &AppState, username: &str) -> Result<Account> {
let allowed = state.manifest.read().unwrap().as_ref().map(|m| m.auth.offline_local).unwrap_or(true);
if !allowed {
bail!("offline accounts are disabled on this server");
}
let name = username.trim();
if !valid_username(name) {
bail!("usernames are 3–16 letters, numbers or underscores");
}
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "offline".into(),
username: name.into(),
uuid: offline_uuid(name),
panel_url: None,
role: None,
};
let account = state.accounts.write().unwrap().upsert(account);
state.save_accounts()?;
Ok(account)
}
pub fn ms_client_id(state: &AppState) -> Result<String> {
state
.manifest
.read()
.unwrap()
.as_ref()
.and_then(|m| m.auth.microsoft.then(|| m.auth.microsoft_client_id.clone()).flatten())
.filter(|c| !c.is_empty())
.ok_or_else(|| anyhow!("Microsoft sign-in isn't enabled on this server"))
}
pub fn save_ms_session(state: &AppState, session: scopenet_core::msa::MsaSession) -> Result<Account> {
let uuid = dashed(&session.profile.id);
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "microsoft".into(),
username: session.profile.name.clone(),
uuid,
panel_url: None,
role: None,
};
let account = state.accounts.write().unwrap().upsert(account);
state.secrets.set(
&account.id,
Secret {
ms_refresh_token: Some(session.refresh_token),
mc_access_token: Some(session.mc_access_token),
mc_expires_at: session.mc_expires_at,
xuid: session.xuid,
..Default::default()
},
)?;
state.save_accounts()?;
Ok(account)
}
fn dashed(id: &str) -> String {
let id = id.replace('-', "");
if id.len() != 32 {
return id;
}
format!("{}-{}-{}-{}-{}", &id[0..8], &id[8..12], &id[12..16], &id[16..20], &id[20..32])
}
/// Credentials passed to the game.
pub async fn game_auth(state: &AppState, account: &Account) -> Result<scopenet_core::launch::Auth> {
use scopenet_core::launch::Auth;
match account.kind.as_str() {
"microsoft" => {
let mut secret = state.secrets.get(&account.id);
if secret.mc_access_token.is_none() || secret.mc_expires_at < now() + 300 {
let refresh = secret.ms_refresh_token.clone().ok_or_else(|| anyhow!("please sign in to Microsoft again"))?;
let client_id = ms_client_id(state)?;
let session = scopenet_core::msa::refresh(&state.http, &client_id, &refresh).await?;
if session.profile.name != account.username {
let mut accounts = state.accounts.write().unwrap();
if let Some(a) = accounts.accounts.iter_mut().find(|a| a.id == account.id) {
a.username = session.profile.name.clone();
}
}
state.save_accounts().ok();
secret = Secret {
ms_refresh_token: Some(session.refresh_token),
mc_access_token: Some(session.mc_access_token),
mc_expires_at: session.mc_expires_at,
xuid: session.xuid,
..secret
};
state.secrets.set(&account.id, secret.clone())?;
}
let active_name = state.accounts.read().unwrap().accounts.iter().find(|a| a.id == account.id).map(|a| a.username.clone());
Ok(Auth {
username: active_name.unwrap_or_else(|| account.username.clone()),
uuid: account.uuid.clone(),
access_token: secret.mc_access_token.unwrap_or_default(),
user_type: "msa".into(),
xuid: secret.xuid,
})
}
_ => Ok(Auth {
username: account.username.clone(),
uuid: account.uuid.clone(),
// Offline mode: any token works; servers in offline mode ignore it.
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
}),
}
}
/// Panel token for requests, when the active account belongs to this panel.
pub fn panel_token(state: &AppState) -> Option<String> {
let panel = state.panel_url()?;
let accounts = state.accounts.read().unwrap();
let active = accounts.active()?;
if active.kind != "panel" || active.panel_url.as_deref() != Some(panel.as_str()) {
return None;
}
state.secrets.get(&active.id).panel_token
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upsert_replaces_same_identity() {
let mut f = AccountsFile::default();
let a = Account { id: "1".into(), kind: "offline".into(), username: "Steve".into(), uuid: "u".into(), panel_url: None, role: None };
f.upsert(a.clone());
f.upsert(Account { id: "2".into(), ..a.clone() });
assert_eq!(f.accounts.len(), 1);
assert_eq!(f.active.as_deref(), Some("1"));
assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
}
}
+343
View File
@@ -0,0 +1,343 @@
//! Everything the UI can ask the backend to do.
use crate::accounts::{self, Account};
use crate::game;
use crate::settings::Settings;
use crate::state::{build, AppState};
use crate::updater;
use scopenet_core::msa::DeviceCode;
use scopenet_core::ping::ServerStatus;
use scopenet_shared::LauncherManifest;
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use tauri_plugin_opener::OpenerExt;
/// Commands return `Err(String)`; the UI shows it as-is.
type Res<T> = Result<T, String>;
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
/// anyhow's alternate format keeps the context chain readable.
fn aerr(e: anyhow::Error) -> String {
format!("{e:#}")
}
#[derive(Serialize)]
pub struct AppInfo {
version: String,
default_panel_url: Option<String>,
panel_locked: bool,
repo: Option<String>,
os: String,
arch: String,
total_ram_mb: u64,
data_dir: String,
}
#[derive(Serialize)]
pub struct Bootstrap {
app: AppInfo,
panel_url: Option<String>,
settings: Settings,
accounts: Vec<Account>,
active_account: Option<String>,
manifest: Option<LauncherManifest>,
game_running: Option<String>,
}
#[tauri::command]
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
let accounts = state.accounts.read().unwrap().clone();
Bootstrap {
app: AppInfo {
version: env!("CARGO_PKG_VERSION").into(),
default_panel_url: build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from),
panel_locked: build::panel_locked(),
repo: build::REPO.map(String::from),
os: std::env::consts::OS.into(),
arch: std::env::consts::ARCH.into(),
total_ram_mb: scopenet_core::sys::total_memory_mb(),
data_dir: state.data_dir.to_string_lossy().into_owned(),
},
panel_url: state.panel_url(),
settings: state.settings.read().unwrap().clone(),
accounts: accounts.accounts,
active_account: accounts.active,
manifest: state.manifest.read().unwrap().clone(),
game_running: state.game.lock().unwrap().as_ref().map(|g| g.instance_id.clone()),
}
}
async fn fetch_manifest(state: &AppState, panel: &str) -> anyhow::Result<LauncherManifest> {
let mut req = state.http.get(format!("{panel}/api/v1/launcher/manifest"));
if let Some(t) = accounts::panel_token(state) {
req = req.bearer_auth(t);
}
let resp = req.send().await.map_err(|e| anyhow::anyhow!("couldn't reach {panel}: {e}"))?;
if !resp.status().is_success() {
anyhow::bail!("{panel} returned {}", resp.status());
}
let m: LauncherManifest = resp.json().await.map_err(|_| anyhow::anyhow!("{panel} doesn't look like a SCOPENET panel"))?;
Ok(m)
}
#[tauri::command]
pub async fn refresh_manifest(state: State<'_, AppState>) -> Res<LauncherManifest> {
let panel = state.panel_url().ok_or("no panel configured")?;
let m = fetch_manifest(&state, &panel).await.map_err(aerr)?;
state.cache_manifest(&m);
Ok(m)
}
#[tauri::command]
pub async fn set_panel_url(state: State<'_, AppState>, url: String) -> Res<LauncherManifest> {
if build::panel_locked() {
return Err("this launcher is locked to its server".into());
}
let mut url = url.trim().trim_end_matches('/').to_string();
if !url.starts_with("http://") && !url.starts_with("https://") {
url = format!("https://{url}");
}
let m = fetch_manifest(&state, &url).await.map_err(aerr)?;
state.settings.write().unwrap().panel_url = Some(url);
state.save_settings().map_err(aerr)?;
state.cache_manifest(&m);
Ok(m)
}
#[tauri::command]
pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Res<()> {
let mut current = state.settings.write().unwrap();
let panel = current.panel_url.clone();
*current = Settings { panel_url: panel, ..settings };
drop(current);
state.save_settings().map_err(aerr)
}
// ---- accounts ----
#[tauri::command]
pub async fn login_panel(state: State<'_, AppState>, username: String, password: String) -> Res<Account> {
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
}
#[derive(Serialize)]
pub struct RegisterResult {
account: Option<Account>,
pending: bool,
}
#[tauri::command]
pub async fn register_panel(state: State<'_, AppState>, username: String, password: String, email: Option<String>) -> Res<RegisterResult> {
let (account, pending) = accounts::register_panel(&state, &username, &password, email).await.map_err(aerr)?;
Ok(RegisterResult { account, pending })
}
#[tauri::command]
pub fn add_offline(state: State<'_, AppState>, username: String) -> Res<Account> {
accounts::add_offline(&state, &username).map_err(aerr)
}
#[tauri::command]
pub async fn ms_start(state: State<'_, AppState>) -> Res<DeviceCode> {
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
let code = scopenet_core::msa::start_device_code(&state.http, &client_id).await.map_err(aerr)?;
*state.device_code.lock().unwrap() = Some(code.clone());
Ok(code)
}
#[tauri::command]
pub async fn ms_finish(state: State<'_, AppState>) -> Res<Account> {
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
let code = state.device_code.lock().unwrap().clone().ok_or("start the sign-in first")?;
let (tx, rx) = tokio::sync::oneshot::channel();
*state.ms_cancel.lock().unwrap() = Some(tx);
let session = tokio::select! {
r = scopenet_core::msa::finish_device_code(&state.http, &client_id, &code) => r.map_err(aerr)?,
_ = rx => return Err("cancelled".into()),
};
*state.device_code.lock().unwrap() = None;
accounts::save_ms_session(&state, session).map_err(aerr)
}
#[tauri::command]
pub fn ms_cancel(state: State<'_, AppState>) {
if let Some(tx) = state.ms_cancel.lock().unwrap().take() {
tx.send(()).ok();
}
}
#[tauri::command]
pub fn select_account(state: State<'_, AppState>, id: String) -> Res<()> {
let mut accounts = state.accounts.write().unwrap();
if !accounts.accounts.iter().any(|a| a.id == id) {
return Err("account not found".into());
}
accounts.active = Some(id);
drop(accounts);
state.save_accounts().map_err(aerr)
}
#[tauri::command]
pub fn remove_account(state: State<'_, AppState>, id: String) -> Res<()> {
let mut accounts = state.accounts.write().unwrap();
accounts.accounts.retain(|a| a.id != id);
if accounts.active.as_deref() == Some(id.as_str()) {
accounts.active = accounts.accounts.first().map(|a| a.id.clone());
}
drop(accounts);
state.secrets.remove(&id).map_err(aerr)?;
state.save_accounts().map_err(aerr)
}
// ---- game ----
#[tauri::command]
pub fn launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
start_task(app, &state, instance_id, true, false)
}
#[tauri::command]
pub fn repair_instance(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
start_task(app, &state, instance_id, false, true)
}
fn start_task(app: AppHandle, state: &AppState, instance_id: String, start: bool, deep: bool) -> Res<()> {
if state.game.lock().unwrap().is_some() {
return Err("Minecraft is already running".into());
}
let mut task = state.task.lock().unwrap();
if task.as_ref().is_some_and(|t| !t.is_finished()) {
return Err("already preparing an instance".into());
}
let app2 = app.clone();
let handle = tauri::async_runtime::spawn(async move {
if let Err(e) = game::run(app2.clone(), instance_id.clone(), start, deep).await {
tracing::error!("launch failed: {e:#}");
game::emit_state(&app2, &instance_id, "error", Some(format!("{e:#}")));
}
});
*task = Some(handle.inner().abort_handle());
Ok(())
}
#[tauri::command]
pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) {
if let Some(t) = state.task.lock().unwrap().take() {
t.abort();
}
game::emit_state(&app, &instance_id, "idle", Some("Cancelled".into()));
}
#[tauri::command]
pub fn kill_game(state: State<'_, AppState>) {
if let Some(g) = state.game.lock().unwrap().take() {
g.kill.send(()).ok();
}
}
#[derive(Serialize)]
pub struct InstanceLocal {
installed: bool,
revision: i64,
size: u64,
}
#[tauri::command]
pub async fn instance_local(state: State<'_, AppState>, instance_id: String) -> Res<InstanceLocal> {
let dir = state.layout.instance_dir(&instance_id);
let s = scopenet_core::sync::load_state(&dir);
let size = tokio::task::spawn_blocking(move || scopenet_core::sys::dir_size(&dir)).await.unwrap_or(0);
Ok(InstanceLocal { installed: s.revision > 0, revision: s.revision, size })
}
#[tauri::command]
pub async fn ping_server(host: String, port: u16) -> Res<ServerStatus> {
scopenet_core::ping::ping(&host, port).await.map_err(aerr)
}
#[tauri::command]
pub fn open_folder(app: AppHandle, state: State<'_, AppState>, kind: String, instance_id: Option<String>) -> Res<()> {
let base = match &instance_id {
Some(id) => state.layout.instance_dir(id),
None => state.data_dir.clone(),
};
let path = match kind.as_str() {
"instance" | "data" => base,
"logs" | "screenshots" | "mods" | "resourcepacks" | "shaderpacks" | "saves" | "crash-reports" => base.join(&kind),
_ => return Err("unknown folder".into()),
};
std::fs::create_dir_all(&path).map_err(err)?;
app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(err)
}
#[tauri::command]
pub fn open_file(app: AppHandle, path: String) -> Res<()> {
app.opener().open_path(path, None::<&str>).map_err(err)
}
#[derive(Serialize)]
pub struct Storage {
shared: u64,
runtimes: u64,
instances: Vec<(String, u64)>,
}
#[tauri::command]
pub async fn storage_info(state: State<'_, AppState>) -> Res<Storage> {
let layout = state.layout.clone();
tokio::task::spawn_blocking(move || {
use scopenet_core::sys::dir_size;
let shared = dir_size(&layout.libraries()) + dir_size(&layout.assets()) + dir_size(&layout.versions());
let runtimes = dir_size(&layout.runtimes());
let instances = std::fs::read_dir(layout.instances())
.map(|rd| rd.filter_map(|e| e.ok()).map(|e| (e.file_name().to_string_lossy().into_owned(), dir_size(&e.path()))).collect())
.unwrap_or_default();
Storage { shared, runtimes, instances }
})
.await
.map_err(err)
}
#[tauri::command]
pub async fn delete_instance_data(state: State<'_, AppState>, instance_id: String) -> Res<()> {
if state.game.lock().unwrap().as_ref().is_some_and(|g| g.instance_id == instance_id) {
return Err("close the game first".into());
}
let dir = state.layout.instance_dir(&instance_id);
tokio::fs::remove_dir_all(dir).await.or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }).map_err(err)
}
#[tauri::command]
pub async fn clear_cache(state: State<'_, AppState>) -> Res<()> {
let cache = state.layout.cache();
tokio::fs::remove_dir_all(cache).await.ok();
Ok(())
}
#[tauri::command]
pub async fn detect_java(path: String) -> Option<u32> {
scopenet_core::java::detect_major(std::path::Path::new(&path)).await
}
#[tauri::command]
pub async fn check_update(state: State<'_, AppState>) -> Res<Option<updater::UpdateInfo>> {
updater::check(&state.http).await.map_err(aerr)
}
#[tauri::command]
pub async fn install_update(app: AppHandle, state: State<'_, AppState>, url: String) -> Res<()> {
updater::download_and_run(&state.http, &url).await.map_err(aerr)?;
app.exit(0);
Ok(())
}
#[tauri::command]
pub fn show_window(app: AppHandle) {
if let Some(w) = app.get_webview_window("main") {
w.show().ok();
w.set_focus().ok();
}
}
+301
View File
@@ -0,0 +1,301 @@
//! The Play button: install → sync → configure → start → watch.
use crate::accounts;
use crate::state::{AppState, RunningGame};
use anyhow::{anyhow, Context, Result};
use scopenet_core::install::{self, InstallSpec};
use scopenet_core::launch::{self, LaunchOptions};
use scopenet_core::progress::{Event, Reporter, Stage};
use scopenet_core::{options, servers_dat, sync};
use scopenet_shared::{InstanceManifest, LaunchEvent};
use serde::Serialize;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tokio::io::{AsyncBufReadExt, BufReader};
#[derive(Serialize, Clone)]
pub struct StatePayload {
pub instance_id: String,
/// "preparing" | "running" | "idle" | "error"
pub state: String,
pub message: Option<String>,
}
#[derive(Serialize, Clone)]
pub struct ExitPayload {
pub instance_id: String,
pub code: Option<i32>,
pub crashed: bool,
pub tail: Vec<String>,
pub crash_report: Option<String>,
}
pub fn emit_state(app: &AppHandle, id: &str, state: &str, message: Option<String>) {
app.emit("launch://state", StatePayload { instance_id: id.into(), state: state.into(), message }).ok();
}
/// Fetch the instance definition, falling back to the last copy we saw so
/// the game still starts without internet.
async fn instance_manifest(state: &AppState, id: &str) -> Result<InstanceManifest> {
let cache = state.layout.instance_dir(id).join(".scopenet").join("manifest.json");
let fetched = async {
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
let mut req = state.http.get(format!("{panel}/api/v1/launcher/instances/{id}"));
if let Some(token) = accounts::panel_token(state) {
req = req.bearer_auth(token);
}
let resp = req.send().await?;
if resp.status().as_u16() == 404 {
anyhow::bail!("this instance no longer exists or you don't have access to it");
}
Ok::<_, anyhow::Error>(resp.error_for_status()?.json::<InstanceManifest>().await?)
}
.await;
match fetched {
Ok(m) => {
std::fs::create_dir_all(cache.parent().unwrap()).ok();
std::fs::write(&cache, serde_json::to_vec(&m)?).ok();
Ok(m)
}
Err(e) => {
let bytes = std::fs::read(&cache).map_err(|_| e)?;
tracing::warn!("panel unreachable, using cached instance manifest");
Ok(serde_json::from_slice(&bytes)?)
}
}
}
fn reporter(app: &AppHandle) -> Reporter {
let app = app.clone();
Arc::new(move |ev: Event| {
app.emit("launch://progress", ev).ok();
})
}
/// Install/verify an instance and (when `start`) run the game.
pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -> Result<()> {
let state = app.state::<AppState>();
let report = reporter(&app);
emit_state(&app, &instance_id, "preparing", None);
let account = state.accounts.read().unwrap().active().cloned();
let account = match (start, account) {
(true, None) => anyhow::bail!("sign in first"),
(_, a) => a,
};
let auth = match &account {
Some(a) if start => Some(accounts::game_auth(&state, a).await?),
_ => None,
};
let manifest = instance_manifest(&state, &instance_id).await?;
let inst = &manifest.instance;
let settings = state.settings.read().unwrap().clone();
let advanced = state.manifest.read().unwrap().as_ref().map(|m| m.branding.features.allow_advanced_java).unwrap_or(true);
let ov = settings.instance_overrides.get(&instance_id).cloned().unwrap_or_default();
let game_dir = state.layout.instance_dir(&instance_id);
let panel = state.panel_url().unwrap_or_default();
let java_override = if advanced {
ov.java_path.clone().or(settings.java_path.clone()).filter(|p| !p.trim().is_empty()).map(PathBuf::from)
} else {
None
};
let spec = InstallSpec {
mc_version: inst.mc_version.clone(),
loader: inst.loader,
loader_version: inst.loader_version.clone(),
java_override,
game_dir: game_dir.clone(),
concurrency: settings.concurrent_downloads.clamp(1, 32) as usize,
deep_verify: deep,
};
let installed = install::install(&state.http, &state.layout, &spec, &report).await?;
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?;
if let Some(server) = &inst.server {
if server.inject && !server.address.is_empty() {
let name = if server.name.is_empty() { inst.name.clone() } else { server.name.clone() };
servers_dat::upsert(&game_dir, &name, &servers_dat::format_address(&server.address, server.port)).ok();
}
}
if settings.keybinds_enabled && installed.version.is_modern() && !settings.keybinds.is_empty() {
options::apply_keybinds(&game_dir, &settings.keybinds).ok();
}
if !start {
emit_state(&app, &instance_id, "idle", Some("Instance is up to date".into()));
return Ok(());
}
let auth = auth.unwrap();
report(Event::Stage { stage: Stage::Launching, label: "Starting Minecraft".into() });
let pick = |a: u32, b: u32, c: u32| {
if a > 0 {
a
} else if b > 0 {
b
} else {
c
}
};
let max = pick(ov.memory_max_mb, settings.memory_max_mb, inst.memory.max_mb).max(512);
let min = pick(ov.memory_min_mb, settings.memory_min_mb, inst.memory.min_mb).min(max);
let mut jvm = launch::split_args(&inst.jvm_args);
if advanced {
jvm.extend(launch::split_args(&settings.jvm_args));
jvm.extend(launch::split_args(&ov.jvm_args));
}
let branding = state.manifest.read().unwrap().as_ref().map(|m| m.branding.clone()).unwrap_or_default();
let opts = LaunchOptions {
auth: auth.clone(),
game_dir: game_dir.clone(),
memory_min_mb: min,
memory_max_mb: max,
gc: settings.gc,
extra_jvm_args: jvm,
resolution: settings.custom_resolution.then_some((settings.width, settings.height)),
fullscreen: settings.fullscreen,
join_server: inst.server.as_ref().filter(|s| s.auto_join && !s.address.is_empty()).map(|s| (s.address.clone(), s.port)),
version_label: if branding.version_label.is_empty() { branding.name.clone() } else { branding.version_label.clone() },
launcher_name: "scopenet".into(),
launcher_version: env!("CARGO_PKG_VERSION").into(),
};
let cmd = launch::build(&installed, &state.layout, &opts);
tracing::info!("launching: {} {}", cmd.java.display(), launch::redact(&cmd.args, &auth.access_token).join(" "));
let close_after = settings.after_launch == "close";
let mut child = launch::spawn(&cmd, installed.java_major, !close_after).context("couldn't start Java")?;
if settings.send_stats && !panel.is_empty() {
let http = state.http.clone();
let token = accounts::panel_token(&state);
let ev = LaunchEvent { instance_id: instance_id.clone(), kind: "launch".into(), username: Some(auth.username.clone()) };
tokio::spawn(async move {
let mut req = http.post(format!("{panel}/api/v1/launcher/events")).json(&ev);
if let Some(t) = token {
req = req.bearer_auth(t);
}
req.send().await.ok();
});
}
if close_after {
tokio::time::sleep(Duration::from_millis(1500)).await;
app.exit(0);
return Ok(());
}
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
*state.game.lock().unwrap() = Some(RunningGame { instance_id: instance_id.clone(), kill: kill_tx });
emit_state(&app, &instance_id, "running", None);
if let Some(window) = app.get_webview_window("main") {
match settings.after_launch.as_str() {
"hide" => {
window.hide().ok();
}
"minimize" => {
window.minimize().ok();
}
_ => {}
}
}
// Stream logs to the UI in small batches (cheap IPC), keep a tail for crash reports.
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
for stream in [
child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>),
child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>),
]
.into_iter()
.flatten()
{
let tx = tx.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
if tx.send(line).is_err() {
break;
}
}
});
}
drop(tx);
let app2 = app.clone();
let id2 = instance_id.clone();
tokio::spawn(async move {
let mut tail: VecDeque<String> = VecDeque::with_capacity(200);
let mut batch: Vec<String> = Vec::new();
let mut kill_rx = kill_rx;
let mut tick = tokio::time::interval(Duration::from_millis(150));
let mut streams_open = true;
let mut kill_armed = true;
let mut killed = false;
let status = loop {
tokio::select! {
line = rx.recv(), if streams_open => match line {
Some(l) => {
if tail.len() == 200 { tail.pop_front(); }
tail.push_back(l.clone());
batch.push(l);
}
None => streams_open = false,
},
_ = tick.tick() => {
if !batch.is_empty() {
app2.emit("game://log", std::mem::take(&mut batch)).ok();
}
},
res = &mut kill_rx, if kill_armed => {
kill_armed = false;
if res.is_ok() {
killed = true;
child.kill().await.ok();
}
},
status = child.wait() => break status.ok(),
}
};
// Drain whatever is left.
while let Ok(l) = rx.try_recv() {
tail.push_back(l.clone());
batch.push(l);
}
if !batch.is_empty() {
app2.emit("game://log", batch).ok();
}
let code = status.and_then(|s| s.code());
let crashed = !killed && code.is_some_and(|c| c != 0);
let crash_report = if crashed { newest_crash_report(&app2, &id2) } else { None };
let st = app2.state::<AppState>();
*st.game.lock().unwrap() = None;
if let Some(w) = app2.get_webview_window("main") {
w.show().ok();
w.unminimize().ok();
if crashed {
w.set_focus().ok();
}
}
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), code, crashed, tail: tail.into_iter().collect(), crash_report })
.ok();
emit_state(&app2, &id2, "idle", None);
});
Ok(())
}
fn newest_crash_report(app: &AppHandle, id: &str) -> Option<String> {
let dir = app.state::<AppState>().layout.instance_dir(id).join("crash-reports");
let newest = std::fs::read_dir(dir)
.ok()?
.filter_map(|e| e.ok())
.filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
.max_by_key(|(t, _)| *t)?;
// Only if it was written in the last few minutes (i.e. by this session).
(newest.0.elapsed().ok()? < Duration::from_secs(300)).then(|| newest.1.to_string_lossy().into_owned())
}
+78
View File
@@ -0,0 +1,78 @@
//! SCOPENET launcher (Tauri shell). The heavy lifting lives in
//! `scopenet-core`; this crate wires it to the UI.
mod accounts;
mod commands;
mod game;
mod secrets;
mod settings;
mod state;
mod updater;
use tauri::Manager;
pub fn run() {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init();
tauri::Builder::default()
// A second launch just focuses the existing window.
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(w) = app.get_webview_window("main") {
w.show().ok();
w.unminimize().ok();
w.set_focus().ok();
}
}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let data_dir = app.path().app_data_dir()?;
app.manage(state::AppState::new(data_dir));
// The window starts hidden and the UI reveals it once painted (no
// white flash). If the UI never does, show it anyway.
let handle = app.handle().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(4));
if let Some(w) = handle.get_webview_window("main") {
if !w.is_visible().unwrap_or(true) {
tracing::warn!("UI didn't signal ready; showing window");
w.show().ok();
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::bootstrap,
commands::refresh_manifest,
commands::set_panel_url,
commands::save_settings,
commands::login_panel,
commands::register_panel,
commands::add_offline,
commands::ms_start,
commands::ms_finish,
commands::ms_cancel,
commands::select_account,
commands::remove_account,
commands::launch,
commands::repair_instance,
commands::cancel_launch,
commands::kill_game,
commands::instance_local,
commands::ping_server,
commands::open_folder,
commands::open_file,
commands::storage_info,
commands::delete_instance_data,
commands::clear_cache,
commands::detect_java,
commands::check_update,
commands::install_update,
commands::show_window,
])
.run(tauri::generate_context!())
.expect("error while running the launcher");
}
+6
View File
@@ -0,0 +1,6 @@
// Hide the console window on Windows release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
scopenet_launcher_lib::run()
}
+132
View File
@@ -0,0 +1,132 @@
//! Encrypted storage for tokens (panel sessions, Microsoft refresh tokens).
//!
//! Secrets live in `secrets.bin`, encrypted with ChaCha20-Poly1305. The key
//! is kept in the OS credential store (Windows Credential Manager / macOS
//! Keychain), so copying the data folder to another machine doesn't leak
//! sessions. Where no credential store exists we fall back to a key file.
use anyhow::{anyhow, Result};
use base64::Engine;
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Secret {
pub panel_token: Option<String>,
pub ms_refresh_token: Option<String>,
pub mc_access_token: Option<String>,
pub mc_expires_at: i64,
pub xuid: Option<String>,
}
pub struct Secrets {
path: PathBuf,
key: [u8; 32],
data: Mutex<HashMap<String, Secret>>,
}
const SERVICE: &str = "net.scopenet.launcher";
fn load_or_create_key(dir: &std::path::Path) -> [u8; 32] {
let b64 = base64::engine::general_purpose::STANDARD;
#[cfg(any(windows, target_os = "macos"))]
{
if let Ok(entry) = keyring::Entry::new(SERVICE, "secrets-key") {
if let Ok(existing) = entry.get_password() {
if let Ok(bytes) = b64.decode(existing) {
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
return key;
}
}
}
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
if entry.set_password(&b64.encode(key)).is_ok() {
return key;
}
tracing::warn!("credential store unavailable, falling back to a key file");
}
}
let _ = SERVICE;
let path = dir.join("secrets.key");
if let Ok(bytes) = std::fs::read(&path).map(|b| b64.decode(b).unwrap_or_default()) {
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
return key;
}
}
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
std::fs::write(&path, b64.encode(key)).ok();
key
}
impl Secrets {
pub fn open(dir: &std::path::Path) -> Self {
let key = load_or_create_key(dir);
let path = dir.join("secrets.bin");
let data = std::fs::read(&path).ok().and_then(|raw| decrypt(&key, &raw).ok()).unwrap_or_default();
Self { path, key, data: Mutex::new(data) }
}
pub fn get(&self, id: &str) -> Secret {
self.data.lock().unwrap().get(id).cloned().unwrap_or_default()
}
pub fn set(&self, id: &str, secret: Secret) -> Result<()> {
let mut data = self.data.lock().unwrap();
data.insert(id.to_string(), secret);
self.flush(&data)
}
pub fn remove(&self, id: &str) -> Result<()> {
let mut data = self.data.lock().unwrap();
data.remove(id);
self.flush(&data)
}
fn flush(&self, data: &HashMap<String, Secret>) -> Result<()> {
let plain = serde_json::to_vec(data)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.key));
let mut nonce = [0u8; 12];
rand::thread_rng().fill_bytes(&mut nonce);
let mut out = nonce.to_vec();
out.extend(cipher.encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|_| anyhow!("encryption failed"))?);
let tmp = self.path.with_extension("bin.tmp");
std::fs::write(&tmp, out)?;
std::fs::rename(tmp, &self.path)?;
Ok(())
}
}
fn decrypt(key: &[u8; 32], raw: &[u8]) -> Result<HashMap<String, Secret>> {
if raw.len() < 13 {
return Err(anyhow!("secrets file too short"));
}
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let plain = cipher.decrypt(Nonce::from_slice(&raw[..12]), &raw[12..]).map_err(|_| anyhow!("can't decrypt secrets"))?;
Ok(serde_json::from_slice(&plain)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let dir = std::env::temp_dir().join(format!("scopenet-secrets-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let s = Secrets::open(&dir);
s.set("a", Secret { panel_token: Some("tok".into()), ..Default::default() }).unwrap();
let raw = std::fs::read(dir.join("secrets.bin")).unwrap();
assert!(!String::from_utf8_lossy(&raw).contains("tok"), "stored encrypted");
let again = Secrets::open(&dir);
assert_eq!(again.get("a").panel_token.as_deref(), Some("tok"));
std::fs::remove_dir_all(dir).ok();
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Player settings, stored as JSON in the app data directory.
use scopenet_core::launch::GcPreset;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct InstanceOverride {
/// 0 = use the global / instance default.
pub memory_max_mb: u32,
pub memory_min_mb: u32,
pub jvm_args: String,
pub java_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
/// Panel URL chosen by the player (ignored when one is baked in and locked).
pub panel_url: Option<String>,
pub selected_instance: Option<String>,
// ---- Game ----
/// 0 = use the value the admin set for the instance.
pub memory_max_mb: u32,
pub memory_min_mb: u32,
pub custom_resolution: bool,
pub width: u32,
pub height: u32,
pub fullscreen: bool,
/// "minimize" | "hide" | "keep" | "close"
pub after_launch: String,
pub show_console: bool,
// ---- Java ----
pub java_path: Option<String>,
pub gc: GcPreset,
pub jvm_args: String,
// ---- Appearance ----
/// "server" (admin's theme) | "custom"
pub theme_mode: String,
pub accent: Option<String>,
pub background_video: bool,
pub reduce_motion: bool,
/// None = follow the admin's setting.
pub glass: Option<bool>,
pub ui_scale: u32,
// ---- Launcher ----
pub concurrent_downloads: u32,
pub check_updates: bool,
pub send_stats: bool,
// ---- Controls ----
pub keybinds_enabled: bool,
pub keybinds: BTreeMap<String, String>,
pub instance_overrides: HashMap<String, InstanceOverride>,
}
impl Default for Settings {
fn default() -> Self {
Self {
panel_url: None,
selected_instance: None,
memory_max_mb: 0,
memory_min_mb: 0,
custom_resolution: false,
width: 1280,
height: 720,
fullscreen: false,
after_launch: "minimize".into(),
show_console: false,
java_path: None,
gc: GcPreset::G1,
jvm_args: String::new(),
theme_mode: "server".into(),
accent: None,
background_video: true,
reduce_motion: false,
glass: None,
ui_scale: 100,
concurrent_downloads: 12,
check_updates: true,
send_stats: true,
keybinds_enabled: false,
keybinds: BTreeMap::new(),
instance_overrides: HashMap::new(),
}
}
}
impl Settings {
pub fn load(path: &Path) -> Self {
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
std::fs::rename(tmp, path)?;
Ok(())
}
}
+87
View File
@@ -0,0 +1,87 @@
use crate::accounts::AccountsFile;
use crate::secrets::Secrets;
use crate::settings::Settings;
use scopenet_core::msa::DeviceCode;
use scopenet_core::Layout;
use scopenet_shared::LauncherManifest;
use std::path::PathBuf;
use std::sync::{Mutex, RwLock};
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
pub mod build {
/// Default panel URL for this build.
pub const PANEL_URL: Option<&str> = option_env!("SCOPENET_PANEL_URL");
/// `owner/repo` used to check GitHub releases for updates.
pub const REPO: Option<&str> = option_env!("SCOPENET_REPO");
/// When set to "1", players can't change the panel URL.
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
pub fn panel_locked() -> bool {
LOCK_PANEL == Some("1") && PANEL_URL.is_some_and(|u| !u.is_empty())
}
}
pub struct RunningGame {
pub instance_id: String,
pub kill: tokio::sync::oneshot::Sender<()>,
}
pub struct AppState {
pub data_dir: PathBuf,
pub layout: Layout,
pub http: reqwest::Client,
pub settings: RwLock<Settings>,
pub accounts: RwLock<AccountsFile>,
pub secrets: Secrets,
pub manifest: RwLock<Option<LauncherManifest>>,
pub device_code: Mutex<Option<DeviceCode>>,
pub ms_cancel: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
/// Install/launch pipeline in progress (abortable).
pub task: Mutex<Option<tokio::task::AbortHandle>>,
pub game: Mutex<Option<RunningGame>>,
}
impl AppState {
pub fn new(data_dir: PathBuf) -> Self {
std::fs::create_dir_all(&data_dir).ok();
let settings = Settings::load(&data_dir.join("settings.json"));
let accounts = AccountsFile::load(&data_dir.join("accounts.json"));
let manifest = std::fs::read(data_dir.join("manifest.json")).ok().and_then(|b| serde_json::from_slice(&b).ok());
Self {
layout: Layout::new(data_dir.join("minecraft")),
http: scopenet_core::http::client(),
secrets: Secrets::open(&data_dir),
settings: RwLock::new(settings),
accounts: RwLock::new(accounts),
manifest: RwLock::new(manifest),
device_code: Mutex::new(None),
ms_cancel: Mutex::new(None),
task: Mutex::new(None),
game: Mutex::new(None),
data_dir,
}
}
/// The panel this launcher talks to (baked-in when locked).
pub fn panel_url(&self) -> Option<String> {
let chosen = self.settings.read().unwrap().panel_url.clone().filter(|u| !u.is_empty());
let baked = build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from);
let url = if build::panel_locked() { baked } else { chosen.or(baked) };
url.map(|u| u.trim_end_matches('/').to_string())
}
pub fn save_settings(&self) -> anyhow::Result<()> {
self.settings.read().unwrap().save(&self.data_dir.join("settings.json"))
}
pub fn save_accounts(&self) -> anyhow::Result<()> {
self.accounts.read().unwrap().save(&self.data_dir.join("accounts.json"))
}
pub fn cache_manifest(&self, m: &LauncherManifest) {
*self.manifest.write().unwrap() = Some(m.clone());
if let Ok(bytes) = serde_json::to_vec(m) {
std::fs::write(self.data_dir.join("manifest.json"), bytes).ok();
}
}
}
+93
View File
@@ -0,0 +1,93 @@
//! Self-update via GitHub Releases: find a newer `*-setup.exe`, download
//! it and hand over to the installer.
use crate::state::build;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
pub struct UpdateInfo {
pub version: String,
pub notes: String,
pub url: String,
pub size: u64,
}
#[derive(Deserialize)]
struct Release {
tag_name: String,
#[serde(default)]
body: String,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
size: u64,
}
fn parse(v: &str) -> Vec<u64> {
v.trim_start_matches('v').split(['.', '-', '+']).map_while(|p| p.parse().ok()).collect()
}
pub fn is_newer(candidate: &str, current: &str) -> bool {
parse(candidate) > parse(current)
}
pub async fn check(http: &reqwest::Client) -> Result<Option<UpdateInfo>> {
let Some(repo) = build::REPO.filter(|r| !r.is_empty()) else { return Ok(None) };
let release: Release = http
.get(format!("https://api.github.com/repos/{repo}/releases/latest"))
.header("Accept", "application/vnd.github+json")
.send()
.await?
.error_for_status()?
.json()
.await?;
if release.draft || release.prerelease || !is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) {
return Ok(None);
}
let asset = release
.assets
.iter()
.find(|a| a.name.to_lowercase().ends_with("-setup.exe"))
.ok_or_else(|| anyhow!("release {} has no installer", release.tag_name))?;
Ok(Some(UpdateInfo {
version: release.tag_name.trim_start_matches('v').to_string(),
notes: release.body,
url: asset.browser_download_url.clone(),
size: asset.size,
}))
}
/// Download the installer and start it. The caller exits the app afterwards.
pub async fn download_and_run(http: &reqwest::Client, url: &str) -> Result<()> {
if !url.starts_with("https://github.com/") && !url.starts_with("https://objects.githubusercontent.com/") {
anyhow::bail!("refusing to download an update from an unexpected host");
}
let dest = std::env::temp_dir().join("scopenet-launcher-setup.exe");
let dl = scopenet_core::http::Download::new(url, dest.clone(), None, None);
scopenet_core::http::download_one(http, &dl, &|_| {}).await.context("downloading update")?;
// /P = passive (progress only), /R = relaunch when done (Tauri NSIS).
std::process::Command::new(&dest).args(["/P", "/R"]).spawn().context("starting the installer")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions() {
assert!(is_newer("v0.2.0", "0.1.9"));
assert!(is_newer("1.0.0", "0.9.12"));
assert!(!is_newer("v0.1.0", "0.1.0"));
assert!(!is_newer("0.1.0-beta", "0.1.0"));
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "SCOPENET Launcher",
"version": "0.1.0",
"identifier": "net.scopenet.launcher",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"label": "main",
"title": "SCOPENET Launcher",
"width": 1120,
"height": 700,
"minWidth": 960,
"minHeight": 600,
"decorations": false,
"shadow": true,
"center": true,
"visible": false,
"backgroundColor": "#0b0d17"
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: http: data: blob:; media-src 'self' https: http: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"publisher": "SCOPENET",
"category": "Game",
"shortDescription": "Minecraft launcher",
"longDescription": "A fast, customisable Minecraft launcher connected to your community's admin panel.",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"windows": {
"webviewInstallMode": { "type": "downloadBootstrapper", "silent": true },
"nsis": {
"installMode": "currentUser",
"displayLanguageSelector": false,
"installerIcon": "icons/icon.ico"
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import { onMount } from 'svelte';
import { LoaderCircle, Download, X } from '@lucide/svelte';
import Background from './components/Background.svelte';
import TitleBar from './components/TitleBar.svelte';
import Rail from './components/Rail.svelte';
import Modal from './components/Modal.svelte';
import SignIn from './components/SignIn.svelte';
import InstanceSettings from './components/InstanceSettings.svelte';
import CrashDialog from './components/CrashDialog.svelte';
import Toasts from './components/Toasts.svelte';
import Setup from './pages/Setup.svelte';
import Login from './pages/Login.svelte';
import Home from './pages/Home.svelte';
import Settings from './pages/Settings.svelte';
import { app, init } from './lib/store.svelte';
import { errorText } from './lib/tauri';
let fatal = $state('');
let updateDismissed = $state(false);
onMount(() => {
init().catch((e) => (fatal = errorText(e)));
});
</script>
<Background />
<div class="shell">
<TitleBar />
{#if fatal && !app.manifest}
<div class="center"><div class="glass msg"><h2>Can't reach the server</h2><p class="muted selectable">{fatal}</p><button class="primary" onclick={() => location.reload()}>Try again</button></div></div>
{:else if !app.ready}
<div class="center"><LoaderCircle class="spin" size={28} /></div>
{:else if !app.panelUrl || !app.manifest}
<Setup />
{:else if app.accounts.length === 0}
<Login />
{:else}
<div class="body">
<Rail />
<main>
{#if app.view === 'settings'}<Settings />{:else}<Home />{/if}
</main>
</div>
{/if}
</div>
{#if app.update && !updateDismissed && app.view !== 'settings' && app.accounts.length}
<div class="update glass">
<Download size={16} />
<span>Launcher {app.update.version} is available</span>
<button class="sm primary" onclick={() => { app.view = 'settings'; app.settingsTab = 'about'; }}>View</button>
<button class="ghost icon sm" aria-label="Dismiss" onclick={() => (updateDismissed = true)}><X size={14} /></button>
</div>
{/if}
<Modal bind:open={app.addAccount} title="Add account" width={26}>
<SignIn />
</Modal>
<InstanceSettings />
<CrashDialog />
<Toasts />
<style>
.shell { position: relative; z-index: 1; height: 100%; display: flex; flex-direction: column; }
.body { flex: 1; display: flex; min-height: 0; }
main { flex: 1; min-width: 0; min-height: 0; position: relative; }
.center { flex: 1; display: grid; place-items: center; color: var(--accent); }
.msg { padding: 2rem; max-width: 28rem; display: flex; flex-direction: column; gap: 0.8rem; color: var(--text); }
.update { position: fixed; top: 3.2rem; left: 50%; transform: translateX(-50%); z-index: 40; display: flex; align-items: center; gap: 0.7rem; padding: 0.45rem 0.5rem 0.45rem 1rem; font-size: 0.87rem; border-radius: var(--radius); background: var(--surface); animation: fade 0.2s ease; }
.update :global(svg:first-child) { color: var(--accent); }
</style>
+156
View File
@@ -0,0 +1,156 @@
/* Sizes are in rem so the "UI scale" setting scales everything (1rem = 15px at 100%). */
:root {
--accent: #6d6af5;
--accent-2: #8b88f8;
--bg: #0d0e12;
--surface: #16181e;
--text: #ecedf1;
--muted: #8b8f9a;
--success: #3fb97c;
--danger: #e5484d;
--warn: #e2a336;
--radius: 10px;
--radius-sm: 7px;
--font: 'Inter Variable', system-ui, sans-serif;
--mono: 'JetBrains Mono Variable', ui-monospace, monospace;
--line: color-mix(in srgb, var(--text) 7%, transparent);
--line-strong: color-mix(in srgb, var(--text) 12%, transparent);
--panel: color-mix(in srgb, var(--surface) 78%, transparent);
--panel-solid: var(--surface);
--blur: blur(14px);
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
font-size: 15px;
color-scheme: dark;
}
.no-glass {
--panel: var(--surface);
--blur: none;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
user-select: none;
-webkit-user-select: none;
}
#app { height: 100%; }
h1, h2, h3, h4 { margin: 0; letter-spacing: -0.015em; }
p { margin: 0; }
a { color: var(--accent); text-decoration: none; }
code, .mono { font-family: var(--mono); }
.selectable, input, textarea { user-select: text; -webkit-user-select: text; }
::-webkit-scrollbar { width: 0.6rem; height: 0.6rem; }
::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--text) 14%, transparent); border-radius: 1rem; border: 0.15rem solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-track { background: transparent; }
.reduce-motion *, .reduce-motion *::before, .reduce-motion *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
/* ---------- surfaces ---------- */
.glass {
background: var(--panel);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--line);
border-radius: var(--radius);
}
/* ---------- controls ---------- */
button {
font: inherit;
color: var(--text);
cursor: pointer;
border: 1px solid var(--line-strong);
background: color-mix(in srgb, var(--text) 6%, transparent);
border-radius: var(--radius-sm);
padding: 0.6rem 1.05rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 520;
font-size: 0.9rem;
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
white-space: nowrap;
}
button:hover { background: color-mix(in srgb, var(--text) 11%, transparent); }
button:active { background: color-mix(in srgb, var(--text) 14%, transparent); }
button:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.primary {
background: var(--accent);
border-color: transparent;
color: white;
}
button.primary:hover { background: color-mix(in srgb, var(--accent) 88%, white); }
button.ghost { background: transparent; border-color: transparent; color: color-mix(in srgb, var(--text) 75%, transparent); }
button.ghost:hover { background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); }
button.danger { color: color-mix(in srgb, var(--danger) 70%, white); background: color-mix(in srgb, var(--danger) 12%, transparent); border-color: color-mix(in srgb, var(--danger) 30%, transparent); }
button.danger:hover { background: color-mix(in srgb, var(--danger) 20%, transparent); }
button.icon { padding: 0.5rem; }
button.sm { padding: 0.4rem 0.75rem; font-size: 0.85rem; }
input, select, textarea {
font: inherit;
font-size: 0.93rem;
width: 100%;
color: var(--text);
background: color-mix(in srgb, var(--bg) 70%, transparent);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 0.65rem 0.8rem;
transition: border-color 0.15s, background 0.15s;
}
input:hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--text) 22%, transparent); }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); background: color-mix(in srgb, var(--bg) 90%, transparent); }
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
select { appearance: none; padding-right: 2.2rem; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 1.15rem) 55%, calc(100% - 0.8rem) 55%; background-size: 0.33rem 0.33rem; background-repeat: no-repeat; }
select option { background: var(--surface); }
input[type='range'] { -webkit-appearance: none; appearance: none; padding: 0; height: 1.2rem; border: none; background: transparent; cursor: pointer; }
input[type='range']::-webkit-slider-runnable-track { height: 0.25rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 12%, transparent); }
input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; width: 0.95rem; height: 0.95rem; margin-top: -0.35rem; border-radius: 50%; background: var(--text); border: 3px solid var(--accent); }
input[type='range']:hover, input[type='range']:focus { background: transparent; }
input[type='checkbox'] { width: auto; accent-color: var(--accent); }
textarea { resize: vertical; min-height: 5rem; line-height: 1.5; }
label.field { display: flex; flex-direction: column; gap: 0.45rem; font-size: 0.85rem; font-weight: 520; color: color-mix(in srgb, var(--text) 80%, transparent); }
.help { font-size: 0.8rem; color: var(--muted); font-weight: 400; line-height: 1.45; }
.chip {
display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.76rem; font-weight: 520;
padding: 0.22rem 0.6rem; border-radius: 0.35rem; background: color-mix(in srgb, var(--text) 7%, transparent);
color: color-mix(in srgb, var(--text) 80%, transparent); white-space: nowrap;
}
.chip.accent { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.segmented { display: inline-flex; padding: 0.2rem; gap: 0.15rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 70%, transparent); border: 1px solid var(--line-strong); }
.segmented button { border: none; background: transparent; padding: 0.45rem 0.9rem; font-size: 0.87rem; color: var(--muted); border-radius: calc(var(--radius-sm) - 2px); }
.segmented button.active { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); }
.col > .segmented { align-self: flex-start; }
.row { display: flex; align-items: center; gap: 0.75rem; }
.col { display: flex; flex-direction: column; gap: 0.75rem; }
.spacer { flex: 1; }
.muted { color: var(--muted); }
.small { font-size: 0.87rem; }
.tiny { font-size: 0.78rem; }
.spin { animation: spin 0.9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fade-up { from { opacity: 0; transform: translateY(0.6rem); } to { opacity: 1; transform: none; } }
@keyframes fade { from { opacity: 0; } }
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Account } from '../lib/types';
let { account, size = 2.2 }: { account: Account | null; size?: number } = $props();
let failed = $state(false);
const hue = $derived([...(account?.username ?? '?')].reduce((a, c) => a + c.charCodeAt(0), 0) % 360);
// Microsoft accounts have real skins; everyone else gets a head by name.
const src = $derived(account ? `https://mc-heads.net/avatar/${account.kind === 'microsoft' ? account.uuid : encodeURIComponent(account.username)}/64` : '');
</script>
{#if account && !failed}
<img {src} alt="" style:width="{size}rem" style:height="{size}rem" onerror={() => (failed = true)} />
{:else}
<span class="fb" style:width="{size}rem" style:height="{size}rem" style:background="hsl({hue} 55% 42%)">{account?.username.slice(0, 1).toUpperCase() ?? '?'}</span>
{/if}
<style>
img, .fb { border-radius: 28%; image-rendering: pixelated; flex-shrink: 0; display: inline-grid; place-items: center; font-weight: 700; color: white; font-size: 0.9rem; }
</style>
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
// Full-window backdrop: instance banner → admin image/video → plain colour.
import { abs, app, selectedInstance } from '../lib/store.svelte';
const b = $derived(app.manifest?.branding);
const inst = $derived(selectedInstance());
const image = $derived(abs(inst?.banner_url) ?? (b?.background.kind === 'image' ? abs(b.background.url) : null));
const video = $derived(!inst?.banner_url && b?.background.kind === 'video' && app.settings?.background_video !== false && !app.running ? abs(b.background.url) : null);
const blur = $derived(b?.background.blur ?? 0);
const dim = $derived((b?.background.dim ?? 55) / 100);
let hidden = $state(false);
</script>
<svelte:document onvisibilitychange={() => (hidden = document.hidden)} />
<div class="bg" aria-hidden="true">
{#if video && !hidden}
{#key video}<video src={video} autoplay muted loop playsinline style:filter="blur({blur}px)"></video>{/key}
<div class="shade" style:--dim={dim}></div>
{:else if image}
{#key image}<div class="img" style:background-image="url(&quot;{image}&quot;)" style:filter="blur({blur}px)"></div>{/key}
<div class="shade" style:--dim={dim}></div>
{/if}
</div>
<style>
.bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; background: var(--bg); }
video, .img { position: absolute; inset: -1.5rem; width: calc(100% + 3rem); height: calc(100% + 3rem); object-fit: cover; background-size: cover; background-position: center; animation: fade 0.4s ease; }
/* Keep text readable: darken, strongest on the left where the title sits. */
.shade {
position: absolute; inset: 0;
background:
linear-gradient(90deg, var(--bg) 0%, color-mix(in srgb, var(--bg) calc(var(--dim) * 100%), transparent) 55%, color-mix(in srgb, var(--bg) calc(var(--dim) * 70%), transparent) 100%),
linear-gradient(0deg, var(--bg) 0%, transparent 40%);
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import { X, Copy, ArrowDown, Search, Trash2 } from '@lucide/svelte';
import { app, toast } from '../lib/store.svelte';
let box = $state<HTMLDivElement>();
let follow = $state(true);
let filter = $state('');
const shown = $derived(filter ? app.logs.filter((l) => l.toLowerCase().includes(filter.toLowerCase())) : app.logs.slice(-1500));
$effect(() => {
void shown.length;
if (follow && box) requestAnimationFrame(() => box && (box.scrollTop = box.scrollHeight));
});
function level(line: string) {
if (/\b(ERROR|FATAL|Exception)\b/.test(line)) return 'err';
if (/\bWARN\b/.test(line)) return 'warn';
return '';
}
</script>
<div class="console glass">
<header>
<strong>Game output</strong>
<span class="tiny muted">{app.logs.length.toLocaleString()} lines</span>
<span class="spacer"></span>
<div class="search"><Search size={13} /><input bind:value={filter} placeholder="Filter" /></div>
<button class="ghost icon sm" title="Copy" aria-label="Copy log" onclick={() => { navigator.clipboard.writeText(app.logs.join('\n')); toast('Log copied'); }}><Copy size={14} /></button>
<button class="ghost icon sm" title="Clear" aria-label="Clear log" onclick={() => (app.logs = [])}><Trash2 size={14} /></button>
<button class="ghost icon sm" aria-label="Close console" onclick={() => (app.consoleOpen = false)}><X size={15} /></button>
</header>
<div class="lines selectable" bind:this={box} onscroll={() => box && (follow = box.scrollHeight - box.scrollTop - box.clientHeight < 40)}>
{#each shown as line, i (i)}<div class="line {level(line)}">{line}</div>{:else}<div class="muted empty">Logs appear here while the game is running.</div>{/each}
</div>
{#if !follow}
<button class="jump sm" onclick={() => { follow = true; if (box) box.scrollTop = box.scrollHeight; }}><ArrowDown size={13} /> Latest</button>
{/if}
</div>
<style>
.console { position: absolute; left: 1rem; right: 1rem; bottom: 1rem; height: 42%; display: flex; flex-direction: column; z-index: 25; background: var(--bg); border-color: var(--line-strong); animation: fade 0.15s ease; overflow: hidden; }
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.6rem 0.5rem 1rem; border-bottom: 1px solid var(--line); font-size: 0.88rem; }
.search { display: flex; align-items: center; gap: 0.3rem; color: var(--muted); }
.search input { width: 10rem; padding: 0.3rem 0.5rem; font-size: 0.8rem; }
.lines { flex: 1; overflow: auto; padding: 0.6rem 1rem; font-family: var(--mono); font-size: 0.74rem; line-height: 1.55; }
.line { white-space: pre-wrap; word-break: break-all; color: color-mix(in srgb, var(--text) 78%, transparent); }
.line.warn { color: var(--warn); }
.line.err { color: color-mix(in srgb, var(--danger) 75%, white); }
.empty { font-family: var(--font); padding: 1rem 0; }
.jump { position: absolute; right: 1.5rem; bottom: 1rem; background: var(--surface); }
</style>
@@ -0,0 +1,39 @@
<script lang="ts">
import { FolderOpen, FileText, Copy, RotateCcw } from '@lucide/svelte';
import Modal from './Modal.svelte';
import { app, play, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
let open = $state(false);
$effect(() => {
open = !!app.crash;
});
const c = $derived(app.crash);
const hint = $derived.by(() => {
const text = c?.tail.join('\n') ?? '';
if (/OutOfMemoryError|Java heap space/.test(text)) return 'Minecraft ran out of memory. Give it more RAM in Settings → Game.';
if (/UnsupportedClassVersionError/.test(text)) return 'Wrong Java version. Switch Java back to "Automatic" in Settings → Java.';
if (/Could not reserve enough space|Invalid maximum heap size/.test(text)) return "Your PC can't give Minecraft that much memory. Lower it in Settings → Game.";
if (/Mixin|mixin.*failed|Incompatible mod set|requires .* but/i.test(text)) return 'A mod failed to load. If you added mods yourself, try removing them.';
return null;
});
</script>
<Modal bind:open title="Minecraft closed unexpectedly" width={40} onclose={() => (app.crash = null)}>
{#if c}
<p class="muted small">The game exited with code {c.code}. {hint ?? 'The last lines of the log are below — they usually explain what went wrong.'}</p>
{#if hint}<div class="hint">{hint}</div>{/if}
<pre class="tail selectable">{c.tail.slice(-40).join('\n')}</pre>
{/if}
{#snippet footer()}
<button class="ghost" onclick={() => { navigator.clipboard.writeText(c?.tail.join('\n') ?? ''); toast('Copied'); }}><Copy size={15} /> Copy</button>
{#if c?.crash_report}<button onclick={() => invoke('open_file', { path: c?.crash_report })}><FileText size={15} /> Crash report</button>{/if}
<button onclick={() => c && invoke('open_folder', { kind: 'logs', instanceId: c.instance_id })}><FolderOpen size={15} /> Logs</button>
<button class="primary" onclick={() => { const id = c?.instance_id; app.crash = null; if (id) play(id); }}><RotateCcw size={15} /> Try again</button>
{/snippet}
</Modal>
<style>
.hint { padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--warn) 12%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); font-size: 0.88rem; }
.tail { margin: 0; max-height: 16rem; overflow: auto; font-family: var(--mono); font-size: 0.72rem; line-height: 1.5; padding: 0.8rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 80%, transparent); border: 1px solid var(--line); white-space: pre-wrap; word-break: break-all; }
</style>
@@ -0,0 +1,84 @@
<script lang="ts">
import { FolderOpen, Wrench, Trash2, FileCode } from '@lucide/svelte';
import Modal from './Modal.svelte';
import { app, repair, saveSettings, toast } from '../lib/store.svelte';
import { bytes, gb } from '../lib/format';
import { errorText, invoke, pickFile } from '../lib/tauri';
import type { InstanceOverride } from '../lib/types';
let open = $state(false);
$effect(() => {
open = !!app.instanceSettings;
});
const inst = $derived(app.manifest?.instances.find((i) => i.id === app.instanceSettings) ?? null);
const advanced = $derived(app.manifest?.branding.features.allow_advanced_java !== false);
const ram = $derived(app.boot?.app.total_ram_mb ?? 8192);
let local = $state<{ installed: boolean; size: number } | null>(null);
// Create the override entry up front (never mutate state inside $derived).
$effect(() => {
if (inst && app.settings && !app.settings.instance_overrides[inst.id]) {
app.settings.instance_overrides[inst.id] = { memory_max_mb: 0, memory_min_mb: 0, jvm_args: '', java_path: null };
}
});
const ov = $derived<InstanceOverride | undefined>(inst ? app.settings?.instance_overrides[inst.id] : undefined);
$effect(() => {
if (inst) invoke<{ installed: boolean; size: number }>('instance_local', { instanceId: inst.id }).then((l) => (local = l)).catch(() => {});
});
const folders = ['instance', 'mods', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs'];
async function del() {
if (!inst) return;
try {
await invoke('delete_instance_data', { instanceId: inst.id });
toast('Local files deleted — they download again next time you play');
local = { installed: false, size: 0 };
} catch (e) {
toast(errorText(e), 'error');
}
}
</script>
<Modal bind:open title={inst ? `${inst.name} settings` : ''} width={34} onclose={() => (app.instanceSettings = null)}>
{#if inst && app.settings && ov}
<div class="grid2">
{#each folders as f}
<button class="sm" onclick={() => invoke('open_folder', { kind: f, instanceId: inst.id })}><FolderOpen size={14} /> {f === 'instance' ? 'Game folder' : f}</button>
{/each}
</div>
<label class="field">
<span class="row">Memory for this instance <span class="spacer"></span><strong>{ov.memory_max_mb ? gb(ov.memory_max_mb) : `Default (${gb(app.settings.memory_max_mb || inst.memory.max_mb)})`}</strong></span>
<input type="range" min="0" max={Math.max(2048, ram - 1024)} step="512" bind:value={ov.memory_max_mb} oninput={saveSettings} />
<span class="help">Slide to the far left to use the global/admin default.</span>
</label>
{#if advanced}
<label class="field">Extra JVM arguments
<input class="mono" bind:value={ov.jvm_args} oninput={saveSettings} placeholder="-XX:+UseStringDeduplication" />
</label>
<label class="field">Java for this instance
<div class="row">
<input value={ov.java_path ?? ''} placeholder="Automatic" readonly />
<button class="sm" onclick={async () => { const p = await pickFile('Choose java executable', [{ name: 'Java', extensions: ['exe', ''] }]); if (p) { ov.java_path = p; saveSettings(); } }}><FileCode size={14} /> Browse</button>
{#if ov.java_path}<button class="sm ghost" onclick={() => { ov.java_path = null; saveSettings(); }}>Reset</button>{/if}
</div>
</label>
{/if}
<div class="danger-zone">
<span class="tiny muted">{local?.installed ? `Uses ${bytes(local.size)} on disk` : 'Not downloaded yet'}</span>
<span class="spacer"></span>
<button class="sm" onclick={() => { app.instanceSettings = null; repair(inst.id); }}><Wrench size={14} /> Repair files</button>
<button class="sm danger" onclick={del}><Trash2 size={14} /> Delete local files</button>
</div>
{/if}
</Modal>
<style>
.grid2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); gap: 0.45rem; }
.grid2 button { justify-content: flex-start; text-transform: capitalize; }
.danger-zone { display: flex; align-items: center; gap: 0.5rem; padding-top: 0.9rem; border-top: 1px solid var(--line); flex-wrap: wrap; }
</style>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import type { Snippet } from 'svelte';
let { open = $bindable(false), title, width = 30, children, footer, onclose }: { open: boolean; title: string; width?: number; children: Snippet; footer?: Snippet; onclose?: () => void } = $props();
function close() {
open = false;
onclose?.();
}
</script>
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<div class="backdrop" role="presentation" onclick={(e) => e.target === e.currentTarget && close()}>
<div class="modal glass" role="dialog" aria-modal="true" aria-label={title} style:max-width="{width}rem">
<header><h3>{title}</h3><button class="ghost icon" onclick={close} aria-label="Close"><X size={17} /></button></header>
<div class="body">{@render children()}</div>
{#if footer}<footer>{@render footer()}</footer>{/if}
</div>
</div>
{/if}
<style>
.backdrop { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 1.5rem; background: color-mix(in srgb, var(--bg) 70%, transparent); animation: fade 0.12s ease; }
.modal { width: 100%; max-height: calc(100vh - 4rem); overflow: auto; background: var(--surface); border-color: var(--line-strong); box-shadow: 0 1rem 3rem -1rem rgba(0, 0, 0, 0.5); animation: pop 0.15s ease; }
header { display: flex; align-items: center; justify-content: space-between; padding: 1.1rem 1.25rem 0; }
.body { padding: 1rem 1.25rem 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
footer { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.9rem 1.25rem; border-top: 1px solid var(--line); }
@keyframes pop { from { opacity: 0; transform: translateY(0.3rem); } }
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { Pin, ArrowUpRight } from '@lucide/svelte';
import { abs } from '../lib/store.svelte';
import { niceDate } from '../lib/format';
import { openUrl } from '../lib/tauri';
import type { NewsItem } from '../lib/types';
let { news }: { news: NewsItem[] } = $props();
const sorted = $derived([...news].sort((a, b) => Number(b.pinned) - Number(a.pinned)));
</script>
<section class="news">
<h4 class="head">News</h4>
<div class="list">
{#each sorted as n (n.id)}
<article class="card glass" class:link={!!n.link}>
{#if n.image_url}<img src={abs(n.image_url)} alt="" loading="lazy" />{/if}
<div class="body">
<div class="meta">
{#if n.tag}<span class="tag">{n.tag}</span>{/if}
{#if n.pinned}<Pin size={11} />{/if}
<span class="spacer"></span>
<span class="tiny muted">{niceDate(n.date)}</span>
</div>
<h3>{n.title}</h3>
<p class="selectable">{n.body}</p>
{#if n.link}
<button class="more ghost sm" onclick={() => n.link && openUrl(n.link)}>Read more <ArrowUpRight size={13} /></button>
{/if}
</div>
</article>
{/each}
</div>
</section>
<style>
.news { display: flex; flex-direction: column; min-height: 0; gap: 0.7rem; }
.head { display: flex; align-items: center; gap: 0.45rem; font-size: 0.75rem; color: var(--muted); font-weight: 560; padding-left: 0.1rem; }
.list { display: flex; flex-direction: column; gap: 0.7rem; overflow-y: auto; padding-right: 0.3rem; min-height: 0; padding-bottom: 1rem; }
.card { overflow: hidden; flex-shrink: 0; background: var(--surface); transition: border-color 0.15s; }
.card:hover { border-color: var(--line-strong); }
.card img { width: 100%; aspect-ratio: 16 / 7; object-fit: cover; display: block; }
.body { padding: 0.85rem 1rem 0.9rem; display: flex; flex-direction: column; gap: 0.35rem; }
.meta { display: flex; align-items: center; gap: 0.4rem; color: var(--muted); }
.tag { font-size: 0.72rem; font-weight: 560; color: var(--accent); }
h3 { font-size: 0.95rem; font-weight: 600; line-height: 1.35; }
p { font-size: 0.83rem; line-height: 1.55; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.more { align-self: flex-start; padding-left: 0; color: var(--accent); margin-top: 0.1rem; }
.more:hover { background: transparent; color: var(--text); }
</style>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { Play, X, Square, LoaderCircle, TriangleAlert, RotateCcw } from '@lucide/svelte';
import { app, cancel, kill, play } from '../lib/store.svelte';
import { bytes } from '../lib/format';
import type { Instance } from '../lib/types';
let { inst }: { inst: Instance } = $props();
const l = $derived(app.launch);
const mine = $derived(app.launchingId === inst.id);
const busy = $derived(mine && l.state === 'preparing');
const running = $derived(app.running === inst.id);
const otherRunning = $derived(!!app.running && app.running !== inst.id);
const pct = $derived(l.total > 0 ? Math.min(100, (l.done / l.total) * 100) : 0);
const eta = $derived(l.speed > 0 && l.total > l.done ? Math.ceil((l.total - l.done) / l.speed) : 0);
const stageNames: Record<string, string> = { preparing: 'Preparing', java: 'Java', game: 'Game files', loader: 'Mod loader', files: 'Instance files', launching: 'Launching' };
const noAccount = $derived(!app.active);
</script>
<svelte:window onkeydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !busy && !running && !otherRunning && !noAccount && app.view === 'home') play(inst.id);
}} />
<div class="wrap">
{#if busy}
<div class="progress glass">
<div class="top">
<span class="stage">
{#if l.stage === 'launching' || !l.total}<LoaderCircle class="spin" size={15} />{/if}
{l.label || 'Preparing…'}
</span>
<button class="ghost icon sm" onclick={cancel} aria-label="Cancel" title="Cancel"><X size={15} /></button>
</div>
<div class="bar" class:indeterminate={!l.total}><span style:width="{pct}%"></span></div>
<div class="meta tiny muted">
<span>{l.stage ? stageNames[l.stage] : ''}{l.filesTotal ? ` · ${l.filesDone.toLocaleString()} / ${l.filesTotal.toLocaleString()} files` : ''}</span>
<span>{#if l.total}{bytes(l.done)} / {bytes(l.total)}{#if l.speed > 0} · {bytes(l.speed)}/s{#if eta} · {eta < 60 ? `${eta}s` : `${Math.ceil(eta / 60)}m`} left{/if}{/if}{/if}</span>
</div>
</div>
{:else if running}
<button class="play running" onclick={kill} title="Force-close Minecraft">
<span class="pulse"></span>
<span class="lbl">Playing</span>
<span class="stop"><Square size={13} fill="currentColor" /> Stop</span>
</button>
{:else}
<button class="play" onclick={() => play(inst.id)} disabled={otherRunning || noAccount} title={noAccount ? 'Sign in first' : otherRunning ? 'Another instance is running' : 'Ctrl + Enter'}>
<Play size={18} fill="currentColor" />
<span class="lbl">Play</span>
</button>
{/if}
{#if mine && l.state === 'error'}
<div class="error glass selectable">
<TriangleAlert size={17} />
<span>{l.message}</span>
<button class="sm" onclick={() => play(inst.id)}><RotateCcw size={13} /> Retry</button>
</div>
{/if}
</div>
<style>
.wrap { display: flex; flex-direction: column; align-items: flex-start; gap: 0.6rem; }
.play {
height: 3.3rem; min-width: 14rem; padding: 0 2rem; border: none; border-radius: var(--radius);
background: var(--accent); color: white; font-size: 1.05rem; font-weight: 600; letter-spacing: 0.02em; gap: 0.65rem;
}
.play:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 88%, white); }
.play:active:not(:disabled) { background: color-mix(in srgb, var(--accent) 85%, black); }
.play:disabled { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--muted); }
.lbl { position: relative; }
.running { background: color-mix(in srgb, var(--success) 16%, var(--surface)); color: var(--text); border: 1px solid color-mix(in srgb, var(--success) 35%, transparent); padding: 0 0.6rem 0 1.4rem; }
.running:hover:not(:disabled) { background: color-mix(in srgb, var(--success) 22%, var(--surface)); }
.pulse { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--success); }
.stop { font-size: 0.8rem; font-weight: 520; letter-spacing: 0; display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.4rem 0.7rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--text) 8%, transparent); color: color-mix(in srgb, var(--text) 80%, transparent); margin-left: 0.6rem; }
.progress { width: 26rem; max-width: 100%; padding: 0.85rem 1rem 0.8rem; display: flex; flex-direction: column; gap: 0.55rem; background: var(--surface); }
.top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.stage { display: flex; align-items: center; gap: 0.5rem; font-weight: 560; font-size: 0.9rem; }
.bar { height: 0.3rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 9%, transparent); overflow: hidden; position: relative; }
.bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width 0.2s linear; }
.bar.indeterminate span { width: 30% !important; animation: slide 1.3s ease-in-out infinite; }
@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(340%); } }
.meta { display: flex; justify-content: space-between; gap: 1rem; font-variant-numeric: tabular-nums; }
.error { display: flex; align-items: center; gap: 0.7rem; padding: 0.65rem 0.8rem; max-width: 34rem; font-size: 0.85rem; background: color-mix(in srgb, var(--danger) 10%, var(--surface)); border-color: color-mix(in srgb, var(--danger) 30%, transparent); line-height: 1.4; }
.error :global(svg) { color: var(--danger); flex-shrink: 0; }
.error span { flex: 1; }
</style>
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import { Settings, Plus, Check, LogOut, Home } from '@lucide/svelte';
import Avatar from './Avatar.svelte';
import { abs, activeAccount, app, instances, removeAccount, selectInstance, switchAccount } from '../lib/store.svelte';
let menu = $state(false);
const kindLabel = { panel: 'Account', microsoft: 'Microsoft', offline: 'Offline' };
const initials = (name: string) => name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase();
</script>
<svelte:window onclick={(e) => menu && !(e.target as HTMLElement).closest('.account') && (menu = false)} />
<nav class="rail glass">
<div class="list">
{#each instances() as inst (inst.id)}
<button
class="inst"
class:active={app.view === 'home' && app.selected === inst.id}
class:running={app.running === inst.id}
onclick={() => { selectInstance(inst.id); app.view = 'home'; }}
aria-label={inst.name}
>
{#if inst.icon_url}<img src={abs(inst.icon_url)} alt="" />{:else}<span>{initials(inst.name)}</span>{/if}
<em class="tip">{inst.name}</em>
</button>
{/each}
</div>
<div class="bottom">
{#if app.view === 'settings'}
<button class="ghost icon big" onclick={() => (app.view = 'home')} aria-label="Home"><Home size={20} /></button>
{/if}
<button class="ghost icon big" class:on={app.view === 'settings'} onclick={() => (app.view = 'settings')} aria-label="Settings">
<Settings size={20} />
{#if app.update}<span class="dot"></span>{/if}
</button>
<div class="account">
<button class="me" onclick={() => (menu = !menu)} aria-label="Accounts">
<Avatar account={activeAccount()} size={2.3} />
</button>
{#if menu}
<div class="menu glass">
<span class="tiny muted head">Accounts</span>
{#each app.accounts as a (a.id)}
<div class="acc" class:current={a.id === app.active}>
<button class="pick" onclick={() => { switchAccount(a.id); menu = false; }}>
<Avatar account={a} size={1.9} />
<span class="who"><strong>{a.username}</strong><span class="tiny muted">{kindLabel[a.kind]}</span></span>
{#if a.id === app.active}<Check size={16} />{/if}
</button>
<button class="ghost icon" title="Sign out" aria-label="Sign out {a.username}" onclick={() => removeAccount(a.id)}><LogOut size={14} /></button>
</div>
{/each}
<button class="add" onclick={() => { app.addAccount = true; menu = false; }}><Plus size={16} /> Add account</button>
</div>
{/if}
</div>
</div>
</nav>
<style>
.rail { width: 4.4rem; display: flex; flex-direction: column; align-items: center; padding: 0.6rem 0 0.8rem; gap: 0.5rem; border: none; border-right: 1px solid var(--line); border-radius: 0; background: color-mix(in srgb, var(--surface) 70%, transparent); position: relative; z-index: 20; flex-shrink: 0; }
.list { display: flex; flex-direction: column; gap: 0.6rem; align-items: center; overflow-y: auto; overflow-x: visible; flex: 1; width: 100%; padding: 0.2rem 0; scrollbar-width: none; }
.inst {
width: 2.9rem; height: 2.9rem; padding: 0; border-radius: var(--radius); position: relative; flex-shrink: 0; overflow: visible;
background: color-mix(in srgb, var(--text) 6%, transparent); border-color: transparent; font-weight: 600; font-size: 0.85rem;
color: color-mix(in srgb, var(--text) 75%, transparent);
}
.inst img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
.inst:hover { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); }
.inst.active { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent); }
.inst.active::before { content: ''; position: absolute; left: -0.75rem; top: 0.7rem; bottom: 0.7rem; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
.inst.running::after { content: ''; position: absolute; right: -0.2rem; bottom: -0.2rem; width: 0.8rem; height: 0.8rem; border-radius: 50%; background: var(--success); border: 2px solid var(--bg); }
.tip { position: fixed; left: 5rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; white-space: nowrap; padding: 0.35rem 0.7rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
.inst:hover .tip { opacity: 1; }
.bottom { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.big { width: 2.6rem; height: 2.6rem; position: relative; }
.big.on { color: var(--text); background: color-mix(in srgb, var(--text) 9%, transparent); }
.dot { position: absolute; top: 0.55rem; right: 0.55rem; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--accent); }
.account { position: relative; margin-top: 0.3rem; }
.me { padding: 0.15rem; border-radius: 30%; background: transparent; border-color: transparent; }
.me:hover { border-color: var(--line-strong); background: transparent; }
.menu { position: absolute; left: calc(100% + 0.9rem); bottom: 0; width: 17rem; padding: 0.6rem; display: flex; flex-direction: column; gap: 0.25rem; background: var(--surface); backdrop-filter: none; border: 1px solid var(--line-strong); box-shadow: 0 0.8rem 2rem -1rem rgba(0, 0, 0, 0.6); animation: fade 0.12s ease; z-index: 60; }
.head { padding: 0.2rem 0.5rem 0.4rem; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; }
.acc { display: flex; align-items: center; gap: 0.2rem; border-radius: var(--radius-sm); }
.acc.current { background: color-mix(in srgb, var(--text) 6%, transparent); }
.pick { flex: 1; justify-content: flex-start; background: transparent; border-color: transparent; padding: 0.45rem 0.5rem; gap: 0.65rem; min-width: 0; }
.pick :global(svg) { color: var(--accent); }
.who { display: flex; flex-direction: column; align-items: flex-start; flex: 1; min-width: 0; }
.who strong { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.add { margin-top: 0.3rem; border-style: dashed; background: transparent; }
</style>
@@ -0,0 +1,87 @@
<script lang="ts">
import { Users, Signal, Copy, Check } from '@lucide/svelte';
import { parseMotd } from '../lib/motd';
import { invoke } from '../lib/tauri';
import type { ServerEntry, ServerStatus } from '../lib/types';
let { server }: { server: ServerEntry } = $props();
let status = $state<ServerStatus | null>(null);
let failed = $state(false);
let copied = $state(false);
$effect(() => {
const { address, port } = server;
status = null;
failed = false;
let alive = true;
const load = () =>
invoke<ServerStatus>('ping_server', { host: address, port })
.then((s) => alive && ((status = s), (failed = false)))
.catch(() => alive && (failed = true));
load();
const t = setInterval(() => !document.hidden && load(), 30_000);
return () => {
alive = false;
clearInterval(t);
};
});
const addr = $derived(server.port === 25565 ? server.address : `${server.address}:${server.port}`);
const bars = $derived(!status ? 0 : status.latency_ms < 60 ? 4 : status.latency_ms < 120 ? 3 : status.latency_ms < 250 ? 2 : 1);
function copy() {
navigator.clipboard.writeText(addr);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<div class="server glass">
{#if status?.favicon}<img src={status.favicon} alt="" class="fav" />{:else}<span class="fav ph" class:on={status}></span>{/if}
<div class="info">
<div class="row name">
<strong>{server.name || server.address}</strong>
<span class="state" class:online={status} class:off={failed}>{status ? 'Online' : failed ? 'Offline' : 'Checking…'}</span>
</div>
{#if status}
<div class="motd">{#each parseMotd(status.motd.split('\n')[0]) as s}<span style:color={s.color} class:b={s.bold} class:i={s.italic}>{s.text}</span>{/each}</div>
{:else}
<button class="addr" onclick={copy}>{addr} {#if copied}<Check size={12} />{:else}<Copy size={12} />{/if}</button>
{/if}
</div>
{#if status}
<div class="stats">
<span class="players" title={status.sample.join(', ')}><Users size={14} /> <b>{status.players_online}</b><span class="muted">/{status.players_max}</span></span>
<span class="ping" title="{status.latency_ms} ms">
<span class="sig">{#each [1, 2, 3, 4] as i}<i class:lit={i <= bars} style:height="{i * 25}%"></i>{/each}</span>
{status.latency_ms}ms
</span>
</div>
{/if}
</div>
<style>
.server { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.9rem 0.5rem 0.5rem; min-width: 18rem; max-width: 28rem; height: 2.75rem; background: var(--surface); }
.fav { width: 1.8rem; height: 1.8rem; border-radius: calc(var(--radius-sm) - 2px); image-rendering: pixelated; flex-shrink: 0; }
.ph { background: color-mix(in srgb, var(--text) 8%, transparent); }
.ph.on { background: color-mix(in srgb, var(--accent) 30%, transparent); }
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.05rem; }
.name { gap: 0.5rem; }
.name strong { font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.state { font-size: 0.72rem; font-weight: 520; color: var(--muted); display: inline-flex; align-items: center; gap: 0.3rem; }
.state::before { content: ''; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; }
.state.online { color: var(--success); }
.state.off { color: var(--danger); }
.motd { font-size: 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: color-mix(in srgb, var(--text) 75%, transparent); }
.motd .b { font-weight: 700; }
.motd .i { font-style: italic; }
.addr { all: unset; cursor: pointer; font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 0.35rem; font-family: var(--mono); }
.addr:hover { color: var(--text); }
.stats { display: flex; flex-direction: column; align-items: flex-end; gap: 0.05rem; font-size: 0.78rem; font-variant-numeric: tabular-nums; }
.players { display: inline-flex; align-items: center; gap: 0.3rem; }
.players :global(svg) { color: var(--muted); }
.ping { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.72rem; }
.sig { display: inline-flex; align-items: flex-end; gap: 0.1rem; height: 0.7rem; }
.sig i { width: 0.18rem; background: color-mix(in srgb, var(--text) 20%, transparent); border-radius: 1px; }
.sig i.lit { background: var(--success); }
</style>
+160
View File
@@ -0,0 +1,160 @@
<script lang="ts">
import { UserRound, WifiOff, LoaderCircle, Copy, ExternalLink, Check, ArrowRight, UserPlus } from '@lucide/svelte';
import { accountAdded, app, toast } from '../lib/store.svelte';
import { errorText, invoke, openUrl } from '../lib/tauri';
import type { Account, DeviceCode } from '../lib/types';
const auth = $derived(app.manifest?.auth);
const methods = $derived(
[
auth?.panel_accounts !== false && { id: 'panel', label: 'Account' },
auth?.microsoft && { id: 'microsoft', label: 'Microsoft' },
auth?.offline_local !== false && { id: 'offline', label: 'Offline' },
].filter(Boolean) as { id: string; label: string }[],
);
let method = $state('');
$effect(() => {
if (!methods.some((m) => m.id === method)) method = methods[0]?.id ?? 'offline';
});
let username = $state('');
let password = $state('');
let email = $state('');
let registering = $state(false);
let busy = $state(false);
let error = $state('');
let pendingNotice = $state(false);
let code = $state<DeviceCode | null>(null);
let copied = $state(false);
const canRegister = $derived(auth?.registration && auth.registration !== 'closed');
async function submit(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
if (method === 'offline') {
await accountAdded(await invoke<Account>('add_offline', { username }));
} else if (registering) {
const r = await invoke<{ account: Account | null; pending: boolean }>('register_panel', { username, password, email: email || null });
if (r.pending) pendingNotice = true;
else if (r.account) await accountAdded(r.account);
} else {
await accountAdded(await invoke<Account>('login_panel', { username, password }));
}
if (!pendingNotice) toast(`Signed in as ${username}`);
} catch (err) {
error = errorText(err);
} finally {
busy = false;
}
}
async function microsoft() {
error = '';
busy = true;
try {
code = await invoke<DeviceCode>('ms_start');
openUrl(code.verification_uri);
const account = await invoke<Account>('ms_finish');
code = null;
await accountAdded(account);
toast(`Signed in as ${account.username}`);
} catch (err) {
if (errorText(err) !== 'cancelled') error = errorText(err);
code = null;
} finally {
busy = false;
}
}
function copy() {
if (!code) return;
navigator.clipboard.writeText(code.user_code);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<div class="signin">
{#if methods.length > 1}
<div class="segmented tabs">
{#each methods as m}
<button class:active={method === m.id} onclick={() => { method = m.id; error = ''; registering = false; }}>{m.label}</button>
{/each}
</div>
{/if}
{#if pendingNotice}
<div class="notice">
<Check size={22} />
<div><strong>Account created!</strong><p class="small muted">An admin needs to approve it before you can sign in. Check back soon.</p></div>
</div>
<button onclick={() => { pendingNotice = false; registering = false; }}>Back to sign in</button>
{:else if method === 'microsoft'}
{#if code}
<div class="code">
<p class="small muted">Go to <strong>{code.verification_uri.replace('https://', '')}</strong> and enter:</p>
<button class="usercode" onclick={copy} title="Copy">{code.user_code} {#if copied}<Check size={18} />{:else}<Copy size={16} />{/if}</button>
<div class="row">
<button class="sm" onclick={() => code && openUrl(code.verification_uri)}><ExternalLink size={14} /> Open page</button>
<button class="sm ghost" onclick={() => invoke('ms_cancel')}>Cancel</button>
</div>
<p class="tiny muted row wait"><LoaderCircle class="spin" size={14} /> Waiting for you to finish in the browser…</p>
</div>
{:else}
<p class="small muted">Own Minecraft? Sign in with Microsoft for your real skin and access to online-mode servers.</p>
<button class="primary big ms" onclick={microsoft} disabled={busy}>
<svg viewBox="0 0 21 21" width="18" height="18" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>
Sign in with Microsoft
</button>
{/if}
{:else}
<form class="col" onsubmit={submit}>
<label class="field">
{method === 'offline' ? 'Username' : 'Username'}
<div class="inp"><span class="ic">{#if method === 'offline'}<WifiOff size={16} />{:else}<UserRound size={16} />{/if}</span>
<input bind:value={username} autocomplete="username" placeholder={method === 'offline' ? 'Steve' : ''} maxlength="16" required />
</div>
{#if method === 'offline'}<span class="help">Play without an account. You'll only see public instances.</span>{/if}
</label>
{#if method === 'panel'}
<label class="field">Password<input type="password" bind:value={password} autocomplete={registering ? 'new-password' : 'current-password'} required minlength={registering ? 8 : undefined} /></label>
{#if registering}
<label class="field">Email <span class="help">Optional</span><input type="email" bind:value={email} /></label>
{/if}
{/if}
<button class="primary big" disabled={busy || !username}>
{#if busy}<LoaderCircle class="spin" size={18} />{:else if registering}<UserPlus size={18} />{:else}<ArrowRight size={18} />{/if}
{method === 'offline' ? 'Play offline' : registering ? 'Create account' : 'Sign in'}
</button>
{#if method === 'panel' && canRegister}
<button type="button" class="ghost sm switch" onclick={() => { registering = !registering; error = ''; }}>
{registering ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
</button>
{/if}
</form>
{/if}
{#if error}<div class="error selectable">{error}</div>{/if}
</div>
<style>
.signin { display: flex; flex-direction: column; gap: 1rem; }
.tabs { width: 100%; }
.tabs button { flex: 1; }
.inp { position: relative; }
.inp .ic { position: absolute; left: 0.8rem; top: 50%; transform: translateY(-50%); color: var(--muted); display: flex; }
.inp input { padding-left: 2.4rem; }
.big { padding: 0.75rem; font-size: 0.95rem; }
.ms { background: color-mix(in srgb, var(--text) 8%, transparent); border: 1px solid var(--line-strong); color: var(--text); }
.ms:hover { background: color-mix(in srgb, var(--text) 12%, transparent); }
.switch { align-self: center; color: var(--muted); }
.code { display: flex; flex-direction: column; align-items: center; gap: 0.8rem; text-align: center; padding: 0.5rem 0; }
.usercode { font-family: var(--mono); font-size: 1.5rem; letter-spacing: 0.12em; padding: 0.6rem 1.2rem; font-weight: 600; }
.wait { gap: 0.4rem; }
.error { font-size: 0.87rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); color: color-mix(in srgb, var(--danger) 60%, white); background: color-mix(in srgb, var(--danger) 12%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); }
.notice { display: flex; gap: 0.8rem; align-items: flex-start; padding: 1rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--success) 12%, transparent); }
.notice :global(svg) { color: var(--success); flex-shrink: 0; }
</style>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import { Minus, Square, X, WifiOff } from '@lucide/svelte';
import { abs, app } from '../lib/store.svelte';
import { windowAction } from '../lib/tauri';
const b = $derived(app.manifest?.branding);
</script>
<header data-tauri-drag-region>
<div class="brand" data-tauri-drag-region>
{#if b?.logo_url}
<img src={abs(b.logo_url)} alt="" />
{:else}
<span class="mark"></span>
{/if}
<span data-tauri-drag-region>{b?.name ?? 'Launcher'}</span>
{#if app.offline}<span class="offline" title="Can't reach the server — using saved data"><WifiOff size={12} /> Offline</span>{/if}
</div>
<div class="controls">
<button class="ctl" onclick={() => windowAction('minimize')} aria-label="Minimise"><Minus size={15} /></button>
<button class="ctl" onclick={() => windowAction('maximize')} aria-label="Maximise"><Square size={12} /></button>
<button class="ctl close" onclick={() => windowAction('close')} aria-label="Close"><X size={16} /></button>
</div>
</header>
<style>
header { height: 2.6rem; display: flex; align-items: center; justify-content: space-between; padding-left: 1rem; position: relative; z-index: 30; flex-shrink: 0; }
.brand { display: flex; align-items: center; gap: 0.6rem; font-weight: 650; font-size: 0.82rem; letter-spacing: 0.08em; pointer-events: none; }
.brand img { height: 1.3rem; border-radius: 0.3rem; }
.mark { width: 0.85rem; height: 0.85rem; border-radius: 50%; border: 2px solid var(--accent); position: relative; }
.mark::after { content: ''; position: absolute; inset: 0.17rem; border-radius: 50%; background: var(--accent); }
.offline { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem; color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); padding: 0.15rem 0.5rem; border-radius: 99rem; pointer-events: auto; }
.controls { display: flex; height: 100%; }
.ctl { height: 100%; width: 3rem; border: none; border-radius: 0; background: transparent; color: color-mix(in srgb, var(--text) 70%, transparent); padding: 0; }
.ctl:hover { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); }
.ctl:active { transform: none; }
.close:hover { background: #e81123; color: white; }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import { CircleCheck, CircleAlert, Info } from '@lucide/svelte';
import { app } from '../lib/store.svelte';
</script>
<div class="toasts" aria-live="polite">
{#each app.toasts as t (t.id)}
<div class="toast glass {t.kind}">
{#if t.kind === 'ok'}<CircleCheck size={17} />{:else if t.kind === 'error'}<CircleAlert size={17} />{:else}<Info size={17} />{/if}
<span class="selectable">{t.text}</span>
</div>
{/each}
</div>
<style>
.toasts { position: fixed; bottom: 1.25rem; right: 1.25rem; z-index: 100; display: flex; flex-direction: column; gap: 0.6rem; max-width: 26rem; }
.toast { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.75rem 1rem; font-size: 0.88rem; line-height: 1.4; background: var(--surface); border-color: var(--line-strong); box-shadow: 0 0.6rem 1.5rem -0.8rem rgba(0, 0, 0, 0.5); animation: fade 0.15s ease; }
.toast :global(svg) { flex-shrink: 0; margin-top: 0.1rem; }
.ok :global(svg) { color: var(--success); }
.error :global(svg) { color: var(--danger); }
.info :global(svg) { color: var(--accent-2); }
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
let { checked = $bindable(false), label, help, disabled = false, onchange }: { checked: boolean; label: string; help?: string; disabled?: boolean; onchange?: () => void } = $props();
</script>
<label class="toggle" class:disabled>
<span class="text"><span class="label">{label}</span>{#if help}<span class="help">{help}</span>{/if}</span>
<input type="checkbox" role="switch" bind:checked {disabled} onchange={() => onchange?.()} />
<span class="track" aria-hidden="true"><span class="thumb"></span></span>
</label>
<style>
.toggle { display: flex; align-items: center; gap: 1rem; cursor: pointer; padding: 0.35rem 0; }
.disabled { opacity: 0.5; cursor: not-allowed; }
.text { flex: 1; display: flex; flex-direction: column; gap: 0.15rem; }
.label { font-weight: 560; font-size: 0.93rem; }
input { position: absolute; opacity: 0; pointer-events: none; }
.track { width: 2.75rem; height: 1.55rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 14%, transparent); position: relative; transition: background 0.2s; flex-shrink: 0; }
.thumb { position: absolute; top: 0.18rem; left: 0.18rem; width: 1.19rem; height: 1.19rem; border-radius: 50%; background: color-mix(in srgb, var(--text) 80%, transparent); transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.6, 1), background 0.2s; }
input:checked + .track { background: var(--accent); }
input:checked + .track .thumb { transform: translateX(1.2rem); background: white; }
input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>
+28
View File
@@ -0,0 +1,28 @@
export function bytes(n: number): string {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
return `${(n / 1024 ** i).toFixed(i > 1 ? 1 : 0)} ${u[i]}`;
}
export function gb(mb: number): string {
return `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1)} GB`;
}
export const loaderName: Record<string, string> = {
vanilla: 'Vanilla',
fabric: 'Fabric',
quilt: 'Quilt',
forge: 'Forge',
neoforge: 'NeoForge',
};
export function shortLoaderVersion(mc: string, v: string | null): string {
return v ? v.replace(`${mc}-`, '') : '';
}
export function niceDate(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso + (iso.length === 10 ? 'T00:00:00' : ''));
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
}
+90
View File
@@ -0,0 +1,90 @@
// Minecraft (1.13+) key names used in options.txt, and friendly labels.
export interface Binding { id: string; label: string; default: string }
export const BIND_GROUPS: { title: string; binds: Binding[] }[] = [
{
title: 'Movement',
binds: [
{ id: 'key.forward', label: 'Walk forwards', default: 'key.keyboard.w' },
{ id: 'key.left', label: 'Strafe left', default: 'key.keyboard.a' },
{ id: 'key.back', label: 'Walk backwards', default: 'key.keyboard.s' },
{ id: 'key.right', label: 'Strafe right', default: 'key.keyboard.d' },
{ id: 'key.jump', label: 'Jump', default: 'key.keyboard.space' },
{ id: 'key.sneak', label: 'Sneak', default: 'key.keyboard.left.shift' },
{ id: 'key.sprint', label: 'Sprint', default: 'key.keyboard.left.control' },
],
},
{
title: 'Gameplay',
binds: [
{ id: 'key.attack', label: 'Attack / destroy', default: 'key.mouse.left' },
{ id: 'key.use', label: 'Use item / place block', default: 'key.mouse.right' },
{ id: 'key.pickItem', label: 'Pick block', default: 'key.mouse.middle' },
{ id: 'key.drop', label: 'Drop item', default: 'key.keyboard.q' },
{ id: 'key.swapOffhand', label: 'Swap to offhand', default: 'key.keyboard.f' },
],
},
{
title: 'Interface',
binds: [
{ id: 'key.inventory', label: 'Inventory', default: 'key.keyboard.e' },
{ id: 'key.chat', label: 'Open chat', default: 'key.keyboard.t' },
{ id: 'key.command', label: 'Open command', default: 'key.keyboard.slash' },
{ id: 'key.playerlist', label: 'Player list', default: 'key.keyboard.tab' },
{ id: 'key.advancements', label: 'Advancements', default: 'key.keyboard.l' },
{ id: 'key.socialInteractions', label: 'Social interactions', default: 'key.keyboard.p' },
{ id: 'key.screenshot', label: 'Screenshot', default: 'key.keyboard.f2' },
{ id: 'key.togglePerspective', label: 'Toggle perspective', default: 'key.keyboard.f5' },
{ id: 'key.fullscreen', label: 'Toggle fullscreen', default: 'key.keyboard.f11' },
],
},
{
title: 'Hotbar',
binds: Array.from({ length: 9 }, (_, i) => ({ id: `key.hotbar.${i + 1}`, label: `Hotbar slot ${i + 1}`, default: `key.keyboard.${i + 1}` })),
},
];
const SPECIAL: Record<string, string> = {
Space: 'space', ShiftLeft: 'left.shift', ShiftRight: 'right.shift', ControlLeft: 'left.control', ControlRight: 'right.control',
AltLeft: 'left.alt', AltRight: 'right.alt', Tab: 'tab', Enter: 'enter', Backspace: 'backspace', CapsLock: 'caps.lock',
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', Backquote: 'grave.accent', Minus: 'minus',
Equal: 'equal', BracketLeft: 'left.bracket', BracketRight: 'right.bracket', Backslash: 'backslash', Semicolon: 'semicolon',
Quote: 'apostrophe', Comma: 'comma', Period: 'period', Slash: 'slash', Insert: 'insert', Delete: 'delete', Home: 'home',
End: 'end', PageUp: 'page.up', PageDown: 'page.down', NumpadAdd: 'keypad.add', NumpadSubtract: 'keypad.subtract',
NumpadMultiply: 'keypad.multiply', NumpadDivide: 'keypad.divide', NumpadEnter: 'keypad.enter', NumpadDecimal: 'keypad.decimal',
};
/** KeyboardEvent.code → Minecraft key name (null for unsupported keys). */
export function fromKeyboard(code: string): string | null {
if (/^Key[A-Z]$/.test(code)) return `key.keyboard.${code.slice(3).toLowerCase()}`;
if (/^Digit\d$/.test(code)) return `key.keyboard.${code.slice(5)}`;
if (/^F\d{1,2}$/.test(code)) return `key.keyboard.${code.toLowerCase()}`;
if (/^Numpad\d$/.test(code)) return `key.keyboard.keypad.${code.slice(6)}`;
return SPECIAL[code] ? `key.keyboard.${SPECIAL[code]}` : null;
}
export function fromMouse(button: number): string {
return ['key.mouse.left', 'key.mouse.middle', 'key.mouse.right', 'key.mouse.4', 'key.mouse.5'][button] ?? `key.mouse.${button + 1}`;
}
/** Human label for a Minecraft key name. */
export function keyLabel(key: string): string {
if (!key || key === 'key.keyboard.unknown') return 'Not bound';
const mouse = key.match(/^key\.mouse\.(.+)$/);
if (mouse) return { left: 'Left click', right: 'Right click', middle: 'Middle click' }[mouse[1]] ?? `Mouse ${mouse[1]}`;
const k = key.replace('key.keyboard.', '');
const names: Record<string, string> = {
'left.shift': 'Left Shift', 'right.shift': 'Right Shift', 'left.control': 'Left Ctrl', 'right.control': 'Right Ctrl',
'left.alt': 'Left Alt', 'right.alt': 'Right Alt', 'caps.lock': 'Caps Lock', 'page.up': 'Page Up', 'page.down': 'Page Down',
'grave.accent': '`', 'left.bracket': '[', 'right.bracket': ']', slash: '/', backslash: '\\', semicolon: ';', apostrophe: "'",
comma: ',', period: '.', minus: '-', equal: '=', up: '↑', down: '↓', left: '←', right: '→',
};
if (names[k]) return names[k];
if (k.startsWith('keypad.')) return `Num ${k.slice(7)}`;
return k.length === 1 ? k.toUpperCase() : k.charAt(0).toUpperCase() + k.slice(1);
}
export function defaults(): Record<string, string> {
return Object.fromEntries(BIND_GROUPS.flatMap((g) => g.binds.map((b) => [b.id, b.default])));
}
+167
View File
@@ -0,0 +1,167 @@
// In-browser stand-in for the Rust backend, used by `npm run dev` outside
// Tauri and for UI screenshots. Scenario via ?mock=setup|login|main.
import { mockEmit } from './tauri';
import type { Account, Bootstrap, Manifest, Settings } from './types';
const scenario = new URLSearchParams(location.search).get('mock') ?? 'main';
const settings: Settings = {
panel_url: scenario === 'setup' ? null : 'https://panel.scopenet.gg',
selected_instance: null,
memory_max_mb: 0,
memory_min_mb: 0,
custom_resolution: false,
width: 1280,
height: 720,
fullscreen: false,
after_launch: 'minimize',
show_console: false,
java_path: null,
gc: 'g1',
jvm_args: '',
theme_mode: 'server',
accent: null,
background_video: true,
reduce_motion: false,
glass: null,
ui_scale: 100,
concurrent_downloads: 12,
check_updates: true,
send_stats: true,
keybinds_enabled: false,
keybinds: {},
instance_overrides: {},
};
const manifest: Manifest = {
api_version: 1,
panel_version: '0.1.0',
user: null,
auth: { panel_accounts: true, registration: 'approval', microsoft: true, microsoft_client_id: 'demo', offline_local: true },
branding: {
name: 'SCOPENET',
tagline: 'Your worlds, one click away.',
logo_url: null,
icon_url: null,
background: { kind: 'gradient', url: null, blur: 0, dim: 55 },
colors: { accent: '#6d6af5', accent_2: '#8b88f8', background: '#0d0e12', surface: '#16181e', text: '#ecedf1', muted: '#8b8f9a', success: '#3fb97c', danger: '#e5484d' },
font: 'Inter',
radius: 10,
glass: false,
version_label: 'SCOPENET',
news: [
{ id: '1', title: 'Season 4 is live!', body: 'A brand new world with custom terrain, player shops and weekly events. Join now and claim your spot before the land rush.', image_url: null, link: 'https://example.com', date: '2026-09-26', pinned: true, tag: 'Update' },
{ id: '2', title: 'Build contest: Autumn Castles', body: 'Submit your best castle build on the Creative server by October 10th. Top three win VIP for a month.', image_url: null, link: null, date: '2026-09-21', pinned: false, tag: 'Event' },
{ id: '3', title: 'Performance boost', body: 'We switched the modpack to Sodium + Lithium. Expect way higher FPS on older machines.', image_url: null, link: null, date: '2026-09-14', pinned: false, tag: 'Changelog' },
],
links: [
{ label: 'Discord', url: 'https://discord.gg/example', icon: 'discord' },
{ label: 'Website', url: 'https://example.com', icon: 'website' },
{ label: 'Store', url: 'https://example.com/store', icon: 'store' },
],
features: { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true },
custom_css: '',
},
instances: [
{ id: 'survival-smp', name: 'Survival SMP', description: 'Our main survival world — season 4. Claims, shops & weekend events.', icon_url: null, banner_url: null, mc_version: '1.21.1', loader: 'fabric', loader_version: '0.16.9', revision: 3, server: { name: 'SCOPENET SMP', address: 'play.scopenet.gg', port: 25565, auto_join: true, inject: true }, memory: { min_mb: 2048, max_mb: 6144 }, jvm_args: '', featured: true, source_label: 'Modrinth · Fabulously Optimized 6.2', file_count: 48, total_size: 184_000_000 },
{ id: 'creative', name: 'Creative Build', description: 'Plots, WorldEdit and weekly build contests.', icon_url: null, banner_url: null, mc_version: '1.20.1', loader: 'vanilla', loader_version: null, revision: 1, server: { name: 'Creative', address: 'creative.scopenet.gg', port: 25565, auto_join: false, inject: true }, memory: { min_mb: 1024, max_mb: 4096 }, jvm_args: '', featured: false, source_label: 'Minecraft 1.20.1', file_count: 0, total_size: 0 },
{ id: 'modded', name: 'Create: Above & Beyond', description: 'Factory building with Create and friends.', icon_url: null, banner_url: null, mc_version: '1.20.1', loader: 'forge', loader_version: '1.20.1-47.3.0', revision: 2, server: null, memory: { min_mb: 4096, max_mb: 8192 }, jvm_args: '', featured: false, source_label: 'CurseForge · Create: Above & Beyond 1.4', file_count: 212, total_size: 612_000_000 },
],
};
const accounts: Account[] = scenario === 'main' || scenario === 'progress' ? [
{ id: 'a1', kind: 'panel', username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', panel_url: 'https://panel.scopenet.gg', role: 'admin' },
{ id: 'a2', kind: 'offline', username: 'Steve', uuid: '5627dd98-e6be-3c21-b8a8-e92344183641', panel_url: null, role: null },
] : [];
let active: string | null = accounts[0]?.id ?? null;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
let running = false;
async function fakeLaunch(id: string) {
const stages: [string, string, number, number][] = [
['java', 'Installing Java 21', 48_000_000, 212],
['game', 'Downloading Minecraft 1.21.1', 420_000_000, 3412],
['loader', 'Installing Fabric 0.16.9', 6_000_000, 14],
['files', 'Syncing instance files', 184_000_000, 48],
];
for (const [stage, label, total, files] of stages) {
mockEmit('launch://progress', { type: 'stage', stage, label });
for (let i = 1; i <= 20; i++) {
await sleep(scenario === 'progress' ? 400 : 60);
mockEmit('launch://progress', { type: 'progress', stage, done: (total * i) / 20, total, files_done: Math.round((files * i) / 20), files_total: files });
}
}
mockEmit('launch://progress', { type: 'stage', stage: 'launching', label: 'Starting Minecraft' });
await sleep(500);
running = true;
mockEmit('launch://state', { instance_id: id, state: 'running', message: null });
const lines = ['[main/INFO]: Loading Minecraft 1.21.1 with Fabric Loader 0.16.9', '[Render thread/INFO]: Setting user: Notch', '[Render thread/INFO]: Backend library: LWJGL version 3.3.3', '[Render thread/INFO]: Reloading ResourceManager: vanilla, fabric', '[Render thread/INFO]: Sound engine started', '[Render thread/INFO]: Connecting to play.scopenet.gg, 25565'];
for (const l of lines) {
await sleep(300);
mockEmit('game://log', [`[12:00:0${lines.indexOf(l)}] ${l}`]);
}
}
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
await sleep(40);
switch (cmd) {
case 'bootstrap': {
const b: Bootstrap = {
app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'scopeddlol/SCOPENET-MC', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' },
panel_url: settings.panel_url,
settings,
accounts,
active_account: active,
manifest: scenario === 'setup' ? null : manifest,
game_running: running ? 'survival-smp' : null,
};
return structuredClone(b);
}
case 'refresh_manifest':
case 'set_panel_url':
if (cmd === 'set_panel_url') settings.panel_url = args.url;
return structuredClone(manifest);
case 'save_settings':
Object.assign(settings, args.settings);
return null;
case 'login_panel':
case 'add_offline': {
const a: Account = { id: crypto.randomUUID(), kind: cmd === 'login_panel' ? 'panel' : 'offline', username: args.username, uuid: crypto.randomUUID(), panel_url: null, role: 'player' };
accounts.push(a);
active = a.id;
return a;
}
case 'register_panel':
return { account: null, pending: true };
case 'ms_start':
return { user_code: 'F7K2-QX9M', verification_uri: 'https://microsoft.com/link', expires_in: 900, interval: 5, message: '' };
case 'ms_finish':
await sleep(600_000);
return null;
case 'select_account':
active = args.id;
return null;
case 'launch':
fakeLaunch(args.instanceId);
return null;
case 'kill_game':
running = false;
mockEmit('game://exit', { instance_id: 'survival-smp', code: 0, crashed: false, tail: [], crash_report: null });
mockEmit('launch://state', { instance_id: 'survival-smp', state: 'idle', message: null });
return null;
case 'ping_server':
await sleep(200);
return { online: true, version: 'Paper 1.21.1', players_online: 42, players_max: 100, sample: ['Notch', 'jeb_', 'Dinnerbone'], motd: '§b§lSCOPENET §r§7» §fSeason 4 is live!', favicon: null, latency_ms: 23 };
case 'instance_local':
return { installed: args.instanceId !== 'modded', revision: 3, size: 1_240_000_000 };
case 'storage_info':
return { shared: 2_400_000_000, runtimes: 410_000_000, instances: [['survival-smp', 1_240_000_000], ['creative', 320_000_000]] };
case 'check_update':
return null;
case 'detect_java':
return 21;
default:
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
// Render Minecraft § formatting codes as HTML-safe segments.
const COLORS: Record<string, string> = {
'0': '#000000', '1': '#0000AA', '2': '#00AA00', '3': '#00AAAA', '4': '#AA0000', '5': '#AA00AA', '6': '#FFAA00', '7': '#AAAAAA',
'8': '#555555', '9': '#5555FF', a: '#55FF55', b: '#55FFFF', c: '#FF5555', d: '#FF55FF', e: '#FFFF55', f: '#FFFFFF',
};
export interface Segment { text: string; color?: string; bold?: boolean; italic?: boolean; underline?: boolean; strike?: boolean }
export function parseMotd(input: string): Segment[] {
const out: Segment[] = [];
let cur: Segment = { text: '' };
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (ch === '§' && i + 1 < input.length) {
const code = input[i + 1].toLowerCase();
if (cur.text) out.push(cur);
const base: Segment = { ...cur, text: '' };
if (code === '#' && /^[0-9a-f]{6}$/i.test(input.slice(i + 2, i + 8))) {
cur = { text: '', color: '#' + input.slice(i + 2, i + 8) };
i += 7;
continue;
}
if (COLORS[code]) cur = { text: '', color: COLORS[code] };
else if (code === 'l') cur = { ...base, bold: true };
else if (code === 'o') cur = { ...base, italic: true };
else if (code === 'n') cur = { ...base, underline: true };
else if (code === 'm') cur = { ...base, strike: true };
else if (code === 'r') cur = { text: '' };
else cur = base;
i++;
continue;
}
cur.text += ch;
}
if (cur.text) out.push(cur);
return out;
}
+243
View File
@@ -0,0 +1,243 @@
import { errorText, invoke, listen } from './tauri';
import { applyTheme } from './theme';
import type { Account, Bootstrap, Instance, Manifest, ProgressEvent, Settings, Stage, UpdateInfo } from './types';
export interface LaunchState {
state: 'idle' | 'preparing' | 'running' | 'error';
stage: Stage | null;
label: string;
done: number;
total: number;
filesDone: number;
filesTotal: number;
speed: number;
message: string | null;
}
export interface ExitInfo { instance_id: string; code: number | null; crashed: boolean; tail: string[]; crash_report: string | null }
const idle = (): LaunchState => ({ state: 'idle', stage: null, label: '', done: 0, total: 0, filesDone: 0, filesTotal: 0, speed: 0, message: null });
export const app = $state({
ready: false,
boot: null as Bootstrap | null,
manifest: null as Manifest | null,
settings: null as Settings | null,
accounts: [] as Account[],
active: null as string | null,
panelUrl: null as string | null,
offline: false,
view: 'home' as 'home' | 'settings',
settingsTab: 'account',
selected: null as string | null,
launch: idle(),
launchingId: null as string | null,
running: null as string | null,
logs: [] as string[],
consoleOpen: false,
crash: null as ExitInfo | null,
update: null as UpdateInfo | null,
addAccount: false,
instanceSettings: null as string | null,
toasts: [] as { id: number; text: string; kind: 'ok' | 'error' | 'info' }[],
});
let toastId = 1;
export function toast(text: string, kind: 'ok' | 'error' | 'info' = 'ok') {
const id = toastId++;
app.toasts.push({ id, text, kind });
setTimeout(() => {
const i = app.toasts.findIndex((t) => t.id === id);
if (i >= 0) app.toasts.splice(i, 1);
}, kind === 'error' ? 7000 : 3500);
}
export const activeAccount = () => app.accounts.find((a) => a.id === app.active) ?? null;
export const instances = (): Instance[] => app.manifest?.instances ?? [];
export const selectedInstance = (): Instance | null => instances().find((i) => i.id === app.selected) ?? instances()[0] ?? null;
/** Resolve panel-relative media URLs ("/uploads/x.png"). */
export function abs(url: string | null | undefined): string | null {
if (!url) return null;
if (url.startsWith('/') && app.panelUrl) return app.panelUrl + url;
return url;
}
function theme() {
if (app.manifest) applyTheme(app.manifest.branding, app.settings);
}
function pickSelected() {
const list = instances();
const wanted = app.settings?.selected_instance;
app.selected = list.find((i) => i.id === wanted)?.id ?? list.find((i) => i.featured)?.id ?? list[0]?.id ?? null;
}
export async function init() {
const boot = await invoke<Bootstrap>('bootstrap');
app.boot = boot;
app.settings = boot.settings;
app.accounts = boot.accounts;
app.active = boot.active_account;
app.manifest = boot.manifest;
app.panelUrl = boot.panel_url;
app.running = boot.game_running;
theme();
pickSelected();
subscribe();
app.ready = true;
invoke('show_window').catch(() => {});
if (app.panelUrl) await refresh();
if (boot.settings.check_updates && boot.app.repo) {
invoke<UpdateInfo | null>('check_update').then((u) => (app.update = u)).catch(() => {});
}
}
export async function refresh() {
try {
app.manifest = await invoke<Manifest>('refresh_manifest');
app.offline = false;
theme();
pickSelected();
} catch (e) {
app.offline = true;
if (!app.manifest) throw e;
}
}
export async function connect(url: string) {
app.manifest = await invoke<Manifest>('set_panel_url', { url });
const boot = await invoke<Bootstrap>('bootstrap');
app.panelUrl = boot.panel_url;
app.offline = false;
theme();
pickSelected();
}
let saveTimer: ReturnType<typeof setTimeout> | undefined;
/** Persist settings (debounced) and re-apply the theme immediately. */
export function saveSettings() {
theme();
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
if (app.settings) invoke('save_settings', { settings: $state.snapshot(app.settings) }).catch((e) => toast(errorText(e), 'error'));
}, 300);
}
export function selectInstance(id: string) {
app.selected = id;
if (app.settings) {
app.settings.selected_instance = id;
saveSettings();
}
}
export async function accountAdded(account: Account) {
const i = app.accounts.findIndex((a) => a.id === account.id);
if (i >= 0) app.accounts[i] = account;
else app.accounts.push(account);
app.active = account.id;
app.addAccount = false;
await refresh().catch(() => {});
}
export async function switchAccount(id: string) {
await invoke('select_account', { id });
app.active = id;
await refresh().catch(() => {});
}
export async function removeAccount(id: string) {
await invoke('remove_account', { id });
app.accounts = app.accounts.filter((a) => a.id !== id);
if (app.active === id) app.active = app.accounts[0]?.id ?? null;
await refresh().catch(() => {});
}
export async function play(id: string) {
app.launch = { ...idle(), state: 'preparing', label: 'Getting ready…' };
app.launchingId = id;
app.logs = [];
try {
await invoke('launch', { instanceId: id });
} catch (e) {
app.launch = { ...idle(), state: 'error', message: errorText(e) };
}
}
export async function repair(id: string) {
app.launch = { ...idle(), state: 'preparing', label: 'Verifying files…' };
app.launchingId = id;
try {
await invoke('repair_instance', { instanceId: id });
} catch (e) {
app.launch = { ...idle(), state: 'error', message: errorText(e) };
}
}
export async function cancel() {
if (app.launchingId) await invoke('cancel_launch', { instanceId: app.launchingId });
}
export async function kill() {
await invoke('kill_game');
}
let subscribed = false;
let last = { t: 0, done: 0, stage: '' as string };
function subscribe() {
if (subscribed) return;
subscribed = true;
listen<ProgressEvent>('launch://progress', (ev) => {
const l = app.launch;
if (ev.type === 'stage') {
l.stage = ev.stage;
l.label = ev.label;
l.done = l.total = l.filesDone = l.filesTotal = 0;
l.speed = 0;
} else if (ev.type === 'progress') {
const now = performance.now();
if (last.stage !== ev.stage || ev.done < last.done) last = { t: now, done: ev.done, stage: ev.stage };
else if (now - last.t > 500) {
const inst = ((ev.done - last.done) / (now - last.t)) * 1000;
l.speed = l.speed ? l.speed * 0.6 + inst * 0.4 : inst;
last = { t: now, done: ev.done, stage: ev.stage };
}
l.stage = ev.stage;
l.done = ev.done;
l.total = ev.total;
l.filesDone = ev.files_done;
l.filesTotal = ev.files_total;
} else if (ev.type === 'log') {
pushLogs([ev.line]);
}
});
listen<{ instance_id: string; state: LaunchState['state']; message: string | null }>('launch://state', (ev) => {
if (ev.state === 'running') {
app.running = ev.instance_id;
app.launch = { ...idle(), state: 'running' };
if (app.settings?.show_console) app.consoleOpen = true;
} else if (ev.state === 'error') {
app.launch = { ...idle(), state: 'error', message: ev.message };
} else if (ev.state === 'idle') {
if (app.running === ev.instance_id) app.running = null;
if (app.launch.state !== 'error') app.launch = idle();
if (ev.message && ev.message !== 'Cancelled') toast(ev.message);
}
});
listen<string[]>('game://log', (lines) => pushLogs(lines));
listen<ExitInfo>('game://exit', (ev) => {
app.running = null;
if (ev.crashed) app.crash = ev;
});
}
function pushLogs(lines: string[]) {
app.logs.push(...lines);
if (app.logs.length > 3000) app.logs.splice(0, app.logs.length - 3000);
}
+57
View File
@@ -0,0 +1,57 @@
// Thin wrapper over Tauri IPC. When the UI runs in a normal browser
// (vite dev / screenshots), calls go to an in-memory mock instead.
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
type Handler = (payload: any) => void;
const mockListeners = new Map<string, Set<Handler>>();
export function mockEmit(event: string, payload: unknown) {
mockListeners.get(event)?.forEach((h) => h(payload));
}
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (inTauri) return tauriInvoke<T>(cmd, args);
const { mockInvoke } = await import('./mock');
return mockInvoke(cmd, args ?? {}) as Promise<T>;
}
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<UnlistenFn> {
if (inTauri) return tauriListen<T>(event, (e) => handler(e.payload));
const set = mockListeners.get(event) ?? new Set();
set.add(handler as Handler);
mockListeners.set(event, set);
return () => set.delete(handler as Handler);
}
export async function windowAction(action: 'minimize' | 'maximize' | 'close' | 'drag') {
if (!inTauri) return;
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const w = getCurrentWindow();
if (action === 'minimize') await w.minimize();
else if (action === 'maximize') await w.toggleMaximize();
else if (action === 'close') await w.close();
else await w.startDragging();
}
export async function openUrl(url: string) {
if (!inTauri) {
window.open(url, '_blank');
return;
}
const { openUrl } = await import('@tauri-apps/plugin-opener');
await openUrl(url);
}
export async function pickFile(title: string, filters?: { name: string; extensions: string[] }[]): Promise<string | null> {
if (!inTauri) return null;
const { open } = await import('@tauri-apps/plugin-dialog');
const r = await open({ title, multiple: false, directory: false, filters });
return typeof r === 'string' ? r : null;
}
export function errorText(e: unknown): string {
return typeof e === 'string' ? e : e instanceof Error ? e.message : JSON.stringify(e);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Branding, Settings } from './types';
let styleEl: HTMLStyleElement | null = null;
/** Push the admin's branding (and the player's overrides) into CSS variables. */
export function applyTheme(b: Branding, s: Settings | null) {
const root = document.documentElement;
const custom = s?.theme_mode === 'custom' && b.features.allow_user_theme;
const accent = (custom && s?.accent) || b.colors.accent;
const vars: Record<string, string> = {
'--accent': accent,
'--accent-2': custom && s?.accent ? shift(accent) : b.colors.accent_2,
'--bg': b.colors.background,
'--surface': b.colors.surface,
'--text': b.colors.text,
'--muted': b.colors.muted,
'--success': b.colors.success,
'--danger': b.colors.danger,
'--radius': `${b.radius}px`,
'--radius-sm': `${Math.max(4, Math.round(b.radius * 0.65))}px`,
'--font': `'${b.font} Variable', '${b.font}', 'Outfit Variable', system-ui, sans-serif`,
};
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
const glass = s?.glass ?? b.glass;
root.classList.toggle('no-glass', !glass);
root.classList.toggle('reduce-motion', !!s?.reduce_motion);
root.style.fontSize = `${(s?.ui_scale ?? 100) * 0.15}px`;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'admin-css';
document.head.appendChild(styleEl);
}
styleEl.textContent = b.custom_css ?? '';
}
/** A second accent derived from the first by rotating its hue. */
function shift(hex: string): string {
const m = hex.replace('#', '').match(/.{2}/g);
if (!m) return hex;
let [r, g, b] = m.map((x) => parseInt(x, 16) / 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0;
const l = (max + min) / 2;
const d = max - min;
const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
if (d) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
}
h = (h * 60 + 50 + 360) % 360;
return `hsl(${h} ${Math.round(sat * 100)}% ${Math.round(l * 100)}%)`;
}
+112
View File
@@ -0,0 +1,112 @@
export type Loader = 'vanilla' | 'fabric' | 'quilt' | 'forge' | 'neoforge';
export interface ServerEntry { name: string; address: string; port: number; auto_join: boolean; inject: boolean }
export interface Instance {
id: string;
name: string;
description: string;
icon_url: string | null;
banner_url: string | null;
mc_version: string;
loader: Loader;
loader_version: string | null;
revision: number;
server: ServerEntry | null;
memory: { min_mb: number; max_mb: number };
jvm_args: string;
featured: boolean;
source_label: string;
file_count: number;
total_size: number;
}
export interface NewsItem { id: string; title: string; body: string; image_url: string | null; link: string | null; date: string | null; pinned: boolean; tag: string | null }
export interface Branding {
name: string;
tagline: string;
logo_url: string | null;
icon_url: string | null;
background: { kind: 'gradient' | 'image' | 'video'; url: string | null; blur: number; dim: number };
colors: { accent: string; accent_2: string; background: string; surface: string; text: string; muted: string; success: string; danger: string };
font: string;
radius: number;
glass: boolean;
version_label: string;
news: NewsItem[];
links: { label: string; url: string; icon: string }[];
features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean };
custom_css: string;
}
export interface AuthConfig {
panel_accounts: boolean;
registration: 'closed' | 'open' | 'approval';
microsoft: boolean;
microsoft_client_id: string | null;
offline_local: boolean;
}
export interface Manifest {
api_version: number;
panel_version: string;
branding: Branding;
auth: AuthConfig;
instances: Instance[];
user: { id: number; username: string; uuid: string; role: string; groups: string[] } | null;
}
export interface Account { id: string; kind: 'panel' | 'microsoft' | 'offline'; username: string; uuid: string; panel_url: string | null; role: string | null }
export type Gc = 'default' | 'g1' | 'zgc';
export interface InstanceOverride { memory_max_mb: number; memory_min_mb: number; jvm_args: string; java_path: string | null }
export interface Settings {
panel_url: string | null;
selected_instance: string | null;
memory_max_mb: number;
memory_min_mb: number;
custom_resolution: boolean;
width: number;
height: number;
fullscreen: boolean;
after_launch: 'minimize' | 'hide' | 'keep' | 'close';
show_console: boolean;
java_path: string | null;
gc: Gc;
jvm_args: string;
theme_mode: 'server' | 'custom';
accent: string | null;
background_video: boolean;
reduce_motion: boolean;
glass: boolean | null;
ui_scale: number;
concurrent_downloads: number;
check_updates: boolean;
send_stats: boolean;
keybinds_enabled: boolean;
keybinds: Record<string, string>;
instance_overrides: Record<string, InstanceOverride>;
}
export interface Bootstrap {
app: { version: string; default_panel_url: string | null; panel_locked: boolean; repo: string | null; os: string; arch: string; total_ram_mb: number; data_dir: string };
panel_url: string | null;
settings: Settings;
accounts: Account[];
active_account: string | null;
manifest: Manifest | null;
game_running: string | null;
}
export type Stage = 'preparing' | 'java' | 'game' | 'loader' | 'files' | 'launching';
export type ProgressEvent =
| { type: 'stage'; stage: Stage; label: string }
| { type: 'progress'; stage: Stage; done: number; total: number; files_done: number; files_total: number }
| { type: 'log'; line: string };
export interface ServerStatus { online: boolean; version: string; players_online: number; players_max: number; sample: string[]; motd: string; favicon: string | null; latency_ms: number }
export interface DeviceCode { user_code: string; verification_uri: string; expires_in: number; interval: number; message: string }
export interface UpdateInfo { version: string; notes: string; url: string; size: number }
+15
View File
@@ -0,0 +1,15 @@
import '@fontsource-variable/outfit';
import '@fontsource-variable/inter';
import '@fontsource-variable/space-grotesk';
import '@fontsource-variable/plus-jakarta-sans';
import '@fontsource-variable/jetbrains-mono';
import './app.css';
import { mount } from 'svelte';
import App from './App.svelte';
// The launcher is a desktop app: no browser context menu or text-drag.
document.addEventListener('contextmenu', (e) => {
if (!(e.target as HTMLElement).closest('input, textarea, .selectable')) e.preventDefault();
});
mount(App, { target: document.getElementById('app')! });
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { SlidersHorizontal, SquareTerminal, Package, MessageCircle, Globe, ShoppingBag, CirclePlay, Tv, Code, AtSign, Link } from '@lucide/svelte';
import PlayButton from '../components/PlayButton.svelte';
import ServerStatus from '../components/ServerStatus.svelte';
import NewsFeed from '../components/NewsFeed.svelte';
import Console from '../components/Console.svelte';
import { activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
import { loaderName, shortLoaderVersion } from '../lib/format';
import { openUrl } from '../lib/tauri';
const inst = $derived(selectedInstance());
const b = $derived(app.manifest!.branding);
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
const hour = new Date().getHours();
const greet = hour < 5 ? 'Late night session' : hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
</script>
<div class="home">
{#if inst}
{#key inst.id}
<section class="hero">
<p class="greet muted">{greet}, <strong>{activeAccount()?.username ?? 'player'}</strong></p>
<div class="chips">
<span class="chip">{inst.mc_version}</span>
<span class="chip">{loaderName[inst.loader]} {shortLoaderVersion(inst.mc_version, inst.loader_version)}</span>
{#if inst.file_count}<span class="chip"><Package size={12} /> {inst.file_count} mods & files</span>{/if}
</div>
<h1>{inst.name}</h1>
<p class="desc">{inst.description || b.tagline}</p>
</section>
<section class="actions">
<PlayButton {inst} />
<div class="row tools">
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
</div>
</section>
{/key}
{:else}
<section class="hero empty">
<h1>{b.name}</h1>
<p class="desc">{instances().length === 0 ? "Nothing to play yet — your server admin hasn't published any instances you can access." : ''}</p>
</section>
{/if}
<aside class="side">
{#if b.features.news && b.news.length}<NewsFeed news={b.news} />{:else}<div></div>{/if}
{#if b.links.length}
<div class="links">
{#each b.links as l}
{@const Icon = linkIcon[l.icon] ?? Link}
<button class="glass link" onclick={() => openUrl(l.url)} title={l.label}><Icon size={16} /> <span>{l.label}</span></button>
{/each}
</div>
{/if}
</aside>
{#if app.consoleOpen}<Console />{/if}
</div>
<style>
.home { position: relative; height: 100%; display: grid; grid-template-columns: 1fr minmax(17rem, 22rem); grid-template-rows: 1fr auto; gap: 1.5rem 2.5rem; padding: 2rem 2rem 2rem 2.5rem; }
.hero { grid-column: 1; grid-row: 1; align-self: end; display: flex; flex-direction: column; gap: 0.75rem; animation: fade 0.3s ease; max-width: 38rem; padding-bottom: 0.5rem; }
.greet { font-size: 0.88rem; }
.greet strong { color: var(--text); font-weight: 560; }
.chips { display: flex; gap: 0.45rem; flex-wrap: wrap; }
h1 { font-size: clamp(2.2rem, 4.4vw, 3.2rem); line-height: 1.05; font-weight: 700; letter-spacing: -0.025em; }
.desc { font-size: 0.95rem; line-height: 1.6; color: var(--muted); max-width: 32rem; }
.actions { grid-column: 1; grid-row: 2; display: flex; flex-direction: column; gap: 0.8rem; animation: fade 0.3s ease both; }
.tools { flex-wrap: wrap; gap: 0.6rem; }
.tool { width: 2.75rem; height: 2.75rem; padding: 0; border-radius: var(--radius); color: color-mix(in srgb, var(--text) 75%, transparent); background: var(--surface); }
.tool.on { color: var(--text); border-color: var(--line-strong); background: color-mix(in srgb, var(--text) 8%, var(--surface)); }
.side { grid-column: 2; grid-row: 1 / 3; display: flex; flex-direction: column; justify-content: space-between; gap: 1rem; min-height: 0; animation: fade 0.3s ease both; }
.links { display: flex; flex-wrap: wrap; gap: 0.45rem; }
.link { padding: 0.5rem 0.75rem; font-size: 0.82rem; border-radius: var(--radius-sm); flex: 1 1 auto; background: var(--surface); color: color-mix(in srgb, var(--text) 80%, transparent); }
.link:hover { border-color: var(--line-strong); color: var(--text); }
@media (max-width: 1060px) { .home { grid-template-columns: 1fr 16rem; } .link span { display: none; } }
</style>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import SignIn from '../components/SignIn.svelte';
import { abs, app } from '../lib/store.svelte';
const b = $derived(app.manifest?.branding);
</script>
<div class="login">
<section class="brand">
{#if b?.logo_url}<img src={abs(b.logo_url)} alt="" class="logo" />{/if}
<h1>{b?.name}</h1>
<p>{b?.tagline}</p>
</section>
<section class="card glass">
<h2>Welcome</h2>
<p class="muted small">Sign in to start playing.</p>
<SignIn />
</section>
</div>
<style>
.login { height: 100%; display: grid; grid-template-columns: 1fr 23rem; align-items: center; gap: 4rem; padding: 2rem 4.5rem 3rem; }
.brand { display: flex; flex-direction: column; gap: 0.8rem; animation: fade 0.3s ease; }
.logo { width: 3.5rem; height: 3.5rem; object-fit: contain; margin-bottom: 0.5rem; }
h1 { font-size: clamp(2.4rem, 4.5vw, 3.4rem); font-weight: 700; letter-spacing: 0.02em; line-height: 1.05; }
.brand p { font-size: 1rem; color: var(--muted); max-width: 26rem; line-height: 1.55; }
.card { padding: 1.6rem; display: flex; flex-direction: column; gap: 0.35rem; background: var(--surface); animation: fade 0.3s ease both; }
.card h2 { font-size: 1.2rem; font-weight: 600; }
.card > :global(.signin) { margin-top: 0.8rem; }
@media (max-width: 900px) { .login { grid-template-columns: 1fr; padding: 2rem; } .brand { display: none; } }
</style>
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more