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

+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);
}