Complete private authentication, server integrations and activity reporting
Add Fabric/Forge version builds and Paper integration, preserve permanent player identities across renames, harden session authorization, and surface privacy-conscious launcher/server activity in the panel.
This commit is contained in:
commit
a1f19e86c6
74 files changed
+2108
-95
No files matched your search
@@ -134,6 +134,25 @@ fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Resu
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub async fn rename_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
|
||||
if state.game.lock().unwrap().is_some() || state.task.lock().unwrap().as_ref().is_some_and(|t| !t.is_finished()) {
|
||||
bail!("close Minecraft and wait for any installation to finish before changing your username");
|
||||
}
|
||||
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||
let token = panel_token(state).ok_or_else(|| anyhow!("sign in to your server account first"))?;
|
||||
let resp = state
|
||||
.http
|
||||
.put(format!("{panel}/api/v1/account/username"))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({"username": username, "password": password}))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(panel_error(resp).await);
|
||||
}
|
||||
save_panel_account(state, &panel, resp.json().await?)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -14,6 +14,11 @@ use tauri_plugin_opener::OpenerExt;
|
||||
/// Commands return `Err(String)`; the UI shows it as-is.
|
||||
type Res<T> = Result<T, String>;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn record_activity(state: State<'_, AppState>, kind: String, instance_id: Option<String>) -> 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()
|
||||
}
|
||||
@@ -152,6 +157,7 @@ pub fn select_account(state: State<'_, AppState>, id: String) -> Res<()> {
|
||||
|
||||
#[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()) {
|
||||
@@ -216,6 +222,11 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option<i64>) -> Res<P
|
||||
|
||||
// ---- game ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_username(state: State<'_, AppState>, username: String, password: String) -> Res<accounts::Account> {
|
||||
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)
|
||||
@@ -237,6 +248,7 @@ fn start_task(app: AppHandle, state: &AppState, instance_id: String, start: bool
|
||||
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::<AppState>(), "launch_failed", &instance_id);
|
||||
tracing::error!("launch failed: {e:#}");
|
||||
game::emit_state(&app2, &instance_id, "error", Some(format!("{e:#}")));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use scopenet_core::install::{self, InstallSpec};
|
||||
use scopenet_core::launch::{self, LaunchOptions};
|
||||
use scopenet_core::progress::{Event, Reporter, Stage};
|
||||
use scopenet_core::{options, servers_dat, sync};
|
||||
use scopenet_shared::{InstanceManifest, LaunchEvent};
|
||||
use scopenet_shared::InstanceManifest;
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
@@ -79,6 +79,10 @@ fn reporter(app: &AppHandle) -> Reporter {
|
||||
pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -> Result<()> {
|
||||
let state = app.state::<AppState>();
|
||||
let report = reporter(&app);
|
||||
let activity = crate::telemetry::capture(&state);
|
||||
if let Some(recorder) = &activity {
|
||||
recorder.background(if deep { "repair_start" } else { "install_start" }, &instance_id);
|
||||
}
|
||||
emit_state(&app, &instance_id, "preparing", None);
|
||||
|
||||
let account = state.accounts.read().unwrap().active().cloned();
|
||||
@@ -127,6 +131,9 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
options::apply_keybinds(&game_dir, &settings.keybinds).ok();
|
||||
}
|
||||
|
||||
if let Some(recorder) = &activity {
|
||||
recorder.background(if deep { "repair_complete" } else { "install_complete" }, &instance_id);
|
||||
}
|
||||
if !start {
|
||||
emit_state(&app, &instance_id, "idle", Some("Instance is up to date".into()));
|
||||
return Ok(());
|
||||
@@ -183,20 +190,11 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
tracing::info!("launching: {} {}", cmd.java.display(), launch::redact(&cmd.args, &auth.access_token).join(" "));
|
||||
|
||||
let close_after = settings.after_launch == "close";
|
||||
let mut child = launch::spawn(&cmd, installed.java_major, !close_after).context("couldn't start Java")?;
|
||||
|
||||
if settings.send_stats && !panel.is_empty() {
|
||||
let http = state.http.clone();
|
||||
let token = accounts::panel_token(&state);
|
||||
let ev = LaunchEvent { instance_id: instance_id.clone(), kind: "launch".into(), username: Some(auth.username.clone()) };
|
||||
tokio::spawn(async move {
|
||||
let mut req = http.post(format!("{panel}/api/v1/launcher/events")).json(&ev);
|
||||
if let Some(t) = token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
req.send().await.ok();
|
||||
});
|
||||
// Register the authenticated launch before Quick Play can reach the server.
|
||||
if let Some(recorder) = &activity {
|
||||
recorder.send("launch", &instance_id).await?;
|
||||
}
|
||||
let mut child = launch::spawn(&cmd, installed.java_major, !close_after).context("couldn't start Java")?;
|
||||
|
||||
if close_after {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
@@ -288,6 +286,17 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
let crashed = !killed && code.is_some_and(|c| c != 0);
|
||||
let crash_report = if crashed { newest_crash_report(&app2, &id2) } else { None };
|
||||
let st = app2.state::<AppState>();
|
||||
crate::telemetry::background(
|
||||
&st,
|
||||
if killed {
|
||||
"game_killed"
|
||||
} else if crashed {
|
||||
"game_crash"
|
||||
} else {
|
||||
"game_exit"
|
||||
},
|
||||
&id2,
|
||||
);
|
||||
*st.game.lock().unwrap() = None;
|
||||
if let Some(w) = app2.get_webview_window("main") {
|
||||
w.show().ok();
|
||||
|
||||
@@ -7,6 +7,7 @@ mod game;
|
||||
mod secrets;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod telemetry;
|
||||
mod updater;
|
||||
|
||||
use tauri::Manager;
|
||||
@@ -46,6 +47,7 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::bootstrap,
|
||||
commands::record_activity,
|
||||
commands::refresh_manifest,
|
||||
commands::set_panel_url,
|
||||
commands::save_settings,
|
||||
@@ -57,6 +59,7 @@ pub fn run() {
|
||||
commands::set_skin_model,
|
||||
commands::delete_skin,
|
||||
commands::set_cape,
|
||||
commands::set_username,
|
||||
commands::select_account,
|
||||
commands::remove_account,
|
||||
commands::launch,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Account-attributed activity only; no passwords, game logs, chat, or local paths.
|
||||
use crate::{accounts, state::AppState};
|
||||
use scopenet_shared::LaunchEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Capture the identity when work starts, so switching accounts cannot
|
||||
/// attribute a running game's exit to the newly selected player.
|
||||
#[derive(Clone)]
|
||||
pub struct Recorder {
|
||||
http: reqwest::Client,
|
||||
panel: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
pub fn capture(state: &AppState) -> Option<Recorder> {
|
||||
Some(Recorder { http: state.http.clone(), panel: state.panel_url()?, token: accounts::panel_token(state)? })
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
pub async fn send(&self, kind: &str, instance: &str) -> anyhow::Result<()> {
|
||||
self.http
|
||||
.post(format!("{}/api/v1/launcher/events", self.panel))
|
||||
.bearer_auth(&self.token)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.json(&LaunchEvent { kind: kind.into(), instance_id: instance.into(), username: None })
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn background(&self, kind: &str, instance: &str) {
|
||||
let recorder = self.clone();
|
||||
let kind = kind.to_owned();
|
||||
let instance = instance.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = recorder.send(&kind, &instance).await {
|
||||
tracing::warn!("activity report failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send(state: &AppState, kind: &str, instance: &str) -> anyhow::Result<()> {
|
||||
if let Some(recorder) = capture(state) {
|
||||
recorder.send(kind, instance).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn background(state: &AppState, kind: &str, instance: &str) {
|
||||
if let Some(recorder) = capture(state) {
|
||||
recorder.background(kind, instance);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,16 @@ export function mockEmit(event: string, payload: unknown) {
|
||||
}
|
||||
|
||||
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
if (inTauri) return tauriInvoke<T>(cmd, args);
|
||||
if (inTauri) {
|
||||
const result = await tauriInvoke<T>(cmd, args);
|
||||
const kinds: Record<string, string> = {
|
||||
bootstrap: 'launcher_open', save_settings: 'settings_changed', select_account: 'account_selected',
|
||||
cancel_launch: 'launch_cancelled', delete_instance_data: 'instance_deleted', clear_cache: 'cache_cleared',
|
||||
install_update: 'update_installed', open_folder: 'folder_opened',
|
||||
};
|
||||
if (kinds[cmd]) void tauriInvoke('record_activity', { kind: kinds[cmd], instanceId: args?.instanceId ?? null }).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
const { mockInvoke } = await import('./mock');
|
||||
return mockInvoke(cmd, args ?? {}) as Promise<T>;
|
||||
}
|
||||
@@ -32,7 +41,10 @@ export async function windowAction(action: 'minimize' | 'maximize' | 'close' | '
|
||||
const w = getCurrentWindow();
|
||||
if (action === 'minimize') await w.minimize();
|
||||
else if (action === 'maximize') await w.toggleMaximize();
|
||||
else if (action === 'close') await w.close();
|
||||
else if (action === 'close') {
|
||||
await tauriInvoke('record_activity', { kind: 'launcher_close', instanceId: null }).catch(() => {});
|
||||
await w.close();
|
||||
}
|
||||
else await w.startDragging();
|
||||
}
|
||||
|
||||
@@ -43,6 +55,7 @@ export async function openUrl(url: string) {
|
||||
}
|
||||
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
||||
await openUrl(url);
|
||||
void tauriInvoke('record_activity', { kind: 'link_opened', instanceId: null }).catch(() => {});
|
||||
}
|
||||
|
||||
export async function pickFile(title: string, filters?: { name: string; extensions: string[] }[]): Promise<string | null> {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys';
|
||||
import { bytes, gb } from '../lib/format';
|
||||
import { errorText, invoke, openUrl, pickFile } from '../lib/tauri';
|
||||
import type { Gc, PlayerProfile, UpdateInfo } from '../lib/types';
|
||||
import type { Account, Gc, PlayerProfile, UpdateInfo } from '../lib/types';
|
||||
|
||||
const serverAccount = $derived(activeAccount()?.kind === 'panel');
|
||||
const tabs = $derived([
|
||||
@@ -33,6 +33,21 @@
|
||||
const advanced = $derived(b?.features.allow_advanced_java !== false);
|
||||
const themeAllowed = $derived(b?.features.allow_user_theme !== false);
|
||||
const kindLabel = { panel: 'Server account', offline: 'Offline' };
|
||||
let newUsername = $state('');
|
||||
let renamePassword = $state('');
|
||||
let renaming = $state(false);
|
||||
async function renameAccount() {
|
||||
renaming = true;
|
||||
try {
|
||||
const account = await invoke<Account>('set_username', { username: newUsername, password: renamePassword });
|
||||
app.accounts = app.accounts.map((a) => a.id === account.id ? account : a);
|
||||
newUsername = '';
|
||||
renamePassword = '';
|
||||
await refresh();
|
||||
toast('Username updated. Your UUID and inventory are unchanged.');
|
||||
} catch (e) { toast(errorText(e), 'error'); }
|
||||
finally { renamePassword = ''; renaming = false; }
|
||||
}
|
||||
|
||||
// ---- controls ----
|
||||
let capturing = $state<string | null>(null);
|
||||
@@ -205,6 +220,13 @@
|
||||
</div>
|
||||
{:else if app.settingsTab === 'skin'}
|
||||
<h1>Skin & cape</h1>
|
||||
<form class="card glass col" onsubmit={(e) => { e.preventDefault(); renameAccount(); }}>
|
||||
<h3>Username</h3>
|
||||
<p class="muted small">Your UUID, inventory and server progress stay with your account. Close Minecraft first. Previous names remain reserved to you.</p>
|
||||
<label>New username<input bind:value={newUsername} required minlength="3" maxlength="16" pattern="[A-Za-z0-9_]+" autocomplete="username" /></label>
|
||||
<label>Current password<input bind:value={renamePassword} required type="password" autocomplete="current-password" /></label>
|
||||
<button disabled={renaming || !!app.running} type="submit">{renaming ? 'Updating…' : 'Change username'}</button>
|
||||
</form>
|
||||
<p class="lead">Stored on {b?.name ?? 'the server'} — visible to everyone on servers that use its sign-in.</p>
|
||||
{#if profileError}
|
||||
<div class="card glass"><p class="warn small">{profileError}</p></div>
|
||||
@@ -396,7 +418,7 @@
|
||||
</div>
|
||||
<div class="card glass col">
|
||||
<Toggle bind:checked={s.check_updates} onchange={saveSettings} label="Check for launcher updates" />
|
||||
<Toggle bind:checked={s.send_stats} onchange={saveSettings} label="Share play stats with the server" help="Lets your admins see which instances are popular. Only sends the instance and your username." />
|
||||
<p class="help">Official server accounts report launcher activity and gameplay to the panel, linked to your permanent UUID. Chat is counted; message text, passwords and command arguments are not collected.</p>
|
||||
</div>
|
||||
{:else if app.settingsTab === 'storage'}
|
||||
<h1>Storage</h1>
|
||||
|
||||
Reference in new issue
Block a user