//! 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::{ Achievement, BaltopEntry, DirectMessage, EconomyTransaction, FriendInfo, GameInvite, Guild, GuildClaim, GuildMember, GuildPost, LauncherManifest, MemberProfile, PlayerProfile, ServerEconomyBalance, SkinProfile, UserLevelInfo, UserPost, UserProfileView, UserQuest, }; 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: Vec, } #[derive(Serialize)] struct RunningGameInfo { run_id: String, instance_id: 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() .iter() .map(|g| RunningGameInfo { run_id: g.run_id.clone(), instance_id: g.instance_id.clone() }) .collect(), } } 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(); let captured = current.instance_game_options.clone(); *current = Settings { panel_url: panel, instance_game_options: captured, ..settings }; drop(current); state.save_settings().map_err(aerr) } #[tauri::command] pub fn save_instance_options(state: State<'_, AppState>, instance_id: String) -> Res> { if state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id) { return Err("close this instance before saving its game settings".into()); } let options = scopenet_core::options::read_vanilla_preferences(&state.layout.instance_dir(&instance_id)).map_err(aerr)?; if options.is_empty() { return Err("no vanilla options.txt settings found yet".into()); } state.settings.write().unwrap().instance_game_options.insert(instance_id, options.clone()); state.save_settings().map_err(aerr)?; Ok(options) } #[tauri::command] pub fn set_instance_fov(state: State<'_, AppState>, instance_id: String, fov: f64) -> Res<()> { if !(-1.0..=1.0).contains(&fov) || !fov.is_finite() { return Err("FOV must be between 30 and 110 degrees".into()); } state.settings.write().unwrap().instance_game_options.entry(instance_id).or_default().insert("fov".into(), format!("{fov:.3}")); 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) } #[tauri::command] pub async fn discord_sign_in_start(state: State<'_, AppState>) -> Res { let panel = state.panel_url().ok_or("no panel configured")?; let response = state.http.get(format!("{panel}/api/v1/auth/discord/start")).send().await.map_err(err)?; if !response.status().is_success() { return Err(accounts::panel_error(response).await.to_string()); } response.json().await.map_err(err) } #[tauri::command] pub async fn discord_sign_in_poll(state: State<'_, AppState>, oauth_state: String) -> Res { let panel = state.panel_url().ok_or("no panel configured")?; let response = state.http.get(format!("{panel}/api/v1/auth/discord/poll")).query(&[("state", oauth_state)]).send().await.map_err(err)?; if !response.status().is_success() { return Err(accounts::panel_error(response).await.to_string()); } let result: serde_json::Value = response.json().await.map_err(err)?; if result.get("token").is_some() { let auth = serde_json::from_value(result.clone()).map_err(err)?; let account = accounts::save_panel_account(&state, &panel, auth).map_err(aerr)?; return Ok(serde_json::json!({"account":account})); } Ok(result) } #[tauri::command] pub async fn request_password_reset(state: State<'_, AppState>, email: String) -> Res { let panel = state.panel_url().ok_or("no panel configured")?; let response = state .http .post(format!("{panel}/api/v1/auth/forgot-password")) .json(&serde_json::json!({"email":email})) .send() .await .map_err(err)?; if !response.status().is_success() { return Err(accounts::panel_error(response).await.to_string()); } let body: serde_json::Value = response.json().await.map_err(err)?; Ok(body["message"].as_str().unwrap_or("Check your email for a reset link.").to_string()) } #[tauri::command] pub async fn discord_connection(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::GET, "/account/connections/discord").await?; let response = req.send().await.map_err(err)?; if !response.status().is_success() { return Err(accounts::panel_error(response).await.to_string()); } response.json().await.map_err(err) } /// The community invite link and whether this account is linked. #[tauri::command] pub async fn discord_info(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::GET, "/account/discord").await?; let response = req.send().await.map_err(err)?; if !response.status().is_success() { return Err(accounts::panel_error(response).await.to_string()); } response.json().await.map_err(err) } #[tauri::command] pub async fn discord_link_start(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::GET, "/auth/discord/start?kind=link").await?; let response = req.send().await.map_err(err)?; if !response.status().is_success() { return Err(accounts::panel_error(response).await.to_string()); } response.json().await.map_err(err) } #[tauri::command] pub async fn discord_unlink(state: State<'_, AppState>) -> Res<()> { let req = account_api(&state, reqwest::Method::DELETE, "/account/connections/discord").await?; let response = req.send().await.map_err(err)?; if !response.status().is_success() { return Err(accounts::panel_error(response).await.to_string()); } Ok(()) } #[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 deep && state.game.lock().unwrap().iter().any(|g| g.instance_id == instance_id) { return Err("Close this instance's game windows before repairing it".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>, run_id: String) { if let Some(g) = state.game.lock().unwrap().iter_mut().find(|g| g.run_id == run_id) { if let Some(kill) = g.kill.take() { 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().iter().any(|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(); } } // ---- stats & leaderboards ---- #[tauri::command] pub async fn get_player_stats(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::GET, "/account/stats").await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load player stats".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_leaderboard(state: State<'_, AppState>, server_id: Option, sort: Option) -> Res { let panel = state.panel_url().ok_or("no panel configured")?; let sort_query = sort.map(|s| format!("?sort={s}")).unwrap_or_default(); let url = if let Some(id) = server_id { format!("{panel}/api/v1/servers/{id}/leaderboard{sort_query}") } else { format!("{panel}/api/v1/leaderboard{sort_query}") }; let resp = state.http.get(&url).send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load leaderboard".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_public_servers(state: State<'_, AppState>) -> Res { let panel = state.panel_url().ok_or("no panel configured")?; let resp = state.http.get(format!("{panel}/api/v1/servers/public")).send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load servers".into()); } resp.json().await.map_err(err) } /// What the SCOPENET Map needs for one game server: which dimensions have tiles, the tile key, and where tiles live (made absolute, /// because the map loads them straight from the panel as images). #[tauri::command] pub async fn get_map_info(state: State<'_, AppState>, server_id: i64) -> Res { let panel = state.panel_url().ok_or("no panel configured")?; let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string()); } let mut info: serde_json::Value = resp.json().await.map_err(err)?; if let Some(base) = info["tile_base"].as_str().map(str::to_owned) { info["tile_base"] = serde_json::json!(format!("{}{}", panel.trim_end_matches('/'), base)); } Ok(info) } /// Players now, plus the claims and pins the game server last sent. #[tauri::command] pub async fn get_map_overlay(state: State<'_, AppState>, server_id: i64) -> Res { let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/map/overlay")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to load the map").to_string()); } resp.json().await.map_err(err) } // --------------------------------------------------------------------------- // Leveling & Quests // --------------------------------------------------------------------------- #[tauri::command] pub async fn get_my_level(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::GET, "/levels/me").await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load user level".into()); } resp.json().await.map_err(err) } /// What quests, achievements and rank milestones hand out besides XP, as short lines keyed `quest:`. #[tauri::command] pub async fn get_reward_summaries(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::GET, "/reward-bundles").await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Ok(serde_json::json!({})); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_my_quests(state: State<'_, AppState>) -> Res> { let req = account_api(&state, reqwest::Method::GET, "/quests/my").await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load quests".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn claim_quest(state: State<'_, AppState>, quest_id: String) -> Res { let req = account_api(&state, reqwest::Method::POST, &format!("/quests/{quest_id}/claim")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to claim quest").to_string()); } get_my_level(state).await } // --------------------------------------------------------------------------- // Achievements // --------------------------------------------------------------------------- #[tauri::command] pub async fn get_my_achievements(state: State<'_, AppState>) -> Res> { let req = account_api(&state, reqwest::Method::GET, "/achievements/my").await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load achievements".into()); } resp.json().await.map_err(err) } // --------------------------------------------------------------------------- // Guilds & Land Claims // --------------------------------------------------------------------------- #[tauri::command] pub async fn get_guilds(state: State<'_, AppState>, instance_id: Option) -> Res> { let panel = state.panel_url().ok_or("no panel configured")?; let q = instance_id.map(|i| format!("?instance_id={i}")).unwrap_or_default(); let resp = state.http.get(format!("{panel}/api/v1/guilds{q}")).send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load guilds".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_my_guild(state: State<'_, AppState>, instance_id: Option) -> Res> { let q = instance_id.map(|i| format!("?instance_id={i}")).unwrap_or_default(); let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/my{q}")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Ok(None); } resp.json().await.map_err(err) } async fn guild_response_error(resp: reqwest::Response, fallback: &str) -> String { let body: serde_json::Value = resp.json().await.unwrap_or_default(); body["error"].as_str().unwrap_or(fallback).to_string() } #[tauri::command] pub async fn request_guild_join(state: State<'_, AppState>, guild_id: String, message: String) -> Res<()> { let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests")).await?; let resp = req.json(&serde_json::json!({"message": message})).send().await.map_err(err)?; if !resp.status().is_success() { return Err(guild_response_error(resp, "could not request to join guild").await); } Ok(()) } #[tauri::command] pub async fn get_guild_join_requests(state: State<'_, AppState>, guild_id: String) -> Res { let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/requests")).await?.send().await.map_err(err)?; if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild requests").await); } resp.json().await.map_err(err) } #[tauri::command] pub async fn respond_guild_join_request(state: State<'_, AppState>, guild_id: String, uuid: String, accept: bool) -> Res<()> { let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/requests/{uuid}/respond")).await?; let resp = req.json(&serde_json::json!({"accept": accept})).send().await.map_err(err)?; if !resp.status().is_success() { return Err(guild_response_error(resp, "could not review guild request").await); } Ok(()) } #[tauri::command] pub async fn get_guild_roles(state: State<'_, AppState>, guild_id: String) -> Res { let resp = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/roles")).await?.send().await.map_err(err)?; if !resp.status().is_success() { return Err(guild_response_error(resp, "could not load guild roles").await); } resp.json().await.map_err(err) } #[tauri::command] pub async fn create_guild_role(state: State<'_, AppState>, guild_id: String, role: serde_json::Value) -> Res<()> { let resp = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/roles")).await?.json(&role).send().await.map_err(err)?; if !resp.status().is_success() { return Err(guild_response_error(resp, "could not create guild role").await); } Ok(()) } #[tauri::command] pub async fn assign_guild_role(state: State<'_, AppState>, guild_id: String, uuid: String, role: String) -> Res<()> { let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}/members/{uuid}/role")) .await? .json(&serde_json::json!({"role":role})) .send() .await .map_err(err)?; if !resp.status().is_success() { return Err(guild_response_error(resp, "could not assign guild role").await); } Ok(()) } #[tauri::command] pub async fn delete_guild_role(state: State<'_, AppState>, guild_id: String, role_id: i64) -> Res<()> { let resp = account_api(&state, reqwest::Method::DELETE, &format!("/guilds/{guild_id}/roles/{role_id}")).await?.send().await.map_err(err)?; if !resp.status().is_success() { return Err(guild_response_error(resp, "could not delete guild role").await); } Ok(()) } #[tauri::command] pub async fn update_guild( state: State<'_, AppState>, guild_id: String, description: String, motd: String, icon_url: String, banner_url: String, ) -> Res<()> { let resp = account_api(&state, reqwest::Method::PUT, &format!("/guilds/{guild_id}")) .await? .json(&serde_json::json!({"description":description,"motd":motd,"icon_url":icon_url,"banner_url":banner_url})) .send() .await .map_err(err)?; if !resp.status().is_success() { return Err(guild_response_error(resp, "could not update guild").await); } Ok(()) } #[tauri::command] pub async fn create_guild( state: State<'_, AppState>, instance_id: String, name: String, tag: String, description: String, icon_url: Option, banner_url: Option, ) -> Res { let req = account_api(&state, reqwest::Method::POST, "/guilds").await?; let resp = req .json(&serde_json::json!({ "instance_id": instance_id, "name": name, "tag": tag, "description": description, "icon_url": icon_url, "banner_url": banner_url, })) .send() .await .map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to create guild").to_string()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_guild_claims( state: State<'_, AppState>, instance_id: String, server_id: i64, dimension: Option, center_x: i32, center_z: i32, ) -> Res> { let panel = state.panel_url().ok_or("no panel configured")?; let dim = dimension.unwrap_or_else(|| "minecraft:overworld".into()); let resp = state.http.get(format!("{panel}/api/v1/guilds/claims/grid?instance_id={instance_id}&server_id={server_id}&dimension={dim}¢er_x={center_x}¢er_z={center_z}")) .send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load claims".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn claim_guild_chunk( state: State<'_, AppState>, guild_id: String, instance_id: String, server_id: i64, dimension: String, chunk_x: i32, chunk_z: i32, ) -> Res { let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/claim")).await?; let resp = req .json(&serde_json::json!({ "instance_id": instance_id, "server_id": server_id, "dimension": dimension, "chunk_x": chunk_x, "chunk_z": chunk_z, })) .send() .await .map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to claim chunk").to_string()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn unclaim_guild_chunk(state: State<'_, AppState>, claim_id: i64) -> Res<()> { let req = account_api(&state, reqwest::Method::DELETE, &format!("/guilds/claims/{claim_id}")).await?; req.send().await.map_err(err)?.error_for_status().map_err(err)?; Ok(()) } #[tauri::command] pub async fn get_guild_posts(state: State<'_, AppState>, guild_id: String) -> Res> { let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/posts")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load guild posts".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn create_guild_post(state: State<'_, AppState>, guild_id: String, title: String, content: String) -> Res { let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/posts")).await?; let resp = req .json(&serde_json::json!({ "title": title, "content": content, })) .send() .await .map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to post announcement").to_string()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_guild_members(state: State<'_, AppState>, guild_id: String) -> Res> { let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/members")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load members".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_guild_wallet(state: State<'_, AppState>, guild_id: String, server_id: i64) -> Res { let req = account_api(&state, reqwest::Method::GET, &format!("/guilds/{guild_id}/wallet?server_id={server_id}")).await?; req.send().await.map_err(err)?.error_for_status().map_err(err)?.json().await.map_err(err) } #[tauri::command] pub async fn transfer_guild_wallet( state: State<'_, AppState>, guild_id: String, server_id: i64, amount: f64, withdraw: bool, ) -> Res { let action = if withdraw { "withdraw" } else { "deposit" }; let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/wallet/{action}")).await?; let resp = req.json(&serde_json::json!({"server_id":server_id,"amount":amount})).send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("Guild wallet transfer failed").to_string()); } resp.json().await.map_err(err) } // --------------------------------------------------------------------------- // Social: Friends, DMs, Invites, Profiles & Feed // --------------------------------------------------------------------------- #[tauri::command] pub async fn get_friends(state: State<'_, AppState>) -> Res> { let req = account_api(&state, reqwest::Method::GET, "/friends").await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load friends".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn send_friend_request(state: State<'_, AppState>, friend_username: Option, username: Option) -> Res<()> { let target = friend_username.or(username).ok_or("username is required")?; let req = account_api(&state, reqwest::Method::POST, "/friends/request").await?; let resp = req.json(&serde_json::json!({ "username": target })).send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to send friend request").to_string()); } Ok(()) } #[tauri::command] pub async fn respond_friend_request( state: State<'_, AppState>, friend_uuid: Option, target_uuid: Option, accept: bool, ) -> Res<()> { let target = friend_uuid.or(target_uuid).ok_or("target_uuid is required")?; let req = account_api(&state, reqwest::Method::POST, "/friends/respond").await?; let resp = req.json(&serde_json::json!({ "target_uuid": target, "accept": accept })).send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to respond to friend request").to_string()); } Ok(()) } #[tauri::command] pub async fn remove_friend(state: State<'_, AppState>, friend_uuid: Option, target_uuid: Option) -> Res<()> { let target = friend_uuid.or(target_uuid).ok_or("target_uuid is required")?; let req = account_api(&state, reqwest::Method::DELETE, &format!("/friends/{target}")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to remove friend").to_string()); } Ok(()) } #[tauri::command] pub async fn search_members(state: State<'_, AppState>, query: String) -> Res> { let req = account_api(&state, reqwest::Method::GET, "/social/members").await?; // `.query` percent-encodes, so names with spaces, `&` or `#` search correctly. let resp = req.query(&[("q", query.trim())]).send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to search members").to_string()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_economy_balances(state: State<'_, AppState>) -> Res> { let req = account_api(&state, reqwest::Method::GET, "/economy/me").await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load economy balances".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_server_baltop(state: State<'_, AppState>, server_id: i64) -> Res> { let req = account_api(&state, reqwest::Method::GET, &format!("/servers/{server_id}/economy/baltop")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load baltop leaderboard".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_transactions(state: State<'_, AppState>) -> Res> { let req = account_api(&state, reqwest::Method::GET, "/economy/transactions").await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load transactions".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_direct_messages(state: State<'_, AppState>, friend_uuid: String, before_id: Option) -> Res> { // FIX #13: Support optional before_id for loading older messages beyond the first 100. let path = if let Some(before) = before_id { format!("/messages/{friend_uuid}?before_id={before}") } else { format!("/messages/{friend_uuid}") }; let req = account_api(&state, reqwest::Method::GET, &path).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load messages".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn send_direct_message(state: State<'_, AppState>, friend_uuid: String, content: String) -> Res { let req = account_api(&state, reqwest::Method::POST, &format!("/messages/{friend_uuid}")).await?; let resp = req.json(&serde_json::json!({ "content": content })).send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to send message").to_string()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn get_game_invites(state: State<'_, AppState>) -> Res> { let req = account_api(&state, reqwest::Method::GET, "/invites").await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load invites".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn send_game_invite(state: State<'_, AppState>, recipient_uuid: String, instance_id: String, server_id: Option) -> Res<()> { let req = account_api(&state, reqwest::Method::POST, "/invites").await?; let resp = req .json(&serde_json::json!({ "recipient_uuid": recipient_uuid, "instance_id": instance_id, "server_id": server_id, })) .send() .await .map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to send invite").to_string()); } Ok(()) } #[tauri::command] pub async fn respond_game_invite(state: State<'_, AppState>, invite_id: String, accept: bool) -> Res<()> { let req = account_api(&state, reqwest::Method::POST, &format!("/invites/{invite_id}/respond")).await?; let resp = req.json(&serde_json::json!({ "action": if accept { "accept" } else { "decline" } })).send().await.map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to respond to invite").to_string()); } Ok(()) } #[tauri::command] pub async fn get_user_profile(state: State<'_, AppState>, uuid: String) -> Res { let req = account_api(&state, reqwest::Method::GET, &format!("/profiles/{uuid}")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load user profile".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn update_my_profile( state: State<'_, AppState>, bio: String, banner_url: Option, custom_badge: Option, featured_achievement_id: Option, accent_color: Option, ) -> Res { let req = account_api(&state, reqwest::Method::PUT, "/profiles/me").await?; let resp = req .json(&serde_json::json!({ "bio": bio, "banner_url": banner_url, "custom_badge": custom_badge, "featured_achievement_id": featured_achievement_id, "accent_color": accent_color, })) .send() .await .map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to update profile").to_string()); } let uuid = state.accounts.read().unwrap().active().ok_or("no active account")?.uuid.clone(); get_user_profile(state, uuid).await } #[tauri::command] pub async fn get_user_posts(state: State<'_, AppState>, user_uuid: Option) -> Res> { let path = user_uuid.map(|u| format!("/posts?uuid={u}")).unwrap_or_else(|| "/posts".into()); let req = account_api(&state, reqwest::Method::GET, &path).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to load posts".into()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn create_user_post(state: State<'_, AppState>, content: String, image_url: Option) -> Res { let req = account_api(&state, reqwest::Method::POST, "/profiles/me/posts").await?; let resp = req .json(&serde_json::json!({ "content": content, "image_url": image_url, })) .send() .await .map_err(err)?; if !resp.status().is_success() { let body: serde_json::Value = resp.json().await.unwrap_or_default(); return Err(body["error"].as_str().unwrap_or("unable to publish post").to_string()); } resp.json().await.map_err(err) } #[tauri::command] pub async fn like_user_post(state: State<'_, AppState>, post_id: i64) -> Res { let req = account_api(&state, reqwest::Method::POST, &format!("/posts/{post_id}/like")).await?; let resp = req.send().await.map_err(err)?; if !resp.status().is_success() { return Err("unable to like post".into()); } let body: serde_json::Value = resp.json().await.map_err(err)?; Ok(body["liked"].as_bool().unwrap_or(false)) } // --------------------------------------------------------------------------- // Map actions and guild invitations // --------------------------------------------------------------------------- async fn ok_or_panel_error(resp: reqwest::Response, fallback: &str) -> Res { if resp.status().is_success() { return Ok(resp.json().await.unwrap_or(serde_json::Value::Null)); } let body: serde_json::Value = resp.json().await.unwrap_or_default(); Err(body["error"].as_str().or(body["message"].as_str()).unwrap_or(fallback).to_string()) } /// From the map: ask a player to teleport to, or send them a message in game. #[tauri::command] pub async fn map_action(state: State<'_, AppState>, server_id: i64, action: String, target_uuid: String, text: Option) -> Res<()> { let req = account_api(&state, reqwest::Method::POST, &format!("/servers/{server_id}/map/actions")).await?; let resp = req .json(&serde_json::json!({ "action": action, "target_uuid": target_uuid, "text": text.unwrap_or_default() })) .send() .await .map_err(err)?; ok_or_panel_error(resp, "unable to do that right now").await.map(|_| ()) } /// Invite a player to a guild you lead or officiate. #[tauri::command] pub async fn guild_send_invite(state: State<'_, AppState>, guild_id: String, target_uuid: String) -> Res { let req = account_api(&state, reqwest::Method::POST, &format!("/guilds/{guild_id}/invites")).await?; let resp = req.json(&serde_json::json!({ "uuid": target_uuid })).send().await.map_err(err)?; ok_or_panel_error(resp, "unable to send the invitation").await } #[tauri::command] pub async fn my_guild_invites(state: State<'_, AppState>) -> Res { let req = account_api(&state, reqwest::Method::GET, "/guilds/invites").await?; let resp = req.send().await.map_err(err)?; ok_or_panel_error(resp, "unable to load invitations").await } #[tauri::command] pub async fn respond_guild_invite(state: State<'_, AppState>, invite_id: i64, accept: bool) -> Res { let path = format!("/guilds/invites/{invite_id}/{}", if accept { "accept" } else { "decline" }); let req = account_api(&state, reqwest::Method::POST, &path).await?; let resp = req.send().await.map_err(err)?; ok_or_panel_error(resp, "unable to answer the invitation").await }