Merge pull request #1 from scopeddlol/claude/minecraft-launcher-customizable-h5tojv
Add launcher engine (scopenet-core) and admin panel server
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
target
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
data
|
||||||
|
docs
|
||||||
|
launcher/src
|
||||||
|
launcher/src-tauri/icons
|
||||||
|
launcher/src-tauri/gen
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['**']
|
||||||
|
tags-ignore: ['**']
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
name: Rust (fmt, clippy, tests)
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install Tauri system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
- name: Placeholder launcher frontend (tauri::generate_context needs it)
|
||||||
|
run: mkdir -p launcher/dist && echo '<!doctype html>' > launcher/dist/index.html
|
||||||
|
- run: cargo fmt --all --check
|
||||||
|
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
|
- run: cargo test --workspace --locked
|
||||||
|
|
||||||
|
web:
|
||||||
|
name: Web UIs (type-check + build)
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
app: [panel/web, launcher]
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ${{ matrix.app }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: ${{ matrix.app }}/package-lock.json
|
||||||
|
- run: npm ci --no-audit --no-fund
|
||||||
|
- run: npm run check
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
launcher-windows:
|
||||||
|
name: Launcher (Windows build check)
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: launcher/package-lock.json
|
||||||
|
- run: npm ci --no-audit --no-fund
|
||||||
|
working-directory: launcher
|
||||||
|
- run: npm run build
|
||||||
|
working-directory: launcher
|
||||||
|
- run: cargo clippy -p scopenet-launcher --locked -- -D warnings
|
||||||
|
- run: cargo test -p scopenet-launcher -p scopenet-core -p scopenet-shared --locked
|
||||||
|
|
||||||
|
engine-online:
|
||||||
|
name: Real installs (Mojang, Fabric, Quilt, Forge, NeoForge)
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
# Talks to third-party servers; a hiccup there shouldn't block merges.
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Install games (vanilla, legacy, Fabric, Quilt, Forge, NeoForge)
|
||||||
|
run: cargo test -p scopenet-core --test online --locked -- --ignored --test-threads=2 --nocapture
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: Docker image builds
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: false
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Publish a release by pushing a tag (`git tag v0.2.0 && git push origin v0.2.0`)
|
||||||
|
# or from the Actions tab → Release → Run workflow (enter a version).
|
||||||
|
#
|
||||||
|
# Optional repository variables (Settings → Secrets and variables → Actions → Variables):
|
||||||
|
# PANEL_URL https://panel.example.com — baked into the launcher as the default server
|
||||||
|
# LOCK_PANEL "1" to stop players from changing the server
|
||||||
|
# LAUNCHER_NAME Installer / window name (default "SCOPENET Launcher")
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*.*.*']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to release (e.g. 0.2.0)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
tag: ${{ steps.v.outputs.tag }}
|
||||||
|
steps:
|
||||||
|
- id: v
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then v="${{ inputs.version }}"; else v="${GITHUB_REF_NAME}"; fi
|
||||||
|
v="${v#v}"
|
||||||
|
if ! [[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then echo "::error::'$v' is not a semantic version"; exit 1; fi
|
||||||
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=v$v" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
launcher:
|
||||||
|
name: Windows installer
|
||||||
|
needs: version
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
key: release
|
||||||
|
- uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: launcher/package-lock.json
|
||||||
|
|
||||||
|
- name: Stamp version
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
sed -i "0,/^version = \".*\"/s//version = \"$VERSION\"/" Cargo.toml
|
||||||
|
node -e "const f='launcher/src-tauri/tauri.conf.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))"
|
||||||
|
node -e "const f='launcher/package.json';const c=require('./'+f);c.version=process.env.VERSION;require('fs').writeFileSync(f,JSON.stringify(c,null,2))"
|
||||||
|
|
||||||
|
- run: npm ci --no-audit --no-fund
|
||||||
|
working-directory: launcher
|
||||||
|
|
||||||
|
- name: Build installer
|
||||||
|
working-directory: launcher
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SCOPENET_PANEL_URL: ${{ vars.PANEL_URL }}
|
||||||
|
SCOPENET_LOCK_PANEL: ${{ vars.LOCK_PANEL }}
|
||||||
|
SCOPENET_REPO: ${{ github.repository }}
|
||||||
|
LAUNCHER_NAME: ${{ vars.LAUNCHER_NAME }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$LAUNCHER_NAME" ]; then
|
||||||
|
CONFIG=$(node -e "console.log(JSON.stringify({productName: process.env.LAUNCHER_NAME, app: {windows: [{...require('./src-tauri/tauri.conf.json').app.windows[0], title: process.env.LAUNCHER_NAME}]}}))")
|
||||||
|
npx tauri build --bundles nsis --config "$CONFIG"
|
||||||
|
else
|
||||||
|
npx tauri build --bundles nsis
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Collect installer
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p out
|
||||||
|
cp target/release/bundle/nsis/*-setup.exe out/
|
||||||
|
ls -la out
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-installer
|
||||||
|
path: out/*-setup.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
panel-image:
|
||||||
|
name: Panel image (GHCR)
|
||||||
|
needs: version
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- name: Image name (GHCR needs lowercase)
|
||||||
|
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel" >> "$GITHUB_ENV"
|
||||||
|
- name: Stamp version
|
||||||
|
run: sed -i "0,/^version = \".*\"/s//version = \"${{ needs.version.outputs.version }}\"/" Cargo.toml
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=${{ needs.version.outputs.tag }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},value=${{ needs.version.outputs.tag }}
|
||||||
|
type=raw,value=latest,enable=${{ !contains(needs.version.outputs.version, '-') }}
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: GitHub release
|
||||||
|
needs: [version, launcher, panel-image]
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-installer
|
||||||
|
path: dist
|
||||||
|
- name: Release notes
|
||||||
|
run: |
|
||||||
|
IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
|
||||||
|
cat > notes.md <<NOTES
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically.
|
||||||
|
|
||||||
|
**Admin panel (Docker):**
|
||||||
|
\`\`\`bash
|
||||||
|
docker pull ${IMAGE}:${{ needs.version.outputs.version }}
|
||||||
|
\`\`\`
|
||||||
|
Or with Compose: set \`image: ${IMAGE}:${{ needs.version.outputs.version }}\` in \`docker-compose.yml\` and run \`docker compose up -d\`.
|
||||||
|
NOTES
|
||||||
|
- uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ needs.version.outputs.tag }}
|
||||||
|
name: ${{ needs.version.outputs.tag }}
|
||||||
|
target_commitish: ${{ github.sha }}
|
||||||
|
body_path: notes.md
|
||||||
|
generate_release_notes: true
|
||||||
|
prerelease: ${{ contains(needs.version.outputs.version, '-') }}
|
||||||
|
files: dist/*-setup.exe
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/target
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
/data
|
||||||
|
/panel/server/data
|
||||||
|
launcher/src-tauri/gen/schemas
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
repository = "https://github.com/scopeddlol/SCOPENET-MC"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
scopenet-shared = { path = "crates/shared" }
|
||||||
|
scopenet-core = { path = "crates/core" }
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "net", "time", "sync", "signal"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] }
|
||||||
|
futures = "0.3"
|
||||||
|
sha1 = "0.10"
|
||||||
|
md-5 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
||||||
|
base64 = "0.22"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
# Small, fast binaries: the launcher and the panel both ship lean.
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
opt-level = 1
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# SCOPENET admin panel — a single static binary + the web UI on `scratch`.
|
||||||
|
# Multi-arch (amd64/arm64) is cross-compiled with cargo-zigbuild on the
|
||||||
|
# build machine's native arch, so no slow QEMU emulation is needed.
|
||||||
|
|
||||||
|
# ---- 1. Web UI ---------------------------------------------------------------
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
|
||||||
|
WORKDIR /src/panel/web
|
||||||
|
COPY panel/web/package.json panel/web/package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY panel/web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- 2. Server binary --------------------------------------------------------
|
||||||
|
FROM --platform=$BUILDPLATFORM rust:1-slim-bookworm AS build
|
||||||
|
ARG TARGETARCH
|
||||||
|
# zig (via the cargo-zigbuild wheel) is the cross C toolchain for musl targets.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends python3-pip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& pip install --no-cache-dir --break-system-packages cargo-zigbuild
|
||||||
|
RUN case "$TARGETARCH" in \
|
||||||
|
amd64) echo x86_64-unknown-linux-musl ;; \
|
||||||
|
arm64) echo aarch64-unknown-linux-musl ;; \
|
||||||
|
*) echo "unsupported arch $TARGETARCH" >&2; exit 1 ;; \
|
||||||
|
esac > /rust-target \
|
||||||
|
&& rustup target add "$(cat /rust-target)"
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
# No --locked: release builds stamp the version into Cargo.toml. Dependencies
|
||||||
|
# stay pinned by Cargo.lock either way (CI enforces --locked).
|
||||||
|
RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry,sharing=locked \
|
||||||
|
--mount=type=cache,target=/src/target,id=scopenet-target-$TARGETARCH \
|
||||||
|
cargo zigbuild --release -p scopenet-panel --target "$(cat /rust-target)" \
|
||||||
|
&& cp "target/$(cat /rust-target)/release/scopenet-panel" /scopenet-panel
|
||||||
|
# Data dir owned by the non-root runtime user.
|
||||||
|
RUN mkdir -p /out/data && chown 65532:65532 /out/data
|
||||||
|
|
||||||
|
# ---- 3. Runtime --------------------------------------------------------------
|
||||||
|
FROM scratch
|
||||||
|
LABEL org.opencontainers.image.title="SCOPENET Panel" \
|
||||||
|
org.opencontainers.image.description="Admin panel for the SCOPENET Minecraft launcher" \
|
||||||
|
org.opencontainers.image.source="https://github.com/scopeddlol/SCOPENET-MC"
|
||||||
|
COPY --from=build /scopenet-panel /scopenet-panel
|
||||||
|
COPY --from=web /src/panel/web/dist /web
|
||||||
|
COPY --from=build --chown=65532:65532 /out/data /data
|
||||||
|
ENV SCOPENET_BIND=0.0.0.0:8080 \
|
||||||
|
SCOPENET_DATA_DIR=/data \
|
||||||
|
SCOPENET_WEB_DIR=/web \
|
||||||
|
RUST_LOG=info
|
||||||
|
USER 65532:65532
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/scopenet-panel", "healthcheck"]
|
||||||
|
ENTRYPOINT ["/scopenet-panel"]
|
||||||
@@ -1,2 +1,121 @@
|
|||||||
# SCOPENET-MC
|
<div align="center">
|
||||||
A Windows/Linux Minecraft Launcher with a Docker-hosted admin panel.
|
|
||||||
|
<img src="launcher/src-tauri/icons/128x128.png" width="96" alt="" />
|
||||||
|
|
||||||
|
# SCOPENET
|
||||||
|
|
||||||
|
**A fast, fully customisable Minecraft launcher — and the self-hosted admin panel that controls it.**
|
||||||
|
|
||||||
|
Brand it, publish vanilla versions or modpacks, manage players, and push changes to every launcher instantly.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/launcher-home.webp" width="49%" alt="Launcher home" />
|
||||||
|
<img src="docs/screenshots/panel-design.webp" width="49%" alt="Panel launcher designer" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## What's inside
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Launcher** (Windows) | Tauri 2 + Rust + Svelte. Uses the system WebView2 instead of bundling Chromium, so the installer stays small and memory use low. |
|
||||||
|
| **Admin panel** (Docker) | Rust (Axum) + SQLite + Svelte. 14 MB image; measured ~2 MB RAM at idle. |
|
||||||
|
| **Engine** | `scopenet-core`: installs Minecraft, Java, Fabric/Quilt/Forge/NeoForge and launches the game. |
|
||||||
|
|
||||||
|
### Launcher features
|
||||||
|
|
||||||
|
- **Accounts** — panel accounts (username + password, offline-mode play), **Microsoft** sign-in, and local **offline** usernames. Tokens are encrypted with a key stored in Windows Credential Manager.
|
||||||
|
- **Instances** — every instance the admin publishes shows up in the sidebar; group-restricted ones only for the right players.
|
||||||
|
- **One-click play** — downloads the right **Java automatically**, the game, the mod loader and the modpack, with live speed/ETA. Files are shared between instances and verified by SHA-1.
|
||||||
|
- **Built-in server** — the instance's server is added to the multiplayer list, **auto-joined** (Quick Play on 1.20+), and its live status (players, MOTD, ping) is shown on the home screen.
|
||||||
|
- **Settings** — memory with a recommendation for your PC, resolution, fullscreen, what happens on launch, Java path, GC presets (G1 / ZGC), JVM args, **Minecraft keybind editor** (applied to every instance), custom accent colour, glass/animation toggles, UI scale, storage manager, per-instance overrides.
|
||||||
|
- **Quality of life** — game console, crash dialog with plain-English hints, repair files, offline launch from cache, self-updates from GitHub Releases, single-instance window, `Ctrl+Enter` to play.
|
||||||
|
- **Branding from the panel** — name, logo, colours, font, corner radius, glass, image/video background, news feed, social links, custom CSS. No rebuild needed.
|
||||||
|
|
||||||
|
### Admin panel features
|
||||||
|
|
||||||
|
- **Instances** — pick a Minecraft version + loader, or import a modpack from **Modrinth**, **CurseForge** or a **.zip** (`.mrpack`, CurseForge export, or any instance folder). Upload extra mods/configs. CurseForge mods that block third-party downloads are flagged with a download link.
|
||||||
|
- **Access control** — public, signed-in players, or specific **groups**.
|
||||||
|
- **Players** — create/approve/disable accounts, roles, groups; sign-ups closed / approval / open.
|
||||||
|
- **Launcher design** — every branding option with a **live preview** and 7 colour presets.
|
||||||
|
- **Dashboard** — launches per day, active players, popular instances, recent activity.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/launcher-install.webp" width="32%" alt="" />
|
||||||
|
<img src="docs/screenshots/launcher-controls.webp" width="32%" alt="" />
|
||||||
|
<img src="docs/screenshots/panel-dashboard.webp" width="32%" alt="" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### 1. Run the panel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -O https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/docker-compose.yml
|
||||||
|
curl -o .env https://raw.githubusercontent.com/scopeddlol/SCOPENET-MC/main/.env.example
|
||||||
|
# edit .env → set ADMIN_PASSWORD
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://your-server:8080`, sign in as `admin`, then:
|
||||||
|
|
||||||
|
1. **Launcher design** → name, logo, colours, news.
|
||||||
|
2. **Instances** → *New instance* → a version, or a Modrinth / CurseForge / .zip modpack.
|
||||||
|
3. **Settings** → how players sign in.
|
||||||
|
|
||||||
|
> Put the panel behind HTTPS (Caddy, Traefik, nginx, Cloudflare Tunnel…) before sharing it. See [docs/admin-guide.md](docs/admin-guide.md).
|
||||||
|
|
||||||
|
### 2. Build your branded launcher
|
||||||
|
|
||||||
|
1. In GitHub → **Settings → Secrets and variables → Actions → Variables**, add:
|
||||||
|
- `PANEL_URL` = `https://panel.example.com` (players connect automatically)
|
||||||
|
- `LOCK_PANEL` = `1` (optional: hide the "change server" option)
|
||||||
|
- `LAUNCHER_NAME` = `MyServer Launcher` (optional)
|
||||||
|
2. **Actions → Release → Run workflow**, enter a version like `1.0.0` — or push a tag `v1.0.0`.
|
||||||
|
|
||||||
|
The workflow builds the **Windows installer**, pushes the **panel image to GHCR** (`ghcr.io/<owner>/scopenet-mc-panel`, amd64 + arm64) and publishes a **GitHub release**. Installed launchers pick up new releases automatically.
|
||||||
|
|
||||||
|
> The GHCR package is private by default the first time — make it public under your GitHub profile → Packages if you want `docker pull` without logging in.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/shared Wire types shared by launcher and panel (manifest, branding, auth)
|
||||||
|
crates/core Launcher engine: Mojang/Java/loaders, file sync, launch, MS auth, ping
|
||||||
|
panel/server Admin panel API (Axum + SQLite)
|
||||||
|
panel/web Admin panel UI (Svelte 5)
|
||||||
|
launcher/ Desktop launcher UI (Svelte 5)
|
||||||
|
launcher/src-tauri Desktop launcher shell (Tauri 2)
|
||||||
|
Dockerfile Panel image (static binary on scratch)
|
||||||
|
docker-compose.yml Panel deployment
|
||||||
|
.github/workflows CI + release (installer, GitHub release, GHCR image)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
|
||||||
|
- [Microsoft sign-in setup](docs/microsoft-auth.md) — Azure app registration
|
||||||
|
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
|
||||||
|
- [Development](docs/development.md) — running everything locally, tests
|
||||||
|
|
||||||
|
## Configuration reference
|
||||||
|
|
||||||
|
**Panel (environment variables)**
|
||||||
|
|
||||||
|
| Variable | Default | |
|
||||||
|
|---|---|---|
|
||||||
|
| `ADMIN_USERNAME` | `admin` | First admin account (created on first start) |
|
||||||
|
| `ADMIN_PASSWORD` | random, printed in logs | First admin password |
|
||||||
|
| `JWT_SECRET` | auto-generated in `/data` | Signs login tokens |
|
||||||
|
| `CURSEFORGE_API_KEY` | – | Also settable in the panel |
|
||||||
|
| `MAX_UPLOAD_MB` | `2048` | Largest modpack upload |
|
||||||
|
| `SCOPENET_BIND` | `0.0.0.0:8080` | Listen address |
|
||||||
|
| `SCOPENET_DATA_DIR` | `/data` | Database, hosted files, uploads |
|
||||||
|
|
||||||
|
**Launcher (build-time, via repository variables)** — `PANEL_URL`, `LOCK_PANEL`, `LAUNCHER_NAME`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Minecraft is a trademark of Mojang Studios. SCOPENET is not affiliated with Mojang or Microsoft. Offline mode is intended for your own community servers.
|
||||||
|
- No license has been chosen yet — add a `LICENSE` file before distributing.
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! Game assets (sounds, languages, textures) — content-addressed objects.
|
||||||
|
|
||||||
|
use crate::http::{self, Download};
|
||||||
|
use crate::paths::Layout;
|
||||||
|
use crate::version::AssetIndexRef;
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub const RESOURCES: &str = "https://resources.download.minecraft.net";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AssetIndex {
|
||||||
|
pub objects: HashMap<String, AssetObject>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub map_to_resources: bool,
|
||||||
|
#[serde(default, rename = "virtual")]
|
||||||
|
pub is_virtual: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct AssetObject {
|
||||||
|
pub hash: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn object_path(layout: &Layout, hash: &str) -> PathBuf {
|
||||||
|
layout.assets().join("objects").join(&hash[..2]).join(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load (downloading if needed) the asset index.
|
||||||
|
pub async fn load_index(client: &reqwest::Client, layout: &Layout, index: &AssetIndexRef) -> Result<AssetIndex> {
|
||||||
|
let path = layout.assets().join("indexes").join(format!("{}.json", index.id));
|
||||||
|
if !http::file_ok(&path, index.sha1.as_deref(), index.size, true) {
|
||||||
|
http::download_one(client, &Download::new(index.url.clone(), path.clone(), index.sha1.clone(), index.size), &|_| {}).await?;
|
||||||
|
}
|
||||||
|
Ok(serde_json::from_slice(&std::fs::read(&path)?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads needed for missing/corrupt objects.
|
||||||
|
pub fn missing_objects(layout: &Layout, index: &AssetIndex, deep: bool) -> Vec<Download> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
index
|
||||||
|
.objects
|
||||||
|
.values()
|
||||||
|
.filter(|o| o.hash.len() > 2 && seen.insert(o.hash.clone()))
|
||||||
|
.filter_map(|o| {
|
||||||
|
let path = object_path(layout, &o.hash);
|
||||||
|
if http::file_ok(&path, Some(&o.hash), Some(o.size), deep) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Download::new(format!("{RESOURCES}/{}/{}", &o.hash[..2], o.hash), path, Some(o.hash.clone()), Some(o.size)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-1.7 versions read assets by name, not hash. Copy them into the
|
||||||
|
/// virtual (or `resources/`) directory. Returns the directory to pass as
|
||||||
|
/// `game_assets`.
|
||||||
|
pub fn materialize_legacy(layout: &Layout, index_id: &str, index: &AssetIndex, game_dir: &Path) -> Result<Option<PathBuf>> {
|
||||||
|
if !index.is_virtual && !index.map_to_resources {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let target = if index.map_to_resources { game_dir.join("resources") } else { layout.assets().join("virtual").join(index_id) };
|
||||||
|
for (name, obj) in &index.objects {
|
||||||
|
let Some(dest) = crate::paths::safe_join(&target, name) else { continue };
|
||||||
|
if dest.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::copy(object_path(layout, &obj.hash), &dest).ok();
|
||||||
|
}
|
||||||
|
Ok(Some(target))
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! HTTP helpers: a shared client, JSON fetches and verified, resumable-safe
|
||||||
|
//! parallel downloads.
|
||||||
|
|
||||||
|
use crate::progress::{Event, Reporter, Stage};
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
pub const USER_AGENT: &str = concat!("SCOPENET-Launcher/", env!("CARGO_PKG_VERSION"), " (+https://github.com/scopeddlol/SCOPENET-MC)");
|
||||||
|
|
||||||
|
pub fn client() -> reqwest::Client {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.user_agent(USER_AGENT)
|
||||||
|
.connect_timeout(Duration::from_secs(15))
|
||||||
|
.read_timeout(Duration::from_secs(60))
|
||||||
|
.pool_max_idle_per_host(16)
|
||||||
|
.build()
|
||||||
|
.expect("http client")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_json<T: DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
|
||||||
|
let resp = client.get(url).send().await.with_context(|| format!("GET {url}"))?;
|
||||||
|
let status = resp.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
bail!("GET {url} returned {status}");
|
||||||
|
}
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
serde_json::from_slice(&bytes).with_context(|| format!("parsing JSON from {url}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch JSON, caching it at `cache`. Falls back to the cached copy when
|
||||||
|
/// offline so the game can still launch without internet.
|
||||||
|
pub async fn get_json_cached<T: DeserializeOwned>(client: &reqwest::Client, url: &str, cache: &Path) -> Result<T> {
|
||||||
|
match client.get(url).send().await.and_then(|r| r.error_for_status()) {
|
||||||
|
Ok(resp) => {
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
let parsed = serde_json::from_slice(&bytes).with_context(|| format!("parsing JSON from {url}"))?;
|
||||||
|
if let Some(dir) = cache.parent() {
|
||||||
|
tokio::fs::create_dir_all(dir).await.ok();
|
||||||
|
}
|
||||||
|
tokio::fs::write(cache, &bytes).await.ok();
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let bytes = tokio::fs::read(cache).await.map_err(|_| anyhow::anyhow!("{err} (and no cached copy)"))?;
|
||||||
|
tracing::warn!("{url} unreachable, using cached copy: {err}");
|
||||||
|
Ok(serde_json::from_slice(&bytes)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha1_file(path: &Path) -> Result<String> {
|
||||||
|
let mut file = std::fs::File::open(path)?;
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
let mut buf = vec![0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buf[..n]);
|
||||||
|
}
|
||||||
|
Ok(hex::encode(hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha1_bytes(bytes: &[u8]) -> String {
|
||||||
|
hex::encode(Sha1::digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap check used before every launch: size match (and hash match when
|
||||||
|
/// `deep` is set).
|
||||||
|
pub fn file_ok(path: &Path, sha1: Option<&str>, size: Option<u64>, deep: bool) -> bool {
|
||||||
|
let Ok(meta) = std::fs::metadata(path) else { return false };
|
||||||
|
if let Some(size) = size {
|
||||||
|
if size > 0 && meta.len() != size {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deep {
|
||||||
|
if let Some(expected) = sha1.filter(|s| !s.is_empty()) {
|
||||||
|
return sha1_file(path).map(|h| h.eq_ignore_ascii_case(expected)).unwrap_or(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Download {
|
||||||
|
pub url: String,
|
||||||
|
pub dest: PathBuf,
|
||||||
|
pub sha1: Option<String>,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Download {
|
||||||
|
pub fn new(url: impl Into<String>, dest: PathBuf, sha1: Option<String>, size: Option<u64>) -> Self {
|
||||||
|
Self { url: url.into(), dest, sha1: sha1.filter(|s| !s.is_empty()), size: size.unwrap_or(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a single file to `dest` atomically (via a `.part` file) and
|
||||||
|
/// verify its SHA-1. Retries transient failures.
|
||||||
|
pub async fn download_one(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn Fn(u64) + Sync)) -> Result<()> {
|
||||||
|
let mut last_err = None;
|
||||||
|
for attempt in 0..4u32 {
|
||||||
|
if attempt > 0 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(400 * 2u64.pow(attempt))).await;
|
||||||
|
}
|
||||||
|
match try_download(client, dl, on_bytes).await {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("download {} failed (attempt {}): {e:#}", dl.url, attempt + 1);
|
||||||
|
last_err = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_err.unwrap()).with_context(|| format!("downloading {}", dl.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_download(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn Fn(u64) + Sync)) -> Result<()> {
|
||||||
|
if let Some(dir) = dl.dest.parent() {
|
||||||
|
tokio::fs::create_dir_all(dir).await?;
|
||||||
|
}
|
||||||
|
let resp = client.get(&dl.url).send().await?.error_for_status()?;
|
||||||
|
let part =
|
||||||
|
dl.dest.with_extension(format!("{}part", dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()));
|
||||||
|
let mut file = tokio::fs::File::create(&part).await?;
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
let mut stream = resp.bytes_stream();
|
||||||
|
let mut written = 0u64;
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk?;
|
||||||
|
hasher.update(&chunk);
|
||||||
|
file.write_all(&chunk).await?;
|
||||||
|
written += chunk.len() as u64;
|
||||||
|
on_bytes(chunk.len() as u64);
|
||||||
|
}
|
||||||
|
file.flush().await?;
|
||||||
|
drop(file);
|
||||||
|
if let Some(expected) = &dl.sha1 {
|
||||||
|
let got = hex::encode(hasher.finalize());
|
||||||
|
if !got.eq_ignore_ascii_case(expected) {
|
||||||
|
tokio::fs::remove_file(&part).await.ok();
|
||||||
|
bail!("checksum mismatch for {} (expected {expected}, got {got}, {written} bytes)", dl.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&part, &dl.dest).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download many files in parallel, reporting aggregate byte progress.
|
||||||
|
pub async fn download_all(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
downloads: Vec<Download>,
|
||||||
|
concurrency: usize,
|
||||||
|
stage: Stage,
|
||||||
|
reporter: &Reporter,
|
||||||
|
) -> Result<()> {
|
||||||
|
if downloads.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let total: u64 = downloads.iter().map(|d| d.size).sum();
|
||||||
|
let files_total = downloads.len() as u32;
|
||||||
|
let done = Arc::new(AtomicU64::new(0));
|
||||||
|
let files_done = Arc::new(AtomicU32::new(0));
|
||||||
|
let last_emit = Arc::new(std::sync::Mutex::new(Instant::now() - Duration::from_secs(1)));
|
||||||
|
|
||||||
|
let emit = {
|
||||||
|
let done = done.clone();
|
||||||
|
let files_done = files_done.clone();
|
||||||
|
let reporter = reporter.clone();
|
||||||
|
move |force: bool| {
|
||||||
|
let mut last = last_emit.lock().unwrap();
|
||||||
|
if force || last.elapsed() >= Duration::from_millis(100) {
|
||||||
|
*last = Instant::now();
|
||||||
|
let d = done.load(Ordering::Relaxed);
|
||||||
|
reporter(Event::Progress {
|
||||||
|
stage,
|
||||||
|
done: d,
|
||||||
|
total: total.max(d),
|
||||||
|
files_done: files_done.load(Ordering::Relaxed),
|
||||||
|
files_total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
emit(true);
|
||||||
|
|
||||||
|
let results: Vec<Result<()>> = futures::stream::iter(downloads.into_iter().map(|dl| {
|
||||||
|
let done = done.clone();
|
||||||
|
let files_done = files_done.clone();
|
||||||
|
let emit = &emit;
|
||||||
|
async move {
|
||||||
|
let on_bytes = |n: u64| {
|
||||||
|
done.fetch_add(n, Ordering::Relaxed);
|
||||||
|
emit(false);
|
||||||
|
};
|
||||||
|
let r = download_one(client, &dl, &on_bytes).await;
|
||||||
|
files_done.fetch_add(1, Ordering::Relaxed);
|
||||||
|
emit(false);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.buffer_unordered(concurrency.clamp(1, 64))
|
||||||
|
.collect()
|
||||||
|
.await;
|
||||||
|
emit(true);
|
||||||
|
|
||||||
|
let errors: Vec<_> = results.into_iter().filter_map(|r| r.err()).collect();
|
||||||
|
if let Some(first) = errors.into_iter().next() {
|
||||||
|
return Err(first);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//! Installs everything needed to launch a version: vanilla game, loader,
|
||||||
|
//! libraries, assets and Java.
|
||||||
|
|
||||||
|
use crate::assets;
|
||||||
|
use crate::http::{self, Download};
|
||||||
|
use crate::java;
|
||||||
|
use crate::libraries::{self, ResolvedLib};
|
||||||
|
use crate::loaders;
|
||||||
|
use crate::meta;
|
||||||
|
use crate::paths::Layout;
|
||||||
|
use crate::progress::{self, Reporter, Stage};
|
||||||
|
use crate::rules::Env;
|
||||||
|
use crate::version::{self, VersionJson};
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use scopenet_shared::Loader;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InstallSpec {
|
||||||
|
pub mc_version: String,
|
||||||
|
pub loader: Loader,
|
||||||
|
pub loader_version: Option<String>,
|
||||||
|
/// User-selected Java executable (skips the automatic runtime).
|
||||||
|
pub java_override: Option<PathBuf>,
|
||||||
|
pub game_dir: PathBuf,
|
||||||
|
pub concurrency: usize,
|
||||||
|
/// Re-hash every file instead of trusting sizes (Repair).
|
||||||
|
pub deep_verify: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Installed {
|
||||||
|
pub version: VersionJson,
|
||||||
|
/// Windowed Java (javaw on Windows).
|
||||||
|
pub java: PathBuf,
|
||||||
|
pub java_major: u32,
|
||||||
|
pub client_jar: PathBuf,
|
||||||
|
pub natives_dir: PathBuf,
|
||||||
|
pub libraries: Vec<ResolvedLib>,
|
||||||
|
pub assets_root: PathBuf,
|
||||||
|
pub game_assets: PathBuf,
|
||||||
|
pub assets_index: String,
|
||||||
|
pub logging_arg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Installed {
|
||||||
|
pub fn classpath(&self, layout: &Layout) -> Vec<PathBuf> {
|
||||||
|
let libs_dir = layout.libraries();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut cp: Vec<PathBuf> =
|
||||||
|
self.libraries.iter().filter(|l| l.classpath).map(|l| l.file(&libs_dir)).filter(|p| seen.insert(p.clone())).collect();
|
||||||
|
cp.push(self.client_jar.clone());
|
||||||
|
cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vanilla version JSON: from Mojang (cached), falling back to disk.
|
||||||
|
async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Result<VersionJson> {
|
||||||
|
let path = layout.version_json(mc);
|
||||||
|
let manifest = match meta::mojang_manifest(client).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
let bytes = std::fs::read(&path).map_err(|_| anyhow!("can't reach Mojang ({e}) and {mc} isn't installed yet"))?;
|
||||||
|
return Ok(serde_json::from_slice(&bytes)?);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let entry =
|
||||||
|
manifest.versions.iter().find(|v| v.id == mc).ok_or_else(|| anyhow!("Minecraft {mc} doesn't exist in Mojang's version list"))?;
|
||||||
|
if !http::file_ok(&path, entry.sha1.as_deref(), None, true) {
|
||||||
|
http::download_one(client, &Download::new(entry.url.clone(), path.clone(), entry.sha1.clone(), None), &|_| {}).await?;
|
||||||
|
}
|
||||||
|
Ok(serde_json::from_slice(&std::fs::read(&path)?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn library_downloads(libs: &[ResolvedLib], layout: &Layout, deep: bool) -> Vec<Download> {
|
||||||
|
let dir = layout.libraries();
|
||||||
|
let mut out: Vec<Download> =
|
||||||
|
libs.iter().filter(|l| !http::file_ok(&l.file(&dir), l.sha1.as_deref(), l.size, deep)).filter_map(|l| l.download(&dir)).collect();
|
||||||
|
out.sort_by(|a, b| a.dest.cmp(&b.dest));
|
||||||
|
out.dedup_by(|a, b| a.dest == b.dest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSpec, reporter: &Reporter) -> Result<Installed> {
|
||||||
|
let env = Env::current();
|
||||||
|
let mc = spec.mc_version.as_str();
|
||||||
|
progress::stage(reporter, Stage::Preparing, format!("Preparing Minecraft {mc}"));
|
||||||
|
|
||||||
|
// 1. Vanilla metadata ----------------------------------------------------
|
||||||
|
let vanilla = vanilla_json(client, layout, mc).await?;
|
||||||
|
|
||||||
|
// 2. Java ----------------------------------------------------------------
|
||||||
|
let (java_dir, java_major) = match &spec.java_override {
|
||||||
|
Some(p) if p.exists() => (None, java::detect_major(p).await.unwrap_or(vanilla.java_major())),
|
||||||
|
Some(p) => bail!("the Java path in your settings doesn't exist: {}", p.display()),
|
||||||
|
None => {
|
||||||
|
let component = vanilla.java_component();
|
||||||
|
progress::stage(reporter, Stage::Java, "Checking Java");
|
||||||
|
(Some(java::ensure_runtime(client, layout, &component, spec.concurrency, reporter).await?), vanilla.java_major())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (java_windowed, java_console) = match (&java_dir, &spec.java_override) {
|
||||||
|
(Some(dir), _) => (java::java_exe(dir, true), java::java_exe(dir, false)),
|
||||||
|
(None, Some(p)) => (java::sibling_exe(p, true), java::sibling_exe(p, false)),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Vanilla files (client jar, libraries, assets, log config) ---------
|
||||||
|
progress::stage(reporter, Stage::Game, format!("Downloading Minecraft {mc}"));
|
||||||
|
let vanilla_client = layout.version_jar(mc);
|
||||||
|
let mut downloads = Vec::new();
|
||||||
|
if let Some(client_dl) = vanilla.downloads.as_ref().and_then(|d| d.client.as_ref()) {
|
||||||
|
if !http::file_ok(&vanilla_client, client_dl.sha1.as_deref(), client_dl.size, spec.deep_verify) {
|
||||||
|
downloads.push(Download::new(client_dl.url.clone(), vanilla_client.clone(), client_dl.sha1.clone(), client_dl.size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloads.extend(library_downloads(&libraries::resolve(&vanilla.libraries, &env), layout, spec.deep_verify));
|
||||||
|
|
||||||
|
let asset_ref = vanilla.asset_index.clone().ok_or_else(|| anyhow!("version {mc} has no asset index"))?;
|
||||||
|
let asset_index = assets::load_index(client, layout, &asset_ref).await?;
|
||||||
|
downloads.extend(assets::missing_objects(layout, &asset_index, spec.deep_verify));
|
||||||
|
|
||||||
|
let mut logging_arg = None;
|
||||||
|
if let Some(log) = vanilla.logging.as_ref().and_then(|l| l.client.as_ref()) {
|
||||||
|
let path = layout.assets().join("log_configs").join(&log.file.id);
|
||||||
|
if !http::file_ok(&path, log.file.sha1.as_deref(), log.file.size, spec.deep_verify) {
|
||||||
|
downloads.push(Download::new(log.file.url.clone(), path.clone(), log.file.sha1.clone(), log.file.size));
|
||||||
|
}
|
||||||
|
logging_arg = Some(log.argument.replace("${path}", &path.to_string_lossy()));
|
||||||
|
}
|
||||||
|
http::download_all(client, downloads, spec.concurrency, Stage::Game, reporter).await?;
|
||||||
|
|
||||||
|
// 4. Loader --------------------------------------------------------------
|
||||||
|
let child: Option<VersionJson> = match spec.loader {
|
||||||
|
Loader::Vanilla => None,
|
||||||
|
Loader::Fabric | Loader::Quilt => {
|
||||||
|
let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap();
|
||||||
|
progress::stage(
|
||||||
|
reporter,
|
||||||
|
Stage::Loader,
|
||||||
|
format!("Installing {} {lv}", if spec.loader == Loader::Fabric { "Fabric" } else { "Quilt" }),
|
||||||
|
);
|
||||||
|
Some(loaders::fabric::profile(client, layout, spec.loader, mc, &lv).await?)
|
||||||
|
}
|
||||||
|
Loader::Forge | Loader::NeoForge => {
|
||||||
|
let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap();
|
||||||
|
Some(
|
||||||
|
loaders::forge::install(loaders::forge::ForgeInstall {
|
||||||
|
client,
|
||||||
|
layout,
|
||||||
|
loader: spec.loader,
|
||||||
|
mc,
|
||||||
|
version: &lv,
|
||||||
|
java: &java_console,
|
||||||
|
client_jar: &vanilla_client,
|
||||||
|
concurrency: spec.concurrency,
|
||||||
|
reporter,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("installing {} {lv}", spec.loader.as_str()))?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let version = match child {
|
||||||
|
Some(child) => version::merge(vanilla, child),
|
||||||
|
None => vanilla,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. Remaining (loader) libraries -----------------------------------------
|
||||||
|
let libs = libraries::resolve(&version.libraries, &env);
|
||||||
|
let lib_downloads = library_downloads(&libs, layout, false);
|
||||||
|
if !lib_downloads.is_empty() {
|
||||||
|
progress::stage(reporter, Stage::Loader, "Downloading loader libraries");
|
||||||
|
http::download_all(client, lib_downloads, spec.concurrency, Stage::Loader, reporter).await?;
|
||||||
|
}
|
||||||
|
let libs_dir = layout.libraries();
|
||||||
|
if let Some(missing) = libs.iter().find(|l| l.classpath && !l.file(&libs_dir).exists()) {
|
||||||
|
bail!("library {} is missing and has no download URL", missing.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Natives, client jar, legacy assets ---------------------------------
|
||||||
|
let natives_dir = layout.natives(&version.id);
|
||||||
|
libraries::extract_natives(&libs, &libs_dir, &natives_dir)?;
|
||||||
|
|
||||||
|
// Forge's module system ignores the game jar by *file name*
|
||||||
|
// (`${version_name}.jar`), so the jar must be named after the final id.
|
||||||
|
let client_jar = if version.id != mc {
|
||||||
|
let jar = layout.version_jar(&version.id);
|
||||||
|
let src_len = std::fs::metadata(&vanilla_client).map(|m| m.len()).unwrap_or(0);
|
||||||
|
if std::fs::metadata(&jar).map(|m| m.len()).unwrap_or(u64::MAX) != src_len {
|
||||||
|
std::fs::create_dir_all(jar.parent().unwrap())?;
|
||||||
|
std::fs::copy(&vanilla_client, &jar)?;
|
||||||
|
}
|
||||||
|
jar
|
||||||
|
} else {
|
||||||
|
vanilla_client
|
||||||
|
};
|
||||||
|
|
||||||
|
let assets_root = layout.assets();
|
||||||
|
let game_assets =
|
||||||
|
assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)?.unwrap_or_else(|| assets_root.clone());
|
||||||
|
|
||||||
|
Ok(Installed {
|
||||||
|
java: java_windowed,
|
||||||
|
java_major,
|
||||||
|
client_jar,
|
||||||
|
natives_dir,
|
||||||
|
libraries: libs,
|
||||||
|
assets_root,
|
||||||
|
game_assets,
|
||||||
|
assets_index: asset_ref.id.clone(),
|
||||||
|
logging_arg,
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! Automatic Java: Mojang publishes the exact runtimes its launcher uses.
|
||||||
|
//! We download the one each Minecraft version asks for, so players never
|
||||||
|
//! have to install Java themselves.
|
||||||
|
|
||||||
|
use crate::http::{self, Download};
|
||||||
|
use crate::paths::Layout;
|
||||||
|
use crate::progress::{self, Reporter, Stage};
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub const RUNTIME_INDEX: &str =
|
||||||
|
"https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json";
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RuntimeEntry {
|
||||||
|
manifest: ManifestRef,
|
||||||
|
version: RuntimeVersion,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ManifestRef {
|
||||||
|
sha1: String,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RuntimeVersion {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RuntimeManifest {
|
||||||
|
files: HashMap<String, RuntimeFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RuntimeFile {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
executable: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
downloads: Option<RuntimeDownloads>,
|
||||||
|
#[serde(default)]
|
||||||
|
target: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RuntimeDownloads {
|
||||||
|
raw: RawDownload,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawDownload {
|
||||||
|
sha1: String,
|
||||||
|
size: u64,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn platform_key() -> &'static str {
|
||||||
|
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||||
|
("windows", "x86") => "windows-x86",
|
||||||
|
("windows", "aarch64") => "windows-arm64",
|
||||||
|
("windows", _) => "windows-x64",
|
||||||
|
("macos", "aarch64") => "mac-os-arm64",
|
||||||
|
("macos", _) => "mac-os",
|
||||||
|
("linux", "x86") => "linux-i386",
|
||||||
|
_ => "linux",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to the Java executable inside a runtime directory.
|
||||||
|
/// `windowed` picks `javaw.exe` on Windows so no console window appears.
|
||||||
|
pub fn java_exe(dir: &Path, windowed: bool) -> PathBuf {
|
||||||
|
if cfg!(windows) {
|
||||||
|
dir.join("bin").join(if windowed { "javaw.exe" } else { "java.exe" })
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
dir.join("jre.bundle/Contents/Home/bin/java")
|
||||||
|
} else {
|
||||||
|
dir.join("bin/java")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For a user-supplied path, derive the console/windowed sibling.
|
||||||
|
pub fn sibling_exe(java: &Path, windowed: bool) -> PathBuf {
|
||||||
|
if !cfg!(windows) {
|
||||||
|
return java.to_path_buf();
|
||||||
|
}
|
||||||
|
let name = if windowed { "javaw.exe" } else { "java.exe" };
|
||||||
|
let candidate = java.with_file_name(name);
|
||||||
|
if candidate.exists() {
|
||||||
|
candidate
|
||||||
|
} else {
|
||||||
|
java.to_path_buf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make sure the Mojang runtime `component` (e.g. `java-runtime-delta`) is
|
||||||
|
/// installed. Returns its directory.
|
||||||
|
pub async fn ensure_runtime(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
layout: &Layout,
|
||||||
|
component: &str,
|
||||||
|
concurrency: usize,
|
||||||
|
reporter: &Reporter,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
let dir = layout.runtimes().join(component);
|
||||||
|
let marker = dir.join(".scopenet-runtime");
|
||||||
|
let installed = std::fs::read_to_string(&marker).ok();
|
||||||
|
|
||||||
|
let index: HashMap<String, HashMap<String, Vec<RuntimeEntry>>> = match http::get_json(client, RUNTIME_INDEX).await {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) if installed.is_some() && java_exe(&dir, false).exists() => {
|
||||||
|
tracing::warn!("java index unreachable ({e}); using installed {component}");
|
||||||
|
return Ok(dir);
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e).context("fetching Java runtime index"),
|
||||||
|
};
|
||||||
|
let entry = index
|
||||||
|
.get(platform_key())
|
||||||
|
.and_then(|p| p.get(component))
|
||||||
|
.and_then(|v| v.first())
|
||||||
|
.ok_or_else(|| anyhow!("Mojang has no '{component}' Java runtime for {}", platform_key()))?;
|
||||||
|
|
||||||
|
if installed.as_deref() == Some(entry.manifest.sha1.as_str()) && java_exe(&dir, false).exists() {
|
||||||
|
return Ok(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress::stage(reporter, Stage::Java, format!("Installing Java {}", entry.version.name));
|
||||||
|
let manifest: RuntimeManifest = http::get_json(client, &entry.manifest.url).await?;
|
||||||
|
let mut downloads = Vec::new();
|
||||||
|
let mut executables = Vec::new();
|
||||||
|
let mut links = Vec::new();
|
||||||
|
for (rel, file) in &manifest.files {
|
||||||
|
let Some(path) = crate::paths::safe_join(&dir, rel) else { continue };
|
||||||
|
match file.kind.as_str() {
|
||||||
|
"directory" => std::fs::create_dir_all(&path)?,
|
||||||
|
"file" => {
|
||||||
|
let Some(dl) = &file.downloads else { continue };
|
||||||
|
if file.executable {
|
||||||
|
executables.push(path.clone());
|
||||||
|
}
|
||||||
|
if !http::file_ok(&path, Some(&dl.raw.sha1), Some(dl.raw.size), true) {
|
||||||
|
downloads.push(Download::new(dl.raw.url.clone(), path, Some(dl.raw.sha1.clone()), Some(dl.raw.size)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"link" => {
|
||||||
|
if let Some(target) = &file.target {
|
||||||
|
links.push((path, target.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http::download_all(client, downloads, concurrency, Stage::Java, reporter).await?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
for exe in executables {
|
||||||
|
if let Ok(meta) = std::fs::metadata(&exe) {
|
||||||
|
let mut perms = meta.permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(&exe, perms).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (path, target) in links {
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
std::os::unix::fs::symlink(target, &path).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = (executables, links);
|
||||||
|
|
||||||
|
std::fs::write(&marker, &entry.manifest.sha1)?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort `java -version` parse for a custom Java path.
|
||||||
|
pub async fn detect_major(java: &Path) -> Option<u32> {
|
||||||
|
let mut cmd = tokio::process::Command::new(sibling_exe(java, false));
|
||||||
|
cmd.arg("-version");
|
||||||
|
#[cfg(windows)]
|
||||||
|
cmd.creation_flags(0x0800_0000);
|
||||||
|
let out = cmd.output().await.ok()?;
|
||||||
|
let text = String::from_utf8_lossy(&out.stderr).to_string() + &String::from_utf8_lossy(&out.stdout);
|
||||||
|
parse_java_version(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_java_version(text: &str) -> Option<u32> {
|
||||||
|
let start = text.find('"')? + 1;
|
||||||
|
let rest = &text[start..];
|
||||||
|
let ver = &rest[..rest.find('"')?];
|
||||||
|
let mut parts = ver.split(['.', '_', '-', '+']);
|
||||||
|
let first: u32 = parts.next()?.parse().ok()?;
|
||||||
|
if first == 1 {
|
||||||
|
parts.next()?.parse().ok()
|
||||||
|
} else {
|
||||||
|
Some(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_versions() {
|
||||||
|
assert_eq!(parse_java_version("java version \"1.8.0_381\""), Some(8));
|
||||||
|
assert_eq!(parse_java_version("openjdk version \"21.0.2\" 2024-01-16"), Some(21));
|
||||||
|
assert_eq!(parse_java_version("openjdk version \"17\" 2021-09-14"), Some(17));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
//! Building the `java ...` command line and starting the game.
|
||||||
|
|
||||||
|
use crate::install::Installed;
|
||||||
|
use crate::paths::Layout;
|
||||||
|
use crate::rules::{self, Env};
|
||||||
|
use crate::version::Arg;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum GcPreset {
|
||||||
|
/// Let the JVM decide.
|
||||||
|
Default,
|
||||||
|
/// Tuned G1 (Aikar-style) — smooth frame times on most machines.
|
||||||
|
#[default]
|
||||||
|
G1,
|
||||||
|
/// ZGC — lowest pauses, needs Java 17+ and a bit more RAM.
|
||||||
|
Zgc,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Auth {
|
||||||
|
pub username: String,
|
||||||
|
/// Dashed or undashed UUID.
|
||||||
|
pub uuid: String,
|
||||||
|
pub access_token: String,
|
||||||
|
/// "msa" for Microsoft accounts, "legacy" for offline.
|
||||||
|
pub user_type: String,
|
||||||
|
pub xuid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LaunchOptions {
|
||||||
|
pub auth: Auth,
|
||||||
|
pub game_dir: PathBuf,
|
||||||
|
pub memory_min_mb: u32,
|
||||||
|
pub memory_max_mb: u32,
|
||||||
|
pub gc: GcPreset,
|
||||||
|
pub extra_jvm_args: Vec<String>,
|
||||||
|
pub resolution: Option<(u32, u32)>,
|
||||||
|
pub fullscreen: bool,
|
||||||
|
/// Join this server right after the game starts.
|
||||||
|
pub join_server: Option<(String, u16)>,
|
||||||
|
pub version_label: String,
|
||||||
|
pub launcher_name: String,
|
||||||
|
pub launcher_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a user-entered argument string, honouring quotes.
|
||||||
|
pub fn split_args(s: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut quote: Option<char> = None;
|
||||||
|
let mut has_token = false;
|
||||||
|
for c in s.chars() {
|
||||||
|
match (quote, c) {
|
||||||
|
(Some(q), c) if c == q => quote = None,
|
||||||
|
(Some(_), c) => cur.push(c),
|
||||||
|
(None, '"') | (None, '\'') => {
|
||||||
|
quote = Some(c);
|
||||||
|
has_token = true;
|
||||||
|
}
|
||||||
|
(None, c) if c.is_whitespace() => {
|
||||||
|
if has_token || !cur.is_empty() {
|
||||||
|
out.push(std::mem::take(&mut cur));
|
||||||
|
has_token = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, c) => cur.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_token || !cur.is_empty() {
|
||||||
|
out.push(cur);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gc_args(preset: GcPreset, java_major: u32) -> Vec<String> {
|
||||||
|
let g1 = [
|
||||||
|
"-XX:+UseG1GC",
|
||||||
|
"-XX:+ParallelRefProcEnabled",
|
||||||
|
"-XX:MaxGCPauseMillis=200",
|
||||||
|
"-XX:+UnlockExperimentalVMOptions",
|
||||||
|
"-XX:+DisableExplicitGC",
|
||||||
|
"-XX:+AlwaysPreTouch",
|
||||||
|
"-XX:G1NewSizePercent=30",
|
||||||
|
"-XX:G1MaxNewSizePercent=40",
|
||||||
|
"-XX:G1HeapRegionSize=8M",
|
||||||
|
"-XX:G1ReservePercent=20",
|
||||||
|
"-XX:G1HeapWastePercent=5",
|
||||||
|
"-XX:G1MixedGCCountTarget=4",
|
||||||
|
"-XX:InitiatingHeapOccupancyPercent=15",
|
||||||
|
"-XX:G1MixedGCLiveThresholdPercent=90",
|
||||||
|
"-XX:G1RSetUpdatingPauseTimePercent=5",
|
||||||
|
"-XX:SurvivorRatio=32",
|
||||||
|
"-XX:+PerfDisableSharedMem",
|
||||||
|
"-XX:MaxTenuringThreshold=1",
|
||||||
|
];
|
||||||
|
match preset {
|
||||||
|
GcPreset::Default => vec![],
|
||||||
|
GcPreset::Zgc if java_major >= 17 => {
|
||||||
|
let mut v = vec!["-XX:+UseZGC".to_string()];
|
||||||
|
// Generational ZGC: opt-in on 21-22, default (flag obsolete) from 23.
|
||||||
|
if (21..23).contains(&java_major) {
|
||||||
|
v.push("-XX:+ZGenerational".into());
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
_ => g1.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn substitute(arg: &str, vars: &HashMap<&str, String>) -> String {
|
||||||
|
let mut out = String::with_capacity(arg.len());
|
||||||
|
let mut rest = arg;
|
||||||
|
while let Some(start) = rest.find("${") {
|
||||||
|
out.push_str(&rest[..start]);
|
||||||
|
let after = &rest[start + 2..];
|
||||||
|
match after.find('}') {
|
||||||
|
Some(end) => {
|
||||||
|
let key = &after[..end];
|
||||||
|
match vars.get(key) {
|
||||||
|
Some(v) => out.push_str(v),
|
||||||
|
None => {
|
||||||
|
out.push_str("${");
|
||||||
|
out.push_str(key);
|
||||||
|
out.push('}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest = &after[end + 1..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push_str(&rest[start..]);
|
||||||
|
rest = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand(args: &[Arg], env: &Env, vars: &HashMap<&str, String>) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for arg in args {
|
||||||
|
match arg {
|
||||||
|
Arg::Plain(s) => out.push(substitute(s, vars)),
|
||||||
|
Arg::Ruled { rules: r, value } => {
|
||||||
|
if rules::allowed(r, env) {
|
||||||
|
out.extend(value.values().iter().map(|s| substitute(s, vars)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Command {
|
||||||
|
pub java: PathBuf,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub cwd: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Command {
|
||||||
|
let v = &installed.version;
|
||||||
|
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||||
|
let classpath = installed.classpath(layout).iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join(sep);
|
||||||
|
|
||||||
|
let quick_play = v.supports_quick_play();
|
||||||
|
let env = Env::current()
|
||||||
|
.with_feature("has_custom_resolution", opts.resolution.is_some())
|
||||||
|
.with_feature("is_quick_play_multiplayer", quick_play && opts.join_server.is_some());
|
||||||
|
|
||||||
|
let uuid = opts.auth.uuid.replace('-', "");
|
||||||
|
let (w, h) = opts.resolution.unwrap_or((854, 480));
|
||||||
|
let mut vars: HashMap<&str, String> = HashMap::new();
|
||||||
|
vars.insert("auth_player_name", opts.auth.username.clone());
|
||||||
|
vars.insert("version_name", v.id.clone());
|
||||||
|
vars.insert("game_directory", opts.game_dir.to_string_lossy().into_owned());
|
||||||
|
vars.insert("assets_root", installed.assets_root.to_string_lossy().into_owned());
|
||||||
|
vars.insert("game_assets", installed.game_assets.to_string_lossy().into_owned());
|
||||||
|
vars.insert("assets_index_name", installed.assets_index.clone());
|
||||||
|
vars.insert("auth_uuid", uuid.clone());
|
||||||
|
vars.insert("auth_access_token", opts.auth.access_token.clone());
|
||||||
|
vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid));
|
||||||
|
vars.insert("clientid", String::new());
|
||||||
|
vars.insert("auth_xuid", opts.auth.xuid.clone().unwrap_or_default());
|
||||||
|
vars.insert("user_type", opts.auth.user_type.clone());
|
||||||
|
vars.insert("user_properties", "{}".into());
|
||||||
|
vars.insert("version_type", opts.version_label.clone());
|
||||||
|
vars.insert("resolution_width", w.to_string());
|
||||||
|
vars.insert("resolution_height", h.to_string());
|
||||||
|
vars.insert("natives_directory", installed.natives_dir.to_string_lossy().into_owned());
|
||||||
|
vars.insert("launcher_name", opts.launcher_name.clone());
|
||||||
|
vars.insert("launcher_version", opts.launcher_version.clone());
|
||||||
|
vars.insert("classpath", classpath);
|
||||||
|
vars.insert("classpath_separator", sep.into());
|
||||||
|
vars.insert("library_directory", layout.libraries().to_string_lossy().into_owned());
|
||||||
|
if let Some((host, port)) = &opts.join_server {
|
||||||
|
vars.insert("quickPlayMultiplayer", format!("{host}:{port}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut args = Vec::new();
|
||||||
|
args.push(format!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb)));
|
||||||
|
args.push(format!("-Xmx{}M", opts.memory_max_mb));
|
||||||
|
args.extend(gc_args(opts.gc, installed.java_major));
|
||||||
|
args.push("-Dlog4j2.formatMsgNoLookups=true".into());
|
||||||
|
if let Some(log) = &installed.logging_arg {
|
||||||
|
args.push(log.clone());
|
||||||
|
}
|
||||||
|
args.extend(opts.extra_jvm_args.iter().cloned());
|
||||||
|
|
||||||
|
match &v.arguments {
|
||||||
|
Some(a) if !a.jvm.is_empty() => args.extend(expand(&a.jvm, &env, &vars)),
|
||||||
|
_ => {
|
||||||
|
args.push(format!("-Djava.library.path={}", vars["natives_directory"]));
|
||||||
|
args.push("-cp".into());
|
||||||
|
args.push(vars["classpath"].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(v.main_class.clone().unwrap_or_else(|| "net.minecraft.client.main.Main".into()));
|
||||||
|
|
||||||
|
let mut game_args = match (&v.arguments, &v.minecraft_arguments) {
|
||||||
|
(_, Some(legacy)) if v.arguments.as_ref().map(|a| a.game.is_empty()).unwrap_or(true) => {
|
||||||
|
legacy.split_whitespace().map(|s| substitute(s, &vars)).collect()
|
||||||
|
}
|
||||||
|
(Some(a), _) => expand(&a.game, &env, &vars),
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let has = |args: &[String], flag: &str| args.iter().any(|a| a == flag);
|
||||||
|
if let Some((w, h)) = opts.resolution {
|
||||||
|
if !has(&game_args, "--width") {
|
||||||
|
game_args.extend(["--width".into(), w.to_string(), "--height".into(), h.to_string()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.fullscreen && !has(&game_args, "--fullscreen") {
|
||||||
|
game_args.push("--fullscreen".into());
|
||||||
|
}
|
||||||
|
if let Some((host, port)) = &opts.join_server {
|
||||||
|
if !quick_play {
|
||||||
|
game_args.extend(["--server".into(), host.clone(), "--port".into(), port.to_string()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.extend(game_args);
|
||||||
|
|
||||||
|
Command { java: installed.java.clone(), args, cwd: opts.game_dir.clone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Java 9+ can read arguments from a file, which sidesteps Windows'
|
||||||
|
/// 32k command-line limit on huge modpacks.
|
||||||
|
fn write_argfile(path: &Path, args: &[String]) -> Result<()> {
|
||||||
|
let body = args.iter().map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\""))).collect::<Vec<_>>().join("\n");
|
||||||
|
std::fs::write(path, body)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the game. With `capture` the game's stdout/stderr are piped back to
|
||||||
|
/// us (for the console); without it they're discarded, which is required
|
||||||
|
/// when the launcher exits while the game keeps running.
|
||||||
|
pub fn spawn(cmd: &Command, java_major: u32, capture: bool) -> Result<tokio::process::Child> {
|
||||||
|
std::fs::create_dir_all(&cmd.cwd)?;
|
||||||
|
let mut proc = tokio::process::Command::new(&cmd.java);
|
||||||
|
let total_len: usize = cmd.args.iter().map(|a| a.len() + 3).sum();
|
||||||
|
if java_major >= 9 && total_len > 8000 {
|
||||||
|
let argfile = cmd.cwd.join(".scopenet").join("launch.args");
|
||||||
|
std::fs::create_dir_all(argfile.parent().unwrap())?;
|
||||||
|
write_argfile(&argfile, &cmd.args)?;
|
||||||
|
proc.arg(format!("@{}", argfile.display()));
|
||||||
|
} else {
|
||||||
|
proc.args(&cmd.args);
|
||||||
|
}
|
||||||
|
let out = || if capture { std::process::Stdio::piped() } else { std::process::Stdio::null() };
|
||||||
|
proc.current_dir(&cmd.cwd).stdin(std::process::Stdio::null()).stdout(out()).stderr(out());
|
||||||
|
#[cfg(windows)]
|
||||||
|
proc.creation_flags(0x0800_0000);
|
||||||
|
proc.spawn().with_context(|| format!("starting {}", cmd.java.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide the access token when showing the command to the user.
|
||||||
|
pub fn redact(args: &[String], token: &str) -> Vec<String> {
|
||||||
|
if token.len() < 8 {
|
||||||
|
return args.to_vec();
|
||||||
|
}
|
||||||
|
args.iter().map(|a| a.replace(token, "********")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::version::VersionJson;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splits_quoted_args() {
|
||||||
|
assert_eq!(split_args(r#"-Xss2M -Dfoo="a b" '' -Dx=1"#), vec!["-Xss2M", "-Dfoo=a b", "", "-Dx=1"]);
|
||||||
|
assert!(split_args(" ").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn substitutes_vars() {
|
||||||
|
let mut vars = HashMap::new();
|
||||||
|
vars.insert("a", "1".to_string());
|
||||||
|
assert_eq!(substitute("x${a}y${b}z", &vars), "x1y${b}z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zgc_falls_back_on_java8() {
|
||||||
|
assert!(gc_args(GcPreset::Zgc, 8).contains(&"-XX:+UseG1GC".to_string()));
|
||||||
|
assert_eq!(gc_args(GcPreset::Zgc, 21), vec!["-XX:+UseZGC", "-XX:+ZGenerational"]);
|
||||||
|
assert_eq!(gc_args(GcPreset::Zgc, 25), vec!["-XX:+UseZGC"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn installed(json: &str) -> Installed {
|
||||||
|
Installed {
|
||||||
|
version: serde_json::from_str::<VersionJson>(json).unwrap(),
|
||||||
|
java: "java".into(),
|
||||||
|
java_major: 17,
|
||||||
|
client_jar: "/v/c.jar".into(),
|
||||||
|
natives_dir: "/n".into(),
|
||||||
|
libraries: vec![],
|
||||||
|
assets_root: "/a".into(),
|
||||||
|
game_assets: "/a".into(),
|
||||||
|
assets_index: "5".into(),
|
||||||
|
logging_arg: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opts(join: bool) -> LaunchOptions {
|
||||||
|
LaunchOptions {
|
||||||
|
auth: Auth {
|
||||||
|
username: "Steve".into(),
|
||||||
|
uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(),
|
||||||
|
access_token: "0".into(),
|
||||||
|
user_type: "legacy".into(),
|
||||||
|
xuid: None,
|
||||||
|
},
|
||||||
|
game_dir: "/g".into(),
|
||||||
|
memory_min_mb: 1024,
|
||||||
|
memory_max_mb: 4096,
|
||||||
|
gc: GcPreset::Default,
|
||||||
|
extra_jvm_args: vec!["-Dcustom=1".into()],
|
||||||
|
resolution: Some((1280, 720)),
|
||||||
|
fullscreen: false,
|
||||||
|
join_server: join.then(|| ("play.example.net".to_string(), 25565)),
|
||||||
|
version_label: "SCOPENET".into(),
|
||||||
|
launcher_name: "scopenet".into(),
|
||||||
|
launcher_version: "0.1.0".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_modern_command_with_quick_play() {
|
||||||
|
let json = r#"{"id":"1.20.1","mainClass":"net.minecraft.client.main.Main","arguments":{
|
||||||
|
"game":["--username","${auth_player_name}","--uuid","${auth_uuid}","--versionType","${version_type}",
|
||||||
|
{"rules":[{"action":"allow","features":{"has_custom_resolution":true}}],"value":["--width","${resolution_width}","--height","${resolution_height}"]},
|
||||||
|
{"rules":[{"action":"allow","features":{"is_quick_play_multiplayer":true}}],"value":["--quickPlayMultiplayer","${quickPlayMultiplayer}"]}],
|
||||||
|
"jvm":["-Djava.library.path=${natives_directory}","-cp","${classpath}"]}}"#;
|
||||||
|
let layout = Layout::new("/root");
|
||||||
|
let cmd = build(&installed(json), &layout, &opts(true));
|
||||||
|
let a = cmd.args.join(" ");
|
||||||
|
assert!(a.starts_with("-Xms1024M -Xmx4096M"));
|
||||||
|
assert!(a.contains("-Dcustom=1"));
|
||||||
|
assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f"));
|
||||||
|
assert!(a.contains("--width 1280 --height 720"));
|
||||||
|
assert!(a.contains("--quickPlayMultiplayer play.example.net:25565"));
|
||||||
|
assert!(!a.contains("--server"));
|
||||||
|
assert!(a.contains("--versionType SCOPENET"));
|
||||||
|
assert!(a.contains("-Djava.library.path=/n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builds_legacy_command_with_server_flag() {
|
||||||
|
let json = r#"{"id":"1.12.2","mainClass":"net.minecraft.launchwrapper.Launch",
|
||||||
|
"minecraftArguments":"--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker"}"#;
|
||||||
|
let cmd = build(&installed(json), &Layout::new("/root"), &opts(true));
|
||||||
|
let a = cmd.args.join(" ");
|
||||||
|
assert!(a.contains("-Djava.library.path=/n -cp /v/c.jar net.minecraft.launchwrapper.Launch --username Steve"));
|
||||||
|
assert!(a.contains("--width 1280 --height 720"));
|
||||||
|
assert!(a.ends_with("--server play.example.net --port 25565"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! SCOPENET launcher engine.
|
||||||
|
//!
|
||||||
|
//! The Tauri app is a thin UI shell around this crate: everything that
|
||||||
|
//! downloads, installs or launches Minecraft lives here so it can be unit
|
||||||
|
//! tested without a window.
|
||||||
|
|
||||||
|
pub mod assets;
|
||||||
|
pub mod http;
|
||||||
|
pub mod install;
|
||||||
|
pub mod java;
|
||||||
|
pub mod launch;
|
||||||
|
pub mod libraries;
|
||||||
|
pub mod loaders;
|
||||||
|
pub mod maven;
|
||||||
|
pub mod meta;
|
||||||
|
pub mod msa;
|
||||||
|
pub mod options;
|
||||||
|
pub mod paths;
|
||||||
|
pub mod ping;
|
||||||
|
pub mod progress;
|
||||||
|
pub mod rules;
|
||||||
|
pub mod servers_dat;
|
||||||
|
pub mod sync;
|
||||||
|
pub mod sys;
|
||||||
|
pub mod version;
|
||||||
|
|
||||||
|
pub use paths::Layout;
|
||||||
|
pub use progress::{Event, Reporter, Stage};
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! Turning version-JSON libraries into files on disk + a classpath.
|
||||||
|
|
||||||
|
use crate::http::Download;
|
||||||
|
use crate::maven::Coord;
|
||||||
|
use crate::rules::{self, Env};
|
||||||
|
use crate::version::Library;
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub const MOJANG_LIBRARIES: &str = "https://libraries.minecraft.net/";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ResolvedLib {
|
||||||
|
pub name: String,
|
||||||
|
/// Path relative to the libraries dir (`/`-separated).
|
||||||
|
pub path: String,
|
||||||
|
/// `None` when the file must come from somewhere else (e.g. a Forge installer).
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub sha1: Option<String>,
|
||||||
|
pub size: Option<u64>,
|
||||||
|
/// Goes on the classpath.
|
||||||
|
pub classpath: bool,
|
||||||
|
/// Contains native libraries that must be extracted.
|
||||||
|
pub native: bool,
|
||||||
|
pub exclude: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolvedLib {
|
||||||
|
pub fn file(&self, libraries_dir: &Path) -> PathBuf {
|
||||||
|
libraries_dir.join(&self.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download(&self, libraries_dir: &Path) -> Option<Download> {
|
||||||
|
self.url.as_ref().map(|url| Download::new(url.clone(), self.file(libraries_dir), self.sha1.clone(), self.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_url(base: &str, path: &str) -> String {
|
||||||
|
if base.ends_with('/') {
|
||||||
|
format!("{base}{path}")
|
||||||
|
} else {
|
||||||
|
format!("{base}/{path}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve(libs: &[Library], env: &Env) -> Vec<ResolvedLib> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for lib in libs {
|
||||||
|
if let Some(rules) = &lib.rules {
|
||||||
|
if !rules::allowed(rules, env) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(coord) = Coord::parse(&lib.name) else { continue };
|
||||||
|
let exclude = lib.extract.as_ref().map(|e| e.exclude.clone()).unwrap_or_default();
|
||||||
|
|
||||||
|
// Legacy natives (LWJGL 2 / pre-1.19): a separate classifier jar
|
||||||
|
// that is extracted but never put on the classpath.
|
||||||
|
if let Some(natives) = &lib.natives {
|
||||||
|
if let Some(classifier) = natives.get(env.os) {
|
||||||
|
let classifier = classifier.replace("${arch}", env.bits());
|
||||||
|
let from_downloads = lib.downloads.as_ref().and_then(|d| d.classifiers.as_ref()).and_then(|c| c.get(&classifier));
|
||||||
|
let native_coord = coord.with_classifier(&classifier);
|
||||||
|
let (path, url, sha1, size) = match from_downloads {
|
||||||
|
Some(a) => (
|
||||||
|
a.path.clone().unwrap_or_else(|| native_coord.path()),
|
||||||
|
Some(a.url.clone()).filter(|u| !u.is_empty()),
|
||||||
|
a.sha1.clone(),
|
||||||
|
a.size,
|
||||||
|
),
|
||||||
|
None => {
|
||||||
|
let p = native_coord.path();
|
||||||
|
let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES);
|
||||||
|
(p.clone(), Some(join_url(base, &p)), None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push(ResolvedLib {
|
||||||
|
name: format!("{}:{}", lib.name, classifier),
|
||||||
|
path,
|
||||||
|
url,
|
||||||
|
sha1,
|
||||||
|
size,
|
||||||
|
classpath: false,
|
||||||
|
native: true,
|
||||||
|
exclude: exclude.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A natives-map library only has a main artifact if downloads.artifact exists.
|
||||||
|
let Some(artifact) = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) else { continue };
|
||||||
|
out.push(ResolvedLib {
|
||||||
|
name: lib.name.clone(),
|
||||||
|
path: artifact.path.clone().unwrap_or_else(|| coord.path()),
|
||||||
|
url: Some(artifact.url.clone()).filter(|u| !u.is_empty()),
|
||||||
|
sha1: artifact.sha1.clone(),
|
||||||
|
size: artifact.size,
|
||||||
|
classpath: true,
|
||||||
|
native: false,
|
||||||
|
exclude,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_native_jar = coord.classifier.as_deref().is_some_and(|c| c.starts_with("natives-"));
|
||||||
|
let resolved = match lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) {
|
||||||
|
Some(a) => ResolvedLib {
|
||||||
|
name: lib.name.clone(),
|
||||||
|
path: a.path.clone().filter(|p| !p.is_empty()).unwrap_or_else(|| coord.path()),
|
||||||
|
url: Some(a.url.clone()).filter(|u| !u.is_empty()),
|
||||||
|
sha1: a.sha1.clone(),
|
||||||
|
size: a.size,
|
||||||
|
classpath: true,
|
||||||
|
native: is_native_jar,
|
||||||
|
exclude,
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
if lib.downloads.is_some() {
|
||||||
|
// `downloads` present but no artifact: classifier-only entry we don't need.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p = coord.path();
|
||||||
|
let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES);
|
||||||
|
ResolvedLib {
|
||||||
|
name: lib.name.clone(),
|
||||||
|
path: p.clone(),
|
||||||
|
url: Some(join_url(base, &p)),
|
||||||
|
sha1: lib.sha1.clone(),
|
||||||
|
size: lib.size,
|
||||||
|
classpath: true,
|
||||||
|
native: is_native_jar,
|
||||||
|
exclude,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push(resolved);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_native_file(name: &str) -> bool {
|
||||||
|
let n = name.to_ascii_lowercase();
|
||||||
|
n.ends_with(".dll") || n.ends_with(".so") || n.ends_with(".dylib") || n.ends_with(".jnilib")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract native libraries (flattened) into `dest`.
|
||||||
|
pub fn extract_natives(libs: &[ResolvedLib], libraries_dir: &Path, dest: &Path) -> Result<()> {
|
||||||
|
std::fs::create_dir_all(dest)?;
|
||||||
|
for lib in libs.iter().filter(|l| l.native) {
|
||||||
|
let path = lib.file(libraries_dir);
|
||||||
|
let Ok(file) = std::fs::File::open(&path) else {
|
||||||
|
tracing::warn!("missing native jar {}", path.display());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut zip = zip::ZipArchive::new(file)?;
|
||||||
|
for i in 0..zip.len() {
|
||||||
|
let mut entry = zip.by_index(i)?;
|
||||||
|
if entry.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.name().to_string();
|
||||||
|
if name.starts_with("META-INF") || lib.exclude.iter().any(|e| name.starts_with(e.as_str())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !is_native_file(&name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let file_name = name.rsplit('/').next().unwrap_or(&name);
|
||||||
|
// Skip natives for other architectures (LWJGL 3.3 ships several).
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
if cfg!(target_arch = "x86_64") && (lower.contains("arm64") || lower.contains("aarch64") || lower.contains("/x86/")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let target = dest.join(file_name);
|
||||||
|
if target.exists() && std::fs::metadata(&target).map(|m| m.len() == entry.size()).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut buf = Vec::with_capacity(entry.size() as usize);
|
||||||
|
entry.read_to_end(&mut buf)?;
|
||||||
|
std::fs::write(&target, buf)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::version::VersionJson;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_legacy_natives_and_maven_libs() {
|
||||||
|
let v: VersionJson = serde_json::from_str(
|
||||||
|
r#"{"id":"x","libraries":[
|
||||||
|
{"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209",
|
||||||
|
"natives":{"linux":"natives-linux","windows":"natives-windows-${arch}"},
|
||||||
|
"extract":{"exclude":["META-INF/"]},
|
||||||
|
"downloads":{"classifiers":{"natives-windows-64":{"path":"p/w64.jar","sha1":"s","size":5,"url":"https://l/w64.jar"}}}},
|
||||||
|
{"name":"net.fabricmc:intermediary:1.20.1","url":"https://maven.fabricmc.net/"},
|
||||||
|
{"name":"com.mojang:only-mac:1","rules":[{"action":"allow","os":{"name":"osx"}}]},
|
||||||
|
{"name":"net.minecraftforge:forge:1.20.1-47.2.0:universal","downloads":{"artifact":{"path":"net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-universal.jar","url":"","sha1":"u","size":9}}}
|
||||||
|
]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let env = Env { os: "windows", arch: "x86_64", features: HashMap::new() };
|
||||||
|
let libs = resolve(&v.libraries, &env);
|
||||||
|
assert_eq!(libs.len(), 3);
|
||||||
|
assert!(libs[0].native && !libs[0].classpath);
|
||||||
|
assert_eq!(libs[0].path, "p/w64.jar");
|
||||||
|
assert_eq!(libs[1].url.as_deref(), Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar"));
|
||||||
|
assert!(libs[2].url.is_none(), "empty url = bundled in installer");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! Fabric and Quilt both publish a ready-made launcher profile.
|
||||||
|
|
||||||
|
use crate::http;
|
||||||
|
use crate::meta::{FABRIC_META, QUILT_META};
|
||||||
|
use crate::paths::Layout;
|
||||||
|
use crate::version::VersionJson;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use scopenet_shared::Loader;
|
||||||
|
|
||||||
|
pub async fn profile(client: &reqwest::Client, layout: &Layout, loader: Loader, mc: &str, loader_version: &str) -> Result<VersionJson> {
|
||||||
|
let url = match loader {
|
||||||
|
Loader::Quilt => format!("{QUILT_META}/versions/loader/{mc}/{loader_version}/profile/json"),
|
||||||
|
_ => format!("{FABRIC_META}/versions/loader/{mc}/{loader_version}/profile/json"),
|
||||||
|
};
|
||||||
|
let cache = layout.cache().join("profiles").join(format!("{}-{mc}-{loader_version}.json", loader.as_str()));
|
||||||
|
let v: VersionJson = http::get_json_cached(client, &url, &cache)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("fetching {} {loader_version} profile for {mc}", loader.as_str()))?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
//! Forge / NeoForge. Their installers ship a version JSON plus a list of
|
||||||
|
//! "processors" (small Java programs that patch the game jar). We replay
|
||||||
|
//! exactly what the official installer does, headlessly.
|
||||||
|
|
||||||
|
use crate::http::{self, Download};
|
||||||
|
use crate::libraries;
|
||||||
|
use crate::maven::Coord;
|
||||||
|
use crate::meta::forge_installer_url;
|
||||||
|
use crate::paths::Layout;
|
||||||
|
use crate::progress::{self, Event, Reporter, Stage};
|
||||||
|
use crate::rules::Env;
|
||||||
|
use crate::version::{Library, VersionJson};
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use scopenet_shared::Loader;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
|
pub struct ForgeInstall<'a> {
|
||||||
|
pub client: &'a reqwest::Client,
|
||||||
|
pub layout: &'a Layout,
|
||||||
|
pub loader: Loader,
|
||||||
|
pub mc: &'a str,
|
||||||
|
/// Maven version, e.g. `1.20.1-47.2.0` (Forge) or `21.1.77` (NeoForge).
|
||||||
|
pub version: &'a str,
|
||||||
|
/// Console Java executable used to run processors.
|
||||||
|
pub java: &'a Path,
|
||||||
|
pub client_jar: &'a Path,
|
||||||
|
pub concurrency: usize,
|
||||||
|
pub reporter: &'a Reporter,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct InstallProfile {
|
||||||
|
#[serde(default = "default_json")]
|
||||||
|
json: String,
|
||||||
|
#[serde(default)]
|
||||||
|
data: HashMap<String, DataEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
processors: Vec<Processor>,
|
||||||
|
#[serde(default)]
|
||||||
|
libraries: Vec<Library>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_json() -> String {
|
||||||
|
"/version.json".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DataEntry {
|
||||||
|
client: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Processor {
|
||||||
|
#[serde(default)]
|
||||||
|
sides: Option<Vec<String>>,
|
||||||
|
jar: String,
|
||||||
|
#[serde(default)]
|
||||||
|
classpath: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
args: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
outputs: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Zip = zip::ZipArchive<std::fs::File>;
|
||||||
|
|
||||||
|
fn read_entry(zip: &mut Zip, name: &str) -> Result<Vec<u8>> {
|
||||||
|
let mut entry = zip.by_name(name.trim_start_matches('/')).with_context(|| format!("installer is missing {name}"))?;
|
||||||
|
let mut buf = Vec::with_capacity(entry.size() as usize);
|
||||||
|
entry.read_to_end(&mut buf)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_entry(zip: &mut Zip, name: &str, dest: &Path) -> Result<bool> {
|
||||||
|
let Ok(mut entry) = zip.by_name(name.trim_start_matches('/')) else { return Ok(false) };
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut buf = Vec::with_capacity(entry.size() as usize);
|
||||||
|
entry.read_to_end(&mut buf)?;
|
||||||
|
std::fs::write(dest, buf)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marker_path(layout: &Layout, id: &str) -> PathBuf {
|
||||||
|
layout.version_dir(id).join(".scopenet-installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn install(ctx: ForgeInstall<'_>) -> Result<VersionJson> {
|
||||||
|
let label = if ctx.loader == Loader::NeoForge { "NeoForge" } else { "Forge" };
|
||||||
|
progress::stage(ctx.reporter, Stage::Loader, format!("Installing {label} {}", ctx.version));
|
||||||
|
|
||||||
|
let installer = ctx.layout.cache().join("installers").join(format!("{}-{}-installer.jar", ctx.loader.as_str(), ctx.version));
|
||||||
|
if !installer.exists() {
|
||||||
|
let url = forge_installer_url(ctx.loader, ctx.version);
|
||||||
|
http::download_one(ctx.client, &Download::new(url, installer.clone(), None, None), &|_| {}).await?;
|
||||||
|
}
|
||||||
|
let mut zip = zip::ZipArchive::new(std::fs::File::open(&installer)?).context("opening installer")?;
|
||||||
|
let profile_raw: serde_json::Value = serde_json::from_slice(&read_entry(&mut zip, "install_profile.json")?)?;
|
||||||
|
|
||||||
|
if profile_raw.get("versionInfo").is_some() {
|
||||||
|
return install_legacy(&ctx, &mut zip, profile_raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile: InstallProfile = serde_json::from_value(profile_raw)?;
|
||||||
|
let mut version: VersionJson = serde_json::from_slice(&read_entry(&mut zip, &profile.json)?)?;
|
||||||
|
let libs_dir = ctx.layout.libraries();
|
||||||
|
|
||||||
|
// Files bundled in the installer's maven/ folder.
|
||||||
|
let names: Vec<String> = zip.file_names().filter(|n| n.starts_with("maven/") && !n.ends_with('/')).map(String::from).collect();
|
||||||
|
for name in names {
|
||||||
|
let dest = libs_dir.join(name.trim_start_matches("maven/"));
|
||||||
|
if !dest.exists() {
|
||||||
|
extract_entry(&mut zip, &name, &dest)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Libraries needed by the processors and by the game.
|
||||||
|
let env = Env::current();
|
||||||
|
let mut all_libs = libraries::resolve(&profile.libraries, &env);
|
||||||
|
all_libs.extend(libraries::resolve(&version.libraries, &env));
|
||||||
|
let mut downloads = Vec::new();
|
||||||
|
for lib in &all_libs {
|
||||||
|
let file = lib.file(&libs_dir);
|
||||||
|
if http::file_ok(&file, lib.sha1.as_deref(), lib.size, false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match lib.download(&libs_dir) {
|
||||||
|
Some(dl) => downloads.push(dl),
|
||||||
|
None => {
|
||||||
|
extract_entry(&mut zip, &format!("maven/{}", lib.path), &file)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloads.dedup_by(|a, b| a.dest == b.dest);
|
||||||
|
http::download_all(ctx.client, downloads, ctx.concurrency, Stage::Loader, ctx.reporter).await?;
|
||||||
|
|
||||||
|
let marker = marker_path(ctx.layout, &version.id);
|
||||||
|
if std::fs::read_to_string(&marker).ok().as_deref() != Some(ctx.version) {
|
||||||
|
run_processors(&ctx, &mut zip, &profile, &installer).await?;
|
||||||
|
std::fs::create_dir_all(marker.parent().unwrap())?;
|
||||||
|
std::fs::write(&marker, ctx.version)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
version.inherits_from = Some(ctx.mc.to_string());
|
||||||
|
save_version(ctx.layout, &version)?;
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_version(layout: &Layout, version: &VersionJson) -> Result<()> {
|
||||||
|
let path = layout.version_json(&version.id);
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||||
|
std::fs::write(path, serde_json::to_vec_pretty(version)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forge ≤ 1.12: `install_profile.json` holds `install` + `versionInfo`.
|
||||||
|
fn install_legacy(ctx: &ForgeInstall<'_>, zip: &mut Zip, raw: serde_json::Value) -> Result<VersionJson> {
|
||||||
|
let info = raw.get("versionInfo").cloned().unwrap_or_default();
|
||||||
|
// Old profiles point at the long-dead files.minecraftforge.net/maven.
|
||||||
|
let fixed = serde_json::to_string(&info)?
|
||||||
|
.replace("http://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/")
|
||||||
|
.replace("https://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/");
|
||||||
|
let mut version: VersionJson = serde_json::from_str(&fixed)?;
|
||||||
|
let install = raw.get("install").ok_or_else(|| anyhow!("legacy installer missing 'install'"))?;
|
||||||
|
let coord = install.get("path").and_then(|v| v.as_str()).and_then(Coord::parse).ok_or_else(|| anyhow!("bad install.path"))?;
|
||||||
|
let file_path = install.get("filePath").and_then(|v| v.as_str()).ok_or_else(|| anyhow!("bad install.filePath"))?;
|
||||||
|
let dest = ctx.layout.libraries().join(coord.path());
|
||||||
|
if !dest.exists() {
|
||||||
|
extract_entry(zip, file_path, &dest)?;
|
||||||
|
}
|
||||||
|
version.inherits_from = Some(ctx.mc.to_string());
|
||||||
|
save_version(ctx.layout, &version)?;
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_data_value(value: &str, libs_dir: &Path, tmp: &Path, zip: &mut Zip) -> Result<String> {
|
||||||
|
if value.starts_with('[') && value.ends_with(']') {
|
||||||
|
let coord = Coord::parse(value).ok_or_else(|| anyhow!("bad coordinate {value}"))?;
|
||||||
|
return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') {
|
||||||
|
return Ok(value[1..value.len() - 1].to_string());
|
||||||
|
}
|
||||||
|
if value.starts_with('/') {
|
||||||
|
let dest = tmp.join(value.trim_start_matches('/'));
|
||||||
|
extract_entry(zip, value, &dest)?;
|
||||||
|
return Ok(dest.to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
Ok(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_arg(arg: &str, data: &HashMap<String, String>, libs_dir: &Path) -> Result<String> {
|
||||||
|
if arg.starts_with('[') && arg.ends_with(']') {
|
||||||
|
let coord = Coord::parse(arg).ok_or_else(|| anyhow!("bad coordinate {arg}"))?;
|
||||||
|
return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
if arg.starts_with('{') && arg.ends_with('}') {
|
||||||
|
let key = &arg[1..arg.len() - 1];
|
||||||
|
return data.get(key).cloned().ok_or_else(|| anyhow!("installer references unknown data key {key}"));
|
||||||
|
}
|
||||||
|
if arg.len() >= 2 && arg.starts_with('\'') && arg.ends_with('\'') {
|
||||||
|
return Ok(arg[1..arg.len() - 1].to_string());
|
||||||
|
}
|
||||||
|
let mut out = arg.to_string();
|
||||||
|
for (k, v) in data {
|
||||||
|
let needle = format!("{{{k}}}");
|
||||||
|
if out.contains(&needle) {
|
||||||
|
out = out.replace(&needle, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn jar_main_class(jar: &Path) -> Result<String> {
|
||||||
|
let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).with_context(|| format!("opening {}", jar.display()))?)?;
|
||||||
|
let manifest = String::from_utf8_lossy(&read_entry(&mut zip, "META-INF/MANIFEST.MF")?).into_owned();
|
||||||
|
parse_main_class(&manifest).ok_or_else(|| anyhow!("{} has no Main-Class", jar.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_main_class(manifest: &str) -> Option<String> {
|
||||||
|
// Manifest lines wrap at 72 bytes; continuation lines start with a space.
|
||||||
|
let mut unwrapped = String::new();
|
||||||
|
for line in manifest.lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix(' ') {
|
||||||
|
unwrapped.push_str(rest);
|
||||||
|
} else {
|
||||||
|
unwrapped.push('\n');
|
||||||
|
unwrapped.push_str(line.trim_end_matches('\r'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unwrapped.lines().find_map(|l| l.strip_prefix("Main-Class:").map(|v| v.trim().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_processors(ctx: &ForgeInstall<'_>, zip: &mut Zip, profile: &InstallProfile, installer: &Path) -> Result<()> {
|
||||||
|
let libs_dir = ctx.layout.libraries();
|
||||||
|
let tmp = ctx.layout.cache().join("forge-tmp").join(ctx.version);
|
||||||
|
std::fs::create_dir_all(&tmp)?;
|
||||||
|
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
for (key, entry) in &profile.data {
|
||||||
|
data.insert(key.clone(), resolve_data_value(&entry.client, &libs_dir, &tmp, zip)?);
|
||||||
|
}
|
||||||
|
data.insert("SIDE".into(), "client".into());
|
||||||
|
data.insert("MINECRAFT_JAR".into(), ctx.client_jar.to_string_lossy().into_owned());
|
||||||
|
data.insert("MINECRAFT_VERSION".into(), ctx.mc.to_string());
|
||||||
|
data.insert("ROOT".into(), ctx.layout.root.to_string_lossy().into_owned());
|
||||||
|
data.insert("INSTALLER".into(), installer.to_string_lossy().into_owned());
|
||||||
|
data.insert("LIBRARY_DIR".into(), libs_dir.to_string_lossy().into_owned());
|
||||||
|
|
||||||
|
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||||
|
let processors: Vec<&Processor> =
|
||||||
|
profile.processors.iter().filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true)).collect();
|
||||||
|
let total = processors.len() as u32;
|
||||||
|
|
||||||
|
for (i, proc) in processors.into_iter().enumerate() {
|
||||||
|
ctx.reporter.as_ref()(Event::Progress {
|
||||||
|
stage: Stage::Loader,
|
||||||
|
done: i as u64,
|
||||||
|
total: total as u64,
|
||||||
|
files_done: i as u32,
|
||||||
|
files_total: total,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip processors whose outputs already exist with the right hash.
|
||||||
|
if !proc.outputs.is_empty() {
|
||||||
|
let mut all_ok = true;
|
||||||
|
for (k, v) in &proc.outputs {
|
||||||
|
let path = resolve_arg(k, &data, &libs_dir)?;
|
||||||
|
let sha = resolve_arg(v, &data, &libs_dir)?;
|
||||||
|
if !http::file_ok(Path::new(&path), Some(&sha), None, true) {
|
||||||
|
all_ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all_ok {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let jar = libs_dir.join(Coord::parse(&proc.jar).ok_or_else(|| anyhow!("bad processor jar {}", proc.jar))?.path());
|
||||||
|
let main_class = jar_main_class(&jar)?;
|
||||||
|
let mut cp = vec![jar.to_string_lossy().into_owned()];
|
||||||
|
for c in &proc.classpath {
|
||||||
|
let coord = Coord::parse(c).ok_or_else(|| anyhow!("bad classpath entry {c}"))?;
|
||||||
|
cp.push(libs_dir.join(coord.path()).to_string_lossy().into_owned());
|
||||||
|
}
|
||||||
|
let args = proc.args.iter().map(|a| resolve_arg(a, &data, &libs_dir)).collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new(ctx.java);
|
||||||
|
cmd.arg("-cp").arg(cp.join(sep)).arg(&main_class).args(&args);
|
||||||
|
cmd.current_dir(&tmp);
|
||||||
|
cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).kill_on_drop(true);
|
||||||
|
#[cfg(windows)]
|
||||||
|
cmd.creation_flags(0x0800_0000);
|
||||||
|
let mut child = cmd.spawn().with_context(|| format!("starting processor {main_class}"))?;
|
||||||
|
|
||||||
|
let tail = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<String>::new()));
|
||||||
|
let pump = |stream: Option<Box<dyn tokio::io::AsyncRead + Unpin + Send>>| {
|
||||||
|
let reporter = ctx.reporter.clone();
|
||||||
|
let tail = tail.clone();
|
||||||
|
async move {
|
||||||
|
let Some(stream) = stream else { return };
|
||||||
|
let mut lines = BufReader::new(stream).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
{
|
||||||
|
let mut t = tail.lock().unwrap();
|
||||||
|
t.push_back(line.clone());
|
||||||
|
if t.len() > 30 {
|
||||||
|
t.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reporter(Event::Log { line });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let out = child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
|
||||||
|
let err = child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>);
|
||||||
|
let (status, _, _) = tokio::join!(child.wait(), pump(out), pump(err));
|
||||||
|
let status = status?;
|
||||||
|
if !status.success() {
|
||||||
|
let tail = tail.lock().unwrap().iter().cloned().collect::<Vec<_>>().join("\n");
|
||||||
|
bail!("{main_class} failed ({status}):\n{tail}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(&tmp).ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_main_class_with_wrapping() {
|
||||||
|
let mf = "Manifest-Version: 1.0\r\nMain-Class: net.minecraftforge.binarypatcher.Consol\r\n eTool\r\nOther: x\r\n";
|
||||||
|
assert_eq!(parse_main_class(mf).as_deref(), Some("net.minecraftforge.binarypatcher.ConsoleTool"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_processor_args() {
|
||||||
|
let libs = Path::new("/libs");
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("MC_SLIM".to_string(), "/libs/slim.jar".to_string());
|
||||||
|
assert_eq!(resolve_arg("{MC_SLIM}", &data, libs).unwrap(), "/libs/slim.jar");
|
||||||
|
assert_eq!(resolve_arg("--flag", &data, libs).unwrap(), "--flag");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_arg("[net.minecraft:client:1.20.1:srg]", &data, libs).unwrap(),
|
||||||
|
Path::new("/libs").join("net/minecraft/client/1.20.1/client-1.20.1-srg.jar").to_string_lossy()
|
||||||
|
);
|
||||||
|
assert!(resolve_arg("{MISSING}", &data, libs).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//! Mod loader installation. Each loader produces a child version JSON that
|
||||||
|
//! is merged on top of vanilla.
|
||||||
|
|
||||||
|
pub mod fabric;
|
||||||
|
pub mod forge;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/// A Maven coordinate: `group:artifact:version[:classifier][@ext]`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Coord {
|
||||||
|
pub group: String,
|
||||||
|
pub artifact: String,
|
||||||
|
pub version: String,
|
||||||
|
pub classifier: Option<String>,
|
||||||
|
pub ext: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Coord {
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
let s = s.trim().trim_start_matches('[').trim_end_matches(']');
|
||||||
|
let (main, ext) = match s.split_once('@') {
|
||||||
|
Some((m, e)) => (m, e.to_string()),
|
||||||
|
None => (s, "jar".to_string()),
|
||||||
|
};
|
||||||
|
let parts: Vec<&str> = main.split(':').collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
group: parts[0].into(),
|
||||||
|
artifact: parts[1].into(),
|
||||||
|
version: parts[2].into(),
|
||||||
|
classifier: parts.get(3).map(|c| c.to_string()),
|
||||||
|
ext,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relative repository path (`/`-separated).
|
||||||
|
pub fn path(&self) -> String {
|
||||||
|
let classifier = self.classifier.as_ref().map(|c| format!("-{c}")).unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"{}/{}/{}/{}-{}{}.{}",
|
||||||
|
self.group.replace('.', "/"),
|
||||||
|
self.artifact,
|
||||||
|
self.version,
|
||||||
|
self.artifact,
|
||||||
|
self.version,
|
||||||
|
classifier,
|
||||||
|
self.ext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identity used to de-duplicate libraries (version-less).
|
||||||
|
pub fn key(&self) -> String {
|
||||||
|
match &self.classifier {
|
||||||
|
Some(c) => format!("{}:{}:{}", self.group, self.artifact, c),
|
||||||
|
None => format!("{}:{}", self.group, self.artifact),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_classifier(&self, classifier: &str) -> Self {
|
||||||
|
Self { classifier: Some(classifier.to_string()), ..self.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_coords() {
|
||||||
|
let c = Coord::parse("net.fabricmc:fabric-loader:0.16.9").unwrap();
|
||||||
|
assert_eq!(c.path(), "net/fabricmc/fabric-loader/0.16.9/fabric-loader-0.16.9.jar");
|
||||||
|
let c = Coord::parse("de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip").unwrap();
|
||||||
|
assert_eq!(c.path(), "de/oceanlabs/mcp/mcp_config/1.20.1-20230612.114412/mcp_config-1.20.1-20230612.114412.zip");
|
||||||
|
let c = Coord::parse("[net.minecraft:client:1.20.1-20230612.114412:slim]").unwrap();
|
||||||
|
assert_eq!(c.path(), "net/minecraft/client/1.20.1-20230612.114412/client-1.20.1-20230612.114412-slim.jar");
|
||||||
|
assert_eq!(c.key(), "net.minecraft:client:slim");
|
||||||
|
assert!(Coord::parse("nope").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! Version metadata from Mojang and the mod-loader projects. Used by the
|
||||||
|
//! launcher (to install) and by the panel (to populate version pickers).
|
||||||
|
|
||||||
|
use crate::http::get_json;
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use scopenet_shared::Loader;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub const MOJANG_MANIFEST: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
|
||||||
|
pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2";
|
||||||
|
pub const QUILT_META: &str = "https://meta.quiltmc.org/v3";
|
||||||
|
pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net";
|
||||||
|
pub const FORGE_FILES: &str = "https://files.minecraftforge.net/net/minecraftforge/forge";
|
||||||
|
pub const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases";
|
||||||
|
pub const NEOFORGE_VERSIONS: &str = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VersionManifest {
|
||||||
|
pub latest: Latest,
|
||||||
|
pub versions: Vec<ManifestVersion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Latest {
|
||||||
|
pub release: String,
|
||||||
|
pub snapshot: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ManifestVersion {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: String,
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sha1: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub release_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mojang_manifest(client: &reqwest::Client) -> Result<VersionManifest> {
|
||||||
|
get_json(client, MOJANG_MANIFEST).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct LoaderVersion {
|
||||||
|
pub version: String,
|
||||||
|
pub stable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FabricLoaderEntry {
|
||||||
|
loader: FabricLoaderInfo,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FabricLoaderInfo {
|
||||||
|
version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
stable: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NeoVersions {
|
||||||
|
versions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ForgePromos {
|
||||||
|
promos: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a NeoForge version to the Minecraft version it targets.
|
||||||
|
/// `21.1.77` → `1.21.1`, `21.0.5` → `1.21`, `26.1.0.3` → `26.1` (new
|
||||||
|
/// year-based Minecraft numbering).
|
||||||
|
pub fn neoforge_mc_version(neo: &str) -> Option<String> {
|
||||||
|
let core = neo.split('-').next()?;
|
||||||
|
let parts: Vec<&str> = core.split('.').collect();
|
||||||
|
let major: u32 = parts.first()?.parse().ok()?;
|
||||||
|
let minor: u32 = parts.get(1)?.parse().ok()?;
|
||||||
|
if major >= 26 {
|
||||||
|
let patch: u32 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
|
||||||
|
return Some(if patch == 0 { format!("{major}.{minor}") } else { format!("{major}.{minor}.{patch}") });
|
||||||
|
}
|
||||||
|
Some(if minor == 0 { format!("1.{major}") } else { format!("1.{major}.{minor}") })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All loader versions for a Minecraft version, newest first.
|
||||||
|
pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str) -> Result<Vec<LoaderVersion>> {
|
||||||
|
Ok(match loader {
|
||||||
|
Loader::Vanilla => vec![],
|
||||||
|
Loader::Fabric | Loader::Quilt => {
|
||||||
|
let url = if loader == Loader::Fabric {
|
||||||
|
format!("{FABRIC_META}/versions/loader/{mc}")
|
||||||
|
} else {
|
||||||
|
format!("{QUILT_META}/versions/loader/{mc}")
|
||||||
|
};
|
||||||
|
let entries: Vec<FabricLoaderEntry> = get_json(client, &url).await?;
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
let stable = e.loader.stable.unwrap_or_else(|| !e.loader.version.contains("beta"));
|
||||||
|
LoaderVersion { version: e.loader.version, stable }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
Loader::Forge => {
|
||||||
|
let all: HashMap<String, Vec<String>> = get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?;
|
||||||
|
let promos: ForgePromos =
|
||||||
|
get_json(client, &format!("{FORGE_FILES}/promotions_slim.json")).await.unwrap_or(ForgePromos { promos: HashMap::new() });
|
||||||
|
let recommended = promos.promos.get(&format!("{mc}-recommended")).map(|v| format!("{mc}-{v}"));
|
||||||
|
let mut list: Vec<LoaderVersion> = all
|
||||||
|
.get(mc)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.map(|v| LoaderVersion { stable: Some(&v) == recommended.as_ref(), version: v })
|
||||||
|
.collect();
|
||||||
|
// Put the recommended build first.
|
||||||
|
list.sort_by_key(|v| !v.stable);
|
||||||
|
list
|
||||||
|
}
|
||||||
|
Loader::NeoForge => {
|
||||||
|
let all: NeoVersions = get_json(client, NEOFORGE_VERSIONS).await?;
|
||||||
|
all.versions
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.filter(|v| neoforge_mc_version(v).as_deref() == Some(mc))
|
||||||
|
.map(|v| LoaderVersion { stable: !v.contains("beta") && !v.contains("alpha"), version: v })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn "latest"/"recommended"/empty into a concrete loader version.
|
||||||
|
pub async fn resolve_loader_version(client: &reqwest::Client, loader: Loader, mc: &str, requested: Option<&str>) -> Result<Option<String>> {
|
||||||
|
if loader == Loader::Vanilla {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if let Some(v) = requested.map(str::trim).filter(|v| !v.is_empty() && *v != "latest" && *v != "recommended") {
|
||||||
|
return Ok(Some(normalize_forge_version(loader, mc, v)));
|
||||||
|
}
|
||||||
|
let versions = loader_versions(client, loader, mc).await.context("fetching loader versions")?;
|
||||||
|
let pick = versions.iter().find(|v| v.stable).or_else(|| versions.first());
|
||||||
|
match pick {
|
||||||
|
Some(v) => Ok(Some(v.version.clone())),
|
||||||
|
None => bail!("no {} versions available for Minecraft {mc}", loader.as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forge versions are stored as `<mc>-<forge>` (the Maven version);
|
||||||
|
/// accept a bare `47.2.0` too.
|
||||||
|
pub fn normalize_forge_version(loader: Loader, mc: &str, v: &str) -> String {
|
||||||
|
if loader == Loader::Forge && !v.contains('-') {
|
||||||
|
format!("{mc}-{v}")
|
||||||
|
} else {
|
||||||
|
v.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forge_installer_url(loader: Loader, version: &str) -> String {
|
||||||
|
match loader {
|
||||||
|
Loader::NeoForge => format!("{NEOFORGE_MAVEN}/net/neoforged/neoforge/{version}/neoforge-{version}-installer.jar"),
|
||||||
|
_ => format!("{FORGE_MAVEN}/net/minecraftforge/forge/{version}/forge-{version}-installer.jar"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neoforge_mapping() {
|
||||||
|
assert_eq!(neoforge_mc_version("21.1.77").as_deref(), Some("1.21.1"));
|
||||||
|
assert_eq!(neoforge_mc_version("20.4.237").as_deref(), Some("1.20.4"));
|
||||||
|
assert_eq!(neoforge_mc_version("21.0.5-beta").as_deref(), Some("1.21"));
|
||||||
|
assert_eq!(neoforge_mc_version("26.1.0.3").as_deref(), Some("26.1"));
|
||||||
|
assert_eq!(neoforge_mc_version("26.1.2.1").as_deref(), Some("26.1.2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forge_urls() {
|
||||||
|
assert_eq!(normalize_forge_version(Loader::Forge, "1.20.1", "47.2.0"), "1.20.1-47.2.0");
|
||||||
|
assert_eq!(
|
||||||
|
forge_installer_url(Loader::Forge, "1.20.1-47.2.0"),
|
||||||
|
"https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
//! Microsoft account sign-in (device-code flow → Xbox Live → Minecraft).
|
||||||
|
//!
|
||||||
|
//! Requires an Azure app registration ("client id") that Mojang has approved
|
||||||
|
//! for the Minecraft API. The admin configures it in the panel.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode";
|
||||||
|
const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
|
||||||
|
const SCOPE: &str = "XboxLive.signin offline_access";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceCode {
|
||||||
|
pub device_code: String,
|
||||||
|
pub user_code: String,
|
||||||
|
pub verification_uri: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
#[serde(default = "default_interval")]
|
||||||
|
pub interval: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_interval() -> u64 {
|
||||||
|
5
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
access_token: Option<String>,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
error_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MsaSession {
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub mc_access_token: String,
|
||||||
|
/// Unix seconds.
|
||||||
|
pub mc_expires_at: i64,
|
||||||
|
pub profile: McProfile,
|
||||||
|
pub xuid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McProfile {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skins: Vec<McSkin>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct McSkin {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub state: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub variant: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result<DeviceCode> {
|
||||||
|
let resp = client.post(DEVICE_CODE_URL).form(&[("client_id", client_id), ("scope", SCOPE)]).send().await?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
bail!("Microsoft rejected the sign-in request: {body}");
|
||||||
|
}
|
||||||
|
Ok(resp.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until the user finishes signing in, then complete the full chain.
|
||||||
|
pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code: &DeviceCode) -> Result<MsaSession> {
|
||||||
|
let mut interval = code.interval.max(1);
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(code.expires_in);
|
||||||
|
loop {
|
||||||
|
if std::time::Instant::now() > deadline {
|
||||||
|
bail!("the sign-in code expired, please try again");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(interval)).await;
|
||||||
|
let resp: TokenResponse = client
|
||||||
|
.post(TOKEN_URL)
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||||
|
("client_id", client_id),
|
||||||
|
("device_code", code.device_code.as_str()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
match resp.error.as_deref() {
|
||||||
|
None => {
|
||||||
|
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
|
||||||
|
let refresh = resp.refresh_token.ok_or_else(|| anyhow!("no refresh token"))?;
|
||||||
|
return complete(client, &access, refresh).await;
|
||||||
|
}
|
||||||
|
Some("authorization_pending") => continue,
|
||||||
|
Some("slow_down") => interval += 5,
|
||||||
|
Some("authorization_declined") => bail!("sign-in was cancelled"),
|
||||||
|
Some("expired_token") => bail!("the sign-in code expired, please try again"),
|
||||||
|
Some(other) => bail!("Microsoft sign-in failed: {other} {}", resp.error_description.unwrap_or_default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh a saved session (used before each launch when the Minecraft
|
||||||
|
/// token is close to expiring).
|
||||||
|
pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result<MsaSession> {
|
||||||
|
let resp: TokenResponse = client
|
||||||
|
.post(TOKEN_URL)
|
||||||
|
.form(&[("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token), ("scope", SCOPE)])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
if let Some(err) = resp.error {
|
||||||
|
bail!("your Microsoft session expired ({err}); please sign in again");
|
||||||
|
}
|
||||||
|
let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?;
|
||||||
|
let refresh = resp.refresh_token.unwrap_or_else(|| refresh_token.to_string());
|
||||||
|
complete(client, &access, refresh).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
struct XboxResponse {
|
||||||
|
token: String,
|
||||||
|
display_claims: DisplayClaims,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DisplayClaims {
|
||||||
|
xui: Vec<Xui>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Xui {
|
||||||
|
uhs: String,
|
||||||
|
#[serde(default)]
|
||||||
|
xid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct McLogin {
|
||||||
|
access_token: String,
|
||||||
|
expires_in: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result<MsaSession> {
|
||||||
|
// Xbox Live
|
||||||
|
let xbl: XboxResponse = client
|
||||||
|
.post("https://user.auth.xboxlive.com/user/authenticate")
|
||||||
|
.json(&json!({
|
||||||
|
"Properties": {"AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": format!("d={ms_access}")},
|
||||||
|
"RelyingParty": "http://auth.xboxlive.com",
|
||||||
|
"TokenType": "JWT"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()
|
||||||
|
.context("Xbox Live authentication failed")?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// XSTS
|
||||||
|
let resp = client
|
||||||
|
.post("https://xsts.auth.xboxlive.com/xsts/authorize")
|
||||||
|
.json(&json!({
|
||||||
|
"Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl.token]},
|
||||||
|
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||||
|
"TokenType": "JWT"
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if resp.status().as_u16() == 401 {
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
|
let msg = match body.get("XErr").and_then(|v| v.as_u64()) {
|
||||||
|
Some(2148916233) => "this Microsoft account has no Xbox profile yet — sign in once at xbox.com, then try again",
|
||||||
|
Some(2148916235) => "Xbox Live isn't available in your country",
|
||||||
|
Some(2148916236) | Some(2148916237) => "this account needs adult verification on xbox.com",
|
||||||
|
Some(2148916238) => "this is a child account — an adult must add it to a Microsoft family first",
|
||||||
|
_ => "Xbox Live refused the sign-in",
|
||||||
|
};
|
||||||
|
bail!("{msg}");
|
||||||
|
}
|
||||||
|
let xsts: XboxResponse = resp.error_for_status()?.json().await?;
|
||||||
|
let claims = xsts.display_claims.xui.first().ok_or_else(|| anyhow!("missing Xbox user hash"))?;
|
||||||
|
let uhs = claims.uhs.clone();
|
||||||
|
let xuid = claims.xid.clone();
|
||||||
|
|
||||||
|
// Minecraft
|
||||||
|
let mc: McLogin = client
|
||||||
|
.post("https://api.minecraftservices.com/authentication/login_with_xbox")
|
||||||
|
.json(&json!({ "identityToken": format!("XBL3.0 x={uhs};{}", xsts.token) }))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()
|
||||||
|
.context("Minecraft services rejected the Xbox token (is the Azure app approved for Minecraft?)")?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let resp = client.get("https://api.minecraftservices.com/minecraft/profile").bearer_auth(&mc.access_token).send().await?;
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
bail!("this Microsoft account doesn't own Minecraft: Java Edition");
|
||||||
|
}
|
||||||
|
let profile: McProfile = resp.error_for_status()?.json().await?;
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||||
|
Ok(MsaSession { refresh_token, mc_access_token: mc.access_token, mc_expires_at: now + mc.expires_in, profile, xuid })
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! Writes player keybinds / preferences into Minecraft's `options.txt`.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Merge `key_<binding>:<key>` lines into options.txt, keeping everything
|
||||||
|
/// else the game has written. Only for 1.13+ (named key codes).
|
||||||
|
pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap<String, String>) -> Result<()> {
|
||||||
|
let mut values: BTreeMap<String, String> = binds.iter().map(|(k, v)| (format!("key_{k}"), v.clone())).collect();
|
||||||
|
apply(game_dir, &mut values)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge arbitrary `name:value` options.
|
||||||
|
pub fn apply(game_dir: &Path, values: &mut BTreeMap<String, String>) -> Result<()> {
|
||||||
|
if values.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = game_dir.join("options.txt");
|
||||||
|
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
for line in existing.lines() {
|
||||||
|
match line.split_once(':') {
|
||||||
|
Some((k, _)) if values.contains_key(k) => {
|
||||||
|
let v = values.remove(k).unwrap();
|
||||||
|
lines.push(format!("{k}:{v}"));
|
||||||
|
}
|
||||||
|
_ => lines.push(line.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (k, v) in values.iter() {
|
||||||
|
lines.push(format!("{k}:{v}"));
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(game_dir)?;
|
||||||
|
std::fs::write(path, lines.join("\n") + "\n")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merges_keybinds() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("options.txt"), "version:3465\nkey_key.forward:key.keyboard.w\nfov:0.0\n").unwrap();
|
||||||
|
let mut binds = BTreeMap::new();
|
||||||
|
binds.insert("key.forward".to_string(), "key.keyboard.up".to_string());
|
||||||
|
binds.insert("key.sprint".to_string(), "key.keyboard.left.control".to_string());
|
||||||
|
apply_keybinds(dir.path(), &binds).unwrap();
|
||||||
|
let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap();
|
||||||
|
assert_eq!(out, "version:3465\nkey_key.forward:key.keyboard.up\nfov:0.0\nkey_key.sprint:key.keyboard.left.control\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// On-disk layout. Versions, libraries, assets and Java runtimes are shared
|
||||||
|
/// between instances so a second instance of the same version costs almost
|
||||||
|
/// no disk space.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Layout {
|
||||||
|
pub root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { root: root.into() }
|
||||||
|
}
|
||||||
|
pub fn libraries(&self) -> PathBuf {
|
||||||
|
self.root.join("libraries")
|
||||||
|
}
|
||||||
|
pub fn versions(&self) -> PathBuf {
|
||||||
|
self.root.join("versions")
|
||||||
|
}
|
||||||
|
pub fn version_dir(&self, id: &str) -> PathBuf {
|
||||||
|
self.versions().join(id)
|
||||||
|
}
|
||||||
|
pub fn version_json(&self, id: &str) -> PathBuf {
|
||||||
|
self.version_dir(id).join(format!("{id}.json"))
|
||||||
|
}
|
||||||
|
pub fn version_jar(&self, id: &str) -> PathBuf {
|
||||||
|
self.version_dir(id).join(format!("{id}.jar"))
|
||||||
|
}
|
||||||
|
pub fn assets(&self) -> PathBuf {
|
||||||
|
self.root.join("assets")
|
||||||
|
}
|
||||||
|
pub fn runtimes(&self) -> PathBuf {
|
||||||
|
self.root.join("runtimes")
|
||||||
|
}
|
||||||
|
pub fn natives(&self, version_id: &str) -> PathBuf {
|
||||||
|
self.root.join("natives").join(version_id)
|
||||||
|
}
|
||||||
|
pub fn instances(&self) -> PathBuf {
|
||||||
|
self.root.join("instances")
|
||||||
|
}
|
||||||
|
pub fn instance_dir(&self, id: &str) -> PathBuf {
|
||||||
|
self.instances().join(sanitize_id(id))
|
||||||
|
}
|
||||||
|
pub fn cache(&self) -> PathBuf {
|
||||||
|
self.root.join("cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep instance ids filesystem-safe no matter what the panel sends.
|
||||||
|
pub fn sanitize_id(id: &str) -> String {
|
||||||
|
let s: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect();
|
||||||
|
let s = s.trim_matches('.').to_string();
|
||||||
|
if s.is_empty() {
|
||||||
|
"_".into()
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join a server-provided relative path onto `base`, refusing anything that
|
||||||
|
/// could escape it (`..`, absolute paths, drive letters).
|
||||||
|
pub fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
|
||||||
|
let rel = rel.replace('\\', "/");
|
||||||
|
if rel.starts_with('/') || rel.contains(':') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = base.to_path_buf();
|
||||||
|
for part in rel.split('/') {
|
||||||
|
match part {
|
||||||
|
"" | "." => continue,
|
||||||
|
".." => return None,
|
||||||
|
p => out.push(p),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out == base {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn safe_join_blocks_traversal() {
|
||||||
|
let base = Path::new("/game");
|
||||||
|
assert_eq!(safe_join(base, "mods/a.jar"), Some(PathBuf::from("/game/mods/a.jar")));
|
||||||
|
assert_eq!(safe_join(base, "config\\x.toml"), Some(PathBuf::from("/game/config/x.toml")));
|
||||||
|
assert!(safe_join(base, "../evil").is_none());
|
||||||
|
assert!(safe_join(base, "mods/../../evil").is_none());
|
||||||
|
assert!(safe_join(base, "/etc/passwd").is_none());
|
||||||
|
assert!(safe_join(base, "C:/Windows").is_none());
|
||||||
|
assert!(safe_join(base, "").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ids_are_sanitized() {
|
||||||
|
assert_eq!(sanitize_id("my pack/../x"), "my_pack_.._x");
|
||||||
|
assert_eq!(sanitize_id(".."), "_");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
//! Minecraft Server List Ping: online players, MOTD, version, latency.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Default)]
|
||||||
|
pub struct ServerStatus {
|
||||||
|
pub online: bool,
|
||||||
|
pub version: String,
|
||||||
|
pub protocol: i64,
|
||||||
|
pub players_online: i64,
|
||||||
|
pub players_max: i64,
|
||||||
|
pub sample: Vec<String>,
|
||||||
|
/// MOTD using legacy `§` colour codes (rendered by the UI).
|
||||||
|
pub motd: String,
|
||||||
|
/// `data:image/png;base64,...`
|
||||||
|
pub favicon: Option<String>,
|
||||||
|
pub latency_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
|
||||||
|
loop {
|
||||||
|
let mut byte = (value & 0x7f) as u8;
|
||||||
|
value = ((value as u32) >> 7) as i32;
|
||||||
|
if value != 0 {
|
||||||
|
byte |= 0x80;
|
||||||
|
}
|
||||||
|
buf.push(byte);
|
||||||
|
if value == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_varint(stream: &mut TcpStream) -> Result<i32> {
|
||||||
|
let mut result = 0i32;
|
||||||
|
for i in 0..5 {
|
||||||
|
let byte = stream.read_u8().await?;
|
||||||
|
result |= ((byte & 0x7f) as i32) << (7 * i);
|
||||||
|
if byte & 0x80 == 0 {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("varint too long")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packet(id: i32, body: &[u8]) -> Vec<u8> {
|
||||||
|
let mut inner = Vec::new();
|
||||||
|
write_varint(&mut inner, id);
|
||||||
|
inner.extend_from_slice(body);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
write_varint(&mut out, inner.len() as i32);
|
||||||
|
out.extend(inner);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawStatus {
|
||||||
|
#[serde(default)]
|
||||||
|
version: Option<RawVersion>,
|
||||||
|
#[serde(default)]
|
||||||
|
players: Option<RawPlayers>,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
favicon: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawVersion {
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
protocol: i64,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawPlayers {
|
||||||
|
#[serde(default)]
|
||||||
|
max: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
online: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
sample: Vec<RawSample>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawSample {
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_code(name: &str) -> Option<char> {
|
||||||
|
Some(match name {
|
||||||
|
"black" => '0',
|
||||||
|
"dark_blue" => '1',
|
||||||
|
"dark_green" => '2',
|
||||||
|
"dark_aqua" => '3',
|
||||||
|
"dark_red" => '4',
|
||||||
|
"dark_purple" => '5',
|
||||||
|
"gold" => '6',
|
||||||
|
"gray" => '7',
|
||||||
|
"dark_gray" => '8',
|
||||||
|
"blue" => '9',
|
||||||
|
"green" => 'a',
|
||||||
|
"aqua" => 'b',
|
||||||
|
"red" => 'c',
|
||||||
|
"light_purple" => 'd',
|
||||||
|
"yellow" => 'e',
|
||||||
|
"white" => 'f',
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flatten a chat component into a `§`-coded string.
|
||||||
|
pub fn flatten_chat(value: &serde_json::Value) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
fn walk(v: &serde_json::Value, out: &mut String) {
|
||||||
|
match v {
|
||||||
|
serde_json::Value::String(s) => out.push_str(s),
|
||||||
|
serde_json::Value::Array(items) => items.iter().for_each(|i| walk(i, out)),
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
if let Some(c) = map.get("color").and_then(|c| c.as_str()) {
|
||||||
|
if let Some(code) = color_code(c) {
|
||||||
|
out.push('§');
|
||||||
|
out.push(code);
|
||||||
|
} else if c.starts_with('#') && c.len() == 7 {
|
||||||
|
// Hex colours: emit a custom marker the UI understands.
|
||||||
|
out.push_str("§#");
|
||||||
|
out.push_str(&c[1..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, code) in [("bold", 'l'), ("italic", 'o'), ("underlined", 'n'), ("strikethrough", 'm')] {
|
||||||
|
if map.get(key).and_then(|b| b.as_bool()) == Some(true) {
|
||||||
|
out.push('§');
|
||||||
|
out.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(t) = map.get("text").and_then(|t| t.as_str()) {
|
||||||
|
out.push_str(t);
|
||||||
|
}
|
||||||
|
if let Some(extra) = map.get("extra") {
|
||||||
|
walk(extra, out);
|
||||||
|
}
|
||||||
|
if map.contains_key("color") {
|
||||||
|
out.push_str("§r");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(value, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ping(host: &str, port: u16) -> Result<ServerStatus> {
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), ping_inner(host, port)).await.context("server did not respond in time")?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ping_inner(host: &str, port: u16) -> Result<ServerStatus> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut stream = TcpStream::connect((host, port)).await.with_context(|| format!("connecting to {host}:{port}"))?;
|
||||||
|
stream.set_nodelay(true).ok();
|
||||||
|
let connect_ms = started.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
let mut body = Vec::new();
|
||||||
|
write_varint(&mut body, -1); // protocol: "whatever you are"
|
||||||
|
write_varint(&mut body, host.len() as i32);
|
||||||
|
body.extend_from_slice(host.as_bytes());
|
||||||
|
body.extend_from_slice(&port.to_be_bytes());
|
||||||
|
write_varint(&mut body, 1); // next state: status
|
||||||
|
stream.write_all(&packet(0x00, &body)).await?;
|
||||||
|
stream.write_all(&packet(0x00, &[])).await?;
|
||||||
|
|
||||||
|
let _len = read_varint(&mut stream).await?;
|
||||||
|
let id = read_varint(&mut stream).await?;
|
||||||
|
if id != 0x00 {
|
||||||
|
bail!("unexpected packet {id}");
|
||||||
|
}
|
||||||
|
let str_len = read_varint(&mut stream).await? as usize;
|
||||||
|
if str_len > 1 << 21 {
|
||||||
|
bail!("status response too large");
|
||||||
|
}
|
||||||
|
let mut json = vec![0u8; str_len];
|
||||||
|
stream.read_exact(&mut json).await?;
|
||||||
|
|
||||||
|
// Latency via ping/pong (fallback: TCP connect time).
|
||||||
|
let t = Instant::now();
|
||||||
|
let mut latency_ms = connect_ms;
|
||||||
|
if stream.write_all(&packet(0x01, &42i64.to_be_bytes())).await.is_ok() {
|
||||||
|
if let Ok(Ok(_)) = tokio::time::timeout(Duration::from_secs(2), async {
|
||||||
|
let _ = read_varint(&mut stream).await?;
|
||||||
|
let _ = read_varint(&mut stream).await?;
|
||||||
|
stream.read_i64().await.map_err(anyhow::Error::from)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
latency_ms = t.elapsed().as_millis() as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: RawStatus = serde_json::from_slice(&json).context("invalid status JSON")?;
|
||||||
|
let players = raw.players.unwrap_or(RawPlayers { max: 0, online: 0, sample: vec![] });
|
||||||
|
let version = raw.version.unwrap_or(RawVersion { name: String::new(), protocol: 0 });
|
||||||
|
Ok(ServerStatus {
|
||||||
|
online: true,
|
||||||
|
version: version.name,
|
||||||
|
protocol: version.protocol,
|
||||||
|
players_online: players.online,
|
||||||
|
players_max: players.max,
|
||||||
|
sample: players.sample.into_iter().map(|s| s.name).filter(|n| !n.is_empty()).take(12).collect(),
|
||||||
|
motd: raw.description.as_ref().map(flatten_chat).unwrap_or_default(),
|
||||||
|
favicon: raw.favicon,
|
||||||
|
latency_ms,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn varints() {
|
||||||
|
let mut b = Vec::new();
|
||||||
|
write_varint(&mut b, 300);
|
||||||
|
assert_eq!(b, vec![0xac, 0x02]);
|
||||||
|
let mut b = Vec::new();
|
||||||
|
write_varint(&mut b, -1);
|
||||||
|
assert_eq!(b, vec![0xff, 0xff, 0xff, 0xff, 0x0f]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flattens_motd() {
|
||||||
|
let v: serde_json::Value =
|
||||||
|
serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap();
|
||||||
|
assert_eq!(flatten_chat(&v), "§b§lScope§rNet");
|
||||||
|
assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pings_a_fake_server() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.unwrap();
|
||||||
|
// read handshake + request (we don't validate them here)
|
||||||
|
let mut buf = [0u8; 256];
|
||||||
|
let _ = sock.read(&mut buf).await.unwrap();
|
||||||
|
let json = br#"{"version":{"name":"1.21.1","protocol":767},"players":{"max":100,"online":7,"sample":[{"name":"Steve","id":"x"}]},"description":"A server"}"#;
|
||||||
|
let mut body = Vec::new();
|
||||||
|
write_varint(&mut body, json.len() as i32);
|
||||||
|
body.extend_from_slice(json);
|
||||||
|
sock.write_all(&packet(0, &body)).await.unwrap();
|
||||||
|
let mut ping = [0u8; 64];
|
||||||
|
let _ = sock.read(&mut ping).await;
|
||||||
|
sock.write_all(&packet(1, &42i64.to_be_bytes())).await.ok();
|
||||||
|
});
|
||||||
|
let s = ping("127.0.0.1", port).await.unwrap();
|
||||||
|
assert!(s.online);
|
||||||
|
assert_eq!(s.players_online, 7);
|
||||||
|
assert_eq!(s.version, "1.21.1");
|
||||||
|
assert_eq!(s.sample, vec!["Steve"]);
|
||||||
|
assert_eq!(s.motd, "A server");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Stage {
|
||||||
|
Preparing,
|
||||||
|
Java,
|
||||||
|
Game,
|
||||||
|
Loader,
|
||||||
|
Files,
|
||||||
|
Launching,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum Event {
|
||||||
|
Stage { stage: Stage, label: String },
|
||||||
|
Progress { stage: Stage, done: u64, total: u64, files_done: u32, files_total: u32 },
|
||||||
|
Log { line: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback the engine uses to report progress. Must be cheap: it is called
|
||||||
|
/// from download tasks (throttled to ~10 Hz).
|
||||||
|
pub type Reporter = Arc<dyn Fn(Event) + Send + Sync>;
|
||||||
|
|
||||||
|
pub fn noop() -> Reporter {
|
||||||
|
Arc::new(|_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stage(r: &Reporter, stage: Stage, label: impl Into<String>) {
|
||||||
|
r(Event::Stage { stage, label: label.into() });
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! Mojang's `rules` mini-language used by libraries and arguments.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Rule {
|
||||||
|
pub action: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub os: Option<OsRule>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub features: Option<HashMap<String, bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct OsRule {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub arch: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Env {
|
||||||
|
/// "windows" | "osx" | "linux"
|
||||||
|
pub os: &'static str,
|
||||||
|
/// "x86_64" | "x86" | "arm64"
|
||||||
|
pub arch: &'static str,
|
||||||
|
pub features: HashMap<String, bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Env {
|
||||||
|
pub fn current() -> Self {
|
||||||
|
let os = if cfg!(windows) {
|
||||||
|
"windows"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"osx"
|
||||||
|
} else {
|
||||||
|
"linux"
|
||||||
|
};
|
||||||
|
let arch = if cfg!(target_arch = "x86") {
|
||||||
|
"x86"
|
||||||
|
} else if cfg!(target_arch = "aarch64") {
|
||||||
|
"arm64"
|
||||||
|
} else {
|
||||||
|
"x86_64"
|
||||||
|
};
|
||||||
|
Self { os, arch, features: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_feature(mut self, name: &str, on: bool) -> Self {
|
||||||
|
self.features.insert(name.to_string(), on);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `${arch}` substitution used in legacy native classifiers.
|
||||||
|
pub fn bits(&self) -> &'static str {
|
||||||
|
if self.arch == "x86" {
|
||||||
|
"32"
|
||||||
|
} else {
|
||||||
|
"64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn os_matches(os: &OsRule, env: &Env) -> bool {
|
||||||
|
if let Some(name) = &os.name {
|
||||||
|
if name != env.os {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(arch) = &os.arch {
|
||||||
|
// Mojang only ever uses "x86" here, meaning 32-bit.
|
||||||
|
let matches = match arch.as_str() {
|
||||||
|
"x86" => env.arch == "x86",
|
||||||
|
"x86_64" | "amd64" => env.arch == "x86_64",
|
||||||
|
"arm64" | "aarch64" => env.arch == "arm64",
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !matches {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `version` is a regex against the OS version (e.g. "^10\\."). We can't
|
||||||
|
// evaluate it reliably, and every use is a harmless JVM hint, so treat
|
||||||
|
// it as matching.
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rule_matches(rule: &Rule, env: &Env) -> bool {
|
||||||
|
if let Some(os) = &rule.os {
|
||||||
|
if !os_matches(os, env) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(features) = &rule.features {
|
||||||
|
for (name, want) in features {
|
||||||
|
if env.features.get(name).copied().unwrap_or(false) != *want {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No rules → allowed. Otherwise start disallowed and let the last matching
|
||||||
|
/// rule decide.
|
||||||
|
pub fn allowed(rules: &[Rule], env: &Env) -> bool {
|
||||||
|
if rules.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut allow = false;
|
||||||
|
for rule in rules {
|
||||||
|
if rule_matches(rule, env) {
|
||||||
|
allow = rule.action == "allow";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allow
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn env(os: &'static str) -> Env {
|
||||||
|
Env { os, arch: "x86_64", features: HashMap::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evaluates_rules() {
|
||||||
|
let rules: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#).unwrap();
|
||||||
|
assert!(allowed(&rules, &env("windows")));
|
||||||
|
assert!(!allowed(&rules, &env("osx")));
|
||||||
|
|
||||||
|
let only_win: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow","os":{"name":"windows"}}]"#).unwrap();
|
||||||
|
assert!(allowed(&only_win, &env("windows")));
|
||||||
|
assert!(!allowed(&only_win, &env("linux")));
|
||||||
|
|
||||||
|
let x86: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow","os":{"arch":"x86"}}]"#).unwrap();
|
||||||
|
assert!(!allowed(&x86, &env("windows")));
|
||||||
|
|
||||||
|
let feat: Vec<Rule> = serde_json::from_str(r#"[{"action":"allow","features":{"has_custom_resolution":true}}]"#).unwrap();
|
||||||
|
assert!(!allowed(&feat, &env("windows")));
|
||||||
|
assert!(allowed(&feat, &env("windows").with_feature("has_custom_resolution", true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! Adds the instance's server to Minecraft's multiplayer list.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use fastnbt::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Insert (or update) `name`/`address` at the top of `servers.dat`,
|
||||||
|
/// preserving every other entry the player has added.
|
||||||
|
pub fn upsert(game_dir: &Path, name: &str, address: &str) -> Result<()> {
|
||||||
|
let path = game_dir.join("servers.dat");
|
||||||
|
let mut root: HashMap<String, Value> = match std::fs::read(&path) {
|
||||||
|
Ok(bytes) => match fastnbt::from_bytes::<Value>(&bytes) {
|
||||||
|
Ok(Value::Compound(map)) => map,
|
||||||
|
_ => HashMap::new(),
|
||||||
|
},
|
||||||
|
Err(_) => HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut servers = match root.remove("servers") {
|
||||||
|
Some(Value::List(list)) => list,
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let same = |v: &Value| match v {
|
||||||
|
Value::Compound(m) => matches!(m.get("ip"), Some(Value::String(ip)) if ip.eq_ignore_ascii_case(address)),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
let existing = servers.iter().position(same);
|
||||||
|
let mut entry = match existing {
|
||||||
|
Some(i) => match servers.remove(i) {
|
||||||
|
Value::Compound(m) => m,
|
||||||
|
_ => HashMap::new(),
|
||||||
|
},
|
||||||
|
None => HashMap::new(),
|
||||||
|
};
|
||||||
|
entry.insert("name".into(), Value::String(name.to_string()));
|
||||||
|
entry.insert("ip".into(), Value::String(address.to_string()));
|
||||||
|
entry.entry("acceptTextures".into()).or_insert(Value::Byte(1));
|
||||||
|
servers.insert(0, Value::Compound(entry));
|
||||||
|
|
||||||
|
root.insert("servers".into(), Value::List(servers));
|
||||||
|
std::fs::create_dir_all(game_dir)?;
|
||||||
|
std::fs::write(&path, fastnbt::to_bytes(&Value::Compound(root))?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_address(host: &str, port: u16) -> String {
|
||||||
|
if port == 25565 {
|
||||||
|
host.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{host}:{port}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn names(dir: &Path) -> Vec<(String, String)> {
|
||||||
|
let v: Value = fastnbt::from_bytes(&std::fs::read(dir.join("servers.dat")).unwrap()).unwrap();
|
||||||
|
let Value::Compound(root) = v else { panic!() };
|
||||||
|
let Some(Value::List(list)) = root.get("servers") else { panic!() };
|
||||||
|
list.iter()
|
||||||
|
.map(|s| match s {
|
||||||
|
Value::Compound(m) => match (m.get("name"), m.get("ip")) {
|
||||||
|
(Some(Value::String(n)), Some(Value::String(i))) => (n.clone(), i.clone()),
|
||||||
|
_ => panic!(),
|
||||||
|
},
|
||||||
|
_ => panic!(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upserts_without_losing_player_servers() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
upsert(dir.path(), "Friend", "friend.net").unwrap();
|
||||||
|
upsert(dir.path(), "SCOPENET", "play.scopenet.gg").unwrap();
|
||||||
|
upsert(dir.path(), "SCOPENET SMP", "play.scopenet.gg").unwrap();
|
||||||
|
assert_eq!(names(dir.path()), vec![("SCOPENET SMP".into(), "play.scopenet.gg".into()), ("Friend".into(), "friend.net".into())]);
|
||||||
|
assert_eq!(format_address("a.b", 25565), "a.b");
|
||||||
|
assert_eq!(format_address("a.b", 25570), "a.b:25570");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
//! Keeps an instance's mods/configs in sync with what the admin published.
|
||||||
|
//!
|
||||||
|
//! Files the panel sends are "managed": they get updated and removed when
|
||||||
|
//! the admin changes the instance. Anything the player adds themselves is
|
||||||
|
//! never touched.
|
||||||
|
|
||||||
|
use crate::http::{self, Download};
|
||||||
|
use crate::paths::safe_join;
|
||||||
|
use crate::progress::{self, Reporter, Stage};
|
||||||
|
use anyhow::Result;
|
||||||
|
use scopenet_shared::FileEntry;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct SyncState {
|
||||||
|
pub revision: i64,
|
||||||
|
pub managed: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path(game_dir: &Path) -> PathBuf {
|
||||||
|
game_dir.join(".scopenet").join("state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_state(game_dir: &Path) -> SyncState {
|
||||||
|
std::fs::read(state_path(game_dir)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_state(game_dir: &Path, state: &SyncState) -> Result<()> {
|
||||||
|
let path = state_path(game_dir);
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||||
|
std::fs::write(path, serde_json::to_vec_pretty(state)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_url(base: &str, url: &str) -> String {
|
||||||
|
if url.starts_with("http://") || url.starts_with("https://") {
|
||||||
|
url.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", base.trim_end_matches('/'), url.trim_start_matches('/'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize, Clone)]
|
||||||
|
pub struct SyncReport {
|
||||||
|
pub downloaded: usize,
|
||||||
|
pub removed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync `files` into `game_dir`.
|
||||||
|
///
|
||||||
|
/// Fast path: when the revision hasn't changed we only check that files
|
||||||
|
/// exist, so launching an up-to-date instance costs a few `stat` calls.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn sync(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
panel_base: &str,
|
||||||
|
game_dir: &Path,
|
||||||
|
revision: i64,
|
||||||
|
files: &[FileEntry],
|
||||||
|
concurrency: usize,
|
||||||
|
deep: bool,
|
||||||
|
reporter: &Reporter,
|
||||||
|
) -> Result<SyncReport> {
|
||||||
|
std::fs::create_dir_all(game_dir)?;
|
||||||
|
let state = load_state(game_dir);
|
||||||
|
let changed = state.revision != revision || deep;
|
||||||
|
progress::stage(reporter, Stage::Files, if changed { "Syncing instance files" } else { "Checking instance files" });
|
||||||
|
|
||||||
|
let mut wanted = HashSet::new();
|
||||||
|
let mut downloads = Vec::new();
|
||||||
|
for f in files {
|
||||||
|
let Some(dest) = safe_join(game_dir, &f.path) else {
|
||||||
|
tracing::warn!("skipping unsafe path from panel: {}", f.path);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
wanted.insert(f.path.replace('\\', "/"));
|
||||||
|
let ok = if changed { http::file_ok(&dest, Some(&f.sha1), Some(f.size), true) } else { dest.exists() };
|
||||||
|
if !ok {
|
||||||
|
downloads.push(Download::new(resolve_url(panel_base, &f.url), dest, Some(f.sha1.clone()), Some(f.size)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let downloaded = downloads.len();
|
||||||
|
http::download_all(client, downloads, concurrency, Stage::Files, reporter).await?;
|
||||||
|
|
||||||
|
let mut removed = 0;
|
||||||
|
for old in &state.managed {
|
||||||
|
if !wanted.contains(old) {
|
||||||
|
if let Some(path) = safe_join(game_dir, old) {
|
||||||
|
if std::fs::remove_file(&path).is_ok() {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut managed: Vec<String> = wanted.into_iter().collect();
|
||||||
|
managed.sort();
|
||||||
|
save_state(game_dir, &SyncState { revision, managed })?;
|
||||||
|
Ok(SyncReport { downloaded, removed })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn urls() {
|
||||||
|
assert_eq!(resolve_url("https://panel.example/", "/files/a/mods/x.jar"), "https://panel.example/files/a/mods/x.jar");
|
||||||
|
assert_eq!(resolve_url("https://panel.example", "https://cdn.modrinth.com/x.jar"), "https://cdn.modrinth.com/x.jar");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn removes_files_the_admin_dropped() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let game = dir.path();
|
||||||
|
std::fs::create_dir_all(game.join("mods")).unwrap();
|
||||||
|
std::fs::write(game.join("mods/old.jar"), b"old").unwrap();
|
||||||
|
std::fs::write(game.join("mods/mine.jar"), b"player added").unwrap();
|
||||||
|
save_state(game, &SyncState { revision: 1, managed: vec!["mods/old.jar".into()] }).unwrap();
|
||||||
|
|
||||||
|
let client = crate::http::client();
|
||||||
|
let report = sync(&client, "http://unused", game, 2, &[], 4, false, &crate::progress::noop()).await.unwrap();
|
||||||
|
assert_eq!(report.removed, 1);
|
||||||
|
assert!(!game.join("mods/old.jar").exists());
|
||||||
|
assert!(game.join("mods/mine.jar").exists(), "player files are never touched");
|
||||||
|
assert_eq!(load_state(game).revision, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! Tiny system probes (kept dependency-free to stay lightweight).
|
||||||
|
|
||||||
|
/// Total physical memory in MiB (best effort; 8 GiB if unknown).
|
||||||
|
pub fn total_memory_mb() -> u64 {
|
||||||
|
imp().unwrap_or(8192)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn imp() -> Option<u64> {
|
||||||
|
use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
|
||||||
|
let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() };
|
||||||
|
status.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
|
||||||
|
let ok = unsafe { GlobalMemoryStatusEx(&mut status) };
|
||||||
|
(ok != 0).then_some(status.ullTotalPhys / 1024 / 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn imp() -> Option<u64> {
|
||||||
|
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||||
|
let line = info.lines().find(|l| l.starts_with("MemTotal:"))?;
|
||||||
|
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||||
|
Some(kb / 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(windows, target_os = "linux")))]
|
||||||
|
fn imp() -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively sum file sizes under `path`.
|
||||||
|
pub fn dir_size(path: &std::path::Path) -> u64 {
|
||||||
|
let Ok(entries) = std::fs::read_dir(path) else { return 0 };
|
||||||
|
entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| match e.file_type() {
|
||||||
|
Ok(t) if t.is_dir() => dir_size(&e.path()),
|
||||||
|
Ok(t) if t.is_file() => e.metadata().map(|m| m.len()).unwrap_or(0),
|
||||||
|
_ => 0,
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
//! Mojang version JSON (also used by Fabric/Quilt/Forge profiles).
|
||||||
|
|
||||||
|
use crate::rules::Rule;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct VersionJson {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub inherits_from: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub jar: Option<String>,
|
||||||
|
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub main_class: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub minecraft_arguments: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub arguments: Option<Arguments>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub libraries: Vec<Library>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub asset_index: Option<AssetIndexRef>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub assets: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub downloads: Option<VersionDownloads>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub java_version: Option<JavaVersion>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub logging: Option<Logging>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub release_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Arguments {
|
||||||
|
#[serde(default)]
|
||||||
|
pub game: Vec<Arg>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub jvm: Vec<Arg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum Arg {
|
||||||
|
Plain(String),
|
||||||
|
Ruled { rules: Vec<Rule>, value: ArgValue },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum ArgValue {
|
||||||
|
One(String),
|
||||||
|
Many(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArgValue {
|
||||||
|
pub fn values(&self) -> Vec<String> {
|
||||||
|
match self {
|
||||||
|
ArgValue::One(s) => vec![s.clone()],
|
||||||
|
ArgValue::Many(v) => v.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Library {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub downloads: Option<LibraryDownloads>,
|
||||||
|
/// Maven repository base (Fabric/Quilt/legacy Forge style).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rules: Option<Vec<Rule>>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub natives: Option<HashMap<String, String>>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub extract: Option<Extract>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sha1: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LibraryDownloads {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub artifact: Option<Artifact>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub classifiers: Option<HashMap<String, Artifact>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Artifact {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sha1: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Extract {
|
||||||
|
#[serde(default)]
|
||||||
|
pub exclude: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AssetIndexRef {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sha1: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub total_size: Option<u64>,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct VersionDownloads {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub client: Option<Artifact>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct JavaVersion {
|
||||||
|
#[serde(default)]
|
||||||
|
pub component: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub major_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct Logging {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub client: Option<LoggingClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LoggingClient {
|
||||||
|
pub argument: String,
|
||||||
|
pub file: LogFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LogFile {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sha1: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VersionJson {
|
||||||
|
/// Java major version this version wants (8 for anything old).
|
||||||
|
pub fn java_major(&self) -> u32 {
|
||||||
|
self.java_version.as_ref().map(|j| j.major_version).filter(|m| *m > 0).unwrap_or(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn java_component(&self) -> String {
|
||||||
|
self.java_version.as_ref().map(|j| j.component.clone()).filter(|c| !c.is_empty()).unwrap_or_else(|| "jre-legacy".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for 1.13+ style argument lists.
|
||||||
|
pub fn is_modern(&self) -> bool {
|
||||||
|
self.arguments.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does this version understand `--quickPlayMultiplayer` (1.20+)?
|
||||||
|
pub fn supports_quick_play(&self) -> bool {
|
||||||
|
self.arguments
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| {
|
||||||
|
a.game.iter().any(|arg| match arg {
|
||||||
|
Arg::Plain(s) => s.contains("quickPlayMultiplayer"),
|
||||||
|
Arg::Ruled { value, .. } => value.values().iter().any(|v| v.contains("quickPlayMultiplayer")),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lib_key(name: &str) -> String {
|
||||||
|
crate::maven::Coord::parse(name).map(|c| c.key()).unwrap_or_else(|| name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge a child profile (Fabric/Forge/...) onto its parent (vanilla).
|
||||||
|
/// Child values win; argument lists are concatenated; libraries are
|
||||||
|
/// de-duplicated with the child's copy taking precedence.
|
||||||
|
pub fn merge(parent: VersionJson, child: VersionJson) -> VersionJson {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut libraries = Vec::new();
|
||||||
|
for lib in child.libraries.into_iter().chain(parent.libraries) {
|
||||||
|
// Natives-map libraries (LWJGL 2) share a key with their jar entry.
|
||||||
|
let key = if lib.natives.is_some() { format!("{}#natives", lib_key(&lib.name)) } else { lib_key(&lib.name) };
|
||||||
|
if seen.insert(key) {
|
||||||
|
libraries.push(lib);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let arguments = match (parent.arguments, child.arguments) {
|
||||||
|
(Some(mut p), Some(c)) => {
|
||||||
|
p.game.extend(c.game);
|
||||||
|
p.jvm.extend(c.jvm);
|
||||||
|
Some(p)
|
||||||
|
}
|
||||||
|
(p, c) => c.or(p),
|
||||||
|
};
|
||||||
|
|
||||||
|
VersionJson {
|
||||||
|
id: child.id,
|
||||||
|
inherits_from: None,
|
||||||
|
jar: child.jar.or(parent.jar),
|
||||||
|
kind: child.kind.or(parent.kind),
|
||||||
|
main_class: child.main_class.or(parent.main_class),
|
||||||
|
minecraft_arguments: child.minecraft_arguments.or(parent.minecraft_arguments),
|
||||||
|
arguments,
|
||||||
|
libraries,
|
||||||
|
asset_index: child.asset_index.or(parent.asset_index),
|
||||||
|
assets: child.assets.or(parent.assets),
|
||||||
|
downloads: child.downloads.or(parent.downloads),
|
||||||
|
java_version: child.java_version.or(parent.java_version),
|
||||||
|
logging: child.logging.or(parent.logging),
|
||||||
|
release_time: parent.release_time.or(child.release_time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub const VANILLA: &str = r#"{
|
||||||
|
"id": "1.20.1", "type": "release", "mainClass": "net.minecraft.client.main.Main",
|
||||||
|
"javaVersion": {"component": "java-runtime-gamma", "majorVersion": 17},
|
||||||
|
"assetIndex": {"id": "5", "sha1": "abc", "size": 1, "totalSize": 2, "url": "https://x/5.json"},
|
||||||
|
"assets": "5",
|
||||||
|
"downloads": {"client": {"sha1": "c", "size": 3, "url": "https://x/client.jar"}},
|
||||||
|
"arguments": {
|
||||||
|
"game": ["--username", "${auth_player_name}",
|
||||||
|
{"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}], "value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]},
|
||||||
|
{"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}], "value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}],
|
||||||
|
"jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]}, "-cp", "${classpath}"]
|
||||||
|
},
|
||||||
|
"libraries": [
|
||||||
|
{"name": "org.ow2.asm:asm:9.3", "downloads": {"artifact": {"path": "org/ow2/asm/asm/9.3/asm-9.3.jar", "sha1": "a", "size": 1, "url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar"}}},
|
||||||
|
{"name": "org.lwjgl:lwjgl:3.3.1:natives-windows", "downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar", "sha1": "n", "size": 1, "url": "https://libraries.minecraft.net/n.jar"}}, "rules": [{"action": "allow", "os": {"name": "windows"}}]}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
const FABRIC: &str = r#"{
|
||||||
|
"id": "fabric-loader-0.16.9-1.20.1", "inheritsFrom": "1.20.1", "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
|
||||||
|
"arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]},
|
||||||
|
"libraries": [
|
||||||
|
{"name": "org.ow2.asm:asm:9.7.1", "url": "https://maven.fabricmc.net/", "sha1": "f"},
|
||||||
|
{"name": "net.fabricmc:fabric-loader:0.16.9", "url": "https://maven.fabricmc.net/"}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merges_fabric_onto_vanilla() {
|
||||||
|
let parent: VersionJson = serde_json::from_str(VANILLA).unwrap();
|
||||||
|
let child: VersionJson = serde_json::from_str(FABRIC).unwrap();
|
||||||
|
assert!(parent.supports_quick_play());
|
||||||
|
let merged = merge(parent, child);
|
||||||
|
assert_eq!(merged.id, "fabric-loader-0.16.9-1.20.1");
|
||||||
|
assert_eq!(merged.main_class.as_deref(), Some("net.fabricmc.loader.impl.launch.knot.KnotClient"));
|
||||||
|
assert_eq!(merged.java_major(), 17);
|
||||||
|
// asm de-duplicated, Fabric's newer copy wins
|
||||||
|
let asm: Vec<_> = merged.libraries.iter().filter(|l| l.name.starts_with("org.ow2.asm:asm:")).collect();
|
||||||
|
assert_eq!(asm.len(), 1);
|
||||||
|
assert_eq!(asm[0].name, "org.ow2.asm:asm:9.7.1");
|
||||||
|
assert_eq!(merged.libraries.len(), 3);
|
||||||
|
let args = merged.arguments.unwrap();
|
||||||
|
assert_eq!(args.jvm.len(), 4);
|
||||||
|
assert!(merged.asset_index.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
//! Wire types shared by the SCOPENET launcher and the admin panel.
|
||||||
|
//!
|
||||||
|
//! Everything the panel sends to the launcher is described here, so both
|
||||||
|
//! sides always agree on the JSON shape.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const API_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Branding / theming
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Branding {
|
||||||
|
pub name: String,
|
||||||
|
pub tagline: String,
|
||||||
|
pub logo_url: Option<String>,
|
||||||
|
pub icon_url: Option<String>,
|
||||||
|
pub background: Background,
|
||||||
|
pub colors: Palette,
|
||||||
|
/// One of the fonts bundled in the launcher: "Inter", "Outfit",
|
||||||
|
/// "Space Grotesk", "Plus Jakarta Sans".
|
||||||
|
pub font: String,
|
||||||
|
/// Corner radius in px used for cards and buttons.
|
||||||
|
pub radius: u8,
|
||||||
|
/// Frosted-glass surfaces (backdrop blur). Disable for very old GPUs.
|
||||||
|
pub glass: bool,
|
||||||
|
/// Shown on the Minecraft title screen / F3 (the `version_type` arg).
|
||||||
|
pub version_label: String,
|
||||||
|
pub news: Vec<NewsItem>,
|
||||||
|
pub links: Vec<SocialLink>,
|
||||||
|
pub features: Features,
|
||||||
|
/// Raw CSS appended to the launcher stylesheet (power users).
|
||||||
|
pub custom_css: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Branding {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "SCOPENET".into(),
|
||||||
|
tagline: "Your worlds, one click away.".into(),
|
||||||
|
logo_url: None,
|
||||||
|
icon_url: None,
|
||||||
|
background: Background::default(),
|
||||||
|
colors: Palette::default(),
|
||||||
|
font: "Inter".into(),
|
||||||
|
radius: 10,
|
||||||
|
glass: false,
|
||||||
|
version_label: "SCOPENET".into(),
|
||||||
|
news: vec![NewsItem {
|
||||||
|
id: "welcome".into(),
|
||||||
|
title: "Welcome to SCOPENET".into(),
|
||||||
|
body: "Your admin can customise this launcher from the panel: colours, news, instances and more.".into(),
|
||||||
|
image_url: None,
|
||||||
|
link: None,
|
||||||
|
date: None,
|
||||||
|
pinned: true,
|
||||||
|
tag: Some("News".into()),
|
||||||
|
}],
|
||||||
|
links: vec![],
|
||||||
|
features: Features::default(),
|
||||||
|
custom_css: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Background {
|
||||||
|
/// "gradient" | "image" | "video"
|
||||||
|
pub kind: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// Blur in px applied to image/video backgrounds.
|
||||||
|
pub blur: u8,
|
||||||
|
/// Darkening overlay 0-100.
|
||||||
|
pub dim: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Background {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { kind: "gradient".into(), url: None, blur: 0, dim: 55 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Palette {
|
||||||
|
pub accent: String,
|
||||||
|
pub accent_2: String,
|
||||||
|
pub background: String,
|
||||||
|
pub surface: String,
|
||||||
|
pub text: String,
|
||||||
|
pub muted: String,
|
||||||
|
pub success: String,
|
||||||
|
pub danger: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Palette {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
accent: "#6d6af5".into(),
|
||||||
|
accent_2: "#8b88f8".into(),
|
||||||
|
background: "#0d0e12".into(),
|
||||||
|
surface: "#16181e".into(),
|
||||||
|
text: "#ecedf1".into(),
|
||||||
|
muted: "#8b8f9a".into(),
|
||||||
|
success: "#3fb97c".into(),
|
||||||
|
danger: "#e5484d".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct NewsItem {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub image_url: Option<String>,
|
||||||
|
pub link: Option<String>,
|
||||||
|
/// ISO date (YYYY-MM-DD)
|
||||||
|
pub date: Option<String>,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub tag: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct SocialLink {
|
||||||
|
pub label: String,
|
||||||
|
pub url: String,
|
||||||
|
/// "discord" | "website" | "youtube" | "twitter" | "github" | "store" | "twitch"
|
||||||
|
pub icon: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Features {
|
||||||
|
pub news: bool,
|
||||||
|
pub server_status: bool,
|
||||||
|
/// Let players override colours/theme in their own settings.
|
||||||
|
pub allow_user_theme: bool,
|
||||||
|
/// Let players change JVM arguments / Java path.
|
||||||
|
pub allow_advanced_java: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Features {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum RegistrationMode {
|
||||||
|
/// Only admins can create accounts.
|
||||||
|
#[default]
|
||||||
|
Closed,
|
||||||
|
/// Anyone can sign up from the launcher.
|
||||||
|
Open,
|
||||||
|
/// Anyone can sign up, but an admin must approve the account.
|
||||||
|
Approval,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public auth configuration the launcher uses to decide which sign-in
|
||||||
|
/// options to show.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AuthConfig {
|
||||||
|
pub panel_accounts: bool,
|
||||||
|
pub registration: RegistrationMode,
|
||||||
|
pub microsoft: bool,
|
||||||
|
pub microsoft_client_id: Option<String>,
|
||||||
|
pub offline_local: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuthConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
panel_accounts: true,
|
||||||
|
registration: RegistrationMode::Closed,
|
||||||
|
microsoft: false,
|
||||||
|
microsoft_client_id: None,
|
||||||
|
offline_local: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PublicUser {
|
||||||
|
pub id: i64,
|
||||||
|
pub username: String,
|
||||||
|
/// Offline-mode UUID (dashed), identical to what an offline server computes.
|
||||||
|
pub uuid: String,
|
||||||
|
pub role: String,
|
||||||
|
pub groups: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub user: PublicUser,
|
||||||
|
/// Set when the account exists but is waiting for admin approval.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Instances
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, Hash)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Loader {
|
||||||
|
#[default]
|
||||||
|
Vanilla,
|
||||||
|
Fabric,
|
||||||
|
Quilt,
|
||||||
|
Forge,
|
||||||
|
NeoForge,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Loader {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Loader::Vanilla => "vanilla",
|
||||||
|
Loader::Fabric => "fabric",
|
||||||
|
Loader::Quilt => "quilt",
|
||||||
|
Loader::Forge => "forge",
|
||||||
|
Loader::NeoForge => "neoforge",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
Some(match s.to_ascii_lowercase().as_str() {
|
||||||
|
"vanilla" | "" => Loader::Vanilla,
|
||||||
|
"fabric" | "fabric-loader" => Loader::Fabric,
|
||||||
|
"quilt" | "quilt-loader" => Loader::Quilt,
|
||||||
|
"forge" => Loader::Forge,
|
||||||
|
"neoforge" | "neo-forge" => Loader::NeoForge,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ServerEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub address: String,
|
||||||
|
pub port: u16,
|
||||||
|
/// Connect straight to the server when the game starts.
|
||||||
|
pub auto_join: bool,
|
||||||
|
/// Add the server to the in-game multiplayer list (servers.dat).
|
||||||
|
pub inject: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerEntry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { name: String::new(), address: String::new(), port: 25565, auto_join: false, inject: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct MemoryDefaults {
|
||||||
|
pub min_mb: u32,
|
||||||
|
pub max_mb: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemoryDefaults {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { min_mb: 1024, max_mb: 4096 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct InstanceSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub icon_url: Option<String>,
|
||||||
|
pub banner_url: Option<String>,
|
||||||
|
pub mc_version: String,
|
||||||
|
pub loader: Loader,
|
||||||
|
pub loader_version: Option<String>,
|
||||||
|
/// Bumped by the panel whenever the instance changes; the launcher
|
||||||
|
/// re-syncs files when this differs from what it last installed.
|
||||||
|
pub revision: i64,
|
||||||
|
pub server: Option<ServerEntry>,
|
||||||
|
pub memory: MemoryDefaults,
|
||||||
|
pub jvm_args: String,
|
||||||
|
pub featured: bool,
|
||||||
|
/// Human description of where the instance came from ("Modrinth: Fabulously Optimized 6.2").
|
||||||
|
pub source_label: String,
|
||||||
|
pub file_count: u32,
|
||||||
|
pub total_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
/// Path relative to the instance game directory, always using `/`.
|
||||||
|
pub path: String,
|
||||||
|
/// Absolute URL, or a panel-relative URL starting with `/`.
|
||||||
|
pub url: String,
|
||||||
|
pub sha1: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
pub struct InstanceManifest {
|
||||||
|
pub instance: InstanceSummary,
|
||||||
|
pub files: Vec<FileEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct LauncherManifest {
|
||||||
|
pub api_version: u32,
|
||||||
|
pub panel_version: String,
|
||||||
|
pub branding: Branding,
|
||||||
|
pub auth: AuthConfig,
|
||||||
|
pub instances: Vec<InstanceSummary>,
|
||||||
|
/// Present when the request carried a valid panel token.
|
||||||
|
pub user: Option<PublicUser>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LaunchEvent {
|
||||||
|
pub instance_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The UUID an offline-mode server assigns to `name`
|
||||||
|
/// (Java's `UUID.nameUUIDFromBytes("OfflinePlayer:" + name)`).
|
||||||
|
pub fn offline_uuid(name: &str) -> String {
|
||||||
|
use md5::{Digest, Md5};
|
||||||
|
let mut hash: [u8; 16] = Md5::digest(format!("OfflinePlayer:{name}").as_bytes()).into();
|
||||||
|
hash[6] = (hash[6] & 0x0f) | 0x30; // version 3
|
||||||
|
hash[8] = (hash[8] & 0x3f) | 0x80; // IETF variant
|
||||||
|
let h: String = hash.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minecraft usernames: 3-16 chars of `[A-Za-z0-9_]`.
|
||||||
|
pub fn valid_username(name: &str) -> bool {
|
||||||
|
(3..=16).contains(&name.len()) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offline_uuid_matches_java() {
|
||||||
|
// Reference values from java.util.UUID.nameUUIDFromBytes.
|
||||||
|
assert_eq!(offline_uuid("Notch"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
|
||||||
|
assert_eq!(offline_uuid("jeb_"), "a762f560-4fce-3236-812a-b80efff0b62b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usernames() {
|
||||||
|
assert!(valid_username("Steve_01"));
|
||||||
|
assert!(!valid_username("ab"));
|
||||||
|
assert!(!valid_username("has space"));
|
||||||
|
assert!(!valid_username("waytoolongusername1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_roundtrip_with_defaults() {
|
||||||
|
let m: LauncherManifest = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(m.branding.name, "SCOPENET");
|
||||||
|
let loader: Loader = serde_json::from_str("\"neoforge\"").unwrap();
|
||||||
|
assert_eq!(loader, Loader::NeoForge);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Admin guide
|
||||||
|
|
||||||
|
## Deploying the panel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # set ADMIN_PASSWORD
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f panel # first start prints the admin account
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything the panel stores lives in the `panel-data` volume (`/data`):
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|---|---|
|
||||||
|
| `panel.db` | SQLite database (users, instances, settings, stats) |
|
||||||
|
| `files/<instance>/` | Configs and uploads hosted for launchers |
|
||||||
|
| `uploads/` | Logos, backgrounds, icons |
|
||||||
|
| `jwt.secret` | Token signing key (unless `JWT_SECRET` is set) |
|
||||||
|
|
||||||
|
**Backups:** stop the container (or use `sqlite3 panel.db ".backup backup.db"`) and copy the volume.
|
||||||
|
|
||||||
|
**Updating:** `docker compose pull && docker compose up -d`. Database migrations run automatically.
|
||||||
|
|
||||||
|
### HTTPS
|
||||||
|
|
||||||
|
Launchers talk to the panel over the internet, so serve it over HTTPS. Example with Caddy:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
panel.example.com {
|
||||||
|
reverse_proxy localhost:8080
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Large modpack uploads go through the proxy — raise its body-size limit if needed (nginx: `client_max_body_size 2g;`).
|
||||||
|
|
||||||
|
## Instances
|
||||||
|
|
||||||
|
An instance = one playable profile: a Minecraft version, an optional mod loader, files (mods, configs…) and an optional server.
|
||||||
|
|
||||||
|
- **Version** — choose any Minecraft version and Vanilla / Fabric / Quilt / Forge / NeoForge. Leave the loader version empty for the latest recommended build (it's pinned when you save).
|
||||||
|
- **Modrinth** — search, pick a version, *Use*. Mods are downloaded by players straight from Modrinth's CDN; configs (`overrides/`) are hosted by the panel.
|
||||||
|
- **CurseForge** — needs a free API key from [console.curseforge.com](https://console.curseforge.com) (Settings → Integrations, or `CURSEFORGE_API_KEY`). Some authors block third-party downloads; those files show up under **Files** with a link — download them yourself and upload them into the same folder.
|
||||||
|
- **Upload .zip** — a `.mrpack`, a CurseForge export, or any zipped instance folder (with `mods/`, `config/`, … or a `.minecraft/` inside). For plain zips, pick the Minecraft version and loader.
|
||||||
|
- **Files** — add or remove individual files at any time. Files players add themselves are never touched; files you remove are deleted from players' instances on their next launch.
|
||||||
|
|
||||||
|
Every save bumps the instance **revision**; launchers re-verify files when it changes, otherwise launching is instant.
|
||||||
|
|
||||||
|
### Built-in server
|
||||||
|
|
||||||
|
On the **Server** tab: name, address, port.
|
||||||
|
|
||||||
|
- *Add to multiplayer list* writes the server into `servers.dat` (keeping servers players added).
|
||||||
|
- *Join automatically* connects on start (Quick Play on 1.20+, `--server` on older versions).
|
||||||
|
|
||||||
|
Your server must allow the accounts you use: offline-mode (`online-mode=false`) for panel/offline accounts, or online-mode for Microsoft accounts. Protect offline-mode servers with a whitelist or an auth plugin.
|
||||||
|
|
||||||
|
### Access
|
||||||
|
|
||||||
|
- **Everyone** — any launcher, including offline and Microsoft accounts.
|
||||||
|
- **Signed-in players** — any panel account.
|
||||||
|
- **Specific groups** — members of the selected groups (create groups on the Players page). Admins see everything.
|
||||||
|
|
||||||
|
## Players
|
||||||
|
|
||||||
|
- **Sign-ups:** Settings → *Closed* (admins create accounts), *Needs approval*, or *Open*.
|
||||||
|
- Panel accounts play under their username with the offline-mode UUID an offline server computes, so inventories and permissions stay consistent.
|
||||||
|
- Disabling an account signs it out everywhere on the next request.
|
||||||
|
- Ten failed logins lock an account for five minutes.
|
||||||
|
|
||||||
|
## Launcher design
|
||||||
|
|
||||||
|
Everything on this page is pushed to launchers when they start or refresh — no reinstall. The **Features** tab lets you allow or block players from changing the theme or Java settings. **Custom CSS** is injected last; handy variables are `--accent`, `--accent-2`, `--surface`, `--radius`.
|
||||||
|
|
||||||
|
## Releasing the launcher
|
||||||
|
|
||||||
|
See the README's *Build your branded launcher*. Players get updates automatically: the launcher checks GitHub Releases on start and offers the new installer.
|
||||||
|
|
||||||
|
**Code signing:** unsigned installers trigger a Windows SmartScreen warning ("More info → Run anyway"). To avoid it, sign the installer with a code-signing certificate (e.g. Azure Trusted Signing) — Tauri supports this via `bundle.windows.signCommand`.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐ HTTPS (JSON) ┌──────────────────────────┐
|
||||||
|
│ Launcher (Tauri) │ ─────────────────────────▶ │ Panel (Axum + SQLite) │
|
||||||
|
│ Svelte UI ⇄ Rust shell │ manifest, instances, │ Svelte admin UI │
|
||||||
|
│ │ │ auth, stats │ /files, /uploads │
|
||||||
|
│ scopenet-core engine │ └──────────────────────────┘
|
||||||
|
│ ├─ Mojang / Java │ ──▶ piston-meta, libraries.minecraft.net, resources
|
||||||
|
│ ├─ Fabric/Quilt/Forge │ ──▶ meta.fabricmc.net, maven.minecraftforge.net, …
|
||||||
|
│ └─ modpack files │ ──▶ Modrinth/CurseForge CDNs, panel /files
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`crates/shared`** defines every JSON type exchanged, so the panel and launcher can't drift apart.
|
||||||
|
- **`crates/core`** has no UI dependencies. It's unit-tested, and CI runs real installs of vanilla, legacy, Fabric, Quilt, Forge and NeoForge against the live servers.
|
||||||
|
- The **panel** also uses `core` for version and loader lookups.
|
||||||
|
|
||||||
|
## Launch pipeline
|
||||||
|
|
||||||
|
1. Resolve the account (refreshing Microsoft tokens if needed).
|
||||||
|
2. Fetch the instance from the panel (cached for offline play).
|
||||||
|
3. `install`: vanilla version JSON → Java runtime (Mojang's own builds) → client jar, libraries, assets → loader profile (Fabric/Quilt), or replay the Forge/NeoForge installer's processors → merge → natives.
|
||||||
|
4. `sync`: download the admin's files; remove files the admin removed; never touch the player's own files. With an unchanged revision, only existence is checked.
|
||||||
|
5. Write `servers.dat` and keybinds, build the Java command (quick play / `--server`, memory, GC presets, `@argfile` for long classpaths), and start the game.
|
||||||
|
6. Stream logs to the console; on a crash, show the tail of the log with hints.
|
||||||
|
|
||||||
|
## Disk layout (launcher)
|
||||||
|
|
||||||
|
```
|
||||||
|
%APPDATA%\net.scopenet.launcher\
|
||||||
|
settings.json, accounts.json, secrets.bin (encrypted), manifest.json (cache)
|
||||||
|
minecraft\
|
||||||
|
versions\ libraries\ assets\ runtimes\ natives\ cache\
|
||||||
|
instances\<id>\ ← game directory (saves, mods, options.txt, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
Public (used by launchers). Send `Authorization: Bearer <token>` to see restricted instances:
|
||||||
|
|
||||||
|
| Method | Path | |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/v1/launcher/manifest` | Branding, auth options, visible instances |
|
||||||
|
| GET | `/api/v1/launcher/instances/{id}` | Instance + file list |
|
||||||
|
| POST | `/api/v1/launcher/events` | Launch stats |
|
||||||
|
| POST | `/api/v1/auth/login` · `/register` | Panel accounts |
|
||||||
|
| GET | `/api/v1/auth/me` | Current user |
|
||||||
|
| GET | `/files/{instance}/{path}` | Hosted instance files |
|
||||||
|
| GET | `/healthz` | Health check |
|
||||||
|
|
||||||
|
Admin (`role = admin`): `/api/admin/{stats,users,groups,branding,settings,instances,uploads}` plus `instances/{id}/{import/modrinth,import/curseforge,import/upload,files,reset}` and meta/search proxies under `/api/admin/{meta,modrinth,curseforge}`.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Passwords are hashed with Argon2id; tokens are HS256 JWTs (30 days); disabled accounts are rejected on every request.
|
||||||
|
- Server-provided paths are sanitised (`safe_join`) on both sides — no `..` or absolute paths.
|
||||||
|
- The panel never returns the CurseForge key; SVG uploads are refused (script risk).
|
||||||
|
- The launcher's CSP only allows scripts from the app itself; media may load from any HTTPS host (admin-chosen images).
|
||||||
|
- Hosted `/files` are public URLs. Don't put secrets in instance files.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Microsoft sign-in
|
||||||
|
|
||||||
|
Microsoft accounts give players their real skin and let them join online-mode servers. It needs an Azure app registration that Mojang has approved for the Minecraft API.
|
||||||
|
|
||||||
|
## 1. Register an app
|
||||||
|
|
||||||
|
1. Go to [portal.azure.com](https://portal.azure.com) → **Microsoft Entra ID → App registrations → New registration**.
|
||||||
|
2. Name: your launcher's name. Supported account types: **Personal Microsoft accounts only**.
|
||||||
|
3. Redirect URI: leave empty.
|
||||||
|
4. After creating it, open **Authentication** → enable **Allow public client flows** (needed for the device-code flow) → Save.
|
||||||
|
5. Copy the **Application (client) ID**.
|
||||||
|
|
||||||
|
## 2. Request Minecraft API access
|
||||||
|
|
||||||
|
New apps can't call the Minecraft services API until Mojang approves them. Submit the form at <https://aka.ms/mce-reviewappid> with your client ID. Approval can take a while; until then sign-in fails with *"Minecraft services rejected the Xbox token"*.
|
||||||
|
|
||||||
|
## 3. Enable it in the panel
|
||||||
|
|
||||||
|
**Settings → Microsoft accounts** → turn on *Sign in with Microsoft* and paste the client ID. Launchers show the Microsoft tab on their next refresh.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The launcher uses the OAuth **device-code flow**: it shows a short code, opens `microsoft.com/link`, and waits. The chain is Microsoft → Xbox Live → XSTS → Minecraft services → profile. The refresh token is stored encrypted on the player's PC and renewed silently before launches. The panel never sees Microsoft credentials.
|
||||||
|
|
||||||
|
Common errors are translated for players (no Xbox profile yet, child account, game not owned).
|
||||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>SCOPENET Launcher</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fn main() {
|
||||||
|
// Re-run when build-time branding changes (set by CI).
|
||||||
|
for var in ["SCOPENET_PANEL_URL", "SCOPENET_REPO", "SCOPENET_LOCK_PANEL"] {
|
||||||
|
println!("cargo:rerun-if-env-changed={var}");
|
||||||
|
}
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Permissions for the main launcher window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-toggle-maximize",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-unminimize",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-is-maximized",
|
||||||
|
"dialog:allow-open",
|
||||||
|
"opener:allow-open-url"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><rect x="48" y="48" width="928" height="928" rx="208" fill="#16181e"/><circle cx="512" cy="512" r="236" fill="none" stroke="#ecedf1" stroke-width="64"/><circle cx="512" cy="512" r="72" fill="#6d6af5"/><path d="M512 196v120M512 708v120M196 512h120M708 512h120" stroke="#ecedf1" stroke-width="64" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,259 @@
|
|||||||
|
//! Player accounts: panel (username/password), Microsoft, and local offline.
|
||||||
|
|
||||||
|
use crate::secrets::Secret;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
use scopenet_shared::{offline_uuid, valid_username, AuthResponse, LoginRequest, RegisterRequest};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Account {
|
||||||
|
pub id: String,
|
||||||
|
/// "panel" | "microsoft" | "offline"
|
||||||
|
pub kind: String,
|
||||||
|
pub username: String,
|
||||||
|
pub uuid: String,
|
||||||
|
/// Panel accounts: which panel they belong to.
|
||||||
|
#[serde(default)]
|
||||||
|
pub panel_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub role: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AccountsFile {
|
||||||
|
pub accounts: Vec<Account>,
|
||||||
|
pub active: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountsFile {
|
||||||
|
pub fn load(path: &Path) -> Self {
|
||||||
|
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> Result<()> {
|
||||||
|
std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active(&self) -> Option<&Account> {
|
||||||
|
let id = self.active.as_ref()?;
|
||||||
|
self.accounts.iter().find(|a| &a.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or replace (same kind + uuid + panel) and make it active.
|
||||||
|
pub fn upsert(&mut self, account: Account) -> Account {
|
||||||
|
if let Some(existing) =
|
||||||
|
self.accounts.iter_mut().find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
|
||||||
|
{
|
||||||
|
let id = existing.id.clone();
|
||||||
|
*existing = Account { id: id.clone(), ..account };
|
||||||
|
self.active = Some(id);
|
||||||
|
return existing.clone();
|
||||||
|
}
|
||||||
|
self.active = Some(account.id.clone());
|
||||||
|
self.accounts.push(account.clone());
|
||||||
|
account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> i64 {
|
||||||
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
|
||||||
|
let status = resp.status();
|
||||||
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
|
anyhow!("{}", body.get("error").and_then(|e| e.as_str()).map(String::from).unwrap_or_else(|| format!("panel returned {status}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
|
||||||
|
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||||
|
let resp = state
|
||||||
|
.http
|
||||||
|
.post(format!("{panel}/api/v1/auth/login"))
|
||||||
|
.json(&LoginRequest { username: username.trim().into(), password: password.into() })
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("couldn't reach the server")?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(panel_error(resp).await);
|
||||||
|
}
|
||||||
|
let auth: AuthResponse = resp.json().await?;
|
||||||
|
save_panel_account(state, &panel, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register_panel(state: &AppState, username: &str, password: &str, email: Option<String>) -> Result<(Option<Account>, bool)> {
|
||||||
|
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||||
|
let resp = state
|
||||||
|
.http
|
||||||
|
.post(format!("{panel}/api/v1/auth/register"))
|
||||||
|
.json(&RegisterRequest { username: username.trim().into(), password: password.into(), email })
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("couldn't reach the server")?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(panel_error(resp).await);
|
||||||
|
}
|
||||||
|
let auth: AuthResponse = resp.json().await?;
|
||||||
|
if auth.pending {
|
||||||
|
return Ok((None, true));
|
||||||
|
}
|
||||||
|
Ok((Some(save_panel_account(state, &panel, auth)?), false))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
|
||||||
|
let account = Account {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: "panel".into(),
|
||||||
|
username: auth.user.username.clone(),
|
||||||
|
uuid: auth.user.uuid.clone(),
|
||||||
|
panel_url: Some(panel.to_string()),
|
||||||
|
role: Some(auth.user.role.clone()),
|
||||||
|
};
|
||||||
|
let account = state.accounts.write().unwrap().upsert(account);
|
||||||
|
state.secrets.set(&account.id, Secret { panel_token: Some(auth.token), ..Default::default() })?;
|
||||||
|
state.save_accounts()?;
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_offline(state: &AppState, username: &str) -> Result<Account> {
|
||||||
|
let allowed = state.manifest.read().unwrap().as_ref().map(|m| m.auth.offline_local).unwrap_or(true);
|
||||||
|
if !allowed {
|
||||||
|
bail!("offline accounts are disabled on this server");
|
||||||
|
}
|
||||||
|
let name = username.trim();
|
||||||
|
if !valid_username(name) {
|
||||||
|
bail!("usernames are 3–16 letters, numbers or underscores");
|
||||||
|
}
|
||||||
|
let account = Account {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: "offline".into(),
|
||||||
|
username: name.into(),
|
||||||
|
uuid: offline_uuid(name),
|
||||||
|
panel_url: None,
|
||||||
|
role: None,
|
||||||
|
};
|
||||||
|
let account = state.accounts.write().unwrap().upsert(account);
|
||||||
|
state.save_accounts()?;
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ms_client_id(state: &AppState) -> Result<String> {
|
||||||
|
state
|
||||||
|
.manifest
|
||||||
|
.read()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.auth.microsoft.then(|| m.auth.microsoft_client_id.clone()).flatten())
|
||||||
|
.filter(|c| !c.is_empty())
|
||||||
|
.ok_or_else(|| anyhow!("Microsoft sign-in isn't enabled on this server"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_ms_session(state: &AppState, session: scopenet_core::msa::MsaSession) -> Result<Account> {
|
||||||
|
let uuid = dashed(&session.profile.id);
|
||||||
|
let account = Account {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
kind: "microsoft".into(),
|
||||||
|
username: session.profile.name.clone(),
|
||||||
|
uuid,
|
||||||
|
panel_url: None,
|
||||||
|
role: None,
|
||||||
|
};
|
||||||
|
let account = state.accounts.write().unwrap().upsert(account);
|
||||||
|
state.secrets.set(
|
||||||
|
&account.id,
|
||||||
|
Secret {
|
||||||
|
ms_refresh_token: Some(session.refresh_token),
|
||||||
|
mc_access_token: Some(session.mc_access_token),
|
||||||
|
mc_expires_at: session.mc_expires_at,
|
||||||
|
xuid: session.xuid,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
state.save_accounts()?;
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dashed(id: &str) -> String {
|
||||||
|
let id = id.replace('-', "");
|
||||||
|
if id.len() != 32 {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
format!("{}-{}-{}-{}-{}", &id[0..8], &id[8..12], &id[12..16], &id[16..20], &id[20..32])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Credentials passed to the game.
|
||||||
|
pub async fn game_auth(state: &AppState, account: &Account) -> Result<scopenet_core::launch::Auth> {
|
||||||
|
use scopenet_core::launch::Auth;
|
||||||
|
match account.kind.as_str() {
|
||||||
|
"microsoft" => {
|
||||||
|
let mut secret = state.secrets.get(&account.id);
|
||||||
|
if secret.mc_access_token.is_none() || secret.mc_expires_at < now() + 300 {
|
||||||
|
let refresh = secret.ms_refresh_token.clone().ok_or_else(|| anyhow!("please sign in to Microsoft again"))?;
|
||||||
|
let client_id = ms_client_id(state)?;
|
||||||
|
let session = scopenet_core::msa::refresh(&state.http, &client_id, &refresh).await?;
|
||||||
|
if session.profile.name != account.username {
|
||||||
|
let mut accounts = state.accounts.write().unwrap();
|
||||||
|
if let Some(a) = accounts.accounts.iter_mut().find(|a| a.id == account.id) {
|
||||||
|
a.username = session.profile.name.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.save_accounts().ok();
|
||||||
|
secret = Secret {
|
||||||
|
ms_refresh_token: Some(session.refresh_token),
|
||||||
|
mc_access_token: Some(session.mc_access_token),
|
||||||
|
mc_expires_at: session.mc_expires_at,
|
||||||
|
xuid: session.xuid,
|
||||||
|
..secret
|
||||||
|
};
|
||||||
|
state.secrets.set(&account.id, secret.clone())?;
|
||||||
|
}
|
||||||
|
let active_name = state.accounts.read().unwrap().accounts.iter().find(|a| a.id == account.id).map(|a| a.username.clone());
|
||||||
|
Ok(Auth {
|
||||||
|
username: active_name.unwrap_or_else(|| account.username.clone()),
|
||||||
|
uuid: account.uuid.clone(),
|
||||||
|
access_token: secret.mc_access_token.unwrap_or_default(),
|
||||||
|
user_type: "msa".into(),
|
||||||
|
xuid: secret.xuid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Ok(Auth {
|
||||||
|
username: account.username.clone(),
|
||||||
|
uuid: account.uuid.clone(),
|
||||||
|
// Offline mode: any token works; servers in offline mode ignore it.
|
||||||
|
access_token: "0".into(),
|
||||||
|
user_type: "legacy".into(),
|
||||||
|
xuid: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panel token for requests, when the active account belongs to this panel.
|
||||||
|
pub fn panel_token(state: &AppState) -> Option<String> {
|
||||||
|
let panel = state.panel_url()?;
|
||||||
|
let accounts = state.accounts.read().unwrap();
|
||||||
|
let active = accounts.active()?;
|
||||||
|
if active.kind != "panel" || active.panel_url.as_deref() != Some(panel.as_str()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
state.secrets.get(&active.id).panel_token
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_replaces_same_identity() {
|
||||||
|
let mut f = AccountsFile::default();
|
||||||
|
let a = Account { id: "1".into(), kind: "offline".into(), username: "Steve".into(), uuid: "u".into(), panel_url: None, role: None };
|
||||||
|
f.upsert(a.clone());
|
||||||
|
f.upsert(Account { id: "2".into(), ..a.clone() });
|
||||||
|
assert_eq!(f.accounts.len(), 1);
|
||||||
|
assert_eq!(f.active.as_deref(), Some("1"));
|
||||||
|
assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! Everything the UI can ask the backend to do.
|
||||||
|
|
||||||
|
use crate::accounts::{self, Account};
|
||||||
|
use crate::game;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use crate::state::{build, AppState};
|
||||||
|
use crate::updater;
|
||||||
|
use scopenet_core::msa::DeviceCode;
|
||||||
|
use scopenet_core::ping::ServerStatus;
|
||||||
|
use scopenet_shared::LauncherManifest;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
/// Commands return `Err(String)`; the UI shows it as-is.
|
||||||
|
type Res<T> = Result<T, String>;
|
||||||
|
|
||||||
|
fn err(e: impl std::fmt::Display) -> String {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// anyhow's alternate format keeps the context chain readable.
|
||||||
|
fn aerr(e: anyhow::Error) -> String {
|
||||||
|
format!("{e:#}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AppInfo {
|
||||||
|
version: String,
|
||||||
|
default_panel_url: Option<String>,
|
||||||
|
panel_locked: bool,
|
||||||
|
repo: Option<String>,
|
||||||
|
os: String,
|
||||||
|
arch: String,
|
||||||
|
total_ram_mb: u64,
|
||||||
|
data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Bootstrap {
|
||||||
|
app: AppInfo,
|
||||||
|
panel_url: Option<String>,
|
||||||
|
settings: Settings,
|
||||||
|
accounts: Vec<Account>,
|
||||||
|
active_account: Option<String>,
|
||||||
|
manifest: Option<LauncherManifest>,
|
||||||
|
game_running: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
|
||||||
|
let accounts = state.accounts.read().unwrap().clone();
|
||||||
|
Bootstrap {
|
||||||
|
app: AppInfo {
|
||||||
|
version: env!("CARGO_PKG_VERSION").into(),
|
||||||
|
default_panel_url: build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from),
|
||||||
|
panel_locked: build::panel_locked(),
|
||||||
|
repo: build::REPO.map(String::from),
|
||||||
|
os: std::env::consts::OS.into(),
|
||||||
|
arch: std::env::consts::ARCH.into(),
|
||||||
|
total_ram_mb: scopenet_core::sys::total_memory_mb(),
|
||||||
|
data_dir: state.data_dir.to_string_lossy().into_owned(),
|
||||||
|
},
|
||||||
|
panel_url: state.panel_url(),
|
||||||
|
settings: state.settings.read().unwrap().clone(),
|
||||||
|
accounts: accounts.accounts,
|
||||||
|
active_account: accounts.active,
|
||||||
|
manifest: state.manifest.read().unwrap().clone(),
|
||||||
|
game_running: state.game.lock().unwrap().as_ref().map(|g| g.instance_id.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_manifest(state: &AppState, panel: &str) -> anyhow::Result<LauncherManifest> {
|
||||||
|
let mut req = state.http.get(format!("{panel}/api/v1/launcher/manifest"));
|
||||||
|
if let Some(t) = accounts::panel_token(state) {
|
||||||
|
req = req.bearer_auth(t);
|
||||||
|
}
|
||||||
|
let resp = req.send().await.map_err(|e| anyhow::anyhow!("couldn't reach {panel}: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!("{panel} returned {}", resp.status());
|
||||||
|
}
|
||||||
|
let m: LauncherManifest = resp.json().await.map_err(|_| anyhow::anyhow!("{panel} doesn't look like a SCOPENET panel"))?;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn refresh_manifest(state: State<'_, AppState>) -> Res<LauncherManifest> {
|
||||||
|
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||||
|
let m = fetch_manifest(&state, &panel).await.map_err(aerr)?;
|
||||||
|
state.cache_manifest(&m);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_panel_url(state: State<'_, AppState>, url: String) -> Res<LauncherManifest> {
|
||||||
|
if build::panel_locked() {
|
||||||
|
return Err("this launcher is locked to its server".into());
|
||||||
|
}
|
||||||
|
let mut url = url.trim().trim_end_matches('/').to_string();
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
url = format!("https://{url}");
|
||||||
|
}
|
||||||
|
let m = fetch_manifest(&state, &url).await.map_err(aerr)?;
|
||||||
|
state.settings.write().unwrap().panel_url = Some(url);
|
||||||
|
state.save_settings().map_err(aerr)?;
|
||||||
|
state.cache_manifest(&m);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Res<()> {
|
||||||
|
let mut current = state.settings.write().unwrap();
|
||||||
|
let panel = current.panel_url.clone();
|
||||||
|
*current = Settings { panel_url: panel, ..settings };
|
||||||
|
drop(current);
|
||||||
|
state.save_settings().map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- accounts ----
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn login_panel(state: State<'_, AppState>, username: String, password: String) -> Res<Account> {
|
||||||
|
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RegisterResult {
|
||||||
|
account: Option<Account>,
|
||||||
|
pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn register_panel(state: State<'_, AppState>, username: String, password: String, email: Option<String>) -> Res<RegisterResult> {
|
||||||
|
let (account, pending) = accounts::register_panel(&state, &username, &password, email).await.map_err(aerr)?;
|
||||||
|
Ok(RegisterResult { account, pending })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_offline(state: State<'_, AppState>, username: String) -> Res<Account> {
|
||||||
|
accounts::add_offline(&state, &username).map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ms_start(state: State<'_, AppState>) -> Res<DeviceCode> {
|
||||||
|
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
|
||||||
|
let code = scopenet_core::msa::start_device_code(&state.http, &client_id).await.map_err(aerr)?;
|
||||||
|
*state.device_code.lock().unwrap() = Some(code.clone());
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ms_finish(state: State<'_, AppState>) -> Res<Account> {
|
||||||
|
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
|
||||||
|
let code = state.device_code.lock().unwrap().clone().ok_or("start the sign-in first")?;
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
*state.ms_cancel.lock().unwrap() = Some(tx);
|
||||||
|
let session = tokio::select! {
|
||||||
|
r = scopenet_core::msa::finish_device_code(&state.http, &client_id, &code) => r.map_err(aerr)?,
|
||||||
|
_ = rx => return Err("cancelled".into()),
|
||||||
|
};
|
||||||
|
*state.device_code.lock().unwrap() = None;
|
||||||
|
accounts::save_ms_session(&state, session).map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn ms_cancel(state: State<'_, AppState>) {
|
||||||
|
if let Some(tx) = state.ms_cancel.lock().unwrap().take() {
|
||||||
|
tx.send(()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn select_account(state: State<'_, AppState>, id: String) -> Res<()> {
|
||||||
|
let mut accounts = state.accounts.write().unwrap();
|
||||||
|
if !accounts.accounts.iter().any(|a| a.id == id) {
|
||||||
|
return Err("account not found".into());
|
||||||
|
}
|
||||||
|
accounts.active = Some(id);
|
||||||
|
drop(accounts);
|
||||||
|
state.save_accounts().map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_account(state: State<'_, AppState>, id: String) -> Res<()> {
|
||||||
|
let mut accounts = state.accounts.write().unwrap();
|
||||||
|
accounts.accounts.retain(|a| a.id != id);
|
||||||
|
if accounts.active.as_deref() == Some(id.as_str()) {
|
||||||
|
accounts.active = accounts.accounts.first().map(|a| a.id.clone());
|
||||||
|
}
|
||||||
|
drop(accounts);
|
||||||
|
state.secrets.remove(&id).map_err(aerr)?;
|
||||||
|
state.save_accounts().map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- game ----
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
|
||||||
|
start_task(app, &state, instance_id, true, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn repair_instance(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
|
||||||
|
start_task(app, &state, instance_id, false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_task(app: AppHandle, state: &AppState, instance_id: String, start: bool, deep: bool) -> Res<()> {
|
||||||
|
if state.game.lock().unwrap().is_some() {
|
||||||
|
return Err("Minecraft is already running".into());
|
||||||
|
}
|
||||||
|
let mut task = state.task.lock().unwrap();
|
||||||
|
if task.as_ref().is_some_and(|t| !t.is_finished()) {
|
||||||
|
return Err("already preparing an instance".into());
|
||||||
|
}
|
||||||
|
let app2 = app.clone();
|
||||||
|
let handle = tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = game::run(app2.clone(), instance_id.clone(), start, deep).await {
|
||||||
|
tracing::error!("launch failed: {e:#}");
|
||||||
|
game::emit_state(&app2, &instance_id, "error", Some(format!("{e:#}")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*task = Some(handle.inner().abort_handle());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) {
|
||||||
|
if let Some(t) = state.task.lock().unwrap().take() {
|
||||||
|
t.abort();
|
||||||
|
}
|
||||||
|
game::emit_state(&app, &instance_id, "idle", Some("Cancelled".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn kill_game(state: State<'_, AppState>) {
|
||||||
|
if let Some(g) = state.game.lock().unwrap().take() {
|
||||||
|
g.kill.send(()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct InstanceLocal {
|
||||||
|
installed: bool,
|
||||||
|
revision: i64,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_local(state: State<'_, AppState>, instance_id: String) -> Res<InstanceLocal> {
|
||||||
|
let dir = state.layout.instance_dir(&instance_id);
|
||||||
|
let s = scopenet_core::sync::load_state(&dir);
|
||||||
|
let size = tokio::task::spawn_blocking(move || scopenet_core::sys::dir_size(&dir)).await.unwrap_or(0);
|
||||||
|
Ok(InstanceLocal { installed: s.revision > 0, revision: s.revision, size })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ping_server(host: String, port: u16) -> Res<ServerStatus> {
|
||||||
|
scopenet_core::ping::ping(&host, port).await.map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_folder(app: AppHandle, state: State<'_, AppState>, kind: String, instance_id: Option<String>) -> Res<()> {
|
||||||
|
let base = match &instance_id {
|
||||||
|
Some(id) => state.layout.instance_dir(id),
|
||||||
|
None => state.data_dir.clone(),
|
||||||
|
};
|
||||||
|
let path = match kind.as_str() {
|
||||||
|
"instance" | "data" => base,
|
||||||
|
"logs" | "screenshots" | "mods" | "resourcepacks" | "shaderpacks" | "saves" | "crash-reports" => base.join(&kind),
|
||||||
|
_ => return Err("unknown folder".into()),
|
||||||
|
};
|
||||||
|
std::fs::create_dir_all(&path).map_err(err)?;
|
||||||
|
app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_file(app: AppHandle, path: String) -> Res<()> {
|
||||||
|
app.opener().open_path(path, None::<&str>).map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Storage {
|
||||||
|
shared: u64,
|
||||||
|
runtimes: u64,
|
||||||
|
instances: Vec<(String, u64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn storage_info(state: State<'_, AppState>) -> Res<Storage> {
|
||||||
|
let layout = state.layout.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
use scopenet_core::sys::dir_size;
|
||||||
|
let shared = dir_size(&layout.libraries()) + dir_size(&layout.assets()) + dir_size(&layout.versions());
|
||||||
|
let runtimes = dir_size(&layout.runtimes());
|
||||||
|
let instances = std::fs::read_dir(layout.instances())
|
||||||
|
.map(|rd| rd.filter_map(|e| e.ok()).map(|e| (e.file_name().to_string_lossy().into_owned(), dir_size(&e.path()))).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Storage { shared, runtimes, instances }
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_instance_data(state: State<'_, AppState>, instance_id: String) -> Res<()> {
|
||||||
|
if state.game.lock().unwrap().as_ref().is_some_and(|g| g.instance_id == instance_id) {
|
||||||
|
return Err("close the game first".into());
|
||||||
|
}
|
||||||
|
let dir = state.layout.instance_dir(&instance_id);
|
||||||
|
tokio::fs::remove_dir_all(dir).await.or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }).map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_cache(state: State<'_, AppState>) -> Res<()> {
|
||||||
|
let cache = state.layout.cache();
|
||||||
|
tokio::fs::remove_dir_all(cache).await.ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_java(path: String) -> Option<u32> {
|
||||||
|
scopenet_core::java::detect_major(std::path::Path::new(&path)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_update(state: State<'_, AppState>) -> Res<Option<updater::UpdateInfo>> {
|
||||||
|
updater::check(&state.http).await.map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_update(app: AppHandle, state: State<'_, AppState>, url: String) -> Res<()> {
|
||||||
|
updater::download_and_run(&state.http, &url).await.map_err(aerr)?;
|
||||||
|
app.exit(0);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn show_window(app: AppHandle) {
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
w.show().ok();
|
||||||
|
w.set_focus().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
//! The Play button: install → sync → configure → start → watch.
|
||||||
|
|
||||||
|
use crate::accounts;
|
||||||
|
use crate::state::{AppState, RunningGame};
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use scopenet_core::install::{self, InstallSpec};
|
||||||
|
use scopenet_core::launch::{self, LaunchOptions};
|
||||||
|
use scopenet_core::progress::{Event, Reporter, Stage};
|
||||||
|
use scopenet_core::{options, servers_dat, sync};
|
||||||
|
use scopenet_shared::{InstanceManifest, LaunchEvent};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct StatePayload {
|
||||||
|
pub instance_id: String,
|
||||||
|
/// "preparing" | "running" | "idle" | "error"
|
||||||
|
pub state: String,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct ExitPayload {
|
||||||
|
pub instance_id: String,
|
||||||
|
pub code: Option<i32>,
|
||||||
|
pub crashed: bool,
|
||||||
|
pub tail: Vec<String>,
|
||||||
|
pub crash_report: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_state(app: &AppHandle, id: &str, state: &str, message: Option<String>) {
|
||||||
|
app.emit("launch://state", StatePayload { instance_id: id.into(), state: state.into(), message }).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the instance definition, falling back to the last copy we saw so
|
||||||
|
/// the game still starts without internet.
|
||||||
|
async fn instance_manifest(state: &AppState, id: &str) -> Result<InstanceManifest> {
|
||||||
|
let cache = state.layout.instance_dir(id).join(".scopenet").join("manifest.json");
|
||||||
|
let fetched = async {
|
||||||
|
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||||
|
let mut req = state.http.get(format!("{panel}/api/v1/launcher/instances/{id}"));
|
||||||
|
if let Some(token) = accounts::panel_token(state) {
|
||||||
|
req = req.bearer_auth(token);
|
||||||
|
}
|
||||||
|
let resp = req.send().await?;
|
||||||
|
if resp.status().as_u16() == 404 {
|
||||||
|
anyhow::bail!("this instance no longer exists or you don't have access to it");
|
||||||
|
}
|
||||||
|
Ok::<_, anyhow::Error>(resp.error_for_status()?.json::<InstanceManifest>().await?)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match fetched {
|
||||||
|
Ok(m) => {
|
||||||
|
std::fs::create_dir_all(cache.parent().unwrap()).ok();
|
||||||
|
std::fs::write(&cache, serde_json::to_vec(&m)?).ok();
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let bytes = std::fs::read(&cache).map_err(|_| e)?;
|
||||||
|
tracing::warn!("panel unreachable, using cached instance manifest");
|
||||||
|
Ok(serde_json::from_slice(&bytes)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reporter(app: &AppHandle) -> Reporter {
|
||||||
|
let app = app.clone();
|
||||||
|
Arc::new(move |ev: Event| {
|
||||||
|
app.emit("launch://progress", ev).ok();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install/verify an instance and (when `start`) run the game.
|
||||||
|
pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -> Result<()> {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let report = reporter(&app);
|
||||||
|
emit_state(&app, &instance_id, "preparing", None);
|
||||||
|
|
||||||
|
let account = state.accounts.read().unwrap().active().cloned();
|
||||||
|
let account = match (start, account) {
|
||||||
|
(true, None) => anyhow::bail!("sign in first"),
|
||||||
|
(_, a) => a,
|
||||||
|
};
|
||||||
|
let auth = match &account {
|
||||||
|
Some(a) if start => Some(accounts::game_auth(&state, a).await?),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let manifest = instance_manifest(&state, &instance_id).await?;
|
||||||
|
let inst = &manifest.instance;
|
||||||
|
let settings = state.settings.read().unwrap().clone();
|
||||||
|
let advanced = state.manifest.read().unwrap().as_ref().map(|m| m.branding.features.allow_advanced_java).unwrap_or(true);
|
||||||
|
let ov = settings.instance_overrides.get(&instance_id).cloned().unwrap_or_default();
|
||||||
|
let game_dir = state.layout.instance_dir(&instance_id);
|
||||||
|
let panel = state.panel_url().unwrap_or_default();
|
||||||
|
|
||||||
|
let java_override = if advanced {
|
||||||
|
ov.java_path.clone().or(settings.java_path.clone()).filter(|p| !p.trim().is_empty()).map(PathBuf::from)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let spec = InstallSpec {
|
||||||
|
mc_version: inst.mc_version.clone(),
|
||||||
|
loader: inst.loader,
|
||||||
|
loader_version: inst.loader_version.clone(),
|
||||||
|
java_override,
|
||||||
|
game_dir: game_dir.clone(),
|
||||||
|
concurrency: settings.concurrent_downloads.clamp(1, 32) as usize,
|
||||||
|
deep_verify: deep,
|
||||||
|
};
|
||||||
|
let installed = install::install(&state.http, &state.layout, &spec, &report).await?;
|
||||||
|
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?;
|
||||||
|
|
||||||
|
if let Some(server) = &inst.server {
|
||||||
|
if server.inject && !server.address.is_empty() {
|
||||||
|
let name = if server.name.is_empty() { inst.name.clone() } else { server.name.clone() };
|
||||||
|
servers_dat::upsert(&game_dir, &name, &servers_dat::format_address(&server.address, server.port)).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if settings.keybinds_enabled && installed.version.is_modern() && !settings.keybinds.is_empty() {
|
||||||
|
options::apply_keybinds(&game_dir, &settings.keybinds).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !start {
|
||||||
|
emit_state(&app, &instance_id, "idle", Some("Instance is up to date".into()));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let auth = auth.unwrap();
|
||||||
|
|
||||||
|
report(Event::Stage { stage: Stage::Launching, label: "Starting Minecraft".into() });
|
||||||
|
let pick = |a: u32, b: u32, c: u32| {
|
||||||
|
if a > 0 {
|
||||||
|
a
|
||||||
|
} else if b > 0 {
|
||||||
|
b
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let max = pick(ov.memory_max_mb, settings.memory_max_mb, inst.memory.max_mb).max(512);
|
||||||
|
let min = pick(ov.memory_min_mb, settings.memory_min_mb, inst.memory.min_mb).min(max);
|
||||||
|
let mut jvm = launch::split_args(&inst.jvm_args);
|
||||||
|
if advanced {
|
||||||
|
jvm.extend(launch::split_args(&settings.jvm_args));
|
||||||
|
jvm.extend(launch::split_args(&ov.jvm_args));
|
||||||
|
}
|
||||||
|
let branding = state.manifest.read().unwrap().as_ref().map(|m| m.branding.clone()).unwrap_or_default();
|
||||||
|
let opts = LaunchOptions {
|
||||||
|
auth: auth.clone(),
|
||||||
|
game_dir: game_dir.clone(),
|
||||||
|
memory_min_mb: min,
|
||||||
|
memory_max_mb: max,
|
||||||
|
gc: settings.gc,
|
||||||
|
extra_jvm_args: jvm,
|
||||||
|
resolution: settings.custom_resolution.then_some((settings.width, settings.height)),
|
||||||
|
fullscreen: settings.fullscreen,
|
||||||
|
join_server: inst.server.as_ref().filter(|s| s.auto_join && !s.address.is_empty()).map(|s| (s.address.clone(), s.port)),
|
||||||
|
version_label: if branding.version_label.is_empty() { branding.name.clone() } else { branding.version_label.clone() },
|
||||||
|
launcher_name: "scopenet".into(),
|
||||||
|
launcher_version: env!("CARGO_PKG_VERSION").into(),
|
||||||
|
};
|
||||||
|
let cmd = launch::build(&installed, &state.layout, &opts);
|
||||||
|
tracing::info!("launching: {} {}", cmd.java.display(), launch::redact(&cmd.args, &auth.access_token).join(" "));
|
||||||
|
|
||||||
|
let close_after = settings.after_launch == "close";
|
||||||
|
let mut child = launch::spawn(&cmd, installed.java_major, !close_after).context("couldn't start Java")?;
|
||||||
|
|
||||||
|
if settings.send_stats && !panel.is_empty() {
|
||||||
|
let http = state.http.clone();
|
||||||
|
let token = accounts::panel_token(&state);
|
||||||
|
let ev = LaunchEvent { instance_id: instance_id.clone(), kind: "launch".into(), username: Some(auth.username.clone()) };
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut req = http.post(format!("{panel}/api/v1/launcher/events")).json(&ev);
|
||||||
|
if let Some(t) = token {
|
||||||
|
req = req.bearer_auth(t);
|
||||||
|
}
|
||||||
|
req.send().await.ok();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if close_after {
|
||||||
|
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||||
|
app.exit(0);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
|
||||||
|
*state.game.lock().unwrap() = Some(RunningGame { instance_id: instance_id.clone(), kill: kill_tx });
|
||||||
|
emit_state(&app, &instance_id, "running", None);
|
||||||
|
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
match settings.after_launch.as_str() {
|
||||||
|
"hide" => {
|
||||||
|
window.hide().ok();
|
||||||
|
}
|
||||||
|
"minimize" => {
|
||||||
|
window.minimize().ok();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream logs to the UI in small batches (cheap IPC), keep a tail for crash reports.
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||||
|
for stream in [
|
||||||
|
child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>),
|
||||||
|
child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let tx = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(stream).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if tx.send(line).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let app2 = app.clone();
|
||||||
|
let id2 = instance_id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tail: VecDeque<String> = VecDeque::with_capacity(200);
|
||||||
|
let mut batch: Vec<String> = Vec::new();
|
||||||
|
let mut kill_rx = kill_rx;
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_millis(150));
|
||||||
|
let mut streams_open = true;
|
||||||
|
let mut kill_armed = true;
|
||||||
|
let mut killed = false;
|
||||||
|
let status = loop {
|
||||||
|
tokio::select! {
|
||||||
|
line = rx.recv(), if streams_open => match line {
|
||||||
|
Some(l) => {
|
||||||
|
if tail.len() == 200 { tail.pop_front(); }
|
||||||
|
tail.push_back(l.clone());
|
||||||
|
batch.push(l);
|
||||||
|
}
|
||||||
|
None => streams_open = false,
|
||||||
|
},
|
||||||
|
_ = tick.tick() => {
|
||||||
|
if !batch.is_empty() {
|
||||||
|
app2.emit("game://log", std::mem::take(&mut batch)).ok();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
res = &mut kill_rx, if kill_armed => {
|
||||||
|
kill_armed = false;
|
||||||
|
if res.is_ok() {
|
||||||
|
killed = true;
|
||||||
|
child.kill().await.ok();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status = child.wait() => break status.ok(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Drain whatever is left.
|
||||||
|
while let Ok(l) = rx.try_recv() {
|
||||||
|
tail.push_back(l.clone());
|
||||||
|
batch.push(l);
|
||||||
|
}
|
||||||
|
if !batch.is_empty() {
|
||||||
|
app2.emit("game://log", batch).ok();
|
||||||
|
}
|
||||||
|
let code = status.and_then(|s| s.code());
|
||||||
|
let crashed = !killed && code.is_some_and(|c| c != 0);
|
||||||
|
let crash_report = if crashed { newest_crash_report(&app2, &id2) } else { None };
|
||||||
|
let st = app2.state::<AppState>();
|
||||||
|
*st.game.lock().unwrap() = None;
|
||||||
|
if let Some(w) = app2.get_webview_window("main") {
|
||||||
|
w.show().ok();
|
||||||
|
w.unminimize().ok();
|
||||||
|
if crashed {
|
||||||
|
w.set_focus().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), code, crashed, tail: tail.into_iter().collect(), crash_report })
|
||||||
|
.ok();
|
||||||
|
emit_state(&app2, &id2, "idle", None);
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn newest_crash_report(app: &AppHandle, id: &str) -> Option<String> {
|
||||||
|
let dir = app.state::<AppState>().layout.instance_dir(id).join("crash-reports");
|
||||||
|
let newest = std::fs::read_dir(dir)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
|
||||||
|
.max_by_key(|(t, _)| *t)?;
|
||||||
|
// Only if it was written in the last few minutes (i.e. by this session).
|
||||||
|
(newest.0.elapsed().ok()? < Duration::from_secs(300)).then(|| newest.1.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! Encrypted storage for tokens (panel sessions, Microsoft refresh tokens).
|
||||||
|
//!
|
||||||
|
//! Secrets live in `secrets.bin`, encrypted with ChaCha20-Poly1305. The key
|
||||||
|
//! is kept in the OS credential store (Windows Credential Manager / macOS
|
||||||
|
//! Keychain), so copying the data folder to another machine doesn't leak
|
||||||
|
//! sessions. Where no credential store exists we fall back to a key file.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::Engine;
|
||||||
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||||
|
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Secret {
|
||||||
|
pub panel_token: Option<String>,
|
||||||
|
pub ms_refresh_token: Option<String>,
|
||||||
|
pub mc_access_token: Option<String>,
|
||||||
|
pub mc_expires_at: i64,
|
||||||
|
pub xuid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Secrets {
|
||||||
|
path: PathBuf,
|
||||||
|
key: [u8; 32],
|
||||||
|
data: Mutex<HashMap<String, Secret>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE: &str = "net.scopenet.launcher";
|
||||||
|
|
||||||
|
fn load_or_create_key(dir: &std::path::Path) -> [u8; 32] {
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
#[cfg(any(windows, target_os = "macos"))]
|
||||||
|
{
|
||||||
|
if let Ok(entry) = keyring::Entry::new(SERVICE, "secrets-key") {
|
||||||
|
if let Ok(existing) = entry.get_password() {
|
||||||
|
if let Ok(bytes) = b64.decode(existing) {
|
||||||
|
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut key);
|
||||||
|
if entry.set_password(&b64.encode(key)).is_ok() {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
tracing::warn!("credential store unavailable, falling back to a key file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = SERVICE;
|
||||||
|
let path = dir.join("secrets.key");
|
||||||
|
if let Ok(bytes) = std::fs::read(&path).map(|b| b64.decode(b).unwrap_or_default()) {
|
||||||
|
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut key);
|
||||||
|
std::fs::write(&path, b64.encode(key)).ok();
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Secrets {
|
||||||
|
pub fn open(dir: &std::path::Path) -> Self {
|
||||||
|
let key = load_or_create_key(dir);
|
||||||
|
let path = dir.join("secrets.bin");
|
||||||
|
let data = std::fs::read(&path).ok().and_then(|raw| decrypt(&key, &raw).ok()).unwrap_or_default();
|
||||||
|
Self { path, key, data: Mutex::new(data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> Secret {
|
||||||
|
self.data.lock().unwrap().get(id).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, id: &str, secret: Secret) -> Result<()> {
|
||||||
|
let mut data = self.data.lock().unwrap();
|
||||||
|
data.insert(id.to_string(), secret);
|
||||||
|
self.flush(&data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, id: &str) -> Result<()> {
|
||||||
|
let mut data = self.data.lock().unwrap();
|
||||||
|
data.remove(id);
|
||||||
|
self.flush(&data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self, data: &HashMap<String, Secret>) -> Result<()> {
|
||||||
|
let plain = serde_json::to_vec(data)?;
|
||||||
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.key));
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
rand::thread_rng().fill_bytes(&mut nonce);
|
||||||
|
let mut out = nonce.to_vec();
|
||||||
|
out.extend(cipher.encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|_| anyhow!("encryption failed"))?);
|
||||||
|
let tmp = self.path.with_extension("bin.tmp");
|
||||||
|
std::fs::write(&tmp, out)?;
|
||||||
|
std::fs::rename(tmp, &self.path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt(key: &[u8; 32], raw: &[u8]) -> Result<HashMap<String, Secret>> {
|
||||||
|
if raw.len() < 13 {
|
||||||
|
return Err(anyhow!("secrets file too short"));
|
||||||
|
}
|
||||||
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
|
||||||
|
let plain = cipher.decrypt(Nonce::from_slice(&raw[..12]), &raw[12..]).map_err(|_| anyhow!("can't decrypt secrets"))?;
|
||||||
|
Ok(serde_json::from_slice(&plain)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("scopenet-secrets-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let s = Secrets::open(&dir);
|
||||||
|
s.set("a", Secret { panel_token: Some("tok".into()), ..Default::default() }).unwrap();
|
||||||
|
let raw = std::fs::read(dir.join("secrets.bin")).unwrap();
|
||||||
|
assert!(!String::from_utf8_lossy(&raw).contains("tok"), "stored encrypted");
|
||||||
|
let again = Secrets::open(&dir);
|
||||||
|
assert_eq!(again.get("a").panel_token.as_deref(), Some("tok"));
|
||||||
|
std::fs::remove_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Player settings, stored as JSON in the app data directory.
|
||||||
|
|
||||||
|
use scopenet_core::launch::GcPreset;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct InstanceOverride {
|
||||||
|
/// 0 = use the global / instance default.
|
||||||
|
pub memory_max_mb: u32,
|
||||||
|
pub memory_min_mb: u32,
|
||||||
|
pub jvm_args: String,
|
||||||
|
pub java_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// Panel URL chosen by the player (ignored when one is baked in and locked).
|
||||||
|
pub panel_url: Option<String>,
|
||||||
|
pub selected_instance: Option<String>,
|
||||||
|
|
||||||
|
// ---- Game ----
|
||||||
|
/// 0 = use the value the admin set for the instance.
|
||||||
|
pub memory_max_mb: u32,
|
||||||
|
pub memory_min_mb: u32,
|
||||||
|
pub custom_resolution: bool,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fullscreen: bool,
|
||||||
|
/// "minimize" | "hide" | "keep" | "close"
|
||||||
|
pub after_launch: String,
|
||||||
|
pub show_console: bool,
|
||||||
|
|
||||||
|
// ---- Java ----
|
||||||
|
pub java_path: Option<String>,
|
||||||
|
pub gc: GcPreset,
|
||||||
|
pub jvm_args: String,
|
||||||
|
|
||||||
|
// ---- Appearance ----
|
||||||
|
/// "server" (admin's theme) | "custom"
|
||||||
|
pub theme_mode: String,
|
||||||
|
pub accent: Option<String>,
|
||||||
|
pub background_video: bool,
|
||||||
|
pub reduce_motion: bool,
|
||||||
|
/// None = follow the admin's setting.
|
||||||
|
pub glass: Option<bool>,
|
||||||
|
pub ui_scale: u32,
|
||||||
|
|
||||||
|
// ---- Launcher ----
|
||||||
|
pub concurrent_downloads: u32,
|
||||||
|
pub check_updates: bool,
|
||||||
|
pub send_stats: bool,
|
||||||
|
|
||||||
|
// ---- Controls ----
|
||||||
|
pub keybinds_enabled: bool,
|
||||||
|
pub keybinds: BTreeMap<String, String>,
|
||||||
|
|
||||||
|
pub instance_overrides: HashMap<String, InstanceOverride>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
panel_url: None,
|
||||||
|
selected_instance: None,
|
||||||
|
memory_max_mb: 0,
|
||||||
|
memory_min_mb: 0,
|
||||||
|
custom_resolution: false,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
fullscreen: false,
|
||||||
|
after_launch: "minimize".into(),
|
||||||
|
show_console: false,
|
||||||
|
java_path: None,
|
||||||
|
gc: GcPreset::G1,
|
||||||
|
jvm_args: String::new(),
|
||||||
|
theme_mode: "server".into(),
|
||||||
|
accent: None,
|
||||||
|
background_video: true,
|
||||||
|
reduce_motion: false,
|
||||||
|
glass: None,
|
||||||
|
ui_scale: 100,
|
||||||
|
concurrent_downloads: 12,
|
||||||
|
check_updates: true,
|
||||||
|
send_stats: true,
|
||||||
|
keybinds_enabled: false,
|
||||||
|
keybinds: BTreeMap::new(),
|
||||||
|
instance_overrides: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn load(path: &Path) -> Self {
|
||||||
|
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
|
||||||
|
std::fs::rename(tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
use crate::accounts::AccountsFile;
|
||||||
|
use crate::secrets::Secrets;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use scopenet_core::msa::DeviceCode;
|
||||||
|
use scopenet_core::Layout;
|
||||||
|
use scopenet_shared::LauncherManifest;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
|
||||||
|
pub mod build {
|
||||||
|
/// Default panel URL for this build.
|
||||||
|
pub const PANEL_URL: Option<&str> = option_env!("SCOPENET_PANEL_URL");
|
||||||
|
/// `owner/repo` used to check GitHub releases for updates.
|
||||||
|
pub const REPO: Option<&str> = option_env!("SCOPENET_REPO");
|
||||||
|
/// When set to "1", players can't change the panel URL.
|
||||||
|
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
|
||||||
|
|
||||||
|
pub fn panel_locked() -> bool {
|
||||||
|
LOCK_PANEL == Some("1") && PANEL_URL.is_some_and(|u| !u.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RunningGame {
|
||||||
|
pub instance_id: String,
|
||||||
|
pub kill: tokio::sync::oneshot::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub layout: Layout,
|
||||||
|
pub http: reqwest::Client,
|
||||||
|
pub settings: RwLock<Settings>,
|
||||||
|
pub accounts: RwLock<AccountsFile>,
|
||||||
|
pub secrets: Secrets,
|
||||||
|
pub manifest: RwLock<Option<LauncherManifest>>,
|
||||||
|
pub device_code: Mutex<Option<DeviceCode>>,
|
||||||
|
pub ms_cancel: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||||
|
/// Install/launch pipeline in progress (abortable).
|
||||||
|
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||||
|
pub game: Mutex<Option<RunningGame>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(data_dir: PathBuf) -> Self {
|
||||||
|
std::fs::create_dir_all(&data_dir).ok();
|
||||||
|
let settings = Settings::load(&data_dir.join("settings.json"));
|
||||||
|
let accounts = AccountsFile::load(&data_dir.join("accounts.json"));
|
||||||
|
let manifest = std::fs::read(data_dir.join("manifest.json")).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||||
|
Self {
|
||||||
|
layout: Layout::new(data_dir.join("minecraft")),
|
||||||
|
http: scopenet_core::http::client(),
|
||||||
|
secrets: Secrets::open(&data_dir),
|
||||||
|
settings: RwLock::new(settings),
|
||||||
|
accounts: RwLock::new(accounts),
|
||||||
|
manifest: RwLock::new(manifest),
|
||||||
|
device_code: Mutex::new(None),
|
||||||
|
ms_cancel: Mutex::new(None),
|
||||||
|
task: Mutex::new(None),
|
||||||
|
game: Mutex::new(None),
|
||||||
|
data_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The panel this launcher talks to (baked-in when locked).
|
||||||
|
pub fn panel_url(&self) -> Option<String> {
|
||||||
|
let chosen = self.settings.read().unwrap().panel_url.clone().filter(|u| !u.is_empty());
|
||||||
|
let baked = build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from);
|
||||||
|
let url = if build::panel_locked() { baked } else { chosen.or(baked) };
|
||||||
|
url.map(|u| u.trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_settings(&self) -> anyhow::Result<()> {
|
||||||
|
self.settings.read().unwrap().save(&self.data_dir.join("settings.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_accounts(&self) -> anyhow::Result<()> {
|
||||||
|
self.accounts.read().unwrap().save(&self.data_dir.join("accounts.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_manifest(&self, m: &LauncherManifest) {
|
||||||
|
*self.manifest.write().unwrap() = Some(m.clone());
|
||||||
|
if let Ok(bytes) = serde_json::to_vec(m) {
|
||||||
|
std::fs::write(self.data_dir.join("manifest.json"), bytes).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! Self-update via GitHub Releases: find a newer `*-setup.exe`, download
|
||||||
|
//! it and hand over to the installer.
|
||||||
|
|
||||||
|
use crate::state::build;
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct UpdateInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub notes: String,
|
||||||
|
pub url: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Release {
|
||||||
|
tag_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
prerelease: bool,
|
||||||
|
assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Asset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(v: &str) -> Vec<u64> {
|
||||||
|
v.trim_start_matches('v').split(['.', '-', '+']).map_while(|p| p.parse().ok()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_newer(candidate: &str, current: &str) -> bool {
|
||||||
|
parse(candidate) > parse(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check(http: &reqwest::Client) -> Result<Option<UpdateInfo>> {
|
||||||
|
let Some(repo) = build::REPO.filter(|r| !r.is_empty()) else { return Ok(None) };
|
||||||
|
let release: Release = http
|
||||||
|
.get(format!("https://api.github.com/repos/{repo}/releases/latest"))
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
if release.draft || release.prerelease || !is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name.to_lowercase().ends_with("-setup.exe"))
|
||||||
|
.ok_or_else(|| anyhow!("release {} has no installer", release.tag_name))?;
|
||||||
|
Ok(Some(UpdateInfo {
|
||||||
|
version: release.tag_name.trim_start_matches('v').to_string(),
|
||||||
|
notes: release.body,
|
||||||
|
url: asset.browser_download_url.clone(),
|
||||||
|
size: asset.size,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the installer and start it. The caller exits the app afterwards.
|
||||||
|
pub async fn download_and_run(http: &reqwest::Client, url: &str) -> Result<()> {
|
||||||
|
if !url.starts_with("https://github.com/") && !url.starts_with("https://objects.githubusercontent.com/") {
|
||||||
|
anyhow::bail!("refusing to download an update from an unexpected host");
|
||||||
|
}
|
||||||
|
let dest = std::env::temp_dir().join("scopenet-launcher-setup.exe");
|
||||||
|
let dl = scopenet_core::http::Download::new(url, dest.clone(), None, None);
|
||||||
|
scopenet_core::http::download_one(http, &dl, &|_| {}).await.context("downloading update")?;
|
||||||
|
// /P = passive (progress only), /R = relaunch when done (Tauri NSIS).
|
||||||
|
std::process::Command::new(&dest).args(["/P", "/R"]).spawn().context("starting the installer")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn versions() {
|
||||||
|
assert!(is_newer("v0.2.0", "0.1.9"));
|
||||||
|
assert!(is_newer("1.0.0", "0.9.12"));
|
||||||
|
assert!(!is_newer("v0.1.0", "0.1.0"));
|
||||||
|
assert!(!is_newer("0.1.0-beta", "0.1.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { LoaderCircle, Download, X } from '@lucide/svelte';
|
||||||
|
import Background from './components/Background.svelte';
|
||||||
|
import TitleBar from './components/TitleBar.svelte';
|
||||||
|
import Rail from './components/Rail.svelte';
|
||||||
|
import Modal from './components/Modal.svelte';
|
||||||
|
import SignIn from './components/SignIn.svelte';
|
||||||
|
import InstanceSettings from './components/InstanceSettings.svelte';
|
||||||
|
import CrashDialog from './components/CrashDialog.svelte';
|
||||||
|
import Toasts from './components/Toasts.svelte';
|
||||||
|
import Setup from './pages/Setup.svelte';
|
||||||
|
import Login from './pages/Login.svelte';
|
||||||
|
import Home from './pages/Home.svelte';
|
||||||
|
import Settings from './pages/Settings.svelte';
|
||||||
|
import { app, init } from './lib/store.svelte';
|
||||||
|
import { errorText } from './lib/tauri';
|
||||||
|
|
||||||
|
let fatal = $state('');
|
||||||
|
let updateDismissed = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
init().catch((e) => (fatal = errorText(e)));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Background />
|
||||||
|
|
||||||
|
<div class="shell">
|
||||||
|
<TitleBar />
|
||||||
|
{#if fatal && !app.manifest}
|
||||||
|
<div class="center"><div class="glass msg"><h2>Can't reach the server</h2><p class="muted selectable">{fatal}</p><button class="primary" onclick={() => location.reload()}>Try again</button></div></div>
|
||||||
|
{:else if !app.ready}
|
||||||
|
<div class="center"><LoaderCircle class="spin" size={28} /></div>
|
||||||
|
{:else if !app.panelUrl || !app.manifest}
|
||||||
|
<Setup />
|
||||||
|
{:else if app.accounts.length === 0}
|
||||||
|
<Login />
|
||||||
|
{:else}
|
||||||
|
<div class="body">
|
||||||
|
<Rail />
|
||||||
|
<main>
|
||||||
|
{#if app.view === 'settings'}<Settings />{:else}<Home />{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if app.update && !updateDismissed && app.view !== 'settings' && app.accounts.length}
|
||||||
|
<div class="update glass">
|
||||||
|
<Download size={16} />
|
||||||
|
<span>Launcher {app.update.version} is available</span>
|
||||||
|
<button class="sm primary" onclick={() => { app.view = 'settings'; app.settingsTab = 'about'; }}>View</button>
|
||||||
|
<button class="ghost icon sm" aria-label="Dismiss" onclick={() => (updateDismissed = true)}><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Modal bind:open={app.addAccount} title="Add account" width={26}>
|
||||||
|
<SignIn />
|
||||||
|
</Modal>
|
||||||
|
<InstanceSettings />
|
||||||
|
<CrashDialog />
|
||||||
|
<Toasts />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shell { position: relative; z-index: 1; height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.body { flex: 1; display: flex; min-height: 0; }
|
||||||
|
main { flex: 1; min-width: 0; min-height: 0; position: relative; }
|
||||||
|
.center { flex: 1; display: grid; place-items: center; color: var(--accent); }
|
||||||
|
.msg { padding: 2rem; max-width: 28rem; display: flex; flex-direction: column; gap: 0.8rem; color: var(--text); }
|
||||||
|
.update { position: fixed; top: 3.2rem; left: 50%; transform: translateX(-50%); z-index: 40; display: flex; align-items: center; gap: 0.7rem; padding: 0.45rem 0.5rem 0.45rem 1rem; font-size: 0.87rem; border-radius: var(--radius); background: var(--surface); animation: fade 0.2s ease; }
|
||||||
|
.update :global(svg:first-child) { color: var(--accent); }
|
||||||
|
</style>
|
||||||
@@ -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; } }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Account } from '../lib/types';
|
||||||
|
let { account, size = 2.2 }: { account: Account | null; size?: number } = $props();
|
||||||
|
let failed = $state(false);
|
||||||
|
const hue = $derived([...(account?.username ?? '?')].reduce((a, c) => a + c.charCodeAt(0), 0) % 360);
|
||||||
|
// Microsoft accounts have real skins; everyone else gets a head by name.
|
||||||
|
const src = $derived(account ? `https://mc-heads.net/avatar/${account.kind === 'microsoft' ? account.uuid : encodeURIComponent(account.username)}/64` : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if account && !failed}
|
||||||
|
<img {src} alt="" style:width="{size}rem" style:height="{size}rem" onerror={() => (failed = true)} />
|
||||||
|
{:else}
|
||||||
|
<span class="fb" style:width="{size}rem" style:height="{size}rem" style:background="hsl({hue} 55% 42%)">{account?.username.slice(0, 1).toUpperCase() ?? '?'}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
img, .fb { border-radius: 28%; image-rendering: pixelated; flex-shrink: 0; display: inline-grid; place-items: center; font-weight: 700; color: white; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Full-window backdrop: instance banner → admin image/video → plain colour.
|
||||||
|
import { abs, app, selectedInstance } from '../lib/store.svelte';
|
||||||
|
|
||||||
|
const b = $derived(app.manifest?.branding);
|
||||||
|
const inst = $derived(selectedInstance());
|
||||||
|
const image = $derived(abs(inst?.banner_url) ?? (b?.background.kind === 'image' ? abs(b.background.url) : null));
|
||||||
|
const video = $derived(!inst?.banner_url && b?.background.kind === 'video' && app.settings?.background_video !== false && !app.running ? abs(b.background.url) : null);
|
||||||
|
const blur = $derived(b?.background.blur ?? 0);
|
||||||
|
const dim = $derived((b?.background.dim ?? 55) / 100);
|
||||||
|
let hidden = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:document onvisibilitychange={() => (hidden = document.hidden)} />
|
||||||
|
|
||||||
|
<div class="bg" aria-hidden="true">
|
||||||
|
{#if video && !hidden}
|
||||||
|
{#key video}<video src={video} autoplay muted loop playsinline style:filter="blur({blur}px)"></video>{/key}
|
||||||
|
<div class="shade" style:--dim={dim}></div>
|
||||||
|
{:else if image}
|
||||||
|
{#key image}<div class="img" style:background-image="url("{image}")" style:filter="blur({blur}px)"></div>{/key}
|
||||||
|
<div class="shade" style:--dim={dim}></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bg { position: fixed; inset: 0; z-index: 0; overflow: hidden; background: var(--bg); }
|
||||||
|
video, .img { position: absolute; inset: -1.5rem; width: calc(100% + 3rem); height: calc(100% + 3rem); object-fit: cover; background-size: cover; background-position: center; animation: fade 0.4s ease; }
|
||||||
|
/* Keep text readable: darken, strongest on the left where the title sits. */
|
||||||
|
.shade {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, var(--bg) 0%, color-mix(in srgb, var(--bg) calc(var(--dim) * 100%), transparent) 55%, color-mix(in srgb, var(--bg) calc(var(--dim) * 70%), transparent) 100%),
|
||||||
|
linear-gradient(0deg, var(--bg) 0%, transparent 40%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, Copy, ArrowDown, Search, Trash2 } from '@lucide/svelte';
|
||||||
|
import { app, toast } from '../lib/store.svelte';
|
||||||
|
|
||||||
|
let box = $state<HTMLDivElement>();
|
||||||
|
let follow = $state(true);
|
||||||
|
let filter = $state('');
|
||||||
|
const shown = $derived(filter ? app.logs.filter((l) => l.toLowerCase().includes(filter.toLowerCase())) : app.logs.slice(-1500));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void shown.length;
|
||||||
|
if (follow && box) requestAnimationFrame(() => box && (box.scrollTop = box.scrollHeight));
|
||||||
|
});
|
||||||
|
|
||||||
|
function level(line: string) {
|
||||||
|
if (/\b(ERROR|FATAL|Exception)\b/.test(line)) return 'err';
|
||||||
|
if (/\bWARN\b/.test(line)) return 'warn';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="console glass">
|
||||||
|
<header>
|
||||||
|
<strong>Game output</strong>
|
||||||
|
<span class="tiny muted">{app.logs.length.toLocaleString()} lines</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<div class="search"><Search size={13} /><input bind:value={filter} placeholder="Filter" /></div>
|
||||||
|
<button class="ghost icon sm" title="Copy" aria-label="Copy log" onclick={() => { navigator.clipboard.writeText(app.logs.join('\n')); toast('Log copied'); }}><Copy size={14} /></button>
|
||||||
|
<button class="ghost icon sm" title="Clear" aria-label="Clear log" onclick={() => (app.logs = [])}><Trash2 size={14} /></button>
|
||||||
|
<button class="ghost icon sm" aria-label="Close console" onclick={() => (app.consoleOpen = false)}><X size={15} /></button>
|
||||||
|
</header>
|
||||||
|
<div class="lines selectable" bind:this={box} onscroll={() => box && (follow = box.scrollHeight - box.scrollTop - box.clientHeight < 40)}>
|
||||||
|
{#each shown as line, i (i)}<div class="line {level(line)}">{line}</div>{:else}<div class="muted empty">Logs appear here while the game is running.</div>{/each}
|
||||||
|
</div>
|
||||||
|
{#if !follow}
|
||||||
|
<button class="jump sm" onclick={() => { follow = true; if (box) box.scrollTop = box.scrollHeight; }}><ArrowDown size={13} /> Latest</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.console { position: absolute; left: 1rem; right: 1rem; bottom: 1rem; height: 42%; display: flex; flex-direction: column; z-index: 25; background: var(--bg); border-color: var(--line-strong); animation: fade 0.15s ease; overflow: hidden; }
|
||||||
|
header { display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.6rem 0.5rem 1rem; border-bottom: 1px solid var(--line); font-size: 0.88rem; }
|
||||||
|
.search { display: flex; align-items: center; gap: 0.3rem; color: var(--muted); }
|
||||||
|
.search input { width: 10rem; padding: 0.3rem 0.5rem; font-size: 0.8rem; }
|
||||||
|
.lines { flex: 1; overflow: auto; padding: 0.6rem 1rem; font-family: var(--mono); font-size: 0.74rem; line-height: 1.55; }
|
||||||
|
.line { white-space: pre-wrap; word-break: break-all; color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||||
|
.line.warn { color: var(--warn); }
|
||||||
|
.line.err { color: color-mix(in srgb, var(--danger) 75%, white); }
|
||||||
|
.empty { font-family: var(--font); padding: 1rem 0; }
|
||||||
|
.jump { position: absolute; right: 1.5rem; bottom: 1rem; background: var(--surface); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FolderOpen, FileText, Copy, RotateCcw } from '@lucide/svelte';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { app, play, toast } from '../lib/store.svelte';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
open = !!app.crash;
|
||||||
|
});
|
||||||
|
const c = $derived(app.crash);
|
||||||
|
const hint = $derived.by(() => {
|
||||||
|
const text = c?.tail.join('\n') ?? '';
|
||||||
|
if (/OutOfMemoryError|Java heap space/.test(text)) return 'Minecraft ran out of memory. Give it more RAM in Settings → Game.';
|
||||||
|
if (/UnsupportedClassVersionError/.test(text)) return 'Wrong Java version. Switch Java back to "Automatic" in Settings → Java.';
|
||||||
|
if (/Could not reserve enough space|Invalid maximum heap size/.test(text)) return "Your PC can't give Minecraft that much memory. Lower it in Settings → Game.";
|
||||||
|
if (/Mixin|mixin.*failed|Incompatible mod set|requires .* but/i.test(text)) return 'A mod failed to load. If you added mods yourself, try removing them.';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:open title="Minecraft closed unexpectedly" width={40} onclose={() => (app.crash = null)}>
|
||||||
|
{#if c}
|
||||||
|
<p class="muted small">The game exited with code {c.code}. {hint ?? 'The last lines of the log are below — they usually explain what went wrong.'}</p>
|
||||||
|
{#if hint}<div class="hint">{hint}</div>{/if}
|
||||||
|
<pre class="tail selectable">{c.tail.slice(-40).join('\n')}</pre>
|
||||||
|
{/if}
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="ghost" onclick={() => { navigator.clipboard.writeText(c?.tail.join('\n') ?? ''); toast('Copied'); }}><Copy size={15} /> Copy</button>
|
||||||
|
{#if c?.crash_report}<button onclick={() => invoke('open_file', { path: c?.crash_report })}><FileText size={15} /> Crash report</button>{/if}
|
||||||
|
<button onclick={() => c && invoke('open_folder', { kind: 'logs', instanceId: c.instance_id })}><FolderOpen size={15} /> Logs</button>
|
||||||
|
<button class="primary" onclick={() => { const id = c?.instance_id; app.crash = null; if (id) play(id); }}><RotateCcw size={15} /> Try again</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hint { padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--warn) 12%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent); font-size: 0.88rem; }
|
||||||
|
.tail { margin: 0; max-height: 16rem; overflow: auto; font-family: var(--mono); font-size: 0.72rem; line-height: 1.5; padding: 0.8rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 80%, transparent); border: 1px solid var(--line); white-space: pre-wrap; word-break: break-all; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FolderOpen, Wrench, Trash2, FileCode } from '@lucide/svelte';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { app, repair, saveSettings, toast } from '../lib/store.svelte';
|
||||||
|
import { bytes, gb } from '../lib/format';
|
||||||
|
import { errorText, invoke, pickFile } from '../lib/tauri';
|
||||||
|
import type { InstanceOverride } from '../lib/types';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
open = !!app.instanceSettings;
|
||||||
|
});
|
||||||
|
const inst = $derived(app.manifest?.instances.find((i) => i.id === app.instanceSettings) ?? null);
|
||||||
|
const advanced = $derived(app.manifest?.branding.features.allow_advanced_java !== false);
|
||||||
|
const ram = $derived(app.boot?.app.total_ram_mb ?? 8192);
|
||||||
|
let local = $state<{ installed: boolean; size: number } | null>(null);
|
||||||
|
|
||||||
|
// Create the override entry up front (never mutate state inside $derived).
|
||||||
|
$effect(() => {
|
||||||
|
if (inst && app.settings && !app.settings.instance_overrides[inst.id]) {
|
||||||
|
app.settings.instance_overrides[inst.id] = { memory_max_mb: 0, memory_min_mb: 0, jvm_args: '', java_path: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const ov = $derived<InstanceOverride | undefined>(inst ? app.settings?.instance_overrides[inst.id] : undefined);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (inst) invoke<{ installed: boolean; size: number }>('instance_local', { instanceId: inst.id }).then((l) => (local = l)).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const folders = ['instance', 'mods', 'resourcepacks', 'shaderpacks', 'saves', 'screenshots', 'logs'];
|
||||||
|
|
||||||
|
async function del() {
|
||||||
|
if (!inst) return;
|
||||||
|
try {
|
||||||
|
await invoke('delete_instance_data', { instanceId: inst.id });
|
||||||
|
toast('Local files deleted — they download again next time you play');
|
||||||
|
local = { installed: false, size: 0 };
|
||||||
|
} catch (e) {
|
||||||
|
toast(errorText(e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:open title={inst ? `${inst.name} settings` : ''} width={34} onclose={() => (app.instanceSettings = null)}>
|
||||||
|
{#if inst && app.settings && ov}
|
||||||
|
<div class="grid2">
|
||||||
|
{#each folders as f}
|
||||||
|
<button class="sm" onclick={() => invoke('open_folder', { kind: f, instanceId: inst.id })}><FolderOpen size={14} /> {f === 'instance' ? 'Game folder' : f}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span class="row">Memory for this instance <span class="spacer"></span><strong>{ov.memory_max_mb ? gb(ov.memory_max_mb) : `Default (${gb(app.settings.memory_max_mb || inst.memory.max_mb)})`}</strong></span>
|
||||||
|
<input type="range" min="0" max={Math.max(2048, ram - 1024)} step="512" bind:value={ov.memory_max_mb} oninput={saveSettings} />
|
||||||
|
<span class="help">Slide to the far left to use the global/admin default.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if advanced}
|
||||||
|
<label class="field">Extra JVM arguments
|
||||||
|
<input class="mono" bind:value={ov.jvm_args} oninput={saveSettings} placeholder="-XX:+UseStringDeduplication" />
|
||||||
|
</label>
|
||||||
|
<label class="field">Java for this instance
|
||||||
|
<div class="row">
|
||||||
|
<input value={ov.java_path ?? ''} placeholder="Automatic" readonly />
|
||||||
|
<button class="sm" onclick={async () => { const p = await pickFile('Choose java executable', [{ name: 'Java', extensions: ['exe', ''] }]); if (p) { ov.java_path = p; saveSettings(); } }}><FileCode size={14} /> Browse</button>
|
||||||
|
{#if ov.java_path}<button class="sm ghost" onclick={() => { ov.java_path = null; saveSettings(); }}>Reset</button>{/if}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<span class="tiny muted">{local?.installed ? `Uses ${bytes(local.size)} on disk` : 'Not downloaded yet'}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="sm" onclick={() => { app.instanceSettings = null; repair(inst.id); }}><Wrench size={14} /> Repair files</button>
|
||||||
|
<button class="sm danger" onclick={del}><Trash2 size={14} /> Delete local files</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); gap: 0.45rem; }
|
||||||
|
.grid2 button { justify-content: flex-start; text-transform: capitalize; }
|
||||||
|
.danger-zone { display: flex; align-items: center; gap: 0.5rem; padding-top: 0.9rem; border-top: 1px solid var(--line); flex-wrap: wrap; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X } from '@lucide/svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
let { open = $bindable(false), title, width = 30, children, footer, onclose }: { open: boolean; title: string; width?: number; children: Snippet; footer?: Snippet; onclose?: () => void } = $props();
|
||||||
|
function close() {
|
||||||
|
open = false;
|
||||||
|
onclose?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="backdrop" role="presentation" onclick={(e) => e.target === e.currentTarget && close()}>
|
||||||
|
<div class="modal glass" role="dialog" aria-modal="true" aria-label={title} style:max-width="{width}rem">
|
||||||
|
<header><h3>{title}</h3><button class="ghost icon" onclick={close} aria-label="Close"><X size={17} /></button></header>
|
||||||
|
<div class="body">{@render children()}</div>
|
||||||
|
{#if footer}<footer>{@render footer()}</footer>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 1.5rem; background: color-mix(in srgb, var(--bg) 70%, transparent); animation: fade 0.12s ease; }
|
||||||
|
.modal { width: 100%; max-height: calc(100vh - 4rem); overflow: auto; background: var(--surface); border-color: var(--line-strong); box-shadow: 0 1rem 3rem -1rem rgba(0, 0, 0, 0.5); animation: pop 0.15s ease; }
|
||||||
|
header { display: flex; align-items: center; justify-content: space-between; padding: 1.1rem 1.25rem 0; }
|
||||||
|
.body { padding: 1rem 1.25rem 1.25rem; display: flex; flex-direction: column; gap: 0.9rem; }
|
||||||
|
footer { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.9rem 1.25rem; border-top: 1px solid var(--line); }
|
||||||
|
@keyframes pop { from { opacity: 0; transform: translateY(0.3rem); } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Pin, ArrowUpRight } from '@lucide/svelte';
|
||||||
|
import { abs } from '../lib/store.svelte';
|
||||||
|
import { niceDate } from '../lib/format';
|
||||||
|
import { openUrl } from '../lib/tauri';
|
||||||
|
import type { NewsItem } from '../lib/types';
|
||||||
|
|
||||||
|
let { news }: { news: NewsItem[] } = $props();
|
||||||
|
const sorted = $derived([...news].sort((a, b) => Number(b.pinned) - Number(a.pinned)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="news">
|
||||||
|
<h4 class="head">News</h4>
|
||||||
|
<div class="list">
|
||||||
|
{#each sorted as n (n.id)}
|
||||||
|
<article class="card glass" class:link={!!n.link}>
|
||||||
|
{#if n.image_url}<img src={abs(n.image_url)} alt="" loading="lazy" />{/if}
|
||||||
|
<div class="body">
|
||||||
|
<div class="meta">
|
||||||
|
{#if n.tag}<span class="tag">{n.tag}</span>{/if}
|
||||||
|
{#if n.pinned}<Pin size={11} />{/if}
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="tiny muted">{niceDate(n.date)}</span>
|
||||||
|
</div>
|
||||||
|
<h3>{n.title}</h3>
|
||||||
|
<p class="selectable">{n.body}</p>
|
||||||
|
{#if n.link}
|
||||||
|
<button class="more ghost sm" onclick={() => n.link && openUrl(n.link)}>Read more <ArrowUpRight size={13} /></button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.news { display: flex; flex-direction: column; min-height: 0; gap: 0.7rem; }
|
||||||
|
.head { display: flex; align-items: center; gap: 0.45rem; font-size: 0.75rem; color: var(--muted); font-weight: 560; padding-left: 0.1rem; }
|
||||||
|
.list { display: flex; flex-direction: column; gap: 0.7rem; overflow-y: auto; padding-right: 0.3rem; min-height: 0; padding-bottom: 1rem; }
|
||||||
|
.card { overflow: hidden; flex-shrink: 0; background: var(--surface); transition: border-color 0.15s; }
|
||||||
|
.card:hover { border-color: var(--line-strong); }
|
||||||
|
.card img { width: 100%; aspect-ratio: 16 / 7; object-fit: cover; display: block; }
|
||||||
|
.body { padding: 0.85rem 1rem 0.9rem; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||||
|
.meta { display: flex; align-items: center; gap: 0.4rem; color: var(--muted); }
|
||||||
|
.tag { font-size: 0.72rem; font-weight: 560; color: var(--accent); }
|
||||||
|
h3 { font-size: 0.95rem; font-weight: 600; line-height: 1.35; }
|
||||||
|
p { font-size: 0.83rem; line-height: 1.55; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.more { align-self: flex-start; padding-left: 0; color: var(--accent); margin-top: 0.1rem; }
|
||||||
|
.more:hover { background: transparent; color: var(--text); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Play, X, Square, LoaderCircle, TriangleAlert, RotateCcw } from '@lucide/svelte';
|
||||||
|
import { app, cancel, kill, play } from '../lib/store.svelte';
|
||||||
|
import { bytes } from '../lib/format';
|
||||||
|
import type { Instance } from '../lib/types';
|
||||||
|
|
||||||
|
let { inst }: { inst: Instance } = $props();
|
||||||
|
|
||||||
|
const l = $derived(app.launch);
|
||||||
|
const mine = $derived(app.launchingId === inst.id);
|
||||||
|
const busy = $derived(mine && l.state === 'preparing');
|
||||||
|
const running = $derived(app.running === inst.id);
|
||||||
|
const otherRunning = $derived(!!app.running && app.running !== inst.id);
|
||||||
|
const pct = $derived(l.total > 0 ? Math.min(100, (l.done / l.total) * 100) : 0);
|
||||||
|
const eta = $derived(l.speed > 0 && l.total > l.done ? Math.ceil((l.total - l.done) / l.speed) : 0);
|
||||||
|
const stageNames: Record<string, string> = { preparing: 'Preparing', java: 'Java', game: 'Game files', loader: 'Mod loader', files: 'Instance files', launching: 'Launching' };
|
||||||
|
const noAccount = $derived(!app.active);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !busy && !running && !otherRunning && !noAccount && app.view === 'home') play(inst.id);
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
{#if busy}
|
||||||
|
<div class="progress glass">
|
||||||
|
<div class="top">
|
||||||
|
<span class="stage">
|
||||||
|
{#if l.stage === 'launching' || !l.total}<LoaderCircle class="spin" size={15} />{/if}
|
||||||
|
{l.label || 'Preparing…'}
|
||||||
|
</span>
|
||||||
|
<button class="ghost icon sm" onclick={cancel} aria-label="Cancel" title="Cancel"><X size={15} /></button>
|
||||||
|
</div>
|
||||||
|
<div class="bar" class:indeterminate={!l.total}><span style:width="{pct}%"></span></div>
|
||||||
|
<div class="meta tiny muted">
|
||||||
|
<span>{l.stage ? stageNames[l.stage] : ''}{l.filesTotal ? ` · ${l.filesDone.toLocaleString()} / ${l.filesTotal.toLocaleString()} files` : ''}</span>
|
||||||
|
<span>{#if l.total}{bytes(l.done)} / {bytes(l.total)}{#if l.speed > 0} · {bytes(l.speed)}/s{#if eta} · {eta < 60 ? `${eta}s` : `${Math.ceil(eta / 60)}m`} left{/if}{/if}{/if}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if running}
|
||||||
|
<button class="play running" onclick={kill} title="Force-close Minecraft">
|
||||||
|
<span class="pulse"></span>
|
||||||
|
<span class="lbl">Playing</span>
|
||||||
|
<span class="stop"><Square size={13} fill="currentColor" /> Stop</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="play" onclick={() => play(inst.id)} disabled={otherRunning || noAccount} title={noAccount ? 'Sign in first' : otherRunning ? 'Another instance is running' : 'Ctrl + Enter'}>
|
||||||
|
<Play size={18} fill="currentColor" />
|
||||||
|
<span class="lbl">Play</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if mine && l.state === 'error'}
|
||||||
|
<div class="error glass selectable">
|
||||||
|
<TriangleAlert size={17} />
|
||||||
|
<span>{l.message}</span>
|
||||||
|
<button class="sm" onclick={() => play(inst.id)}><RotateCcw size={13} /> Retry</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap { display: flex; flex-direction: column; align-items: flex-start; gap: 0.6rem; }
|
||||||
|
.play {
|
||||||
|
height: 3.3rem; min-width: 14rem; padding: 0 2rem; border: none; border-radius: var(--radius);
|
||||||
|
background: var(--accent); color: white; font-size: 1.05rem; font-weight: 600; letter-spacing: 0.02em; gap: 0.65rem;
|
||||||
|
}
|
||||||
|
.play:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 88%, white); }
|
||||||
|
.play:active:not(:disabled) { background: color-mix(in srgb, var(--accent) 85%, black); }
|
||||||
|
.play:disabled { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--muted); }
|
||||||
|
.lbl { position: relative; }
|
||||||
|
.running { background: color-mix(in srgb, var(--success) 16%, var(--surface)); color: var(--text); border: 1px solid color-mix(in srgb, var(--success) 35%, transparent); padding: 0 0.6rem 0 1.4rem; }
|
||||||
|
.running:hover:not(:disabled) { background: color-mix(in srgb, var(--success) 22%, var(--surface)); }
|
||||||
|
.pulse { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--success); }
|
||||||
|
.stop { font-size: 0.8rem; font-weight: 520; letter-spacing: 0; display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.4rem 0.7rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--text) 8%, transparent); color: color-mix(in srgb, var(--text) 80%, transparent); margin-left: 0.6rem; }
|
||||||
|
.progress { width: 26rem; max-width: 100%; padding: 0.85rem 1rem 0.8rem; display: flex; flex-direction: column; gap: 0.55rem; background: var(--surface); }
|
||||||
|
.top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
|
||||||
|
.stage { display: flex; align-items: center; gap: 0.5rem; font-weight: 560; font-size: 0.9rem; }
|
||||||
|
.bar { height: 0.3rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 9%, transparent); overflow: hidden; position: relative; }
|
||||||
|
.bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width 0.2s linear; }
|
||||||
|
.bar.indeterminate span { width: 30% !important; animation: slide 1.3s ease-in-out infinite; }
|
||||||
|
@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(340%); } }
|
||||||
|
.meta { display: flex; justify-content: space-between; gap: 1rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.error { display: flex; align-items: center; gap: 0.7rem; padding: 0.65rem 0.8rem; max-width: 34rem; font-size: 0.85rem; background: color-mix(in srgb, var(--danger) 10%, var(--surface)); border-color: color-mix(in srgb, var(--danger) 30%, transparent); line-height: 1.4; }
|
||||||
|
.error :global(svg) { color: var(--danger); flex-shrink: 0; }
|
||||||
|
.error span { flex: 1; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Settings, Plus, Check, LogOut, Home } from '@lucide/svelte';
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
import { abs, activeAccount, app, instances, removeAccount, selectInstance, switchAccount } from '../lib/store.svelte';
|
||||||
|
|
||||||
|
let menu = $state(false);
|
||||||
|
const kindLabel = { panel: 'Account', microsoft: 'Microsoft', offline: 'Offline' };
|
||||||
|
const initials = (name: string) => name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onclick={(e) => menu && !(e.target as HTMLElement).closest('.account') && (menu = false)} />
|
||||||
|
|
||||||
|
<nav class="rail glass">
|
||||||
|
<div class="list">
|
||||||
|
{#each instances() as inst (inst.id)}
|
||||||
|
<button
|
||||||
|
class="inst"
|
||||||
|
class:active={app.view === 'home' && app.selected === inst.id}
|
||||||
|
class:running={app.running === inst.id}
|
||||||
|
onclick={() => { selectInstance(inst.id); app.view = 'home'; }}
|
||||||
|
aria-label={inst.name}
|
||||||
|
>
|
||||||
|
{#if inst.icon_url}<img src={abs(inst.icon_url)} alt="" />{:else}<span>{initials(inst.name)}</span>{/if}
|
||||||
|
<em class="tip">{inst.name}</em>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom">
|
||||||
|
{#if app.view === 'settings'}
|
||||||
|
<button class="ghost icon big" onclick={() => (app.view = 'home')} aria-label="Home"><Home size={20} /></button>
|
||||||
|
{/if}
|
||||||
|
<button class="ghost icon big" class:on={app.view === 'settings'} onclick={() => (app.view = 'settings')} aria-label="Settings">
|
||||||
|
<Settings size={20} />
|
||||||
|
{#if app.update}<span class="dot"></span>{/if}
|
||||||
|
</button>
|
||||||
|
<div class="account">
|
||||||
|
<button class="me" onclick={() => (menu = !menu)} aria-label="Accounts">
|
||||||
|
<Avatar account={activeAccount()} size={2.3} />
|
||||||
|
</button>
|
||||||
|
{#if menu}
|
||||||
|
<div class="menu glass">
|
||||||
|
<span class="tiny muted head">Accounts</span>
|
||||||
|
{#each app.accounts as a (a.id)}
|
||||||
|
<div class="acc" class:current={a.id === app.active}>
|
||||||
|
<button class="pick" onclick={() => { switchAccount(a.id); menu = false; }}>
|
||||||
|
<Avatar account={a} size={1.9} />
|
||||||
|
<span class="who"><strong>{a.username}</strong><span class="tiny muted">{kindLabel[a.kind]}</span></span>
|
||||||
|
{#if a.id === app.active}<Check size={16} />{/if}
|
||||||
|
</button>
|
||||||
|
<button class="ghost icon" title="Sign out" aria-label="Sign out {a.username}" onclick={() => removeAccount(a.id)}><LogOut size={14} /></button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button class="add" onclick={() => { app.addAccount = true; menu = false; }}><Plus size={16} /> Add account</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rail { width: 4.4rem; display: flex; flex-direction: column; align-items: center; padding: 0.6rem 0 0.8rem; gap: 0.5rem; border: none; border-right: 1px solid var(--line); border-radius: 0; background: color-mix(in srgb, var(--surface) 70%, transparent); position: relative; z-index: 20; flex-shrink: 0; }
|
||||||
|
.list { display: flex; flex-direction: column; gap: 0.6rem; align-items: center; overflow-y: auto; overflow-x: visible; flex: 1; width: 100%; padding: 0.2rem 0; scrollbar-width: none; }
|
||||||
|
.inst {
|
||||||
|
width: 2.9rem; height: 2.9rem; padding: 0; border-radius: var(--radius); position: relative; flex-shrink: 0; overflow: visible;
|
||||||
|
background: color-mix(in srgb, var(--text) 6%, transparent); border-color: transparent; font-weight: 600; font-size: 0.85rem;
|
||||||
|
color: color-mix(in srgb, var(--text) 75%, transparent);
|
||||||
|
}
|
||||||
|
.inst img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||||
|
.inst:hover { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); }
|
||||||
|
.inst.active { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent); }
|
||||||
|
.inst.active::before { content: ''; position: absolute; left: -0.75rem; top: 0.7rem; bottom: 0.7rem; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); }
|
||||||
|
.inst.running::after { content: ''; position: absolute; right: -0.2rem; bottom: -0.2rem; width: 0.8rem; height: 0.8rem; border-radius: 50%; background: var(--success); border: 2px solid var(--bg); }
|
||||||
|
.tip { position: fixed; left: 5rem; transform: translateY(0); font-style: normal; font-size: 0.82rem; font-weight: 600; white-space: nowrap; padding: 0.35rem 0.7rem; border-radius: 0.5rem; background: var(--surface); border: 1px solid var(--line-strong); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 50; color: var(--text); }
|
||||||
|
.inst:hover .tip { opacity: 1; }
|
||||||
|
.bottom { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
|
||||||
|
.big { width: 2.6rem; height: 2.6rem; position: relative; }
|
||||||
|
.big.on { color: var(--text); background: color-mix(in srgb, var(--text) 9%, transparent); }
|
||||||
|
.dot { position: absolute; top: 0.55rem; right: 0.55rem; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--accent); }
|
||||||
|
.account { position: relative; margin-top: 0.3rem; }
|
||||||
|
.me { padding: 0.15rem; border-radius: 30%; background: transparent; border-color: transparent; }
|
||||||
|
.me:hover { border-color: var(--line-strong); background: transparent; }
|
||||||
|
.menu { position: absolute; left: calc(100% + 0.9rem); bottom: 0; width: 17rem; padding: 0.6rem; display: flex; flex-direction: column; gap: 0.25rem; background: var(--surface); backdrop-filter: none; border: 1px solid var(--line-strong); box-shadow: 0 0.8rem 2rem -1rem rgba(0, 0, 0, 0.6); animation: fade 0.12s ease; z-index: 60; }
|
||||||
|
.head { padding: 0.2rem 0.5rem 0.4rem; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; }
|
||||||
|
.acc { display: flex; align-items: center; gap: 0.2rem; border-radius: var(--radius-sm); }
|
||||||
|
.acc.current { background: color-mix(in srgb, var(--text) 6%, transparent); }
|
||||||
|
.pick { flex: 1; justify-content: flex-start; background: transparent; border-color: transparent; padding: 0.45rem 0.5rem; gap: 0.65rem; min-width: 0; }
|
||||||
|
.pick :global(svg) { color: var(--accent); }
|
||||||
|
.who { display: flex; flex-direction: column; align-items: flex-start; flex: 1; min-width: 0; }
|
||||||
|
.who strong { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||||
|
.add { margin-top: 0.3rem; border-style: dashed; background: transparent; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Users, Signal, Copy, Check } from '@lucide/svelte';
|
||||||
|
import { parseMotd } from '../lib/motd';
|
||||||
|
import { invoke } from '../lib/tauri';
|
||||||
|
import type { ServerEntry, ServerStatus } from '../lib/types';
|
||||||
|
|
||||||
|
let { server }: { server: ServerEntry } = $props();
|
||||||
|
let status = $state<ServerStatus | null>(null);
|
||||||
|
let failed = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { address, port } = server;
|
||||||
|
status = null;
|
||||||
|
failed = false;
|
||||||
|
let alive = true;
|
||||||
|
const load = () =>
|
||||||
|
invoke<ServerStatus>('ping_server', { host: address, port })
|
||||||
|
.then((s) => alive && ((status = s), (failed = false)))
|
||||||
|
.catch(() => alive && (failed = true));
|
||||||
|
load();
|
||||||
|
const t = setInterval(() => !document.hidden && load(), 30_000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const addr = $derived(server.port === 25565 ? server.address : `${server.address}:${server.port}`);
|
||||||
|
const bars = $derived(!status ? 0 : status.latency_ms < 60 ? 4 : status.latency_ms < 120 ? 3 : status.latency_ms < 250 ? 2 : 1);
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
navigator.clipboard.writeText(addr);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 1500);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="server glass">
|
||||||
|
{#if status?.favicon}<img src={status.favicon} alt="" class="fav" />{:else}<span class="fav ph" class:on={status}></span>{/if}
|
||||||
|
<div class="info">
|
||||||
|
<div class="row name">
|
||||||
|
<strong>{server.name || server.address}</strong>
|
||||||
|
<span class="state" class:online={status} class:off={failed}>{status ? 'Online' : failed ? 'Offline' : 'Checking…'}</span>
|
||||||
|
</div>
|
||||||
|
{#if status}
|
||||||
|
<div class="motd">{#each parseMotd(status.motd.split('\n')[0]) as s}<span style:color={s.color} class:b={s.bold} class:i={s.italic}>{s.text}</span>{/each}</div>
|
||||||
|
{:else}
|
||||||
|
<button class="addr" onclick={copy}>{addr} {#if copied}<Check size={12} />{:else}<Copy size={12} />{/if}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if status}
|
||||||
|
<div class="stats">
|
||||||
|
<span class="players" title={status.sample.join(', ')}><Users size={14} /> <b>{status.players_online}</b><span class="muted">/{status.players_max}</span></span>
|
||||||
|
<span class="ping" title="{status.latency_ms} ms">
|
||||||
|
<span class="sig">{#each [1, 2, 3, 4] as i}<i class:lit={i <= bars} style:height="{i * 25}%"></i>{/each}</span>
|
||||||
|
{status.latency_ms}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.server { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0.9rem 0.5rem 0.5rem; min-width: 18rem; max-width: 28rem; height: 2.75rem; background: var(--surface); }
|
||||||
|
.fav { width: 1.8rem; height: 1.8rem; border-radius: calc(var(--radius-sm) - 2px); image-rendering: pixelated; flex-shrink: 0; }
|
||||||
|
.ph { background: color-mix(in srgb, var(--text) 8%, transparent); }
|
||||||
|
.ph.on { background: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||||
|
.info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.05rem; }
|
||||||
|
.name { gap: 0.5rem; }
|
||||||
|
.name strong { font-size: 0.85rem; font-weight: 560; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.state { font-size: 0.72rem; font-weight: 520; color: var(--muted); display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.state::before { content: ''; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; }
|
||||||
|
.state.online { color: var(--success); }
|
||||||
|
.state.off { color: var(--danger); }
|
||||||
|
.motd { font-size: 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: color-mix(in srgb, var(--text) 75%, transparent); }
|
||||||
|
.motd .b { font-weight: 700; }
|
||||||
|
.motd .i { font-style: italic; }
|
||||||
|
.addr { all: unset; cursor: pointer; font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 0.35rem; font-family: var(--mono); }
|
||||||
|
.addr:hover { color: var(--text); }
|
||||||
|
.stats { display: flex; flex-direction: column; align-items: flex-end; gap: 0.05rem; font-size: 0.78rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.players { display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.players :global(svg) { color: var(--muted); }
|
||||||
|
.ping { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.72rem; }
|
||||||
|
.sig { display: inline-flex; align-items: flex-end; gap: 0.1rem; height: 0.7rem; }
|
||||||
|
.sig i { width: 0.18rem; background: color-mix(in srgb, var(--text) 20%, transparent); border-radius: 1px; }
|
||||||
|
.sig i.lit { background: var(--success); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { UserRound, WifiOff, LoaderCircle, Copy, ExternalLink, Check, ArrowRight, UserPlus } from '@lucide/svelte';
|
||||||
|
import { accountAdded, app, toast } from '../lib/store.svelte';
|
||||||
|
import { errorText, invoke, openUrl } from '../lib/tauri';
|
||||||
|
import type { Account, DeviceCode } from '../lib/types';
|
||||||
|
|
||||||
|
const auth = $derived(app.manifest?.auth);
|
||||||
|
const methods = $derived(
|
||||||
|
[
|
||||||
|
auth?.panel_accounts !== false && { id: 'panel', label: 'Account' },
|
||||||
|
auth?.microsoft && { id: 'microsoft', label: 'Microsoft' },
|
||||||
|
auth?.offline_local !== false && { id: 'offline', label: 'Offline' },
|
||||||
|
].filter(Boolean) as { id: string; label: string }[],
|
||||||
|
);
|
||||||
|
let method = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
if (!methods.some((m) => m.id === method)) method = methods[0]?.id ?? 'offline';
|
||||||
|
});
|
||||||
|
|
||||||
|
let username = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
let email = $state('');
|
||||||
|
let registering = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let pendingNotice = $state(false);
|
||||||
|
let code = $state<DeviceCode | null>(null);
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
const canRegister = $derived(auth?.registration && auth.registration !== 'closed');
|
||||||
|
|
||||||
|
async function submit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
error = '';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
if (method === 'offline') {
|
||||||
|
await accountAdded(await invoke<Account>('add_offline', { username }));
|
||||||
|
} else if (registering) {
|
||||||
|
const r = await invoke<{ account: Account | null; pending: boolean }>('register_panel', { username, password, email: email || null });
|
||||||
|
if (r.pending) pendingNotice = true;
|
||||||
|
else if (r.account) await accountAdded(r.account);
|
||||||
|
} else {
|
||||||
|
await accountAdded(await invoke<Account>('login_panel', { username, password }));
|
||||||
|
}
|
||||||
|
if (!pendingNotice) toast(`Signed in as ${username}`);
|
||||||
|
} catch (err) {
|
||||||
|
error = errorText(err);
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function microsoft() {
|
||||||
|
error = '';
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
code = await invoke<DeviceCode>('ms_start');
|
||||||
|
openUrl(code.verification_uri);
|
||||||
|
const account = await invoke<Account>('ms_finish');
|
||||||
|
code = null;
|
||||||
|
await accountAdded(account);
|
||||||
|
toast(`Signed in as ${account.username}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (errorText(err) !== 'cancelled') error = errorText(err);
|
||||||
|
code = null;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
if (!code) return;
|
||||||
|
navigator.clipboard.writeText(code.user_code);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 1500);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="signin">
|
||||||
|
{#if methods.length > 1}
|
||||||
|
<div class="segmented tabs">
|
||||||
|
{#each methods as m}
|
||||||
|
<button class:active={method === m.id} onclick={() => { method = m.id; error = ''; registering = false; }}>{m.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingNotice}
|
||||||
|
<div class="notice">
|
||||||
|
<Check size={22} />
|
||||||
|
<div><strong>Account created!</strong><p class="small muted">An admin needs to approve it before you can sign in. Check back soon.</p></div>
|
||||||
|
</div>
|
||||||
|
<button onclick={() => { pendingNotice = false; registering = false; }}>Back to sign in</button>
|
||||||
|
{:else if method === 'microsoft'}
|
||||||
|
{#if code}
|
||||||
|
<div class="code">
|
||||||
|
<p class="small muted">Go to <strong>{code.verification_uri.replace('https://', '')}</strong> and enter:</p>
|
||||||
|
<button class="usercode" onclick={copy} title="Copy">{code.user_code} {#if copied}<Check size={18} />{:else}<Copy size={16} />{/if}</button>
|
||||||
|
<div class="row">
|
||||||
|
<button class="sm" onclick={() => code && openUrl(code.verification_uri)}><ExternalLink size={14} /> Open page</button>
|
||||||
|
<button class="sm ghost" onclick={() => invoke('ms_cancel')}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
<p class="tiny muted row wait"><LoaderCircle class="spin" size={14} /> Waiting for you to finish in the browser…</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="small muted">Own Minecraft? Sign in with Microsoft for your real skin and access to online-mode servers.</p>
|
||||||
|
<button class="primary big ms" onclick={microsoft} disabled={busy}>
|
||||||
|
<svg viewBox="0 0 21 21" width="18" height="18" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>
|
||||||
|
Sign in with Microsoft
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<form class="col" onsubmit={submit}>
|
||||||
|
<label class="field">
|
||||||
|
{method === 'offline' ? 'Username' : 'Username'}
|
||||||
|
<div class="inp"><span class="ic">{#if method === 'offline'}<WifiOff size={16} />{:else}<UserRound size={16} />{/if}</span>
|
||||||
|
<input bind:value={username} autocomplete="username" placeholder={method === 'offline' ? 'Steve' : ''} maxlength="16" required />
|
||||||
|
</div>
|
||||||
|
{#if method === 'offline'}<span class="help">Play without an account. You'll only see public instances.</span>{/if}
|
||||||
|
</label>
|
||||||
|
{#if method === 'panel'}
|
||||||
|
<label class="field">Password<input type="password" bind:value={password} autocomplete={registering ? 'new-password' : 'current-password'} required minlength={registering ? 8 : undefined} /></label>
|
||||||
|
{#if registering}
|
||||||
|
<label class="field">Email <span class="help">Optional</span><input type="email" bind:value={email} /></label>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<button class="primary big" disabled={busy || !username}>
|
||||||
|
{#if busy}<LoaderCircle class="spin" size={18} />{:else if registering}<UserPlus size={18} />{:else}<ArrowRight size={18} />{/if}
|
||||||
|
{method === 'offline' ? 'Play offline' : registering ? 'Create account' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
{#if method === 'panel' && canRegister}
|
||||||
|
<button type="button" class="ghost sm switch" onclick={() => { registering = !registering; error = ''; }}>
|
||||||
|
{registering ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}<div class="error selectable">{error}</div>{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.signin { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.tabs { width: 100%; }
|
||||||
|
.tabs button { flex: 1; }
|
||||||
|
.inp { position: relative; }
|
||||||
|
.inp .ic { position: absolute; left: 0.8rem; top: 50%; transform: translateY(-50%); color: var(--muted); display: flex; }
|
||||||
|
.inp input { padding-left: 2.4rem; }
|
||||||
|
.big { padding: 0.75rem; font-size: 0.95rem; }
|
||||||
|
.ms { background: color-mix(in srgb, var(--text) 8%, transparent); border: 1px solid var(--line-strong); color: var(--text); }
|
||||||
|
.ms:hover { background: color-mix(in srgb, var(--text) 12%, transparent); }
|
||||||
|
.switch { align-self: center; color: var(--muted); }
|
||||||
|
.code { display: flex; flex-direction: column; align-items: center; gap: 0.8rem; text-align: center; padding: 0.5rem 0; }
|
||||||
|
.usercode { font-family: var(--mono); font-size: 1.5rem; letter-spacing: 0.12em; padding: 0.6rem 1.2rem; font-weight: 600; }
|
||||||
|
.wait { gap: 0.4rem; }
|
||||||
|
.error { font-size: 0.87rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); color: color-mix(in srgb, var(--danger) 60%, white); background: color-mix(in srgb, var(--danger) 12%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); }
|
||||||
|
.notice { display: flex; gap: 0.8rem; align-items: flex-start; padding: 1rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--success) 12%, transparent); }
|
||||||
|
.notice :global(svg) { color: var(--success); flex-shrink: 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Minus, Square, X, WifiOff } from '@lucide/svelte';
|
||||||
|
import { abs, app } from '../lib/store.svelte';
|
||||||
|
import { windowAction } from '../lib/tauri';
|
||||||
|
|
||||||
|
const b = $derived(app.manifest?.branding);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header data-tauri-drag-region>
|
||||||
|
<div class="brand" data-tauri-drag-region>
|
||||||
|
{#if b?.logo_url}
|
||||||
|
<img src={abs(b.logo_url)} alt="" />
|
||||||
|
{:else}
|
||||||
|
<span class="mark"></span>
|
||||||
|
{/if}
|
||||||
|
<span data-tauri-drag-region>{b?.name ?? 'Launcher'}</span>
|
||||||
|
{#if app.offline}<span class="offline" title="Can't reach the server — using saved data"><WifiOff size={12} /> Offline</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button class="ctl" onclick={() => windowAction('minimize')} aria-label="Minimise"><Minus size={15} /></button>
|
||||||
|
<button class="ctl" onclick={() => windowAction('maximize')} aria-label="Maximise"><Square size={12} /></button>
|
||||||
|
<button class="ctl close" onclick={() => windowAction('close')} aria-label="Close"><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
header { height: 2.6rem; display: flex; align-items: center; justify-content: space-between; padding-left: 1rem; position: relative; z-index: 30; flex-shrink: 0; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 0.6rem; font-weight: 650; font-size: 0.82rem; letter-spacing: 0.08em; pointer-events: none; }
|
||||||
|
.brand img { height: 1.3rem; border-radius: 0.3rem; }
|
||||||
|
.mark { width: 0.85rem; height: 0.85rem; border-radius: 50%; border: 2px solid var(--accent); position: relative; }
|
||||||
|
.mark::after { content: ''; position: absolute; inset: 0.17rem; border-radius: 50%; background: var(--accent); }
|
||||||
|
.offline { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.72rem; color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); padding: 0.15rem 0.5rem; border-radius: 99rem; pointer-events: auto; }
|
||||||
|
.controls { display: flex; height: 100%; }
|
||||||
|
.ctl { height: 100%; width: 3rem; border: none; border-radius: 0; background: transparent; color: color-mix(in srgb, var(--text) 70%, transparent); padding: 0; }
|
||||||
|
.ctl:hover { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); }
|
||||||
|
.ctl:active { transform: none; }
|
||||||
|
.close:hover { background: #e81123; color: white; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { CircleCheck, CircleAlert, Info } from '@lucide/svelte';
|
||||||
|
import { app } from '../lib/store.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toasts" aria-live="polite">
|
||||||
|
{#each app.toasts as t (t.id)}
|
||||||
|
<div class="toast glass {t.kind}">
|
||||||
|
{#if t.kind === 'ok'}<CircleCheck size={17} />{:else if t.kind === 'error'}<CircleAlert size={17} />{:else}<Info size={17} />{/if}
|
||||||
|
<span class="selectable">{t.text}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toasts { position: fixed; bottom: 1.25rem; right: 1.25rem; z-index: 100; display: flex; flex-direction: column; gap: 0.6rem; max-width: 26rem; }
|
||||||
|
.toast { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.75rem 1rem; font-size: 0.88rem; line-height: 1.4; background: var(--surface); border-color: var(--line-strong); box-shadow: 0 0.6rem 1.5rem -0.8rem rgba(0, 0, 0, 0.5); animation: fade 0.15s ease; }
|
||||||
|
.toast :global(svg) { flex-shrink: 0; margin-top: 0.1rem; }
|
||||||
|
.ok :global(svg) { color: var(--success); }
|
||||||
|
.error :global(svg) { color: var(--danger); }
|
||||||
|
.info :global(svg) { color: var(--accent-2); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { checked = $bindable(false), label, help, disabled = false, onchange }: { checked: boolean; label: string; help?: string; disabled?: boolean; onchange?: () => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="toggle" class:disabled>
|
||||||
|
<span class="text"><span class="label">{label}</span>{#if help}<span class="help">{help}</span>{/if}</span>
|
||||||
|
<input type="checkbox" role="switch" bind:checked {disabled} onchange={() => onchange?.()} />
|
||||||
|
<span class="track" aria-hidden="true"><span class="thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toggle { display: flex; align-items: center; gap: 1rem; cursor: pointer; padding: 0.35rem 0; }
|
||||||
|
.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.text { flex: 1; display: flex; flex-direction: column; gap: 0.15rem; }
|
||||||
|
.label { font-weight: 560; font-size: 0.93rem; }
|
||||||
|
input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.track { width: 2.75rem; height: 1.55rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 14%, transparent); position: relative; transition: background 0.2s; flex-shrink: 0; }
|
||||||
|
.thumb { position: absolute; top: 0.18rem; left: 0.18rem; width: 1.19rem; height: 1.19rem; border-radius: 50%; background: color-mix(in srgb, var(--text) 80%, transparent); transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.6, 1), background 0.2s; }
|
||||||
|
input:checked + .track { background: var(--accent); }
|
||||||
|
input:checked + .track .thumb { transform: translateX(1.2rem); background: white; }
|
||||||
|
input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export function bytes(n: number): string {
|
||||||
|
if (!n) return '0 B';
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||||
|
return `${(n / 1024 ** i).toFixed(i > 1 ? 1 : 0)} ${u[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gb(mb: number): string {
|
||||||
|
return `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loaderName: Record<string, string> = {
|
||||||
|
vanilla: 'Vanilla',
|
||||||
|
fabric: 'Fabric',
|
||||||
|
quilt: 'Quilt',
|
||||||
|
forge: 'Forge',
|
||||||
|
neoforge: 'NeoForge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shortLoaderVersion(mc: string, v: string | null): string {
|
||||||
|
return v ? v.replace(`${mc}-`, '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function niceDate(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso + (iso.length === 10 ? 'T00:00:00' : ''));
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Minecraft (1.13+) key names used in options.txt, and friendly labels.
|
||||||
|
|
||||||
|
export interface Binding { id: string; label: string; default: string }
|
||||||
|
|
||||||
|
export const BIND_GROUPS: { title: string; binds: Binding[] }[] = [
|
||||||
|
{
|
||||||
|
title: 'Movement',
|
||||||
|
binds: [
|
||||||
|
{ id: 'key.forward', label: 'Walk forwards', default: 'key.keyboard.w' },
|
||||||
|
{ id: 'key.left', label: 'Strafe left', default: 'key.keyboard.a' },
|
||||||
|
{ id: 'key.back', label: 'Walk backwards', default: 'key.keyboard.s' },
|
||||||
|
{ id: 'key.right', label: 'Strafe right', default: 'key.keyboard.d' },
|
||||||
|
{ id: 'key.jump', label: 'Jump', default: 'key.keyboard.space' },
|
||||||
|
{ id: 'key.sneak', label: 'Sneak', default: 'key.keyboard.left.shift' },
|
||||||
|
{ id: 'key.sprint', label: 'Sprint', default: 'key.keyboard.left.control' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gameplay',
|
||||||
|
binds: [
|
||||||
|
{ id: 'key.attack', label: 'Attack / destroy', default: 'key.mouse.left' },
|
||||||
|
{ id: 'key.use', label: 'Use item / place block', default: 'key.mouse.right' },
|
||||||
|
{ id: 'key.pickItem', label: 'Pick block', default: 'key.mouse.middle' },
|
||||||
|
{ id: 'key.drop', label: 'Drop item', default: 'key.keyboard.q' },
|
||||||
|
{ id: 'key.swapOffhand', label: 'Swap to offhand', default: 'key.keyboard.f' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Interface',
|
||||||
|
binds: [
|
||||||
|
{ id: 'key.inventory', label: 'Inventory', default: 'key.keyboard.e' },
|
||||||
|
{ id: 'key.chat', label: 'Open chat', default: 'key.keyboard.t' },
|
||||||
|
{ id: 'key.command', label: 'Open command', default: 'key.keyboard.slash' },
|
||||||
|
{ id: 'key.playerlist', label: 'Player list', default: 'key.keyboard.tab' },
|
||||||
|
{ id: 'key.advancements', label: 'Advancements', default: 'key.keyboard.l' },
|
||||||
|
{ id: 'key.socialInteractions', label: 'Social interactions', default: 'key.keyboard.p' },
|
||||||
|
{ id: 'key.screenshot', label: 'Screenshot', default: 'key.keyboard.f2' },
|
||||||
|
{ id: 'key.togglePerspective', label: 'Toggle perspective', default: 'key.keyboard.f5' },
|
||||||
|
{ id: 'key.fullscreen', label: 'Toggle fullscreen', default: 'key.keyboard.f11' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Hotbar',
|
||||||
|
binds: Array.from({ length: 9 }, (_, i) => ({ id: `key.hotbar.${i + 1}`, label: `Hotbar slot ${i + 1}`, default: `key.keyboard.${i + 1}` })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPECIAL: Record<string, string> = {
|
||||||
|
Space: 'space', ShiftLeft: 'left.shift', ShiftRight: 'right.shift', ControlLeft: 'left.control', ControlRight: 'right.control',
|
||||||
|
AltLeft: 'left.alt', AltRight: 'right.alt', Tab: 'tab', Enter: 'enter', Backspace: 'backspace', CapsLock: 'caps.lock',
|
||||||
|
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', Backquote: 'grave.accent', Minus: 'minus',
|
||||||
|
Equal: 'equal', BracketLeft: 'left.bracket', BracketRight: 'right.bracket', Backslash: 'backslash', Semicolon: 'semicolon',
|
||||||
|
Quote: 'apostrophe', Comma: 'comma', Period: 'period', Slash: 'slash', Insert: 'insert', Delete: 'delete', Home: 'home',
|
||||||
|
End: 'end', PageUp: 'page.up', PageDown: 'page.down', NumpadAdd: 'keypad.add', NumpadSubtract: 'keypad.subtract',
|
||||||
|
NumpadMultiply: 'keypad.multiply', NumpadDivide: 'keypad.divide', NumpadEnter: 'keypad.enter', NumpadDecimal: 'keypad.decimal',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** KeyboardEvent.code → Minecraft key name (null for unsupported keys). */
|
||||||
|
export function fromKeyboard(code: string): string | null {
|
||||||
|
if (/^Key[A-Z]$/.test(code)) return `key.keyboard.${code.slice(3).toLowerCase()}`;
|
||||||
|
if (/^Digit\d$/.test(code)) return `key.keyboard.${code.slice(5)}`;
|
||||||
|
if (/^F\d{1,2}$/.test(code)) return `key.keyboard.${code.toLowerCase()}`;
|
||||||
|
if (/^Numpad\d$/.test(code)) return `key.keyboard.keypad.${code.slice(6)}`;
|
||||||
|
return SPECIAL[code] ? `key.keyboard.${SPECIAL[code]}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromMouse(button: number): string {
|
||||||
|
return ['key.mouse.left', 'key.mouse.middle', 'key.mouse.right', 'key.mouse.4', 'key.mouse.5'][button] ?? `key.mouse.${button + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a Minecraft key name. */
|
||||||
|
export function keyLabel(key: string): string {
|
||||||
|
if (!key || key === 'key.keyboard.unknown') return 'Not bound';
|
||||||
|
const mouse = key.match(/^key\.mouse\.(.+)$/);
|
||||||
|
if (mouse) return { left: 'Left click', right: 'Right click', middle: 'Middle click' }[mouse[1]] ?? `Mouse ${mouse[1]}`;
|
||||||
|
const k = key.replace('key.keyboard.', '');
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
'left.shift': 'Left Shift', 'right.shift': 'Right Shift', 'left.control': 'Left Ctrl', 'right.control': 'Right Ctrl',
|
||||||
|
'left.alt': 'Left Alt', 'right.alt': 'Right Alt', 'caps.lock': 'Caps Lock', 'page.up': 'Page Up', 'page.down': 'Page Down',
|
||||||
|
'grave.accent': '`', 'left.bracket': '[', 'right.bracket': ']', slash: '/', backslash: '\\', semicolon: ';', apostrophe: "'",
|
||||||
|
comma: ',', period: '.', minus: '-', equal: '=', up: '↑', down: '↓', left: '←', right: '→',
|
||||||
|
};
|
||||||
|
if (names[k]) return names[k];
|
||||||
|
if (k.startsWith('keypad.')) return `Num ${k.slice(7)}`;
|
||||||
|
return k.length === 1 ? k.toUpperCase() : k.charAt(0).toUpperCase() + k.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaults(): Record<string, string> {
|
||||||
|
return Object.fromEntries(BIND_GROUPS.flatMap((g) => g.binds.map((b) => [b.id, b.default])));
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// In-browser stand-in for the Rust backend, used by `npm run dev` outside
|
||||||
|
// Tauri and for UI screenshots. Scenario via ?mock=setup|login|main.
|
||||||
|
import { mockEmit } from './tauri';
|
||||||
|
import type { Account, Bootstrap, Manifest, Settings } from './types';
|
||||||
|
|
||||||
|
const scenario = new URLSearchParams(location.search).get('mock') ?? 'main';
|
||||||
|
|
||||||
|
const settings: Settings = {
|
||||||
|
panel_url: scenario === 'setup' ? null : 'https://panel.scopenet.gg',
|
||||||
|
selected_instance: null,
|
||||||
|
memory_max_mb: 0,
|
||||||
|
memory_min_mb: 0,
|
||||||
|
custom_resolution: false,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
fullscreen: false,
|
||||||
|
after_launch: 'minimize',
|
||||||
|
show_console: false,
|
||||||
|
java_path: null,
|
||||||
|
gc: 'g1',
|
||||||
|
jvm_args: '',
|
||||||
|
theme_mode: 'server',
|
||||||
|
accent: null,
|
||||||
|
background_video: true,
|
||||||
|
reduce_motion: false,
|
||||||
|
glass: null,
|
||||||
|
ui_scale: 100,
|
||||||
|
concurrent_downloads: 12,
|
||||||
|
check_updates: true,
|
||||||
|
send_stats: true,
|
||||||
|
keybinds_enabled: false,
|
||||||
|
keybinds: {},
|
||||||
|
instance_overrides: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manifest: Manifest = {
|
||||||
|
api_version: 1,
|
||||||
|
panel_version: '0.1.0',
|
||||||
|
user: null,
|
||||||
|
auth: { panel_accounts: true, registration: 'approval', microsoft: true, microsoft_client_id: 'demo', offline_local: true },
|
||||||
|
branding: {
|
||||||
|
name: 'SCOPENET',
|
||||||
|
tagline: 'Your worlds, one click away.',
|
||||||
|
logo_url: null,
|
||||||
|
icon_url: null,
|
||||||
|
background: { kind: 'gradient', url: null, blur: 0, dim: 55 },
|
||||||
|
colors: { accent: '#6d6af5', accent_2: '#8b88f8', background: '#0d0e12', surface: '#16181e', text: '#ecedf1', muted: '#8b8f9a', success: '#3fb97c', danger: '#e5484d' },
|
||||||
|
font: 'Inter',
|
||||||
|
radius: 10,
|
||||||
|
glass: false,
|
||||||
|
version_label: 'SCOPENET',
|
||||||
|
news: [
|
||||||
|
{ id: '1', title: 'Season 4 is live!', body: 'A brand new world with custom terrain, player shops and weekly events. Join now and claim your spot before the land rush.', image_url: null, link: 'https://example.com', date: '2026-09-26', pinned: true, tag: 'Update' },
|
||||||
|
{ id: '2', title: 'Build contest: Autumn Castles', body: 'Submit your best castle build on the Creative server by October 10th. Top three win VIP for a month.', image_url: null, link: null, date: '2026-09-21', pinned: false, tag: 'Event' },
|
||||||
|
{ id: '3', title: 'Performance boost', body: 'We switched the modpack to Sodium + Lithium. Expect way higher FPS on older machines.', image_url: null, link: null, date: '2026-09-14', pinned: false, tag: 'Changelog' },
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ label: 'Discord', url: 'https://discord.gg/example', icon: 'discord' },
|
||||||
|
{ label: 'Website', url: 'https://example.com', icon: 'website' },
|
||||||
|
{ label: 'Store', url: 'https://example.com/store', icon: 'store' },
|
||||||
|
],
|
||||||
|
features: { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true },
|
||||||
|
custom_css: '',
|
||||||
|
},
|
||||||
|
instances: [
|
||||||
|
{ id: 'survival-smp', name: 'Survival SMP', description: 'Our main survival world — season 4. Claims, shops & weekend events.', icon_url: null, banner_url: null, mc_version: '1.21.1', loader: 'fabric', loader_version: '0.16.9', revision: 3, server: { name: 'SCOPENET SMP', address: 'play.scopenet.gg', port: 25565, auto_join: true, inject: true }, memory: { min_mb: 2048, max_mb: 6144 }, jvm_args: '', featured: true, source_label: 'Modrinth · Fabulously Optimized 6.2', file_count: 48, total_size: 184_000_000 },
|
||||||
|
{ id: 'creative', name: 'Creative Build', description: 'Plots, WorldEdit and weekly build contests.', icon_url: null, banner_url: null, mc_version: '1.20.1', loader: 'vanilla', loader_version: null, revision: 1, server: { name: 'Creative', address: 'creative.scopenet.gg', port: 25565, auto_join: false, inject: true }, memory: { min_mb: 1024, max_mb: 4096 }, jvm_args: '', featured: false, source_label: 'Minecraft 1.20.1', file_count: 0, total_size: 0 },
|
||||||
|
{ id: 'modded', name: 'Create: Above & Beyond', description: 'Factory building with Create and friends.', icon_url: null, banner_url: null, mc_version: '1.20.1', loader: 'forge', loader_version: '1.20.1-47.3.0', revision: 2, server: null, memory: { min_mb: 4096, max_mb: 8192 }, jvm_args: '', featured: false, source_label: 'CurseForge · Create: Above & Beyond 1.4', file_count: 212, total_size: 612_000_000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const accounts: Account[] = scenario === 'main' || scenario === 'progress' ? [
|
||||||
|
{ id: 'a1', kind: 'panel', username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', panel_url: 'https://panel.scopenet.gg', role: 'admin' },
|
||||||
|
{ id: 'a2', kind: 'offline', username: 'Steve', uuid: '5627dd98-e6be-3c21-b8a8-e92344183641', panel_url: null, role: null },
|
||||||
|
] : [];
|
||||||
|
let active: string | null = accounts[0]?.id ?? null;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
async function fakeLaunch(id: string) {
|
||||||
|
const stages: [string, string, number, number][] = [
|
||||||
|
['java', 'Installing Java 21', 48_000_000, 212],
|
||||||
|
['game', 'Downloading Minecraft 1.21.1', 420_000_000, 3412],
|
||||||
|
['loader', 'Installing Fabric 0.16.9', 6_000_000, 14],
|
||||||
|
['files', 'Syncing instance files', 184_000_000, 48],
|
||||||
|
];
|
||||||
|
for (const [stage, label, total, files] of stages) {
|
||||||
|
mockEmit('launch://progress', { type: 'stage', stage, label });
|
||||||
|
for (let i = 1; i <= 20; i++) {
|
||||||
|
await sleep(scenario === 'progress' ? 400 : 60);
|
||||||
|
mockEmit('launch://progress', { type: 'progress', stage, done: (total * i) / 20, total, files_done: Math.round((files * i) / 20), files_total: files });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mockEmit('launch://progress', { type: 'stage', stage: 'launching', label: 'Starting Minecraft' });
|
||||||
|
await sleep(500);
|
||||||
|
running = true;
|
||||||
|
mockEmit('launch://state', { instance_id: id, state: 'running', message: null });
|
||||||
|
const lines = ['[main/INFO]: Loading Minecraft 1.21.1 with Fabric Loader 0.16.9', '[Render thread/INFO]: Setting user: Notch', '[Render thread/INFO]: Backend library: LWJGL version 3.3.3', '[Render thread/INFO]: Reloading ResourceManager: vanilla, fabric', '[Render thread/INFO]: Sound engine started', '[Render thread/INFO]: Connecting to play.scopenet.gg, 25565'];
|
||||||
|
for (const l of lines) {
|
||||||
|
await sleep(300);
|
||||||
|
mockEmit('game://log', [`[12:00:0${lines.indexOf(l)}] ${l}`]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
|
||||||
|
await sleep(40);
|
||||||
|
switch (cmd) {
|
||||||
|
case 'bootstrap': {
|
||||||
|
const b: Bootstrap = {
|
||||||
|
app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'scopeddlol/SCOPENET-MC', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' },
|
||||||
|
panel_url: settings.panel_url,
|
||||||
|
settings,
|
||||||
|
accounts,
|
||||||
|
active_account: active,
|
||||||
|
manifest: scenario === 'setup' ? null : manifest,
|
||||||
|
game_running: running ? 'survival-smp' : null,
|
||||||
|
};
|
||||||
|
return structuredClone(b);
|
||||||
|
}
|
||||||
|
case 'refresh_manifest':
|
||||||
|
case 'set_panel_url':
|
||||||
|
if (cmd === 'set_panel_url') settings.panel_url = args.url;
|
||||||
|
return structuredClone(manifest);
|
||||||
|
case 'save_settings':
|
||||||
|
Object.assign(settings, args.settings);
|
||||||
|
return null;
|
||||||
|
case 'login_panel':
|
||||||
|
case 'add_offline': {
|
||||||
|
const a: Account = { id: crypto.randomUUID(), kind: cmd === 'login_panel' ? 'panel' : 'offline', username: args.username, uuid: crypto.randomUUID(), panel_url: null, role: 'player' };
|
||||||
|
accounts.push(a);
|
||||||
|
active = a.id;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
case 'register_panel':
|
||||||
|
return { account: null, pending: true };
|
||||||
|
case 'ms_start':
|
||||||
|
return { user_code: 'F7K2-QX9M', verification_uri: 'https://microsoft.com/link', expires_in: 900, interval: 5, message: '' };
|
||||||
|
case 'ms_finish':
|
||||||
|
await sleep(600_000);
|
||||||
|
return null;
|
||||||
|
case 'select_account':
|
||||||
|
active = args.id;
|
||||||
|
return null;
|
||||||
|
case 'launch':
|
||||||
|
fakeLaunch(args.instanceId);
|
||||||
|
return null;
|
||||||
|
case 'kill_game':
|
||||||
|
running = false;
|
||||||
|
mockEmit('game://exit', { instance_id: 'survival-smp', code: 0, crashed: false, tail: [], crash_report: null });
|
||||||
|
mockEmit('launch://state', { instance_id: 'survival-smp', state: 'idle', message: null });
|
||||||
|
return null;
|
||||||
|
case 'ping_server':
|
||||||
|
await sleep(200);
|
||||||
|
return { online: true, version: 'Paper 1.21.1', players_online: 42, players_max: 100, sample: ['Notch', 'jeb_', 'Dinnerbone'], motd: '§b§lSCOPENET §r§7» §fSeason 4 is live!', favicon: null, latency_ms: 23 };
|
||||||
|
case 'instance_local':
|
||||||
|
return { installed: args.instanceId !== 'modded', revision: 3, size: 1_240_000_000 };
|
||||||
|
case 'storage_info':
|
||||||
|
return { shared: 2_400_000_000, runtimes: 410_000_000, instances: [['survival-smp', 1_240_000_000], ['creative', 320_000_000]] };
|
||||||
|
case 'check_update':
|
||||||
|
return null;
|
||||||
|
case 'detect_java':
|
||||||
|
return 21;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Render Minecraft § formatting codes as HTML-safe segments.
|
||||||
|
const COLORS: Record<string, string> = {
|
||||||
|
'0': '#000000', '1': '#0000AA', '2': '#00AA00', '3': '#00AAAA', '4': '#AA0000', '5': '#AA00AA', '6': '#FFAA00', '7': '#AAAAAA',
|
||||||
|
'8': '#555555', '9': '#5555FF', a: '#55FF55', b: '#55FFFF', c: '#FF5555', d: '#FF55FF', e: '#FFFF55', f: '#FFFFFF',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Segment { text: string; color?: string; bold?: boolean; italic?: boolean; underline?: boolean; strike?: boolean }
|
||||||
|
|
||||||
|
export function parseMotd(input: string): Segment[] {
|
||||||
|
const out: Segment[] = [];
|
||||||
|
let cur: Segment = { text: '' };
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const ch = input[i];
|
||||||
|
if (ch === '§' && i + 1 < input.length) {
|
||||||
|
const code = input[i + 1].toLowerCase();
|
||||||
|
if (cur.text) out.push(cur);
|
||||||
|
const base: Segment = { ...cur, text: '' };
|
||||||
|
if (code === '#' && /^[0-9a-f]{6}$/i.test(input.slice(i + 2, i + 8))) {
|
||||||
|
cur = { text: '', color: '#' + input.slice(i + 2, i + 8) };
|
||||||
|
i += 7;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (COLORS[code]) cur = { text: '', color: COLORS[code] };
|
||||||
|
else if (code === 'l') cur = { ...base, bold: true };
|
||||||
|
else if (code === 'o') cur = { ...base, italic: true };
|
||||||
|
else if (code === 'n') cur = { ...base, underline: true };
|
||||||
|
else if (code === 'm') cur = { ...base, strike: true };
|
||||||
|
else if (code === 'r') cur = { text: '' };
|
||||||
|
else cur = base;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cur.text += ch;
|
||||||
|
}
|
||||||
|
if (cur.text) out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { errorText, invoke, listen } from './tauri';
|
||||||
|
import { applyTheme } from './theme';
|
||||||
|
import type { Account, Bootstrap, Instance, Manifest, ProgressEvent, Settings, Stage, UpdateInfo } from './types';
|
||||||
|
|
||||||
|
export interface LaunchState {
|
||||||
|
state: 'idle' | 'preparing' | 'running' | 'error';
|
||||||
|
stage: Stage | null;
|
||||||
|
label: string;
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
filesDone: number;
|
||||||
|
filesTotal: number;
|
||||||
|
speed: number;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExitInfo { instance_id: string; code: number | null; crashed: boolean; tail: string[]; crash_report: string | null }
|
||||||
|
|
||||||
|
const idle = (): LaunchState => ({ state: 'idle', stage: null, label: '', done: 0, total: 0, filesDone: 0, filesTotal: 0, speed: 0, message: null });
|
||||||
|
|
||||||
|
export const app = $state({
|
||||||
|
ready: false,
|
||||||
|
boot: null as Bootstrap | null,
|
||||||
|
manifest: null as Manifest | null,
|
||||||
|
settings: null as Settings | null,
|
||||||
|
accounts: [] as Account[],
|
||||||
|
active: null as string | null,
|
||||||
|
panelUrl: null as string | null,
|
||||||
|
offline: false,
|
||||||
|
view: 'home' as 'home' | 'settings',
|
||||||
|
settingsTab: 'account',
|
||||||
|
selected: null as string | null,
|
||||||
|
launch: idle(),
|
||||||
|
launchingId: null as string | null,
|
||||||
|
running: null as string | null,
|
||||||
|
logs: [] as string[],
|
||||||
|
consoleOpen: false,
|
||||||
|
crash: null as ExitInfo | null,
|
||||||
|
update: null as UpdateInfo | null,
|
||||||
|
addAccount: false,
|
||||||
|
instanceSettings: null as string | null,
|
||||||
|
toasts: [] as { id: number; text: string; kind: 'ok' | 'error' | 'info' }[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let toastId = 1;
|
||||||
|
export function toast(text: string, kind: 'ok' | 'error' | 'info' = 'ok') {
|
||||||
|
const id = toastId++;
|
||||||
|
app.toasts.push({ id, text, kind });
|
||||||
|
setTimeout(() => {
|
||||||
|
const i = app.toasts.findIndex((t) => t.id === id);
|
||||||
|
if (i >= 0) app.toasts.splice(i, 1);
|
||||||
|
}, kind === 'error' ? 7000 : 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activeAccount = () => app.accounts.find((a) => a.id === app.active) ?? null;
|
||||||
|
export const instances = (): Instance[] => app.manifest?.instances ?? [];
|
||||||
|
export const selectedInstance = (): Instance | null => instances().find((i) => i.id === app.selected) ?? instances()[0] ?? null;
|
||||||
|
|
||||||
|
/** Resolve panel-relative media URLs ("/uploads/x.png"). */
|
||||||
|
export function abs(url: string | null | undefined): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith('/') && app.panelUrl) return app.panelUrl + url;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function theme() {
|
||||||
|
if (app.manifest) applyTheme(app.manifest.branding, app.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSelected() {
|
||||||
|
const list = instances();
|
||||||
|
const wanted = app.settings?.selected_instance;
|
||||||
|
app.selected = list.find((i) => i.id === wanted)?.id ?? list.find((i) => i.featured)?.id ?? list[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
const boot = await invoke<Bootstrap>('bootstrap');
|
||||||
|
app.boot = boot;
|
||||||
|
app.settings = boot.settings;
|
||||||
|
app.accounts = boot.accounts;
|
||||||
|
app.active = boot.active_account;
|
||||||
|
app.manifest = boot.manifest;
|
||||||
|
app.panelUrl = boot.panel_url;
|
||||||
|
app.running = boot.game_running;
|
||||||
|
theme();
|
||||||
|
pickSelected();
|
||||||
|
subscribe();
|
||||||
|
app.ready = true;
|
||||||
|
invoke('show_window').catch(() => {});
|
||||||
|
if (app.panelUrl) await refresh();
|
||||||
|
if (boot.settings.check_updates && boot.app.repo) {
|
||||||
|
invoke<UpdateInfo | null>('check_update').then((u) => (app.update = u)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
try {
|
||||||
|
app.manifest = await invoke<Manifest>('refresh_manifest');
|
||||||
|
app.offline = false;
|
||||||
|
theme();
|
||||||
|
pickSelected();
|
||||||
|
} catch (e) {
|
||||||
|
app.offline = true;
|
||||||
|
if (!app.manifest) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connect(url: string) {
|
||||||
|
app.manifest = await invoke<Manifest>('set_panel_url', { url });
|
||||||
|
const boot = await invoke<Bootstrap>('bootstrap');
|
||||||
|
app.panelUrl = boot.panel_url;
|
||||||
|
app.offline = false;
|
||||||
|
theme();
|
||||||
|
pickSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
/** Persist settings (debounced) and re-apply the theme immediately. */
|
||||||
|
export function saveSettings() {
|
||||||
|
theme();
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
if (app.settings) invoke('save_settings', { settings: $state.snapshot(app.settings) }).catch((e) => toast(errorText(e), 'error'));
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectInstance(id: string) {
|
||||||
|
app.selected = id;
|
||||||
|
if (app.settings) {
|
||||||
|
app.settings.selected_instance = id;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accountAdded(account: Account) {
|
||||||
|
const i = app.accounts.findIndex((a) => a.id === account.id);
|
||||||
|
if (i >= 0) app.accounts[i] = account;
|
||||||
|
else app.accounts.push(account);
|
||||||
|
app.active = account.id;
|
||||||
|
app.addAccount = false;
|
||||||
|
await refresh().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchAccount(id: string) {
|
||||||
|
await invoke('select_account', { id });
|
||||||
|
app.active = id;
|
||||||
|
await refresh().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAccount(id: string) {
|
||||||
|
await invoke('remove_account', { id });
|
||||||
|
app.accounts = app.accounts.filter((a) => a.id !== id);
|
||||||
|
if (app.active === id) app.active = app.accounts[0]?.id ?? null;
|
||||||
|
await refresh().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function play(id: string) {
|
||||||
|
app.launch = { ...idle(), state: 'preparing', label: 'Getting ready…' };
|
||||||
|
app.launchingId = id;
|
||||||
|
app.logs = [];
|
||||||
|
try {
|
||||||
|
await invoke('launch', { instanceId: id });
|
||||||
|
} catch (e) {
|
||||||
|
app.launch = { ...idle(), state: 'error', message: errorText(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repair(id: string) {
|
||||||
|
app.launch = { ...idle(), state: 'preparing', label: 'Verifying files…' };
|
||||||
|
app.launchingId = id;
|
||||||
|
try {
|
||||||
|
await invoke('repair_instance', { instanceId: id });
|
||||||
|
} catch (e) {
|
||||||
|
app.launch = { ...idle(), state: 'error', message: errorText(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancel() {
|
||||||
|
if (app.launchingId) await invoke('cancel_launch', { instanceId: app.launchingId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kill() {
|
||||||
|
await invoke('kill_game');
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscribed = false;
|
||||||
|
let last = { t: 0, done: 0, stage: '' as string };
|
||||||
|
|
||||||
|
function subscribe() {
|
||||||
|
if (subscribed) return;
|
||||||
|
subscribed = true;
|
||||||
|
|
||||||
|
listen<ProgressEvent>('launch://progress', (ev) => {
|
||||||
|
const l = app.launch;
|
||||||
|
if (ev.type === 'stage') {
|
||||||
|
l.stage = ev.stage;
|
||||||
|
l.label = ev.label;
|
||||||
|
l.done = l.total = l.filesDone = l.filesTotal = 0;
|
||||||
|
l.speed = 0;
|
||||||
|
} else if (ev.type === 'progress') {
|
||||||
|
const now = performance.now();
|
||||||
|
if (last.stage !== ev.stage || ev.done < last.done) last = { t: now, done: ev.done, stage: ev.stage };
|
||||||
|
else if (now - last.t > 500) {
|
||||||
|
const inst = ((ev.done - last.done) / (now - last.t)) * 1000;
|
||||||
|
l.speed = l.speed ? l.speed * 0.6 + inst * 0.4 : inst;
|
||||||
|
last = { t: now, done: ev.done, stage: ev.stage };
|
||||||
|
}
|
||||||
|
l.stage = ev.stage;
|
||||||
|
l.done = ev.done;
|
||||||
|
l.total = ev.total;
|
||||||
|
l.filesDone = ev.files_done;
|
||||||
|
l.filesTotal = ev.files_total;
|
||||||
|
} else if (ev.type === 'log') {
|
||||||
|
pushLogs([ev.line]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listen<{ instance_id: string; state: LaunchState['state']; message: string | null }>('launch://state', (ev) => {
|
||||||
|
if (ev.state === 'running') {
|
||||||
|
app.running = ev.instance_id;
|
||||||
|
app.launch = { ...idle(), state: 'running' };
|
||||||
|
if (app.settings?.show_console) app.consoleOpen = true;
|
||||||
|
} else if (ev.state === 'error') {
|
||||||
|
app.launch = { ...idle(), state: 'error', message: ev.message };
|
||||||
|
} else if (ev.state === 'idle') {
|
||||||
|
if (app.running === ev.instance_id) app.running = null;
|
||||||
|
if (app.launch.state !== 'error') app.launch = idle();
|
||||||
|
if (ev.message && ev.message !== 'Cancelled') toast(ev.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listen<string[]>('game://log', (lines) => pushLogs(lines));
|
||||||
|
|
||||||
|
listen<ExitInfo>('game://exit', (ev) => {
|
||||||
|
app.running = null;
|
||||||
|
if (ev.crashed) app.crash = ev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushLogs(lines: string[]) {
|
||||||
|
app.logs.push(...lines);
|
||||||
|
if (app.logs.length > 3000) app.logs.splice(0, app.logs.length - 3000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Thin wrapper over Tauri IPC. When the UI runs in a normal browser
|
||||||
|
// (vite dev / screenshots), calls go to an in-memory mock instead.
|
||||||
|
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
|
||||||
|
type Handler = (payload: any) => void;
|
||||||
|
const mockListeners = new Map<string, Set<Handler>>();
|
||||||
|
|
||||||
|
export function mockEmit(event: string, payload: unknown) {
|
||||||
|
mockListeners.get(event)?.forEach((h) => h(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
if (inTauri) return tauriInvoke<T>(cmd, args);
|
||||||
|
const { mockInvoke } = await import('./mock');
|
||||||
|
return mockInvoke(cmd, args ?? {}) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<UnlistenFn> {
|
||||||
|
if (inTauri) return tauriListen<T>(event, (e) => handler(e.payload));
|
||||||
|
const set = mockListeners.get(event) ?? new Set();
|
||||||
|
set.add(handler as Handler);
|
||||||
|
mockListeners.set(event, set);
|
||||||
|
return () => set.delete(handler as Handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function windowAction(action: 'minimize' | 'maximize' | 'close' | 'drag') {
|
||||||
|
if (!inTauri) return;
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
|
const w = getCurrentWindow();
|
||||||
|
if (action === 'minimize') await w.minimize();
|
||||||
|
else if (action === 'maximize') await w.toggleMaximize();
|
||||||
|
else if (action === 'close') await w.close();
|
||||||
|
else await w.startDragging();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openUrl(url: string) {
|
||||||
|
if (!inTauri) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
||||||
|
await openUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickFile(title: string, filters?: { name: string; extensions: string[] }[]): Promise<string | null> {
|
||||||
|
if (!inTauri) return null;
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const r = await open({ title, multiple: false, directory: false, filters });
|
||||||
|
return typeof r === 'string' ? r : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorText(e: unknown): string {
|
||||||
|
return typeof e === 'string' ? e : e instanceof Error ? e.message : JSON.stringify(e);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Branding, Settings } from './types';
|
||||||
|
|
||||||
|
let styleEl: HTMLStyleElement | null = null;
|
||||||
|
|
||||||
|
/** Push the admin's branding (and the player's overrides) into CSS variables. */
|
||||||
|
export function applyTheme(b: Branding, s: Settings | null) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const custom = s?.theme_mode === 'custom' && b.features.allow_user_theme;
|
||||||
|
const accent = (custom && s?.accent) || b.colors.accent;
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
'--accent': accent,
|
||||||
|
'--accent-2': custom && s?.accent ? shift(accent) : b.colors.accent_2,
|
||||||
|
'--bg': b.colors.background,
|
||||||
|
'--surface': b.colors.surface,
|
||||||
|
'--text': b.colors.text,
|
||||||
|
'--muted': b.colors.muted,
|
||||||
|
'--success': b.colors.success,
|
||||||
|
'--danger': b.colors.danger,
|
||||||
|
'--radius': `${b.radius}px`,
|
||||||
|
'--radius-sm': `${Math.max(4, Math.round(b.radius * 0.65))}px`,
|
||||||
|
'--font': `'${b.font} Variable', '${b.font}', 'Outfit Variable', system-ui, sans-serif`,
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
|
||||||
|
const glass = s?.glass ?? b.glass;
|
||||||
|
root.classList.toggle('no-glass', !glass);
|
||||||
|
root.classList.toggle('reduce-motion', !!s?.reduce_motion);
|
||||||
|
root.style.fontSize = `${(s?.ui_scale ?? 100) * 0.15}px`;
|
||||||
|
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement('style');
|
||||||
|
styleEl.id = 'admin-css';
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
styleEl.textContent = b.custom_css ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A second accent derived from the first by rotating its hue. */
|
||||||
|
function shift(hex: string): string {
|
||||||
|
const m = hex.replace('#', '').match(/.{2}/g);
|
||||||
|
if (!m) return hex;
|
||||||
|
let [r, g, b] = m.map((x) => parseInt(x, 16) / 255);
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
|
let h = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
const d = max - min;
|
||||||
|
const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
||||||
|
if (d) {
|
||||||
|
if (max === r) h = ((g - b) / d) % 6;
|
||||||
|
else if (max === g) h = (b - r) / d + 2;
|
||||||
|
else h = (r - g) / d + 4;
|
||||||
|
}
|
||||||
|
h = (h * 60 + 50 + 360) % 360;
|
||||||
|
return `hsl(${h} ${Math.round(sat * 100)}% ${Math.round(l * 100)}%)`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
export type Loader = 'vanilla' | 'fabric' | 'quilt' | 'forge' | 'neoforge';
|
||||||
|
|
||||||
|
export interface ServerEntry { name: string; address: string; port: number; auto_join: boolean; inject: boolean }
|
||||||
|
|
||||||
|
export interface Instance {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon_url: string | null;
|
||||||
|
banner_url: string | null;
|
||||||
|
mc_version: string;
|
||||||
|
loader: Loader;
|
||||||
|
loader_version: string | null;
|
||||||
|
revision: number;
|
||||||
|
server: ServerEntry | null;
|
||||||
|
memory: { min_mb: number; max_mb: number };
|
||||||
|
jvm_args: string;
|
||||||
|
featured: boolean;
|
||||||
|
source_label: string;
|
||||||
|
file_count: number;
|
||||||
|
total_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsItem { id: string; title: string; body: string; image_url: string | null; link: string | null; date: string | null; pinned: boolean; tag: string | null }
|
||||||
|
|
||||||
|
export interface Branding {
|
||||||
|
name: string;
|
||||||
|
tagline: string;
|
||||||
|
logo_url: string | null;
|
||||||
|
icon_url: string | null;
|
||||||
|
background: { kind: 'gradient' | 'image' | 'video'; url: string | null; blur: number; dim: number };
|
||||||
|
colors: { accent: string; accent_2: string; background: string; surface: string; text: string; muted: string; success: string; danger: string };
|
||||||
|
font: string;
|
||||||
|
radius: number;
|
||||||
|
glass: boolean;
|
||||||
|
version_label: string;
|
||||||
|
news: NewsItem[];
|
||||||
|
links: { label: string; url: string; icon: string }[];
|
||||||
|
features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean };
|
||||||
|
custom_css: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthConfig {
|
||||||
|
panel_accounts: boolean;
|
||||||
|
registration: 'closed' | 'open' | 'approval';
|
||||||
|
microsoft: boolean;
|
||||||
|
microsoft_client_id: string | null;
|
||||||
|
offline_local: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Manifest {
|
||||||
|
api_version: number;
|
||||||
|
panel_version: string;
|
||||||
|
branding: Branding;
|
||||||
|
auth: AuthConfig;
|
||||||
|
instances: Instance[];
|
||||||
|
user: { id: number; username: string; uuid: string; role: string; groups: string[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Account { id: string; kind: 'panel' | 'microsoft' | 'offline'; username: string; uuid: string; panel_url: string | null; role: string | null }
|
||||||
|
|
||||||
|
export type Gc = 'default' | 'g1' | 'zgc';
|
||||||
|
|
||||||
|
export interface InstanceOverride { memory_max_mb: number; memory_min_mb: number; jvm_args: string; java_path: string | null }
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
panel_url: string | null;
|
||||||
|
selected_instance: string | null;
|
||||||
|
memory_max_mb: number;
|
||||||
|
memory_min_mb: number;
|
||||||
|
custom_resolution: boolean;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fullscreen: boolean;
|
||||||
|
after_launch: 'minimize' | 'hide' | 'keep' | 'close';
|
||||||
|
show_console: boolean;
|
||||||
|
java_path: string | null;
|
||||||
|
gc: Gc;
|
||||||
|
jvm_args: string;
|
||||||
|
theme_mode: 'server' | 'custom';
|
||||||
|
accent: string | null;
|
||||||
|
background_video: boolean;
|
||||||
|
reduce_motion: boolean;
|
||||||
|
glass: boolean | null;
|
||||||
|
ui_scale: number;
|
||||||
|
concurrent_downloads: number;
|
||||||
|
check_updates: boolean;
|
||||||
|
send_stats: boolean;
|
||||||
|
keybinds_enabled: boolean;
|
||||||
|
keybinds: Record<string, string>;
|
||||||
|
instance_overrides: Record<string, InstanceOverride>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bootstrap {
|
||||||
|
app: { version: string; default_panel_url: string | null; panel_locked: boolean; repo: string | null; os: string; arch: string; total_ram_mb: number; data_dir: string };
|
||||||
|
panel_url: string | null;
|
||||||
|
settings: Settings;
|
||||||
|
accounts: Account[];
|
||||||
|
active_account: string | null;
|
||||||
|
manifest: Manifest | null;
|
||||||
|
game_running: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Stage = 'preparing' | 'java' | 'game' | 'loader' | 'files' | 'launching';
|
||||||
|
export type ProgressEvent =
|
||||||
|
| { type: 'stage'; stage: Stage; label: string }
|
||||||
|
| { type: 'progress'; stage: Stage; done: number; total: number; files_done: number; files_total: number }
|
||||||
|
| { type: 'log'; line: string };
|
||||||
|
|
||||||
|
export interface ServerStatus { online: boolean; version: string; players_online: number; players_max: number; sample: string[]; motd: string; favicon: string | null; latency_ms: number }
|
||||||
|
export interface DeviceCode { user_code: string; verification_uri: string; expires_in: number; interval: number; message: string }
|
||||||
|
export interface UpdateInfo { version: string; notes: string; url: string; size: number }
|
||||||
@@ -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')! });
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SlidersHorizontal, SquareTerminal, Package, MessageCircle, Globe, ShoppingBag, CirclePlay, Tv, Code, AtSign, Link } from '@lucide/svelte';
|
||||||
|
import PlayButton from '../components/PlayButton.svelte';
|
||||||
|
import ServerStatus from '../components/ServerStatus.svelte';
|
||||||
|
import NewsFeed from '../components/NewsFeed.svelte';
|
||||||
|
import Console from '../components/Console.svelte';
|
||||||
|
import { activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
|
||||||
|
import { loaderName, shortLoaderVersion } from '../lib/format';
|
||||||
|
import { openUrl } from '../lib/tauri';
|
||||||
|
|
||||||
|
const inst = $derived(selectedInstance());
|
||||||
|
const b = $derived(app.manifest!.branding);
|
||||||
|
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
|
||||||
|
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const greet = hour < 5 ? 'Late night session' : hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="home">
|
||||||
|
{#if inst}
|
||||||
|
{#key inst.id}
|
||||||
|
<section class="hero">
|
||||||
|
<p class="greet muted">{greet}, <strong>{activeAccount()?.username ?? 'player'}</strong></p>
|
||||||
|
<div class="chips">
|
||||||
|
<span class="chip">{inst.mc_version}</span>
|
||||||
|
<span class="chip">{loaderName[inst.loader]} {shortLoaderVersion(inst.mc_version, inst.loader_version)}</span>
|
||||||
|
{#if inst.file_count}<span class="chip"><Package size={12} /> {inst.file_count} mods & files</span>{/if}
|
||||||
|
</div>
|
||||||
|
<h1>{inst.name}</h1>
|
||||||
|
<p class="desc">{inst.description || b.tagline}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="actions">
|
||||||
|
<PlayButton {inst} />
|
||||||
|
<div class="row tools">
|
||||||
|
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
|
||||||
|
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
|
||||||
|
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/key}
|
||||||
|
{:else}
|
||||||
|
<section class="hero empty">
|
||||||
|
<h1>{b.name}</h1>
|
||||||
|
<p class="desc">{instances().length === 0 ? "Nothing to play yet — your server admin hasn't published any instances you can access." : ''}</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<aside class="side">
|
||||||
|
{#if b.features.news && b.news.length}<NewsFeed news={b.news} />{:else}<div></div>{/if}
|
||||||
|
{#if b.links.length}
|
||||||
|
<div class="links">
|
||||||
|
{#each b.links as l}
|
||||||
|
{@const Icon = linkIcon[l.icon] ?? Link}
|
||||||
|
<button class="glass link" onclick={() => openUrl(l.url)} title={l.label}><Icon size={16} /> <span>{l.label}</span></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{#if app.consoleOpen}<Console />{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.home { position: relative; height: 100%; display: grid; grid-template-columns: 1fr minmax(17rem, 22rem); grid-template-rows: 1fr auto; gap: 1.5rem 2.5rem; padding: 2rem 2rem 2rem 2.5rem; }
|
||||||
|
.hero { grid-column: 1; grid-row: 1; align-self: end; display: flex; flex-direction: column; gap: 0.75rem; animation: fade 0.3s ease; max-width: 38rem; padding-bottom: 0.5rem; }
|
||||||
|
.greet { font-size: 0.88rem; }
|
||||||
|
.greet strong { color: var(--text); font-weight: 560; }
|
||||||
|
.chips { display: flex; gap: 0.45rem; flex-wrap: wrap; }
|
||||||
|
h1 { font-size: clamp(2.2rem, 4.4vw, 3.2rem); line-height: 1.05; font-weight: 700; letter-spacing: -0.025em; }
|
||||||
|
.desc { font-size: 0.95rem; line-height: 1.6; color: var(--muted); max-width: 32rem; }
|
||||||
|
.actions { grid-column: 1; grid-row: 2; display: flex; flex-direction: column; gap: 0.8rem; animation: fade 0.3s ease both; }
|
||||||
|
.tools { flex-wrap: wrap; gap: 0.6rem; }
|
||||||
|
.tool { width: 2.75rem; height: 2.75rem; padding: 0; border-radius: var(--radius); color: color-mix(in srgb, var(--text) 75%, transparent); background: var(--surface); }
|
||||||
|
.tool.on { color: var(--text); border-color: var(--line-strong); background: color-mix(in srgb, var(--text) 8%, var(--surface)); }
|
||||||
|
.side { grid-column: 2; grid-row: 1 / 3; display: flex; flex-direction: column; justify-content: space-between; gap: 1rem; min-height: 0; animation: fade 0.3s ease both; }
|
||||||
|
.links { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||||
|
.link { padding: 0.5rem 0.75rem; font-size: 0.82rem; border-radius: var(--radius-sm); flex: 1 1 auto; background: var(--surface); color: color-mix(in srgb, var(--text) 80%, transparent); }
|
||||||
|
.link:hover { border-color: var(--line-strong); color: var(--text); }
|
||||||
|
@media (max-width: 1060px) { .home { grid-template-columns: 1fr 16rem; } .link span { display: none; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SignIn from '../components/SignIn.svelte';
|
||||||
|
import { abs, app } from '../lib/store.svelte';
|
||||||
|
|
||||||
|
const b = $derived(app.manifest?.branding);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="login">
|
||||||
|
<section class="brand">
|
||||||
|
{#if b?.logo_url}<img src={abs(b.logo_url)} alt="" class="logo" />{/if}
|
||||||
|
<h1>{b?.name}</h1>
|
||||||
|
<p>{b?.tagline}</p>
|
||||||
|
</section>
|
||||||
|
<section class="card glass">
|
||||||
|
<h2>Welcome</h2>
|
||||||
|
<p class="muted small">Sign in to start playing.</p>
|
||||||
|
<SignIn />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login { height: 100%; display: grid; grid-template-columns: 1fr 23rem; align-items: center; gap: 4rem; padding: 2rem 4.5rem 3rem; }
|
||||||
|
.brand { display: flex; flex-direction: column; gap: 0.8rem; animation: fade 0.3s ease; }
|
||||||
|
.logo { width: 3.5rem; height: 3.5rem; object-fit: contain; margin-bottom: 0.5rem; }
|
||||||
|
h1 { font-size: clamp(2.4rem, 4.5vw, 3.4rem); font-weight: 700; letter-spacing: 0.02em; line-height: 1.05; }
|
||||||
|
.brand p { font-size: 1rem; color: var(--muted); max-width: 26rem; line-height: 1.55; }
|
||||||
|
.card { padding: 1.6rem; display: flex; flex-direction: column; gap: 0.35rem; background: var(--surface); animation: fade 0.3s ease both; }
|
||||||
|
.card h2 { font-size: 1.2rem; font-weight: 600; }
|
||||||
|
.card > :global(.signin) { margin-top: 0.8rem; }
|
||||||
|
@media (max-width: 900px) { .login { grid-template-columns: 1fr; padding: 2rem; } .brand { display: none; } }
|
||||||
|
</style>
|
||||||