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'); 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('check_update').then((u) => (app.update = u)).catch(() => {}); } } export async function refresh() { try { app.manifest = await invoke('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('set_panel_url', { url }); const boot = await invoke('bootstrap'); app.panelUrl = boot.panel_url; app.offline = false; theme(); pickSelected(); } let saveTimer: ReturnType | 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('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('game://log', (lines) => pushLogs(lines)); listen('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); }