diff --git a/Cargo.lock b/Cargo.lock index f4a80b1..b2bf4b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3642,6 +3642,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", diff --git a/Cargo.toml b/Cargo.toml index 279d7d3..c7a8a40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,3 +38,6 @@ strip = true [profile.dev.package."*"] opt-level = 1 +# Dependencies rarely need a debugger; skipping their debug info keeps +# target/ several times smaller. +debug = false diff --git a/launcher/src-tauri/Cargo.toml b/launcher/src-tauri/Cargo.toml index f1537ad..c708e0a 100644 --- a/launcher/src-tauri/Cargo.toml +++ b/launcher/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true tokio.workspace = true -reqwest.workspace = true +reqwest = { workspace = true, features = ["multipart"] } futures.workspace = true uuid.workspace = true base64.workspace = true diff --git a/launcher/src-tauri/src/accounts.rs b/launcher/src-tauri/src/accounts.rs index a9a8923..7461005 100644 --- a/launcher/src-tauri/src/accounts.rs +++ b/launcher/src-tauri/src/accounts.rs @@ -1,16 +1,18 @@ -//! Player accounts: panel (username/password), Microsoft, and local offline. +//! Player accounts: panel accounts (authenticated by the panel's Yggdrasil +//! server through authlib-injector) and local offline accounts. 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 serde_json::json; use std::path::Path; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Account { pub id: String, - /// "panel" | "microsoft" | "offline" + /// "panel" | "offline" pub kind: String, pub username: String, pub uuid: String, @@ -30,7 +32,13 @@ pub struct AccountsFile { 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() + let mut file: Self = std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + // Microsoft accounts are no longer supported. + file.accounts.retain(|a| a.kind == "panel" || a.kind == "offline"); + if file.active.as_ref().is_some_and(|id| !file.accounts.iter().any(|a| &a.id == id)) { + file.active = file.accounts.first().map(|a| a.id.clone()); + } + file } pub fn save(&self, path: &Path) -> Result<()> { @@ -59,14 +67,17 @@ impl AccountsFile { } } -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}"))) + anyhow!( + "{}", + body.get("error") + .and_then(|e| e.as_str()) + .or_else(|| body.get("errorMessage").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 { @@ -114,7 +125,11 @@ fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Resu 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() })?; + let ygg = auth.yggdrasil.ok_or_else(|| anyhow!("the server didn't start a game session — is the panel up to date?"))?; + state.secrets.set( + &account.id, + Secret { panel_token: Some(auth.token), ygg_access_token: Some(ygg.access_token), ygg_client_token: Some(ygg.client_token) }, + )?; state.save_accounts()?; Ok(account) } @@ -141,93 +156,82 @@ pub fn add_offline(state: &AppState, username: &str) -> Result { Ok(account) } -pub fn ms_client_id(state: &AppState) -> Result { - state +/// The panel's Yggdrasil API root for an account. +pub fn yggdrasil_url(state: &AppState, account: &Account) -> Option { + let panel = account.panel_url.clone()?; + let from_manifest = 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")) + .filter(|_| state.panel_url().as_deref() == Some(panel.as_str())) + .and_then(|m| m.auth.yggdrasil_url.clone()); + Some(from_manifest.unwrap_or_else(|| format!("{panel}/api/yggdrasil"))) } -pub fn save_ms_session(state: &AppState, session: scopenet_core::msa::MsaSession) -> Result { - 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, +/// Make sure the account's game session is valid, refreshing it if needed. +/// Returns the access token. +async fn ensure_session(state: &AppState, account: &Account, api: &str) -> Result { + let secret = state.secrets.get(&account.id); + let (Some(access), Some(client)) = (secret.ygg_access_token.clone(), secret.ygg_client_token.clone()) else { + bail!("please sign in to {} again", account.username); }; - 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; + let validate = state + .http + .post(format!("{api}/authserver/validate")) + .json(&json!({ "accessToken": access, "clientToken": client })) + .send() + .await + .context("couldn't reach the auth server")?; + if validate.status().is_success() { + return Ok(access); } - format!("{}-{}-{}-{}-{}", &id[0..8], &id[8..12], &id[12..16], &id[16..20], &id[20..32]) + let resp = state + .http + .post(format!("{api}/authserver/refresh")) + .json(&json!({ "accessToken": access, "clientToken": client })) + .send() + .await + .context("couldn't reach the auth server")?; + if !resp.status().is_success() { + bail!("your session for {} expired — please sign in again", account.username); + } + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Refreshed { + access_token: String, + client_token: String, + } + let r: Refreshed = resp.json().await?; + state + .secrets + .set(&account.id, Secret { ygg_access_token: Some(r.access_token.clone()), ygg_client_token: Some(r.client_token), ..secret })?; + Ok(r.access_token) } -/// Credentials passed to the game. -pub async fn game_auth(state: &AppState, account: &Account) -> Result { +/// Credentials passed to the game, plus the auth server URL for +/// authlib-injector (panel accounts only). +pub async fn game_auth(state: &AppState, account: &Account) -> Result<(scopenet_core::launch::Auth, Option)> { 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, - }) + "panel" => { + let api = yggdrasil_url(state, account).ok_or_else(|| anyhow!("account has no server"))?; + let access_token = ensure_session(state, account, &api).await?; + Ok(( + Auth { username: account.username.clone(), uuid: account.uuid.clone(), access_token, user_type: "mojang".into() }, + Some(api), + )) } - _ => 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, - }), + _ => Ok(( + Auth { + username: account.username.clone(), + uuid: account.uuid.clone(), + // Offline mode: any token works; offline servers ignore it. + access_token: "0".into(), + user_type: "legacy".into(), + }, + None, + )), } } @@ -254,6 +258,21 @@ mod tests { 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"); + } + + #[test] + fn drops_microsoft_accounts_on_load() { + let dir = std::env::temp_dir().join(format!("scopenet-acc-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("accounts.json"); + std::fs::write( + &path, + r#"{"accounts":[{"id":"m","kind":"microsoft","username":"A","uuid":"x"},{"id":"o","kind":"offline","username":"B","uuid":"y"}],"active":"m"}"#, + ) + .unwrap(); + let f = AccountsFile::load(&path); + assert_eq!(f.accounts.len(), 1); + assert_eq!(f.active.as_deref(), Some("o")); + std::fs::remove_dir_all(dir).ok(); } } diff --git a/launcher/src-tauri/src/commands.rs b/launcher/src-tauri/src/commands.rs index 5d57c16..183fba8 100644 --- a/launcher/src-tauri/src/commands.rs +++ b/launcher/src-tauri/src/commands.rs @@ -5,9 +5,8 @@ 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 scopenet_shared::{LauncherManifest, PlayerProfile}; use serde::Serialize; use tauri::{AppHandle, Manager, State}; use tauri_plugin_opener::OpenerExt; @@ -140,35 +139,6 @@ pub fn add_offline(state: State<'_, AppState>, username: String) -> Res accounts::add_offline(&state, &username).map_err(aerr) } -#[tauri::command] -pub async fn ms_start(state: State<'_, AppState>) -> Res { - 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 { - 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(); @@ -192,6 +162,58 @@ pub fn remove_account(state: State<'_, AppState>, id: String) -> Res<()> { 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 +} + // ---- game ---- #[tauri::command] diff --git a/launcher/src-tauri/src/game.rs b/launcher/src-tauri/src/game.rs index 4890d69..0aa05ef 100644 --- a/launcher/src-tauri/src/game.rs +++ b/launcher/src-tauri/src/game.rs @@ -131,7 +131,20 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) - emit_state(&app, &instance_id, "idle", Some("Instance is up to date".into())); return Ok(()); } - let auth = auth.unwrap(); + let (auth, auth_server) = auth.unwrap(); + + // Panel accounts authenticate through the panel's Yggdrasil server via + // authlib-injector; offline accounts launch without it. + let agent_args = match &auth_server { + Some(api) => { + report(Event::Stage { stage: Stage::Launching, label: "Preparing sign-in".into() }); + let jar = + scopenet_core::authlib::ensure(&state.http, &state.layout, Some(&panel)).await.context("setting up authlib-injector")?; + let prefetched = scopenet_core::authlib::prefetch_metadata(&state.http, api).await.ok(); + scopenet_core::authlib::jvm_args(&jar, api, prefetched.as_deref()) + } + None => vec![], + }; report(Event::Stage { stage: Stage::Launching, label: "Starting Minecraft".into() }); let pick = |a: u32, b: u32, c: u32| { @@ -153,6 +166,7 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) - let branding = state.manifest.read().unwrap().as_ref().map(|m| m.branding.clone()).unwrap_or_default(); let opts = LaunchOptions { auth: auth.clone(), + agent_args, game_dir: game_dir.clone(), memory_min_mb: min, memory_max_mb: max, diff --git a/launcher/src-tauri/src/lib.rs b/launcher/src-tauri/src/lib.rs index f2ecaee..88d1841 100644 --- a/launcher/src-tauri/src/lib.rs +++ b/launcher/src-tauri/src/lib.rs @@ -52,9 +52,11 @@ pub fn run() { commands::login_panel, commands::register_panel, commands::add_offline, - commands::ms_start, - commands::ms_finish, - commands::ms_cancel, + commands::account_profile, + commands::upload_skin, + commands::set_skin_model, + commands::delete_skin, + commands::set_cape, commands::select_account, commands::remove_account, commands::launch, diff --git a/launcher/src-tauri/src/secrets.rs b/launcher/src-tauri/src/secrets.rs index 7ec0120..30f1e97 100644 --- a/launcher/src-tauri/src/secrets.rs +++ b/launcher/src-tauri/src/secrets.rs @@ -1,4 +1,4 @@ -//! Encrypted storage for tokens (panel sessions, Microsoft refresh tokens). +//! Encrypted storage for tokens (panel sessions and game sessions). //! //! Secrets live in `secrets.bin`, encrypted with ChaCha20-Poly1305. The key //! is kept in the OS credential store (Windows Credential Manager / macOS @@ -18,11 +18,11 @@ use std::sync::Mutex; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Secret { + /// Panel API session (manifest, account/skin API). pub panel_token: Option, - pub ms_refresh_token: Option, - pub mc_access_token: Option, - pub mc_expires_at: i64, - pub xuid: Option, + /// Game session from the panel's Yggdrasil server. + pub ygg_access_token: Option, + pub ygg_client_token: Option, } pub struct Secrets { diff --git a/launcher/src-tauri/src/state.rs b/launcher/src-tauri/src/state.rs index 09a19b6..bddcfb2 100644 --- a/launcher/src-tauri/src/state.rs +++ b/launcher/src-tauri/src/state.rs @@ -1,7 +1,6 @@ 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; @@ -34,8 +33,6 @@ pub struct AppState { pub accounts: RwLock, pub secrets: Secrets, pub manifest: RwLock>, - pub device_code: Mutex>, - pub ms_cancel: Mutex>>, /// Install/launch pipeline in progress (abortable). pub task: Mutex>, pub game: Mutex>, @@ -54,8 +51,6 @@ impl AppState { 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, diff --git a/launcher/src/components/Avatar.svelte b/launcher/src/components/Avatar.svelte index 64e9cba..ded9009 100644 --- a/launcher/src/components/Avatar.svelte +++ b/launcher/src/components/Avatar.svelte @@ -1,18 +1,24 @@ -{#if account && !failed} +{#if src && !failed} (failed = true)} /> {:else} - {account?.username.slice(0, 1).toUpperCase() ?? '?'} + {account?.username.slice(0, 1).toUpperCase() ?? '?'} {/if} diff --git a/launcher/src/components/Rail.svelte b/launcher/src/components/Rail.svelte index d93c25e..98443c7 100644 --- a/launcher/src/components/Rail.svelte +++ b/launcher/src/components/Rail.svelte @@ -4,7 +4,7 @@ import { abs, activeAccount, app, instances, removeAccount, selectInstance, switchAccount } from '../lib/store.svelte'; let menu = $state(false); - const kindLabel = { panel: 'Account', microsoft: 'Microsoft', offline: 'Offline' }; + const kindLabel = { panel: 'Account', offline: 'Offline' }; const initials = (name: string) => name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase(); diff --git a/launcher/src/components/SignIn.svelte b/launcher/src/components/SignIn.svelte index 2be89cf..ac29469 100644 --- a/launcher/src/components/SignIn.svelte +++ b/launcher/src/components/SignIn.svelte @@ -1,14 +1,13 @@ - {:else if method === 'microsoft'} - {#if code} -
-

Go to {code.verification_uri.replace('https://', '')} and enter:

- -
- - -
-

Waiting for you to finish in the browser…

-
- {:else} -

Own Minecraft? Sign in with Microsoft for your real skin and access to online-mode servers.

- - {/if} {:else}