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,261 @@
|
||||
//! Player accounts: panel (username/password), Microsoft, and local offline.
|
||||
|
||||
use crate::secrets::Secret;
|
||||
use crate::state::AppState;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use scopenet_shared::{offline_uuid, valid_username, AuthResponse, LoginRequest, RegisterRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Account {
|
||||
pub id: String,
|
||||
/// "panel" | "microsoft" | "offline"
|
||||
pub kind: String,
|
||||
pub username: String,
|
||||
pub uuid: String,
|
||||
/// Panel accounts: which panel they belong to.
|
||||
#[serde(default)]
|
||||
pub panel_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct AccountsFile {
|
||||
pub accounts: Vec<Account>,
|
||||
pub active: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountsFile {
|
||||
pub fn load(path: &Path) -> Self {
|
||||
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(self)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn active(&self) -> Option<&Account> {
|
||||
let id = self.active.as_ref()?;
|
||||
self.accounts.iter().find(|a| &a.id == id)
|
||||
}
|
||||
|
||||
/// Add or replace (same kind + uuid + panel) and make it active.
|
||||
pub fn upsert(&mut self, account: Account) -> Account {
|
||||
if let Some(existing) = self
|
||||
.accounts
|
||||
.iter_mut()
|
||||
.find(|a| a.kind == account.kind && a.uuid == account.uuid && a.panel_url == account.panel_url)
|
||||
{
|
||||
let id = existing.id.clone();
|
||||
*existing = Account { id: id.clone(), ..account };
|
||||
self.active = Some(id);
|
||||
return existing.clone();
|
||||
}
|
||||
self.active = Some(account.id.clone());
|
||||
self.accounts.push(account.clone());
|
||||
account
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
|
||||
}
|
||||
|
||||
async fn panel_error(resp: reqwest::Response) -> anyhow::Error {
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||
anyhow!("{}", body.get("error").and_then(|e| e.as_str()).map(String::from).unwrap_or_else(|| format!("panel returned {status}")))
|
||||
}
|
||||
|
||||
pub async fn login_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
|
||||
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||
let resp = state
|
||||
.http
|
||||
.post(format!("{panel}/api/v1/auth/login"))
|
||||
.json(&LoginRequest { username: username.trim().into(), password: password.into() })
|
||||
.send()
|
||||
.await
|
||||
.context("couldn't reach the server")?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(panel_error(resp).await);
|
||||
}
|
||||
let auth: AuthResponse = resp.json().await?;
|
||||
save_panel_account(state, &panel, auth)
|
||||
}
|
||||
|
||||
pub async fn register_panel(state: &AppState, username: &str, password: &str, email: Option<String>) -> Result<(Option<Account>, bool)> {
|
||||
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||
let resp = state
|
||||
.http
|
||||
.post(format!("{panel}/api/v1/auth/register"))
|
||||
.json(&RegisterRequest { username: username.trim().into(), password: password.into(), email })
|
||||
.send()
|
||||
.await
|
||||
.context("couldn't reach the server")?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(panel_error(resp).await);
|
||||
}
|
||||
let auth: AuthResponse = resp.json().await?;
|
||||
if auth.pending {
|
||||
return Ok((None, true));
|
||||
}
|
||||
Ok((Some(save_panel_account(state, &panel, auth)?), false))
|
||||
}
|
||||
|
||||
fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Result<Account> {
|
||||
let account = Account {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
kind: "panel".into(),
|
||||
username: auth.user.username.clone(),
|
||||
uuid: auth.user.uuid.clone(),
|
||||
panel_url: Some(panel.to_string()),
|
||||
role: Some(auth.user.role.clone()),
|
||||
};
|
||||
let account = state.accounts.write().unwrap().upsert(account);
|
||||
state.secrets.set(&account.id, Secret { panel_token: Some(auth.token), ..Default::default() })?;
|
||||
state.save_accounts()?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
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 {
|
||||
bail!("offline accounts are disabled on this server");
|
||||
}
|
||||
let name = username.trim();
|
||||
if !valid_username(name) {
|
||||
bail!("usernames are 3–16 letters, numbers or underscores");
|
||||
}
|
||||
let account = Account {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
kind: "offline".into(),
|
||||
username: name.into(),
|
||||
uuid: offline_uuid(name),
|
||||
panel_url: None,
|
||||
role: None,
|
||||
};
|
||||
let account = state.accounts.write().unwrap().upsert(account);
|
||||
state.save_accounts()?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub fn ms_client_id(state: &AppState) -> Result<String> {
|
||||
state
|
||||
.manifest
|
||||
.read()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.and_then(|m| m.auth.microsoft.then(|| m.auth.microsoft_client_id.clone()).flatten())
|
||||
.filter(|c| !c.is_empty())
|
||||
.ok_or_else(|| anyhow!("Microsoft sign-in isn't enabled on this server"))
|
||||
}
|
||||
|
||||
pub fn save_ms_session(state: &AppState, session: scopenet_core::msa::MsaSession) -> Result<Account> {
|
||||
let uuid = dashed(&session.profile.id);
|
||||
let account = Account {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
kind: "microsoft".into(),
|
||||
username: session.profile.name.clone(),
|
||||
uuid,
|
||||
panel_url: None,
|
||||
role: None,
|
||||
};
|
||||
let account = state.accounts.write().unwrap().upsert(account);
|
||||
state.secrets.set(
|
||||
&account.id,
|
||||
Secret {
|
||||
ms_refresh_token: Some(session.refresh_token),
|
||||
mc_access_token: Some(session.mc_access_token),
|
||||
mc_expires_at: session.mc_expires_at,
|
||||
xuid: session.xuid,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
state.save_accounts()?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
fn dashed(id: &str) -> String {
|
||||
let id = id.replace('-', "");
|
||||
if id.len() != 32 {
|
||||
return id;
|
||||
}
|
||||
format!("{}-{}-{}-{}-{}", &id[0..8], &id[8..12], &id[12..16], &id[16..20], &id[20..32])
|
||||
}
|
||||
|
||||
/// Credentials passed to the game.
|
||||
pub async fn game_auth(state: &AppState, account: &Account) -> Result<scopenet_core::launch::Auth> {
|
||||
use scopenet_core::launch::Auth;
|
||||
match account.kind.as_str() {
|
||||
"microsoft" => {
|
||||
let mut secret = state.secrets.get(&account.id);
|
||||
if secret.mc_access_token.is_none() || secret.mc_expires_at < now() + 300 {
|
||||
let refresh = secret.ms_refresh_token.clone().ok_or_else(|| anyhow!("please sign in to Microsoft again"))?;
|
||||
let client_id = ms_client_id(state)?;
|
||||
let session = scopenet_core::msa::refresh(&state.http, &client_id, &refresh).await?;
|
||||
if session.profile.name != account.username {
|
||||
let mut accounts = state.accounts.write().unwrap();
|
||||
if let Some(a) = accounts.accounts.iter_mut().find(|a| a.id == account.id) {
|
||||
a.username = session.profile.name.clone();
|
||||
}
|
||||
}
|
||||
state.save_accounts().ok();
|
||||
secret = Secret {
|
||||
ms_refresh_token: Some(session.refresh_token),
|
||||
mc_access_token: Some(session.mc_access_token),
|
||||
mc_expires_at: session.mc_expires_at,
|
||||
xuid: session.xuid,
|
||||
..secret
|
||||
};
|
||||
state.secrets.set(&account.id, secret.clone())?;
|
||||
}
|
||||
let active_name = state.accounts.read().unwrap().accounts.iter().find(|a| a.id == account.id).map(|a| a.username.clone());
|
||||
Ok(Auth {
|
||||
username: active_name.unwrap_or_else(|| account.username.clone()),
|
||||
uuid: account.uuid.clone(),
|
||||
access_token: secret.mc_access_token.unwrap_or_default(),
|
||||
user_type: "msa".into(),
|
||||
xuid: secret.xuid,
|
||||
})
|
||||
}
|
||||
_ => Ok(Auth {
|
||||
username: account.username.clone(),
|
||||
uuid: account.uuid.clone(),
|
||||
// Offline mode: any token works; servers in offline mode ignore it.
|
||||
access_token: "0".into(),
|
||||
user_type: "legacy".into(),
|
||||
xuid: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel token for requests, when the active account belongs to this panel.
|
||||
pub fn panel_token(state: &AppState) -> Option<String> {
|
||||
let panel = state.panel_url()?;
|
||||
let accounts = state.accounts.read().unwrap();
|
||||
let active = accounts.active()?;
|
||||
if active.kind != "panel" || active.panel_url.as_deref() != Some(panel.as_str()) {
|
||||
return None;
|
||||
}
|
||||
state.secrets.get(&active.id).panel_token
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_same_identity() {
|
||||
let mut f = AccountsFile::default();
|
||||
let a = Account { id: "1".into(), kind: "offline".into(), username: "Steve".into(), uuid: "u".into(), panel_url: None, role: None };
|
||||
f.upsert(a.clone());
|
||||
f.upsert(Account { id: "2".into(), ..a.clone() });
|
||||
assert_eq!(f.accounts.len(), 1);
|
||||
assert_eq!(f.active.as_deref(), Some("1"));
|
||||
assert_eq!(dashed("b50ad385829d3141a2167e7d7539ba7f"), "b50ad385-829d-3141-a216-7e7d7539ba7f");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! The Play button: install → sync → configure → start → watch.
|
||||
|
||||
use crate::accounts;
|
||||
use crate::state::{AppState, RunningGame};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
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 serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct StatePayload {
|
||||
pub instance_id: String,
|
||||
/// "preparing" | "running" | "idle" | "error"
|
||||
pub state: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct ExitPayload {
|
||||
pub instance_id: String,
|
||||
pub code: Option<i32>,
|
||||
pub crashed: bool,
|
||||
pub tail: Vec<String>,
|
||||
pub crash_report: Option<String>,
|
||||
}
|
||||
|
||||
pub fn emit_state(app: &AppHandle, id: &str, state: &str, message: Option<String>) {
|
||||
app.emit("launch://state", StatePayload { instance_id: id.into(), state: state.into(), message }).ok();
|
||||
}
|
||||
|
||||
/// Fetch the instance definition, falling back to the last copy we saw so
|
||||
/// the game still starts without internet.
|
||||
async fn instance_manifest(state: &AppState, id: &str) -> Result<InstanceManifest> {
|
||||
let cache = state.layout.instance_dir(id).join(".scopenet").join("manifest.json");
|
||||
let fetched = async {
|
||||
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||
let mut req = state.http.get(format!("{panel}/api/v1/launcher/instances/{id}"));
|
||||
if let Some(token) = accounts::panel_token(state) {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
if resp.status().as_u16() == 404 {
|
||||
anyhow::bail!("this instance no longer exists or you don't have access to it");
|
||||
}
|
||||
Ok::<_, anyhow::Error>(resp.error_for_status()?.json::<InstanceManifest>().await?)
|
||||
}
|
||||
.await;
|
||||
match fetched {
|
||||
Ok(m) => {
|
||||
std::fs::create_dir_all(cache.parent().unwrap()).ok();
|
||||
std::fs::write(&cache, serde_json::to_vec(&m)?).ok();
|
||||
Ok(m)
|
||||
}
|
||||
Err(e) => {
|
||||
let bytes = std::fs::read(&cache).map_err(|_| e)?;
|
||||
tracing::warn!("panel unreachable, using cached instance manifest");
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reporter(app: &AppHandle) -> Reporter {
|
||||
let app = app.clone();
|
||||
Arc::new(move |ev: Event| {
|
||||
app.emit("launch://progress", ev).ok();
|
||||
})
|
||||
}
|
||||
|
||||
/// Install/verify an instance and (when `start`) run the game.
|
||||
pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -> Result<()> {
|
||||
let state = app.state::<AppState>();
|
||||
let report = reporter(&app);
|
||||
emit_state(&app, &instance_id, "preparing", None);
|
||||
|
||||
let account = state.accounts.read().unwrap().active().cloned();
|
||||
let account = match (start, account) {
|
||||
(true, None) => anyhow::bail!("sign in first"),
|
||||
(_, a) => a,
|
||||
};
|
||||
let auth = match &account {
|
||||
Some(a) if start => Some(accounts::game_auth(&state, a).await?),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let manifest = instance_manifest(&state, &instance_id).await?;
|
||||
let inst = &manifest.instance;
|
||||
let settings = state.settings.read().unwrap().clone();
|
||||
let advanced = state.manifest.read().unwrap().as_ref().map(|m| m.branding.features.allow_advanced_java).unwrap_or(true);
|
||||
let ov = settings.instance_overrides.get(&instance_id).cloned().unwrap_or_default();
|
||||
let game_dir = state.layout.instance_dir(&instance_id);
|
||||
let panel = state.panel_url().unwrap_or_default();
|
||||
|
||||
let java_override = if advanced {
|
||||
ov.java_path.clone().or(settings.java_path.clone()).filter(|p| !p.trim().is_empty()).map(PathBuf::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let spec = InstallSpec {
|
||||
mc_version: inst.mc_version.clone(),
|
||||
loader: inst.loader,
|
||||
loader_version: inst.loader_version.clone(),
|
||||
java_override,
|
||||
game_dir: game_dir.clone(),
|
||||
concurrency: settings.concurrent_downloads.clamp(1, 32) as usize,
|
||||
deep_verify: deep,
|
||||
};
|
||||
let installed = install::install(&state.http, &state.layout, &spec, &report).await?;
|
||||
sync::sync(&state.http, &panel, &game_dir, inst.revision, &manifest.files, spec.concurrency, deep, &report).await?;
|
||||
|
||||
if let Some(server) = &inst.server {
|
||||
if server.inject && !server.address.is_empty() {
|
||||
let name = if server.name.is_empty() { inst.name.clone() } else { server.name.clone() };
|
||||
servers_dat::upsert(&game_dir, &name, &servers_dat::format_address(&server.address, server.port)).ok();
|
||||
}
|
||||
}
|
||||
if settings.keybinds_enabled && installed.version.is_modern() && !settings.keybinds.is_empty() {
|
||||
options::apply_keybinds(&game_dir, &settings.keybinds).ok();
|
||||
}
|
||||
|
||||
if !start {
|
||||
emit_state(&app, &instance_id, "idle", Some("Instance is up to date".into()));
|
||||
return Ok(());
|
||||
}
|
||||
let auth = auth.unwrap();
|
||||
|
||||
report(Event::Stage { stage: Stage::Launching, label: "Starting Minecraft".into() });
|
||||
let pick = |a: u32, b: u32, c: u32| if a > 0 { a } else if b > 0 { b } else { c };
|
||||
let max = pick(ov.memory_max_mb, settings.memory_max_mb, inst.memory.max_mb).max(512);
|
||||
let min = pick(ov.memory_min_mb, settings.memory_min_mb, inst.memory.min_mb).min(max);
|
||||
let mut jvm = launch::split_args(&inst.jvm_args);
|
||||
if advanced {
|
||||
jvm.extend(launch::split_args(&settings.jvm_args));
|
||||
jvm.extend(launch::split_args(&ov.jvm_args));
|
||||
}
|
||||
let branding = state.manifest.read().unwrap().as_ref().map(|m| m.branding.clone()).unwrap_or_default();
|
||||
let opts = LaunchOptions {
|
||||
auth: auth.clone(),
|
||||
game_dir: game_dir.clone(),
|
||||
memory_min_mb: min,
|
||||
memory_max_mb: max,
|
||||
gc: settings.gc,
|
||||
extra_jvm_args: jvm,
|
||||
resolution: settings.custom_resolution.then_some((settings.width, settings.height)),
|
||||
fullscreen: settings.fullscreen,
|
||||
join_server: inst.server.as_ref().filter(|s| s.auto_join && !s.address.is_empty()).map(|s| (s.address.clone(), s.port)),
|
||||
version_label: if branding.version_label.is_empty() { branding.name.clone() } else { branding.version_label.clone() },
|
||||
launcher_name: "scopenet".into(),
|
||||
launcher_version: env!("CARGO_PKG_VERSION").into(),
|
||||
};
|
||||
let cmd = launch::build(&installed, &state.layout, &opts);
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
if close_after {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
app.exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
|
||||
*state.game.lock().unwrap() = Some(RunningGame { instance_id: instance_id.clone(), kill: kill_tx });
|
||||
emit_state(&app, &instance_id, "running", None);
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match settings.after_launch.as_str() {
|
||||
"hide" => {
|
||||
window.hide().ok();
|
||||
}
|
||||
"minimize" => {
|
||||
window.minimize().ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream logs to the UI in small batches (cheap IPC), keep a tail for crash reports.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
for stream in [child.stdout.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>), child.stderr.take().map(|s| Box::new(s) as Box<dyn tokio::io::AsyncRead + Unpin + Send>)].into_iter().flatten() {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stream).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(tx);
|
||||
|
||||
let app2 = app.clone();
|
||||
let id2 = instance_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut tail: VecDeque<String> = VecDeque::with_capacity(200);
|
||||
let mut batch: Vec<String> = Vec::new();
|
||||
let mut kill_rx = kill_rx;
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(150));
|
||||
let mut streams_open = true;
|
||||
let mut kill_armed = true;
|
||||
let mut killed = false;
|
||||
let status = loop {
|
||||
tokio::select! {
|
||||
line = rx.recv(), if streams_open => match line {
|
||||
Some(l) => {
|
||||
if tail.len() == 200 { tail.pop_front(); }
|
||||
tail.push_back(l.clone());
|
||||
batch.push(l);
|
||||
}
|
||||
None => streams_open = false,
|
||||
},
|
||||
_ = tick.tick() => {
|
||||
if !batch.is_empty() {
|
||||
app2.emit("game://log", std::mem::take(&mut batch)).ok();
|
||||
}
|
||||
},
|
||||
res = &mut kill_rx, if kill_armed => {
|
||||
kill_armed = false;
|
||||
if res.is_ok() {
|
||||
killed = true;
|
||||
child.kill().await.ok();
|
||||
}
|
||||
},
|
||||
status = child.wait() => break status.ok(),
|
||||
}
|
||||
};
|
||||
// Drain whatever is left.
|
||||
while let Ok(l) = rx.try_recv() {
|
||||
tail.push_back(l.clone());
|
||||
batch.push(l);
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
app2.emit("game://log", batch).ok();
|
||||
}
|
||||
let code = status.and_then(|s| s.code());
|
||||
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>();
|
||||
*st.game.lock().unwrap() = None;
|
||||
if let Some(w) = app2.get_webview_window("main") {
|
||||
w.show().ok();
|
||||
w.unminimize().ok();
|
||||
if crashed {
|
||||
w.set_focus().ok();
|
||||
}
|
||||
}
|
||||
app2.emit("game://exit", ExitPayload { instance_id: id2.clone(), code, crashed, tail: tail.into_iter().collect(), crash_report }).ok();
|
||||
emit_state(&app2, &id2, "idle", None);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn newest_crash_report(app: &AppHandle, id: &str) -> Option<String> {
|
||||
let dir = app.state::<AppState>().layout.instance_dir(id).join("crash-reports");
|
||||
let newest = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
|
||||
.max_by_key(|(t, _)| *t)?;
|
||||
// Only if it was written in the last few minutes (i.e. by this session).
|
||||
(newest.0.elapsed().ok()? < Duration::from_secs(300)).then(|| newest.1.to_string_lossy().into_owned())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! ScopeNet launcher (Tauri shell). The heavy lifting lives in
|
||||
//! `scopenet-core`; this crate wires it to the UI.
|
||||
|
||||
mod accounts;
|
||||
mod commands;
|
||||
mod game;
|
||||
mod secrets;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod updater;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
|
||||
tauri::Builder::default()
|
||||
// A second launch just focuses the existing window.
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
w.show().ok();
|
||||
w.unminimize().ok();
|
||||
w.set_focus().ok();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
app.manage(state::AppState::new(data_dir));
|
||||
// The window starts hidden and the UI reveals it once painted (no
|
||||
// white flash). If the UI never does, show it anyway.
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(4));
|
||||
if let Some(w) = handle.get_webview_window("main") {
|
||||
if !w.is_visible().unwrap_or(true) {
|
||||
tracing::warn!("UI didn't signal ready; showing window");
|
||||
w.show().ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::bootstrap,
|
||||
commands::refresh_manifest,
|
||||
commands::set_panel_url,
|
||||
commands::save_settings,
|
||||
commands::login_panel,
|
||||
commands::register_panel,
|
||||
commands::add_offline,
|
||||
commands::ms_start,
|
||||
commands::ms_finish,
|
||||
commands::ms_cancel,
|
||||
commands::select_account,
|
||||
commands::remove_account,
|
||||
commands::launch,
|
||||
commands::repair_instance,
|
||||
commands::cancel_launch,
|
||||
commands::kill_game,
|
||||
commands::instance_local,
|
||||
commands::ping_server,
|
||||
commands::open_folder,
|
||||
commands::open_file,
|
||||
commands::storage_info,
|
||||
commands::delete_instance_data,
|
||||
commands::clear_cache,
|
||||
commands::detect_java,
|
||||
commands::check_update,
|
||||
commands::install_update,
|
||||
commands::show_window,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running the launcher");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Hide the console window on Windows release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
scopenet_launcher_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Encrypted storage for tokens (panel sessions, Microsoft refresh tokens).
|
||||
//!
|
||||
//! Secrets live in `secrets.bin`, encrypted with ChaCha20-Poly1305. The key
|
||||
//! is kept in the OS credential store (Windows Credential Manager / macOS
|
||||
//! Keychain), so copying the data folder to another machine doesn't leak
|
||||
//! sessions. Where no credential store exists we fall back to a key file.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use base64::Engine;
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Secret {
|
||||
pub panel_token: Option<String>,
|
||||
pub ms_refresh_token: Option<String>,
|
||||
pub mc_access_token: Option<String>,
|
||||
pub mc_expires_at: i64,
|
||||
pub xuid: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Secrets {
|
||||
path: PathBuf,
|
||||
key: [u8; 32],
|
||||
data: Mutex<HashMap<String, Secret>>,
|
||||
}
|
||||
|
||||
const SERVICE: &str = "net.scopenet.launcher";
|
||||
|
||||
fn load_or_create_key(dir: &std::path::Path) -> [u8; 32] {
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
{
|
||||
if let Ok(entry) = keyring::Entry::new(SERVICE, "secrets-key") {
|
||||
if let Ok(existing) = entry.get_password() {
|
||||
if let Ok(bytes) = b64.decode(existing) {
|
||||
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
if entry.set_password(&b64.encode(key)).is_ok() {
|
||||
return key;
|
||||
}
|
||||
tracing::warn!("credential store unavailable, falling back to a key file");
|
||||
}
|
||||
}
|
||||
let _ = SERVICE;
|
||||
let path = dir.join("secrets.key");
|
||||
if let Ok(bytes) = std::fs::read(&path).map(|b| b64.decode(b).unwrap_or_default()) {
|
||||
if let Ok(key) = <[u8; 32]>::try_from(bytes.as_slice()) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut key);
|
||||
std::fs::write(&path, b64.encode(key)).ok();
|
||||
key
|
||||
}
|
||||
|
||||
impl Secrets {
|
||||
pub fn open(dir: &std::path::Path) -> Self {
|
||||
let key = load_or_create_key(dir);
|
||||
let path = dir.join("secrets.bin");
|
||||
let data = std::fs::read(&path).ok().and_then(|raw| decrypt(&key, &raw).ok()).unwrap_or_default();
|
||||
Self { path, key, data: Mutex::new(data) }
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Secret {
|
||||
self.data.lock().unwrap().get(id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn set(&self, id: &str, secret: Secret) -> Result<()> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data.insert(id.to_string(), secret);
|
||||
self.flush(&data)
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: &str) -> Result<()> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data.remove(id);
|
||||
self.flush(&data)
|
||||
}
|
||||
|
||||
fn flush(&self, data: &HashMap<String, Secret>) -> Result<()> {
|
||||
let plain = serde_json::to_vec(data)?;
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&self.key));
|
||||
let mut nonce = [0u8; 12];
|
||||
rand::thread_rng().fill_bytes(&mut nonce);
|
||||
let mut out = nonce.to_vec();
|
||||
out.extend(cipher.encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|_| anyhow!("encryption failed"))?);
|
||||
let tmp = self.path.with_extension("bin.tmp");
|
||||
std::fs::write(&tmp, out)?;
|
||||
std::fs::rename(tmp, &self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn decrypt(key: &[u8; 32], raw: &[u8]) -> Result<HashMap<String, Secret>> {
|
||||
if raw.len() < 13 {
|
||||
return Err(anyhow!("secrets file too short"));
|
||||
}
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
|
||||
let plain = cipher.decrypt(Nonce::from_slice(&raw[..12]), &raw[12..]).map_err(|_| anyhow!("can't decrypt secrets"))?;
|
||||
Ok(serde_json::from_slice(&plain)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let dir = std::env::temp_dir().join(format!("scopenet-secrets-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let s = Secrets::open(&dir);
|
||||
s.set("a", Secret { panel_token: Some("tok".into()), ..Default::default() }).unwrap();
|
||||
let raw = std::fs::read(dir.join("secrets.bin")).unwrap();
|
||||
assert!(!String::from_utf8_lossy(&raw).contains("tok"), "stored encrypted");
|
||||
let again = Secrets::open(&dir);
|
||||
assert_eq!(again.get("a").panel_token.as_deref(), Some("tok"));
|
||||
std::fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Player settings, stored as JSON in the app data directory.
|
||||
|
||||
use scopenet_core::launch::GcPreset;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct InstanceOverride {
|
||||
/// 0 = use the global / instance default.
|
||||
pub memory_max_mb: u32,
|
||||
pub memory_min_mb: u32,
|
||||
pub jvm_args: String,
|
||||
pub java_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// Panel URL chosen by the player (ignored when one is baked in and locked).
|
||||
pub panel_url: Option<String>,
|
||||
pub selected_instance: Option<String>,
|
||||
|
||||
// ---- Game ----
|
||||
/// 0 = use the value the admin set for the instance.
|
||||
pub memory_max_mb: u32,
|
||||
pub memory_min_mb: u32,
|
||||
pub custom_resolution: bool,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fullscreen: bool,
|
||||
/// "minimize" | "hide" | "keep" | "close"
|
||||
pub after_launch: String,
|
||||
pub show_console: bool,
|
||||
|
||||
// ---- Java ----
|
||||
pub java_path: Option<String>,
|
||||
pub gc: GcPreset,
|
||||
pub jvm_args: String,
|
||||
|
||||
// ---- Appearance ----
|
||||
/// "server" (admin's theme) | "custom"
|
||||
pub theme_mode: String,
|
||||
pub accent: Option<String>,
|
||||
pub background_video: bool,
|
||||
pub reduce_motion: bool,
|
||||
/// None = follow the admin's setting.
|
||||
pub glass: Option<bool>,
|
||||
pub ui_scale: u32,
|
||||
|
||||
// ---- Launcher ----
|
||||
pub concurrent_downloads: u32,
|
||||
pub check_updates: bool,
|
||||
pub send_stats: bool,
|
||||
|
||||
// ---- Controls ----
|
||||
pub keybinds_enabled: bool,
|
||||
pub keybinds: BTreeMap<String, String>,
|
||||
|
||||
pub instance_overrides: HashMap<String, InstanceOverride>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
panel_url: None,
|
||||
selected_instance: None,
|
||||
memory_max_mb: 0,
|
||||
memory_min_mb: 0,
|
||||
custom_resolution: false,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
fullscreen: false,
|
||||
after_launch: "minimize".into(),
|
||||
show_console: false,
|
||||
java_path: None,
|
||||
gc: GcPreset::G1,
|
||||
jvm_args: String::new(),
|
||||
theme_mode: "server".into(),
|
||||
accent: None,
|
||||
background_video: true,
|
||||
reduce_motion: false,
|
||||
glass: None,
|
||||
ui_scale: 100,
|
||||
concurrent_downloads: 12,
|
||||
check_updates: true,
|
||||
send_stats: true,
|
||||
keybinds_enabled: false,
|
||||
keybinds: BTreeMap::new(),
|
||||
instance_overrides: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn load(path: &Path) -> Self {
|
||||
std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
|
||||
std::fs::rename(tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use crate::accounts::AccountsFile;
|
||||
use crate::secrets::Secrets;
|
||||
use crate::settings::Settings;
|
||||
use scopenet_core::msa::DeviceCode;
|
||||
use scopenet_core::Layout;
|
||||
use scopenet_shared::LauncherManifest;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
|
||||
/// Values baked in at build time by CI (see `.github/workflows/release.yml`).
|
||||
pub mod build {
|
||||
/// Default panel URL for this build.
|
||||
pub const PANEL_URL: Option<&str> = option_env!("SCOPENET_PANEL_URL");
|
||||
/// `owner/repo` used to check GitHub releases for updates.
|
||||
pub const REPO: Option<&str> = option_env!("SCOPENET_REPO");
|
||||
/// When set to "1", players can't change the panel URL.
|
||||
pub const LOCK_PANEL: Option<&str> = option_env!("SCOPENET_LOCK_PANEL");
|
||||
|
||||
pub fn panel_locked() -> bool {
|
||||
LOCK_PANEL == Some("1") && PANEL_URL.is_some_and(|u| !u.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RunningGame {
|
||||
pub instance_id: String,
|
||||
pub kill: tokio::sync::oneshot::Sender<()>,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub data_dir: PathBuf,
|
||||
pub layout: Layout,
|
||||
pub http: reqwest::Client,
|
||||
pub settings: RwLock<Settings>,
|
||||
pub accounts: RwLock<AccountsFile>,
|
||||
pub secrets: Secrets,
|
||||
pub manifest: RwLock<Option<LauncherManifest>>,
|
||||
pub device_code: Mutex<Option<DeviceCode>>,
|
||||
pub ms_cancel: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
|
||||
/// Install/launch pipeline in progress (abortable).
|
||||
pub task: Mutex<Option<tokio::task::AbortHandle>>,
|
||||
pub game: Mutex<Option<RunningGame>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
let settings = Settings::load(&data_dir.join("settings.json"));
|
||||
let accounts = AccountsFile::load(&data_dir.join("accounts.json"));
|
||||
let manifest = std::fs::read(data_dir.join("manifest.json")).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
Self {
|
||||
layout: Layout::new(data_dir.join("minecraft")),
|
||||
http: scopenet_core::http::client(),
|
||||
secrets: Secrets::open(&data_dir),
|
||||
settings: RwLock::new(settings),
|
||||
accounts: RwLock::new(accounts),
|
||||
manifest: RwLock::new(manifest),
|
||||
device_code: Mutex::new(None),
|
||||
ms_cancel: Mutex::new(None),
|
||||
task: Mutex::new(None),
|
||||
game: Mutex::new(None),
|
||||
data_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel this launcher talks to (baked-in when locked).
|
||||
pub fn panel_url(&self) -> Option<String> {
|
||||
let chosen = self.settings.read().unwrap().panel_url.clone().filter(|u| !u.is_empty());
|
||||
let baked = build::PANEL_URL.filter(|u| !u.is_empty()).map(String::from);
|
||||
let url = if build::panel_locked() { baked } else { chosen.or(baked) };
|
||||
url.map(|u| u.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
pub fn save_settings(&self) -> anyhow::Result<()> {
|
||||
self.settings.read().unwrap().save(&self.data_dir.join("settings.json"))
|
||||
}
|
||||
|
||||
pub fn save_accounts(&self) -> anyhow::Result<()> {
|
||||
self.accounts.read().unwrap().save(&self.data_dir.join("accounts.json"))
|
||||
}
|
||||
|
||||
pub fn cache_manifest(&self, m: &LauncherManifest) {
|
||||
*self.manifest.write().unwrap() = Some(m.clone());
|
||||
if let Ok(bytes) = serde_json::to_vec(m) {
|
||||
std::fs::write(self.data_dir.join("manifest.json"), bytes).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Self-update via GitHub Releases: find a newer `*-setup.exe`, download
|
||||
//! it and hand over to the installer.
|
||||
|
||||
use crate::state::build;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub notes: String,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
#[serde(default)]
|
||||
body: String,
|
||||
#[serde(default)]
|
||||
draft: bool,
|
||||
#[serde(default)]
|
||||
prerelease: bool,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
fn parse(v: &str) -> Vec<u64> {
|
||||
v.trim_start_matches('v').split(['.', '-', '+']).map_while(|p| p.parse().ok()).collect()
|
||||
}
|
||||
|
||||
pub fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
parse(candidate) > parse(current)
|
||||
}
|
||||
|
||||
pub async fn check(http: &reqwest::Client) -> Result<Option<UpdateInfo>> {
|
||||
let Some(repo) = build::REPO.filter(|r| !r.is_empty()) else { return Ok(None) };
|
||||
let release: Release = http
|
||||
.get(format!("https://api.github.com/repos/{repo}/releases/latest"))
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
if release.draft || release.prerelease || !is_newer(&release.tag_name, env!("CARGO_PKG_VERSION")) {
|
||||
return Ok(None);
|
||||
}
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.to_lowercase().ends_with("-setup.exe"))
|
||||
.ok_or_else(|| anyhow!("release {} has no installer", release.tag_name))?;
|
||||
Ok(Some(UpdateInfo {
|
||||
version: release.tag_name.trim_start_matches('v').to_string(),
|
||||
notes: release.body,
|
||||
url: asset.browser_download_url.clone(),
|
||||
size: asset.size,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Download the installer and start it. The caller exits the app afterwards.
|
||||
pub async fn download_and_run(http: &reqwest::Client, url: &str) -> Result<()> {
|
||||
if !url.starts_with("https://github.com/") && !url.starts_with("https://objects.githubusercontent.com/") {
|
||||
anyhow::bail!("refusing to download an update from an unexpected host");
|
||||
}
|
||||
let dest = std::env::temp_dir().join("scopenet-launcher-setup.exe");
|
||||
let dl = scopenet_core::http::Download::new(url, dest.clone(), None, None);
|
||||
scopenet_core::http::download_one(http, &dl, &|_| {}).await.context("downloading update")?;
|
||||
// /P = passive (progress only), /R = relaunch when done (Tauri NSIS).
|
||||
std::process::Command::new(&dest).args(["/P", "/R"]).spawn().context("starting the installer")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn versions() {
|
||||
assert!(is_newer("v0.2.0", "0.1.9"));
|
||||
assert!(is_newer("1.0.0", "0.9.12"));
|
||||
assert!(!is_newer("v0.1.0", "0.1.0"));
|
||||
assert!(!is_newer("0.1.0-beta", "0.1.0"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user