Files
launcher/src/lib/launcher.svelte.ts
T
Kelorastus 4d620fc5ad update
2026-09-23 16:05:19 +02:00

471 lines
14 KiB
TypeScript

// 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<StackComponent, StackEvent | null>;
/** 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<Phase> = 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<Status | null>(null);
settings = $state<Settings | null>(null);
progress = $state<InstallProgress | null>(null);
/** Smoothed bytes/sec for a steadier ETA. */
speed = $state(0);
stack = $state<StackMap>(emptyStack());
logs = $state<LogEvent[]>([]);
unseen = $state(0);
unseenError = $state(false);
activityOpen = $state(false);
/** Last command failure, shown next to the primary action. */
notice = $state<string | null>(null);
/** Name of the command in flight, if any. */
busy = $state<string | null>(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<string | null>(null);
/** Decisions waiting in this PC's database; null until the panel loads them. */
conflicts = $state<SyncConflict[] | null>(null);
notes = $state<NotesState>({
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<Unlisten>) =>
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<void>): Promise<boolean> {
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<Market> {
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<string | null> {
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();