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:
60 files changed
+9424
-47
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user