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,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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user