Add Tauri launcher with Svelte UI

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

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

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
//! Player accounts: panel (username/password), Microsoft, and local offline.
use crate::secrets::Secret;
use crate::state::AppState;
use anyhow::{anyhow, bail, Context, Result};
use scopenet_shared::{offline_uuid, valid_username, AuthResponse, LoginRequest, RegisterRequest};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Account {
pub id: String,
/// "panel" | "microsoft" | "offline"
pub kind: String,
pub username: String,
pub uuid: String,
/// Panel accounts: which panel they belong to.
#[serde(default)]
pub panel_url: Option<String>,
#[serde(default)]
pub role: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AccountsFile {
pub accounts: Vec<Account>,
pub active: Option<String>,
}
impl AccountsFile {
pub fn load(path: &Path) -> Self {
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
pub fn save(&self, path: &Path) -> Result<()> {
std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
Ok(())
}
pub fn active(&self) -> Option<&Account> {
let id = self.active.as_ref()?;
self.accounts.iter().find(|a| &a.id == id)
}
/// Add or replace (same kind + uuid + panel) and make it active.
pub fn upsert(&mut self, account: Account) -> Account {
if let Some(existing) = self
.accounts
.iter_mut()
.find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
{
let id = existing.id.clone();
*existing = Account { id: id.clone(), ..account };
self.active = Some(id);
return existing.clone();
}
self.active = Some(account.id.clone());
self.accounts.push(account.clone());
account
}
}
fn now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
}
async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body: serde_json::Value = resp.json().await.unwrap_or_default();
anyhow!("{}", body.get("error").and_then(|e| e.as_str()).map(String::from).unwrap_or_else(|| format!("panel returned {status}")))
}
pub async fn login_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
let resp = state
.http
.post(format!("{panel}/api/v1/auth/login"))
.json(&LoginRequest { username: username.trim().into(), password: password.into() })
.send()
.await
.context("couldn't reach the server")?;
if !resp.status().is_success() {
return Err(panel_error(resp).await);
}
let auth: AuthResponse = resp.json().await?;
save_panel_account(state, &panel, auth)
}
pub async fn register_panel(state: &AppState, username: &str, password: &str, email: Option<String>) -> Result<(Option<Account>, bool)> {
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
let resp = state
.http
.post(format!("{panel}/api/v1/auth/register"))
.json(&RegisterRequest { username: username.trim().into(), password: password.into(), email })
.send()
.await
.context("couldn't reach the server")?;
if !resp.status().is_success() {
return Err(panel_error(resp).await);
}
let auth: AuthResponse = resp.json().await?;
if auth.pending {
return Ok((None, true));
}
Ok((Some(save_panel_account(state, &panel, auth)?), false))
}
fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "panel".into(),
username: auth.user.username.clone(),
uuid: auth.user.uuid.clone(),
panel_url: Some(panel.to_string()),
role: Some(auth.user.role.clone()),
};
let account = state.accounts.write().unwrap().upsert(account);
state.secrets.set(&account.id, Secret { panel_token: Some(auth.token), ..Default::default() })?;
state.save_accounts()?;
Ok(account)
}
pub fn add_offline(state: &AppState, username: &str) -> Result<Account> {
let allowed = state.manifest.read().unwrap().as_ref().map(|m| m.auth.offline_local).unwrap_or(true);
if !allowed {
bail!("offline accounts are disabled on this server");
}
let name = username.trim();
if !valid_username(name) {
bail!("usernames are 3–16 letters, numbers or underscores");
}
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "offline".into(),
username: name.into(),
uuid: offline_uuid(name),
panel_url: None,
role: None,
};
let account = state.accounts.write().unwrap().upsert(account);
state.save_accounts()?;
Ok(account)
}
pub fn ms_client_id(state: &AppState) -> Result<String> {
state
.manifest
.read()
.unwrap()
.as_ref()
.and_then(|m| m.auth.microsoft.then(|| m.auth.microsoft_client_id.clone()).flatten())
.filter(|c| !c.is_empty())
.ok_or_else(|| anyhow!("Microsoft sign-in isn't enabled on this server"))
}
pub fn save_ms_session(state: &AppState, session: scopenet_core::msa::MsaSession) -> Result<Account> {
let uuid = dashed(&session.profile.id);
let account = Account {
id: uuid::Uuid::new_v4().to_string(),
kind: "microsoft".into(),
username: session.profile.name.clone(),
uuid,
panel_url: None,
role: None,
};
let account = state.accounts.write().unwrap().upsert(account);
state.secrets.set(
&account.id,
Secret {
ms_refresh_token: Some(session.refresh_token),
mc_access_token: Some(session.mc_access_token),
mc_expires_at: session.mc_expires_at,
xuid: session.xuid,
..Default::default()
},
)?;
state.save_accounts()?;
Ok(account)
}
fn dashed(id: &str) -> String {
let id = id.replace('-', "");
if id.len() != 32 {
return id;
}
format!("{}-{}-{}-{}-{}", &id[0..8], &id[8..12], &id[12..16], &id[16..20], &id[20..32])
}
/// Credentials passed to the game.
pub async fn game_auth(state: &AppState, account: &Account) -> Result<scopenet_core::launch::Auth> {
use scopenet_core::launch::Auth;
match account.kind.as_str() {
"microsoft" => {
let mut secret = state.secrets.get(&account.id);
if secret.mc_access_token.is_none() || secret.mc_expires_at < now() + 300 {
let refresh = secret.ms_refresh_token.clone().ok_or_else(|| anyhow!("please sign in to Microsoft again"))?;
let client_id = ms_client_id(state)?;
let session = scopenet_core::msa::refresh(&state.http, &client_id, &refresh).await?;
if session.profile.name != account.username {
let mut accounts = state.accounts.write().unwrap();
if let Some(a) = accounts.accounts.iter_mut().find(|a| a.id == account.id) {
a.username = session.profile.name.clone();
}
}
state.save_accounts().ok();
secret = Secret {
ms_refresh_token: Some(session.refresh_token),
mc_access_token: Some(session.mc_access_token),
mc_expires_at: session.mc_expires_at,
xuid: session.xuid,
..secret
};
state.secrets.set(&account.id, secret.clone())?;
}
let active_name = state.accounts.read().unwrap().accounts.iter().find(|a| a.id == account.id).map(|a| a.username.clone());
Ok(Auth {
username: active_name.unwrap_or_else(|| account.username.clone()),
uuid: account.uuid.clone(),
access_token: secret.mc_access_token.unwrap_or_default(),
user_type: "msa".into(),
xuid: secret.xuid,
})
}
_ => Ok(Auth {
username: account.username.clone(),
uuid: account.uuid.clone(),
// Offline mode: any token works; servers in offline mode ignore it.
access_token: "0".into(),
user_type: "legacy".into(),
xuid: None,
}),
}
}
/// Panel token for requests, when the active account belongs to this panel.
pub fn panel_token(state: &AppState) -> Option<String> {
let panel = state.panel_url()?;
let accounts = state.accounts.read().unwrap();
let active = accounts.active()?;
if active.kind != "panel" || active.panel_url.as_deref() != Some(panel.as_str()) {
return None;
}
state.secrets.get(&active.id).panel_token
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upsert_replaces_same_identity() {
let mut f = AccountsFile::default();
let a = Account { id: "1".into(), kind: "offline".into(), username: "Steve".into(), uuid: "u".into(), panel_url: None, role: None };
f.upsert(a.clone());
f.upsert(Account { id: "2".into(), ..a.clone() });
assert_eq!(f.accounts.len(), 1);
assert_eq!(f.active.as_deref(), Some("1"));
assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
}
}