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

Generated
+1
View File
@@ -3642,6 +3642,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
+3
View File
@@ -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
+1 -1
View File
@@ -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
+103 -84
View File
@@ -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();
}
}
+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]
+15 -1
View File
@@ -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,
+5 -3
View File
@@ -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,
+5 -5
View File
@@ -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 {
-5
View File
@@ -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,
+11 -5
View File
@@ -1,18 +1,24 @@
<script lang="ts">
import { app } from '../lib/store.svelte';
import type { Account } from '../lib/types';
let { account, size = 2.2 }: { account: Account | null; size?: number } = $props();
let failed = $state(false);
const hue = $derived([...(account?.username ?? '?')].reduce((a, c) => a + c.charCodeAt(0), 0) % 360);
// Microsoft accounts have real skins; everyone else gets a head by name.
const src = $derived(account ? `https://mc-heads.net/avatar/${account.kind === 'microsoft' ? account.uuid : encodeURIComponent(account.username)}/64` : '');
// Server accounts: head rendered by the panel from the player's skin.
const src = $derived(account?.kind === 'panel' && account.panel_url ? `${account.panel_url}/api/v1/avatar/${account.uuid}?size=64&v=${app.skinVersion}` : null);
$effect(() => {
void src;
failed = false;
});
</script>
{#if account && !failed}
{#if src && !failed}
<img {src} alt="" style:width="{size}rem" style:height="{size}rem" onerror={() => (failed = true)} />
{:else}
<span class="fb" style:width="{size}rem" style:height="{size}rem" style:background="hsl({hue} 55% 42%)">{account?.username.slice(0, 1).toUpperCase() ?? '?'}</span>
<span class="fb" style:width="{size}rem" style:height="{size}rem" style:background="hsl({hue} 45% 40%)">{account?.username.slice(0, 1).toUpperCase() ?? '?'}</span>
{/if}
<style>
img, .fb { border-radius: 28%; image-rendering: pixelated; flex-shrink: 0; display: inline-grid; place-items: center; font-weight: 700; color: white; font-size: 0.9rem; }
img, .fb { border-radius: 25%; image-rendering: pixelated; flex-shrink: 0; display: inline-grid; place-items: center; font-weight: 650; color: white; font-size: 0.9rem; }
</style>
+1 -1
View File
@@ -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();
</script>
+3 -53
View File
@@ -1,14 +1,13 @@
<script lang="ts">
import { UserRound, WifiOff, LoaderCircle, Copy, ExternalLink, Check, ArrowRight, UserPlus } from '@lucide/svelte';
import { UserRound, WifiOff, LoaderCircle, Check, ArrowRight, UserPlus } from '@lucide/svelte';
import { accountAdded, app, toast } from '../lib/store.svelte';
import { errorText, invoke, openUrl } from '../lib/tauri';
import type { Account, DeviceCode } from '../lib/types';
import { errorText, invoke } from '../lib/tauri';
import type { Account } from '../lib/types';
const auth = $derived(app.manifest?.auth);
const methods = $derived(
[
auth?.panel_accounts !== false && { id: 'panel', label: 'Account' },
auth?.microsoft && { id: 'microsoft', label: 'Microsoft' },
auth?.offline_local !== false && { id: 'offline', label: 'Offline' },
].filter(Boolean) as { id: string; label: string }[],
);
@@ -24,8 +23,6 @@
let busy = $state(false);
let error = $state('');
let pendingNotice = $state(false);
let code = $state<DeviceCode | null>(null);
let copied = $state(false);
const canRegister = $derived(auth?.registration && auth.registration !== 'closed');
@@ -51,30 +48,6 @@
}
}
async function microsoft() {
error = '';
busy = true;
try {
code = await invoke<DeviceCode>('ms_start');
openUrl(code.verification_uri);
const account = await invoke<Account>('ms_finish');
code = null;
await accountAdded(account);
toast(`Signed in as ${account.username}`);
} catch (err) {
if (errorText(err) !== 'cancelled') error = errorText(err);
code = null;
} finally {
busy = false;
}
}
function copy() {
if (!code) return;
navigator.clipboard.writeText(code.user_code);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<div class="signin">
@@ -92,24 +65,6 @@
<div><strong>Account created!</strong><p class="small muted">An admin needs to approve it before you can sign in. Check back soon.</p></div>
</div>
<button onclick={() => { pendingNotice = false; registering = false; }}>Back to sign in</button>
{:else if method === 'microsoft'}
{#if code}
<div class="code">
<p class="small muted">Go to <strong>{code.verification_uri.replace('https://', '')}</strong> and enter:</p>
<button class="usercode" onclick={copy} title="Copy">{code.user_code} {#if copied}<Check size={18} />{:else}<Copy size={16} />{/if}</button>
<div class="row">
<button class="sm" onclick={() => code && openUrl(code.verification_uri)}><ExternalLink size={14} /> Open page</button>
<button class="sm ghost" onclick={() => invoke('ms_cancel')}>Cancel</button>
</div>
<p class="tiny muted row wait"><LoaderCircle class="spin" size={14} /> Waiting for you to finish in the browser…</p>
</div>
{:else}
<p class="small muted">Own Minecraft? Sign in with Microsoft for your real skin and access to online-mode servers.</p>
<button class="primary big ms" onclick={microsoft} disabled={busy}>
<svg viewBox="0 0 21 21" width="18" height="18" aria-hidden="true"><rect x="1" y="1" width="9" height="9" fill="#f25022"/><rect x="11" y="1" width="9" height="9" fill="#7fba00"/><rect x="1" y="11" width="9" height="9" fill="#00a4ef"/><rect x="11" y="11" width="9" height="9" fill="#ffb900"/></svg>
Sign in with Microsoft
</button>
{/if}
{:else}
<form class="col" onsubmit={submit}>
<label class="field">
@@ -148,12 +103,7 @@
.inp .ic { position: absolute; left: 0.8rem; top: 50%; transform: translateY(-50%); color: var(--muted); display: flex; }
.inp input { padding-left: 2.4rem; }
.big { padding: 0.75rem; font-size: 0.95rem; }
.ms { background: color-mix(in srgb, var(--text) 8%, transparent); border: 1px solid var(--line-strong); color: var(--text); }
.ms:hover { background: color-mix(in srgb, var(--text) 12%, transparent); }
.switch { align-self: center; color: var(--muted); }
.code { display: flex; flex-direction: column; align-items: center; gap: 0.8rem; text-align: center; padding: 0.5rem 0; }
.usercode { font-family: var(--mono); font-size: 1.5rem; letter-spacing: 0.12em; padding: 0.6rem 1.2rem; font-weight: 600; }
.wait { gap: 0.4rem; }
.error { font-size: 0.87rem; padding: 0.7rem 0.9rem; border-radius: var(--radius-sm); color: color-mix(in srgb, var(--danger) 60%, white); background: color-mix(in srgb, var(--danger) 12%, transparent); border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); }
.notice { display: flex; gap: 0.8rem; align-items: flex-start; padding: 1rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--success) 12%, transparent); }
.notice :global(svg) { color: var(--success); flex-shrink: 0; }
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
// Flat front/back render of a Minecraft skin (+ cape) on a canvas.
let { skin, cape = null, slim = false, side = 'front', scale = 8 }: {
skin: string | null; cape?: string | null; slim?: boolean; side?: 'front' | 'back'; scale?: number;
} = $props();
let canvas = $state<HTMLCanvasElement>();
function load(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
type Part = [sx: number, sy: number, w: number, h: number, dx: number, dy: number, mirror?: boolean];
function parts(legacy: boolean): { base: Part[]; overlay: Part[] } {
const aw = slim ? 3 : 4;
const off = slim ? 1 : 0; // slim arms sit one pixel closer to the body
if (side === 'front') {
const base: Part[] = [
[8, 8, 8, 8, 4, 0],
[20, 20, 8, 12, 4, 8],
[44, 20, aw, 12, off, 8],
legacy ? [44, 20, aw, 12, 12, 8, true] : [36, 52, aw, 12, 12, 8],
[4, 20, 4, 12, 4, 20],
legacy ? [4, 20, 4, 12, 8, 20, true] : [20, 52, 4, 12, 8, 20],
];
const overlay: Part[] = [[40, 8, 8, 8, 4, 0]];
if (!legacy) overlay.push([20, 36, 8, 12, 4, 8], [44, 36, aw, 12, off, 8], [52, 52, aw, 12, 12, 8], [4, 36, 4, 12, 4, 20], [4, 52, 4, 12, 8, 20]);
return { base, overlay };
}
const rb = slim ? 51 : 52; // back face of the right arm
const lb = slim ? 43 : 44;
const base: Part[] = [
[24, 8, 8, 8, 4, 0],
[32, 20, 8, 12, 4, 8],
[rb, 20, aw, 12, 12, 8],
legacy ? [rb, 20, aw, 12, off, 8, true] : [lb, 52, aw, 12, off, 8],
[12, 20, 4, 12, 8, 20],
legacy ? [12, 20, 4, 12, 4, 20, true] : [28, 52, 4, 12, 4, 20],
];
const overlay: Part[] = [[56, 8, 8, 8, 4, 0]];
if (!legacy) overlay.push([32, 36, 8, 12, 4, 8], [rb, 36, aw, 12, 12, 8], [lb + 16, 52, aw, 12, off, 8], [12, 36, 4, 12, 8, 20], [12, 52, 4, 12, 4, 20]);
return { base, overlay };
}
function draw(ctx: CanvasRenderingContext2D, img: HTMLImageElement, [sx, sy, w, h, dx, dy, mirror]: Part) {
if (mirror) {
ctx.save();
ctx.translate((dx + w) * scale, dy * scale);
ctx.scale(-1, 1);
ctx.drawImage(img, sx, sy, w, h, 0, 0, w * scale, h * scale);
ctx.restore();
} else {
ctx.drawImage(img, sx, sy, w, h, dx * scale, dy * scale, w * scale, h * scale);
}
}
$effect(() => {
const el = canvas;
const src = skin, capeSrc = cape, s = side, sl = slim;
void s, sl;
if (!el) return;
const ctx = el.getContext('2d')!;
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, el.width, el.height);
let cancelled = false;
(async () => {
if (src) {
const img = await load(src).catch(() => null);
if (!img || cancelled) return;
const { base, overlay } = parts(img.height === 32);
base.forEach((p) => draw(ctx, img, p));
overlay.forEach((p) => draw(ctx, img, p));
} else {
// Silhouette placeholder.
ctx.fillStyle = 'rgba(255,255,255,0.08)';
for (const [x, y, w, h] of [[4, 0, 8, 8], [4, 8, 8, 12], [0, 8, 4, 12], [12, 8, 4, 12], [4, 20, 4, 12], [8, 20, 4, 12]]) {
ctx.fillRect(x * scale, y * scale, w * scale, h * scale);
}
}
if (capeSrc && s === 'back') {
const c = await load(capeSrc).catch(() => null);
if (!c || cancelled) return;
const u = c.width / 64; // HD capes
ctx.drawImage(c, 1 * u, 1 * u, 10 * u, 16 * u, 3 * scale, 8 * scale, 10 * scale, 16 * scale);
}
})();
return () => (cancelled = true);
});
</script>
<canvas bind:this={canvas} width={16 * scale} height={32 * scale} aria-label="Skin {side}"></canvas>
<style>
canvas { image-rendering: pixelated; display: block; }
</style>
+27 -7
View File
@@ -1,7 +1,7 @@
// In-browser stand-in for the Rust backend, used by `npm run dev` outside
// Tauri and for UI screenshots. Scenario via ?mock=setup|login|main.
import { mockEmit } from './tauri';
import type { Account, Bootstrap, Manifest, Settings } from './types';
import type { Account, Bootstrap, Manifest, PlayerProfile, Settings } from './types';
const scenario = new URLSearchParams(location.search).get('mock') ?? 'main';
@@ -37,7 +37,7 @@ const manifest: Manifest = {
api_version: 1,
panel_version: '0.1.0',
user: null,
auth: { panel_accounts: true, registration: 'approval', microsoft: true, microsoft_client_id: 'demo', offline_local: true },
auth: { panel_accounts: true, registration: 'approval', offline_local: true, yggdrasil_url: 'https://panel.scopenet.gg/api/yggdrasil' },
branding: {
name: 'SCOPENET',
tagline: 'Your worlds, one click away.',
@@ -76,6 +76,18 @@ const accounts: Account[] = scenario === 'main' || scenario === 'progress' ? [
let active: string | null = accounts[0]?.id ?? null;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const profile: PlayerProfile = {
uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f',
name: 'Notch',
skin_url: null,
skin_model: 'classic',
cape: null,
available_capes: [
{ id: 1, name: 'Founder', url: '' },
{ id: 2, name: 'Season 4', url: '' },
],
};
let running = false;
async function fakeLaunch(id: string) {
@@ -134,11 +146,19 @@ export async function mockInvoke(cmd: string, args: Record<string, any>): Promis
}
case 'register_panel':
return { account: null, pending: true };
case 'ms_start':
return { user_code: 'F7K2-QX9M', verification_uri: 'https://microsoft.com/link', expires_in: 900, interval: 5, message: '' };
case 'ms_finish':
await sleep(600_000);
return null;
case 'account_profile':
case 'set_cape':
case 'set_skin_model':
if (cmd === 'set_cape') profile.cape = profile.available_capes.find((c) => c.id === args.capeId) ?? null;
if (cmd === 'set_skin_model') profile.skin_model = args.model;
return structuredClone(profile);
case 'upload_skin':
profile.skin_url = 'data:image/png;base64,' + args.data;
profile.skin_model = args.model;
return structuredClone(profile);
case 'delete_skin':
profile.skin_url = null;
return structuredClone(profile);
case 'select_account':
active = args.id;
return null;
+2
View File
@@ -39,6 +39,8 @@ export const app = $state({
update: null as UpdateInfo | null,
addAccount: false,
instanceSettings: null as string | null,
/** Bumped after a skin change so avatars reload. */
skinVersion: 0,
toasts: [] as { id: number; text: string; kind: 'ok' | 'error' | 'info' }[],
});
+4 -4
View File
@@ -43,8 +43,7 @@ export interface Branding {
export interface AuthConfig {
panel_accounts: boolean;
registration: 'closed' | 'open' | 'approval';
microsoft: boolean;
microsoft_client_id: string | null;
yggdrasil_url: string | null;
offline_local: boolean;
}
@@ -57,7 +56,7 @@ export interface Manifest {
user: { id: number; username: string; uuid: string; role: string; groups: string[] } | null;
}
export interface Account { id: string; kind: 'panel' | 'microsoft' | 'offline'; username: string; uuid: string; panel_url: string | null; role: string | null }
export interface Account { id: string; kind: 'panel' | 'offline'; username: string; uuid: string; panel_url: string | null; role: string | null }
export type Gc = 'default' | 'g1' | 'zgc';
@@ -108,5 +107,6 @@ export type ProgressEvent =
| { type: 'log'; line: string };
export interface ServerStatus { online: boolean; version: string; players_online: number; players_max: number; sample: string[]; motd: string; favicon: string | null; latency_ms: number }
export interface DeviceCode { user_code: string; verification_uri: string; expires_in: number; interval: number; message: string }
export interface CapeInfo { id: number; name: string; url: string }
export interface PlayerProfile { uuid: string; name: string; skin_url: string | null; skin_model: 'classic' | 'slim'; cape: CapeInfo | null; available_capes: CapeInfo[] }
export interface UpdateInfo { version: string; notes: string; url: string; size: number }
+118 -7
View File
@@ -1,18 +1,21 @@
<script lang="ts">
import {
UserRound, Gamepad2, Cpu, Keyboard, Palette, Rocket, HardDrive, Info, Plus, LogOut, Check, FolderOpen, Trash2,
FileCode, RotateCcw, Download, LoaderCircle, RefreshCw, MousePointerClick, Zap, Gauge, Feather, Link2,
FileCode, RotateCcw, Download, LoaderCircle, RefreshCw, MousePointerClick, Zap, Gauge, Feather, Link2, Shirt, Upload, Ban,
} from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import SkinView from '../components/SkinView.svelte';
import Toggle from '../components/Toggle.svelte';
import { app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.svelte';
import { activeAccount, app, connect, instances, refresh, removeAccount, saveSettings, switchAccount, toast } from '../lib/store.svelte';
import { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys';
import { bytes, gb } from '../lib/format';
import { errorText, invoke, openUrl, pickFile } from '../lib/tauri';
import type { Gc, UpdateInfo } from '../lib/types';
import type { Gc, PlayerProfile, UpdateInfo } from '../lib/types';
const tabs = [
const serverAccount = $derived(activeAccount()?.kind === 'panel');
const tabs = $derived([
{ id: 'account', label: 'Accounts', icon: UserRound },
...(serverAccount ? [{ id: 'skin', label: 'Skin & cape', icon: Shirt }] : []),
{ id: 'game', label: 'Game', icon: Gamepad2 },
{ id: 'java', label: 'Java & performance', icon: Cpu },
{ id: 'controls', label: 'Controls', icon: Keyboard },
@@ -20,7 +23,7 @@
{ id: 'launcher', label: 'Launcher', icon: Rocket },
{ id: 'storage', label: 'Storage', icon: HardDrive },
{ id: 'about', label: 'About', icon: Info },
];
]);
const s = $derived(app.settings!);
const b = $derived(app.manifest?.branding);
@@ -29,7 +32,7 @@
const recommended = $derived(Math.min(8192, Math.max(2048, Math.floor(ram / 2 / 512) * 512)));
const advanced = $derived(b?.features.allow_advanced_java !== false);
const themeAllowed = $derived(b?.features.allow_user_theme !== false);
const kindLabel = { panel: 'Server account', microsoft: 'Microsoft account', offline: 'Offline' };
const kindLabel = { panel: 'Server account', offline: 'Offline' };
// ---- controls ----
let capturing = $state<string | null>(null);
@@ -54,6 +57,48 @@
return new Set([...seen].filter(([, n]) => n > 1).map(([k]) => k));
});
// ---- skin & cape ----
let profile = $state<PlayerProfile | null>(null);
let profileError = $state<string | null>(null);
let skinBusy = $state(false);
let dragging = $state(false);
$effect(() => {
if (app.settingsTab !== 'skin') return;
void app.active;
profile = null;
profileError = null;
invoke<PlayerProfile>('account_profile').then((p) => (profile = p)).catch((e) => (profileError = errorText(e)));
});
$effect(() => {
if (app.settingsTab === 'skin' && !serverAccount) app.settingsTab = 'account';
});
async function skinAction(cmd: string, args: Record<string, unknown> = {}, done?: string) {
skinBusy = true;
try {
profile = await invoke<PlayerProfile>(cmd, args);
app.skinVersion++;
if (done) toast(done);
} catch (e) {
toast(errorText(e), 'error');
} finally {
skinBusy = false;
}
}
async function uploadSkin(file: File | undefined) {
if (!file) return;
if (file.type && file.type !== 'image/png') return toast('Skins must be PNG files', 'error');
if (file.size > 2_000_000) return toast('That file is too large for a skin', 'error');
const buf = new Uint8Array(await file.arrayBuffer());
let bin = '';
for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
await skinAction('upload_skin', { data: btoa(bin), model: profile?.skin_model ?? 'classic' }, 'Skin updated');
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
uploadSkin(e.dataTransfer?.files[0]);
}
// ---- storage ----
let storage = $state<{ shared: number; runtimes: number; instances: [string, number][] } | null>(null);
function loadStorage() {
@@ -158,6 +203,55 @@
{/each}
<button class="add" onclick={() => (app.addAccount = true)}><Plus size={16} /> Add account</button>
</div>
{:else if app.settingsTab === 'skin'}
<h1>Skin & cape</h1>
<p class="lead">Stored on {b?.name ?? 'the server'} — visible to everyone on servers that use its sign-in.</p>
{#if profileError}
<div class="card glass"><p class="warn small">{profileError}</p></div>
{:else if !profile}
<div class="card glass center"><LoaderCircle size={20} class="spin" /></div>
{:else}
<div class="card glass skin-card" class:busy={skinBusy}>
<div class="preview">
<div class="figure"><SkinView skin={profile.skin_url} cape={profile.cape?.url ?? null} slim={profile.skin_model === 'slim'} side="front" scale={7} /><span class="tiny muted">Front</span></div>
<div class="figure"><SkinView skin={profile.skin_url} cape={profile.cape?.url ?? null} slim={profile.skin_model === 'slim'} side="back" scale={7} /><span class="tiny muted">Back</span></div>
</div>
<div class="col grow skin-controls">
<label class="drop" class:over={dragging} ondragover={(e) => { e.preventDefault(); dragging = true; }} ondragleave={() => (dragging = false)} ondrop={onDrop}>
<Upload size={18} />
<span><strong>Upload a skin</strong><span class="tiny muted">PNG, 64×64 or 64×32 — drop it here or click to browse</span></span>
<input type="file" accept="image/png" onchange={(e) => { uploadSkin(e.currentTarget.files?.[0]); e.currentTarget.value = ''; }} />
</label>
<label class="field">Arm style
<div class="segmented full">
{#each [['classic', 'Classic (4px)'], ['slim', 'Slim (3px)']] as [id, label]}
<button class:active={profile.skin_model === id} disabled={skinBusy} onclick={() => profile!.skin_model !== id && skinAction('set_skin_model', { model: id })}>{label}</button>
{/each}
</div>
</label>
{#if profile.skin_url}
<button class="sm link-btn" disabled={skinBusy} onclick={() => skinAction('delete_skin', {}, 'Skin reset')}><RotateCcw size={14} /> Reset to default</button>
{/if}
</div>
</div>
<div class="card glass col">
<span class="label">Cape</span>
{#if profile.available_capes.length}
<div class="capes">
<button class="cape" class:active={!profile.cape} disabled={skinBusy} onclick={() => profile!.cape && skinAction('set_cape', { capeId: null })}>
<span class="cape-img none"><Ban size={18} /></span>No cape
</button>
{#each profile.available_capes as c (c.id)}
<button class="cape" class:active={profile.cape?.id === c.id} disabled={skinBusy} onclick={() => profile!.cape?.id !== c.id && skinAction('set_cape', { capeId: c.id })}>
<span class="cape-img" style:background-image={c.url ? `url(${c.url})` : undefined}></span>{c.name}
</button>
{/each}
</div>
{:else}
<p class="muted small">No capes are available to you yet. Server staff can hand them out from the panel.</p>
{/if}
</div>
{/if}
{:else if app.settingsTab === 'game'}
<h1>Game</h1>
<p class="lead">Applies to every instance unless you override it in the instance's settings.</p>
@@ -426,5 +520,22 @@
.update { display: flex; align-items: center; gap: 0.9rem; padding: 0.9rem 1rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
.update :global(svg) { color: var(--accent); }
.notes { margin: 0; white-space: pre-wrap; font-family: var(--font); font-size: 0.84rem; color: var(--muted); max-height: 12rem; overflow: auto; }
@media (max-width: 1050px) { .binds { grid-template-columns: 1fr; } .tabs { width: 12rem; } }
.center { display: grid; place-items: center; min-height: 8rem; }
.skin-card { display: flex; gap: 1.6rem; align-items: center; transition: opacity 0.15s; }
.skin-card.busy { opacity: 0.6; }
.preview { display: flex; gap: 1rem; padding: 1rem 1.2rem; border-radius: var(--radius-sm); background: color-mix(in srgb, var(--bg) 60%, transparent); }
.figure { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
.skin-controls { display: flex; flex-direction: column; gap: 1rem; }
.drop { position: relative; display: flex; align-items: center; gap: 0.8rem; padding: 0.9rem 1rem; border: 1px dashed var(--line-strong); border-radius: var(--radius-sm); cursor: pointer; transition: border-color 0.15s, background 0.15s; }
.drop:hover, .drop.over { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); }
.drop > span { display: flex; flex-direction: column; gap: 0.15rem; }
.drop :global(svg) { color: var(--accent); flex-shrink: 0; }
.drop input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.capes { display: grid; grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr)); gap: 0.6rem; }
.cape { flex-direction: column; gap: 0.5rem; padding: 0.8rem 0.5rem 0.6rem; font-size: 0.8rem; background: color-mix(in srgb, var(--bg) 50%, transparent); white-space: normal; }
.cape.active { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
/* Cape texture: the outer face is the 10×16 region at (1,1) of a 64×32 sheet. */
.cape-img { width: 2.5rem; height: 4rem; border-radius: 0.2rem; image-rendering: pixelated; background-color: color-mix(in srgb, var(--text) 8%, transparent); background-size: 640% 200%; background-position: 1.85% 6.25%; background-repeat: no-repeat; }
.cape-img.none { display: grid; place-items: center; color: var(--muted); }
@media (max-width: 1050px) { .binds { grid-template-columns: 1fr; } .tabs { width: 12rem; } .skin-card { flex-direction: column; align-items: stretch; } .preview { justify-content: center; } }
</style>
+1 -1
View File
@@ -27,7 +27,7 @@ fn check_size(kind: Kind, w: u32, h: u32) -> AppResult<()> {
// The vanilla client only accepts these two layouts.
Kind::Skin => (w, h) == (64, 64) || (w, h) == (64, 32),
// 64x32 is standard; HD capes are multiples of it; 22x17 is the old format.
Kind::Cape => (w % 64 == 0 && h * 2 == w && w <= 1024) || (w, h) == (22, 17),
Kind::Cape => (w.is_multiple_of(64) && h * 2 == w && w <= 1024) || (w, h) == (22, 17),
};
if ok {
Ok(())