- 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
244 lines
7.6 KiB
TypeScript
244 lines
7.6 KiB
TypeScript
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);
|
|
}
|