Launcher: panel accounts via authlib-injector, skin & cape editor
Microsoft sign-in is gone. Server accounts now receive Yggdrasil tokens from the panel and launch with authlib-injector pointed at the panel's auth server, so online-mode servers verify players against it. - Download authlib-injector from the panel mirror (fallback: official), SHA-256 verified and cached, with prefetched metadata - New Skin & cape settings tab: upload, arm style, reset, cape picker with front/back previews - Avatars render from the panel's head endpoint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
18 files changed
+454
-208
No files matched your search
@@ -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<Account> {
|
||||
@@ -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<Account> {
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub fn ms_client_id(state: &AppState) -> Result<String> {
|
||||
state
|
||||
/// The panel's Yggdrasil API root for an account.
|
||||
pub fn yggdrasil_url(state: &AppState, account: &Account) -> Option<String> {
|
||||
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<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,
|
||||
/// 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<String> {
|
||||
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<scopenet_core::launch::Auth> {
|
||||
/// 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<String>)> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<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();
|
||||
@@ -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<reqwest::RequestBuilder> {
|
||||
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<PlayerProfile> {
|
||||
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<PlayerProfile> {
|
||||
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<PlayerProfile> {
|
||||
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<PlayerProfile> {
|
||||
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<PlayerProfile> {
|
||||
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<i64>) -> Res<PlayerProfile> {
|
||||
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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
pub ms_refresh_token: Option<String>,
|
||||
pub mc_access_token: Option<String>,
|
||||
pub mc_expires_at: i64,
|
||||
pub xuid: Option<String>,
|
||||
/// Game session from the panel's Yggdrasil server.
|
||||
pub ygg_access_token: Option<String>,
|
||||
pub ygg_client_token: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Secrets {
|
||||
|
||||
@@ -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<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>>,
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user