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:
60 files changed
+9424
-47
No files matched your search
@@ -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' });
|
||||
}
|
||||
@@ -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])));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}%)`;
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user