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:
Claude committed 2026-09-28 08:58:27 +00:00
1 parent 99b45141fc
commit 77a15cab8b
18 files changed
+454 -208

No files matched your search

+53 -31
View File
@@ -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]