//! 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::ping::ServerStatus; use scopenet_shared::{LauncherManifest, PlayerProfile, SkinProfile}; 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 = Result; #[tauri::command] pub async fn record_activity(state: State<'_, AppState>, kind: String, instance_id: Option) -> Res<()> { crate::telemetry::send(&state, &kind, instance_id.as_deref().unwrap_or("")).await.map_err(aerr) } 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, panel_locked: bool, repo: Option, os: String, arch: String, total_ram_mb: u64, data_dir: String, } #[derive(Serialize)] pub struct Bootstrap { app: AppInfo, panel_url: Option, settings: Settings, accounts: Vec, active_account: Option, manifest: Option, game_running: Option, } #[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 { 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 { 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 { 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 { accounts::login_panel(&state, &username, &password).await.map_err(aerr) } #[derive(Serialize)] pub struct RegisterResult { account: Option, pending: bool, } #[tauri::command] pub async fn register_panel(state: State<'_, AppState>, username: String, password: String, email: Option) -> Res { 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 { accounts::add_offline(&state, &username).map_err(aerr) } #[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<()> { crate::telemetry::background(&state, "logout", ""); 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) } // ---- skin & cape (panel accounts) ---- async fn account_api(state: &AppState, method: reqwest::Method, path: &str) -> Res { let panel = state.panel_url().ok_or("no panel configured")?; let token = accounts::panel_token(state).ok_or("sign in with a server account to change your skin")?; Ok(state.http.request(method, format!("{panel}/api/v1{path}")).bearer_auth(token)) } async fn profile_response(resp: reqwest::Response) -> Res { if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("the server refused the change").to_string()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn account_profile(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::GET, "/account/profile").await?; profile_response(req.send().await.map_err(err)?).await } /// `data` is the PNG as base64 (read in the UI from a file picker). #[tauri::command] pub async fn upload_skin(state: State<'_, AppState>, data: String, model: String) -> Res { use base64::Engine; let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|_| "couldn't read that image".to_string())?; let form = reqwest::multipart::Form::new() .text("model", model) .part("file", reqwest::multipart::Part::bytes(bytes).file_name("skin.png").mime_str("image/png").map_err(err)?); let req = account_api(&state, reqwest::Method::POST, "/account/skin").await?; profile_response(req.multipart(form).send().await.map_err(err)?).await } #[tauri::command] pub async fn set_skin_model(state: State<'_, AppState>, model: String) -> Res { let req = account_api(&state, reqwest::Method::PUT, "/account/skin/model").await?; profile_response(req.json(&serde_json::json!({ "model": model })).send().await.map_err(err)?).await } #[tauri::command] pub async fn delete_skin(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::DELETE, "/account/skin").await?; profile_response(req.send().await.map_err(err)?).await } #[tauri::command] pub async fn set_cape(state: State<'_, AppState>, cape_id: Option) -> Res { let req = account_api(&state, reqwest::Method::PUT, "/account/cape").await?; profile_response(req.json(&serde_json::json!({ "cape_id": cape_id })).send().await.map_err(err)?).await } fn load_skin_profiles(data_dir: &std::path::Path) -> Vec { let path = data_dir.join("skin_profiles.json"); std::fs::read(&path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default() } fn write_skin_profiles(data_dir: &std::path::Path, profiles: &[SkinProfile]) -> anyhow::Result<()> { let path = data_dir.join("skin_profiles.json"); let json = serde_json::to_vec_pretty(profiles)?; std::fs::write(path, json)?; Ok(()) } #[tauri::command] pub fn get_skin_profiles(state: State<'_, AppState>) -> Res> { Ok(load_skin_profiles(&state.data_dir)) } #[tauri::command] pub async fn save_skin_profile(state: State<'_, AppState>, mut profile: SkinProfile) -> Res> { use base64::Engine; if let Some(ref mut data) = profile.skin_data { if data.starts_with('/') { if let Some(panel) = state.panel_url() { *data = format!("{panel}{data}"); } } if data.starts_with("http://") || data.starts_with("https://") { if let Ok(resp) = state.http.get(&*data).send().await { if resp.status().is_success() { if let Ok(bytes) = resp.bytes().await { let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); *data = format!("data:image/png;base64,{b64}"); } } } } else if !data.starts_with("data:") && !data.is_empty() { *data = format!("data:image/png;base64,{data}"); } } if profile.created_at.is_empty() { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs().to_string()) .unwrap_or_default(); profile.created_at = ts; } let mut profiles = load_skin_profiles(&state.data_dir); if let Some(idx) = profiles.iter().position(|x| x.id == profile.id) { profiles[idx] = profile; } else { profiles.push(profile); } write_skin_profiles(&state.data_dir, &profiles).map_err(aerr)?; Ok(profiles) } #[tauri::command] pub fn delete_skin_profile(state: State<'_, AppState>, id: String) -> Res> { let mut profiles = load_skin_profiles(&state.data_dir); profiles.retain(|x| x.id != id); write_skin_profiles(&state.data_dir, &profiles).map_err(aerr)?; Ok(profiles) } #[tauri::command] pub async fn apply_skin_profile(state: State<'_, AppState>, id: String) -> Res { use base64::Engine; let profiles = load_skin_profiles(&state.data_dir); let p = profiles.into_iter().find(|x| x.id == id).ok_or_else(|| "profile not found".to_string())?; if let Some(skin_data) = p.skin_data { if !skin_data.is_empty() { let bytes = if skin_data.starts_with("data:") { let comma = skin_data.find(',').ok_or_else(|| "invalid data uri".to_string())?; base64::engine::general_purpose::STANDARD.decode(skin_data[comma + 1..].trim()).map_err(err)? } else if skin_data.starts_with("http://") || skin_data.starts_with("https://") { state.http.get(&skin_data).send().await.map_err(err)?.bytes().await.map_err(err)?.to_vec() } else { base64::engine::general_purpose::STANDARD.decode(skin_data.trim()).map_err(err)? }; let form = reqwest::multipart::Form::new() .text("model", p.skin_model.clone()) .part("file", reqwest::multipart::Part::bytes(bytes).file_name("skin.png").mime_str("image/png").map_err(err)?); let req = account_api(&state, reqwest::Method::POST, "/account/skin").await?; profile_response(req.multipart(form).send().await.map_err(err)?).await?; } else { let req = account_api(&state, reqwest::Method::PUT, "/account/skin/model").await?; profile_response(req.json(&serde_json::json!({ "model": p.skin_model })).send().await.map_err(err)?).await?; } } else { let req = account_api(&state, reqwest::Method::PUT, "/account/skin/model").await?; profile_response(req.json(&serde_json::json!({ "model": p.skin_model })).send().await.map_err(err)?).await?; } let req = account_api(&state, reqwest::Method::PUT, "/account/cape").await?; profile_response(req.json(&serde_json::json!({ "cape_id": p.cape_id })).send().await.map_err(err)?).await?; account_profile(state).await } // ---- game ---- #[tauri::command] pub async fn set_username(state: State<'_, AppState>, username: String, password: String) -> Res { accounts::rename_panel(&state, &username, &password).await.map_err(aerr) } #[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 { crate::telemetry::background(&app2.state::(), "launch_failed", &instance_id); 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 { 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 { 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) -> 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 { 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 { scopenet_core::java::detect_major(std::path::Path::new(&path)).await } #[tauri::command] pub async fn check_update(state: State<'_, AppState>) -> Res> { 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(); } }