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

+343
View File
@@ -0,0 +1,343 @@
//! Everything the UI can ask the backend to do.
use crate::accounts::{self, Account};
use crate::game;
use crate::settings::Settings;
use crate::state::{build, AppState};
use crate::updater;
use scopenet_core::msa::DeviceCode;
use scopenet_core::ping::ServerStatus;
use scopenet_shared::LauncherManifest;
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use tauri_plugin_opener::OpenerExt;
/// Commands return `Err(String)`; the UI shows it as-is.
type Res<T> = Result<T, String>;
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
/// anyhow's alternate format keeps the context chain readable.
fn aerr(e: anyhow::Error) -> String {
format!("{e:#}")
}
#[derive(Serialize)]
pub struct AppInfo {
version: String,
default_panel_url: Option<String>,
panel_locked: bool,
repo: Option<String>,
os: String,
arch: String,
total_ram_mb: u64,
data_dir: String,
}
#[derive(Serialize)]
pub struct Bootstrap {
app: AppInfo,
panel_url: Option<String>,
settings: Settings,
accounts: Vec<Account>,
active_account: Option<String>,
manifest: Option<LauncherManifest>,
game_running: Option<String>,
}
#[tauri::command]
pub fn bootstrap(state: State<'_, AppState>) -> Bootstrap {
let accounts = state.accounts.read().unwrap().clone();
Bootstrap {
app: AppInfo {
version: env!("CARGO_PKG_VERSION").into(),
default_panel_url: build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from),
panel_locked: build::panel_locked(),
repo: build::REPO.map(String::from),
os: std::env::consts::OS.into(),
arch: std::env::consts::ARCH.into(),
total_ram_mb: scopenet_core::sys::total_memory_mb(),
data_dir: state.data_dir.to_string_lossy().into_owned(),
},
panel_url: state.panel_url(),
settings: state.settings.read().unwrap().clone(),
accounts: accounts.accounts,
active_account: accounts.active,
manifest: state.manifest.read().unwrap().clone(),
game_running: state.game.lock().unwrap().as_ref().map(|g| g.instance_id.clone()),
}
}
async fn fetch_manifest(state: &AppState, panel: &str) -> anyhow::Result<LauncherManifest> {
let mut req = state.http.get(format!("{panel}/api/v1/launcher/manifest"));
if let Some(t) = accounts::panel_token(state) {
req = req.bearer_auth(t);
}
let resp = req.send().await.map_err(|e| anyhow::anyhow!("couldn't reach {panel}: {e}"))?;
if !resp.status().is_success() {
anyhow::bail!("{panel} returned {}", resp.status());
}
let m: LauncherManifest = resp.json().await.map_err(|_| anyhow::anyhow!("{panel} doesn't look like a ScopeNet panel"))?;
Ok(m)
}
#[tauri::command]
pub async fn refresh_manifest(state: State<'_, AppState>) -> Res<LauncherManifest> {
let panel = state.panel_url().ok_or("no panel configured")?;
let m = fetch_manifest(&state, &panel).await.map_err(aerr)?;
state.cache_manifest(&m);
Ok(m)
}
#[tauri::command]
pub async fn set_panel_url(state: State<'_, AppState>, url: String) -> Res<LauncherManifest> {
if build::panel_locked() {
return Err("this launcher is locked to its server".into());
}
let mut url = url.trim().trim_end_matches('/').to_string();
if !url.starts_with("http://") && !url.starts_with("https://") {
url = format!("https://{url}");
}
let m = fetch_manifest(&state, &url).await.map_err(aerr)?;
state.settings.write().unwrap().panel_url = Some(url);
state.save_settings().map_err(aerr)?;
state.cache_manifest(&m);
Ok(m)
}
#[tauri::command]
pub fn save_settings(state: State<'_, AppState>, settings: Settings) -> Res<()> {
let mut current = state.settings.write().unwrap();
let panel = current.panel_url.clone();
*current = Settings { panel_url: panel, ..settings };
drop(current);
state.save_settings().map_err(aerr)
}
// ---- accounts ----
#[tauri::command]
pub async fn login_panel(state: State<'_, AppState>, username: String, password: String) -> Res<Account> {
accounts::login_panel(&state, &username, &password).await.map_err(aerr)
}
#[derive(Serialize)]
pub struct RegisterResult {
account: Option<Account>,
pending: bool,
}
#[tauri::command]
pub async fn register_panel(state: State<'_, AppState>, username: String, password: String, email: Option<String>) -> Res<RegisterResult> {
let (account, pending) = accounts::register_panel(&state, &username, &password, email).await.map_err(aerr)?;
Ok(RegisterResult { account, pending })
}
#[tauri::command]
pub fn add_offline(state: State<'_, AppState>, username: String) -> Res<Account> {
accounts::add_offline(&state, &username).map_err(aerr)
}
#[tauri::command]
pub async fn ms_start(state: State<'_, AppState>) -> Res<DeviceCode> {
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
let code = scopenet_core::msa::start_device_code(&state.http, &client_id).await.map_err(aerr)?;
*state.device_code.lock().unwrap() = Some(code.clone());
Ok(code)
}
#[tauri::command]
pub async fn ms_finish(state: State<'_, AppState>) -> Res<Account> {
let client_id = accounts::ms_client_id(&state).map_err(aerr)?;
let code = state.device_code.lock().unwrap().clone().ok_or("start the sign-in first")?;
let (tx, rx) = tokio::sync::oneshot::channel();
*state.ms_cancel.lock().unwrap() = Some(tx);
let session = tokio::select! {
r = scopenet_core::msa::finish_device_code(&state.http, &client_id, &code) => r.map_err(aerr)?,
_ = rx => return Err("cancelled".into()),
};
*state.device_code.lock().unwrap() = None;
accounts::save_ms_session(&state, session).map_err(aerr)
}
#[tauri::command]
pub fn ms_cancel(state: State<'_, AppState>) {
if let Some(tx) = state.ms_cancel.lock().unwrap().take() {
tx.send(()).ok();
}
}
#[tauri::command]
pub fn select_account(state: State<'_, AppState>, id: String) -> Res<()> {
let mut accounts = state.accounts.write().unwrap();
if !accounts.accounts.iter().any(|a| a.id == id) {
return Err("account not found".into());
}
accounts.active = Some(id);
drop(accounts);
state.save_accounts().map_err(aerr)
}
#[tauri::command]
pub fn remove_account(state: State<'_, AppState>, id: String) -> Res<()> {
let mut accounts = state.accounts.write().unwrap();
accounts.accounts.retain(|a| a.id != id);
if accounts.active.as_deref() == Some(id.as_str()) {
accounts.active = accounts.accounts.first().map(|a| a.id.clone());
}
drop(accounts);
state.secrets.remove(&id).map_err(aerr)?;
state.save_accounts().map_err(aerr)
}
// ---- game ----
#[tauri::command]
pub fn launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
start_task(app, &state, instance_id, true, false)
}
#[tauri::command]
pub fn repair_instance(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
start_task(app, &state, instance_id, false, true)
}
fn start_task(app: AppHandle, state: &AppState, instance_id: String, start: bool, deep: bool) -> Res<()> {
if state.game.lock().unwrap().is_some() {
return Err("Minecraft is already running".into());
}
let mut task = state.task.lock().unwrap();
if task.as_ref().is_some_and(|t| !t.is_finished()) {
return Err("already preparing an instance".into());
}
let app2 = app.clone();
let handle = tauri::async_runtime::spawn(async move {
if let Err(e) = game::run(app2.clone(), instance_id.clone(), start, deep).await {
tracing::error!("launch failed: {e:#}");
game::emit_state(&app2, &instance_id, "error", Some(format!("{e:#}")));
}
});
*task = Some(handle.inner().abort_handle());
Ok(())
}
#[tauri::command]
pub fn cancel_launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) {
if let Some(t) = state.task.lock().unwrap().take() {
t.abort();
}
game::emit_state(&app, &instance_id, "idle", Some("Cancelled".into()));
}
#[tauri::command]
pub fn kill_game(state: State<'_, AppState>) {
if let Some(g) = state.game.lock().unwrap().take() {
g.kill.send(()).ok();
}
}
#[derive(Serialize)]
pub struct InstanceLocal {
installed: bool,
revision: i64,
size: u64,
}
#[tauri::command]
pub async fn instance_local(state: State<'_, AppState>, instance_id: String) -> Res<InstanceLocal> {
let dir = state.layout.instance_dir(&instance_id);
let s = scopenet_core::sync::load_state(&dir);
let size = tokio::task::spawn_blocking(move || scopenet_core::sys::dir_size(&dir)).await.unwrap_or(0);
Ok(InstanceLocal { installed: s.revision > 0, revision: s.revision, size })
}
#[tauri::command]
pub async fn ping_server(host: String, port: u16) -> Res<ServerStatus> {
scopenet_core::ping::ping(&host, port).await.map_err(aerr)
}
#[tauri::command]
pub fn open_folder(app: AppHandle, state: State<'_, AppState>, kind: String, instance_id: Option<String>) -> Res<()> {
let base = match &instance_id {
Some(id) => state.layout.instance_dir(id),
None => state.data_dir.clone(),
};
let path = match kind.as_str() {
"instance" | "data" => base,
"logs" | "screenshots" | "mods" | "resourcepacks" | "shaderpacks" | "saves" | "crash-reports" => base.join(&kind),
_ => return Err("unknown folder".into()),
};
std::fs::create_dir_all(&path).map_err(err)?;
app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(err)
}
#[tauri::command]
pub fn open_file(app: AppHandle, path: String) -> Res<()> {
app.opener().open_path(path, None::<&str>).map_err(err)
}
#[derive(Serialize)]
pub struct Storage {
shared: u64,
runtimes: u64,
instances: Vec<(String, u64)>,
}
#[tauri::command]
pub async fn storage_info(state: State<'_, AppState>) -> Res<Storage> {
let layout = state.layout.clone();
tokio::task::spawn_blocking(move || {
use scopenet_core::sys::dir_size;
let shared = dir_size(&layout.libraries()) + dir_size(&layout.assets()) + dir_size(&layout.versions());
let runtimes = dir_size(&layout.runtimes());
let instances = std::fs::read_dir(layout.instances())
.map(|rd| rd.filter_map(|e| e.ok()).map(|e| (e.file_name().to_string_lossy().into_owned(), dir_size(&e.path()))).collect())
.unwrap_or_default();
Storage { shared, runtimes, instances }
})
.await
.map_err(err)
}
#[tauri::command]
pub async fn delete_instance_data(state: State<'_, AppState>, instance_id: String) -> Res<()> {
if state.game.lock().unwrap().as_ref().is_some_and(|g| g.instance_id == instance_id) {
return Err("close the game first".into());
}
let dir = state.layout.instance_dir(&instance_id);
tokio::fs::remove_dir_all(dir).await.or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { Ok(()) } else { Err(e) }).map_err(err)
}
#[tauri::command]
pub async fn clear_cache(state: State<'_, AppState>) -> Res<()> {
let cache = state.layout.cache();
tokio::fs::remove_dir_all(cache).await.ok();
Ok(())
}
#[tauri::command]
pub async fn detect_java(path: String) -> Option<u32> {
scopenet_core::java::detect_major(std::path::Path::new(&path)).await
}
#[tauri::command]
pub async fn check_update(state: State<'_, AppState>) -> Res<Option<updater::UpdateInfo>> {
updater::check(&state.http).await.map_err(aerr)
}
#[tauri::command]
pub async fn install_update(app: AppHandle, state: State<'_, AppState>, url: String) -> Res<()> {
updater::download_and_run(&state.http, &url).await.map_err(aerr)?;
app.exit(0);
Ok(())
}
#[tauri::command]
pub fn show_window(app: AppHandle) {
if let Some(w) = app.get_webview_window("main") {
w.show().ok();
w.set_focus().ok();
}
}