diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7e333c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +target +**/node_modules +**/dist +.git +.github +data +docs +launcher/src +launcher/src-tauri/icons +launcher/src-tauri/gen diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..504866a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11e5475 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 '' > 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a1b6623 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 <&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"] diff --git a/README.md b/README.md index 961bebe..3d81459 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,121 @@ -# SCOPENET-MC -A Windows/Linux Minecraft Launcher with a Docker-hosted admin panel. +
+ + + +# 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. + +
+ +

+ Launcher home + Panel launcher designer +

+ +## 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. + +

+ + + +

+ +## 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//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. diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..b6ebcf9 --- /dev/null +++ b/crates/core/Cargo.toml @@ -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" diff --git a/crates/core/src/assets.rs b/crates/core/src/assets.rs new file mode 100644 index 0000000..44dbe08 --- /dev/null +++ b/crates/core/src/assets.rs @@ -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, + #[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 { + 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 { + 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> { + 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)) +} diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs new file mode 100644 index 0000000..0916859 --- /dev/null +++ b/crates/core/src/http.rs @@ -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(client: &reqwest::Client, url: &str) -> Result { + 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(client: &reqwest::Client, url: &str, cache: &Path) -> Result { + 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 { + 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, 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, + pub size: u64, +} + +impl Download { + pub fn new(url: impl Into, dest: PathBuf, sha1: Option, size: Option) -> 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, + 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> = 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(()) +} diff --git a/crates/core/src/install.rs b/crates/core/src/install.rs new file mode 100644 index 0000000..843f80f --- /dev/null +++ b/crates/core/src/install.rs @@ -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, + /// User-selected Java executable (skips the automatic runtime). + pub java_override: Option, + 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, + pub assets_root: PathBuf, + pub game_assets: PathBuf, + pub assets_index: String, + pub logging_arg: Option, +} + +impl Installed { + pub fn classpath(&self, layout: &Layout) -> Vec { + let libs_dir = layout.libraries(); + let mut seen = std::collections::HashSet::new(); + let mut cp: Vec = + 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 { + 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 { + let dir = layout.libraries(); + let mut out: Vec = + 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 { + 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 = 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, + }) +} diff --git a/crates/core/src/java.rs b/crates/core/src/java.rs new file mode 100644 index 0000000..5f94ac0 --- /dev/null +++ b/crates/core/src/java.rs @@ -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, +} + +#[derive(Deserialize)] +struct RuntimeFile { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + executable: bool, + #[serde(default)] + downloads: Option, + #[serde(default)] + target: Option, +} +#[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 { + let dir = layout.runtimes().join(component); + let marker = dir.join(".scopenet-runtime"); + let installed = std::fs::read_to_string(&marker).ok(); + + let index: HashMap>> = 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 { + 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 { + 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)); + } +} diff --git a/crates/core/src/launch.rs b/crates/core/src/launch.rs new file mode 100644 index 0000000..bac1fc2 --- /dev/null +++ b/crates/core/src/launch.rs @@ -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, +} + +#[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, + 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 { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = 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 { + 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 { + 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, + 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::>().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::>().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 { + 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 { + 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::(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")); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..517d024 --- /dev/null +++ b/crates/core/src/lib.rs @@ -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}; diff --git a/crates/core/src/libraries.rs b/crates/core/src/libraries.rs new file mode 100644 index 0000000..40673d3 --- /dev/null +++ b/crates/core/src/libraries.rs @@ -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, + pub sha1: Option, + pub size: Option, + /// Goes on the classpath. + pub classpath: bool, + /// Contains native libraries that must be extracted. + pub native: bool, + pub exclude: Vec, +} + +impl ResolvedLib { + pub fn file(&self, libraries_dir: &Path) -> PathBuf { + libraries_dir.join(&self.path) + } + + pub fn download(&self, libraries_dir: &Path) -> Option { + 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 { + 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"); + } +} diff --git a/crates/core/src/loaders/fabric.rs b/crates/core/src/loaders/fabric.rs new file mode 100644 index 0000000..e4a2f37 --- /dev/null +++ b/crates/core/src/loaders/fabric.rs @@ -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 { + 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) +} diff --git a/crates/core/src/loaders/forge.rs b/crates/core/src/loaders/forge.rs new file mode 100644 index 0000000..cb83c7c --- /dev/null +++ b/crates/core/src/loaders/forge.rs @@ -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, + #[serde(default)] + processors: Vec, + #[serde(default)] + libraries: Vec, +} + +fn default_json() -> String { + "/version.json".into() +} + +#[derive(Deserialize)] +struct DataEntry { + client: String, +} + +#[derive(Deserialize)] +struct Processor { + #[serde(default)] + sides: Option>, + jar: String, + #[serde(default)] + classpath: Vec, + #[serde(default)] + args: Vec, + #[serde(default)] + outputs: HashMap, +} + +type Zip = zip::ZipArchive; + +fn read_entry(zip: &mut Zip, name: &str) -> Result> { + 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 { + 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 { + 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 = 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 { + 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 { + 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, libs_dir: &Path) -> Result { + 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 { + 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 { + // 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::>>()?; + + 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::::new())); + let pump = |stream: Option>| { + 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); + let err = child.stderr.take().map(|s| Box::new(s) as Box); + let (status, _, _) = tokio::join!(child.wait(), pump(out), pump(err)); + let status = status?; + if !status.success() { + let tail = tail.lock().unwrap().iter().cloned().collect::>().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()); + } +} diff --git a/crates/core/src/loaders/mod.rs b/crates/core/src/loaders/mod.rs new file mode 100644 index 0000000..a81662f --- /dev/null +++ b/crates/core/src/loaders/mod.rs @@ -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; diff --git a/crates/core/src/maven.rs b/crates/core/src/maven.rs new file mode 100644 index 0000000..4db5f9f --- /dev/null +++ b/crates/core/src/maven.rs @@ -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, + pub ext: String, +} + +impl Coord { + pub fn parse(s: &str) -> Option { + 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()); + } +} diff --git a/crates/core/src/meta.rs b/crates/core/src/meta.rs new file mode 100644 index 0000000..0349b02 --- /dev/null +++ b/crates/core/src/meta.rs @@ -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, +} + +#[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, + #[serde(default)] + pub release_time: Option, +} + +pub async fn mojang_manifest(client: &reqwest::Client) -> Result { + 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, +} + +#[derive(Deserialize)] +struct NeoVersions { + versions: Vec, +} + +#[derive(Deserialize)] +struct ForgePromos { + promos: HashMap, +} + +/// 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 { + 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> { + 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 = 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> = 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 = 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> { + 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 `-` (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" + ); + } +} diff --git a/crates/core/src/msa.rs b/crates/core/src/msa.rs new file mode 100644 index 0000000..533ef06 --- /dev/null +++ b/crates/core/src/msa.rs @@ -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, + refresh_token: Option, + error: Option, + error_description: Option, +} + +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McProfile { + pub id: String, + pub name: String, + #[serde(default)] + pub skins: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McSkin { + pub url: String, + #[serde(default)] + pub state: String, + #[serde(default)] + pub variant: Option, +} + +pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result { + 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 { + 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 { + 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, +} +#[derive(Deserialize)] +struct Xui { + uhs: String, + #[serde(default)] + xid: Option, +} + +#[derive(Deserialize)] +struct McLogin { + access_token: String, + expires_in: i64, +} + +async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result { + // 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 }) +} diff --git a/crates/core/src/options.rs b/crates/core/src/options.rs new file mode 100644 index 0000000..0588b59 --- /dev/null +++ b/crates/core/src/options.rs @@ -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_:` 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) -> Result<()> { + let mut values: BTreeMap = 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) -> 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 = 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"); + } +} diff --git a/crates/core/src/paths.rs b/crates/core/src/paths.rs new file mode 100644 index 0000000..99dff59 --- /dev/null +++ b/crates/core/src/paths.rs @@ -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) -> 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 { + 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(".."), "_"); + } +} diff --git a/crates/core/src/ping.rs b/crates/core/src/ping.rs new file mode 100644 index 0000000..f1d497d --- /dev/null +++ b/crates/core/src/ping.rs @@ -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, + /// MOTD using legacy `§` colour codes (rendered by the UI). + pub motd: String, + /// `data:image/png;base64,...` + pub favicon: Option, + pub latency_ms: u64, +} + +fn write_varint(buf: &mut Vec, 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 { + 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 { + 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, + #[serde(default)] + players: Option, + #[serde(default)] + description: Option, + #[serde(default)] + favicon: Option, +} +#[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, +} +#[derive(Deserialize)] +struct RawSample { + #[serde(default)] + name: String, +} + +fn color_code(name: &str) -> Option { + 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 { + 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 { + 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"); + } +} diff --git a/crates/core/src/progress.rs b/crates/core/src/progress.rs new file mode 100644 index 0000000..189bf69 --- /dev/null +++ b/crates/core/src/progress.rs @@ -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; + +pub fn noop() -> Reporter { + Arc::new(|_| {}) +} + +pub fn stage(r: &Reporter, stage: Stage, label: impl Into) { + r(Event::Stage { stage, label: label.into() }); +} diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs new file mode 100644 index 0000000..352085f --- /dev/null +++ b/crates/core/src/rules.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OsRule { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Clone)] +pub struct Env { + /// "windows" | "osx" | "linux" + pub os: &'static str, + /// "x86_64" | "x86" | "arm64" + pub arch: &'static str, + pub features: HashMap, +} + +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 = 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 = 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 = serde_json::from_str(r#"[{"action":"allow","os":{"arch":"x86"}}]"#).unwrap(); + assert!(!allowed(&x86, &env("windows"))); + + let feat: Vec = 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))); + } +} diff --git a/crates/core/src/servers_dat.rs b/crates/core/src/servers_dat.rs new file mode 100644 index 0000000..06a8bbc --- /dev/null +++ b/crates/core/src/servers_dat.rs @@ -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 = match std::fs::read(&path) { + Ok(bytes) => match fastnbt::from_bytes::(&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"); + } +} diff --git a/crates/core/src/sync.rs b/crates/core/src/sync.rs new file mode 100644 index 0000000..0da4dea --- /dev/null +++ b/crates/core/src/sync.rs @@ -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, +} + +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 { + 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 = 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); + } +} diff --git a/crates/core/src/sys.rs b/crates/core/src/sys.rs new file mode 100644 index 0000000..088a258 --- /dev/null +++ b/crates/core/src/sys.rs @@ -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 { + use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() }; + status.dwLength = std::mem::size_of::() as u32; + let ok = unsafe { GlobalMemoryStatusEx(&mut status) }; + (ok != 0).then_some(status.ullTotalPhys / 1024 / 1024) +} + +#[cfg(target_os = "linux")] +fn imp() -> Option { + 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 { + 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() +} diff --git a/crates/core/src/version.rs b/crates/core/src/version.rs new file mode 100644 index 0000000..c92f423 --- /dev/null +++ b/crates/core/src/version.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jar: Option, + #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub main_class: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minecraft_arguments: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arguments: Option, + #[serde(default)] + pub libraries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assets: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub java_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Arguments { + #[serde(default)] + pub game: Vec, + #[serde(default)] + pub jvm: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Arg { + Plain(String), + Ruled { rules: Vec, value: ArgValue }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum ArgValue { + One(String), + Many(Vec), +} + +impl ArgValue { + pub fn values(&self) -> Vec { + 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, + /// Maven repository base (Fabric/Quilt/legacy Forge style). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rules: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub natives: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extract: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LibraryDownloads { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub classifiers: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Artifact { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(default)] + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Extract { + #[serde(default)] + pub exclude: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AssetIndexRef { + pub id: String, + #[serde(default)] + pub sha1: Option, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub total_size: Option, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VersionDownloads { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, +} + +#[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, +} + +#[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, + #[serde(default)] + pub size: Option, + 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()); + } +} diff --git a/crates/core/tests/online.rs b/crates/core/tests/online.rs new file mode 100644 index 0000000..880e13f --- /dev/null +++ b/crates/core/tests/online.rs @@ -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; +} diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml new file mode 100644 index 0000000..015a2aa --- /dev/null +++ b/crates/shared/Cargo.toml @@ -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 diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs new file mode 100644 index 0000000..aea8358 --- /dev/null +++ b/crates/shared/src/lib.rs @@ -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, + pub icon_url: Option, + 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, + pub links: Vec, + 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, + /// 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, + pub link: Option, + /// ISO date (YYYY-MM-DD) + pub date: Option, + pub pinned: bool, + pub tag: Option, +} + +#[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, + 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, +} + +#[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, +} + +#[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 { + 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, + pub banner_url: Option, + pub mc_version: String, + pub loader: Loader, + pub loader_version: Option, + /// 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, + 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, +} + +#[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, + /// Present when the request carried a valid panel token. + pub user: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LaunchEvent { + pub instance_id: String, + pub kind: String, + #[serde(default)] + pub username: Option, +} + +// --------------------------------------------------------------------------- +// 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); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..94dcb54 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/admin-guide.md b/docs/admin-guide.md new file mode 100644 index 0000000..cc63fee --- /dev/null +++ b/docs/admin-guide.md @@ -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//` | 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`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..77a6b21 --- /dev/null +++ b/docs/architecture.md @@ -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\\ ← game directory (saves, mods, options.txt, …) +``` + +## HTTP API + +Public (used by launchers). Send `Authorization: Bearer ` 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. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..60e89a7 --- /dev/null +++ b/docs/development.md @@ -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. diff --git a/docs/microsoft-auth.md b/docs/microsoft-auth.md new file mode 100644 index 0000000..aff3913 --- /dev/null +++ b/docs/microsoft-auth.md @@ -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 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). diff --git a/docs/screenshots/launcher-appearance.webp b/docs/screenshots/launcher-appearance.webp new file mode 100644 index 0000000..c69bccf Binary files /dev/null and b/docs/screenshots/launcher-appearance.webp differ diff --git a/docs/screenshots/launcher-controls.webp b/docs/screenshots/launcher-controls.webp new file mode 100644 index 0000000..e785876 Binary files /dev/null and b/docs/screenshots/launcher-controls.webp differ diff --git a/docs/screenshots/launcher-home.webp b/docs/screenshots/launcher-home.webp new file mode 100644 index 0000000..0d57b47 Binary files /dev/null and b/docs/screenshots/launcher-home.webp differ diff --git a/docs/screenshots/launcher-install.webp b/docs/screenshots/launcher-install.webp new file mode 100644 index 0000000..f44ecd8 Binary files /dev/null and b/docs/screenshots/launcher-install.webp differ diff --git a/docs/screenshots/launcher-login.webp b/docs/screenshots/launcher-login.webp new file mode 100644 index 0000000..26ae01a Binary files /dev/null and b/docs/screenshots/launcher-login.webp differ diff --git a/docs/screenshots/panel-dashboard.webp b/docs/screenshots/panel-dashboard.webp new file mode 100644 index 0000000..c4b1920 Binary files /dev/null and b/docs/screenshots/panel-dashboard.webp differ diff --git a/docs/screenshots/panel-design.webp b/docs/screenshots/panel-design.webp new file mode 100644 index 0000000..d2ec4e4 Binary files /dev/null and b/docs/screenshots/panel-design.webp differ diff --git a/docs/screenshots/panel-instance.webp b/docs/screenshots/panel-instance.webp new file mode 100644 index 0000000..76c9555 Binary files /dev/null and b/docs/screenshots/panel-instance.webp differ diff --git a/docs/screenshots/panel-players.webp b/docs/screenshots/panel-players.webp new file mode 100644 index 0000000..26f9dba Binary files /dev/null and b/docs/screenshots/panel-players.webp differ diff --git a/launcher/index.html b/launcher/index.html new file mode 100644 index 0000000..0eead3a --- /dev/null +++ b/launcher/index.html @@ -0,0 +1,13 @@ + + + + + + + SCOPENET Launcher + + +
+ + + diff --git a/launcher/package-lock.json b/launcher/package-lock.json new file mode 100644 index 0000000..95442a3 --- /dev/null +++ b/launcher/package-lock.json @@ -0,0 +1,1501 @@ +{ + "name": "scopenet-launcher", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scopenet-launcher", + "version": "0.1.0", + "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" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/outfit": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.3.0.tgz", + "integrity": "sha512-fPb5XnYK0k7SbR++HEmm4+ZqDszmLKQ+sPRUr8oQ/KNC0vy6r6hazJFe8iYozv4cqlHbvJ/JECkJWrfDodUMVQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/plus-jakarta-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.3.0.tgz", + "integrity": "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/space-grotesk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz", + "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.48.0.tgz", + "integrity": "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.0.tgz", + "integrity": "sha512-fUSxFX8VABYe9W9K5ROdEaKVkNUFRcMSRofjruvaxztXA+I0XnQHcYHF8m/a6iExRUxsUf0HsFWhPBuH9IgzJw==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.0.tgz", + "integrity": "sha512-GIShnAR4C16kSTA8NO02fo8fOw/I5z5sAUEB5S2HlPHFnBkbihL4c9a/MpkxOm1+q5S+tFd3X4QUjHWxfyGzpQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.12.0", + "@tauri-apps/cli-darwin-x64": "2.12.0", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.0", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.0", + "@tauri-apps/cli-linux-arm64-musl": "2.12.0", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.0", + "@tauri-apps/cli-linux-x64-gnu": "2.12.0", + "@tauri-apps/cli-linux-x64-musl": "2.12.0", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.0", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.0", + "@tauri-apps/cli-win32-x64-msvc": "2.12.0" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.0.tgz", + "integrity": "sha512-v5J+jISihLGtih1+3yWagbsL+5dbLZORu4vaSI0vhY0Heb+MAW+T/1s1RyKxrQrwEPGBG5kqwfDDTnSbmVkuqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.0.tgz", + "integrity": "sha512-g27I07pfW/dMS8vcNqB9lamgKF0TqLlcy8e7t9qDGsB1iaeyjJWb2TgBF4tM9ZwLqPAZUXbsz28oXljO12WC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.0.tgz", + "integrity": "sha512-gXyQ710fNc99YuxFxNyeX9PLSrIpbtgBBj7GQo9tSeRT6fIK40q3/gm7X5jqZo7mWLR2m0R/2ALd3vAWo0Z32Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.0.tgz", + "integrity": "sha512-H9cyXuq9WgxoHh2NZ+R+oN+W78x5UwaBDEp0/fmqUUkaBoioCuOZErM9xKiIV6bpNAhmw1RoWZ49u37q/vaiAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.0.tgz", + "integrity": "sha512-5tFuk3JKMBa1M/iN4gN32OYlSfhY3aqb5PO3zCugxaT+sfecyMKpc3EM9VDbhmwWBE9QD4Tfj7wGhlqx2yy2pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.0.tgz", + "integrity": "sha512-LOrMyV+nb7pKq8P0BZtTBQuyzm1ORjn16R9MlupmxuT5LqxtuYJlw3VMwbaeXCToJ8jbP5u9JnkfmbqDlvETXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.0.tgz", + "integrity": "sha512-aMIkI+X9qvD2+k80QZB3tuOYT41/cEheikC510CwAIZ31KLXORsRASYYzM4mi1/58ICmtzSLFhCC08keir7COw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.0.tgz", + "integrity": "sha512-9gl3NhUbfuGhSKjeGCypKSdtoezOQRcDUrkhTWtLQEjnajets6peO6NadgI2DF0OzbiiFOzrnnIGMwBS+Ap6Iw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.0.tgz", + "integrity": "sha512-1W+v86wsYLuyBDN0iDessD4CD9o+yNyO0m8Dp3+/vfjiZrJ/v7R7/aTRBeikaq2tLGgxeag0gxnFggBNBGw91A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.0.tgz", + "integrity": "sha512-SsBjvq4f7YaBGqcWoE3cycoItPf0SrQeqFqWWgfxoeZWAIz01E31LDSeZ2p/etJiIY0R9oi045chkFfb0O7WGw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.0.tgz", + "integrity": "sha512-9zzDKYUH4TKKBgRdDdVQX8fiRO5gJ8hD1nGsHU9DOHG5Jpm3arjbu9Xg1frR20EusG3Sl8X0dppoO/4EoUXUGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.8.0.tgz", + "integrity": "sha512-HSSD5C8UFE9ijD1wue84F7jjaIReFmj8AAPtS+xyjYRltJafIc4BiWHY7SJ6vbbew6u+YljEJHfyNfy5VwywXg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.6.0.tgz", + "integrity": "sha512-KIl9qkBGyAUrbzfCtdk+NRPTXUOwpMyUKsKN57H6/qA0q8auVywXpfUyWClqX6I/iH8PHmqAUa/KK9YZPWVQew==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.12.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/launcher/package.json b/launcher/package.json new file mode 100644 index 0000000..bd79a93 --- /dev/null +++ b/launcher/package.json @@ -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" + } +} diff --git a/launcher/src-tauri/Cargo.toml b/launcher/src-tauri/Cargo.toml new file mode 100644 index 0000000..f1537ad --- /dev/null +++ b/launcher/src-tauri/Cargo.toml @@ -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"] } diff --git a/launcher/src-tauri/build.rs b/launcher/src-tauri/build.rs new file mode 100644 index 0000000..3343069 --- /dev/null +++ b/launcher/src-tauri/build.rs @@ -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() +} diff --git a/launcher/src-tauri/capabilities/default.json b/launcher/src-tauri/capabilities/default.json new file mode 100644 index 0000000..79318e5 --- /dev/null +++ b/launcher/src-tauri/capabilities/default.json @@ -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" + ] +} diff --git a/launcher/src-tauri/icons/128x128.png b/launcher/src-tauri/icons/128x128.png new file mode 100644 index 0000000..b36ad73 Binary files /dev/null and b/launcher/src-tauri/icons/128x128.png differ diff --git a/launcher/src-tauri/icons/128x128@2x.png b/launcher/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..98b87d1 Binary files /dev/null and b/launcher/src-tauri/icons/128x128@2x.png differ diff --git a/launcher/src-tauri/icons/32x32.png b/launcher/src-tauri/icons/32x32.png new file mode 100644 index 0000000..bf8f227 Binary files /dev/null and b/launcher/src-tauri/icons/32x32.png differ diff --git a/launcher/src-tauri/icons/64x64.png b/launcher/src-tauri/icons/64x64.png new file mode 100644 index 0000000..55cb35e Binary files /dev/null and b/launcher/src-tauri/icons/64x64.png differ diff --git a/launcher/src-tauri/icons/icon.icns b/launcher/src-tauri/icons/icon.icns new file mode 100644 index 0000000..a9b869d Binary files /dev/null and b/launcher/src-tauri/icons/icon.icns differ diff --git a/launcher/src-tauri/icons/icon.ico b/launcher/src-tauri/icons/icon.ico new file mode 100644 index 0000000..36294a3 Binary files /dev/null and b/launcher/src-tauri/icons/icon.ico differ diff --git a/launcher/src-tauri/icons/icon.png b/launcher/src-tauri/icons/icon.png new file mode 100644 index 0000000..0fba465 Binary files /dev/null and b/launcher/src-tauri/icons/icon.png differ diff --git a/launcher/src-tauri/icons/logo.svg b/launcher/src-tauri/icons/logo.svg new file mode 100644 index 0000000..93f43d0 --- /dev/null +++ b/launcher/src-tauri/icons/logo.svg @@ -0,0 +1 @@ + diff --git a/launcher/src-tauri/src/accounts.rs b/launcher/src-tauri/src/accounts.rs new file mode 100644 index 0000000..a9a8923 --- /dev/null +++ b/launcher/src-tauri/src/accounts.rs @@ -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, + #[serde(default)] + pub role: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct AccountsFile { + pub accounts: Vec, + pub active: Option, +} + +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 { + 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) -> Result<(Option, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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"); + } +} diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs new file mode 100644 index 0000000..5d57c16 --- /dev/null +++ b/launcher/src-tauri/src/commands.rs @@ -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 = Result; + +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, + panel_locked: bool, + repo: Option, + os: String, + arch: String, + total_ram_mb: u64, + data_dir: String, +} + +#[derive(Serialize)] +pub struct Bootstrap { + app: AppInfo, + panel_url: Option, + settings: Settings, + accounts: Vec, + active_account: Option, + manifest: Option, + game_running: Option, +} + +#[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 { + 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 { + 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 { + 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 { + accounts::login_panel(&state, &username, &password).await.map_err(aerr) +} + +#[derive(Serialize)] +pub struct RegisterResult { + account: Option, + pending: bool, +} + +#[tauri::command] +pub async fn register_panel(state: State<'_, AppState>, username: String, password: String, email: Option) -> Res { + 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 { + accounts::add_offline(&state, &username).map_err(aerr) +} + +#[tauri::command] +pub async fn ms_start(state: State<'_, AppState>) -> Res { + 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 { + 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 { + 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 { + 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) -> 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 { + 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 { + scopenet_core::java::detect_major(std::path::Path::new(&path)).await +} + +#[tauri::command] +pub async fn check_update(state: State<'_, AppState>) -> Res> { + 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(); + } +} diff --git a/launcher/src-tauri/src/game.rs b/launcher/src-tauri/src/game.rs new file mode 100644 index 0000000..4890d69 --- /dev/null +++ b/launcher/src-tauri/src/game.rs @@ -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, +} + +#[derive(Serialize, Clone)] +pub struct ExitPayload { + pub instance_id: String, + pub code: Option, + pub crashed: bool, + pub tail: Vec, + pub crash_report: Option, +} + +pub fn emit_state(app: &AppHandle, id: &str, state: &str, message: Option) { + 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 { + 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::().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::(); + 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::(); + for stream in [ + child.stdout.take().map(|s| Box::new(s) as Box), + child.stderr.take().map(|s| Box::new(s) as Box), + ] + .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 = VecDeque::with_capacity(200); + let mut batch: Vec = 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::(); + *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 { + let dir = app.state::().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()) +} diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs new file mode 100644 index 0000000..f2ecaee --- /dev/null +++ b/launcher/src-tauri/src/lib.rs @@ -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"); +} diff --git a/launcher/src-tauri/src/main.rs b/launcher/src-tauri/src/main.rs new file mode 100644 index 0000000..61a622c --- /dev/null +++ b/launcher/src-tauri/src/main.rs @@ -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() +} diff --git a/launcher/src-tauri/src/secrets.rs b/launcher/src-tauri/src/secrets.rs new file mode 100644 index 0000000..7ec0120 --- /dev/null +++ b/launcher/src-tauri/src/secrets.rs @@ -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, + pub ms_refresh_token: Option, + pub mc_access_token: Option, + pub mc_expires_at: i64, + pub xuid: Option, +} + +pub struct Secrets { + path: PathBuf, + key: [u8; 32], + data: Mutex>, +} + +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) -> 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> { + 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(); + } +} diff --git a/launcher/src-tauri/src/settings.rs b/launcher/src-tauri/src/settings.rs new file mode 100644 index 0000000..a956d1e --- /dev/null +++ b/launcher/src-tauri/src/settings.rs @@ -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, +} + +#[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, + pub selected_instance: Option, + + // ---- 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, + pub gc: GcPreset, + pub jvm_args: String, + + // ---- Appearance ---- + /// "server" (admin's theme) | "custom" + pub theme_mode: String, + pub accent: Option, + pub background_video: bool, + pub reduce_motion: bool, + /// None = follow the admin's setting. + pub glass: Option, + 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, + + pub instance_overrides: HashMap, +} + +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(()) + } +} diff --git a/launcher/src-tauri/src/state.rs b/launcher/src-tauri/src/state.rs new file mode 100644 index 0000000..09a19b6 --- /dev/null +++ b/launcher/src-tauri/src/state.rs @@ -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, + pub accounts: RwLock, + pub secrets: Secrets, + pub manifest: RwLock>, + pub device_code: Mutex>, + pub ms_cancel: Mutex>>, + /// Install/launch pipeline in progress (abortable). + pub task: Mutex>, + pub game: Mutex>, +} + +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 { + 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(); + } + } +} diff --git a/launcher/src-tauri/src/updater.rs b/launcher/src-tauri/src/updater.rs new file mode 100644 index 0000000..fe80dc5 --- /dev/null +++ b/launcher/src-tauri/src/updater.rs @@ -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, +} + +#[derive(Deserialize)] +struct Asset { + name: String, + browser_download_url: String, + size: u64, +} + +fn parse(v: &str) -> Vec { + 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> { + 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")); + } +} diff --git a/launcher/src-tauri/tauri.conf.json b/launcher/src-tauri/tauri.conf.json new file mode 100644 index 0000000..31be86e --- /dev/null +++ b/launcher/src-tauri/tauri.conf.json @@ -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" + } + } + } +} diff --git a/launcher/src/App.svelte b/launcher/src/App.svelte new file mode 100644 index 0000000..322f747 --- /dev/null +++ b/launcher/src/App.svelte @@ -0,0 +1,73 @@ + + + + +
+ + {#if fatal && !app.manifest} +

Can't reach the server

{fatal}

+ {:else if !app.ready} +
+ {:else if !app.panelUrl || !app.manifest} + + {:else if app.accounts.length === 0} + + {:else} +
+ +
+ {#if app.view === 'settings'}{:else}{/if} +
+
+ {/if} +
+ +{#if app.update && !updateDismissed && app.view !== 'settings' && app.accounts.length} +
+ + Launcher {app.update.version} is available + + +
+{/if} + + + + + + + + + diff --git a/launcher/src/app.css b/launcher/src/app.css new file mode 100644 index 0000000..dd103d0 --- /dev/null +++ b/launcher/src/app.css @@ -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; } } diff --git a/launcher/src/components/Avatar.svelte b/launcher/src/components/Avatar.svelte new file mode 100644 index 0000000..64e9cba --- /dev/null +++ b/launcher/src/components/Avatar.svelte @@ -0,0 +1,18 @@ + + +{#if account && !failed} + (failed = true)} /> +{:else} + {account?.username.slice(0, 1).toUpperCase() ?? '?'} +{/if} + + diff --git a/launcher/src/components/Background.svelte b/launcher/src/components/Background.svelte new file mode 100644 index 0000000..57427fa --- /dev/null +++ b/launcher/src/components/Background.svelte @@ -0,0 +1,36 @@ + + + (hidden = document.hidden)} /> + + + + diff --git a/launcher/src/components/Console.svelte b/launcher/src/components/Console.svelte new file mode 100644 index 0000000..388fa90 --- /dev/null +++ b/launcher/src/components/Console.svelte @@ -0,0 +1,51 @@ + + +
+
+ Game output + {app.logs.length.toLocaleString()} lines + + + + + +
+
box && (follow = box.scrollHeight - box.scrollTop - box.clientHeight < 40)}> + {#each shown as line, i (i)}
{line}
{:else}
Logs appear here while the game is running.
{/each} +
+ {#if !follow} + + {/if} +
+ + diff --git a/launcher/src/components/CrashDialog.svelte b/launcher/src/components/CrashDialog.svelte new file mode 100644 index 0000000..d3c25bf --- /dev/null +++ b/launcher/src/components/CrashDialog.svelte @@ -0,0 +1,39 @@ + + + (app.crash = null)}> + {#if c} +

The game exited with code {c.code}. {hint ?? 'The last lines of the log are below — they usually explain what went wrong.'}

+ {#if hint}
{hint}
{/if} +
{c.tail.slice(-40).join('\n')}
+ {/if} + {#snippet footer()} + + {#if c?.crash_report}{/if} + + + {/snippet} +
+ + diff --git a/launcher/src/components/InstanceSettings.svelte b/launcher/src/components/InstanceSettings.svelte new file mode 100644 index 0000000..f019fea --- /dev/null +++ b/launcher/src/components/InstanceSettings.svelte @@ -0,0 +1,84 @@ + + + (app.instanceSettings = null)}> + {#if inst && app.settings && ov} +
+ {#each folders as f} + + {/each} +
+ + + + {#if advanced} + + + {/if} + +
+ {local?.installed ? `Uses ${bytes(local.size)} on disk` : 'Not downloaded yet'} + + + +
+ {/if} +
+ + diff --git a/launcher/src/components/Modal.svelte b/launcher/src/components/Modal.svelte new file mode 100644 index 0000000..72a66b4 --- /dev/null +++ b/launcher/src/components/Modal.svelte @@ -0,0 +1,30 @@ + + + open && e.key === 'Escape' && close()} /> + +{#if open} + +{/if} + + diff --git a/launcher/src/components/NewsFeed.svelte b/launcher/src/components/NewsFeed.svelte new file mode 100644 index 0000000..d6b571a --- /dev/null +++ b/launcher/src/components/NewsFeed.svelte @@ -0,0 +1,50 @@ + + +
+

News

+
+ {#each sorted as n (n.id)} +
+ {#if n.image_url}{/if} +
+
+ {#if n.tag}{n.tag}{/if} + {#if n.pinned}{/if} + + {niceDate(n.date)} +
+

{n.title}

+

{n.body}

+ {#if n.link} + + {/if} +
+
+ {/each} +
+
+ + diff --git a/launcher/src/components/PlayButton.svelte b/launcher/src/components/PlayButton.svelte new file mode 100644 index 0000000..2e1c0b4 --- /dev/null +++ b/launcher/src/components/PlayButton.svelte @@ -0,0 +1,86 @@ + + + { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !busy && !running && !otherRunning && !noAccount && app.view === 'home') play(inst.id); +}} /> + +
+ {#if busy} +
+
+ + {#if l.stage === 'launching' || !l.total}{/if} + {l.label || 'Preparing…'} + + +
+
+
+ {l.stage ? stageNames[l.stage] : ''}{l.filesTotal ? ` · ${l.filesDone.toLocaleString()} / ${l.filesTotal.toLocaleString()} files` : ''} + {#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} +
+
+ {:else if running} + + {:else} + + {/if} + {#if mine && l.state === 'error'} +
+ + {l.message} + +
+ {/if} +
+ + diff --git a/launcher/src/components/Rail.svelte b/launcher/src/components/Rail.svelte new file mode 100644 index 0000000..d93c25e --- /dev/null +++ b/launcher/src/components/Rail.svelte @@ -0,0 +1,92 @@ + + + menu && !(e.target as HTMLElement).closest('.account') && (menu = false)} /> + + + + diff --git a/launcher/src/components/ServerStatus.svelte b/launcher/src/components/ServerStatus.svelte new file mode 100644 index 0000000..390533c --- /dev/null +++ b/launcher/src/components/ServerStatus.svelte @@ -0,0 +1,87 @@ + + +
+ {#if status?.favicon}{:else}{/if} +
+
+ {server.name || server.address} + {status ? 'Online' : failed ? 'Offline' : 'Checking…'} +
+ {#if status} +
{#each parseMotd(status.motd.split('\n')[0]) as s}{s.text}{/each}
+ {:else} + + {/if} +
+ {#if status} +
+ {status.players_online}/{status.players_max} + + {#each [1, 2, 3, 4] as i}{/each} + {status.latency_ms}ms + +
+ {/if} +
+ + diff --git a/launcher/src/components/SignIn.svelte b/launcher/src/components/SignIn.svelte new file mode 100644 index 0000000..2be89cf --- /dev/null +++ b/launcher/src/components/SignIn.svelte @@ -0,0 +1,160 @@ + + + + + diff --git a/launcher/src/components/TitleBar.svelte b/launcher/src/components/TitleBar.svelte new file mode 100644 index 0000000..3e403b9 --- /dev/null +++ b/launcher/src/components/TitleBar.svelte @@ -0,0 +1,38 @@ + + +
+
+ {#if b?.logo_url} + + {:else} + + {/if} + {b?.name ?? 'Launcher'} + {#if app.offline} Offline{/if} +
+
+ + + +
+
+ + diff --git a/launcher/src/components/Toasts.svelte b/launcher/src/components/Toasts.svelte new file mode 100644 index 0000000..89f1d21 --- /dev/null +++ b/launcher/src/components/Toasts.svelte @@ -0,0 +1,22 @@ + + +
+ {#each app.toasts as t (t.id)} +
+ {#if t.kind === 'ok'}{:else if t.kind === 'error'}{:else}{/if} + {t.text} +
+ {/each} +
+ + diff --git a/launcher/src/components/Toggle.svelte b/launcher/src/components/Toggle.svelte new file mode 100644 index 0000000..0b3d49f --- /dev/null +++ b/launcher/src/components/Toggle.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/launcher/src/lib/format.ts b/launcher/src/lib/format.ts new file mode 100644 index 0000000..dc84238 --- /dev/null +++ b/launcher/src/lib/format.ts @@ -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 = { + 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' }); +} diff --git a/launcher/src/lib/keys.ts b/launcher/src/lib/keys.ts new file mode 100644 index 0000000..bdbef9d --- /dev/null +++ b/launcher/src/lib/keys.ts @@ -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 = { + 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 = { + '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 { + return Object.fromEntries(BIND_GROUPS.flatMap((g) => g.binds.map((b) => [b.id, b.default]))); +} diff --git a/launcher/src/lib/mock.ts b/launcher/src/lib/mock.ts new file mode 100644 index 0000000..ff06180 --- /dev/null +++ b/launcher/src/lib/mock.ts @@ -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): Promise { + 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; + } +} diff --git a/launcher/src/lib/motd.ts b/launcher/src/lib/motd.ts new file mode 100644 index 0000000..43db84a --- /dev/null +++ b/launcher/src/lib/motd.ts @@ -0,0 +1,37 @@ +// Render Minecraft § formatting codes as HTML-safe segments. +const COLORS: Record = { + '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; +} diff --git a/launcher/src/lib/store.svelte.ts b/launcher/src/lib/store.svelte.ts new file mode 100644 index 0000000..e7980b9 --- /dev/null +++ b/launcher/src/lib/store.svelte.ts @@ -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'); + 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('check_update').then((u) => (app.update = u)).catch(() => {}); + } +} + +export async function refresh() { + try { + app.manifest = await invoke('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('set_panel_url', { url }); + const boot = await invoke('bootstrap'); + app.panelUrl = boot.panel_url; + app.offline = false; + theme(); + pickSelected(); +} + +let saveTimer: ReturnType | 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('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('game://log', (lines) => pushLogs(lines)); + + listen('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); +} diff --git a/launcher/src/lib/tauri.ts b/launcher/src/lib/tauri.ts new file mode 100644 index 0000000..3cfd60d --- /dev/null +++ b/launcher/src/lib/tauri.ts @@ -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>(); + +export function mockEmit(event: string, payload: unknown) { + mockListeners.get(event)?.forEach((h) => h(payload)); +} + +export async function invoke(cmd: string, args?: Record): Promise { + if (inTauri) return tauriInvoke(cmd, args); + const { mockInvoke } = await import('./mock'); + return mockInvoke(cmd, args ?? {}) as Promise; +} + +export async function listen(event: string, handler: (payload: T) => void): Promise { + if (inTauri) return tauriListen(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 { + 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); +} diff --git a/launcher/src/lib/theme.ts b/launcher/src/lib/theme.ts new file mode 100644 index 0000000..a7bcf23 --- /dev/null +++ b/launcher/src/lib/theme.ts @@ -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 = { + '--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)}%)`; +} diff --git a/launcher/src/lib/types.ts b/launcher/src/lib/types.ts new file mode 100644 index 0000000..96332ae --- /dev/null +++ b/launcher/src/lib/types.ts @@ -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; + instance_overrides: Record; +} + +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 } diff --git a/launcher/src/main.ts b/launcher/src/main.ts new file mode 100644 index 0000000..67d88d3 --- /dev/null +++ b/launcher/src/main.ts @@ -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')! }); diff --git a/launcher/src/pages/Home.svelte b/launcher/src/pages/Home.svelte new file mode 100644 index 0000000..c969ba5 --- /dev/null +++ b/launcher/src/pages/Home.svelte @@ -0,0 +1,81 @@ + + +
+ {#if inst} + {#key inst.id} +
+

{greet}, {activeAccount()?.username ?? 'player'}

+
+ {inst.mc_version} + {loaderName[inst.loader]} {shortLoaderVersion(inst.mc_version, inst.loader_version)} + {#if inst.file_count} {inst.file_count} mods & files{/if} +
+

{inst.name}

+

{inst.description || b.tagline}

+
+ +
+ +
+ + + {#if inst.server && b.features.server_status}{/if} +
+
+ {/key} + {:else} +
+

{b.name}

+

{instances().length === 0 ? "Nothing to play yet — your server admin hasn't published any instances you can access." : ''}

+
+ {/if} + + + + {#if app.consoleOpen}{/if} +
+ + diff --git a/launcher/src/pages/Login.svelte b/launcher/src/pages/Login.svelte new file mode 100644 index 0000000..03a4488 --- /dev/null +++ b/launcher/src/pages/Login.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/launcher/src/pages/Settings.svelte b/launcher/src/pages/Settings.svelte new file mode 100644 index 0000000..ecb9b05 --- /dev/null +++ b/launcher/src/pages/Settings.svelte @@ -0,0 +1,430 @@ + + + + +
+ + +
+ {#key app.settingsTab} +
+ {#if app.settingsTab === 'account'} +

Accounts

+

Switch between accounts or add another one.

+
+ {#each app.accounts as a (a.id)} +
+ +
{a.username}{kindLabel[a.kind]}{a.role === 'admin' ? ' · Admin' : ''}
+ {#if a.id === app.active} + Active + {:else} + + {/if} + +
+ {:else} +

No accounts yet.

+ {/each} + +
+ {:else if app.settingsTab === 'game'} +

Game

+

Applies to every instance unless you override it in the instance's settings.

+
+ + +
+
+ + {#if s.custom_resolution} +
+ + × + +
+ {/if} + +
+
+ + +
+ {:else if app.settingsTab === 'java'} +

Java & performance

+

The launcher downloads the right Java for each Minecraft version automatically.

+
+ Garbage collector +
+ {#each gcPresets as g} + + {/each} +
+
+ {#if advanced} +
+ + +
+ {/if} +
+ +
+ {:else if app.settingsTab === 'controls'} +

Controls

+

Set your Minecraft keybinds once — the launcher applies them to every instance before it starts.

+
+ +
+
+ {#each BIND_GROUPS as group} +
+

{group.title}

+ {#each group.binds as bind} + {@const key = binds[bind.id]} +
+ {bind.label} + +
+ {/each} +
+ {/each} +
+
+ + Press Esc while choosing to unbind. Shortcut in the launcher: Ctrl + Enter to play. +
+ {:else if app.settingsTab === 'appearance'} +

Appearance

+

Make the launcher feel like yours{themeAllowed ? '' : ' — your server has locked the theme'}.

+ {#if themeAllowed} +
+
+ + +
+ {#if s.theme_mode === 'custom'} +
+ {#each accents as c} + + {/each} + +
+ {/if} +
+ {/if} +
+ s.glass ?? b?.glass ?? true, (v) => (s.glass = v)} onchange={saveSettings} + label="Frosted glass effects" help="Turn off for better performance on older graphics cards." /> + + + +
+ {:else if app.settingsTab === 'launcher'} +

Launcher

+

Connection and behaviour.

+
+ + +
+
+ + +
+ {:else if app.settingsTab === 'storage'} +

Storage

+

Game files are shared between instances, so extra instances of the same version take almost no space.

+ {#if storage} +
+
{bytes(storageTotal)}used in total + +
+
+ + + {#each storage.instances as [, n]}{/each} +
+
+ Game files {bytes(storage.shared)} + Java {bytes(storage.runtimes)} + Instances {bytes(storage.instances.reduce((a, [, n]) => a + n, 0))} +
+
+
+ {#each storage.instances as [id, n]} +
+
{instName(id)}{bytes(n)} · worlds, mods & settings
+ + +
+ {:else} +

No instances downloaded yet.

+ {/each} +
+ + {:else} +

Measuring…

+ {/if} + {:else if app.settingsTab === 'about'} +

About

+
+
+
+
{b?.name ?? 'SCOPENET'} Launcher

Version {app.boot?.app.version} · {app.boot?.app.os} {app.boot?.app.arch}

+
+ {#if app.update} +
+ +
Version {app.update.version} is available

{bytes(app.update.size)}

+ +
+ {#if app.update.notes}
{app.update.notes}
{/if} + {:else if app.boot?.app.repo} + + {/if} +

Built with Tauri, Rust and Svelte. Minecraft is a trademark of Mojang Studios; this launcher is not affiliated with Mojang or Microsoft.

+ {#if app.boot?.app.repo}{/if} +
+ {/if} +
+ {/key} +
+
+ + diff --git a/launcher/src/pages/Setup.svelte b/launcher/src/pages/Setup.svelte new file mode 100644 index 0000000..fc16eb5 --- /dev/null +++ b/launcher/src/pages/Setup.svelte @@ -0,0 +1,45 @@ + + +
+
+
+

Connect to your server

+

Enter the panel address your community gave you. You only need to do this once.

+ + {#if error}
{error}
{/if} + +
+
+ + diff --git a/launcher/svelte.config.js b/launcher/svelte.config.js new file mode 100644 index 0000000..36c98fd --- /dev/null +++ b/launcher/svelte.config.js @@ -0,0 +1,3 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { preprocess: vitePreprocess() }; diff --git a/launcher/tsconfig.json b/launcher/tsconfig.json new file mode 100644 index 0000000..2c84844 --- /dev/null +++ b/launcher/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "types": ["svelte", "vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/launcher/vite.config.ts b/launcher/vite.config.ts new file mode 100644 index 0000000..753a81a --- /dev/null +++ b/launcher/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +// https://tauri.app/start/frontend/vite/ +export default defineConfig({ + plugins: [svelte()], + clearScreen: false, + server: { port: 1420, strictPort: true, watch: { ignored: ['**/src-tauri/**'] } }, + envPrefix: ['VITE_', 'TAURI_ENV_'], + build: { + target: 'es2022', + cssCodeSplit: false, + sourcemap: false, + }, +}); diff --git a/panel/server/Cargo.toml b/panel/server/Cargo.toml new file mode 100644 index 0000000..ec4096b --- /dev/null +++ b/panel/server/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "scopenet-panel" +description = "SCOPENET admin panel: manages instances, branding and players for the launcher" +version.workspace = true +edition.workspace = true + +[dependencies] +scopenet-shared.workspace = true +scopenet-core.workspace = true +anyhow.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +reqwest.workspace = true +futures.workspace = true +sha1.workspace = true +hex.workspace = true +uuid.workspace = true +zip.workspace = true +tracing.workspace = true +chrono.workspace = true +rand.workspace = true +axum = { version = "0.8", features = ["multipart", "macros"] } +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "derive"] } +argon2 = "0.5" +jsonwebtoken = "9" +percent-encoding = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/panel/server/src/auth.rs b/panel/server/src/auth.rs new file mode 100644 index 0000000..1503910 --- /dev/null +++ b/panel/server/src/auth.rs @@ -0,0 +1,199 @@ +//! Passwords (Argon2), tokens (JWT) and the request extractors that guard +//! routes. + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use scopenet_shared::{offline_uuid, PublicUser}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const TOKEN_DAYS: i64 = 30; + +pub fn hash_password(password: &str) -> AppResult { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}"))) +} + +pub fn verify_password(password: &str, hash: &str) -> bool { + PasswordHash::new(hash).map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok()).unwrap_or(false) +} + +pub fn validate_password(password: &str) -> AppResult<()> { + if password.chars().count() < 8 { + return Err(AppError::bad_request("passwords need at least 8 characters")); + } + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: i64, + pub name: String, + pub role: String, + pub exp: i64, +} + +pub struct Keys { + enc: EncodingKey, + dec: DecodingKey, +} + +impl Keys { + pub fn new(secret: &[u8]) -> Self { + Self { enc: EncodingKey::from_secret(secret), dec: DecodingKey::from_secret(secret) } + } + + pub fn issue(&self, user: &UserRow) -> AppResult { + let claims = Claims { + sub: user.id, + name: user.username.clone(), + role: user.role.clone(), + exp: (chrono::Utc::now() + chrono::Duration::days(TOKEN_DAYS)).timestamp(), + }; + encode(&Header::default(), &claims, &self.enc) + .map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) + } + + pub fn verify(&self, token: &str) -> Option { + decode::(token, &self.dec, &Validation::default()).ok().map(|d| d.claims) + } +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct UserRow { + pub id: i64, + pub username: String, + #[serde(skip)] + pub password_hash: String, + pub email: Option, + pub role: String, + pub status: String, + pub created_at: String, + pub last_login: Option, +} + +impl UserRow { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + +pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult> { + Ok(sqlx::query_scalar("SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name") + .bind(user_id) + .fetch_all(&state.db) + .await?) +} + +pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult { + Ok(PublicUser { + id: user.id, + username: user.username.clone(), + uuid: offline_uuid(&user.username), + role: user.role.clone(), + groups: user_groups(state, user.id).await?, + }) +} + +fn bearer(parts: &Parts) -> Option<&str> { + parts + .headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))) +} + +async fn resolve(parts: &Parts, state: &AppState) -> AppResult> { + let Some(token) = bearer(parts) else { return Ok(None) }; + let Some(claims) = state.keys.verify(token) else { + return Err(AppError::unauthorized("your session expired, please sign in again")); + }; + let user: Option = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(claims.sub).fetch_optional(&state.db).await?; + match user { + Some(u) if u.status == "active" => Ok(Some(u)), + Some(u) if u.status == "pending" => Err(AppError::forbidden("your account is waiting for approval")), + _ => Err(AppError::unauthorized("account disabled or removed")), + } +} + +/// Signed-in user if a valid token was sent, otherwise `None`. +pub struct MaybeUser(pub Option); + +impl FromRequestParts for MaybeUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + // A bad token on a public endpoint just means "anonymous". + Ok(MaybeUser(resolve(parts, state).await.unwrap_or(None))) + } +} + +pub struct AuthUser(pub UserRow); + +impl FromRequestParts for AuthUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + resolve(parts, state).await?.map(AuthUser).ok_or_else(|| AppError::unauthorized("sign in required")) + } +} + +pub struct AdminUser(pub UserRow); + +impl FromRequestParts for AdminUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let user = resolve(parts, state).await?.ok_or_else(|| AppError::unauthorized("sign in required"))?; + if !user.is_admin() { + return Err(AppError::forbidden("admins only")); + } + Ok(AdminUser(user)) + } +} + +/// Very small brute-force guard: 10 failed attempts per username locks it +/// for 5 minutes. +#[derive(Default)] +pub struct LoginGuard { + failures: Mutex>, +} + +impl LoginGuard { + const MAX: u32 = 10; + const WINDOW: Duration = Duration::from_secs(300); + + pub fn check(&self, username: &str) -> AppResult<()> { + let map = self.failures.lock().unwrap(); + if let Some((count, since)) = map.get(&username.to_lowercase()) { + if *count >= Self::MAX && since.elapsed() < Self::WINDOW { + return Err(AppError::new( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "too many failed attempts, try again in a few minutes", + )); + } + } + Ok(()) + } + + pub fn fail(&self, username: &str) { + let mut map = self.failures.lock().unwrap(); + let entry = map.entry(username.to_lowercase()).or_insert((0, Instant::now())); + if entry.1.elapsed() >= Self::WINDOW { + *entry = (0, Instant::now()); + } + entry.0 += 1; + } + + pub fn succeed(&self, username: &str) { + self.failures.lock().unwrap().remove(&username.to_lowercase()); + } +} diff --git a/panel/server/src/config.rs b/panel/server/src/config.rs new file mode 100644 index 0000000..536a634 --- /dev/null +++ b/panel/server/src/config.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +/// Runtime configuration, read from environment variables (see +/// `docker-compose.yml` for the documented list). +#[derive(Debug, Clone)] +pub struct Config { + pub bind: String, + pub data_dir: PathBuf, + pub web_dir: PathBuf, + pub admin_username: String, + pub admin_password: Option, + pub jwt_secret: Option, + pub curseforge_api_key: Option, + pub max_upload_mb: usize, +} + +fn var(name: &str) -> Option { + std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +impl Config { + pub fn from_env() -> Self { + Self { + bind: var("SCOPENET_BIND").unwrap_or_else(|| "0.0.0.0:8080".into()), + data_dir: var("SCOPENET_DATA_DIR").unwrap_or_else(|| "./data".into()).into(), + web_dir: var("SCOPENET_WEB_DIR").unwrap_or_else(|| "./panel/web/dist".into()).into(), + admin_username: var("ADMIN_USERNAME").unwrap_or_else(|| "admin".into()), + admin_password: var("ADMIN_PASSWORD"), + jwt_secret: var("JWT_SECRET"), + curseforge_api_key: var("CURSEFORGE_API_KEY"), + max_upload_mb: var("MAX_UPLOAD_MB").and_then(|v| v.parse().ok()).unwrap_or(2048), + } + } + + pub fn files_dir(&self) -> PathBuf { + self.data_dir.join("files") + } + pub fn uploads_dir(&self) -> PathBuf { + self.data_dir.join("uploads") + } +} diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs new file mode 100644 index 0000000..7bd2ad2 --- /dev/null +++ b/panel/server/src/db.rs @@ -0,0 +1,120 @@ +use anyhow::Result; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; +use sqlx::SqlitePool; +use std::path::Path; +use std::str::FromStr; + +/// Schema migrations, applied in order. Never edit a released entry — +/// append a new one instead. +const MIGRATIONS: &[&str] = &[ + // 1: initial schema + r#" + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + email TEXT, + role TEXT NOT NULL DEFAULT 'player', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + last_login TEXT + ); + CREATE TABLE groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + color TEXT NOT NULL DEFAULT '#7c5cff' + ); + CREATE TABLE user_groups ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, group_id) + ); + CREATE TABLE instances ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + icon_url TEXT, + banner_url TEXT, + mc_version TEXT NOT NULL, + loader TEXT NOT NULL DEFAULT 'vanilla', + loader_version TEXT, + source_kind TEXT NOT NULL DEFAULT 'vanilla', + source_label TEXT NOT NULL DEFAULT '', + source_ref TEXT NOT NULL DEFAULT '{}', + visibility TEXT NOT NULL DEFAULT 'public', + allowed_groups TEXT NOT NULL DEFAULT '[]', + memory_min INTEGER NOT NULL DEFAULT 1024, + memory_max INTEGER NOT NULL DEFAULT 4096, + jvm_args TEXT NOT NULL DEFAULT '', + server TEXT, + featured INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + sort INTEGER NOT NULL DEFAULT 0, + revision INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE instance_files ( + instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + path TEXT NOT NULL, + url TEXT NOT NULL, + sha1 TEXT NOT NULL, + size INTEGER NOT NULL, + origin TEXT NOT NULL, + note TEXT, + PRIMARY KEY (instance_id, path) + ); + CREATE TABLE kv ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id TEXT, + username TEXT, + kind TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX events_created ON events(created_at); + "#, +]; + +pub async fn connect(data_dir: &Path) -> Result { + std::fs::create_dir_all(data_dir)?; + let url = format!("sqlite://{}", data_dir.join("panel.db").display()); + let opts = SqliteConnectOptions::from_str(&url)? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .foreign_keys(true); + let pool = SqlitePoolOptions::new().max_connections(8).connect_with(opts).await?; + migrate(&pool).await?; + Ok(pool) +} + +pub async fn connect_memory() -> Result { + let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true); + let pool = SqlitePoolOptions::new().max_connections(1).connect_with(opts).await?; + migrate(&pool).await?; + Ok(pool) +} + +async fn migrate(pool: &SqlitePool) -> Result<()> { + let current: i64 = sqlx::query_scalar("PRAGMA user_version").fetch_one(pool).await?; + for (i, sql) in MIGRATIONS.iter().enumerate() { + let version = i as i64 + 1; + if version <= current { + continue; + } + let mut tx = pool.begin().await?; + sqlx::raw_sql(sql).execute(&mut *tx).await?; + sqlx::raw_sql(&format!("PRAGMA user_version = {version}")).execute(&mut *tx).await?; + tx.commit().await?; + tracing::info!("applied database migration {version}"); + } + Ok(()) +} + +pub fn now() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} diff --git a/panel/server/src/error.rs b/panel/server/src/error.rs new file mode 100644 index 0000000..6285270 --- /dev/null +++ b/panel/server/src/error.rs @@ -0,0 +1,74 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +/// Every handler returns `Result<_, AppError>`; errors become +/// `{"error": "..."}` with a sensible status code. +#[derive(Debug)] +pub struct AppError { + pub status: StatusCode, + pub message: String, +} + +pub type AppResult = Result; + +impl AppError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { status, message: message.into() } + } + pub fn bad_request(m: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, m) + } + pub fn unauthorized(m: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, m) + } + pub fn forbidden(m: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, m) + } + pub fn not_found(m: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, m) + } + pub fn conflict(m: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, m) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + if self.status.is_server_error() { + tracing::error!("{}", self.message); + } + (self.status, Json(json!({ "error": self.message }))).into_response() + } +} + +impl From for AppError { + fn from(e: anyhow::Error) -> Self { + Self::new(StatusCode::BAD_GATEWAY, format!("{e:#}")) + } +} + +impl From for AppError { + fn from(e: sqlx::Error) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("database error: {e}")) + } +} + +impl From for AppError { + fn from(e: std::io::Error) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("io error: {e}")) + } +} + +impl From for AppError { + fn from(e: serde_json::Error) -> Self { + Self::bad_request(format!("invalid JSON: {e}")) + } +} + +impl From for AppError { + fn from(e: axum::extract::multipart::MultipartError) -> Self { + Self::bad_request(format!("upload failed: {e}")) + } +} diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs new file mode 100644 index 0000000..50035cc --- /dev/null +++ b/panel/server/src/lib.rs @@ -0,0 +1,97 @@ +//! SCOPENET admin panel. + +pub mod auth; +pub mod config; +pub mod db; +pub mod error; +pub mod packs; +pub mod routes; +pub mod state; +pub mod store; + +use axum::http::{header, HeaderValue}; +use axum::Router; +use rand::RngCore; +use state::AppState; +use std::sync::Arc; +use tower_http::compression::CompressionLayer; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::set_header::SetResponseHeaderLayer; +use tower_http::trace::TraceLayer; + +/// Load (or create) the JWT signing secret. +pub fn jwt_secret(cfg: &config::Config) -> anyhow::Result> { + if let Some(s) = &cfg.jwt_secret { + return Ok(s.as_bytes().to_vec()); + } + let path = cfg.data_dir.join("jwt.secret"); + if let Ok(bytes) = std::fs::read(&path) { + if bytes.len() >= 32 { + return Ok(bytes); + } + } + let mut bytes = vec![0u8; 64]; + rand::thread_rng().fill_bytes(&mut bytes); + std::fs::create_dir_all(&cfg.data_dir)?; + std::fs::write(&path, &bytes)?; + Ok(bytes) +} + +pub async fn build_state(cfg: config::Config, db: sqlx::SqlitePool) -> anyhow::Result { + let secret = jwt_secret(&cfg)?; + Ok(AppState { + db, + keys: Arc::new(auth::Keys::new(&secret)), + http: scopenet_core::http::client(), + login_guard: Arc::new(auth::LoginGuard::default()), + cfg: Arc::new(cfg), + }) +} + +/// Create the first admin account if none exists. +pub async fn bootstrap_admin(state: &AppState) -> anyhow::Result<()> { + let admins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetch_one(&state.db).await?; + if admins > 0 { + return Ok(()); + } + let (password, generated) = match &state.cfg.admin_password { + Some(p) => (p.clone(), false), + None => { + let mut bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut bytes); + (hex::encode(bytes), true) + } + }; + let hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!(e.message))?; + sqlx::query("INSERT INTO users (username, password_hash, role, status, created_at) VALUES (?, ?, 'admin', 'active', ?)") + .bind(&state.cfg.admin_username) + .bind(hash) + .bind(db::now()) + .execute(&state.db) + .await?; + if generated { + tracing::warn!("============================================================"); + tracing::warn!(" Created admin account '{}' with password: {password}", state.cfg.admin_username); + tracing::warn!(" Set ADMIN_PASSWORD to choose your own. Change it after login!"); + tracing::warn!("============================================================"); + } else { + tracing::info!("created admin account '{}'", state.cfg.admin_username); + } + Ok(()) +} + +pub fn app(state: AppState) -> Router { + let web = &state.cfg.web_dir; + let spa = ServeDir::new(web).fallback(ServeFile::new(web.join("index.html"))); + let long_cache = SetResponseHeaderLayer::overriding(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=86400")); + + Router::new() + .route("/healthz", axum::routing::get(routes::public::health)) + .merge(routes::api(&state)) + .nest_service("/files", ServeDir::new(state.cfg.files_dir())) + .nest_service("/uploads", tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir()))) + .fallback_service(spa) + .layer(CompressionLayer::new()) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} diff --git a/panel/server/src/main.rs b/panel/server/src/main.rs new file mode 100644 index 0000000..947107a --- /dev/null +++ b/panel/server/src/main.rs @@ -0,0 +1,61 @@ +use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // `scopenet-panel healthcheck` — used by the Docker HEALTHCHECK so the + // image doesn't need curl. + if std::env::args().nth(1).as_deref() == Some("healthcheck") { + return healthcheck().await; + } + + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,tower_http=warn,sqlx=warn"))) + .compact() + .init(); + + let cfg = Config::from_env(); + let pool = db::connect(&cfg.data_dir).await?; + if !cfg.web_dir.join("index.html").exists() { + tracing::warn!("web UI not found at {} (build panel/web or set SCOPENET_WEB_DIR)", cfg.web_dir.display()); + } + let bind = cfg.bind.clone(); + let state = build_state(cfg, pool).await?; + bootstrap_admin(&state).await?; + + let listener = tokio::net::TcpListener::bind(&bind).await?; + tracing::info!("SCOPENET panel v{} listening on http://{bind}", env!("CARGO_PKG_VERSION")); + axum::serve(listener, app(state)).with_graceful_shutdown(shutdown()).await?; + Ok(()) +} + +async fn shutdown() { + let ctrl_c = async { + tokio::signal::ctrl_c().await.ok(); + }; + #[cfg(unix)] + let term = async { + if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + s.recv().await; + } + }; + #[cfg(not(unix))] + let term = std::future::pending::<()>(); + tokio::select! { _ = ctrl_c => {}, _ = term => {} } + tracing::info!("shutting down"); +} + +async fn healthcheck() -> anyhow::Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let bind = std::env::var("SCOPENET_BIND").unwrap_or_else(|_| "0.0.0.0:8080".into()); + let port = bind.rsplit(':').next().unwrap_or("8080"); + let mut s = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await?; + s.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n").await?; + let mut buf = String::new(); + s.read_to_string(&mut buf).await?; + if buf.starts_with("HTTP/1.1 200") || buf.starts_with("HTTP/1.0 200") { + Ok(()) + } else { + anyhow::bail!("unhealthy: {}", buf.lines().next().unwrap_or("")) + } +} diff --git a/panel/server/src/packs.rs b/panel/server/src/packs.rs new file mode 100644 index 0000000..70f8a1b --- /dev/null +++ b/panel/server/src/packs.rs @@ -0,0 +1,638 @@ +//! Modpack import: Modrinth (.mrpack), CurseForge (.zip + API) and plain +//! instance zips. Everything is flattened into a list of files the launcher +//! downloads — mods straight from the CDN, overrides from this panel. + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use crate::store; +use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use scopenet_core::paths::safe_join; +use scopenet_shared::Loader; +use serde::Deserialize; +use serde_json::json; +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use std::path::Path; + +const CF_API: &str = "https://api.curseforge.com/v1"; +const MODRINTH_API: &str = "https://api.modrinth.com/v2"; +const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'.').remove(b'_').remove(b'~'); + +#[derive(Debug, Clone)] +pub struct NewFile { + pub path: String, + pub url: String, + pub sha1: String, + pub size: u64, + pub origin: &'static str, + pub note: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct PackInfo { + pub mc_version: String, + pub loader: Loader, + pub loader_version: Option, + pub name: String, + pub version: String, + pub files: Vec, +} + +/// Panel-relative URL for a file stored under `data/files//`. +pub fn files_url(instance_id: &str, rel: &str) -> String { + let encoded: Vec = rel.split('/').map(|s| utf8_percent_encode(s, PATH_SEGMENT).to_string()).collect(); + format!("/files/{}/{}", utf8_percent_encode(instance_id, PATH_SEGMENT), encoded.join("/")) +} + +type Zip = zip::ZipArchive>>; + +fn read_json(zip: &mut Zip, name: &str) -> Option { + let mut entry = zip.by_name(name).ok()?; + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).ok()?; + serde_json::from_slice(&buf).ok() +} + +/// Paths we never ship from a plain instance export. +fn is_junk(rel: &str) -> bool { + const DIRS: &[&str] = &[ + "logs/", + "crash-reports/", + "screenshots/", + "versions/", + "libraries/", + "assets/", + "natives/", + "__MACOSX/", + ".fabric/", + ".scopenet/", + ]; + const FILES: &[&str] = &[ + "usercache.json", + "usernamecache.json", + "launcher_profiles.json", + "launcher_accounts.json", + ".DS_Store", + "instance.cfg", + "mmc-pack.json", + ]; + DIRS.iter().any(|d| rel.starts_with(d)) || FILES.iter().any(|f| rel == *f || rel.ends_with(&format!("/{f}"))) +} + +/// Extract entries under `prefix` into the instance's file store. +fn extract_prefix( + zip: &mut Zip, + prefix: &str, + dest_root: &Path, + instance_id: &str, + origin: &'static str, + skip_junk: bool, +) -> AppResult> { + let mut out = Vec::new(); + for i in 0..zip.len() { + let mut entry = zip.by_index(i).map_err(|e| AppError::bad_request(format!("corrupt zip: {e}")))?; + if entry.is_dir() { + continue; + } + let name = entry.name().replace('\\', "/"); + let Some(rel) = name.strip_prefix(prefix) else { continue }; + if rel.is_empty() || (skip_junk && is_junk(rel)) { + continue; + } + let Some(dest) = safe_join(dest_root, rel) else { continue }; + 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)?; + let sha1 = scopenet_core::http::sha1_bytes(&buf); + std::fs::write(&dest, &buf)?; + out.push(NewFile { path: rel.to_string(), url: files_url(instance_id, rel), sha1, size: buf.len() as u64, origin, note: None }); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Modrinth +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrIndex { + #[serde(default)] + name: String, + #[serde(default)] + version_id: String, + files: Vec, + dependencies: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrFile { + path: String, + hashes: HashMap, + #[serde(default)] + env: Option>, + downloads: Vec, + #[serde(default)] + file_size: u64, +} + +fn import_mrpack(zip: &mut Zip, index: MrIndex, dest_root: &Path, instance_id: &str) -> AppResult { + let deps = &index.dependencies; + let mc = deps.get("minecraft").cloned().ok_or_else(|| AppError::bad_request("mrpack has no minecraft dependency"))?; + let (loader, loader_version) = if let Some(v) = deps.get("fabric-loader") { + (Loader::Fabric, Some(v.clone())) + } else if let Some(v) = deps.get("quilt-loader") { + (Loader::Quilt, Some(v.clone())) + } else if let Some(v) = deps.get("neoforge") { + (Loader::NeoForge, Some(v.clone())) + } else if let Some(v) = deps.get("forge") { + (Loader::Forge, Some(scopenet_core::meta::normalize_forge_version(Loader::Forge, &mc, v))) + } else { + (Loader::Vanilla, None) + }; + + let mut files: Vec = Vec::new(); + for f in index.files { + if f.env.as_ref().and_then(|e| e.get("client")).map(|c| c == "unsupported").unwrap_or(false) { + continue; + } + let (Some(url), Some(sha1)) = (f.downloads.first(), f.hashes.get("sha1")) else { continue }; + if safe_join(dest_root, &f.path).is_none() { + continue; + } + files.push(NewFile { + path: f.path.replace('\\', "/"), + url: url.clone(), + sha1: sha1.clone(), + size: f.file_size, + origin: "pack", + note: None, + }); + } + // client-overrides win over overrides. + let mut overrides = extract_prefix(zip, "overrides/", dest_root, instance_id, "override", false)?; + overrides.extend(extract_prefix(zip, "client-overrides/", dest_root, instance_id, "override", false)?); + merge_files(&mut files, overrides); + + Ok(PackInfo { mc_version: mc, loader, loader_version, name: index.name, version: index.version_id, files }) +} + +fn merge_files(files: &mut Vec, extra: Vec) { + for f in extra { + files.retain(|x| x.path != f.path); + files.push(f); + } +} + +#[derive(Deserialize, serde::Serialize)] +pub struct MrVersion { + pub id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + #[serde(default)] + pub game_versions: Vec, + #[serde(default)] + pub loaders: Vec, + pub files: Vec, + #[serde(default)] + pub date_published: String, +} + +#[derive(Deserialize, serde::Serialize)] +pub struct MrVersionFile { + pub url: String, + pub filename: String, + #[serde(default)] + pub primary: bool, + #[serde(default)] + pub hashes: HashMap, +} + +#[derive(Deserialize)] +struct MrProject { + title: String, + #[serde(default)] + icon_url: Option, +} + +pub async fn fetch_modrinth(state: &AppState, version_id: &str) -> AppResult<(Vec, String, Option)> { + let version: MrVersion = scopenet_core::http::get_json(&state.http, &format!("{MODRINTH_API}/version/{version_id}")).await?; + let project: MrProject = scopenet_core::http::get_json(&state.http, &format!("{MODRINTH_API}/project/{}", version.project_id)).await?; + let file = version + .files + .iter() + .find(|f| f.primary && f.filename.ends_with(".mrpack")) + .or_else(|| version.files.iter().find(|f| f.filename.ends_with(".mrpack"))) + .ok_or_else(|| AppError::bad_request("that version has no .mrpack file"))?; + let bytes = download_bytes(state, &file.url).await?; + if let Some(expected) = file.hashes.get("sha1") { + if !scopenet_core::http::sha1_bytes(&bytes).eq_ignore_ascii_case(expected) { + return Err(AppError::bad_request("downloaded pack failed its checksum")); + } + } + Ok((bytes, format!("Modrinth · {} {}", project.title, version.version_number), project.icon_url)) +} + +async fn download_bytes(state: &AppState, url: &str) -> AppResult> { + let resp = state.http.get(url).send().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?; + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("download failed: {} returned {}", url, resp.status()))); + } + Ok(resp.bytes().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.to_vec()) +} + +// --------------------------------------------------------------------------- +// CurseForge +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfManifest { + minecraft: CfMinecraft, + #[serde(default)] + files: Vec, + #[serde(default = "default_overrides")] + overrides: String, + #[serde(default)] + name: String, + #[serde(default)] + version: String, +} +fn default_overrides() -> String { + "overrides".into() +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfMinecraft { + version: String, + #[serde(default)] + mod_loaders: Vec, +} +#[derive(Deserialize)] +struct CfLoader { + id: String, + #[serde(default)] + primary: bool, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfManifestFile { + #[serde(rename = "projectID")] + project_id: i64, + #[serde(rename = "fileID")] + file_id: i64, + #[serde(default = "yes")] + required: bool, +} +fn yes() -> bool { + true +} + +#[derive(Deserialize)] +struct CfData { + data: T, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfFile { + id: i64, + mod_id: i64, + file_name: String, + #[serde(default)] + download_url: Option, + #[serde(default)] + file_length: u64, + #[serde(default)] + hashes: Vec, +} +#[derive(Deserialize)] +struct CfHash { + value: String, + algo: i32, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfMod { + id: i64, + name: String, + #[serde(default)] + class_id: Option, + #[serde(default)] + links: Option, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfLinks { + #[serde(default)] + website_url: Option, +} + +pub fn parse_cf_loader(mc: &str, id: &str) -> (Loader, Option) { + let (name, version) = id.split_once('-').unwrap_or((id, "")); + match Loader::parse(name) { + Some(Loader::Forge) => (Loader::Forge, Some(scopenet_core::meta::normalize_forge_version(Loader::Forge, mc, version))), + Some(l) if l != Loader::Vanilla => (l, Some(version.to_string()).filter(|v| !v.is_empty())), + _ => (Loader::Vanilla, None), + } +} + +fn cf_folder(class_id: Option) -> &'static str { + match class_id { + Some(12) => "resourcepacks", + Some(6552) => "shaderpacks", + Some(4546) => "config", + _ => "mods", + } +} + +async fn cf_post(state: &AppState, key: &str, path: &str, body: serde_json::Value) -> AppResult { + let resp = state + .http + .post(format!("{CF_API}{path}")) + .header("x-api-key", key) + .json(&body) + .send() + .await + .map_err(|e| AppError::from(anyhow::Error::from(e)))?; + if resp.status().as_u16() == 403 { + return Err(AppError::bad_request("CurseForge rejected the API key")); + } + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("CurseForge returned {}", resp.status()))); + } + Ok(resp.json::>().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.data) +} + +async fn cf_get(state: &AppState, key: &str, path: &str) -> AppResult { + let resp = state + .http + .get(format!("{CF_API}{path}")) + .header("x-api-key", key) + .send() + .await + .map_err(|e| AppError::from(anyhow::Error::from(e)))?; + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("CurseForge returned {}", resp.status()))); + } + Ok(resp.json::>().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.data) +} + +pub async fn cf_raw_get(state: &AppState, path: &str) -> AppResult { + let key = store::curseforge_key(state).await?; + cf_get(state, &key, path).await +} + +async fn resolve_cf_files(state: &AppState, manifest_files: &[CfManifestFile]) -> AppResult> { + let wanted: Vec<&CfManifestFile> = manifest_files.iter().filter(|f| f.required).collect(); + if wanted.is_empty() { + return Ok(vec![]); + } + let key = store::curseforge_key(state).await?; + let mut files: Vec = Vec::new(); + let mut mods: HashMap = HashMap::new(); + for chunk in wanted.chunks(500) { + let ids: Vec = chunk.iter().map(|f| f.file_id).collect(); + files.extend(cf_post::>(state, &key, "/mods/files", json!({ "fileIds": ids })).await?); + let mod_ids: Vec = chunk.iter().map(|f| f.project_id).collect(); + for m in cf_post::>(state, &key, "/mods", json!({ "modIds": mod_ids })).await? { + mods.insert(m.id, m); + } + } + + let mut out = Vec::new(); + for f in files { + let m = mods.get(&f.mod_id); + let folder = cf_folder(m.and_then(|m| m.class_id)); + let sha1 = f.hashes.iter().find(|h| h.algo == 1).map(|h| h.value.clone()).unwrap_or_default(); + let url = f.download_url.clone().unwrap_or_default(); + let note = if url.is_empty() { + let name = m.map(|m| m.name.clone()).unwrap_or_else(|| format!("project {}", f.mod_id)); + let site = m.and_then(|m| m.links.as_ref()).and_then(|l| l.website_url.clone()).unwrap_or_default(); + Some(format!("{name} blocks third-party downloads. Download it from {site}/files/{} and upload it here.", f.id)) + } else { + None + }; + out.push(NewFile { path: format!("{folder}/{}", f.file_name), url, sha1, size: f.file_length, origin: "pack", note }); + } + Ok(out) +} + +pub async fn fetch_curseforge(state: &AppState, mod_id: i64, file_id: i64) -> AppResult<(Vec, String, Option)> { + let key = store::curseforge_key(state).await?; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Info { + download_url: Option, + display_name: String, + } + #[derive(Deserialize)] + struct Logo { + #[serde(rename = "thumbnailUrl")] + thumbnail_url: Option, + } + #[derive(Deserialize)] + struct ModInfo { + name: String, + logo: Option, + } + let info: Info = cf_get(state, &key, &format!("/mods/{mod_id}/files/{file_id}")).await?; + let project: ModInfo = cf_get(state, &key, &format!("/mods/{mod_id}")).await?; + let url = info + .download_url + .ok_or_else(|| AppError::bad_request("this modpack can't be downloaded through the API; download the zip and upload it instead"))?; + let bytes = download_bytes(state, &url).await?; + Ok((bytes, format!("CurseForge · {} ({})", project.name, info.display_name), project.logo.and_then(|l| l.thumbnail_url))) +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/// Fallback versions for plain zips (which don't declare their own). +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Fallback { + pub mc_version: Option, + pub loader: Option, + pub loader_version: Option, +} + +/// Result of the blocking parse step: a finished pack, or a CurseForge +/// manifest (+ extracted overrides) that still needs API resolution. +type Parsed = (Option<(PackInfo, &'static str)>, Option<(CfManifest, Vec)>); + +/// Import any supported zip for `instance_id`. +pub async fn import_zip(state: &AppState, instance_id: &str, bytes: Vec, fallback: Fallback) -> AppResult<(PackInfo, &'static str)> { + let dest_root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id)); + std::fs::create_dir_all(&dest_root)?; + let id = instance_id.to_string(); + + // Parsing + extraction is CPU/disk bound: keep it off the async workers. + let root = dest_root.clone(); + let (parsed, cf_manifest) = tokio::task::spawn_blocking(move || -> AppResult { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| AppError::bad_request(format!("not a valid zip: {e}")))?; + if let Some(index) = read_json::(&mut zip, "modrinth.index.json") { + return Ok((Some((import_mrpack(&mut zip, index, &root, &id)?, "modrinth")), None)); + } + if let Some(manifest) = read_json::(&mut zip, "manifest.json") { + let prefix = format!("{}/", manifest.overrides.trim_end_matches('/')); + let overrides = extract_prefix(&mut zip, &prefix, &root, &id, "override", false)?; + return Ok((None, Some((manifest, overrides)))); + } + // Plain instance zip: find the game directory inside it. + let names: Vec = zip.file_names().map(|n| n.replace('\\', "/")).collect(); + let prefix = detect_root(&names); + let files = extract_prefix(&mut zip, &prefix, &root, &id, "override", true)?; + Ok((Some((PackInfo { files, ..Default::default() }, "zip")), None)) + }) + .await + .map_err(|e| AppError::bad_request(format!("import crashed: {e}")))??; + + if let Some((manifest, overrides)) = cf_manifest { + let mc = manifest.minecraft.version.clone(); + let primary = manifest.minecraft.mod_loaders.iter().find(|l| l.primary).or(manifest.minecraft.mod_loaders.first()); + let (loader, loader_version) = primary.map(|l| parse_cf_loader(&mc, &l.id)).unwrap_or((Loader::Vanilla, None)); + let mut files = resolve_cf_files(state, &manifest.files).await?; + merge_files(&mut files, overrides); + return Ok(( + PackInfo { mc_version: mc, loader, loader_version, name: manifest.name, version: manifest.version, files }, + "curseforge", + )); + } + + let (mut info, kind) = parsed.expect("parsed"); + if kind == "zip" { + info.mc_version = fallback.mc_version.filter(|v| !v.is_empty()).ok_or_else(|| { + AppError::bad_request("this zip isn't a Modrinth or CurseForge pack — pick the Minecraft version and loader for it") + })?; + info.loader = fallback.loader.unwrap_or_default(); + info.loader_version = fallback.loader_version.filter(|v| !v.is_empty()); + } + Ok((info, kind)) +} + +/// Find the game directory inside a zip: a `.minecraft/` folder, or a +/// single top-level folder wrapping everything. +pub fn detect_root(names: &[String]) -> String { + if let Some(pos) = names.iter().filter_map(|n| n.find(".minecraft/").map(|i| &n[..i + ".minecraft/".len()])).min_by_key(|p| p.len()) { + return pos.to_string(); + } + let markers = ["mods/", "config/", "resourcepacks/", "shaderpacks/", "options.txt"]; + if names.iter().any(|n| markers.iter().any(|m| n.starts_with(m))) { + return String::new(); + } + let tops: std::collections::HashSet<&str> = names.iter().filter_map(|n| n.split_once('/').map(|(t, _)| t)).collect(); + if tops.len() == 1 && names.iter().all(|n| n.contains('/')) { + return format!("{}/", tops.into_iter().next().unwrap()); + } + String::new() +} + +/// Replace the instance's pack/override files with `info.files`, keep files +/// the admin uploaded by hand, update versions and bump the revision. +pub async fn apply( + state: &AppState, + instance_id: &str, + info: &PackInfo, + kind: &str, + label: &str, + source_ref: serde_json::Value, +) -> AppResult<()> { + let mut tx = state.db.begin().await?; + sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND origin != 'upload'").bind(instance_id).execute(&mut *tx).await?; + for f in &info.files { + sqlx::query( + "INSERT INTO instance_files (instance_id, path, url, sha1, size, origin, note) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(instance_id, path) DO UPDATE SET url = excluded.url, sha1 = excluded.sha1, size = excluded.size, origin = excluded.origin, note = excluded.note", + ) + .bind(instance_id) + .bind(&f.path) + .bind(&f.url) + .bind(&f.sha1) + .bind(f.size as i64) + .bind(f.origin) + .bind(&f.note) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "UPDATE instances SET mc_version = ?, loader = ?, loader_version = ?, source_kind = ?, source_label = ?, source_ref = ?, + revision = revision + 1, updated_at = ? WHERE id = ?", + ) + .bind(&info.mc_version) + .bind(info.loader.as_str()) + .bind(&info.loader_version) + .bind(kind) + .bind(label) + .bind(source_ref.to_string()) + .bind(crate::db::now()) + .bind(instance_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + gc_files(state, instance_id).await?; + Ok(()) +} + +/// Delete stored files no longer referenced by the instance. +pub async fn gc_files(state: &AppState, instance_id: &str) -> AppResult<()> { + let root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id)); + let referenced: std::collections::HashSet = + store::instance_files(state, instance_id).await?.into_iter().filter(|f| f.url.starts_with("/files/")).map(|f| f.path).collect(); + tokio::task::spawn_blocking(move || { + fn walk(dir: &Path, root: &Path, keep: &std::collections::HashSet) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + walk(&p, root, keep); + std::fs::remove_dir(&p).ok(); // only succeeds when empty + } else if let Ok(rel) = p.strip_prefix(root) { + let rel = rel.to_string_lossy().replace('\\', "/"); + if !keep.contains(&rel) { + std::fs::remove_file(&p).ok(); + } + } + } + } + walk(&root, &root, &referenced); + }) + .await + .ok(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cf_loader_ids() { + assert_eq!(parse_cf_loader("1.20.1", "forge-47.2.0"), (Loader::Forge, Some("1.20.1-47.2.0".into()))); + assert_eq!(parse_cf_loader("1.21.1", "neoforge-21.1.77"), (Loader::NeoForge, Some("21.1.77".into()))); + assert_eq!(parse_cf_loader("1.21.1", "fabric-0.16.9"), (Loader::Fabric, Some("0.16.9".into()))); + } + + #[test] + fn detects_zip_roots() { + let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(detect_root(&v(&["mods/a.jar", "config/b.toml"])), ""); + assert_eq!(detect_root(&v(&["Pack/mods/a.jar", "Pack/config/b.toml"])), "Pack/"); + assert_eq!(detect_root(&v(&["x/.minecraft/mods/a.jar", "x/instance.cfg"])), "x/.minecraft/"); + } + + #[test] + fn encodes_file_urls() { + assert_eq!(files_url("smp", "mods/My Mod+1.jar"), "/files/smp/mods/My%20Mod%2B1.jar"); + } + + #[test] + fn junk_is_skipped() { + assert!(is_junk("logs/latest.log")); + assert!(is_junk("usercache.json")); + assert!(!is_junk("mods/sodium.jar")); + } +} diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs new file mode 100644 index 0000000..3153b94 --- /dev/null +++ b/panel/server/src/routes/admin.rs @@ -0,0 +1,672 @@ +//! Admin API used by the panel web UI. + +use crate::auth::{self, AdminUser, UserRow}; +use crate::error::{AppError, AppResult}; +use crate::packs::{self, Fallback}; +use crate::state::AppState; +use crate::store::{self, AdminInstance, Settings}; +use axum::extract::{Multipart, Path, Query, State}; +use axum::Json; +use scopenet_core::paths::{safe_join, sanitize_id}; +use scopenet_shared::{valid_username, Branding, Loader, MemoryDefaults, ServerEntry}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- + +pub async fn stats(_: AdminUser, State(state): State) -> AppResult> { + let users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(&state.db).await?; + let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'pending'").fetch_one(&state.db).await?; + let instances: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM instances").fetch_one(&state.db).await?; + let since = (chrono::Utc::now() - chrono::Duration::days(13)).format("%Y-%m-%d").to_string(); + let daily: Vec<(String, i64)> = sqlx::query_as( + "SELECT substr(created_at, 1, 10) AS day, COUNT(*) FROM events WHERE kind = 'launch' AND created_at >= ? GROUP BY day ORDER BY day", + ) + .bind(&since) + .fetch_all(&state.db) + .await?; + let top: Vec<(Option, i64)> = sqlx::query_as( + "SELECT instance_id, COUNT(*) AS c FROM events WHERE kind = 'launch' AND created_at >= ? GROUP BY instance_id ORDER BY c DESC LIMIT 5", + ) + .bind(&since) + .fetch_all(&state.db) + .await?; + let recent: Vec<(Option, Option, String)> = + sqlx::query_as("SELECT instance_id, username, created_at FROM events ORDER BY id DESC LIMIT 12").fetch_all(&state.db).await?; + let players_7d: i64 = sqlx::query_scalar("SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?") + .bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339()) + .fetch_one(&state.db) + .await?; + + // Fill in empty days so the chart is continuous. + let mut days = Vec::new(); + for i in (0..14).rev() { + let d = (chrono::Utc::now() - chrono::Duration::days(i)).format("%Y-%m-%d").to_string(); + let count = daily.iter().find(|(day, _)| *day == d).map(|(_, c)| *c).unwrap_or(0); + days.push(json!({ "day": d, "launches": count })); + } + let settings = store::settings(&state).await?; + Ok(Json(json!({ + "users": users, + "pending": pending, + "instances": instances, + "players_7d": players_7d, + "launches": days, + "top_instances": top.into_iter().map(|(id, c)| json!({"instance_id": id, "launches": c})).collect::>(), + "recent": recent.into_iter().map(|(i, u, t)| json!({"instance_id": i, "username": u, "at": t})).collect::>(), + "launcher_download_url": settings.launcher_download_url, + "version": env!("CARGO_PKG_VERSION"), + }))) +} + +// --------------------------------------------------------------------------- +// Users & groups +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct AdminUserView { + #[serde(flatten)] + user: UserRow, + uuid: String, + groups: Vec, +} + +async fn view(state: &AppState, user: UserRow) -> AppResult { + let groups = auth::user_groups(state, user.id).await?; + Ok(AdminUserView { uuid: scopenet_shared::offline_uuid(&user.username), groups, user }) +} + +pub async fn list_users(_: AdminUser, State(state): State) -> AppResult>> { + let users: Vec = + sqlx::query_as("SELECT * FROM users ORDER BY status = 'pending' DESC, username COLLATE NOCASE").fetch_all(&state.db).await?; + let mut out = Vec::with_capacity(users.len()); + for u in users { + out.push(view(&state, u).await?); + } + Ok(Json(out)) +} + +#[derive(Deserialize)] +pub struct UserInput { + username: Option, + password: Option, + email: Option, + role: Option, + status: Option, + groups: Option>, +} + +fn check_role(role: &str) -> AppResult<()> { + if !matches!(role, "admin" | "player") { + return Err(AppError::bad_request("role must be admin or player")); + } + Ok(()) +} + +fn check_status(status: &str) -> AppResult<()> { + if !matches!(status, "active" | "pending" | "disabled") { + return Err(AppError::bad_request("status must be active, pending or disabled")); + } + Ok(()) +} + +async fn set_groups(state: &AppState, user_id: i64, groups: &[String]) -> AppResult<()> { + sqlx::query("DELETE FROM user_groups WHERE user_id = ?").bind(user_id).execute(&state.db).await?; + for g in groups { + sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) SELECT ?, id FROM groups WHERE name = ?") + .bind(user_id) + .bind(g) + .execute(&state.db) + .await?; + } + Ok(()) +} + +pub async fn create_user(_: AdminUser, State(state): State, Json(input): Json) -> AppResult> { + let username = input.username.as_deref().map(str::trim).unwrap_or_default(); + if !valid_username(username) { + return Err(AppError::bad_request("usernames are 3-16 letters, numbers or underscores")); + } + let password = input.password.unwrap_or_default(); + auth::validate_password(&password)?; + let role = input.role.unwrap_or_else(|| "player".into()); + check_role(&role)?; + let status = input.status.unwrap_or_else(|| "active".into()); + check_status(&status)?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(username) + .bind(auth::hash_password(&password)?) + .bind(input.email.filter(|e| !e.trim().is_empty())) + .bind(&role) + .bind(&status) + .bind(crate::db::now()) + .fetch_one(&state.db) + .await + .map_err(|e| match e { + sqlx::Error::Database(d) if d.message().contains("UNIQUE") => AppError::conflict("that username is taken"), + e => e.into(), + })?; + if let Some(groups) = input.groups { + set_groups(&state, id, &groups).await?; + } + let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; + Ok(Json(view(&state, user).await?)) +} + +pub async fn update_user( + AdminUser(me): AdminUser, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> AppResult> { + let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::not_found("user not found"))?; + if let Some(password) = input.password.filter(|p| !p.is_empty()) { + auth::validate_password(&password)?; + sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") + .bind(auth::hash_password(&password)?) + .bind(id) + .execute(&state.db) + .await?; + } + if let Some(email) = input.email { + sqlx::query("UPDATE users SET email = ? WHERE id = ?") + .bind(Some(email.trim()).filter(|e| !e.is_empty())) + .bind(id) + .execute(&state.db) + .await?; + } + if let Some(role) = input.role { + check_role(&role)?; + if me.id == id && role != "admin" { + return Err(AppError::bad_request("you can't remove your own admin role")); + } + sqlx::query("UPDATE users SET role = ? WHERE id = ?").bind(role).bind(id).execute(&state.db).await?; + } + if let Some(status) = input.status { + check_status(&status)?; + if me.id == id && status != "active" { + return Err(AppError::bad_request("you can't disable your own account")); + } + sqlx::query("UPDATE users SET status = ? WHERE id = ?").bind(status).bind(id).execute(&state.db).await?; + } + if let Some(groups) = input.groups { + set_groups(&state, user.id, &groups).await?; + } + let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; + Ok(Json(view(&state, user).await?)) +} + +pub async fn delete_user(AdminUser(me): AdminUser, State(state): State, Path(id): Path) -> AppResult> { + if me.id == id { + return Err(AppError::bad_request("you can't delete your own account")); + } + sqlx::query("DELETE FROM users WHERE id = ?").bind(id).execute(&state.db).await?; + Ok(Json(json!({ "ok": true }))) +} + +#[derive(Serialize, Deserialize, sqlx::FromRow)] +pub struct Group { + #[serde(default)] + id: i64, + name: String, + #[serde(default = "default_color")] + color: String, + #[serde(default)] + #[sqlx(default)] + members: i64, +} +fn default_color() -> String { + "#7c5cff".into() +} + +pub async fn list_groups(_: AdminUser, State(state): State) -> AppResult>> { + Ok(Json( + sqlx::query_as( + "SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members FROM groups g ORDER BY g.name", + ) + .fetch_all(&state.db) + .await?, + )) +} + +pub async fn create_group(_: AdminUser, State(state): State, Json(g): Json) -> AppResult> { + let name = g.name.trim(); + if name.is_empty() || name.len() > 32 { + return Err(AppError::bad_request("group names are 1-32 characters")); + } + sqlx::query("INSERT INTO groups (name, color) VALUES (?, ?)") + .bind(name) + .bind(&g.color) + .execute(&state.db) + .await + .map_err(|_| AppError::conflict("a group with that name exists"))?; + Ok(Json(json!({ "ok": true }))) +} + +pub async fn delete_group(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + sqlx::query("DELETE FROM groups WHERE id = ?").bind(id).execute(&state.db).await?; + Ok(Json(json!({ "ok": true }))) +} + +// --------------------------------------------------------------------------- +// Branding & settings +// --------------------------------------------------------------------------- + +pub async fn get_branding(_: AdminUser, State(state): State) -> AppResult> { + Ok(Json(store::branding(&state).await?)) +} + +pub async fn put_branding(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + if b.name.trim().is_empty() { + return Err(AppError::bad_request("the launcher needs a name")); + } + store::kv_set(&state, "branding", &b).await?; + Ok(Json(b)) +} + +#[derive(Serialize)] +pub struct SettingsView { + #[serde(flatten)] + settings: Settings, + curseforge_key_set: bool, + curseforge_key_from_env: bool, +} + +pub async fn get_settings(_: AdminUser, State(state): State) -> AppResult> { + settings_view(&state).await +} + +async fn settings_view(state: &AppState) -> AppResult> { + let mut s = store::settings(state).await?; + let set = s.curseforge_api_key.as_deref().is_some_and(|k| !k.is_empty()); + s.curseforge_api_key = None; // never send secrets back to the browser + Ok(Json(SettingsView { settings: s, curseforge_key_set: set, curseforge_key_from_env: state.cfg.curseforge_api_key.is_some() })) +} + +pub async fn put_settings(_: AdminUser, State(state): State, Json(mut s): Json) -> AppResult> { + let old = store::settings(&state).await?; + // An empty key means "keep the current one"; "-" clears it. + s.curseforge_api_key = match s.curseforge_api_key.as_deref().map(str::trim) { + None | Some("") => old.curseforge_api_key, + Some("-") => None, + Some(k) => Some(k.to_string()), + }; + if s.auth.microsoft && s.auth.microsoft_client_id.as_deref().map(str::trim).unwrap_or("").is_empty() { + return Err(AppError::bad_request("Microsoft sign-in needs an Azure client ID")); + } + store::kv_set(&state, "settings", &s).await?; + settings_view(&state).await +} + +// --------------------------------------------------------------------------- +// Instances +// --------------------------------------------------------------------------- + +pub async fn list_instances(_: AdminUser, State(state): State) -> AppResult>> { + let mut out = Vec::new(); + for row in store::list_instances(&state).await? { + let stats = store::file_stats(&state, &row.id).await?; + out.push(row.to_admin(stats)); + } + Ok(Json(out)) +} + +#[derive(Deserialize, Default)] +#[serde(default)] +pub struct InstanceInput { + name: String, + description: String, + icon_url: Option, + banner_url: Option, + mc_version: String, + loader: Loader, + loader_version: Option, + visibility: Option, + allowed_groups: Vec, + memory: Option, + jvm_args: String, + server: Option, + featured: bool, + enabled: Option, + sort: i64, +} + +async fn validated(state: &AppState, mut input: InstanceInput) -> AppResult { + input.name = input.name.trim().to_string(); + if input.name.is_empty() { + return Err(AppError::bad_request("give the instance a name")); + } + if input.mc_version.trim().is_empty() { + return Err(AppError::bad_request("pick a Minecraft version")); + } + let vis = input.visibility.clone().unwrap_or_else(|| "public".into()); + if !matches!(vis.as_str(), "public" | "members" | "groups") { + return Err(AppError::bad_request("visibility must be public, members or groups")); + } + input.visibility = Some(vis); + let mem = input.memory.unwrap_or_default(); + if mem.max_mb < 512 || mem.min_mb > mem.max_mb { + return Err(AppError::bad_request("memory: max must be at least 512 MB and not below min")); + } + // Pin "latest" to a concrete loader version when we can reach the meta + // servers; otherwise the launcher resolves it at install time. + if input.loader == Loader::Vanilla { + input.loader_version = None; + } else { + let requested = input.loader_version.clone(); + match scopenet_core::meta::resolve_loader_version(&state.http, input.loader, &input.mc_version, requested.as_deref()).await { + Ok(v) => input.loader_version = v, + Err(e) => tracing::warn!("couldn't resolve loader version: {e:#}"), + } + } + Ok(input) +} + +pub async fn create_instance( + _: AdminUser, + State(state): State, + Json(input): Json, +) -> AppResult> { + let input = validated(&state, input).await?; + let id = store::unique_slug(&state, &input.name).await?; + let now = crate::db::now(); + let mem = input.memory.unwrap_or_default(); + sqlx::query( + "INSERT INTO instances (id, name, description, icon_url, banner_url, mc_version, loader, loader_version, source_kind, source_label, + visibility, allowed_groups, memory_min, memory_max, jvm_args, server, featured, enabled, sort, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'vanilla', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&input.name) + .bind(&input.description) + .bind(&input.icon_url) + .bind(&input.banner_url) + .bind(&input.mc_version) + .bind(input.loader.as_str()) + .bind(&input.loader_version) + .bind(format!("Minecraft {}", input.mc_version)) + .bind(input.visibility.as_deref().unwrap_or("public")) + .bind(serde_json::to_string(&input.allowed_groups)?) + .bind(mem.min_mb as i64) + .bind(mem.max_mb as i64) + .bind(&input.jvm_args) + .bind(input.server.as_ref().map(serde_json::to_string).transpose()?) + .bind(input.featured) + .bind(input.enabled.unwrap_or(true)) + .bind(input.sort) + .bind(&now) + .bind(&now) + .execute(&state.db) + .await?; + detail(&state, id).await.map(|Json(v)| Json(v.instance)) +} + +#[derive(Serialize)] +pub struct InstanceDetail { + instance: AdminInstance, + files: Vec, +} + +pub async fn get_instance(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + detail(&state, id).await +} + +async fn detail(state: &AppState, id: String) -> AppResult> { + let row = store::get_instance(state, &id).await?; + let stats = store::file_stats(state, &id).await?; + Ok(Json(InstanceDetail { instance: row.to_admin(stats), files: store::instance_files(state, &id).await? })) +} + +pub async fn update_instance( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> AppResult> { + let existing = store::get_instance(&state, &id).await?; + let input = validated(&state, input).await?; + let mem = input.memory.unwrap_or_default(); + // Modpack instances keep the versions the pack declared unless the + // admin explicitly changes them here. + let label = if existing.source_kind == "vanilla" { format!("Minecraft {}", input.mc_version) } else { existing.source_label.clone() }; + sqlx::query( + "UPDATE instances SET name = ?, description = ?, icon_url = ?, banner_url = ?, mc_version = ?, loader = ?, loader_version = ?, + source_label = ?, visibility = ?, allowed_groups = ?, memory_min = ?, memory_max = ?, jvm_args = ?, server = ?, featured = ?, + enabled = ?, sort = ?, revision = revision + 1, updated_at = ? WHERE id = ?", + ) + .bind(&input.name) + .bind(&input.description) + .bind(input.icon_url.filter(|s| !s.is_empty())) + .bind(input.banner_url.filter(|s| !s.is_empty())) + .bind(&input.mc_version) + .bind(input.loader.as_str()) + .bind(&input.loader_version) + .bind(label) + .bind(input.visibility.as_deref().unwrap_or("public")) + .bind(serde_json::to_string(&input.allowed_groups)?) + .bind(mem.min_mb as i64) + .bind(mem.max_mb as i64) + .bind(&input.jvm_args) + .bind(input.server.as_ref().map(serde_json::to_string).transpose()?) + .bind(input.featured) + .bind(input.enabled.unwrap_or(true)) + .bind(input.sort) + .bind(crate::db::now()) + .bind(&id) + .execute(&state.db) + .await?; + detail(&state, id).await +} + +pub async fn delete_instance(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + store::get_instance(&state, &id).await?; + sqlx::query("DELETE FROM instances WHERE id = ?").bind(&id).execute(&state.db).await?; + let dir = state.cfg.files_dir().join(sanitize_id(&id)); + tokio::fs::remove_dir_all(dir).await.ok(); + Ok(Json(json!({ "ok": true }))) +} + +/// Drop the modpack, keeping only hand-uploaded files. +pub async fn reset_source(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + let row = store::get_instance(&state, &id).await?; + let info = packs::PackInfo { + mc_version: row.mc_version.clone(), + loader: Loader::parse(&row.loader).unwrap_or_default(), + loader_version: row.loader_version.clone(), + ..Default::default() + }; + packs::apply(&state, &id, &info, "vanilla", &format!("Minecraft {}", row.mc_version), json!({})).await?; + detail(&state, id).await +} + +#[derive(Deserialize)] +pub struct ModrinthImport { + version_id: String, + #[serde(default)] + project_id: Option, +} + +pub async fn import_modrinth( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + let row = store::get_instance(&state, &id).await?; + let (bytes, label, icon) = packs::fetch_modrinth(&state, &req.version_id).await?; + let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?; + packs::apply(&state, &id, &info, "modrinth", &label, json!({ "version_id": req.version_id, "project_id": req.project_id })).await?; + set_icon_if_missing(&state, &row, icon).await?; + detail(&state, id).await +} + +#[derive(Deserialize)] +pub struct CurseForgeImport { + mod_id: i64, + file_id: i64, +} + +pub async fn import_curseforge( + _: AdminUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + let row = store::get_instance(&state, &id).await?; + let (bytes, label, icon) = packs::fetch_curseforge(&state, req.mod_id, req.file_id).await?; + let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?; + packs::apply(&state, &id, &info, "curseforge", &label, json!({ "mod_id": req.mod_id, "file_id": req.file_id })).await?; + set_icon_if_missing(&state, &row, icon).await?; + detail(&state, id).await +} + +async fn set_icon_if_missing(state: &AppState, row: &store::InstanceRow, icon: Option) -> AppResult<()> { + if row.icon_url.is_none() { + if let Some(icon) = icon { + sqlx::query("UPDATE instances SET icon_url = ? WHERE id = ?").bind(icon).bind(&row.id).execute(&state.db).await?; + } + } + Ok(()) +} + +/// Upload a .mrpack / CurseForge zip / plain instance zip. +pub async fn import_upload( + _: AdminUser, + State(state): State, + Path(id): Path, + mut form: Multipart, +) -> AppResult> { + store::get_instance(&state, &id).await?; + let mut bytes = None; + let mut filename = String::from("upload.zip"); + let mut fallback = Fallback::default(); + while let Some(field) = form.next_field().await? { + match field.name().unwrap_or_default() { + "file" => { + filename = field.file_name().unwrap_or("upload.zip").to_string(); + bytes = Some(field.bytes().await?.to_vec()); + } + "mc_version" => fallback.mc_version = Some(field.text().await?), + "loader" => fallback.loader = Loader::parse(&field.text().await?), + "loader_version" => fallback.loader_version = Some(field.text().await?), + _ => {} + } + } + let bytes = bytes.ok_or_else(|| AppError::bad_request("no file uploaded"))?; + let (mut info, kind) = packs::import_zip(&state, &id, bytes, fallback).await?; + if kind == "zip" && info.loader != Loader::Vanilla { + info.loader_version = + scopenet_core::meta::resolve_loader_version(&state.http, info.loader, &info.mc_version, info.loader_version.as_deref()) + .await + .ok() + .flatten(); + } + let label = match kind { + "modrinth" | "curseforge" if !info.name.is_empty() => format!("{} {}", info.name, info.version).trim().to_string(), + _ => format!("Uploaded · {filename}"), + }; + packs::apply(&state, &id, &info, kind, &label, json!({ "filename": filename })).await?; + detail(&state, id).await +} + +/// Add individual files (extra mods, configs, or a CurseForge mod that +/// can't be downloaded automatically). +pub async fn upload_files( + _: AdminUser, + State(state): State, + Path(id): Path, + mut form: Multipart, +) -> AppResult> { + store::get_instance(&state, &id).await?; + let root = state.cfg.files_dir().join(sanitize_id(&id)); + let mut folder = String::from("mods"); + let mut added = 0; + while let Some(field) = form.next_field().await? { + match field.name().unwrap_or_default() { + "folder" => folder = field.text().await?.trim().trim_matches('/').to_string(), + "file" => { + let name = field.file_name().unwrap_or("file").rsplit(['/', '\\']).next().unwrap_or("file").to_string(); + let rel = if folder.is_empty() { name.clone() } else { format!("{folder}/{name}") }; + let dest = safe_join(&root, &rel).ok_or_else(|| AppError::bad_request("invalid path"))?; + let data = field.bytes().await?; + tokio::fs::create_dir_all(dest.parent().unwrap()).await?; + tokio::fs::write(&dest, &data).await?; + let sha1 = scopenet_core::http::sha1_bytes(&data); + // Uploading a file with the same name as a "missing" CurseForge + // entry replaces it. + sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND url = '' AND path LIKE ?") + .bind(&id) + .bind(format!("%/{name}")) + .execute(&state.db) + .await?; + sqlx::query( + "INSERT INTO instance_files (instance_id, path, url, sha1, size, origin) VALUES (?, ?, ?, ?, ?, 'upload') + ON CONFLICT(instance_id, path) DO UPDATE SET url = excluded.url, sha1 = excluded.sha1, size = excluded.size, origin = 'upload', note = NULL", + ) + .bind(&id) + .bind(&rel) + .bind(packs::files_url(&id, &rel)) + .bind(sha1) + .bind(data.len() as i64) + .execute(&state.db) + .await?; + added += 1; + } + _ => {} + } + } + if added == 0 { + return Err(AppError::bad_request("no files uploaded")); + } + store::bump_revision(&state, &id).await?; + detail(&state, id).await +} + +#[derive(Deserialize)] +pub struct PathQuery { + path: String, +} + +pub async fn delete_file( + _: AdminUser, + State(state): State, + Path(id): Path, + Query(q): Query, +) -> AppResult> { + sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND path = ?").bind(&id).bind(&q.path).execute(&state.db).await?; + store::bump_revision(&state, &id).await?; + packs::gc_files(&state, &id).await?; + detail(&state, id).await +} + +// --------------------------------------------------------------------------- +// Media uploads (logos, backgrounds, icons) +// --------------------------------------------------------------------------- + +pub async fn upload_media(_: AdminUser, State(state): State, mut form: Multipart) -> AppResult> { + while let Some(field) = form.next_field().await? { + if field.name() != Some("file") { + continue; + } + let name = field.file_name().unwrap_or_default().to_lowercase(); + let ext = name.rsplit('.').next().unwrap_or_default().to_string(); + // SVG is excluded on purpose: it can carry scripts. + if !matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "mp4" | "webm" | "avif") { + return Err(AppError::bad_request("supported: png, jpg, gif, webp, avif, ico, mp4, webm")); + } + let data = field.bytes().await?; + let file = format!("{}.{ext}", uuid::Uuid::new_v4().simple()); + tokio::fs::create_dir_all(state.cfg.uploads_dir()).await?; + tokio::fs::write(state.cfg.uploads_dir().join(&file), &data).await?; + return Ok(Json(json!({ "url": format!("/uploads/{file}") }))); + } + Err(AppError::bad_request("no file uploaded")) +} diff --git a/panel/server/src/routes/meta.rs b/panel/server/src/routes/meta.rs new file mode 100644 index 0000000..710e272 --- /dev/null +++ b/panel/server/src/routes/meta.rs @@ -0,0 +1,89 @@ +//! Version pickers and modpack search, proxied so the browser never needs +//! API keys. + +use crate::auth::AdminUser; +use crate::error::AppResult; +use crate::packs; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::Json; +use scopenet_core::meta::{self, LoaderVersion}; +use scopenet_shared::Loader; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct McVersion { + id: String, + kind: String, + released: Option, +} + +pub async fn minecraft(_: AdminUser, State(state): State) -> AppResult> { + let m = meta::mojang_manifest(&state.http).await?; + let versions: Vec = m.versions.into_iter().map(|v| McVersion { id: v.id, kind: v.kind, released: v.release_time }).collect(); + Ok(Json(serde_json::json!({ "latest": m.latest, "versions": versions }))) +} + +#[derive(Deserialize)] +pub struct LoaderQuery { + mc: String, +} + +pub async fn loaders( + _: AdminUser, + State(state): State, + Path(loader): Path, + Query(q): Query, +) -> AppResult>> { + let loader = Loader::parse(&loader).unwrap_or_default(); + Ok(Json(meta::loader_versions(&state.http, loader, &q.mc).await?)) +} + +#[derive(Deserialize)] +pub struct SearchQuery { + #[serde(default)] + q: String, +} + +pub async fn modrinth_search( + _: AdminUser, + State(state): State, + Query(q): Query, +) -> AppResult> { + let resp = state + .http + .get("https://api.modrinth.com/v2/search") + .query(&[("query", q.q.as_str()), ("facets", r#"[["project_type:modpack"]]"#), ("limit", "24"), ("index", "relevance")]) + .send() + .await + .map_err(anyhow::Error::from)? + .error_for_status() + .map_err(anyhow::Error::from)?; + Ok(Json(resp.json().await.map_err(anyhow::Error::from)?)) +} + +pub async fn modrinth_versions( + _: AdminUser, + State(state): State, + Path(project): Path, +) -> AppResult>> { + let url = format!("https://api.modrinth.com/v2/project/{}/version", urlencode(&project)); + Ok(Json(scopenet_core::http::get_json(&state.http, &url).await?)) +} + +pub async fn curseforge_search( + _: AdminUser, + State(state): State, + Query(q): Query, +) -> AppResult> { + let path = format!("/mods/search?gameId=432&classId=4471&pageSize=24&sortField=2&sortOrder=desc&searchFilter={}", urlencode(&q.q)); + Ok(Json(packs::cf_raw_get(&state, &path).await?)) +} + +pub async fn curseforge_files(_: AdminUser, State(state): State, Path(mod_id): Path) -> AppResult> { + Ok(Json(packs::cf_raw_get(&state, &format!("/mods/{mod_id}/files?pageSize=40")).await?)) +} + +fn urlencode(s: &str) -> String { + percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs new file mode 100644 index 0000000..f866887 --- /dev/null +++ b/panel/server/src/routes/mod.rs @@ -0,0 +1,46 @@ +pub mod admin; +pub mod meta; +pub mod public; + +use crate::state::AppState; +use axum::extract::DefaultBodyLimit; +use axum::routing::{delete, get, post}; +use axum::Router; + +pub fn api(state: &AppState) -> Router { + let upload_limit = state.cfg.max_upload_mb * 1024 * 1024; + + let launcher = Router::new() + .route("/launcher/manifest", get(public::manifest)) + .route("/launcher/instances/{id}", get(public::instance_manifest)) + .route("/launcher/events", post(public::event)) + .route("/auth/login", post(public::login)) + .route("/auth/register", post(public::register)) + .route("/auth/me", get(public::me)); + + let admin = Router::new() + .route("/stats", get(admin::stats)) + .route("/users", get(admin::list_users).post(admin::create_user)) + .route("/users/{id}", axum::routing::patch(admin::update_user).delete(admin::delete_user)) + .route("/groups", get(admin::list_groups).post(admin::create_group)) + .route("/groups/{id}", delete(admin::delete_group)) + .route("/branding", get(admin::get_branding).put(admin::put_branding)) + .route("/settings", get(admin::get_settings).put(admin::put_settings)) + .route("/instances", get(admin::list_instances).post(admin::create_instance)) + .route("/instances/{id}", get(admin::get_instance).put(admin::update_instance).delete(admin::delete_instance)) + .route("/instances/{id}/reset", post(admin::reset_source)) + .route("/instances/{id}/import/modrinth", post(admin::import_modrinth)) + .route("/instances/{id}/import/curseforge", post(admin::import_curseforge)) + .route("/instances/{id}/import/upload", post(admin::import_upload)) + .route("/instances/{id}/files", post(admin::upload_files).delete(admin::delete_file)) + .route("/uploads", post(admin::upload_media)) + .route("/meta/minecraft", get(meta::minecraft)) + .route("/meta/loaders/{loader}", get(meta::loaders)) + .route("/modrinth/search", get(meta::modrinth_search)) + .route("/modrinth/project/{id}/versions", get(meta::modrinth_versions)) + .route("/curseforge/search", get(meta::curseforge_search)) + .route("/curseforge/mod/{id}/files", get(meta::curseforge_files)) + .layer(DefaultBodyLimit::max(upload_limit)); + + Router::new().nest("/api/v1", launcher).nest("/api/admin", admin) +} diff --git a/panel/server/src/routes/public.rs b/panel/server/src/routes/public.rs new file mode 100644 index 0000000..919223b --- /dev/null +++ b/panel/server/src/routes/public.rs @@ -0,0 +1,142 @@ +//! Endpoints the launcher talks to. + +use crate::auth::{self, AuthUser, MaybeUser, UserRow}; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use crate::store; +use axum::extract::{Path, State}; +use axum::Json; +use scopenet_shared::*; + +pub async fn health() -> &'static str { + "ok" +} + +pub async fn manifest(State(state): State, MaybeUser(user): MaybeUser) -> AppResult> { + let settings = store::settings(&state).await?; + let groups = match &user { + Some(u) => auth::user_groups(&state, u.id).await?, + None => vec![], + }; + let mut instances = Vec::new(); + for row in store::list_instances(&state).await? { + if row.visible_to(user.as_ref(), &groups) { + let stats = store::file_stats(&state, &row.id).await?; + instances.push(row.to_summary(stats)); + } + } + let public_user = match &user { + Some(u) => Some(auth::public_user(&state, u).await?), + None => None, + }; + let mut auth_cfg = settings.auth; + if !auth_cfg.microsoft { + auth_cfg.microsoft_client_id = None; + } + Ok(Json(LauncherManifest { + api_version: API_VERSION, + panel_version: env!("CARGO_PKG_VERSION").into(), + branding: store::branding(&state).await?, + auth: auth_cfg, + instances, + user: public_user, + })) +} + +pub async fn instance_manifest( + State(state): State, + MaybeUser(user): MaybeUser, + Path(id): Path, +) -> AppResult> { + let row = store::get_instance(&state, &id).await?; + let groups = match &user { + Some(u) => auth::user_groups(&state, u.id).await?, + None => vec![], + }; + if !row.visible_to(user.as_ref(), &groups) { + return Err(AppError::not_found("instance not found")); + } + let stats = store::file_stats(&state, &id).await?; + let files = store::to_entries(store::instance_files(&state, &id).await?); + Ok(Json(InstanceManifest { instance: row.to_summary(stats), files })) +} + +async fn find_user(state: &AppState, username: &str) -> AppResult> { + Ok(sqlx::query_as("SELECT * FROM users WHERE username = ?").bind(username.trim()).fetch_optional(&state.db).await?) +} + +pub async fn login(State(state): State, Json(req): Json) -> AppResult> { + let username = req.username.trim().to_string(); + state.login_guard.check(&username)?; + let settings = store::settings(&state).await?; + let user = find_user(&state, &username).await?; + let Some(user) = user.filter(|u| auth::verify_password(&req.password, &u.password_hash)) else { + state.login_guard.fail(&username); + return Err(AppError::unauthorized("wrong username or password")); + }; + state.login_guard.succeed(&username); + match user.status.as_str() { + "pending" => return Err(AppError::forbidden("your account is waiting for an admin to approve it")), + "disabled" => return Err(AppError::forbidden("this account has been disabled")), + _ => {} + } + if !settings.auth.panel_accounts && !user.is_admin() { + return Err(AppError::forbidden("account sign-in is currently disabled")); + } + sqlx::query("UPDATE users SET last_login = ? WHERE id = ?").bind(crate::db::now()).bind(user.id).execute(&state.db).await?; + Ok(Json(AuthResponse { token: state.keys.issue(&user)?, user: auth::public_user(&state, &user).await?, pending: false })) +} + +pub async fn register(State(state): State, Json(req): Json) -> AppResult> { + let settings = store::settings(&state).await?; + if !settings.auth.panel_accounts || settings.auth.registration == RegistrationMode::Closed { + return Err(AppError::forbidden("sign-ups are closed — ask an admin for an account")); + } + let username = req.username.trim(); + if !valid_username(username) { + return Err(AppError::bad_request("usernames are 3-16 letters, numbers or underscores")); + } + auth::validate_password(&req.password)?; + if find_user(&state, username).await?.is_some() { + return Err(AppError::conflict("that username is taken")); + } + let status = if settings.auth.registration == RegistrationMode::Approval { "pending" } else { "active" }; + let id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, 'player', ?, ?) RETURNING id", + ) + .bind(username) + .bind(auth::hash_password(&req.password)?) + .bind(req.email.as_deref().map(str::trim).filter(|e| !e.is_empty())) + .bind(status) + .bind(crate::db::now()) + .fetch_one(&state.db) + .await?; + let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; + let pending = status == "pending"; + Ok(Json(AuthResponse { + token: if pending { String::new() } else { state.keys.issue(&user)? }, + user: auth::public_user(&state, &user).await?, + pending, + })) +} + +pub async fn me(State(state): State, AuthUser(user): AuthUser) -> AppResult> { + Ok(Json(auth::public_user(&state, &user).await?)) +} + +pub async fn event( + State(state): State, + MaybeUser(user): MaybeUser, + Json(ev): Json, +) -> AppResult> { + let kind = if ev.kind == "launch" { "launch" } else { "other" }; + let name = user.map(|u| u.username).or(ev.username).map(|n| n.chars().take(32).collect::()); + sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)") + .bind(ev.instance_id.chars().take(64).collect::()) + .bind(name) + .bind(kind) + .bind(crate::db::now()) + .execute(&state.db) + .await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/panel/server/src/state.rs b/panel/server/src/state.rs new file mode 100644 index 0000000..aeb078c --- /dev/null +++ b/panel/server/src/state.rs @@ -0,0 +1,13 @@ +use crate::auth::{Keys, LoginGuard}; +use crate::config::Config; +use sqlx::SqlitePool; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub db: SqlitePool, + pub cfg: Arc, + pub keys: Arc, + pub http: reqwest::Client, + pub login_guard: Arc, +} diff --git a/panel/server/src/store.rs b/panel/server/src/store.rs new file mode 100644 index 0000000..0dc7da8 --- /dev/null +++ b/panel/server/src/store.rs @@ -0,0 +1,272 @@ +//! Persistence helpers: key/value settings and instances. + +use crate::auth::UserRow; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use scopenet_shared::{AuthConfig, Branding, FileEntry, InstanceSummary, Loader, MemoryDefaults, ServerEntry}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +pub async fn kv_get(state: &AppState, key: &str) -> AppResult { + let raw: Option = sqlx::query_scalar("SELECT value FROM kv WHERE key = ?").bind(key).fetch_optional(&state.db).await?; + Ok(raw.and_then(|r| serde_json::from_str(&r).ok()).unwrap_or_default()) +} + +pub async fn kv_set(state: &AppState, key: &str, value: &T) -> AppResult<()> { + sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") + .bind(key) + .bind(serde_json::to_string(value)?) + .execute(&state.db) + .await?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Settings { + pub auth: AuthConfig, + pub curseforge_api_key: Option, + /// Where players can download the launcher (shown on the dashboard). + pub launcher_download_url: Option, +} + +pub async fn settings(state: &AppState) -> AppResult { + kv_get(state, "settings").await +} + +pub async fn branding(state: &AppState) -> AppResult { + kv_get(state, "branding").await +} + +/// Environment variable wins over the value saved in the panel. +pub async fn curseforge_key(state: &AppState) -> AppResult { + if let Some(k) = &state.cfg.curseforge_api_key { + return Ok(k.clone()); + } + settings(state) + .await? + .curseforge_api_key + .filter(|k| !k.is_empty()) + .ok_or_else(|| AppError::bad_request("add a CurseForge API key in Settings first (get one at console.curseforge.com)")) +} + +// --------------------------------------------------------------------------- +// Instances +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct InstanceRow { + pub id: String, + pub name: String, + pub description: String, + pub icon_url: Option, + pub banner_url: Option, + pub mc_version: String, + pub loader: String, + pub loader_version: Option, + pub source_kind: String, + pub source_label: String, + pub source_ref: String, + pub visibility: String, + pub allowed_groups: String, + pub memory_min: i64, + pub memory_max: i64, + pub jvm_args: String, + pub server: Option, + pub featured: bool, + pub enabled: bool, + pub sort: i64, + pub revision: i64, + pub created_at: String, + pub updated_at: String, +} + +/// Full instance as seen by admins. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct AdminInstance { + pub id: String, + pub name: String, + pub description: String, + pub icon_url: Option, + pub banner_url: Option, + pub mc_version: String, + pub loader: Loader, + pub loader_version: Option, + pub source_kind: String, + pub source_label: String, + pub source_ref: serde_json::Value, + /// "public" | "members" | "groups" + pub visibility: String, + pub allowed_groups: Vec, + pub memory: MemoryDefaults, + pub jvm_args: String, + pub server: Option, + pub featured: bool, + pub enabled: bool, + pub sort: i64, + pub revision: i64, + pub created_at: String, + pub updated_at: String, + pub file_count: u32, + pub total_size: u64, + pub missing_count: u32, +} + +impl InstanceRow { + pub fn to_admin(&self, stats: FileStats) -> AdminInstance { + AdminInstance { + id: self.id.clone(), + name: self.name.clone(), + description: self.description.clone(), + icon_url: self.icon_url.clone(), + banner_url: self.banner_url.clone(), + mc_version: self.mc_version.clone(), + loader: Loader::parse(&self.loader).unwrap_or_default(), + loader_version: self.loader_version.clone(), + source_kind: self.source_kind.clone(), + source_label: self.source_label.clone(), + source_ref: serde_json::from_str(&self.source_ref).unwrap_or_default(), + visibility: self.visibility.clone(), + allowed_groups: serde_json::from_str(&self.allowed_groups).unwrap_or_default(), + memory: MemoryDefaults { min_mb: self.memory_min as u32, max_mb: self.memory_max as u32 }, + jvm_args: self.jvm_args.clone(), + server: self.server.as_deref().and_then(|s| serde_json::from_str(s).ok()), + featured: self.featured, + enabled: self.enabled, + sort: self.sort, + revision: self.revision, + created_at: self.created_at.clone(), + updated_at: self.updated_at.clone(), + file_count: stats.count, + total_size: stats.size, + missing_count: stats.missing, + } + } + + pub fn to_summary(&self, stats: FileStats) -> InstanceSummary { + let a = self.to_admin(stats); + InstanceSummary { + id: a.id, + name: a.name, + description: a.description, + icon_url: a.icon_url, + banner_url: a.banner_url, + mc_version: a.mc_version, + loader: a.loader, + loader_version: a.loader_version, + revision: a.revision, + server: a.server.filter(|s| !s.address.trim().is_empty()), + memory: a.memory, + jvm_args: a.jvm_args, + featured: a.featured, + source_label: a.source_label, + file_count: a.file_count, + total_size: a.total_size, + } + } + + /// Can this (possibly anonymous) user see the instance? + pub fn visible_to(&self, user: Option<&UserRow>, groups: &[String]) -> bool { + if !self.enabled { + return false; + } + if user.is_some_and(|u| u.is_admin()) { + return true; + } + match self.visibility.as_str() { + "public" => true, + "members" => user.is_some(), + "groups" => { + let allowed: Vec = serde_json::from_str(&self.allowed_groups).unwrap_or_default(); + user.is_some() && allowed.iter().any(|g| groups.iter().any(|x| x.eq_ignore_ascii_case(g))) + } + _ => false, + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct FileStats { + pub count: u32, + pub size: u64, + pub missing: u32, +} + +pub async fn file_stats(state: &AppState, instance_id: &str) -> AppResult { + let (count, size, missing): (i64, Option, Option) = + sqlx::query_as("SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?") + .bind(instance_id) + .fetch_one(&state.db) + .await?; + Ok(FileStats { count: count as u32, size: size.unwrap_or(0) as u64, missing: missing.unwrap_or(0) as u32 }) +} + +pub async fn get_instance(state: &AppState, id: &str) -> AppResult { + sqlx::query_as("SELECT * FROM instances WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::not_found("instance not found")) +} + +pub async fn list_instances(state: &AppState) -> AppResult> { + Ok(sqlx::query_as("SELECT * FROM instances ORDER BY featured DESC, sort ASC, name COLLATE NOCASE ASC").fetch_all(&state.db).await?) +} + +pub async fn bump_revision(state: &AppState, id: &str) -> AppResult<()> { + sqlx::query("UPDATE instances SET revision = revision + 1, updated_at = ? WHERE id = ?") + .bind(crate::db::now()) + .bind(id) + .execute(&state.db) + .await?; + Ok(()) +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct FileRow { + pub path: String, + pub url: String, + pub sha1: String, + pub size: i64, + pub origin: String, + pub note: Option, +} + +pub async fn instance_files(state: &AppState, id: &str) -> AppResult> { + Ok(sqlx::query_as("SELECT path, url, sha1, size, origin, note FROM instance_files WHERE instance_id = ? ORDER BY path") + .bind(id) + .fetch_all(&state.db) + .await?) +} + +pub fn to_entries(files: Vec) -> Vec { + files + .into_iter() + .filter(|f| !f.url.is_empty()) + .map(|f| FileEntry { path: f.path, url: f.url, sha1: f.sha1, size: f.size as u64 }) + .collect() +} + +/// URL-safe, human-readable id from a name, made unique. +pub async fn unique_slug(state: &AppState, name: &str) -> AppResult { + let mut base: String = name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + base.truncate(40); + if base.is_empty() { + base = "instance".into(); + } + let mut slug = base.clone(); + let mut n = 2; + while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM instances WHERE id = ?").bind(&slug).fetch_one(&state.db).await? > 0 { + slug = format!("{base}-{n}"); + n += 1; + } + Ok(slug) +} diff --git a/panel/server/tests/api.rs b/panel/server/tests/api.rs new file mode 100644 index 0000000..5cd8159 --- /dev/null +++ b/panel/server/tests/api.rs @@ -0,0 +1,310 @@ +//! End-to-end tests against the real router with an in-memory database. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db}; +use serde_json::{json, Value}; +use std::io::Write; +use tower::ServiceExt; + +struct TestApp { + router: axum::Router, + _dir: tempfile::TempDir, +} + +async fn setup() -> TestApp { + let dir = tempfile::tempdir().unwrap(); + let cfg = Config { + bind: "127.0.0.1:0".into(), + data_dir: dir.path().to_path_buf(), + web_dir: dir.path().join("web"), + admin_username: "admin".into(), + admin_password: Some("supersecret".into()), + jwt_secret: Some("test-secret-test-secret-test-secret".into()), + curseforge_api_key: None, + max_upload_mb: 64, + }; + let pool = db::connect_memory().await.unwrap(); + let state = build_state(cfg, pool).await.unwrap(); + bootstrap_admin(&state).await.unwrap(); + TestApp { router: app(state), _dir: dir } +} + +impl TestApp { + async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri); + if let Some(t) = token { + req = req.header("authorization", format!("Bearer {t}")); + } + let req = match body { + Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())).unwrap(), + None => req.body(Body::empty()).unwrap(), + }; + self.send(req).await + } + + async fn send(&self, req: Request) -> (StatusCode, Value) { + let resp = self.router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into()))) + } + + async fn login(&self, user: &str, pass: &str) -> String { + let (s, v) = self.call("POST", "/api/v1/auth/login", None, Some(json!({"username": user, "password": pass}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + v["token"].as_str().unwrap().to_string() + } +} + +fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec) { + let boundary = "----scopenettest"; + let mut body = Vec::new(); + for (k, v) in fields { + write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").unwrap(); + } + write!( + body, + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", + file.0 + ) + .unwrap(); + body.extend_from_slice(file.1); + write!(body, "\r\n--{boundary}--\r\n").unwrap(); + (format!("multipart/form-data; boundary={boundary}"), body) +} + +fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default(); + for (name, data) in entries { + z.start_file(*name, opts).unwrap(); + z.write_all(data).unwrap(); + } + z.finish().unwrap(); + } + buf.into_inner() +} + +#[tokio::test] +async fn health_and_default_manifest() { + let t = setup().await; + let (s, v) = t.call("GET", "/healthz", None, None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v, "ok"); + let (s, v) = t.call("GET", "/api/v1/launcher/manifest", None, None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v["branding"]["name"], "SCOPENET"); + assert_eq!(v["api_version"], 1); + assert!(v["user"].is_null()); +} + +#[tokio::test] +async fn admin_only_routes_are_guarded() { + let t = setup().await; + let (s, _) = t.call("GET", "/api/admin/users", None, None).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + let (s, _) = t.call("GET", "/api/admin/users", Some("garbage"), None).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + + let admin = t.login("admin", "supersecret").await; + let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve")); + + let player = t.login("steve", "password123").await; // usernames are case-insensitive + let (s, _) = t.call("GET", "/api/admin/users", Some(&player), None).await; + assert_eq!(s, StatusCode::FORBIDDEN); + let (s, v) = t.call("GET", "/api/v1/auth/me", Some(&player), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v["username"], "Steve"); +} + +#[tokio::test] +async fn wrong_password_and_lockout() { + let t = setup().await; + for _ in 0..10 { + let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "nope"}))).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + } + let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "supersecret"}))).await; + assert_eq!(s, StatusCode::TOO_MANY_REQUESTS); +} + +#[tokio::test] +async fn registration_modes() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let reg = json!({"username": "Alex", "password": "password123"}); + let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await; + assert_eq!(s, StatusCode::FORBIDDEN, "closed by default"); + + let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"registration": "approval"}}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let (s, v) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["pending"], true); + let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "Alex", "password": "password123"}))).await; + assert_eq!(s, StatusCode::FORBIDDEN, "pending accounts can't sign in"); + + let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg)).await; + assert_eq!(s, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn instance_visibility_and_zip_upload() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (_, _) = t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "VIP"}))).await; + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Vip1", "password": "password123", "groups": ["VIP"]}))).await; + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Pleb", "password": "password123"}))).await; + + let (s, v) = t + .call( + "POST", + "/api/admin/instances", + Some(&admin), + Some(json!({"name": "VIP Survival!", "mc_version": "1.21.1", "visibility": "groups", "allowed_groups": ["VIP"], + "server": {"name": "SMP", "address": "play.example.net", "port": 25565, "auto_join": true, "inject": true}})), + ) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["id"], "vip-survival"); + let (_, v) = t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Public", "mc_version": "1.20.1"}))).await; + assert_eq!(v["id"], "public"); + + let names = |v: &Value| v["instances"].as_array().unwrap().iter().map(|i| i["id"].as_str().unwrap().to_string()).collect::>(); + let (_, anon) = t.call("GET", "/api/v1/launcher/manifest", None, None).await; + assert_eq!(names(&anon), vec!["public"]); + let vip = t.login("Vip1", "password123").await; + let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&vip), None).await; + assert_eq!(names(&m).len(), 2); + assert_eq!(m["user"]["groups"][0], "VIP"); + let pleb = t.login("Pleb", "password123").await; + let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&pleb), None).await; + assert_eq!(names(&m), vec!["public"]); + let (s, _) = t.call("GET", "/api/v1/launcher/instances/vip-survival", Some(&pleb), None).await; + assert_eq!(s, StatusCode::NOT_FOUND); + + // Plain zip without version info → needs the fallback fields. + let zip = zip_bytes(&[("MyPack/mods/cool.jar", b"jarjar"), ("MyPack/config/x.toml", b"a=1"), ("MyPack/logs/latest.log", b"junk")]); + let (ct, body) = multipart(&[], ("pack.zip", &zip)); + let req = Request::post("/api/admin/instances/public/import/upload") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + let (s, v) = t.send(req).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "{v}"); + + let (ct, body) = multipart(&[("mc_version", "1.20.1"), ("loader", "vanilla")], ("pack.zip", &zip)); + let req = Request::post("/api/admin/instances/public/import/upload") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + let (s, v) = t.send(req).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let paths: Vec<&str> = v["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect(); + assert_eq!(paths, vec!["config/x.toml", "mods/cool.jar"]); + assert_eq!(v["instance"]["revision"], 2); + + // Launcher sees the files and can download them. + let (_, im) = t.call("GET", "/api/v1/launcher/instances/public", None, None).await; + let url = im["files"][1]["url"].as_str().unwrap().to_string(); + assert_eq!(url, "/files/public/mods/cool.jar"); + assert_eq!(im["files"][1]["sha1"], scopenet_core::http::sha1_bytes(b"jarjar")); + let resp = t.router.clone().oneshot(Request::get(&url).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&bytes[..], b"jarjar"); +} + +#[tokio::test] +async fn mrpack_upload_sets_versions_and_overrides() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Pack", "mc_version": "1.20.1"}))).await; + let index = json!({ + "formatVersion": 1, "game": "minecraft", "versionId": "6.2.0", "name": "Fabulous", + "files": [ + {"path": "mods/sodium.jar", "hashes": {"sha1": "aaa", "sha512": "x"}, "downloads": ["https://cdn.modrinth.com/sodium.jar"], "fileSize": 10}, + {"path": "mods/server-only.jar", "hashes": {"sha1": "bbb"}, "env": {"client": "unsupported", "server": "required"}, "downloads": ["https://cdn.modrinth.com/s.jar"], "fileSize": 5}, + {"path": "../escape.jar", "hashes": {"sha1": "ccc"}, "downloads": ["https://x/e.jar"], "fileSize": 1} + ], + "dependencies": {"minecraft": "1.21.1", "fabric-loader": "0.16.9"} + }); + let zip = zip_bytes(&[ + ("modrinth.index.json", index.to_string().as_bytes()), + ("overrides/options.txt", b"fov:0.5"), + ("overrides/config/a.json", b"{}"), + ("client-overrides/config/a.json", b"{\"client\":1}"), + ]); + let (ct, body) = multipart(&[], ("fab.mrpack", &zip)); + let req = Request::post("/api/admin/instances/pack/import/upload") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + let (s, v) = t.send(req).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["instance"]["mc_version"], "1.21.1"); + assert_eq!(v["instance"]["loader"], "fabric"); + assert_eq!(v["instance"]["loader_version"], "0.16.9"); + assert_eq!(v["instance"]["source_kind"], "modrinth"); + assert_eq!(v["instance"]["source_label"], "Fabulous 6.2.0"); + let files = v["files"].as_array().unwrap(); + let paths: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect(); + assert_eq!(paths, vec!["config/a.json", "mods/sodium.jar", "options.txt"]); + let cfg = files.iter().find(|f| f["path"] == "config/a.json").unwrap(); + assert_eq!(cfg["sha1"], scopenet_core::http::sha1_bytes(b"{\"client\":1}"), "client-overrides win"); +} + +#[tokio::test] +async fn branding_roundtrip_and_media_validation() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (_, mut b) = t.call("GET", "/api/admin/branding", Some(&admin), None).await; + b["name"] = json!("Craftopia"); + b["colors"]["accent"] = json!("#ff5500"); + let (s, _) = t.call("PUT", "/api/admin/branding", Some(&admin), Some(b)).await; + assert_eq!(s, StatusCode::OK); + let (_, m) = t.call("GET", "/api/v1/launcher/manifest", None, None).await; + assert_eq!(m["branding"]["name"], "Craftopia"); + assert_eq!(m["branding"]["colors"]["accent"], "#ff5500"); + + let (ct, body) = multipart(&[], ("evil.svg", b"")); + let req = Request::post("/api/admin/uploads") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + let (s, _) = t.send(req).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let (ct, body) = multipart(&[], ("logo.png", b"\x89PNG")); + let req = Request::post("/api/admin/uploads") + .header("authorization", format!("Bearer {admin}")) + .header("content-type", &ct) + .body(Body::from(body)) + .unwrap(); + let (s, v) = t.send(req).await; + assert_eq!(s, StatusCode::OK); + assert!(v["url"].as_str().unwrap().starts_with("/uploads/")); +} + +#[tokio::test] +async fn settings_never_leak_curseforge_key() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": "secret-key"}))).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v["curseforge_key_set"], true); + assert!(v["curseforge_api_key"].is_null()); + // Saving again with an empty key keeps it. + let (_, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": ""}))).await; + assert_eq!(v["curseforge_key_set"], true); + let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"microsoft": true}}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "MS needs a client id"); +} diff --git a/panel/web/index.html b/panel/web/index.html new file mode 100644 index 0000000..4e26551 --- /dev/null +++ b/panel/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + SCOPENET Panel + + +
+ + + diff --git a/panel/web/package-lock.json b/panel/web/package-lock.json new file mode 100644 index 0000000..551f9c3 --- /dev/null +++ b/panel/web/package-lock.json @@ -0,0 +1,1255 @@ +{ + "name": "scopenet-panel-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scopenet-panel-web", + "version": "0.1.0", + "devDependencies": { + "@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", + "@lucide/svelte": "^1.48.0", + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@tsconfig/svelte": "^5.0.8", + "svelte": "^5.57.1", + "svelte-check": "^4.7.6", + "typescript": "~5.9.0", + "vite": "^8.3.1" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/outfit": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.3.0.tgz", + "integrity": "sha512-fPb5XnYK0k7SbR++HEmm4+ZqDszmLKQ+sPRUr8oQ/KNC0vy6r6hazJFe8iYozv4cqlHbvJ/JECkJWrfDodUMVQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/plus-jakarta-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.3.0.tgz", + "integrity": "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/space-grotesk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz", + "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.48.0.tgz", + "integrity": "sha512-bLimVDjehxu3fpu9XEUpqEkBk2sJAHAT2sVcLHhlM70EdNsXuj9ZnYENWv5xgw3+05h4i5TirAxTiTgnZZf8gw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/panel/web/package.json b/panel/web/package.json new file mode 100644 index 0000000..107c9a5 --- /dev/null +++ b/panel/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "scopenet-panel-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@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", + "@lucide/svelte": "^1.48.0", + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@tsconfig/svelte": "^5.0.8", + "svelte": "^5.57.1", + "svelte-check": "^4.7.6", + "typescript": "~5.9.0", + "vite": "^8.3.1" + } +} diff --git a/panel/web/public/favicon.svg b/panel/web/public/favicon.svg new file mode 100644 index 0000000..226bcf4 --- /dev/null +++ b/panel/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/panel/web/src/App.svelte b/panel/web/src/App.svelte new file mode 100644 index 0000000..420b03c --- /dev/null +++ b/panel/web/src/App.svelte @@ -0,0 +1,80 @@ + + +{#if checking} +
+{:else if !session.user} + +{:else} +
+ +
+ {#key route.name + route.params.join('/')} + {#if route.name === 'instances' && route.params[0]} + + {:else if route.name === 'instances'} + + {:else if route.name === 'users'} + + {:else if route.name === 'branding'} + (brand = b)} /> + {:else if route.name === 'settings'} + + {:else} + + {/if} + {/key} +
+
+{/if} + + + + diff --git a/panel/web/src/app.css b/panel/web/src/app.css new file mode 100644 index 0000000..59bc7dc --- /dev/null +++ b/panel/web/src/app.css @@ -0,0 +1,301 @@ +:root { + --bg: #0d0e12; + --bg-2: #111217; + --surface: #16181e; + --surface-2: #1c1e25; + --surface-3: #25272f; + --line: rgba(255, 255, 255, 0.07); + --line-strong: rgba(255, 255, 255, 0.12); + --text: #ecedf1; + --text-2: #b4b7c0; + --muted: #8b8f9a; + --accent: #6d6af5; + --accent-2: #8b88f8; + --accent-soft: rgba(109, 106, 245, 0.14); + --good: #3fb97c; + --warn: #e2a336; + --bad: #e5484d; + --radius: 10px; + --radius-sm: 7px; + --shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.5); + --font: 'Inter Variable', system-ui, sans-serif; + --mono: 'JetBrains Mono Variable', ui-monospace, monospace; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14.5px; + -webkit-font-smoothing: antialiased; +} + +body { background: var(--bg); } + +a { + color: var(--accent-2); + text-decoration: none; +} + +h1, h2, h3, h4 { + margin: 0; + font-weight: 600; + letter-spacing: -0.01em; +} + +h1 { font-size: 1.5rem; } +h2 { font-size: 1.2rem; } +h3 { font-size: 1rem; } + +p { margin: 0; } + +code, .mono { + font-family: var(--mono); + font-size: 0.85em; +} + +::selection { + background: rgba(109, 106, 245, 0.4); +} + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 10px; border: 2px solid var(--bg); } +::-webkit-scrollbar-track { background: transparent; } + +/* ---------- layout primitives ---------- */ + +.page { + padding: 32px 40px 64px; + max-width: 1320px; + margin: 0 auto; + animation: fade-in 0.25s ease; +} + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 28px; + flex-wrap: wrap; +} + +.page-head p { + color: var(--muted); + margin-top: 6px; +} + +.row { display: flex; gap: 12px; align-items: center; } +.row.wrap { flex-wrap: wrap; } +.col { display: flex; flex-direction: column; gap: 12px; } +.spacer { flex: 1; } +.grid { display: grid; gap: 16px; } +.muted { color: var(--muted); } +.small { font-size: 0.85rem; } +.tiny { font-size: 0.75rem; } + +.card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 20px; +} + +.card.hover { + transition: border-color 0.15s; +} +.card.hover:hover { border-color: var(--line-strong); } + +.section-title { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; +} + +.section-title .hint { + margin-left: auto; + font-size: 0.8rem; + color: var(--muted); +} + +/* ---------- controls ---------- */ + +button, .btn { + font: inherit; + cursor: pointer; + border: 1px solid var(--line-strong); + background: var(--surface-2); + color: var(--text); + border-radius: var(--radius-sm); + padding: 9px 16px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 520; + transition: background 0.15s, border-color 0.15s, opacity 0.15s; + white-space: nowrap; +} +button:hover { background: var(--surface-3); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +button.primary, a.btn.primary { + background: var(--accent); + border-color: transparent; + color: white; +} +button.primary:hover, a.btn.primary:hover { background: color-mix(in srgb, var(--accent) 88%, white); } + +button.ghost { background: transparent; border-color: transparent; color: var(--text-2); } +button.ghost:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); } +button.danger { color: #f2999c; border-color: rgba(229, 72, 77, 0.3); background: rgba(229, 72, 77, 0.08); } +button.danger:hover { background: rgba(229, 72, 77, 0.15); } +button.icon { padding: 8px; } +button.sm { padding: 6px 11px; font-size: 0.85rem; } + +input, select, textarea { + font: inherit; + width: 100%; + background: var(--bg-2); + color: var(--text); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + padding: 10px 12px; + transition: border-color 0.15s, background 0.15s; +} +input:hover, select:hover, textarea:hover { border-color: rgba(255, 255, 255, 0.2); } +input:focus, select:focus, textarea:focus { border-color: var(--accent); background: #0c0e18; outline: none; } +textarea { resize: vertical; min-height: 80px; line-height: 1.5; } +select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 18px) 55%, calc(100% - 13px) 55%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 34px; } +input[type='color'] { padding: 0; width: 42px; height: 42px; border-radius: 10px; cursor: pointer; overflow: hidden; } +input[type='color']::-webkit-color-swatch-wrapper { padding: 0; } +input[type='color']::-webkit-color-swatch { border: none; } +input[type='range'] { -webkit-appearance: none; appearance: none; padding: 0; height: 18px; border: none; background: transparent; cursor: pointer; } +input[type='range']::-webkit-slider-runnable-track { height: 4px; border-radius: 99px; background: var(--surface-3); } +input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; margin-top: -5px; 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); } + +label.field { + display: flex; + flex-direction: column; + gap: 7px; + font-size: 0.85rem; + color: var(--text-2); + font-weight: 500; +} +label.field .help { color: var(--muted); font-weight: 400; font-size: 0.78rem; } + +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + border-radius: 5px; + font-size: 0.75rem; + font-weight: 520; + background: rgba(255, 255, 255, 0.06); + color: var(--text-2); + border: 1px solid var(--line); + white-space: nowrap; +} +.badge.accent { background: var(--accent-soft); color: #b9b7fb; border-color: rgba(109, 106, 245, 0.28); } +.badge.good { background: rgba(52, 211, 153, 0.12); color: #6ee7b7; border-color: rgba(52, 211, 153, 0.25); } +.badge.warn { background: rgba(251, 191, 36, 0.12); color: #fcd34d; border-color: rgba(251, 191, 36, 0.25); } +.badge.bad { background: rgba(244, 63, 94, 0.12); color: #fda4af; border-color: rgba(244, 63, 94, 0.25); } + +.segmented { + display: inline-flex; + background: var(--bg-2); + border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); + padding: 3px; + gap: 2px; +} +.segmented button { + border: none; + background: transparent; + padding: 7px 14px; + border-radius: 7px; + color: var(--muted); + font-size: 0.88rem; +} +.segmented button.active { background: var(--surface-3); color: var(--text); } + +.tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--line); + margin-bottom: 24px; + overflow-x: auto; +} +.tabs button { + background: none; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + color: var(--muted); + padding: 12px 16px; +} +.tabs button:hover { color: var(--text); background: none; } +.tabs button.active { color: var(--text); border-bottom-color: var(--accent); } + +table.table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} +.table th { + text-align: left; + color: var(--muted); + font-weight: 500; + font-size: 0.78rem; + padding: 10px 14px; + border-bottom: 1px solid var(--line); +} +.table td { + padding: 12px 14px; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} +.table tr:last-child td { border-bottom: none; } +.table tbody tr { transition: background 0.1s; } +.table tbody tr:hover { background: rgba(255, 255, 255, 0.02); } + +.empty { + text-align: center; + padding: 56px 20px; + color: var(--muted); +} +.empty h3 { color: var(--text); margin: 12px 0 6px; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.spin { animation: spin 0.9s linear infinite; } + +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } + +@media (max-width: 900px) { + .page { padding: 20px 16px 48px; } +} diff --git a/panel/web/src/components/Avatar.svelte b/panel/web/src/components/Avatar.svelte new file mode 100644 index 0000000..e1c171c --- /dev/null +++ b/panel/web/src/components/Avatar.svelte @@ -0,0 +1,16 @@ + + +{#if failed} + {name.slice(0, 1).toUpperCase()} +{:else} + (failed = true)} /> +{/if} + + diff --git a/panel/web/src/components/BarChart.svelte b/panel/web/src/components/BarChart.svelte new file mode 100644 index 0000000..038541f --- /dev/null +++ b/panel/web/src/components/BarChart.svelte @@ -0,0 +1,77 @@ + + +
+ + {#each ticks as t} + + {t.toLocaleString()} + {/each} + {#each data as d, i} + {@const x = PAD_L + i * band + (band - barW) / 2} + + {#if i % 2 === data.length % 2 || data.length < 8} + {fmtDay(d.day)} + {/if} + (hover = i)} onmouseleave={() => (hover = null)} + /> + {/each} + + {#if hover !== null} + {@const d = data[hover]} +
+ {d.launches.toLocaleString()} launch{d.launches === 1 ? '' : 'es'} + {fmtDay(d.day)} +
+ {/if} + + + {#each data as d}{/each} +
{label}
{d.day}{d.launches}
+
+ + diff --git a/panel/web/src/components/ColorInput.svelte b/panel/web/src/components/ColorInput.svelte new file mode 100644 index 0000000..bddef74 --- /dev/null +++ b/panel/web/src/components/ColorInput.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/panel/web/src/components/ImageInput.svelte b/panel/web/src/components/ImageInput.svelte new file mode 100644 index 0000000..8ebbf9f --- /dev/null +++ b/panel/web/src/components/ImageInput.svelte @@ -0,0 +1,66 @@ + + +
+ {label} +
+ +
+ + {#if help}{help}{/if} +
+ {#if value} + + {/if} +
+ +
+ + diff --git a/panel/web/src/components/LauncherPreview.svelte b/panel/web/src/components/LauncherPreview.svelte new file mode 100644 index 0000000..8613e46 --- /dev/null +++ b/panel/web/src/components/LauncherPreview.svelte @@ -0,0 +1,122 @@ + + +
+
+ {#if media} +
+ {#if b.background.kind === 'video'} + + + {:else} +
+ {/if} +
+
+ {/if} + +
+ {#if b.logo_url}{:else}{/if} + {b.name} +
+ +
+ {instanceName.slice(0, 2).toUpperCase()} + CB + CA + + + +
+ +
+
+
1.21.1Fabric
+

{instanceName}

+

{b.tagline}

+
+ Play + {#if b.features.server_status} 42/100 online{/if} +
+
+ {#if b.features.news && news.length} +
+ News + {#each news as n} +
+ {#if n.image_url}{/if} +
+ {#if n.tag}{n.tag}{/if} + {n.title || 'Untitled'} + {n.body} +
+
+ {/each} +
+ {/if} +
+
+
+ + diff --git a/panel/web/src/components/Modal.svelte b/panel/web/src/components/Modal.svelte new file mode 100644 index 0000000..8882f45 --- /dev/null +++ b/panel/web/src/components/Modal.svelte @@ -0,0 +1,44 @@ + + + + +{#if open} + +{/if} + + diff --git a/panel/web/src/components/Sidebar.svelte b/panel/web/src/components/Sidebar.svelte new file mode 100644 index 0000000..3b3a7ad --- /dev/null +++ b/panel/web/src/components/Sidebar.svelte @@ -0,0 +1,90 @@ + + + + + diff --git a/panel/web/src/components/Toasts.svelte b/panel/web/src/components/Toasts.svelte new file mode 100644 index 0000000..18be5ff --- /dev/null +++ b/panel/web/src/components/Toasts.svelte @@ -0,0 +1,28 @@ + + +
+ {#each toasts as t (t.id)} +
+ {#if t.kind === 'ok'}{:else if t.kind === 'error'}{:else}{/if} + {t.text} + +
+ {/each} +
+ + diff --git a/panel/web/src/components/Toggle.svelte b/panel/web/src/components/Toggle.svelte new file mode 100644 index 0000000..71b9ace --- /dev/null +++ b/panel/web/src/components/Toggle.svelte @@ -0,0 +1,26 @@ + + + + + diff --git a/panel/web/src/components/VersionPicker.svelte b/panel/web/src/components/VersionPicker.svelte new file mode 100644 index 0000000..fc49fb8 --- /dev/null +++ b/panel/web/src/components/VersionPicker.svelte @@ -0,0 +1,96 @@ + + +
+ + + {#if showLoader} +
+ Mod loader +
+ {#each LOADERS as l} + + {/each} +
+
+ + {#if loader !== 'vanilla'} + + {/if} + {/if} +
+ + diff --git a/panel/web/src/lib/api.ts b/panel/web/src/lib/api.ts new file mode 100644 index 0000000..d85b3db --- /dev/null +++ b/panel/web/src/lib/api.ts @@ -0,0 +1,73 @@ +import { logout, session } from './session.svelte'; + +export class ApiError extends Error { + constructor(message: string, public status: number) { + super(message); + } +} + +type Options = { method?: string; body?: unknown; form?: FormData }; + +export async function api(path: string, opts: Options = {}): Promise { + const headers: Record = {}; + if (session.token) headers.Authorization = `Bearer ${session.token}`; + let body: BodyInit | undefined; + if (opts.form) body = opts.form; + else if (opts.body !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(opts.body); + } + const res = await fetch(path, { method: opts.method ?? (body ? 'POST' : 'GET'), headers, body }); + const text = await res.text(); + let data: any = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = text; + } + if (!res.ok) { + if (res.status === 401 && session.token) logout(); + throw new ApiError(data?.error ?? `Request failed (${res.status})`, res.status); + } + return data as T; +} + +export const get = (p: string) => api(p); +export const post = (p: string, body?: unknown) => api(p, { method: 'POST', body: body ?? {} }); +export const put = (p: string, body: unknown) => api(p, { method: 'PUT', body }); +export const patch = (p: string, body: unknown) => api(p, { method: 'PATCH', body }); +export const del = (p: string) => api(p, { method: 'DELETE' }); + +export async function uploadMedia(file: File): Promise { + const form = new FormData(); + form.append('file', file); + const res = await api<{ url: string }>('/api/admin/uploads', { method: 'POST', form }); + return res.url; +} + +export function formatBytes(n: number): string { + if (!n) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024))); + return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${units[i]}`; +} + +export function timeAgo(iso?: string | null): string { + if (!iso) return 'never'; + const s = (Date.now() - new Date(iso).getTime()) / 1000; + if (s < 60) return 'just now'; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + if (s < 86400 * 30) return `${Math.floor(s / 86400)}d ago`; + return new Date(iso).toLocaleDateString(); +} + +export const LOADERS = [ + { id: 'vanilla', label: 'Vanilla' }, + { id: 'fabric', label: 'Fabric' }, + { id: 'quilt', label: 'Quilt' }, + { id: 'forge', label: 'Forge' }, + { id: 'neoforge', label: 'NeoForge' }, +] as const; + +export const loaderLabel = (id: string) => LOADERS.find((l) => l.id === id)?.label ?? id; diff --git a/panel/web/src/lib/router.svelte.ts b/panel/web/src/lib/router.svelte.ts new file mode 100644 index 0000000..bf791ba --- /dev/null +++ b/panel/web/src/lib/router.svelte.ts @@ -0,0 +1,18 @@ +// Tiny hash router: #/instances/abc → { name: 'instances', params: ['abc'] } +function parse() { + const parts = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean).map(decodeURIComponent); + return { name: parts[0] ?? 'dashboard', params: parts.slice(1) }; +} + +export const route = $state(parse()); + +window.addEventListener('hashchange', () => { + const r = parse(); + route.name = r.name; + route.params = r.params; + window.scrollTo({ top: 0 }); +}); + +export function go(path: string) { + location.hash = '#/' + path.replace(/^\//, ''); +} diff --git a/panel/web/src/lib/session.svelte.ts b/panel/web/src/lib/session.svelte.ts new file mode 100644 index 0000000..eeb0bae --- /dev/null +++ b/panel/web/src/lib/session.svelte.ts @@ -0,0 +1,34 @@ +const KEY = 'scopenet.panel.token'; + +function read(): string { + try { + return localStorage.getItem(KEY) ?? ''; + } catch { + return ''; + } +} + +export interface Me { + id: number; + username: string; + uuid: string; + role: string; + groups: string[]; +} + +export const session = $state<{ token: string; user: Me | null }>({ token: read(), user: null }); + +export function setToken(token: string) { + session.token = token; + try { + if (token) localStorage.setItem(KEY, token); + else localStorage.removeItem(KEY); + } catch { + /* storage unavailable: session lasts until reload */ + } +} + +export function logout() { + setToken(''); + session.user = null; +} diff --git a/panel/web/src/lib/toast.svelte.ts b/panel/web/src/lib/toast.svelte.ts new file mode 100644 index 0000000..120aea3 --- /dev/null +++ b/panel/web/src/lib/toast.svelte.ts @@ -0,0 +1,19 @@ +export type Toast = { id: number; kind: 'ok' | 'error' | 'info'; text: string }; + +export const toasts = $state([]); +let next = 1; + +export function toast(text: string, kind: Toast['kind'] = 'ok') { + const id = next++; + toasts.push({ id, kind, text }); + setTimeout(() => dismiss(id), kind === 'error' ? 6000 : 3200); +} + +export function dismiss(id: number) { + const i = toasts.findIndex((t) => t.id === id); + if (i >= 0) toasts.splice(i, 1); +} + +export function toastError(e: unknown) { + toast(e instanceof Error ? e.message : String(e), 'error'); +} diff --git a/panel/web/src/lib/types.ts b/panel/web/src/lib/types.ts new file mode 100644 index 0000000..9c4bf6d --- /dev/null +++ b/panel/web/src/lib/types.ts @@ -0,0 +1,80 @@ +export interface MemoryDefaults { min_mb: number; max_mb: number } +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: string; + loader_version: string | null; + source_kind: string; + source_label: string; + source_ref: any; + visibility: 'public' | 'members' | 'groups'; + allowed_groups: string[]; + memory: MemoryDefaults; + jvm_args: string; + server: ServerEntry | null; + featured: boolean; + enabled: boolean; + sort: number; + revision: number; + created_at: string; + updated_at: string; + file_count: number; + total_size: number; + missing_count: number; +} + +export interface FileRow { path: string; url: string; sha1: string; size: number; origin: string; note: string | null } + +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 SocialLink { label: string; url: string; icon: string } + +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: SocialLink[]; + features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean }; + custom_css: string; +} + +export interface User { + id: number; + username: string; + email: string | null; + role: 'admin' | 'player'; + status: 'active' | 'pending' | 'disabled'; + created_at: string; + last_login: string | null; + uuid: string; + groups: string[]; +} + +export interface Group { id: number; name: string; color: string; members: number } + +export interface Settings { + auth: { + panel_accounts: boolean; + registration: 'closed' | 'open' | 'approval'; + microsoft: boolean; + microsoft_client_id: string | null; + offline_local: boolean; + }; + curseforge_api_key?: string | null; + launcher_download_url: string | null; + curseforge_key_set: boolean; + curseforge_key_from_env: boolean; +} diff --git a/panel/web/src/main.ts b/panel/web/src/main.ts new file mode 100644 index 0000000..a888b19 --- /dev/null +++ b/panel/web/src/main.ts @@ -0,0 +1,11 @@ +import '@fontsource-variable/outfit'; +import '@fontsource-variable/jetbrains-mono'; +// Launcher fonts, so the design preview matches exactly. +import '@fontsource-variable/inter'; +import '@fontsource-variable/space-grotesk'; +import '@fontsource-variable/plus-jakarta-sans'; +import './app.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; + +mount(App, { target: document.getElementById('app')! }); diff --git a/panel/web/src/pages/Branding.svelte b/panel/web/src/pages/Branding.svelte new file mode 100644 index 0000000..6117470 --- /dev/null +++ b/panel/web/src/pages/Branding.svelte @@ -0,0 +1,252 @@ + + +
+
+
+

Launcher design

+

Everything here updates every player's launcher live — no rebuild or reinstall.

+
+
+ {#if dirty}{/if} + +
+
+ + {#if b} +
+
+
+ {#each sections as s}{/each} +
+ + {#if section === 'identity'} +
+ + + + + +
+ {:else if section === 'colors'} +
+
+ {#each presets as p} + + {/each} +
+
+ + + + + + + + +
+
+ {:else if section === 'background'} +
+
+ {#each ['gradient', 'image', 'video'] as k} + + {/each} +
+ {#if b.background.kind !== 'gradient'} + + {:else} +

A plain background in your background colour. Clean, and the lightest option on player PCs.

+ {/if} + + +
+ {:else if section === 'style'} +
+
+ Font +
+ {#each fonts as f} + + {/each} +
+
+ + +
+ {:else if section === 'news'} +
+ + {#each b.news as n, i (n.id)} +
(dragIndex = i)} ondragover={(e) => e.preventDefault()} ondrop={() => dropNews(i)}> +
+ + + + +
+ +
+ + + +
+ +
+ {:else} +

No news yet

Posts show up on the launcher home screen.

+ {/each} +
+ {:else if section === 'links'} +
+ {#each b.links as l, i} + + {/each} + +
+ {:else if section === 'features'} +
+ + + + +
+ {:else if section === 'css'} +
+

Injected into the launcher after the built-in styles. Useful variables: --accent, --surface, --radius.

+ +
+ {/if} +
+ +
+
+ Live preview + +
+
+
+ {/if} +
+ + diff --git a/panel/web/src/pages/Dashboard.svelte b/panel/web/src/pages/Dashboard.svelte new file mode 100644 index 0000000..701fe70 --- /dev/null +++ b/panel/web/src/pages/Dashboard.svelte @@ -0,0 +1,150 @@ + + +
+
+
+

{greeting}, {session.user?.username}

+

Here's what's happening with your launcher.

+
+ {#if stats?.launcher_download_url} + Launcher download + {/if} +
+ +
+ {#each [ + { label: 'Active players (7d)', value: stats?.players_7d, icon: Activity, tone: 'accent' }, + { label: 'Accounts', value: stats?.users, icon: Users, tone: 'cyan' }, + { label: 'Awaiting approval', value: stats?.pending, icon: UserCheck, tone: 'warn', href: '#/users' }, + { label: 'Instances', value: stats?.instances, icon: Boxes, tone: 'green', href: '#/instances' }, + ] as s} + + + {s.value ?? '—'} + {s.label} + + {/each} +
+ +
+
+
+

Game launches

+ {total.toLocaleString()} in the last 14 days +
+ {#if stats} + + {:else} +
+ {/if} +
+ +
+

Getting started

+
+ {#each steps as s} + + {#if s.done}{:else}{/if} + {s.label} + + + {/each} +
+
+ +
+

Popular instances

+ {#if stats?.top_instances?.length} +
+ {#each stats.top_instances as t} + {@const pct = (t.launches / stats.top_instances[0].launches) * 100} +
+ {nameOf(t.instance_id)} + + {t.launches} +
+ {/each} +
+ {:else} +

No launches yet. Stats appear once players start the game.

+ {/if} +
+ +
+

Recent activity

+ {#if stats?.recent?.length} +
+ {#each stats.recent as r} +
+ + {r.username ?? 'Someone'} launched {nameOf(r.instance_id)} + {timeAgo(r.at)} +
+ {/each} +
+ {:else} +

Nothing yet.

+ {/if} +
+
+
+ + diff --git a/panel/web/src/pages/InstanceEditor.svelte b/panel/web/src/pages/InstanceEditor.svelte new file mode 100644 index 0000000..86076d5 --- /dev/null +++ b/panel/web/src/pages/InstanceEditor.svelte @@ -0,0 +1,560 @@ + + +{#if form && inst} +
+ All instances +
+
+
{#if form.icon_url}{:else}{/if}
+
+

{inst.name}

+

+ {inst.mc_version} + {loaderLabel(inst.loader)} {inst.loader_version?.replace(`${inst.mc_version}-`, '') ?? ''} + {inst.source_label || 'Vanilla'} + revision {inst.revision} +

+
+
+ +
+ +
+ {#each tabs as t} + + {/each} +
+ + {#if tab === 'general'} +
+
+

Details

+ + + + +
+
+

Performance defaults

+

Players can change these in their settings; these are the starting values.

+ + + +
+
+ {:else if tab === 'source'} +
+ +
+ {inst.source_label || 'Vanilla'} + Minecraft {inst.mc_version} · {loaderLabel(inst.loader)} {inst.loader_version ?? ''} · {inst.file_count} files ({formatBytes(inst.total_size)}) +
+ {#if inst.source_kind !== 'vanilla'} + + {/if} +
+ +
+ + + + +
+ + {#if sourceMode === 'version'} +
+

+ {#if inst.source_kind !== 'vanilla'}Changing versions here overrides what the modpack declared — only do this if you know the pack supports it.{:else}Pick the game version and (optionally) a mod loader. Add mods in the Files tab.{/if} +

+ +
+ {:else if sourceMode === 'upload'} +
+
{ e.preventDefault(); dragging = true; }} + ondragleave={() => (dragging = false)} + ondrop={onDrop} + onclick={() => document.getElementById('pack-file')?.click()} + onkeydown={(e) => e.key === 'Enter' && document.getElementById('pack-file')?.click()} + > + + {#if uploadFile} + {uploadFile.name}{formatBytes(uploadFile.size)} + {:else} + Drop a modpack here + .mrpack, CurseForge .zip, or any instance folder zipped up + {/if} +
+ (uploadFile = (e.target as HTMLInputElement).files?.[0] ?? null)} /> +
+ Plain zip? Choose its Minecraft version and loader +
+
+ +
+ {:else} +
+
{ e.preventDefault(); search(); }}> + + +
+ + {#if selected} +
+ +
+ {#if selected.icon}{/if} +

{selected.title}

{selected.text}

+
+ {#if loadingVersions} +

Loading versions…

+ {:else} +
+ {#each versions as v} +
+
{v.name}{v.meta}
+ {v.date ? new Date(v.date).toLocaleDateString() : ''} + +
+ {/each} +
+ {/if} +
+ {:else if results.length} +
+ {#each results as r} + + {/each} +
+ {:else} +

+ {sourceMode === 'curseforge' ? 'CurseForge needs an API key (Settings → Integrations). ' : ''}Search for a modpack, then pick a version. Mods download straight from the CDN; configs are hosted by this panel. +

+ {/if} +
+ {/if} + {:else if tab === 'files'} + {#if missing.length} +
+
{missing.length} file{missing.length > 1 ? 's' : ''} need a manual upload
+

These CurseForge authors block third-party downloads. Download each file and upload it below into the same folder — it replaces the placeholder automatically.

+ {#each missing as m} +
+ {m.path} + + {#if noteLink(m.note)} Download page{/if} +
+ {/each} +
+ {/if} + +
+
+

{files.length} files

+ + + {#if folder === 'custom'}{/if} + + +
+ {#if files.length} +
+ + + + {#each files as f (f.path)} + + + + + + + {/each} + +
PathSourceSize
{f.path}{#if !f.url}missing{:else}{f.origin === 'pack' ? 'CDN' : f.origin === 'upload' ? 'uploaded' : 'config'}{/if}{formatBytes(f.size)}
+
+ {:else} +

No extra files. Vanilla instances don't need any — add mods, configs or resource packs here.

+ {/if} +
+ {:else if tab === 'server' && form.server} +
+

Built-in server

+

Adds your server to the in-game multiplayer list and shows its live status in the launcher.

+
+ + + +
+ + + {#if form.server.address} + + {/if} +
+ {:else if tab === 'access'} +
+
+

Who can see this instance?

+
+ {#each [ + { id: 'public', icon: Globe, title: 'Everyone', text: 'Including offline and Microsoft accounts' }, + { id: 'members', icon: UsersIcon, title: 'Signed-in players', text: 'Anyone with a panel account' }, + { id: 'groups', icon: Lock, title: 'Specific groups', text: 'Only members of the groups below' }, + ] as v} + + {/each} +
+ {#if form.visibility === 'groups'} +
+ {#each groups as g} + {@const on = form.allowed_groups.includes(g.name)} + + {:else} +

No groups yet — create them on the Players page.

+ {/each} +
+ {/if} +
+
+

Listing

+ + + +
+
+ {/if} +
+ + {#if dirty} +
+ You have unsaved changes + + +
+ {/if} + + {#if importing} +
+
+ +

Importing {importing}

+

Downloading and unpacking the pack. Large packs can take a minute.

+
+
+ {/if} + + +

The instance disappears from every launcher and its hosted files are deleted. Players keep their local worlds.

+ {#snippet footer()} + + + {/snippet} +
+{:else} +

Loading…

+{/if} + + diff --git a/panel/web/src/pages/Instances.svelte b/panel/web/src/pages/Instances.svelte new file mode 100644 index 0000000..4e347a7 --- /dev/null +++ b/panel/web/src/pages/Instances.svelte @@ -0,0 +1,147 @@ + + +
+
+
+

Instances

+

Each instance is a playable profile in the launcher: a Minecraft version or a modpack, plus an optional server.

+
+ +
+ + {#if instances === null} +
{#each [1, 2, 3] as _}
{/each}
+ {:else if instances.length === 0} +
+ +

No instances yet

+

Create one to give players something to launch.

+ +
+ {:else} + + {/if} +
+ + + + +
+ Start from +
+ {#each [ + { id: 'vanilla', title: 'Minecraft version', text: 'Vanilla or a bare mod loader' }, + { id: 'modpack', title: 'Modpack', text: 'Modrinth, CurseForge or a .zip' }, + ] as s} + + {/each} +
+
+ {#if draft.source === 'vanilla'} + + {:else} +

You'll pick the modpack on the next screen. Its Minecraft version and loader are set automatically.

+ {/if} + {#snippet footer()} + + + {/snippet} +
+ + diff --git a/panel/web/src/pages/Login.svelte b/panel/web/src/pages/Login.svelte new file mode 100644 index 0000000..9c64428 --- /dev/null +++ b/panel/web/src/pages/Login.svelte @@ -0,0 +1,53 @@ + + +
+ +
+ + diff --git a/panel/web/src/pages/Settings.svelte b/panel/web/src/pages/Settings.svelte new file mode 100644 index 0000000..797e239 --- /dev/null +++ b/panel/web/src/pages/Settings.svelte @@ -0,0 +1,109 @@ + + +
+
+
+

Settings

+

How players sign in, and connections to other services.

+
+ +
+ + {#if s} +
+

Panel accounts

+ + {#if s.auth.panel_accounts} +
+ Sign-ups from the launcher +
+ + + +
+ + {s.auth.registration === 'closed' ? 'Only admins create accounts.' : s.auth.registration === 'approval' ? 'Anyone can sign up; you approve them on the Players page.' : 'Anyone can create an account and play immediately.'} + +
+ {/if} +
+ +
+

Offline play

+ +
+ +
+

Microsoft accounts

+ + {#if s.auth.microsoft} + + {/if} +
+ +
+

Integrations

+ + +
+ {/if} +
+ + diff --git a/panel/web/src/pages/Users.svelte b/panel/web/src/pages/Users.svelte new file mode 100644 index 0000000..a388157 --- /dev/null +++ b/panel/web/src/pages/Users.svelte @@ -0,0 +1,247 @@ + + +
+
+
+

Players

+

Accounts sign in from the launcher and play with their username in offline mode.

+
+ +
+ + {#if pending.length} +
+ {pending.length} player{pending.length > 1 ? 's' : ''} waiting for approval +
+ {#each pending as u} + {u.username} + + + {/each} +
+
+ {/if} + +
+
+
+ + {users.length} accounts +
+
+ + + + {#each shown as u (u.id)} + + + + + + + + {/each} + +
PlayerRoleGroupsLast seen
+
+ +
+ {u.username}{#if u.id === session.user?.id} (you){/if} + {u.uuid} +
+
+
+ {#if u.role === 'admin'} Admin{:else}Player{/if} + {#if u.status === 'pending'}Pending{:else if u.status === 'disabled'}Disabled{/if} +
{#each u.groups as g}{g}{/each}
{timeAgo(u.last_login)} + + {#if u.id !== session.user?.id} + + {/if} +
+
+
+ +
+

Groups

+

Use groups to give VIPs, staff or testers access to private instances.

+
+ {#each groups as g (g.id)} +
+ + {g.name} + {g.members} member{g.members === 1 ? '' : 's'} + + +
+ {/each} +
+
+ + + +
+
+
+
+ + + {#if editing} + {#if !editing.id} + + {/if} + + +
+ + +
+ {#if groups.length} +
+ Groups +
+ {#each groups as g} + {@const on = editing.groups?.includes(g.name)} + + {/each} +
+
+ {/if} + {/if} + {#snippet footer()} + + + {/snippet} +
+ + +

They won't be able to sign in any more. This can't be undone.

+ {#snippet footer()} + + + {/snippet} +
+ + diff --git a/panel/web/svelte.config.js b/panel/web/svelte.config.js new file mode 100644 index 0000000..36c98fd --- /dev/null +++ b/panel/web/svelte.config.js @@ -0,0 +1,3 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { preprocess: vitePreprocess() }; diff --git a/panel/web/tsconfig.json b/panel/web/tsconfig.json new file mode 100644 index 0000000..2c84844 --- /dev/null +++ b/panel/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "types": ["svelte", "vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/panel/web/vite.config.ts b/panel/web/vite.config.ts new file mode 100644 index 0000000..482f0dc --- /dev/null +++ b/panel/web/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +// In dev, API calls are proxied to a locally running panel (cargo run -p scopenet-panel). +const target = process.env.PANEL_URL ?? 'http://localhost:8080'; + +export default defineConfig({ + plugins: [svelte()], + server: { + port: 5173, + proxy: { + '/api': target, + '/files': target, + '/uploads': target, + }, + }, + build: { + target: 'es2022', + cssCodeSplit: false, + chunkSizeWarningLimit: 800, + }, +}); diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..6677761 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 140 +use_small_heuristics = "Max"