Add Tauri launcher with Svelte UI

- Accounts: panel login/sign-up, Microsoft device-code sign-in, offline
  play; tokens encrypted with a key kept in the OS credential store
- Home: instance rail, live server status, news feed, social links,
  Play button with download progress, speed and ETA
- Full settings: accounts, memory/resolution/after-launch behaviour,
  Java and GC presets, Minecraft keybind editor applied to options.txt,
  theme overrides, storage manager, self-update via GitHub releases
- Game console, crash dialog with hints, per-instance overrides
- Live admin branding (colours, fonts, backgrounds, custom CSS)
- Online install tests for vanilla/Fabric/Quilt/Forge/NeoForge (CI)

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 05:00:30 +00:00
1 parent af9147ec75
commit 50eb1a0cac
60 files changed
+9424 -47

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
export function bytes(n: number): string {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
return `${(n / 1024 ** i).toFixed(i > 1 ? 1 : 0)} ${u[i]}`;
}
export function gb(mb: number): string {
return `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1)} GB`;
}
export const loaderName: Record<string, string> = {
vanilla: 'Vanilla',
fabric: 'Fabric',
quilt: 'Quilt',
forge: 'Forge',
neoforge: 'NeoForge',
};
export function shortLoaderVersion(mc: string, v: string | null): string {
return v ? v.replace(`${mc}-`, '') : '';
}
export function niceDate(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso + (iso.length === 10 ? 'T00:00:00' : ''));
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric' });
}
+90
View File
@@ -0,0 +1,90 @@
// Minecraft (1.13+) key names used in options.txt, and friendly labels.
export interface Binding { id: string; label: string; default: string }
export const BIND_GROUPS: { title: string; binds: Binding[] }[] = [
{
title: 'Movement',
binds: [
{ id: 'key.forward', label: 'Walk forwards', default: 'key.keyboard.w' },
{ id: 'key.left', label: 'Strafe left', default: 'key.keyboard.a' },
{ id: 'key.back', label: 'Walk backwards', default: 'key.keyboard.s' },
{ id: 'key.right', label: 'Strafe right', default: 'key.keyboard.d' },
{ id: 'key.jump', label: 'Jump', default: 'key.keyboard.space' },
{ id: 'key.sneak', label: 'Sneak', default: 'key.keyboard.left.shift' },
{ id: 'key.sprint', label: 'Sprint', default: 'key.keyboard.left.control' },
],
},
{
title: 'Gameplay',
binds: [
{ id: 'key.attack', label: 'Attack / destroy', default: 'key.mouse.left' },
{ id: 'key.use', label: 'Use item / place block', default: 'key.mouse.right' },
{ id: 'key.pickItem', label: 'Pick block', default: 'key.mouse.middle' },
{ id: 'key.drop', label: 'Drop item', default: 'key.keyboard.q' },
{ id: 'key.swapOffhand', label: 'Swap to offhand', default: 'key.keyboard.f' },
],
},
{
title: 'Interface',
binds: [
{ id: 'key.inventory', label: 'Inventory', default: 'key.keyboard.e' },
{ id: 'key.chat', label: 'Open chat', default: 'key.keyboard.t' },
{ id: 'key.command', label: 'Open command', default: 'key.keyboard.slash' },
{ id: 'key.playerlist', label: 'Player list', default: 'key.keyboard.tab' },
{ id: 'key.advancements', label: 'Advancements', default: 'key.keyboard.l' },
{ id: 'key.socialInteractions', label: 'Social interactions', default: 'key.keyboard.p' },
{ id: 'key.screenshot', label: 'Screenshot', default: 'key.keyboard.f2' },
{ id: 'key.togglePerspective', label: 'Toggle perspective', default: 'key.keyboard.f5' },
{ id: 'key.fullscreen', label: 'Toggle fullscreen', default: 'key.keyboard.f11' },
],
},
{
title: 'Hotbar',
binds: Array.from({ length: 9 }, (_, i) => ({ id: `key.hotbar.${i + 1}`, label: `Hotbar slot ${i + 1}`, default: `key.keyboard.${i + 1}` })),
},
];
const SPECIAL: Record<string, string> = {
Space: 'space', ShiftLeft: 'left.shift', ShiftRight: 'right.shift', ControlLeft: 'left.control', ControlRight: 'right.control',
AltLeft: 'left.alt', AltRight: 'right.alt', Tab: 'tab', Enter: 'enter', Backspace: 'backspace', CapsLock: 'caps.lock',
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', Backquote: 'grave.accent', Minus: 'minus',
Equal: 'equal', BracketLeft: 'left.bracket', BracketRight: 'right.bracket', Backslash: 'backslash', Semicolon: 'semicolon',
Quote: 'apostrophe', Comma: 'comma', Period: 'period', Slash: 'slash', Insert: 'insert', Delete: 'delete', Home: 'home',
End: 'end', PageUp: 'page.up', PageDown: 'page.down', NumpadAdd: 'keypad.add', NumpadSubtract: 'keypad.subtract',
NumpadMultiply: 'keypad.multiply', NumpadDivide: 'keypad.divide', NumpadEnter: 'keypad.enter', NumpadDecimal: 'keypad.decimal',
};
/** KeyboardEvent.code → Minecraft key name (null for unsupported keys). */
export function fromKeyboard(code: string): string | null {
if (/^Key[A-Z]$/.test(code)) return `key.keyboard.${code.slice(3).toLowerCase()}`;
if (/^Digit\d$/.test(code)) return `key.keyboard.${code.slice(5)}`;
if (/^F\d{1,2}$/.test(code)) return `key.keyboard.${code.toLowerCase()}`;
if (/^Numpad\d$/.test(code)) return `key.keyboard.keypad.${code.slice(6)}`;
return SPECIAL[code] ? `key.keyboard.${SPECIAL[code]}` : null;
}
export function fromMouse(button: number): string {
return ['key.mouse.left', 'key.mouse.middle', 'key.mouse.right', 'key.mouse.4', 'key.mouse.5'][button] ?? `key.mouse.${button + 1}`;
}
/** Human label for a Minecraft key name. */
export function keyLabel(key: string): string {
if (!key || key === 'key.keyboard.unknown') return 'Not bound';
const mouse = key.match(/^key\.mouse\.(.+)$/);
if (mouse) return { left: 'Left click', right: 'Right click', middle: 'Middle click' }[mouse[1]] ?? `Mouse ${mouse[1]}`;
const k = key.replace('key.keyboard.', '');
const names: Record<string, string> = {
'left.shift': 'Left Shift', 'right.shift': 'Right Shift', 'left.control': 'Left Ctrl', 'right.control': 'Right Ctrl',
'left.alt': 'Left Alt', 'right.alt': 'Right Alt', 'caps.lock': 'Caps Lock', 'page.up': 'Page Up', 'page.down': 'Page Down',
'grave.accent': '`', 'left.bracket': '[', 'right.bracket': ']', slash: '/', backslash: '\\', semicolon: ';', apostrophe: "'",
comma: ',', period: '.', minus: '-', equal: '=', up: '↑', down: '↓', left: '←', right: '→',
};
if (names[k]) return names[k];
if (k.startsWith('keypad.')) return `Num ${k.slice(7)}`;
return k.length === 1 ? k.toUpperCase() : k.charAt(0).toUpperCase() + k.slice(1);
}
export function defaults(): Record<string, string> {
return Object.fromEntries(BIND_GROUPS.flatMap((g) => g.binds.map((b) => [b.id, b.default])));
}
+167
View File
@@ -0,0 +1,167 @@
// 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';
const scenario = new URLSearchParams(location.search).get('mock') ?? 'main';
const settings: Settings = {
panel_url: scenario === 'setup' ? null : 'https://panel.scopenet.gg',
selected_instance: null,
memory_max_mb: 0,
memory_min_mb: 0,
custom_resolution: false,
width: 1280,
height: 720,
fullscreen: false,
after_launch: 'minimize',
show_console: false,
java_path: null,
gc: 'g1',
jvm_args: '',
theme_mode: 'server',
accent: null,
background_video: true,
reduce_motion: false,
glass: null,
ui_scale: 100,
concurrent_downloads: 12,
check_updates: true,
send_stats: true,
keybinds_enabled: false,
keybinds: {},
instance_overrides: {},
};
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 },
branding: {
name: 'ScopeNet',
tagline: 'Your worlds, one click away.',
logo_url: null,
icon_url: null,
background: { kind: 'gradient', url: null, blur: 0, dim: 45 },
colors: { accent: '#7c5cff', accent_2: '#22d3ee', background: '#0b0d17', surface: '#141828', text: '#eef0ff', muted: '#8b90b3', success: '#34d399', danger: '#f43f5e' },
font: 'Outfit',
radius: 14,
glass: true,
version_label: 'ScopeNet',
news: [
{ id: '1', title: 'Season 4 is live!', body: 'A brand new world with custom terrain, player shops and weekly events. Join now and claim your spot before the land rush.', image_url: null, link: 'https://example.com', date: '2026-09-26', pinned: true, tag: 'Update' },
{ id: '2', title: 'Build contest: Autumn Castles', body: 'Submit your best castle build on the Creative server by October 10th. Top three win VIP for a month.', image_url: null, link: null, date: '2026-09-21', pinned: false, tag: 'Event' },
{ id: '3', title: 'Performance boost', body: 'We switched the modpack to Sodium + Lithium. Expect way higher FPS on older machines.', image_url: null, link: null, date: '2026-09-14', pinned: false, tag: 'Changelog' },
],
links: [
{ label: 'Discord', url: 'https://discord.gg/example', icon: 'discord' },
{ label: 'Website', url: 'https://example.com', icon: 'website' },
{ label: 'Store', url: 'https://example.com/store', icon: 'store' },
],
features: { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true },
custom_css: '',
},
instances: [
{ id: 'survival-smp', name: 'Survival SMP', description: 'Our main survival world — season 4. Claims, shops & weekend events.', icon_url: null, banner_url: null, mc_version: '1.21.1', loader: 'fabric', loader_version: '0.16.9', revision: 3, server: { name: 'ScopeNet SMP', address: 'play.scopenet.gg', port: 25565, auto_join: true, inject: true }, memory: { min_mb: 2048, max_mb: 6144 }, jvm_args: '', featured: true, source_label: 'Modrinth · Fabulously Optimized 6.2', file_count: 48, total_size: 184_000_000 },
{ id: 'creative', name: 'Creative Build', description: 'Plots, WorldEdit and weekly build contests.', icon_url: null, banner_url: null, mc_version: '1.20.1', loader: 'vanilla', loader_version: null, revision: 1, server: { name: 'Creative', address: 'creative.scopenet.gg', port: 25565, auto_join: false, inject: true }, memory: { min_mb: 1024, max_mb: 4096 }, jvm_args: '', featured: false, source_label: 'Minecraft 1.20.1', file_count: 0, total_size: 0 },
{ id: 'modded', name: 'Create: Above & Beyond', description: 'Factory building with Create and friends.', icon_url: null, banner_url: null, mc_version: '1.20.1', loader: 'forge', loader_version: '1.20.1-47.3.0', revision: 2, server: null, memory: { min_mb: 4096, max_mb: 8192 }, jvm_args: '', featured: false, source_label: 'CurseForge · Create: Above & Beyond 1.4', file_count: 212, total_size: 612_000_000 },
],
};
const accounts: Account[] = scenario === 'main' || scenario === 'progress' ? [
{ id: 'a1', kind: 'panel', username: 'Notch', uuid: 'b50ad385-829d-3141-a216-7e7d7539ba7f', panel_url: 'https://panel.scopenet.gg', role: 'admin' },
{ id: 'a2', kind: 'offline', username: 'Steve', uuid: '5627dd98-e6be-3c21-b8a8-e92344183641', panel_url: null, role: null },
] : [];
let active: string | null = accounts[0]?.id ?? null;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
let running = false;
async function fakeLaunch(id: string) {
const stages: [string, string, number, number][] = [
['java', 'Installing Java 21', 48_000_000, 212],
['game', 'Downloading Minecraft 1.21.1', 420_000_000, 3412],
['loader', 'Installing Fabric 0.16.9', 6_000_000, 14],
['files', 'Syncing instance files', 184_000_000, 48],
];
for (const [stage, label, total, files] of stages) {
mockEmit('launch://progress', { type: 'stage', stage, label });
for (let i = 1; i <= 20; i++) {
await sleep(scenario === 'progress' ? 400 : 60);
mockEmit('launch://progress', { type: 'progress', stage, done: (total * i) / 20, total, files_done: Math.round((files * i) / 20), files_total: files });
}
}
mockEmit('launch://progress', { type: 'stage', stage: 'launching', label: 'Starting Minecraft' });
await sleep(500);
running = true;
mockEmit('launch://state', { instance_id: id, state: 'running', message: null });
const lines = ['[main/INFO]: Loading Minecraft 1.21.1 with Fabric Loader 0.16.9', '[Render thread/INFO]: Setting user: Notch', '[Render thread/INFO]: Backend library: LWJGL version 3.3.3', '[Render thread/INFO]: Reloading ResourceManager: vanilla, fabric', '[Render thread/INFO]: Sound engine started', '[Render thread/INFO]: Connecting to play.scopenet.gg, 25565'];
for (const l of lines) {
await sleep(300);
mockEmit('game://log', [`[12:00:0${lines.indexOf(l)}] ${l}`]);
}
}
export async function mockInvoke(cmd: string, args: Record<string, any>): Promise<unknown> {
await sleep(40);
switch (cmd) {
case 'bootstrap': {
const b: Bootstrap = {
app: { version: '0.1.0', default_panel_url: null, panel_locked: false, repo: 'scopeddlol/SCOPENET-MC', os: 'windows', arch: 'x86_64', total_ram_mb: 16384, data_dir: 'C:\\Users\\you\\AppData\\Roaming\\net.scopenet.launcher' },
panel_url: settings.panel_url,
settings,
accounts,
active_account: active,
manifest: scenario === 'setup' ? null : manifest,
game_running: running ? 'survival-smp' : null,
};
return structuredClone(b);
}
case 'refresh_manifest':
case 'set_panel_url':
if (cmd === 'set_panel_url') settings.panel_url = args.url;
return structuredClone(manifest);
case 'save_settings':
Object.assign(settings, args.settings);
return null;
case 'login_panel':
case 'add_offline': {
const a: Account = { id: crypto.randomUUID(), kind: cmd === 'login_panel' ? 'panel' : 'offline', username: args.username, uuid: crypto.randomUUID(), panel_url: null, role: 'player' };
accounts.push(a);
active = a.id;
return a;
}
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 'select_account':
active = args.id;
return null;
case 'launch':
fakeLaunch(args.instanceId);
return null;
case 'kill_game':
running = false;
mockEmit('game://exit', { instance_id: 'survival-smp', code: 0, crashed: false, tail: [], crash_report: null });
mockEmit('launch://state', { instance_id: 'survival-smp', state: 'idle', message: null });
return null;
case 'ping_server':
await sleep(200);
return { online: true, version: 'Paper 1.21.1', players_online: 42, players_max: 100, sample: ['Notch', 'jeb_', 'Dinnerbone'], motd: '§b§lScopeNet §r§7» §fSeason 4 is live!', favicon: null, latency_ms: 23 };
case 'instance_local':
return { installed: args.instanceId !== 'modded', revision: 3, size: 1_240_000_000 };
case 'storage_info':
return { shared: 2_400_000_000, runtimes: 410_000_000, instances: [['survival-smp', 1_240_000_000], ['creative', 320_000_000]] };
case 'check_update':
return null;
case 'detect_java':
return 21;
default:
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
// Render Minecraft § formatting codes as HTML-safe segments.
const COLORS: Record<string, string> = {
'0': '#000000', '1': '#0000AA', '2': '#00AA00', '3': '#00AAAA', '4': '#AA0000', '5': '#AA00AA', '6': '#FFAA00', '7': '#AAAAAA',
'8': '#555555', '9': '#5555FF', a: '#55FF55', b: '#55FFFF', c: '#FF5555', d: '#FF55FF', e: '#FFFF55', f: '#FFFFFF',
};
export interface Segment { text: string; color?: string; bold?: boolean; italic?: boolean; underline?: boolean; strike?: boolean }
export function parseMotd(input: string): Segment[] {
const out: Segment[] = [];
let cur: Segment = { text: '' };
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (ch === '§' && i + 1 < input.length) {
const code = input[i + 1].toLowerCase();
if (cur.text) out.push(cur);
const base: Segment = { ...cur, text: '' };
if (code === '#' && /^[0-9a-f]{6}$/i.test(input.slice(i + 2, i + 8))) {
cur = { text: '', color: '#' + input.slice(i + 2, i + 8) };
i += 7;
continue;
}
if (COLORS[code]) cur = { text: '', color: COLORS[code] };
else if (code === 'l') cur = { ...base, bold: true };
else if (code === 'o') cur = { ...base, italic: true };
else if (code === 'n') cur = { ...base, underline: true };
else if (code === 'm') cur = { ...base, strike: true };
else if (code === 'r') cur = { text: '' };
else cur = base;
i++;
continue;
}
cur.text += ch;
}
if (cur.text) out.push(cur);
return out;
}
+243
View File
@@ -0,0 +1,243 @@
import { errorText, invoke, listen } from './tauri';
import { applyTheme } from './theme';
import type { Account, Bootstrap, Instance, Manifest, ProgressEvent, Settings, Stage, UpdateInfo } from './types';
export interface LaunchState {
state: 'idle' | 'preparing' | 'running' | 'error';
stage: Stage | null;
label: string;
done: number;
total: number;
filesDone: number;
filesTotal: number;
speed: number;
message: string | null;
}
export interface ExitInfo { instance_id: string; code: number | null; crashed: boolean; tail: string[]; crash_report: string | null }
const idle = (): LaunchState => ({ state: 'idle', stage: null, label: '', done: 0, total: 0, filesDone: 0, filesTotal: 0, speed: 0, message: null });
export const app = $state({
ready: false,
boot: null as Bootstrap | null,
manifest: null as Manifest | null,
settings: null as Settings | null,
accounts: [] as Account[],
active: null as string | null,
panelUrl: null as string | null,
offline: false,
view: 'home' as 'home' | 'settings',
settingsTab: 'account',
selected: null as string | null,
launch: idle(),
launchingId: null as string | null,
running: null as string | null,
logs: [] as string[],
consoleOpen: false,
crash: null as ExitInfo | null,
update: null as UpdateInfo | null,
addAccount: false,
instanceSettings: null as string | null,
toasts: [] as { id: number; text: string; kind: 'ok' | 'error' | 'info' }[],
});
let toastId = 1;
export function toast(text: string, kind: 'ok' | 'error' | 'info' = 'ok') {
const id = toastId++;
app.toasts.push({ id, text, kind });
setTimeout(() => {
const i = app.toasts.findIndex((t) => t.id === id);
if (i >= 0) app.toasts.splice(i, 1);
}, kind === 'error' ? 7000 : 3500);
}
export const activeAccount = () => app.accounts.find((a) => a.id === app.active) ?? null;
export const instances = (): Instance[] => app.manifest?.instances ?? [];
export const selectedInstance = (): Instance | null => instances().find((i) => i.id === app.selected) ?? instances()[0] ?? null;
/** Resolve panel-relative media URLs ("/uploads/x.png"). */
export function abs(url: string | null | undefined): string | null {
if (!url) return null;
if (url.startsWith('/') && app.panelUrl) return app.panelUrl + url;
return url;
}
function theme() {
if (app.manifest) applyTheme(app.manifest.branding, app.settings);
}
function pickSelected() {
const list = instances();
const wanted = app.settings?.selected_instance;
app.selected = list.find((i) => i.id === wanted)?.id ?? list.find((i) => i.featured)?.id ?? list[0]?.id ?? null;
}
export async function init() {
const boot = await invoke<Bootstrap>('bootstrap');
app.boot = boot;
app.settings = boot.settings;
app.accounts = boot.accounts;
app.active = boot.active_account;
app.manifest = boot.manifest;
app.panelUrl = boot.panel_url;
app.running = boot.game_running;
theme();
pickSelected();
subscribe();
app.ready = true;
invoke('show_window').catch(() => {});
if (app.panelUrl) await refresh();
if (boot.settings.check_updates && boot.app.repo) {
invoke<UpdateInfo | null>('check_update').then((u) => (app.update = u)).catch(() => {});
}
}
export async function refresh() {
try {
app.manifest = await invoke<Manifest>('refresh_manifest');
app.offline = false;
theme();
pickSelected();
} catch (e) {
app.offline = true;
if (!app.manifest) throw e;
}
}
export async function connect(url: string) {
app.manifest = await invoke<Manifest>('set_panel_url', { url });
const boot = await invoke<Bootstrap>('bootstrap');
app.panelUrl = boot.panel_url;
app.offline = false;
theme();
pickSelected();
}
let saveTimer: ReturnType<typeof setTimeout> | undefined;
/** Persist settings (debounced) and re-apply the theme immediately. */
export function saveSettings() {
theme();
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
if (app.settings) invoke('save_settings', { settings: $state.snapshot(app.settings) }).catch((e) => toast(errorText(e), 'error'));
}, 300);
}
export function selectInstance(id: string) {
app.selected = id;
if (app.settings) {
app.settings.selected_instance = id;
saveSettings();
}
}
export async function accountAdded(account: Account) {
const i = app.accounts.findIndex((a) => a.id === account.id);
if (i >= 0) app.accounts[i] = account;
else app.accounts.push(account);
app.active = account.id;
app.addAccount = false;
await refresh().catch(() => {});
}
export async function switchAccount(id: string) {
await invoke('select_account', { id });
app.active = id;
await refresh().catch(() => {});
}
export async function removeAccount(id: string) {
await invoke('remove_account', { id });
app.accounts = app.accounts.filter((a) => a.id !== id);
if (app.active === id) app.active = app.accounts[0]?.id ?? null;
await refresh().catch(() => {});
}
export async function play(id: string) {
app.launch = { ...idle(), state: 'preparing', label: 'Getting ready…' };
app.launchingId = id;
app.logs = [];
try {
await invoke('launch', { instanceId: id });
} catch (e) {
app.launch = { ...idle(), state: 'error', message: errorText(e) };
}
}
export async function repair(id: string) {
app.launch = { ...idle(), state: 'preparing', label: 'Verifying files…' };
app.launchingId = id;
try {
await invoke('repair_instance', { instanceId: id });
} catch (e) {
app.launch = { ...idle(), state: 'error', message: errorText(e) };
}
}
export async function cancel() {
if (app.launchingId) await invoke('cancel_launch', { instanceId: app.launchingId });
}
export async function kill() {
await invoke('kill_game');
}
let subscribed = false;
let last = { t: 0, done: 0, stage: '' as string };
function subscribe() {
if (subscribed) return;
subscribed = true;
listen<ProgressEvent>('launch://progress', (ev) => {
const l = app.launch;
if (ev.type === 'stage') {
l.stage = ev.stage;
l.label = ev.label;
l.done = l.total = l.filesDone = l.filesTotal = 0;
l.speed = 0;
} else if (ev.type === 'progress') {
const now = performance.now();
if (last.stage !== ev.stage || ev.done < last.done) last = { t: now, done: ev.done, stage: ev.stage };
else if (now - last.t > 500) {
const inst = ((ev.done - last.done) / (now - last.t)) * 1000;
l.speed = l.speed ? l.speed * 0.6 + inst * 0.4 : inst;
last = { t: now, done: ev.done, stage: ev.stage };
}
l.stage = ev.stage;
l.done = ev.done;
l.total = ev.total;
l.filesDone = ev.files_done;
l.filesTotal = ev.files_total;
} else if (ev.type === 'log') {
pushLogs([ev.line]);
}
});
listen<{ instance_id: string; state: LaunchState['state']; message: string | null }>('launch://state', (ev) => {
if (ev.state === 'running') {
app.running = ev.instance_id;
app.launch = { ...idle(), state: 'running' };
if (app.settings?.show_console) app.consoleOpen = true;
} else if (ev.state === 'error') {
app.launch = { ...idle(), state: 'error', message: ev.message };
} else if (ev.state === 'idle') {
if (app.running === ev.instance_id) app.running = null;
if (app.launch.state !== 'error') app.launch = idle();
if (ev.message && ev.message !== 'Cancelled') toast(ev.message);
}
});
listen<string[]>('game://log', (lines) => pushLogs(lines));
listen<ExitInfo>('game://exit', (ev) => {
app.running = null;
if (ev.crashed) app.crash = ev;
});
}
function pushLogs(lines: string[]) {
app.logs.push(...lines);
if (app.logs.length > 3000) app.logs.splice(0, app.logs.length - 3000);
}
+57
View File
@@ -0,0 +1,57 @@
// Thin wrapper over Tauri IPC. When the UI runs in a normal browser
// (vite dev / screenshots), calls go to an in-memory mock instead.
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
export const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
type Handler = (payload: any) => void;
const mockListeners = new Map<string, Set<Handler>>();
export function mockEmit(event: string, payload: unknown) {
mockListeners.get(event)?.forEach((h) => h(payload));
}
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (inTauri) return tauriInvoke<T>(cmd, args);
const { mockInvoke } = await import('./mock');
return mockInvoke(cmd, args ?? {}) as Promise<T>;
}
export async function listen<T>(event: string, handler: (payload: T) => void): Promise<UnlistenFn> {
if (inTauri) return tauriListen<T>(event, (e) => handler(e.payload));
const set = mockListeners.get(event) ?? new Set();
set.add(handler as Handler);
mockListeners.set(event, set);
return () => set.delete(handler as Handler);
}
export async function windowAction(action: 'minimize' | 'maximize' | 'close' | 'drag') {
if (!inTauri) return;
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const w = getCurrentWindow();
if (action === 'minimize') await w.minimize();
else if (action === 'maximize') await w.toggleMaximize();
else if (action === 'close') await w.close();
else await w.startDragging();
}
export async function openUrl(url: string) {
if (!inTauri) {
window.open(url, '_blank');
return;
}
const { openUrl } = await import('@tauri-apps/plugin-opener');
await openUrl(url);
}
export async function pickFile(title: string, filters?: { name: string; extensions: string[] }[]): Promise<string | null> {
if (!inTauri) return null;
const { open } = await import('@tauri-apps/plugin-dialog');
const r = await open({ title, multiple: false, directory: false, filters });
return typeof r === 'string' ? r : null;
}
export function errorText(e: unknown): string {
return typeof e === 'string' ? e : e instanceof Error ? e.message : JSON.stringify(e);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Branding, Settings } from './types';
let styleEl: HTMLStyleElement | null = null;
/** Push the admin's branding (and the player's overrides) into CSS variables. */
export function applyTheme(b: Branding, s: Settings | null) {
const root = document.documentElement;
const custom = s?.theme_mode === 'custom' && b.features.allow_user_theme;
const accent = (custom && s?.accent) || b.colors.accent;
const vars: Record<string, string> = {
'--accent': accent,
'--accent-2': custom && s?.accent ? shift(accent) : b.colors.accent_2,
'--bg': b.colors.background,
'--surface': b.colors.surface,
'--text': b.colors.text,
'--muted': b.colors.muted,
'--success': b.colors.success,
'--danger': b.colors.danger,
'--radius': `${b.radius}px`,
'--radius-sm': `${Math.max(4, Math.round(b.radius * 0.65))}px`,
'--font': `'${b.font} Variable', '${b.font}', 'Outfit Variable', system-ui, sans-serif`,
};
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v);
const glass = s?.glass ?? b.glass;
root.classList.toggle('no-glass', !glass);
root.classList.toggle('reduce-motion', !!s?.reduce_motion);
root.style.fontSize = `${(s?.ui_scale ?? 100) * 0.15}px`;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'admin-css';
document.head.appendChild(styleEl);
}
styleEl.textContent = b.custom_css ?? '';
}
/** A second accent derived from the first by rotating its hue. */
function shift(hex: string): string {
const m = hex.replace('#', '').match(/.{2}/g);
if (!m) return hex;
let [r, g, b] = m.map((x) => parseInt(x, 16) / 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0;
const l = (max + min) / 2;
const d = max - min;
const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
if (d) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
}
h = (h * 60 + 50 + 360) % 360;
return `hsl(${h} ${Math.round(sat * 100)}% ${Math.round(l * 100)}%)`;
}
+112
View File
@@ -0,0 +1,112 @@
export type Loader = 'vanilla' | 'fabric' | 'quilt' | 'forge' | 'neoforge';
export interface ServerEntry { name: string; address: string; port: number; auto_join: boolean; inject: boolean }
export interface Instance {
id: string;
name: string;
description: string;
icon_url: string | null;
banner_url: string | null;
mc_version: string;
loader: Loader;
loader_version: string | null;
revision: number;
server: ServerEntry | null;
memory: { min_mb: number; max_mb: number };
jvm_args: string;
featured: boolean;
source_label: string;
file_count: number;
total_size: number;
}
export interface NewsItem { id: string; title: string; body: string; image_url: string | null; link: string | null; date: string | null; pinned: boolean; tag: string | null }
export interface Branding {
name: string;
tagline: string;
logo_url: string | null;
icon_url: string | null;
background: { kind: 'gradient' | 'image' | 'video'; url: string | null; blur: number; dim: number };
colors: { accent: string; accent_2: string; background: string; surface: string; text: string; muted: string; success: string; danger: string };
font: string;
radius: number;
glass: boolean;
version_label: string;
news: NewsItem[];
links: { label: string; url: string; icon: string }[];
features: { news: boolean; server_status: boolean; allow_user_theme: boolean; allow_advanced_java: boolean };
custom_css: string;
}
export interface AuthConfig {
panel_accounts: boolean;
registration: 'closed' | 'open' | 'approval';
microsoft: boolean;
microsoft_client_id: string | null;
offline_local: boolean;
}
export interface Manifest {
api_version: number;
panel_version: string;
branding: Branding;
auth: AuthConfig;
instances: Instance[];
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 type Gc = 'default' | 'g1' | 'zgc';
export interface InstanceOverride { memory_max_mb: number; memory_min_mb: number; jvm_args: string; java_path: string | null }
export interface Settings {
panel_url: string | null;
selected_instance: string | null;
memory_max_mb: number;
memory_min_mb: number;
custom_resolution: boolean;
width: number;
height: number;
fullscreen: boolean;
after_launch: 'minimize' | 'hide' | 'keep' | 'close';
show_console: boolean;
java_path: string | null;
gc: Gc;
jvm_args: string;
theme_mode: 'server' | 'custom';
accent: string | null;
background_video: boolean;
reduce_motion: boolean;
glass: boolean | null;
ui_scale: number;
concurrent_downloads: number;
check_updates: boolean;
send_stats: boolean;
keybinds_enabled: boolean;
keybinds: Record<string, string>;
instance_overrides: Record<string, InstanceOverride>;
}
export interface Bootstrap {
app: { version: string; default_panel_url: string | null; panel_locked: boolean; repo: string | null; os: string; arch: string; total_ram_mb: number; data_dir: string };
panel_url: string | null;
settings: Settings;
accounts: Account[];
active_account: string | null;
manifest: Manifest | null;
game_running: string | null;
}
export type Stage = 'preparing' | 'java' | 'game' | 'loader' | 'files' | 'launching';
export type ProgressEvent =
| { type: 'stage'; stage: Stage; label: string }
| { type: 'progress'; stage: Stage; done: number; total: number; files_done: number; files_total: number }
| { 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 UpdateInfo { version: string; notes: string; url: string; size: number }