Add Tauri launcher with Svelte UI

- Accounts: panel login/sign-up, Microsoft device-code sign-in, offline
  play; tokens encrypted with a key kept in the OS credential store
- Home: instance rail, live server status, news feed, social links,
  Play button with download progress, speed and ETA
- Full settings: accounts, memory/resolution/after-launch behaviour,
  Java and GC presets, Minecraft keybind editor applied to options.txt,
  theme overrides, storage manager, self-update via GitHub releases
- Game console, crash dialog with hints, per-instance overrides
- Live admin branding (colours, fonts, backgrounds, custom CSS)
- Online install tests for vanilla/Fabric/Quilt/Forge/NeoForge (CI)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
Claude committed 2026-09-28 05:00:30 +00:00
1 parent af9147ec75
commit 50eb1a0cac
60 files changed
+9424 -47

No files matched your search

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

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#8b6cff"/><stop offset="1" stop-color="#22d3ee"/></linearGradient><radialGradient id="glow" cx="0.5" cy="0.45" r="0.6"><stop offset="0" stop-color="#8b6cff" stop-opacity="0.35"/><stop offset="1" stop-color="#8b6cff" stop-opacity="0"/></radialGradient></defs><rect x="32" y="32" width="960" height="960" rx="224" fill="#0b0d17"/><rect x="32" y="32" width="960" height="960" rx="224" fill="url(#glow)"/><path d="M512 176 804 344v336L512 848 220 680V344z" fill="none" stroke="url(#g)" stroke-width="72" stroke-linejoin="round"/><path d="M512 176v168M804 344 660 428M220 344l144 84M512 848V680" stroke="url(#g)" stroke-width="40" stroke-linecap="round" opacity="0.45"/><circle cx="512" cy="512" r="112" fill="url(#g)"/></svg>

After

Width:  |  Height:  |  Size: 872 B

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