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
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["crates/shared", "crates/core", "panel/server"]
|
members = ["crates/shared", "crates/core", "panel/server", "launcher/src-tauri"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -268,7 +268,10 @@ fn write_argfile(path: &Path, args: &[String]) -> Result<()> {
|
|||||||
Ok(())
|
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)?;
|
std::fs::create_dir_all(&cmd.cwd)?;
|
||||||
let mut proc = tokio::process::Command::new(&cmd.java);
|
let mut proc = tokio::process::Command::new(&cmd.java);
|
||||||
let total_len: usize = cmd.args.iter().map(|a| a.len() + 3).sum();
|
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 {
|
} else {
|
||||||
proc.args(&cmd.args);
|
proc.args(&cmd.args);
|
||||||
}
|
}
|
||||||
proc.current_dir(&cmd.cwd)
|
let out = || if capture { std::process::Stdio::piped() } else { std::process::Stdio::null() };
|
||||||
.stdin(std::process::Stdio::null())
|
proc.current_dir(&cmd.cwd).stdin(std::process::Stdio::null()).stdout(out()).stderr(out());
|
||||||
.stdout(std::process::Stdio::piped())
|
|
||||||
.stderr(std::process::Stdio::piped());
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
proc.creation_flags(0x0800_0000);
|
proc.creation_flags(0x0800_0000);
|
||||||
proc.spawn().with_context(|| format!("starting {}", cmd.java.display()))
|
proc.spawn().with_context(|| format!("starting {}", cmd.java.display()))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>ScopeNet Launcher</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "scopenet-launcher",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
|
"@fontsource-variable/outfit": "^5.3.0",
|
||||||
|
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
|
||||||
|
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||||
|
"@tauri-apps/api": "^2.12.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.8.0",
|
||||||
|
"@tauri-apps/plugin-opener": "^2.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@lucide/svelte": "^1.48.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||||
|
"@tauri-apps/cli": "^2.12.0",
|
||||||
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
|
"svelte": "^5.57.1",
|
||||||
|
"svelte-check": "^4.7.6",
|
||||||
|
"typescript": "~5.9.0",
|
||||||
|
"vite": "^8.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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"] }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fn main() {
|
||||||
|
// Re-run when build-time branding changes (set by CI).
|
||||||
|
for var in ["SCOPENET_PANEL_URL", "SCOPENET_REPO", "SCOPENET_LOCK_PANEL"] {
|
||||||
|
println!("cargo:rerun-if-env-changed={var}");
|
||||||
|
}
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Permissions for the main launcher window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-toggle-maximize",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-unminimize",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-is-maximized",
|
||||||
|
"dialog:allow-open",
|
||||||
|
"opener:allow-open-url"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -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 |
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! Everything the UI can ask the backend to do.
|
||||||
|
|
||||||
|
use crate::accounts::{self, Account};
|
||||||
|
use crate::game;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use crate::state::{build, AppState};
|
||||||
|
use crate::updater;
|
||||||
|
use scopenet_core::msa::DeviceCode;
|
||||||
|
use scopenet_core::ping::ServerStatus;
|
||||||
|
use scopenet_shared::LauncherManifest;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
/// Commands return `Err(String)`; the UI shows it as-is.
|
||||||
|
type Res<T> = Result<T, String>;
|
||||||
|
|
||||||
|
fn err(e: impl std::fmt::Display) -> String {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// anyhow's alternate format keeps the context chain readable.
|
||||||
|
fn aerr(e: anyhow::Error) -> String {
|
||||||
|
format!("{e:#}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AppInfo {
|
||||||
|
version: String,
|
||||||
|
default_panel_url: Option<String>,
|
||||||
|
panel_locked: bool,
|
||||||
|
repo: Option<String>,
|
||||||
|
os: String,
|
||||||
|
arch: String,
|
||||||
|
total_ram_mb: u64,
|
||||||
|
data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Bootstrap {
|
||||||
|
app: AppInfo,
|
||||||
|
panel_url: Option<String>,
|
||||||
|
settings: Settings,
|
||||||
|
accounts: Vec<Account>,
|
||||||
|
active_account: Option<String>,
|
||||||
|
manifest: Option<LauncherManifest>,
|
||||||
|
game_running: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
|
||||||
|
let accounts = state.accounts.read().unwrap().clone();
|
||||||
|
Bootstrap {
|
||||||
|
app: AppInfo {
|
||||||
|
version: env!("CARGO_PKG_VERSION").into(),
|
||||||
|
default_panel_url: build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from),
|
||||||
|
panel_locked: build::panel_locked(),
|
||||||
|
repo: build::REPO.map(String::from),
|
||||||
|
os: std::env::consts::OS.into(),
|
||||||
|
arch: std::env::consts::ARCH.into(),
|
||||||
|
total_ram_mb: scopenet_core::sys::total_memory_mb(),
|
||||||
|
data_dir: state.data_dir.to_string_lossy().into_owned(),
|
||||||
|
},
|
||||||
|
panel_url: state.panel_url(),
|
||||||
|
settings: state.settings.read().unwrap().clone(),
|
||||||
|
accounts: accounts.accounts,
|
||||||
|
active_account: accounts.active,
|
||||||
|
manifest: state.manifest.read().unwrap().clone(),
|
||||||
|
game_running: state.game.lock().unwrap().as_ref().map(|g| g.instance_id.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_manifest(state: &AppState, panel: &str) -> anyhow::Result<LauncherManifest> {
|
||||||
|
let mut req = state.http.get(format!("{panel}/api/v1/launcher/manifest"));
|
||||||
|
if let Some(t) = accounts::panel_token(state) {
|
||||||
|
req = req.bearer_auth(t);
|
||||||
|
}
|
||||||
|
let resp = req.send().await.map_err(|e| anyhow::anyhow!("couldn't reach {panel}: {e}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!("{panel} returned {}", resp.status());
|
||||||
|
}
|
||||||
|
let m: LauncherManifest = resp.json().await.map_err(|_| anyhow::anyhow!("{panel} doesn't look like a ScopeNet panel"))?;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn refresh_manifest(state: State<'_, AppState>) -> Res<LauncherManifest> {
|
||||||
|
let panel = state.panel_url().ok_or("no panel configured")?;
|
||||||
|
let m = fetch_manifest(&state, &panel).await.map_err(aerr)?;
|
||||||
|
state.cache_manifest(&m);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_panel_url(state: State<'_, AppState>, url: String) -> Res<LauncherManifest> {
|
||||||
|
if build::panel_locked() {
|
||||||
|
return Err("this launcher is locked to its server".into());
|
||||||
|
}
|
||||||
|
let mut url = url.trim().trim_end_matches('/').to_string();
|
||||||
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||||
|
url = format!("https://{url}");
|
||||||
|
}
|
||||||
|
let m = fetch_manifest(&state, &url).await.map_err(aerr)?;
|
||||||
|
state.settings.write().unwrap().panel_url = Some(url);
|
||||||
|
state.save_settings().map_err(aerr)?;
|
||||||
|
state.cache_manifest(&m);
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Res<()> {
|
||||||
|
let mut current = state.settings.write().unwrap();
|
||||||
|
let panel = current.panel_url.clone();
|
||||||
|
*current = Settings { panel_url: panel, ..settings };
|
||||||
|
drop(current);
|
||||||
|
state.save_settings().map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- accounts ----
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn login_panel(state: State<'_, AppState>, username: String, password: String) -> Res<Account> {
|
||||||
|
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RegisterResult {
|
||||||
|
account: Option<Account>,
|
||||||
|
pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn register_panel(state: State<'_, AppState>, username: String, password: String, email: Option<String>) -> Res<RegisterResult> {
|
||||||
|
let (account, pending) = accounts::register_panel(&state, &username, &password, email).await.map_err(aerr)?;
|
||||||
|
Ok(RegisterResult { account, pending })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn add_offline(state: State<'_, AppState>, username: String) -> Res<Account> {
|
||||||
|
accounts::add_offline(&state, &username).map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ms_start(state: State<'_, AppState>) -> Res<DeviceCode> {
|
||||||
|
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
|
||||||
|
let code = scopenet_core::msa::start_device_code(&state.http, &client_id).await.map_err(aerr)?;
|
||||||
|
*state.device_code.lock().unwrap() = Some(code.clone());
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ms_finish(state: State<'_, AppState>) -> Res<Account> {
|
||||||
|
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
|
||||||
|
let code = state.device_code.lock().unwrap().clone().ok_or("start the sign-in first")?;
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
*state.ms_cancel.lock().unwrap() = Some(tx);
|
||||||
|
let session = tokio::select! {
|
||||||
|
r = scopenet_core::msa::finish_device_code(&state.http, &client_id, &code) => r.map_err(aerr)?,
|
||||||
|
_ = rx => return Err("cancelled".into()),
|
||||||
|
};
|
||||||
|
*state.device_code.lock().unwrap() = None;
|
||||||
|
accounts::save_ms_session(&state, session).map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn ms_cancel(state: State<'_, AppState>) {
|
||||||
|
if let Some(tx) = state.ms_cancel.lock().unwrap().take() {
|
||||||
|
tx.send(()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn select_account(state: State<'_, AppState>, id: String) -> Res<()> {
|
||||||
|
let mut accounts = state.accounts.write().unwrap();
|
||||||
|
if !accounts.accounts.iter().any(|a| a.id == id) {
|
||||||
|
return Err("account not found".into());
|
||||||
|
}
|
||||||
|
accounts.active = Some(id);
|
||||||
|
drop(accounts);
|
||||||
|
state.save_accounts().map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn remove_account(state: State<'_, AppState>, id: String) -> Res<()> {
|
||||||
|
let mut accounts = state.accounts.write().unwrap();
|
||||||
|
accounts.accounts.retain(|a| a.id != id);
|
||||||
|
if accounts.active.as_deref() == Some(id.as_str()) {
|
||||||
|
accounts.active = accounts.accounts.first().map(|a| a.id.clone());
|
||||||
|
}
|
||||||
|
drop(accounts);
|
||||||
|
state.secrets.remove(&id).map_err(aerr)?;
|
||||||
|
state.save_accounts().map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- game ----
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
|
||||||
|
start_task(app, &state, instance_id, true, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn repair_instance(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
|
||||||
|
start_task(app, &state, instance_id, false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_task(app: AppHandle, state: &AppState, instance_id: String, start: bool, deep: bool) -> Res<()> {
|
||||||
|
if state.game.lock().unwrap().is_some() {
|
||||||
|
return Err("Minecraft is already running".into());
|
||||||
|
}
|
||||||
|
let mut task = state.task.lock().unwrap();
|
||||||
|
if task.as_ref().is_some_and(|t| !t.is_finished()) {
|
||||||
|
return Err("already preparing an instance".into());
|
||||||
|
}
|
||||||
|
let app2 = app.clone();
|
||||||
|
let handle = tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = game::run(app2.clone(), instance_id.clone(), start, deep).await {
|
||||||
|
tracing::error!("launch failed: {e:#}");
|
||||||
|
game::emit_state(&app2, &instance_id, "error", Some(format!("{e:#}")));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
*task = Some(handle.inner().abort_handle());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) {
|
||||||
|
if let Some(t) = state.task.lock().unwrap().take() {
|
||||||
|
t.abort();
|
||||||
|
}
|
||||||
|
game::emit_state(&app, &instance_id, "idle", Some("Cancelled".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn kill_game(state: State<'_, AppState>) {
|
||||||
|
if let Some(g) = state.game.lock().unwrap().take() {
|
||||||
|
g.kill.send(()).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct InstanceLocal {
|
||||||
|
installed: bool,
|
||||||
|
revision: i64,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn instance_local(state: State<'_, AppState>, instance_id: String) -> Res<InstanceLocal> {
|
||||||
|
let dir = state.layout.instance_dir(&instance_id);
|
||||||
|
let s = scopenet_core::sync::load_state(&dir);
|
||||||
|
let size = tokio::task::spawn_blocking(move || scopenet_core::sys::dir_size(&dir)).await.unwrap_or(0);
|
||||||
|
Ok(InstanceLocal { installed: s.revision > 0, revision: s.revision, size })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ping_server(host: String, port: u16) -> Res<ServerStatus> {
|
||||||
|
scopenet_core::ping::ping(&host, port).await.map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_folder(app: AppHandle, state: State<'_, AppState>, kind: String, instance_id: Option<String>) -> Res<()> {
|
||||||
|
let base = match &instance_id {
|
||||||
|
Some(id) => state.layout.instance_dir(id),
|
||||||
|
None => state.data_dir.clone(),
|
||||||
|
};
|
||||||
|
let path = match kind.as_str() {
|
||||||
|
"instance" | "data" => base,
|
||||||
|
"logs" | "screenshots" | "mods" | "resourcepacks" | "shaderpacks" | "saves" | "crash-reports" => base.join(&kind),
|
||||||
|
_ => return Err("unknown folder".into()),
|
||||||
|
};
|
||||||
|
std::fs::create_dir_all(&path).map_err(err)?;
|
||||||
|
app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_file(app: AppHandle, path: String) -> Res<()> {
|
||||||
|
app.opener().open_path(path, None::<&str>).map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Storage {
|
||||||
|
shared: u64,
|
||||||
|
runtimes: u64,
|
||||||
|
instances: Vec<(String, u64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn storage_info(state: State<'_, AppState>) -> Res<Storage> {
|
||||||
|
let layout = state.layout.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
use scopenet_core::sys::dir_size;
|
||||||
|
let shared = dir_size(&layout.libraries()) + dir_size(&layout.assets()) + dir_size(&layout.versions());
|
||||||
|
let runtimes = dir_size(&layout.runtimes());
|
||||||
|
let instances = std::fs::read_dir(layout.instances())
|
||||||
|
.map(|rd| rd.filter_map(|e| e.ok()).map(|e| (e.file_name().to_string_lossy().into_owned(), dir_size(&e.path()))).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Storage { shared, runtimes, instances }
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_instance_data(state: State<'_, AppState>, instance_id: String) -> Res<()> {
|
||||||
|
if state.game.lock().unwrap().as_ref().is_some_and(|g| g.instance_id == instance_id) {
|
||||||
|
return Err("close the game first".into());
|
||||||
|
}
|
||||||
|
let dir = state.layout.instance_dir(&instance_id);
|
||||||
|
tokio::fs::remove_dir_all(dir).await.or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }).map_err(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_cache(state: State<'_, AppState>) -> Res<()> {
|
||||||
|
let cache = state.layout.cache();
|
||||||
|
tokio::fs::remove_dir_all(cache).await.ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn detect_java(path: String) -> Option<u32> {
|
||||||
|
scopenet_core::java::detect_major(std::path::Path::new(&path)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_update(state: State<'_, AppState>) -> Res<Option<updater::UpdateInfo>> {
|
||||||
|
updater::check(&state.http).await.map_err(aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_update(app: AppHandle, state: State<'_, AppState>, url: String) -> Res<()> {
|
||||||
|
updater::download_and_run(&state.http, &url).await.map_err(aerr)?;
|
||||||
|
app.exit(0);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn show_window(app: AppHandle) {
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
w.show().ok();
|
||||||
|
w.set_focus().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
//! ScopeNet launcher (Tauri shell). The heavy lifting lives in
|
||||||
|
//! `scopenet-core`; this crate wires it to the UI.
|
||||||
|
|
||||||
|
mod accounts;
|
||||||
|
mod commands;
|
||||||
|
mod game;
|
||||||
|
mod secrets;
|
||||||
|
mod settings;
|
||||||
|
mod state;
|
||||||
|
mod updater;
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
pub fn run() {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
// A second launch just focuses the existing window.
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
w.show().ok();
|
||||||
|
w.unminimize().ok();
|
||||||
|
w.set_focus().ok();
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.setup(|app| {
|
||||||
|
let data_dir = app.path().app_data_dir()?;
|
||||||
|
app.manage(state::AppState::new(data_dir));
|
||||||
|
// The window starts hidden and the UI reveals it once painted (no
|
||||||
|
// white flash). If the UI never does, show it anyway.
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(4));
|
||||||
|
if let Some(w) = handle.get_webview_window("main") {
|
||||||
|
if !w.is_visible().unwrap_or(true) {
|
||||||
|
tracing::warn!("UI didn't signal ready; showing window");
|
||||||
|
w.show().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::bootstrap,
|
||||||
|
commands::refresh_manifest,
|
||||||
|
commands::set_panel_url,
|
||||||
|
commands::save_settings,
|
||||||
|
commands::login_panel,
|
||||||
|
commands::register_panel,
|
||||||
|
commands::add_offline,
|
||||||
|
commands::ms_start,
|
||||||
|
commands::ms_finish,
|
||||||
|
commands::ms_cancel,
|
||||||
|
commands::select_account,
|
||||||
|
commands::remove_account,
|
||||||
|
commands::launch,
|
||||||
|
commands::repair_instance,
|
||||||
|
commands::cancel_launch,
|
||||||
|
commands::kill_game,
|
||||||
|
commands::instance_local,
|
||||||
|
commands::ping_server,
|
||||||
|
commands::open_folder,
|
||||||
|
commands::open_file,
|
||||||
|
commands::storage_info,
|
||||||
|
commands::delete_instance_data,
|
||||||
|
commands::clear_cache,
|
||||||
|
commands::detect_java,
|
||||||
|
commands::check_update,
|
||||||
|
commands::install_update,
|
||||||
|
commands::show_window,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running the launcher");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Hide the console window on Windows release builds.
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
scopenet_launcher_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! Encrypted storage for tokens (panel sessions, Microsoft refresh tokens).
|
||||||
|
//!
|
||||||
|
//! Secrets live in `secrets.bin`, encrypted with ChaCha20-Poly1305. The key
|
||||||
|
//! is kept in the OS credential store (Windows Credential Manager / macOS
|
||||||
|
//! Keychain), so copying the data folder to another machine doesn't leak
|
||||||
|
//! sessions. Where no credential store exists we fall back to a key file.
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use base64::Engine;
|
||||||
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||||
|
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Secret {
|
||||||
|
pub panel_token: Option<String>,
|
||||||
|
pub ms_refresh_token: Option<String>,
|
||||||
|
pub mc_access_token: Option<String>,
|
||||||
|
pub mc_expires_at: i64,
|
||||||
|
pub xuid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Secrets {
|
||||||
|
path: PathBuf,
|
||||||
|
key: [u8; 32],
|
||||||
|
data: Mutex<HashMap<String, Secret>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVICE: &str = "net.scopenet.launcher";
|
||||||
|
|
||||||
|
fn load_or_create_key(dir: &std::path::Path) -> [u8; 32] {
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
#[cfg(any(windows, target_os = "macos"))]
|
||||||
|
{
|
||||||
|
if let Ok(entry) = keyring::Entry::new(SERVICE, "secrets-key") {
|
||||||
|
if let Ok(existing) = entry.get_password() {
|
||||||
|
if let Ok(bytes) = b64.decode(existing) {
|
||||||
|
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut key);
|
||||||
|
if entry.set_password(&b64.encode(key)).is_ok() {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
tracing::warn!("credential store unavailable, falling back to a key file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = SERVICE;
|
||||||
|
let path = dir.join("secrets.key");
|
||||||
|
if let Ok(bytes) = std::fs::read(&path).map(|b| b64.decode(b).unwrap_or_default()) {
|
||||||
|
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut key);
|
||||||
|
std::fs::write(&path, b64.encode(key)).ok();
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Secrets {
|
||||||
|
pub fn open(dir: &std::path::Path) -> Self {
|
||||||
|
let key = load_or_create_key(dir);
|
||||||
|
let path = dir.join("secrets.bin");
|
||||||
|
let data = std::fs::read(&path).ok().and_then(|raw| decrypt(&key, &raw).ok()).unwrap_or_default();
|
||||||
|
Self { path, key, data: Mutex::new(data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &str) -> Secret {
|
||||||
|
self.data.lock().unwrap().get(id).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, id: &str, secret: Secret) -> Result<()> {
|
||||||
|
let mut data = self.data.lock().unwrap();
|
||||||
|
data.insert(id.to_string(), secret);
|
||||||
|
self.flush(&data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, id: &str) -> Result<()> {
|
||||||
|
let mut data = self.data.lock().unwrap();
|
||||||
|
data.remove(id);
|
||||||
|
self.flush(&data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self, data: &HashMap<String, Secret>) -> Result<()> {
|
||||||
|
let plain = serde_json::to_vec(data)?;
|
||||||
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.key));
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
rand::thread_rng().fill_bytes(&mut nonce);
|
||||||
|
let mut out = nonce.to_vec();
|
||||||
|
out.extend(cipher.encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|_| anyhow!("encryption failed"))?);
|
||||||
|
let tmp = self.path.with_extension("bin.tmp");
|
||||||
|
std::fs::write(&tmp, out)?;
|
||||||
|
std::fs::rename(tmp, &self.path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrypt(key: &[u8; 32], raw: &[u8]) -> Result<HashMap<String, Secret>> {
|
||||||
|
if raw.len() < 13 {
|
||||||
|
return Err(anyhow!("secrets file too short"));
|
||||||
|
}
|
||||||
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
|
||||||
|
let plain = cipher.decrypt(Nonce::from_slice(&raw[..12]), &raw[12..]).map_err(|_| anyhow!("can't decrypt secrets"))?;
|
||||||
|
Ok(serde_json::from_slice(&plain)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("scopenet-secrets-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let s = Secrets::open(&dir);
|
||||||
|
s.set("a", Secret { panel_token: Some("tok".into()), ..Default::default() }).unwrap();
|
||||||
|
let raw = std::fs::read(dir.join("secrets.bin")).unwrap();
|
||||||
|
assert!(!String::from_utf8_lossy(&raw).contains("tok"), "stored encrypted");
|
||||||
|
let again = Secrets::open(&dir);
|
||||||
|
assert_eq!(again.get("a").panel_token.as_deref(), Some("tok"));
|
||||||
|
std::fs::remove_dir_all(dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//! Player settings, stored as JSON in the app data directory.
|
||||||
|
|
||||||
|
use scopenet_core::launch::GcPreset;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct InstanceOverride {
|
||||||
|
/// 0 = use the global / instance default.
|
||||||
|
pub memory_max_mb: u32,
|
||||||
|
pub memory_min_mb: u32,
|
||||||
|
pub jvm_args: String,
|
||||||
|
pub java_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// Panel URL chosen by the player (ignored when one is baked in and locked).
|
||||||
|
pub panel_url: Option<String>,
|
||||||
|
pub selected_instance: Option<String>,
|
||||||
|
|
||||||
|
// ---- Game ----
|
||||||
|
/// 0 = use the value the admin set for the instance.
|
||||||
|
pub memory_max_mb: u32,
|
||||||
|
pub memory_min_mb: u32,
|
||||||
|
pub custom_resolution: bool,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub fullscreen: bool,
|
||||||
|
/// "minimize" | "hide" | "keep" | "close"
|
||||||
|
pub after_launch: String,
|
||||||
|
pub show_console: bool,
|
||||||
|
|
||||||
|
// ---- Java ----
|
||||||
|
pub java_path: Option<String>,
|
||||||
|
pub gc: GcPreset,
|
||||||
|
pub jvm_args: String,
|
||||||
|
|
||||||
|
// ---- Appearance ----
|
||||||
|
/// "server" (admin's theme) | "custom"
|
||||||
|
pub theme_mode: String,
|
||||||
|
pub accent: Option<String>,
|
||||||
|
pub background_video: bool,
|
||||||
|
pub reduce_motion: bool,
|
||||||
|
/// None = follow the admin's setting.
|
||||||
|
pub glass: Option<bool>,
|
||||||
|
pub ui_scale: u32,
|
||||||
|
|
||||||
|
// ---- Launcher ----
|
||||||
|
pub concurrent_downloads: u32,
|
||||||
|
pub check_updates: bool,
|
||||||
|
pub send_stats: bool,
|
||||||
|
|
||||||
|
// ---- Controls ----
|
||||||
|
pub keybinds_enabled: bool,
|
||||||
|
pub keybinds: BTreeMap<String, String>,
|
||||||
|
|
||||||
|
pub instance_overrides: HashMap<String, InstanceOverride>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
panel_url: None,
|
||||||
|
selected_instance: None,
|
||||||
|
memory_max_mb: 0,
|
||||||
|
memory_min_mb: 0,
|
||||||
|
custom_resolution: false,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
fullscreen: false,
|
||||||
|
after_launch: "minimize".into(),
|
||||||
|
show_console: false,
|
||||||
|
java_path: None,
|
||||||
|
gc: GcPreset::G1,
|
||||||
|
jvm_args: String::new(),
|
||||||
|
theme_mode: "server".into(),
|
||||||
|
accent: None,
|
||||||
|
background_video: true,
|
||||||
|
reduce_motion: false,
|
||||||
|
glass: None,
|
||||||
|
ui_scale: 100,
|
||||||
|
concurrent_downloads: 12,
|
||||||
|
check_updates: true,
|
||||||
|
send_stats: true,
|
||||||
|
keybinds_enabled: false,
|
||||||
|
keybinds: BTreeMap::new(),
|
||||||
|
instance_overrides: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn load(path: &Path) -> Self {
|
||||||
|
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
|
||||||
|
std::fs::rename(tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
use crate::accounts::AccountsFile;
|
||||||
|
use crate::secrets::Secrets;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use scopenet_core::msa::DeviceCode;
|
||||||
|
use scopenet_core::Layout;
|
||||||
|
use scopenet_shared::LauncherManifest;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Mutex, RwLock};
|
||||||
|
|
||||||
|
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
|
||||||
|
pub mod build {
|
||||||
|
/// Default panel URL for this build.
|
||||||
|
pub const PANEL_URL: Option<&str> = option_env!("SCOPENET_PANEL_URL");
|
||||||
|
/// `owner/repo` used to check GitHub releases for updates.
|
||||||
|
pub const REPO: Option<&str> = option_env!("SCOPENET_REPO");
|
||||||
|
/// When set to "1", players can't change the panel URL.
|
||||||
|
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
|
||||||
|
|
||||||
|
pub fn panel_locked() -> bool {
|
||||||
|
LOCK_PANEL == Some("1") && PANEL_URL.is_some_and(|u| !u.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RunningGame {
|
||||||
|
pub instance_id: String,
|
||||||
|
pub kill: tokio::sync::oneshot::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub layout: Layout,
|
||||||
|
pub http: reqwest::Client,
|
||||||
|
pub settings: RwLock<Settings>,
|
||||||
|
pub accounts: RwLock<AccountsFile>,
|
||||||
|
pub secrets: Secrets,
|
||||||
|
pub manifest: RwLock<Option<LauncherManifest>>,
|
||||||
|
pub device_code: Mutex<Option<DeviceCode>>,
|
||||||
|
pub ms_cancel: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||||
|
/// Install/launch pipeline in progress (abortable).
|
||||||
|
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||||
|
pub game: Mutex<Option<RunningGame>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(data_dir: PathBuf) -> Self {
|
||||||
|
std::fs::create_dir_all(&data_dir).ok();
|
||||||
|
let settings = Settings::load(&data_dir.join("settings.json"));
|
||||||
|
let accounts = AccountsFile::load(&data_dir.join("accounts.json"));
|
||||||
|
let manifest = std::fs::read(data_dir.join("manifest.json")).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||||
|
Self {
|
||||||
|
layout: Layout::new(data_dir.join("minecraft")),
|
||||||
|
http: scopenet_core::http::client(),
|
||||||
|
secrets: Secrets::open(&data_dir),
|
||||||
|
settings: RwLock::new(settings),
|
||||||
|
accounts: RwLock::new(accounts),
|
||||||
|
manifest: RwLock::new(manifest),
|
||||||
|
device_code: Mutex::new(None),
|
||||||
|
ms_cancel: Mutex::new(None),
|
||||||
|
task: Mutex::new(None),
|
||||||
|
game: Mutex::new(None),
|
||||||
|
data_dir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The panel this launcher talks to (baked-in when locked).
|
||||||
|
pub fn panel_url(&self) -> Option<String> {
|
||||||
|
let chosen = self.settings.read().unwrap().panel_url.clone().filter(|u| !u.is_empty());
|
||||||
|
let baked = build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from);
|
||||||
|
let url = if build::panel_locked() { baked } else { chosen.or(baked) };
|
||||||
|
url.map(|u| u.trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_settings(&self) -> anyhow::Result<()> {
|
||||||
|
self.settings.read().unwrap().save(&self.data_dir.join("settings.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_accounts(&self) -> anyhow::Result<()> {
|
||||||
|
self.accounts.read().unwrap().save(&self.data_dir.join("accounts.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_manifest(&self, m: &LauncherManifest) {
|
||||||
|
*self.manifest.write().unwrap() = Some(m.clone());
|
||||||
|
if let Ok(bytes) = serde_json::to_vec(m) {
|
||||||
|
std::fs::write(self.data_dir.join("manifest.json"), bytes).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! Self-update via GitHub Releases: find a newer `*-setup.exe`, download
|
||||||
|
//! it and hand over to the installer.
|
||||||
|
|
||||||
|
use crate::state::build;
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct UpdateInfo {
|
||||||
|
pub version: String,
|
||||||
|
pub notes: String,
|
||||||
|
pub url: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Release {
|
||||||
|
tag_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
body: String,
|
||||||
|
#[serde(default)]
|
||||||
|
draft: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
prerelease: bool,
|
||||||
|
assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Asset {
|
||||||
|
name: String,
|
||||||
|
browser_download_url: String,
|
||||||
|
size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(v: &str) -> Vec<u64> {
|
||||||
|
v.trim_start_matches('v').split(['.', '-', '+']).map_while(|p| p.parse().ok()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_newer(candidate: &str, current: &str) -> bool {
|
||||||
|
parse(candidate) > parse(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check(http: &reqwest::Client) -> Result<Option<UpdateInfo>> {
|
||||||
|
let Some(repo) = build::REPO.filter(|r| !r.is_empty()) else { return Ok(None) };
|
||||||
|
let release: Release = http
|
||||||
|
.get(format!("https://api.github.com/repos/{repo}/releases/latest"))
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
if release.draft || release.prerelease || !is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let asset = release
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.name.to_lowercase().ends_with("-setup.exe"))
|
||||||
|
.ok_or_else(|| anyhow!("release {} has no installer", release.tag_name))?;
|
||||||
|
Ok(Some(UpdateInfo {
|
||||||
|
version: release.tag_name.trim_start_matches('v').to_string(),
|
||||||
|
notes: release.body,
|
||||||
|
url: asset.browser_download_url.clone(),
|
||||||
|
size: asset.size,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the installer and start it. The caller exits the app afterwards.
|
||||||
|
pub async fn download_and_run(http: &reqwest::Client, url: &str) -> Result<()> {
|
||||||
|
if !url.starts_with("https://github.com/") && !url.starts_with("https://objects.githubusercontent.com/") {
|
||||||
|
anyhow::bail!("refusing to download an update from an unexpected host");
|
||||||
|
}
|
||||||
|
let dest = std::env::temp_dir().join("scopenet-launcher-setup.exe");
|
||||||
|
let dl = scopenet_core::http::Download::new(url, dest.clone(), None, None);
|
||||||
|
scopenet_core::http::download_one(http, &dl, &|_| {}).await.context("downloading update")?;
|
||||||
|
// /P = passive (progress only), /R = relaunch when done (Tauri NSIS).
|
||||||
|
std::process::Command::new(&dest).args(["/P", "/R"]).spawn().context("starting the installer")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn versions() {
|
||||||
|
assert!(is_newer("v0.2.0", "0.1.9"));
|
||||||
|
assert!(is_newer("1.0.0", "0.9.12"));
|
||||||
|
assert!(!is_newer("v0.1.0", "0.1.0"));
|
||||||
|
assert!(!is_newer("0.1.0-beta", "0.1.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "ScopeNet Launcher",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "net.scopenet.launcher",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": false,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "ScopeNet Launcher",
|
||||||
|
"width": 1120,
|
||||||
|
"height": 700,
|
||||||
|
"minWidth": 960,
|
||||||
|
"minHeight": 600,
|
||||||
|
"decorations": false,
|
||||||
|
"shadow": true,
|
||||||
|
"center": true,
|
||||||
|
"visible": false,
|
||||||
|
"backgroundColor": "#0b0d17"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https: http: data: blob:; media-src 'self' https: http: blob:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["nsis"],
|
||||||
|
"publisher": "ScopeNet",
|
||||||
|
"category": "Game",
|
||||||
|
"shortDescription": "Minecraft launcher",
|
||||||
|
"longDescription": "A fast, customisable Minecraft launcher connected to your community's admin panel.",
|
||||||
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
|
||||||
|
"windows": {
|
||||||
|
"webviewInstallMode": { "type": "downloadBootstrapper", "silent": true },
|
||||||
|
"nsis": {
|
||||||
|
"installMode": "currentUser",
|
||||||
|
"displayLanguageSelector": false,
|
||||||
|
"installerIcon": "icons/icon.ico"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { LoaderCircle, Download, X } from '@lucide/svelte';
|
||||||
|
import Background from './components/Background.svelte';
|
||||||
|
import TitleBar from './components/TitleBar.svelte';
|
||||||
|
import Rail from './components/Rail.svelte';
|
||||||
|
import Modal from './components/Modal.svelte';
|
||||||
|
import SignIn from './components/SignIn.svelte';
|
||||||
|
import InstanceSettings from './components/InstanceSettings.svelte';
|
||||||
|
import CrashDialog from './components/CrashDialog.svelte';
|
||||||
|
import Toasts from './components/Toasts.svelte';
|
||||||
|
import Setup from './pages/Setup.svelte';
|
||||||
|
import Login from './pages/Login.svelte';
|
||||||
|
import Home from './pages/Home.svelte';
|
||||||
|
import Settings from './pages/Settings.svelte';
|
||||||
|
import { app, init } from './lib/store.svelte';
|
||||||
|
import { errorText } from './lib/tauri';
|
||||||
|
|
||||||
|
let fatal = $state('');
|
||||||
|
let updateDismissed = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
init().catch((e) => (fatal = errorText(e)));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Background />
|
||||||
|
|
||||||
|
<div class="shell">
|
||||||
|
<TitleBar />
|
||||||
|
{#if fatal && !app.manifest}
|
||||||
|
<div class="center"><div class="glass msg"><h2>Can't reach the server</h2><p class="muted selectable">{fatal}</p><button class="primary" onclick={() => location.reload()}>Try again</button></div></div>
|
||||||
|
{:else if !app.ready}
|
||||||
|
<div class="center"><LoaderCircle class="spin" size={28} /></div>
|
||||||
|
{:else if !app.panelUrl || !app.manifest}
|
||||||
|
<Setup />
|
||||||
|
{:else if app.accounts.length === 0}
|
||||||
|
<Login />
|
||||||
|
{:else}
|
||||||
|
<div class="body">
|
||||||
|
<Rail />
|
||||||
|
<main>
|
||||||
|
{#if app.view === 'settings'}<Settings />{:else}<Home />{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if app.update && !updateDismissed && app.view !== 'settings' && app.accounts.length}
|
||||||
|
<div class="update glass">
|
||||||
|
<Download size={16} />
|
||||||
|
<span>Launcher {app.update.version} is available</span>
|
||||||
|
<button class="sm primary" onclick={() => { app.view = 'settings'; app.settingsTab = 'about'; }}>View</button>
|
||||||
|
<button class="ghost icon sm" aria-label="Dismiss" onclick={() => (updateDismissed = true)}><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Modal bind:open={app.addAccount} title="Add account" width={26}>
|
||||||
|
<SignIn />
|
||||||
|
</Modal>
|
||||||
|
<InstanceSettings />
|
||||||
|
<CrashDialog />
|
||||||
|
<Toasts />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shell { position: relative; z-index: 1; height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.body { flex: 1; display: flex; min-height: 0; }
|
||||||
|
main { flex: 1; min-width: 0; min-height: 0; position: relative; }
|
||||||
|
.center { flex: 1; display: grid; place-items: center; color: var(--accent); }
|
||||||
|
.msg { padding: 2rem; max-width: 28rem; display: flex; flex-direction: column; gap: 0.8rem; color: var(--text); }
|
||||||
|
.update { position: fixed; top: 3.2rem; left: 50%; transform: translateX(-50%); z-index: 40; display: flex; align-items: center; gap: 0.7rem; padding: 0.45rem 0.5rem 0.45rem 1rem; font-size: 0.87rem; border-radius: 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>
|
||||||
@@ -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; } }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Account } from '../lib/types';
|
||||||
|
let { account, size = 2.2 }: { account: Account | null; size?: number } = $props();
|
||||||
|
let failed = $state(false);
|
||||||
|
const hue = $derived([...(account?.username ?? '?')].reduce((a, c) => a + c.charCodeAt(0), 0) % 360);
|
||||||
|
// Microsoft accounts have real skins; everyone else gets a head by name.
|
||||||
|
const src = $derived(account ? `https://mc-heads.net/avatar/${account.kind === 'microsoft' ? account.uuid : encodeURIComponent(account.username)}/64` : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if account && !failed}
|
||||||
|
<img {src} alt="" style:width="{size}rem" style:height="{size}rem" onerror={() => (failed = true)} />
|
||||||
|
{:else}
|
||||||
|
<span class="fb" style:width="{size}rem" style:height="{size}rem" style:background="hsl({hue} 55% 42%)">{account?.username.slice(0, 1).toUpperCase() ?? '?'}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
img, .fb { border-radius: 28%; image-rendering: pixelated; flex-shrink: 0; display: inline-grid; place-items: center; font-weight: 700; color: white; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,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("{image}")" 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { checked = $bindable(false), label, help, disabled = false, onchange }: { checked: boolean; label: string; help?: string; disabled?: boolean; onchange?: () => void } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="toggle" class:disabled>
|
||||||
|
<span class="text"><span class="label">{label}</span>{#if help}<span class="help">{help}</span>{/if}</span>
|
||||||
|
<input type="checkbox" role="switch" bind:checked {disabled} onchange={() => onchange?.()} />
|
||||||
|
<span class="track" aria-hidden="true"><span class="thumb"></span></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toggle { display: flex; align-items: center; gap: 1rem; cursor: pointer; padding: 0.35rem 0; }
|
||||||
|
.disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.text { flex: 1; display: flex; flex-direction: column; gap: 0.15rem; }
|
||||||
|
.label { font-weight: 560; font-size: 0.93rem; }
|
||||||
|
input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.track { width: 2.75rem; height: 1.55rem; border-radius: 99rem; background: color-mix(in srgb, var(--text) 14%, transparent); position: relative; transition: background 0.2s; flex-shrink: 0; }
|
||||||
|
.thumb { position: absolute; top: 0.18rem; left: 0.18rem; width: 1.19rem; height: 1.19rem; border-radius: 50%; background: color-mix(in srgb, var(--text) 80%, transparent); transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.6, 1), background 0.2s; }
|
||||||
|
input:checked + .track { background: var(--accent); }
|
||||||
|
input:checked + .track .thumb { transform: translateX(1.2rem); background: white; }
|
||||||
|
input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export function bytes(n: number): string {
|
||||||
|
if (!n) return '0 B';
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||||
|
return `${(n / 1024 ** i).toFixed(i > 1 ? 1 : 0)} ${u[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gb(mb: number): string {
|
||||||
|
return `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loaderName: Record<string, string> = {
|
||||||
|
vanilla: 'Vanilla',
|
||||||
|
fabric: 'Fabric',
|
||||||
|
quilt: 'Quilt',
|
||||||
|
forge: 'Forge',
|
||||||
|
neoforge: 'NeoForge',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function shortLoaderVersion(mc: string, v: string | null): string {
|
||||||
|
return v ? v.replace(`${mc}-`, '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function niceDate(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso + (iso.length === 10 ? 'T00:00:00' : ''));
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Minecraft (1.13+) key names used in options.txt, and friendly labels.
|
||||||
|
|
||||||
|
export interface Binding { id: string; label: string; default: string }
|
||||||
|
|
||||||
|
export const BIND_GROUPS: { title: string; binds: Binding[] }[] = [
|
||||||
|
{
|
||||||
|
title: 'Movement',
|
||||||
|
binds: [
|
||||||
|
{ id: 'key.forward', label: 'Walk forwards', default: 'key.keyboard.w' },
|
||||||
|
{ id: 'key.left', label: 'Strafe left', default: 'key.keyboard.a' },
|
||||||
|
{ id: 'key.back', label: 'Walk backwards', default: 'key.keyboard.s' },
|
||||||
|
{ id: 'key.right', label: 'Strafe right', default: 'key.keyboard.d' },
|
||||||
|
{ id: 'key.jump', label: 'Jump', default: 'key.keyboard.space' },
|
||||||
|
{ id: 'key.sneak', label: 'Sneak', default: 'key.keyboard.left.shift' },
|
||||||
|
{ id: 'key.sprint', label: 'Sprint', default: 'key.keyboard.left.control' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Gameplay',
|
||||||
|
binds: [
|
||||||
|
{ id: 'key.attack', label: 'Attack / destroy', default: 'key.mouse.left' },
|
||||||
|
{ id: 'key.use', label: 'Use item / place block', default: 'key.mouse.right' },
|
||||||
|
{ id: 'key.pickItem', label: 'Pick block', default: 'key.mouse.middle' },
|
||||||
|
{ id: 'key.drop', label: 'Drop item', default: 'key.keyboard.q' },
|
||||||
|
{ id: 'key.swapOffhand', label: 'Swap to offhand', default: 'key.keyboard.f' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Interface',
|
||||||
|
binds: [
|
||||||
|
{ id: 'key.inventory', label: 'Inventory', default: 'key.keyboard.e' },
|
||||||
|
{ id: 'key.chat', label: 'Open chat', default: 'key.keyboard.t' },
|
||||||
|
{ id: 'key.command', label: 'Open command', default: 'key.keyboard.slash' },
|
||||||
|
{ id: 'key.playerlist', label: 'Player list', default: 'key.keyboard.tab' },
|
||||||
|
{ id: 'key.advancements', label: 'Advancements', default: 'key.keyboard.l' },
|
||||||
|
{ id: 'key.socialInteractions', label: 'Social interactions', default: 'key.keyboard.p' },
|
||||||
|
{ id: 'key.screenshot', label: 'Screenshot', default: 'key.keyboard.f2' },
|
||||||
|
{ id: 'key.togglePerspective', label: 'Toggle perspective', default: 'key.keyboard.f5' },
|
||||||
|
{ id: 'key.fullscreen', label: 'Toggle fullscreen', default: 'key.keyboard.f11' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Hotbar',
|
||||||
|
binds: Array.from({ length: 9 }, (_, i) => ({ id: `key.hotbar.${i + 1}`, label: `Hotbar slot ${i + 1}`, default: `key.keyboard.${i + 1}` })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SPECIAL: Record<string, string> = {
|
||||||
|
Space: 'space', ShiftLeft: 'left.shift', ShiftRight: 'right.shift', ControlLeft: 'left.control', ControlRight: 'right.control',
|
||||||
|
AltLeft: 'left.alt', AltRight: 'right.alt', Tab: 'tab', Enter: 'enter', Backspace: 'backspace', CapsLock: 'caps.lock',
|
||||||
|
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', Backquote: 'grave.accent', Minus: 'minus',
|
||||||
|
Equal: 'equal', BracketLeft: 'left.bracket', BracketRight: 'right.bracket', Backslash: 'backslash', Semicolon: 'semicolon',
|
||||||
|
Quote: 'apostrophe', Comma: 'comma', Period: 'period', Slash: 'slash', Insert: 'insert', Delete: 'delete', Home: 'home',
|
||||||
|
End: 'end', PageUp: 'page.up', PageDown: 'page.down', NumpadAdd: 'keypad.add', NumpadSubtract: 'keypad.subtract',
|
||||||
|
NumpadMultiply: 'keypad.multiply', NumpadDivide: 'keypad.divide', NumpadEnter: 'keypad.enter', NumpadDecimal: 'keypad.decimal',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** KeyboardEvent.code → Minecraft key name (null for unsupported keys). */
|
||||||
|
export function fromKeyboard(code: string): string | null {
|
||||||
|
if (/^Key[A-Z]$/.test(code)) return `key.keyboard.${code.slice(3).toLowerCase()}`;
|
||||||
|
if (/^Digit\d$/.test(code)) return `key.keyboard.${code.slice(5)}`;
|
||||||
|
if (/^F\d{1,2}$/.test(code)) return `key.keyboard.${code.toLowerCase()}`;
|
||||||
|
if (/^Numpad\d$/.test(code)) return `key.keyboard.keypad.${code.slice(6)}`;
|
||||||
|
return SPECIAL[code] ? `key.keyboard.${SPECIAL[code]}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromMouse(button: number): string {
|
||||||
|
return ['key.mouse.left', 'key.mouse.middle', 'key.mouse.right', 'key.mouse.4', 'key.mouse.5'][button] ?? `key.mouse.${button + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a Minecraft key name. */
|
||||||
|
export function keyLabel(key: string): string {
|
||||||
|
if (!key || key === 'key.keyboard.unknown') return 'Not bound';
|
||||||
|
const mouse = key.match(/^key\.mouse\.(.+)$/);
|
||||||
|
if (mouse) return { left: 'Left click', right: 'Right click', middle: 'Middle click' }[mouse[1]] ?? `Mouse ${mouse[1]}`;
|
||||||
|
const k = key.replace('key.keyboard.', '');
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
'left.shift': 'Left Shift', 'right.shift': 'Right Shift', 'left.control': 'Left Ctrl', 'right.control': 'Right Ctrl',
|
||||||
|
'left.alt': 'Left Alt', 'right.alt': 'Right Alt', 'caps.lock': 'Caps Lock', 'page.up': 'Page Up', 'page.down': 'Page Down',
|
||||||
|
'grave.accent': '`', 'left.bracket': '[', 'right.bracket': ']', slash: '/', backslash: '\\', semicolon: ';', apostrophe: "'",
|
||||||
|
comma: ',', period: '.', minus: '-', equal: '=', up: '↑', down: '↓', left: '←', right: '→',
|
||||||
|
};
|
||||||
|
if (names[k]) return names[k];
|
||||||
|
if (k.startsWith('keypad.')) return `Num ${k.slice(7)}`;
|
||||||
|
return k.length === 1 ? k.toUpperCase() : k.charAt(0).toUpperCase() + k.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaults(): Record<string, string> {
|
||||||
|
return Object.fromEntries(BIND_GROUPS.flatMap((g) => g.binds.map((b) => [b.id, b.default])));
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// In-browser stand-in for the Rust backend, used by `npm run dev` outside
|
||||||
|
// Tauri and for UI screenshots. Scenario via ?mock=setup|login|main.
|
||||||
|
import { mockEmit } from './tauri';
|
||||||
|
import type { Account, Bootstrap, Manifest, Settings } from './types';
|
||||||
|
|
||||||
|
const scenario = new URLSearchParams(location.search).get('mock') ?? 'main';
|
||||||
|
|
||||||
|
const settings: Settings = {
|
||||||
|
panel_url: scenario === 'setup' ? null : 'https://panel.scopenet.gg',
|
||||||
|
selected_instance: null,
|
||||||
|
memory_max_mb: 0,
|
||||||
|
memory_min_mb: 0,
|
||||||
|
custom_resolution: false,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
fullscreen: false,
|
||||||
|
after_launch: 'minimize',
|
||||||
|
show_console: false,
|
||||||
|
java_path: null,
|
||||||
|
gc: 'g1',
|
||||||
|
jvm_args: '',
|
||||||
|
theme_mode: 'server',
|
||||||
|
accent: null,
|
||||||
|
background_video: true,
|
||||||
|
reduce_motion: false,
|
||||||
|
glass: null,
|
||||||
|
ui_scale: 100,
|
||||||
|
concurrent_downloads: 12,
|
||||||
|
check_updates: true,
|
||||||
|
send_stats: true,
|
||||||
|
keybinds_enabled: false,
|
||||||
|
keybinds: {},
|
||||||
|
instance_overrides: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manifest: Manifest = {
|
||||||
|
api_version: 1,
|
||||||
|
panel_version: '0.1.0',
|
||||||
|
user: null,
|
||||||
|
auth: { panel_accounts: true, registration: 'approval', microsoft: true, microsoft_client_id: 'demo', offline_local: true },
|
||||||
|
branding: {
|
||||||
|
name: 'ScopeNet',
|
||||||
|
tagline: 'Your worlds, one click away.',
|
||||||
|
logo_url: null,
|
||||||
|
icon_url: null,
|
||||||
|
background: { kind: 'gradient', url: null, blur: 0, dim: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Render Minecraft § formatting codes as HTML-safe segments.
|
||||||
|
const COLORS: Record<string, string> = {
|
||||||
|
'0': '#000000', '1': '#0000AA', '2': '#00AA00', '3': '#00AAAA', '4': '#AA0000', '5': '#AA00AA', '6': '#FFAA00', '7': '#AAAAAA',
|
||||||
|
'8': '#555555', '9': '#5555FF', a: '#55FF55', b: '#55FFFF', c: '#FF5555', d: '#FF55FF', e: '#FFFF55', f: '#FFFFFF',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Segment { text: string; color?: string; bold?: boolean; italic?: boolean; underline?: boolean; strike?: boolean }
|
||||||
|
|
||||||
|
export function parseMotd(input: string): Segment[] {
|
||||||
|
const out: Segment[] = [];
|
||||||
|
let cur: Segment = { text: '' };
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const ch = input[i];
|
||||||
|
if (ch === '§' && i + 1 < input.length) {
|
||||||
|
const code = input[i + 1].toLowerCase();
|
||||||
|
if (cur.text) out.push(cur);
|
||||||
|
const base: Segment = { ...cur, text: '' };
|
||||||
|
if (code === '#' && /^[0-9a-f]{6}$/i.test(input.slice(i + 2, i + 8))) {
|
||||||
|
cur = { text: '', color: '#' + input.slice(i + 2, i + 8) };
|
||||||
|
i += 7;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (COLORS[code]) cur = { text: '', color: COLORS[code] };
|
||||||
|
else if (code === 'l') cur = { ...base, bold: true };
|
||||||
|
else if (code === 'o') cur = { ...base, italic: true };
|
||||||
|
else if (code === 'n') cur = { ...base, underline: true };
|
||||||
|
else if (code === 'm') cur = { ...base, strike: true };
|
||||||
|
else if (code === 'r') cur = { text: '' };
|
||||||
|
else cur = base;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cur.text += ch;
|
||||||
|
}
|
||||||
|
if (cur.text) out.push(cur);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { errorText, invoke, listen } from './tauri';
|
||||||
|
import { applyTheme } from './theme';
|
||||||
|
import type { Account, Bootstrap, Instance, Manifest, ProgressEvent, Settings, Stage, UpdateInfo } from './types';
|
||||||
|
|
||||||
|
export interface LaunchState {
|
||||||
|
state: 'idle' | 'preparing' | 'running' | 'error';
|
||||||
|
stage: Stage | null;
|
||||||
|
label: string;
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
filesDone: number;
|
||||||
|
filesTotal: number;
|
||||||
|
speed: number;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExitInfo { instance_id: string; code: number | null; crashed: boolean; tail: string[]; crash_report: string | null }
|
||||||
|
|
||||||
|
const idle = (): LaunchState => ({ state: 'idle', stage: null, label: '', done: 0, total: 0, filesDone: 0, filesTotal: 0, speed: 0, message: null });
|
||||||
|
|
||||||
|
export const app = $state({
|
||||||
|
ready: false,
|
||||||
|
boot: null as Bootstrap | null,
|
||||||
|
manifest: null as Manifest | null,
|
||||||
|
settings: null as Settings | null,
|
||||||
|
accounts: [] as Account[],
|
||||||
|
active: null as string | null,
|
||||||
|
panelUrl: null as string | null,
|
||||||
|
offline: false,
|
||||||
|
view: 'home' as 'home' | 'settings',
|
||||||
|
settingsTab: 'account',
|
||||||
|
selected: null as string | null,
|
||||||
|
launch: idle(),
|
||||||
|
launchingId: null as string | null,
|
||||||
|
running: null as string | null,
|
||||||
|
logs: [] as string[],
|
||||||
|
consoleOpen: false,
|
||||||
|
crash: null as ExitInfo | null,
|
||||||
|
update: null as UpdateInfo | null,
|
||||||
|
addAccount: false,
|
||||||
|
instanceSettings: null as string | null,
|
||||||
|
toasts: [] as { id: number; text: string; kind: 'ok' | 'error' | 'info' }[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let toastId = 1;
|
||||||
|
export function toast(text: string, kind: 'ok' | 'error' | 'info' = 'ok') {
|
||||||
|
const id = toastId++;
|
||||||
|
app.toasts.push({ id, text, kind });
|
||||||
|
setTimeout(() => {
|
||||||
|
const i = app.toasts.findIndex((t) => t.id === id);
|
||||||
|
if (i >= 0) app.toasts.splice(i, 1);
|
||||||
|
}, kind === 'error' ? 7000 : 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activeAccount = () => app.accounts.find((a) => a.id === app.active) ?? null;
|
||||||
|
export const instances = (): Instance[] => app.manifest?.instances ?? [];
|
||||||
|
export const selectedInstance = (): Instance | null => instances().find((i) => i.id === app.selected) ?? instances()[0] ?? null;
|
||||||
|
|
||||||
|
/** Resolve panel-relative media URLs ("/uploads/x.png"). */
|
||||||
|
export function abs(url: string | null | undefined): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith('/') && app.panelUrl) return app.panelUrl + url;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function theme() {
|
||||||
|
if (app.manifest) applyTheme(app.manifest.branding, app.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSelected() {
|
||||||
|
const list = instances();
|
||||||
|
const wanted = app.settings?.selected_instance;
|
||||||
|
app.selected = list.find((i) => i.id === wanted)?.id ?? list.find((i) => i.featured)?.id ?? list[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function init() {
|
||||||
|
const boot = await invoke<Bootstrap>('bootstrap');
|
||||||
|
app.boot = boot;
|
||||||
|
app.settings = boot.settings;
|
||||||
|
app.accounts = boot.accounts;
|
||||||
|
app.active = boot.active_account;
|
||||||
|
app.manifest = boot.manifest;
|
||||||
|
app.panelUrl = boot.panel_url;
|
||||||
|
app.running = boot.game_running;
|
||||||
|
theme();
|
||||||
|
pickSelected();
|
||||||
|
subscribe();
|
||||||
|
app.ready = true;
|
||||||
|
invoke('show_window').catch(() => {});
|
||||||
|
if (app.panelUrl) await refresh();
|
||||||
|
if (boot.settings.check_updates && boot.app.repo) {
|
||||||
|
invoke<UpdateInfo | null>('check_update').then((u) => (app.update = u)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
try {
|
||||||
|
app.manifest = await invoke<Manifest>('refresh_manifest');
|
||||||
|
app.offline = false;
|
||||||
|
theme();
|
||||||
|
pickSelected();
|
||||||
|
} catch (e) {
|
||||||
|
app.offline = true;
|
||||||
|
if (!app.manifest) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connect(url: string) {
|
||||||
|
app.manifest = await invoke<Manifest>('set_panel_url', { url });
|
||||||
|
const boot = await invoke<Bootstrap>('bootstrap');
|
||||||
|
app.panelUrl = boot.panel_url;
|
||||||
|
app.offline = false;
|
||||||
|
theme();
|
||||||
|
pickSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
/** Persist settings (debounced) and re-apply the theme immediately. */
|
||||||
|
export function saveSettings() {
|
||||||
|
theme();
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
if (app.settings) invoke('save_settings', { settings: $state.snapshot(app.settings) }).catch((e) => toast(errorText(e), 'error'));
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectInstance(id: string) {
|
||||||
|
app.selected = id;
|
||||||
|
if (app.settings) {
|
||||||
|
app.settings.selected_instance = id;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accountAdded(account: Account) {
|
||||||
|
const i = app.accounts.findIndex((a) => a.id === account.id);
|
||||||
|
if (i >= 0) app.accounts[i] = account;
|
||||||
|
else app.accounts.push(account);
|
||||||
|
app.active = account.id;
|
||||||
|
app.addAccount = false;
|
||||||
|
await refresh().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchAccount(id: string) {
|
||||||
|
await invoke('select_account', { id });
|
||||||
|
app.active = id;
|
||||||
|
await refresh().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAccount(id: string) {
|
||||||
|
await invoke('remove_account', { id });
|
||||||
|
app.accounts = app.accounts.filter((a) => a.id !== id);
|
||||||
|
if (app.active === id) app.active = app.accounts[0]?.id ?? null;
|
||||||
|
await refresh().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function play(id: string) {
|
||||||
|
app.launch = { ...idle(), state: 'preparing', label: 'Getting ready…' };
|
||||||
|
app.launchingId = id;
|
||||||
|
app.logs = [];
|
||||||
|
try {
|
||||||
|
await invoke('launch', { instanceId: id });
|
||||||
|
} catch (e) {
|
||||||
|
app.launch = { ...idle(), state: 'error', message: errorText(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repair(id: string) {
|
||||||
|
app.launch = { ...idle(), state: 'preparing', label: 'Verifying files…' };
|
||||||
|
app.launchingId = id;
|
||||||
|
try {
|
||||||
|
await invoke('repair_instance', { instanceId: id });
|
||||||
|
} catch (e) {
|
||||||
|
app.launch = { ...idle(), state: 'error', message: errorText(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancel() {
|
||||||
|
if (app.launchingId) await invoke('cancel_launch', { instanceId: app.launchingId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kill() {
|
||||||
|
await invoke('kill_game');
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscribed = false;
|
||||||
|
let last = { t: 0, done: 0, stage: '' as string };
|
||||||
|
|
||||||
|
function subscribe() {
|
||||||
|
if (subscribed) return;
|
||||||
|
subscribed = true;
|
||||||
|
|
||||||
|
listen<ProgressEvent>('launch://progress', (ev) => {
|
||||||
|
const l = app.launch;
|
||||||
|
if (ev.type === 'stage') {
|
||||||
|
l.stage = ev.stage;
|
||||||
|
l.label = ev.label;
|
||||||
|
l.done = l.total = l.filesDone = l.filesTotal = 0;
|
||||||
|
l.speed = 0;
|
||||||
|
} else if (ev.type === 'progress') {
|
||||||
|
const now = performance.now();
|
||||||
|
if (last.stage !== ev.stage || ev.done < last.done) last = { t: now, done: ev.done, stage: ev.stage };
|
||||||
|
else if (now - last.t > 500) {
|
||||||
|
const inst = ((ev.done - last.done) / (now - last.t)) * 1000;
|
||||||
|
l.speed = l.speed ? l.speed * 0.6 + inst * 0.4 : inst;
|
||||||
|
last = { t: now, done: ev.done, stage: ev.stage };
|
||||||
|
}
|
||||||
|
l.stage = ev.stage;
|
||||||
|
l.done = ev.done;
|
||||||
|
l.total = ev.total;
|
||||||
|
l.filesDone = ev.files_done;
|
||||||
|
l.filesTotal = ev.files_total;
|
||||||
|
} else if (ev.type === 'log') {
|
||||||
|
pushLogs([ev.line]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listen<{ instance_id: string; state: LaunchState['state']; message: string | null }>('launch://state', (ev) => {
|
||||||
|
if (ev.state === 'running') {
|
||||||
|
app.running = ev.instance_id;
|
||||||
|
app.launch = { ...idle(), state: 'running' };
|
||||||
|
if (app.settings?.show_console) app.consoleOpen = true;
|
||||||
|
} else if (ev.state === 'error') {
|
||||||
|
app.launch = { ...idle(), state: 'error', message: ev.message };
|
||||||
|
} else if (ev.state === 'idle') {
|
||||||
|
if (app.running === ev.instance_id) app.running = null;
|
||||||
|
if (app.launch.state !== 'error') app.launch = idle();
|
||||||
|
if (ev.message && ev.message !== 'Cancelled') toast(ev.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listen<string[]>('game://log', (lines) => pushLogs(lines));
|
||||||
|
|
||||||
|
listen<ExitInfo>('game://exit', (ev) => {
|
||||||
|
app.running = null;
|
||||||
|
if (ev.crashed) app.crash = ev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushLogs(lines: string[]) {
|
||||||
|
app.logs.push(...lines);
|
||||||
|
if (app.logs.length > 3000) app.logs.splice(0, app.logs.length - 3000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Thin wrapper over Tauri IPC. When the UI runs in a normal browser
|
||||||
|
// (vite dev / screenshots), calls go to an in-memory mock instead.
|
||||||
|
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
|
||||||
|
type Handler = (payload: any) => void;
|
||||||
|
const mockListeners = new Map<string, Set<Handler>>();
|
||||||
|
|
||||||
|
export function mockEmit(event: string, payload: unknown) {
|
||||||
|
mockListeners.get(event)?.forEach((h) => h(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
if (inTauri) return tauriInvoke<T>(cmd, args);
|
||||||
|
const { mockInvoke } = await import('./mock');
|
||||||
|
return mockInvoke(cmd, args ?? {}) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<UnlistenFn> {
|
||||||
|
if (inTauri) return tauriListen<T>(event, (e) => handler(e.payload));
|
||||||
|
const set = mockListeners.get(event) ?? new Set();
|
||||||
|
set.add(handler as Handler);
|
||||||
|
mockListeners.set(event, set);
|
||||||
|
return () => set.delete(handler as Handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function windowAction(action: 'minimize' | 'maximize' | 'close' | 'drag') {
|
||||||
|
if (!inTauri) return;
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
|
const w = getCurrentWindow();
|
||||||
|
if (action === 'minimize') await w.minimize();
|
||||||
|
else if (action === 'maximize') await w.toggleMaximize();
|
||||||
|
else if (action === 'close') await w.close();
|
||||||
|
else await w.startDragging();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openUrl(url: string) {
|
||||||
|
if (!inTauri) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
||||||
|
await openUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickFile(title: string, filters?: { name: string; extensions: string[] }[]): Promise<string | null> {
|
||||||
|
if (!inTauri) return null;
|
||||||
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
const r = await open({ title, multiple: false, directory: false, filters });
|
||||||
|
return typeof r === 'string' ? r : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorText(e: unknown): string {
|
||||||
|
return typeof e === 'string' ? e : e instanceof Error ? e.message : JSON.stringify(e);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Branding, Settings } from './types';
|
||||||
|
|
||||||
|
let styleEl: HTMLStyleElement | null = null;
|
||||||
|
|
||||||
|
/** Push the admin's branding (and the player's overrides) into CSS variables. */
|
||||||
|
export function applyTheme(b: Branding, s: Settings | null) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const custom = s?.theme_mode === 'custom' && b.features.allow_user_theme;
|
||||||
|
const accent = (custom && s?.accent) || b.colors.accent;
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
'--accent': accent,
|
||||||
|
'--accent-2': custom && s?.accent ? shift(accent) : b.colors.accent_2,
|
||||||
|
'--bg': b.colors.background,
|
||||||
|
'--surface': b.colors.surface,
|
||||||
|
'--text': b.colors.text,
|
||||||
|
'--muted': b.colors.muted,
|
||||||
|
'--success': b.colors.success,
|
||||||
|
'--danger': b.colors.danger,
|
||||||
|
'--radius': `${b.radius}px`,
|
||||||
|
'--radius-sm': `${Math.max(4, Math.round(b.radius * 0.65))}px`,
|
||||||
|
'--font': `'${b.font} Variable', '${b.font}', 'Outfit Variable', system-ui, sans-serif`,
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
|
||||||
|
const glass = s?.glass ?? b.glass;
|
||||||
|
root.classList.toggle('no-glass', !glass);
|
||||||
|
root.classList.toggle('reduce-motion', !!s?.reduce_motion);
|
||||||
|
root.style.fontSize = `${(s?.ui_scale ?? 100) * 0.15}px`;
|
||||||
|
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement('style');
|
||||||
|
styleEl.id = 'admin-css';
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
styleEl.textContent = b.custom_css ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A second accent derived from the first by rotating its hue. */
|
||||||
|
function shift(hex: string): string {
|
||||||
|
const m = hex.replace('#', '').match(/.{2}/g);
|
||||||
|
if (!m) return hex;
|
||||||
|
let [r, g, b] = m.map((x) => parseInt(x, 16) / 255);
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
|
let h = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
const d = max - min;
|
||||||
|
const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
||||||
|
if (d) {
|
||||||
|
if (max === r) h = ((g - b) / d) % 6;
|
||||||
|
else if (max === g) h = (b - r) / d + 2;
|
||||||
|
else h = (r - g) / d + 4;
|
||||||
|
}
|
||||||
|
h = (h * 60 + 50 + 360) % 360;
|
||||||
|
return `hsl(${h} ${Math.round(sat * 100)}% ${Math.round(l * 100)}%)`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
export type Loader = 'vanilla' | 'fabric' | 'quilt' | 'forge' | 'neoforge';
|
||||||
|
|
||||||
|
export interface ServerEntry { name: string; address: string; port: number; auto_join: boolean; inject: boolean }
|
||||||
|
|
||||||
|
export interface Instance {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon_url: string | null;
|
||||||
|
banner_url: string | null;
|
||||||
|
mc_version: string;
|
||||||
|
loader: Loader;
|
||||||
|
loader_version: string | null;
|
||||||
|
revision: number;
|
||||||
|
server: ServerEntry | null;
|
||||||
|
memory: { min_mb: number; max_mb: number };
|
||||||
|
jvm_args: string;
|
||||||
|
featured: boolean;
|
||||||
|
source_label: string;
|
||||||
|
file_count: number;
|
||||||
|
total_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewsItem { id: string; title: string; body: string; image_url: string | null; link: string | null; date: string | null; pinned: boolean; tag: string | null }
|
||||||
|
|
||||||
|
export interface Branding {
|
||||||
|
name: string;
|
||||||
|
tagline: string;
|
||||||
|
logo_url: string | null;
|
||||||
|
icon_url: string | null;
|
||||||
|
background: { kind: 'gradient' | 'image' | 'video'; url: string | null; blur: number; dim: number };
|
||||||
|
colors: { accent: string; accent_2: string; background: string; surface: string; text: string; muted: string; success: string; danger: string };
|
||||||
|
font: string;
|
||||||
|
radius: number;
|
||||||
|
glass: boolean;
|
||||||
|
version_label: string;
|
||||||
|
news: NewsItem[];
|
||||||
|
links: { label: string; url: string; icon: string }[];
|
||||||
|
features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean };
|
||||||
|
custom_css: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthConfig {
|
||||||
|
panel_accounts: boolean;
|
||||||
|
registration: 'closed' | 'open' | 'approval';
|
||||||
|
microsoft: boolean;
|
||||||
|
microsoft_client_id: string | null;
|
||||||
|
offline_local: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Manifest {
|
||||||
|
api_version: number;
|
||||||
|
panel_version: string;
|
||||||
|
branding: Branding;
|
||||||
|
auth: AuthConfig;
|
||||||
|
instances: Instance[];
|
||||||
|
user: { id: number; username: string; uuid: string; role: string; groups: string[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Account { id: string; kind: 'panel' | 'microsoft' | 'offline'; username: string; uuid: string; panel_url: string | null; role: string | null }
|
||||||
|
|
||||||
|
export type Gc = 'default' | 'g1' | 'zgc';
|
||||||
|
|
||||||
|
export interface InstanceOverride { memory_max_mb: number; memory_min_mb: number; jvm_args: string; java_path: string | null }
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
panel_url: string | null;
|
||||||
|
selected_instance: string | null;
|
||||||
|
memory_max_mb: number;
|
||||||
|
memory_min_mb: number;
|
||||||
|
custom_resolution: boolean;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fullscreen: boolean;
|
||||||
|
after_launch: 'minimize' | 'hide' | 'keep' | 'close';
|
||||||
|
show_console: boolean;
|
||||||
|
java_path: string | null;
|
||||||
|
gc: Gc;
|
||||||
|
jvm_args: string;
|
||||||
|
theme_mode: 'server' | 'custom';
|
||||||
|
accent: string | null;
|
||||||
|
background_video: boolean;
|
||||||
|
reduce_motion: boolean;
|
||||||
|
glass: boolean | null;
|
||||||
|
ui_scale: number;
|
||||||
|
concurrent_downloads: number;
|
||||||
|
check_updates: boolean;
|
||||||
|
send_stats: boolean;
|
||||||
|
keybinds_enabled: boolean;
|
||||||
|
keybinds: Record<string, string>;
|
||||||
|
instance_overrides: Record<string, InstanceOverride>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bootstrap {
|
||||||
|
app: { version: string; default_panel_url: string | null; panel_locked: boolean; repo: string | null; os: string; arch: string; total_ram_mb: number; data_dir: string };
|
||||||
|
panel_url: string | null;
|
||||||
|
settings: Settings;
|
||||||
|
accounts: Account[];
|
||||||
|
active_account: string | null;
|
||||||
|
manifest: Manifest | null;
|
||||||
|
game_running: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Stage = 'preparing' | 'java' | 'game' | 'loader' | 'files' | 'launching';
|
||||||
|
export type ProgressEvent =
|
||||||
|
| { type: 'stage'; stage: Stage; label: string }
|
||||||
|
| { type: 'progress'; stage: Stage; done: number; total: number; files_done: number; files_total: number }
|
||||||
|
| { type: 'log'; line: string };
|
||||||
|
|
||||||
|
export interface ServerStatus { online: boolean; version: string; players_online: number; players_max: number; sample: string[]; motd: string; favicon: string | null; latency_ms: number }
|
||||||
|
export interface DeviceCode { user_code: string; verification_uri: string; expires_in: number; interval: number; message: string }
|
||||||
|
export interface UpdateInfo { version: string; notes: string; url: string; size: number }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import '@fontsource-variable/outfit';
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
|
import '@fontsource-variable/space-grotesk';
|
||||||
|
import '@fontsource-variable/plus-jakarta-sans';
|
||||||
|
import '@fontsource-variable/jetbrains-mono';
|
||||||
|
import './app.css';
|
||||||
|
import { mount } from 'svelte';
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
// The launcher is a desktop app: no browser context menu or text-drag.
|
||||||
|
document.addEventListener('contextmenu', (e) => {
|
||||||
|
if (!(e.target as HTMLElement).closest('input, textarea, .selectable')) e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
mount(App, { target: document.getElementById('app')! });
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SlidersHorizontal, SquareTerminal, Package, MessageCircle, Globe, ShoppingBag, CirclePlay, Tv, Code, AtSign, Link } from '@lucide/svelte';
|
||||||
|
import PlayButton from '../components/PlayButton.svelte';
|
||||||
|
import ServerStatus from '../components/ServerStatus.svelte';
|
||||||
|
import NewsFeed from '../components/NewsFeed.svelte';
|
||||||
|
import Console from '../components/Console.svelte';
|
||||||
|
import { activeAccount, app, instances, selectedInstance } from '../lib/store.svelte';
|
||||||
|
import { loaderName, shortLoaderVersion } from '../lib/format';
|
||||||
|
import { openUrl } from '../lib/tauri';
|
||||||
|
|
||||||
|
const inst = $derived(selectedInstance());
|
||||||
|
const b = $derived(app.manifest!.branding);
|
||||||
|
// Lucide ships no brand logos, so each platform gets a recognisable generic icon.
|
||||||
|
const linkIcon: Record<string, any> = { discord: MessageCircle, website: Globe, store: ShoppingBag, youtube: CirclePlay, twitch: Tv, github: Code, twitter: AtSign };
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const greet = hour < 5 ? 'Late night session' : hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="home">
|
||||||
|
{#if inst}
|
||||||
|
{#key inst.id}
|
||||||
|
<section class="hero">
|
||||||
|
<p class="greet muted">{greet}, <strong>{activeAccount()?.username ?? 'player'}</strong></p>
|
||||||
|
<div class="chips">
|
||||||
|
<span class="chip">{inst.mc_version}</span>
|
||||||
|
<span class="chip">{loaderName[inst.loader]} {shortLoaderVersion(inst.mc_version, inst.loader_version)}</span>
|
||||||
|
{#if inst.file_count}<span class="chip"><Package size={12} /> {inst.file_count} mods & files</span>{/if}
|
||||||
|
</div>
|
||||||
|
<h1>{inst.name}</h1>
|
||||||
|
<p class="desc">{inst.description || b.tagline}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="actions">
|
||||||
|
<PlayButton {inst} />
|
||||||
|
<div class="row tools">
|
||||||
|
<button class="glass tool" onclick={() => (app.instanceSettings = inst.id)} title="Instance settings"><SlidersHorizontal size={17} /></button>
|
||||||
|
<button class="glass tool" class:on={app.consoleOpen} onclick={() => (app.consoleOpen = !app.consoleOpen)} title="Game console"><SquareTerminal size={17} /></button>
|
||||||
|
{#if inst.server && b.features.server_status}<ServerStatus server={inst.server} />{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/key}
|
||||||
|
{:else}
|
||||||
|
<section class="hero empty">
|
||||||
|
<h1>{b.name}</h1>
|
||||||
|
<p class="desc">{instances().length === 0 ? "Nothing to play yet — your server admin hasn't published any instances you can access." : ''}</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<aside class="side">
|
||||||
|
{#if b.features.news && b.news.length}<NewsFeed news={b.news} />{:else}<div></div>{/if}
|
||||||
|
{#if b.links.length}
|
||||||
|
<div class="links">
|
||||||
|
{#each b.links as l}
|
||||||
|
{@const Icon = linkIcon[l.icon] ?? Link}
|
||||||
|
<button class="glass link" onclick={() => openUrl(l.url)} title={l.label}><Icon size={16} /> <span>{l.label}</span></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{#if app.consoleOpen}<Console />{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.home { position: relative; height: 100%; display: grid; grid-template-columns: 1fr minmax(17rem, 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default { preprocess: vitePreprocess() };
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||