471 lines
14 KiB
TypeScript
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();
|