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:
Kelorastus
2026-09-15 15:23:17 +02:00
commit b7f3061be3
102 changed files with 47611 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
// The ONLY module that touches Tauri. Everything else talks to `backend`.
// Inside the Tauri webview this is a thin typed wrapper over invoke/listen
// (docs/CONTRACT.md); in a plain browser it is the timer-driven mock.
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { createMockBackend } from "./mock";
import type {
Backend,
InstallProgress,
LogEvent,
Settings,
SignInResult,
StackEvent,
Status,
Unlisten,
} from "./types";
export const IN_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
function subscribe<T>(event: string, handler: (payload: T) => void): Promise<Unlisten> {
return listen<T>(event, (e) => handler(e.payload));
}
function createTauriBackend(): Backend {
const win = getCurrentWindow();
return {
kind: "tauri",
mock: null,
window: {
minimize: () => win.minimize(),
toggleMaximize: () => win.toggleMaximize(),
close: () => win.close(),
isMaximized: () => win.isMaximized(),
onResized: (handler) => win.onResized(() => handler()),
},
getStatus: () => invoke<Status>("get_status"),
refresh: () => invoke<Status>("refresh"),
signIn: () => invoke<SignInResult>("sign_in"),
cancelSignIn: () => invoke<void>("cancel_sign_in"),
signOut: () => invoke<Status>("sign_out"),
getNotes: (version) => invoke<string>("get_notes", version === undefined ? {} : { version }),
openExternal: (url) => invoke<void>("open_external", { url }),
getStack: () => invoke<StackEvent[]>("get_stack"),
startInstall: () => invoke<void>("start_install"),
startInstallFromDrive: (path) => invoke<void>("start_install_from_drive", { path }),
cancelInstall: () => invoke<void>("cancel_install"),
launch: () => invoke<void>("launch"),
stop: () => invoke<void>("stop"),
getSettings: () => invoke<Settings>("get_settings"),
setSettings: (settings) => invoke<Status>("set_settings", { settings }),
pickFolder: () => invoke<string | null>("pick_folder"),
onStatus: (handler) => subscribe<Status>("status", handler),
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
onStack: (handler) => subscribe<StackEvent>("stack", handler),
onLog: (handler) => subscribe<LogEvent>("log", handler),
};
}
export const backend: Backend = IN_TAURI
? createTauriBackend()
: createMockBackend(new URLSearchParams(window.location.search));
+94
View File
@@ -0,0 +1,94 @@
import type { InstallStage, StackComponent, StackState } from "./types";
const UNITS = ["B", "KB", "MB", "GB", "TB"];
/** Binary sizes with Windows-style labels: 412 MB, 18.6 GB. */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < UNITS.length - 1) {
value /= 1024;
unit++;
}
const digits = unit === 0 || value >= 100 ? 0 : 1;
return `${value.toFixed(digits)} ${UNITS[unit]}`;
}
export function formatSpeed(bytesPerSec: number): string {
return `${formatBytes(bytesPerSec)}/s`;
}
/** 45 s · 4 min 12 s · 32 min · 1 h 03 min */
export function formatDuration(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return "—";
const s = Math.round(totalSeconds);
if (s < 60) return `${s} s`;
const minutes = Math.floor(s / 60);
if (minutes < 10) return `${minutes} min ${String(s % 60).padStart(2, "0")} s`;
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
return `${hours} h ${String(minutes % 60).padStart(2, "0")} min`;
}
export function formatCount(n: number): string {
return n.toLocaleString("en-US");
}
/** Unix seconds → 24 h wall clock. */
export function formatClock(unixSeconds: number): string {
const d = new Date(unixSeconds * 1000);
return [d.getHours(), d.getMinutes(), d.getSeconds()]
.map((n) => String(n).padStart(2, "0"))
.join(":");
}
export function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
/** Tauri rejects with a string; be forgiving about anything else. */
export function errorText(error: unknown): string {
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
if (error && typeof error === "object" && "message" in error) {
return String((error as { message: unknown }).message);
}
return "Unexpected error";
}
export const STAGE_LABEL: Record<InstallStage, string> = {
manifest: "Reading manifest",
app: "Application",
db: "Database snapshot",
src: "Source data",
tiles: "Map tiles",
restore: "Restoring database",
finalize: "Finalizing",
};
export const COMPONENT_LABEL: Record<StackComponent, string> = {
postgres: "Database",
api: "Engine API",
tiles: "Tile server",
odian: "ODIAN",
};
export const COMPONENT_DETAIL: Record<StackComponent, string> = {
postgres: "PostgreSQL",
api: "odian-engine-api",
tiles: "Local tiles",
odian: "Odian.exe",
};
export const STACK_STATE_LABEL: Record<StackState | "waiting", string> = {
waiting: "Waiting",
starting: "Starting",
ready: "Ready",
stopped: "Stopped",
failed: "Failed",
};
+394
View File
@@ -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();
+84
View File
@@ -0,0 +1,84 @@
// Patch notes come from the site (or its cached copy) and are untrusted:
// Markdown is rendered with marked, then every byte of HTML goes through
// DOMPurify with a small allowlist before it reaches {@html}.
import { Marked, type Token, type TokensList } from "marked";
import DOMPurify from "dompurify";
const marked = new Marked({ gfm: true, breaks: false, async: false });
const ALLOWED_TAGS = [
"h1", "h2", "h3", "h4", "h5", "h6",
"p", "br", "hr", "blockquote",
"ul", "ol", "li",
"strong", "em", "b", "i", "del", "s", "code", "pre", "kbd", "sub", "sup",
"a",
"table", "thead", "tbody", "tr", "th", "td",
"input", // GFM task-list checkboxes (forced disabled below)
];
const ALLOWED_ATTR = ["href", "title", "align", "start", "type", "checked", "disabled"];
const SAFE_HREF = /^(https?:|mailto:|#)/i;
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
const href = node.getAttribute("href") ?? "";
if (!SAFE_HREF.test(href.trim())) node.removeAttribute("href");
node.removeAttribute("target");
node.setAttribute("rel", "noopener noreferrer");
if (href && !node.getAttribute("title")) node.setAttribute("title", href);
}
if (node.tagName === "INPUT") {
if (node.getAttribute("type") !== "checkbox") node.remove();
else node.setAttribute("disabled", "");
}
});
function sanitize(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
});
}
function render(tokens: Token[], links: TokensList["links"]): string {
const list = Object.assign(tokens, { links }) as TokensList;
return sanitize(marked.parser(list));
}
function isRoadmapHeading(token: Token): boolean {
return token.type === "heading" && token.depth === 2 && /^roadmap$/i.test(token.text.trim());
}
export interface RenderedNotes {
/** Everything except the Roadmap section. */
notes: string;
/** Contents under `## Roadmap` (heading excluded), or null when absent/empty. */
roadmap: string | null;
}
export function renderNotes(markdown: string): RenderedNotes {
const tokens = marked.lexer(markdown);
const notes: Token[] = [];
const roadmap: Token[] = [];
let inRoadmap = false;
let sawRoadmap = false;
for (const token of tokens) {
if (isRoadmapHeading(token)) {
inRoadmap = true;
sawRoadmap = true;
continue;
}
if (inRoadmap && token.type === "heading" && token.depth <= 2) inRoadmap = false;
(inRoadmap ? roadmap : notes).push(token);
}
const hasRoadmapContent = roadmap.some((t) => t.type !== "space");
return {
notes: render(notes, tokens.links),
roadmap: sawRoadmap && hasRoadmapContent ? render(roadmap, tokens.links) : null,
};
}
+75
View File
@@ -0,0 +1,75 @@
// Sample patch notes for the browser mock. They deliberately contain markup
// that must NOT survive sanitizing (a script, an event handler, a javascript:
// link): if any of it shows up or runs, the sanitizer is broken.
export type NotesVariant = "full" | "no-roadmap";
const PROBES = `
<script>alert("patch notes are untrusted")</script>
<img src="x" onerror="alert('patch notes are untrusted')">
`;
const ROADMAP = `
## Roadmap
### Next — 0.53
- Globally unique IDs across DHPs, the groundwork for merging offline edits.
- ODIAN assistant in the Chat panel, answering from the ontology.
- Pattern-of-life (Habits) summary available to the assistant by hex ID.
### Later
- Merge offline edits back into the DHP database, git-style.
- Search across entity documents from the map.
- NATO MIM governance workflow for exported data.
`;
const NOTES: Record<string, string> = {
"0.52.0": `
# ODIAN 0.52.0
Released 2026-09-12 · required update for all operators on this DHP.
## Highlights
- **Mission assignment** — target → aircraft tasking now proposes a ranked list. The pick you make is recorded with the solve.
- **Habits panel** — a pattern-of-life summary for each aircraft, one click from the track card.
- **Offline map tiles** — the dark basemap \`protomaps_20260423\` ships with the install, so panning and zooming never need the network.
## Improvements
- Track trails draw about 3× faster above 5,000 live contacts.
- NATO MIM export validates against the SHACL shapes before it writes anything.
- The Chat panel keeps its scroll position while new messages arrive.
- Install and update resume from the last verified file.
## Fixes
- Fixed a crash when a vessel report had no MMSI.
- Vessel positions no longer freeze after an AIS feed outage.
- The replay time slider no longer skips the final minute.
${PROBES}
## Known issues
- The first launch after an install can take up to 60 s while Postgres warms its cache.
> Report problems from **Help → Send diagnostics** inside ODIAN, or read the [operator guide](https://docs.example.invalid/odian/0.52) and [this link](javascript:alert(1)).
`,
"0.51.0": `
# ODIAN 0.51.0
Released 2026-08-04.
## Highlights
- **Airspace layers** — restricted and danger areas draw from the DHP's AIXM import.
- **Track history** — scrub up to 72 h of recorded positions per contact.
## Improvements
- Startup is 40% faster on machines with a local database.
- Layer panel remembers which groups you collapsed.
## Fixes
- Fixed tooltips lingering after the map was dragged.
- Fixed a rare deadlock when two operators edited the same annotation.
`,
};
export function mockNotes(version: string, variant: NotesVariant): string {
const body = NOTES[version] ?? `# ODIAN ${version}\n\nNo notes were published for this version.\n`;
return variant === "full" ? `${body}${ROADMAP}` : body;
}
+613
View File
@@ -0,0 +1,613 @@
// Browser-only stand-in for the Rust backend, so the hub can be designed with
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
//
// ?scenario=first-run | update-required | ready | offline-installed |
// offline-not-installed | installing | running | error
// &fail=sign_in | install | restore | launch | notes make that flow fail
// &speed=4 run timers 4x faster
// &notes=no-roadmap notes without a Roadmap
//
// Commands reject with a plain string, like Tauri does.
import { mockNotes, type NotesVariant } from "./mock-notes";
import {
MOCK_SCENARIOS,
SIGN_IN_CANCELLED,
type Backend,
type InstallProgress,
type InstallStage,
type LogEvent,
type LogLevel,
type MockControls,
type MockScenario,
type Settings,
type SignInResult,
type StackComponent,
type StackEvent,
type Status,
type Unlisten,
} from "./types";
const CURRENT = "0.52.0";
const PREVIOUS = "0.51.0";
const OPERATOR = "m1op";
const SITE_URL = "http://192.168.1.41:30097";
const SITE_NAME = "DHP Alpha";
const MCP_URL = "http://192.168.1.41:30091";
const DEFAULT_DIR = "D:\\ODIAN";
const KB = 1024;
const MB = 1024 ** 2;
const GB = 1024 ** 3;
const FAIL_POINTS = ["sign_in", "install", "restore", "launch", "notes"] as const;
type FailPoint = (typeof FAIL_POINTS)[number];
interface StagePlan {
stage: InstallStage;
/** Simulated wall time; the reported speed stays realistic. */
seconds: number;
bytes: number;
items: number;
bytesPerSec: number;
message: (done: number, total: number) => string | null;
}
function installPlan(kind: "fresh" | "update", source: "site" | "drive"): StagePlan[] {
const rate = source === "drive" ? 468 * MB : 112 * MB;
const plan: StagePlan[] = [
{
stage: "manifest",
seconds: 0.8,
bytes: 86 * KB,
items: 1,
bytesPerSec: source === "drive" ? 40 * MB : 2.4 * MB,
message: () => `manifest ${CURRENT} (${source === "drive" ? "drive" : SITE_NAME})`,
},
{
stage: "app",
seconds: 3,
bytes: kind === "update" ? 96 * MB : 412 * MB,
items: kind === "update" ? 138 : 1284,
bytesPerSec: rate,
message: (d, t) => `app: ${d}/${t} files`,
},
{
stage: "db",
seconds: 4,
bytes: kind === "update" ? 2.3 * GB : 18.6 * GB,
items: 1,
bytesPerSec: rate,
message: () => (kind === "update" ? "db odian_20260912.delta" : "db odian_20260912.dump"),
},
];
if (kind === "fresh") {
plan.push(
{
stage: "src",
seconds: 2.5,
bytes: 3.4 * GB,
items: 412,
bytesPerSec: rate,
message: (d, t) => `src: ${d}/${t} files`,
},
{
stage: "tiles",
seconds: 9,
bytes: 212 * GB,
items: 1402,
bytesPerSec: rate,
message: (d, t) => `tiles dark/protomaps_20260423: ${d}/${t} blocks`,
},
);
}
plan.push(
{
stage: "restore",
seconds: 5,
bytes: 0,
items: 231,
bytesPerSec: 0,
message: (d, t) => `pg_restore: ${d}/${t} tables`,
},
{
stage: "finalize",
seconds: 1,
bytes: 0,
items: 0,
bytesPerSec: 0,
message: () => "writing install manifest",
},
);
return plan;
}
const STAGE_DONE: Record<InstallStage, string> = {
manifest: "Manifest verified",
app: "Application files verified",
db: "Database snapshot verified",
src: "Source data verified",
tiles: "Map tiles verified",
restore: "Database restored into local Postgres",
finalize: "Install finalized",
};
type Payloads = {
status: Status;
"install-progress": InstallProgress;
stack: StackEvent;
log: LogEvent;
};
type Channel = keyof Payloads;
type Listeners = { [K in Channel]: Set<(payload: Payloads[K]) => void> };
const now = () => Math.floor(Date.now() / 1000);
const busy = "Busy: wait for the current task to finish.";
function pick<T extends string>(value: string | null, allowed: readonly T[]): T | null {
return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : null;
}
/** Enforces the contract's derived fields. */
function derive(s: Status): Status {
const available = s.online ? (s.available ?? CURRENT) : null;
const update_required = s.online && s.installed !== null && available !== null && s.installed !== available;
return {
...s,
available,
signed_in: s.online && s.signed_in,
update_required,
can_launch: s.installed !== null && !update_required && s.phase === "idle",
};
}
function initialStatus(scenario: MockScenario): Status {
const s: Status = {
online: true,
site: { url: SITE_URL, name: SITE_NAME },
operator: null,
signed_in: false,
installed: null,
available: CURRENT,
update_required: false,
can_launch: false,
phase: "idle",
error: null,
install_dir: DEFAULT_DIR,
};
switch (scenario) {
case "first-run":
break;
case "update-required":
Object.assign(s, { installed: PREVIOUS, operator: OPERATOR, signed_in: true });
break;
case "ready":
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
break;
case "offline-installed":
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
break;
case "offline-not-installed":
Object.assign(s, { online: false });
break;
case "installing":
Object.assign(s, { operator: OPERATOR, signed_in: true, phase: "installing" });
break;
case "running":
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running" });
break;
case "error":
Object.assign(s, {
operator: OPERATOR,
signed_in: true,
phase: "error",
error:
"Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume.",
});
break;
}
return derive(s);
}
class MockBackend implements Backend {
readonly kind = "mock" as const;
readonly mock: MockControls;
readonly window = {
minimize: async () => this.log("info", "Minimize pressed (browser mock)"),
toggleMaximize: async () => this.log("info", "Maximize pressed (browser mock)"),
close: async () => this.log("info", "Close pressed (browser mock: the tab stays open)"),
isMaximized: async () => false,
onResized: async (): Promise<Unlisten> => () => {},
};
private status: Status;
private settings: Settings;
private network: boolean;
private job = 0;
private booted = false;
private signInCancelled = false;
/** Latest event per component, as `get_stack` reports it. */
private readonly stackStates = new Map<StackComponent, StackEvent>();
private readonly listeners: Listeners = {
status: new Set(),
"install-progress": new Set(),
stack: new Set(),
log: new Set(),
};
constructor(
private readonly scenario: MockScenario,
private readonly fail: FailPoint | null,
private readonly speed: number,
private readonly notesVariant: NotesVariant,
) {
this.status = initialStatus(scenario);
this.network = this.status.online;
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
const self = this;
this.mock = {
get scenario() {
return self.scenario;
},
get online() {
return self.network;
},
setOnline(online: boolean) {
self.network = online;
self.log(online ? "info" : "warn", online ? "Mock: network restored" : "Mock: network cable pulled");
void self.refresh();
},
};
}
// ── plumbing ────────────────────────────────────────────────────────────
private emit<K extends Channel>(channel: K, payload: Payloads[K]) {
if (channel === "stack") {
const event = payload as StackEvent;
this.stackStates.set(event.component, structuredClone(event));
}
for (const handler of this.listeners[channel]) handler(structuredClone(payload));
}
private listen<K extends Channel>(channel: K, handler: (payload: Payloads[K]) => void): Promise<Unlisten> {
this.listeners[channel].add(handler);
return Promise.resolve(() => void this.listeners[channel].delete(handler));
}
private commit(patch: Partial<Status>) {
this.status = derive({ ...this.status, ...patch });
this.emit("status", this.status);
}
private log(level: LogLevel, message: string, at = now()) {
this.emit("log", { level, message, at });
}
private wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms / this.speed));
}
private snapshot(): Status {
return structuredClone(this.status);
}
/** Scenario side effects start on the first get_status, once the UI listens. */
private boot() {
if (this.booted) return;
this.booted = true;
const t = now();
const s = this.status;
this.log("info", "ODIAN Launcher started", t - 6);
this.log(
s.online ? "info" : "warn",
s.online
? `${SITE_NAME} answered /v1/health (current version ${CURRENT})`
: `${SITE_URL} unreachable (2 s timeout): working offline`,
t - 5,
);
if (s.update_required) {
this.log("warn", `Installed ${s.installed} is behind the DHP (${CURRENT}): update required before launch`, t - 4);
}
if (this.scenario === "running") this.log("info", "Odian.exe is running (pid 14212)", t - 3);
if (this.scenario === "error" && s.error) this.log("error", s.error, t - 2);
if (this.scenario === "installing") {
setTimeout(() => void this.runInstall("fresh", "site", undefined, { index: 4, fraction: 0.27 }), 300);
}
}
// ── commands ────────────────────────────────────────────────────────────
async getStatus(): Promise<Status> {
this.boot();
return this.snapshot();
}
async refresh(): Promise<Status> {
const wasIdle = this.status.phase === "idle";
const wasOnline = this.status.online;
if (wasIdle) this.commit({ phase: "checking" });
await this.wait(this.network ? 450 : 2000);
const online = this.network;
this.commit({
online,
phase: wasIdle && this.status.phase === "checking" ? "idle" : this.status.phase,
});
if (online !== wasOnline) {
this.log(
online ? "info" : "warn",
online ? `${SITE_NAME} is reachable again (current version ${CURRENT})` : `${SITE_NAME} unreachable: working offline`,
);
if (online && this.status.update_required) {
this.log("warn", `Update to ${CURRENT} required before launch while connected`);
}
}
return this.snapshot();
}
async signIn(): Promise<SignInResult> {
if (!this.status.online) throw "Your DHP is unreachable. Connect to the DHP network to sign in.";
if (this.status.phase !== "idle") throw busy;
this.commit({ phase: "signing_in", error: null });
this.signInCancelled = false;
this.log("info", "Opened the system browser for Keycloak sign-in");
// Like the real backend, a cancel ends the wait at once instead of after the browser timeout.
for (let waited = 0; waited < 2400 && !this.signInCancelled; waited += 100) await this.wait(100);
if (this.signInCancelled) {
this.signInCancelled = false;
throw SIGN_IN_CANCELLED;
}
// Re-read after the await: settings or network toggles may have changed the phase.
if ((this.status.phase as string) !== "signing_in") throw "Sign-in was interrupted.";
if (this.fail === "sign_in") {
const message = "Sign-in timed out: the browser never called back to the launcher.";
this.commit({ phase: "idle", error: message });
throw message;
}
this.commit({ phase: "idle", signed_in: true, operator: OPERATOR });
this.log("info", `Signed in as ${OPERATOR}`);
return { username: OPERATOR };
}
async cancelSignIn(): Promise<void> {
if (this.status.phase !== "signing_in") return;
this.signInCancelled = true;
this.commit({ phase: "idle" });
this.log("info", "Sign-in cancelled");
}
async signOut(): Promise<Status> {
this.commit({ signed_in: false, operator: null });
this.log("info", "Signed out: this PC no longer remembers the operator");
return this.snapshot();
}
async openExternal(url: string): Promise<void> {
if (!/^https?:\/\//i.test(url)) throw "Only web links can be opened.";
this.log("info", `Opened ${url} in the system browser (browser mock: new tab)`);
window.open(url, "_blank", "noopener,noreferrer");
}
async getStack(): Promise<StackEvent[]> {
if (this.scenario === "running" && this.stackStates.size === 0) {
for (const component of ["postgres", "api", "tiles", "odian"] as const) {
this.stackStates.set(component, { component, state: "ready", message: null });
}
}
return [...this.stackStates.values()].map((event) => structuredClone(event));
}
async getNotes(version?: string): Promise<string> {
await this.wait(380);
if (this.fail === "notes") throw "Could not load patch notes: the DHP answered HTTP 502.";
if (!this.status.online) {
if (!this.status.installed) throw "No cached notes: ODIAN is not installed on this machine.";
return mockNotes(this.status.installed, this.notesVariant);
}
return mockNotes(version ?? this.status.available ?? CURRENT, this.notesVariant);
}
async startInstall(): Promise<void> {
const s = this.status;
if (!s.online) throw "Your DHP is unreachable. Connect to install, or install from a drive.";
if (!s.signed_in) throw "Sign in first: downloading from the DHP needs your DHP sign-in.";
if (s.phase !== "idle" && s.phase !== "error") throw busy;
const kind = s.installed && s.installed !== s.available ? "update" : "fresh";
void this.runInstall(kind, "site");
}
async startInstallFromDrive(path: string): Promise<void> {
const s = this.status;
if (s.phase !== "idle" && s.phase !== "error") throw busy;
if (!path.trim()) throw "Pick the root folder of an ODIAN drive.";
const kind = s.installed && s.installed !== CURRENT ? "update" : "fresh";
void this.runInstall(kind, "drive", path);
}
async cancelInstall(): Promise<void> {
if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started.";
if (this.status.phase !== "installing") throw "Nothing is installing.";
this.job++;
this.commit({ phase: "idle" });
this.log("warn", "Install cancelled at the next file boundary; everything already verified was kept");
}
async launch(): Promise<void> {
const s = this.status;
if (!s.can_launch) {
throw s.update_required
? `Update to ${s.available} before launching while connected.`
: "ODIAN is not ready to launch.";
}
void this.runLaunch();
}
async stop(): Promise<void> {
const phase = this.status.phase;
if (phase !== "running" && phase !== "launching") throw "ODIAN is not running.";
void this.runStop();
}
async getSettings(): Promise<Settings> {
return structuredClone(this.settings);
}
async setSettings(settings: Settings): Promise<Status> {
for (const [label, url] of [
["DHP URL", settings.site_url],
["MCP URL", settings.mcp_url],
] as const) {
try {
new URL(url);
} catch {
throw `${label} is not a valid URL: ${url}`;
}
}
if (!settings.install_dir.trim()) throw "Install folder cannot be empty.";
const siteChanged = settings.site_url !== this.settings.site_url;
this.settings = structuredClone(settings);
this.commit({
site: { url: settings.site_url, name: settings.site_url === SITE_URL ? SITE_NAME : null },
install_dir: settings.install_dir,
});
this.log("info", "Settings saved");
if (siteChanged) void this.refresh();
return this.snapshot();
}
async pickFolder(): Promise<string | null> {
await new Promise((resolve) => setTimeout(resolve, 450));
return "E:\\ODIAN";
}
onStatus(handler: (status: Status) => void) {
return this.listen("status", handler);
}
onInstallProgress(handler: (progress: InstallProgress) => void) {
return this.listen("install-progress", handler);
}
onStack(handler: (event: StackEvent) => void) {
return this.listen("stack", handler);
}
onLog(handler: (event: LogEvent) => void) {
return this.listen("log", handler);
}
// ── flows ───────────────────────────────────────────────────────────────
private failFlow(message: string) {
this.job++;
this.commit({ phase: "error", error: message });
this.log("error", message);
}
private async runInstall(
kind: "fresh" | "update",
source: "site" | "drive",
path?: string,
resume?: { index: number; fraction: number },
) {
const job = ++this.job;
const plan = installPlan(kind, source);
this.commit({ phase: "installing", error: null });
if (!resume) {
const verb = kind === "update" ? `Updating to ODIAN ${CURRENT}` : `Installing ODIAN ${CURRENT}`;
this.log("info", source === "drive" ? `${verb} from drive ${path}` : `${verb} from ${SITE_NAME}`);
}
for (let i = resume?.index ?? 0; i < plan.length; i++) {
const step = plan[i];
const phase = step.stage === "restore" ? "restoring" : "installing";
if (this.status.phase !== phase) this.commit({ phase });
const ticks = Math.max(1, Math.round(step.seconds * 10));
const first = resume && i === resume.index ? Math.floor(ticks * resume.fraction) : 0;
for (let t = first; t <= ticks; t++) {
if (t > first) await this.wait(100);
if (job !== this.job) return;
const fraction = t / ticks;
if (source === "site" && !this.network) {
return this.failFlow(
`Connection to ${SITE_NAME} lost during ${step.stage}. Everything already verified was kept; retry when you are back online.`,
);
}
if (this.fail === "install" && step.stage === (kind === "fresh" ? "tiles" : "db") && fraction >= 0.62) {
return this.failFlow(
step.stage === "tiles"
? "Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume."
: "odian_20260912.delta failed verification (sha256 mismatch). Retry to download it again.",
);
}
if (this.fail === "restore" && step.stage === "restore" && fraction >= 0.4) {
return this.failFlow("pg_restore failed on table track_point: could not extend file (No space left on device).");
}
const done = Math.round(step.items * fraction);
const wobble = 1 + 0.07 * Math.sin(t / 6) + (Math.random() - 0.5) * 0.06;
this.emit("install-progress", {
stage: step.stage,
stage_index: i + 1,
stage_count: plan.length,
done_bytes: Math.round(step.bytes * fraction),
total_bytes: Math.round(step.bytes),
done_items: done,
total_items: step.items,
bytes_per_sec: step.bytesPerSec > 0 && t < ticks ? Math.round(step.bytesPerSec * wobble) : 0,
message: step.message(done, step.items),
});
}
this.log("info", STAGE_DONE[step.stage]);
}
if (job !== this.job) return;
this.commit({ phase: "idle", installed: CURRENT });
this.log("info", `ODIAN ${CURRENT} is installed in ${this.status.install_dir}`);
}
private async runLaunch() {
const job = ++this.job;
this.commit({ phase: "launching", error: null });
this.log("info", `Starting the local stack for ODIAN ${this.status.installed}`);
const steps: [StackComponent, number, string][] = [
["postgres", 1300, "listening on 127.0.0.1:55432"],
["api", 1500, "healthy on 127.0.0.1:18080"],
["tiles", 900, "serving 3 tilesets"],
["odian", 1700, "Odian.exe pid 14212"],
];
for (const [component, ms, ready] of steps) {
this.emit("stack", { component, state: "starting", message: null });
await this.wait(ms);
if (job !== this.job) return;
if (this.fail === "launch" && component === "api") {
this.emit("stack", { component, state: "failed", message: "exit code 1" });
this.emit("stack", { component: "postgres", state: "stopped", message: null });
return this.failFlow("Engine API exited with code 1: bind 127.0.0.1:18080: address already in use.");
}
this.emit("stack", { component, state: "ready", message: ready });
this.log("info", `${component}: ${ready}`);
}
this.commit({ phase: "running" });
}
private async runStop() {
const job = ++this.job;
this.log("info", "Stopping the local stack");
for (const component of ["odian", "tiles", "api", "postgres"] as const) {
await this.wait(380);
if (job !== this.job) return;
this.emit("stack", { component, state: "stopped", message: null });
}
this.commit({ phase: "idle" });
this.log("info", "Local stack stopped cleanly");
}
}
export function createMockBackend(params: URLSearchParams): Backend {
const scenario = pick(params.get("scenario"), MOCK_SCENARIOS) ?? "first-run";
const fail = pick(params.get("fail"), FAIL_POINTS);
const speed = Math.min(20, Math.max(0.25, Number(params.get("speed")) || 1));
const notes: NotesVariant = params.get("notes") === "no-roadmap" ? "no-roadmap" : "full";
return new MockBackend(scenario, fail, speed, notes);
}
+160
View File
@@ -0,0 +1,160 @@
// Wire types shared with the Rust backend. Mirrors docs/CONTRACT.md exactly:
// snake_case fields, timestamps in Unix seconds.
export type Phase =
| "idle" // nothing running
| "checking" // probing the site
| "signing_in" // browser sign-in in progress
| "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres
| "launching" // starting Postgres → API → tile server → Odian.exe
| "running" // Odian.exe is open
| "error";
export interface SiteInfo {
url: string;
name: string | null;
}
export interface Status {
/** The site's update service answered /v1/health. */
online: boolean;
site: SiteInfo;
/** Last signed-in ODIAN username (cached; valid offline). */
operator: string | null;
/** Holds a site token right now (online only). */
signed_in: boolean;
/** Installed version, e.g. "0.51.0". */
installed: string | null;
/** The site's CURRENT version (online only). */
available: string | null;
/** online && installed !== available (launch is blocked). */
update_required: boolean;
/** installed && !update_required && phase is idle. */
can_launch: boolean;
phase: Phase;
/** Last error, human readable. */
error: string | null;
/** e.g. "D:\\ODIAN". */
install_dir: string;
}
export interface Settings {
/** e.g. "http://192.168.1.41:30097". */
site_url: string;
/** Site MCP, for Keycloak sign-in. */
mcp_url: string;
install_dir: string;
}
export type InstallStage =
| "manifest"
| "app"
| "db"
| "src"
| "tiles"
| "restore"
| "finalize";
export interface InstallProgress {
stage: InstallStage;
/** 1-based. */
stage_index: number;
stage_count: number;
done_bytes: number;
/** 0 when unknown. */
total_bytes: number;
done_items: number;
total_items: number;
bytes_per_sec: number;
/** e.g. "tiles dark/protomaps_20260423: 412/1402 blocks". */
message: string | null;
}
export type StackComponent = "postgres" | "api" | "tiles" | "odian";
export type StackState = "starting" | "ready" | "stopped" | "failed";
export interface StackEvent {
component: StackComponent;
state: StackState;
message: string | null;
}
export type LogLevel = "info" | "warn" | "error";
export interface LogEvent {
level: LogLevel;
message: string;
/** Unix seconds. */
at: number;
}
export interface SignInResult {
username: string;
}
/** The message `sign_in` rejects with after `cancel_sign_in`: not an error to show. */
export const SIGN_IN_CANCELLED = "Sign-in was cancelled.";
export type Unlisten = () => void;
/** The frameless window's own controls (drawn by the UI title bar). */
export interface WindowControls {
minimize(): Promise<void>;
toggleMaximize(): Promise<void>;
close(): Promise<void>;
isMaximized(): Promise<boolean>;
onResized(handler: () => void): Promise<Unlisten>;
}
export const MOCK_SCENARIOS = [
"first-run",
"update-required",
"ready",
"offline-installed",
"offline-not-installed",
"installing",
"running",
"error",
] as const;
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
/** Design-time controls, only present on the browser mock. Not part of the contract. */
export interface MockControls {
readonly scenario: MockScenario;
readonly online: boolean;
setOnline(online: boolean): void;
}
/** Everything the UI may ask of the backend. Commands and events from CONTRACT.md. */
export interface Backend {
readonly kind: "tauri" | "mock";
readonly mock: MockControls | null;
readonly window: WindowControls;
getStatus(): Promise<Status>;
refresh(): Promise<Status>;
signIn(): Promise<SignInResult>;
/** Abandons a sign-in waiting for the browser; `signIn` then rejects with SIGN_IN_CANCELLED. */
cancelSignIn(): Promise<void>;
signOut(): Promise<Status>;
getNotes(version?: string): Promise<string>;
/** Opens an http(s) link in the system browser. */
openExternal(url: string): Promise<void>;
startInstall(): Promise<void>;
startInstallFromDrive(path: string): Promise<void>;
cancelInstall(): Promise<void>;
launch(): Promise<void>;
stop(): Promise<void>;
/** Latest event per stack component, for a UI that (re)loads mid-launch. */
getStack(): Promise<StackEvent[]>;
getSettings(): Promise<Settings>;
setSettings(settings: Settings): Promise<Status>;
pickFolder(): Promise<string | null>;
onStatus(handler: (status: Status) => void): Promise<Unlisten>;
onInstallProgress(handler: (progress: InstallProgress) => void): Promise<Unlisten>;
onStack(handler: (event: StackEvent) => void): Promise<Unlisten>;
onLog(handler: (event: LogEvent) => void): Promise<Unlisten>;
}