ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)
- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and update progress, local stack status, settings with DHP and MCP addresses. - crates/odian-packs: Rust installer for pack format v1 (signed manifests, resumable verified downloads from a DHP or an ODIAN drive), byte-compatible with ODIAN_MCP_Server's Python odian_packs. - Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe, one Job Object, clean shutdown; database snapshot restore on a fresh PC only. - Keycloak sign-in through the DHP's MCP, remembered until Sign out with a launcher session that renews the Engine token silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
// 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 {
|
||||
InstallProgress,
|
||||
LogEvent,
|
||||
LogLevel,
|
||||
Phase,
|
||||
Settings,
|
||||
StackComponent,
|
||||
StackEvent,
|
||||
Status,
|
||||
Unlisten,
|
||||
} from "./types";
|
||||
|
||||
export type ActionKind =
|
||||
| "loading"
|
||||
| "sign_in"
|
||||
| "signing_in"
|
||||
| "install"
|
||||
| "offline_unavailable"
|
||||
| "signed_out_offline"
|
||||
| "installing"
|
||||
| "restoring"
|
||||
| "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 "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",
|
||||
"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);
|
||||
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)) : "");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
install() {
|
||||
return this.run("install", async () => {
|
||||
this.lastOp = { kind: "install" };
|
||||
await backend.startInstall();
|
||||
});
|
||||
}
|
||||
|
||||
async installFromDrive() {
|
||||
const path = await this.pickFolder();
|
||||
if (!path) return false;
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user