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

+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>