// App state as Svelte 5 runes. Mirrors backend Status/events and wraps every // command so failures surface as a notice plus an activity entry. import { backend } from "./backend"; import { errorText, hostOf } from "./format"; import { SIGN_IN_CANCELLED } from "./types"; import type { ConflictChoice, InstallProgress, LogEvent, LogLevel, Market, Phase, Settings, StackComponent, StackEvent, Status, SyncConflict, Unlisten, } from "./types"; export type ActionKind = | "loading" | "sign_in" | "signing_in" | "install" | "offline_unavailable" | "signed_out_offline" | "installing" | "restoring" | "syncing" | "update" | "launch" | "stack" | "error"; export const STACK_ORDER: readonly StackComponent[] = ["postgres", "api", "tiles", "odian"]; export type StackMap = Record; /** Which primary action the hub shows for a given status. */ export function deriveAction(s: Status | null): ActionKind { if (!s) return "loading"; switch (s.phase) { case "error": return "error"; case "signing_in": return "signing_in"; case "installing": return "installing"; case "restoring": return "restoring"; case "syncing": return "syncing"; case "launching": case "running": return "stack"; } // idle or checking if (s.installed === null) { if (!s.online) return "offline_unavailable"; return s.signed_in ? "install" : "sign_in"; } if (s.update_required) return s.signed_in ? "update" : "sign_in"; // Launching runs ODIAN as the remembered operator; after Sign out there is none. if (s.operator === null) return s.online ? "sign_in" : "signed_out_offline"; return "launch"; } type LastOp = { kind: "install" } | { kind: "drive"; path: string } | { kind: "launch" }; interface NotesState { key: string | null; loading: boolean; markdown: string | null; error: string | null; version: string | null; fromCache: boolean; } const LOCKED_PHASES: ReadonlySet = new Set([ "signing_in", "installing", "restoring", "syncing", "launching", "running", ]); const MAX_LOGS = 250; const FOCUS_REFRESH_MS = 30_000; const emptyStack = (): StackMap => ({ postgres: null, api: null, tiles: null, odian: null }); const nowSeconds = () => Math.floor(Date.now() / 1000); class Launcher { readonly kind = backend.kind; readonly mock = backend.mock; readonly win = backend.window; status = $state(null); settings = $state(null); progress = $state(null); /** Smoothed bytes/sec for a steadier ETA. */ speed = $state(0); stack = $state(emptyStack()); logs = $state([]); unseen = $state(0); unseenError = $state(false); activityOpen = $state(false); /** Last command failure, shown next to the primary action. */ notice = $state(null); /** Name of the command in flight, if any. */ busy = $state(null); refreshing = $state(false); stopping = $state(false); /** The sync review panel. */ syncOpen = $state(false); /** The marketplace panel, and the drive it describes (null: this PC and its DHP). */ marketOpen = $state(false); marketDrive = $state(null); /** Decisions waiting in this PC's database; null until the panel loads them. */ conflicts = $state(null); notes = $state({ key: null, loading: false, markdown: null, error: null, version: null, fromCache: false, }); action: ActionKind = $derived(deriveAction(this.status)); /** Settings can't change while something is installing or running. */ locked = $derived(this.status ? LOCKED_PHASES.has(this.status.phase) : false); siteName = $derived(this.status ? (this.status.site.name ?? hostOf(this.status.site.url)) : ""); /** Conflicts plus refused edits from the last sync: what needs the operator. */ syncAttention = $derived(this.status?.sync ? this.status.sync.conflicts + this.status.sync.rejected : 0); notesVersion = $derived( this.status ? (this.status.online ? (this.status.available ?? this.status.installed) : this.status.installed) : null, ); notesKey = $derived(this.status ? `${this.status.online}|${this.notesVersion ?? ""}` : null); eta = $derived.by(() => { const p = this.progress; if (!p || p.total_bytes <= 0 || this.speed <= 0) return null; return Math.max(0, p.total_bytes - p.done_bytes) / this.speed; }); private lastOp: LastOp | null = null; private lastProbe = 0; /** Subscribes to backend events and loads the first status. Returns a disposer. */ start(): () => void { let disposed = false; const unlisteners: Unlisten[] = []; const keep = (p: Promise) => p.then((unlisten) => { if (disposed) unlisten(); else unlisteners.push(unlisten); }); void (async () => { try { await Promise.all([ keep(backend.onStatus((s) => this.applyStatus(s))), keep(backend.onInstallProgress((p) => this.applyProgress(p))), keep(backend.onStack((e) => (this.stack[e.component] = e))), keep(backend.onLog((l) => this.pushLog(l))), ]); const [status, settings] = await Promise.all([backend.getStatus(), backend.getSettings()]); if (disposed) return; this.applyStatus(status); this.settings = settings; // A UI (re)loaded mid-launch redraws the stack from the backend's last known states. for (const event of await backend.getStack()) this.stack[event.component] = event; } catch (e) { this.fail(e); } if (!disposed) void this.refresh(); })(); const onFocus = () => { if (this.status?.phase === "idle" && Date.now() - this.lastProbe > FOCUS_REFRESH_MS) void this.refresh(); }; window.addEventListener("focus", onFocus); return () => { disposed = true; unlisteners.forEach((u) => u()); window.removeEventListener("focus", onFocus); }; } // ── event reducers ────────────────────────────────────────────────────── private applyStatus(next: Status) { const prev = this.status; if (next.phase === "launching" && prev?.phase !== "launching") this.stack = emptyStack(); if (next.phase !== "installing" && next.phase !== "restoring") { this.progress = null; this.speed = 0; } if (next.phase !== "running" && next.phase !== "launching") this.stopping = false; if (prev && prev.phase !== next.phase && LOCKED_PHASES.has(next.phase)) this.notice = null; this.status = next; } private applyProgress(p: InstallProgress) { const prev = this.progress; const sameStage = prev !== null && prev.stage_index === p.stage_index && prev.stage === p.stage; if (p.bytes_per_sec <= 0) this.speed = 0; else if (!sameStage || this.speed <= 0) this.speed = p.bytes_per_sec; else this.speed = this.speed * 0.85 + p.bytes_per_sec * 0.15; this.progress = p; } private pushLog(entry: LogEvent) { this.logs = [entry, ...this.logs].slice(0, MAX_LOGS); if (!this.activityOpen) { this.unseen++; if (entry.level === "error") this.unseenError = true; } } private note(level: LogLevel, message: string) { this.pushLog({ level, message, at: nowSeconds() }); } private fail(error: unknown) { const message = errorText(error); this.notice = message; this.note("error", message); } private async run(name: string, task: () => Promise): Promise { if (this.busy) return false; this.busy = name; this.notice = null; try { await task(); return true; } catch (e) { this.fail(e); return false; } finally { this.busy = null; } } // ── commands ──────────────────────────────────────────────────────────── async refresh() { if (this.refreshing) return; this.refreshing = true; this.lastProbe = Date.now(); try { this.applyStatus(await backend.refresh()); } catch (e) { this.fail(e); } finally { this.refreshing = false; } } signIn() { return this.run("sign_in", async () => { try { await backend.signIn(); } catch (e) { // Cancelling is the operator's choice, not a failure to show. if (errorText(e) !== SIGN_IN_CANCELLED) throw e; this.note("info", SIGN_IN_CANCELLED); } this.applyStatus(await backend.getStatus()); }); } /** Not wrapped in `run`: the sign-in it cancels is the command in flight. */ async cancelSignIn() { try { await backend.cancelSignIn(); } catch (e) { this.fail(e); } } signOut() { return this.run("sign_out", async () => this.applyStatus(await backend.signOut())); } async openExternal(url: string) { try { await backend.openExternal(url); } catch (e) { this.fail(e); } } /** The marketplace listing, of this PC's DHP or of a drive. Throws, so the drawer says why. */ loadMarket(drive?: string): Promise { return backend.getMarket(drive); } openMarket() { this.marketDrive = null; this.marketOpen = true; } /** Installing from a drive starts by looking at what the drive carries. */ async chooseDrive() { const path = await this.pickFolder(); if (!path) return; this.marketDrive = path; this.marketOpen = true; } /** Choose what an install about to run will bring; nothing installed is touched. */ setMarket(selected: string[], drive?: string) { return this.run("market", () => backend.setMarket(selected, drive)); } /** Save the choice of optional items and apply it (downloads show as an install). */ applyMarket(selected: string[]) { return this.run("market", async () => { this.lastOp = null; await backend.applyMarket(selected); this.applyStatus(await backend.getStatus()); }); } install() { return this.run("install", async () => { this.lastOp = { kind: "install" }; await backend.startInstall(); }); } /** * Installing from a drive is a copy the operator chooses first: the marketplace opens on what * the drive carries, and starts this once they have picked. */ async installFromDrive(path: string) { return this.run("install", async () => { this.lastOp = { kind: "drive", path }; await backend.startInstallFromDrive(path); }); } cancelInstall() { return this.run("cancel", () => backend.cancelInstall()); } launch() { return this.run("launch", async () => { this.lastOp = { kind: "launch" }; await backend.launch(); }); } stop() { return this.run("stop", async () => { this.stopping = true; try { await backend.stop(); } catch (e) { this.stopping = false; throw e; } }); } /** Repeats the failed operation; without history, infers it from status. */ retry() { const op = this.lastOp; const s = this.status; if (op?.kind === "drive") { return this.run("install", () => backend.startInstallFromDrive(op.path)); } if (op?.kind === "install") return this.install(); if (op?.kind === "launch") return this.launch(); if (!s) return this.refresh(); if (s.installed === null || s.update_required) { return s.online && s.signed_in ? this.install() : this.refresh(); } return this.launch(); } saveSettings(next: Settings) { return this.run("settings", async () => { this.applyStatus(await backend.setSettings(next)); this.settings = await backend.getSettings(); }); } syncNow() { return this.run("sync", async () => { await backend.syncNow(); // The panel lists what the sync just found (or settled). if (this.syncOpen) this.conflicts = await backend.getSyncConflicts(); }); } /** Opens the review panel and loads what waits for a decision. */ async openSync() { this.syncOpen = true; await this.run("conflicts", async () => { this.conflicts = await backend.getSyncConflicts(); }); } closeSync() { this.syncOpen = false; } resolveConflict(conflict: SyncConflict, choice: ConflictChoice, value?: unknown) { return this.run("resolve", async () => { this.conflicts = await backend.resolveSyncConflict(conflict.tbl, conflict.key, choice, value); }); } async pickFolder(): Promise { try { return await backend.pickFolder(); } catch (e) { this.fail(e); return null; } } async loadNotes() { const key = this.notesKey; const s = this.status; if (key === null || !s) return; if (key === this.notes.key && (this.notes.loading || this.notes.markdown !== null)) return; const version = this.notesVersion ?? undefined; this.notes = { key, loading: true, markdown: null, error: null, version: version ?? null, fromCache: !s.online, }; try { const markdown = await backend.getNotes(version); if (this.notes.key === key) this.notes = { ...this.notes, loading: false, markdown }; } catch (e) { if (this.notes.key === key) this.notes = { ...this.notes, loading: false, error: errorText(e) }; } } // ── activity panel ────────────────────────────────────────────────────── setActivityOpen(open: boolean) { this.activityOpen = open; if (open) { this.unseen = 0; this.unseenError = false; } } clearLogs() { this.logs = []; this.unseen = 0; this.unseenError = false; } dismissNotice() { this.notice = null; } } export const launcher = new Launcher();