Files
ODIAN_Launcher/docs/CONTRACT.md
T
Kelorastus 4d620fc5ad update
2026-09-23 16:05:19 +02:00

13 KiB

UI ↔ backend contract

The Svelte UI (src/) talks to the Rust backend (src-tauri/) only through the Tauri commands and events below. src/lib/backend.ts is the single place that calls them. When the page runs outside Tauri (a plain browser, for design work), it falls back to a mock with the same shapes.

All names are snake_case on the wire (serde defaults), and timestamps are Unix seconds.

Types

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
  | "syncing"       // exchanging operator data with the DHP (ODIAN is not running)
  | "launching"     // starting Postgres → API → tile server → Odian.exe
  | "running"       // Odian.exe is open
  | "error";        // the last long operation failed; see `error`

interface Status {
  online: boolean;              // the site's update service answered /v1/health
  site: { url: string; name: string | null };
  operator: string | null;      // last signed-in ODIAN username (cached; valid offline)
  signed_in: boolean;           // this PC remembers the operator's site sign-in until Sign out: the
                                // launcher renews the short Engine token by itself (launcher session)
  installed: string | null;     // installed version, e.g. "0.51.0"
  available: string | null;    // the site's CURRENT version (online only)
  update_required: boolean;     // online && installed !== null && available !== null && installed !== available
                                // (never true when nothing is installed: that is a first install)
  can_launch: boolean;          // installed && !update_required && phase is idle or error
  phase: Phase;
  error: string | null;         // set with phase "error"; cleared by the next command or `refresh`
  install_dir: string;          // e.g. "D:\\ODIAN"
  portable: boolean;            // this launcher ships on an ODIAN drive: it runs that drive's
                                // install (any letter), the folder is fixed, no self-update
  sync: SyncStatus | null;      // counts from the last sync this session; null before one
  live: boolean | null;         // while ODIAN runs: true = its data comes from the DHP,
                                // false = from this PC. null when it is not running, or
                                // the DHP offers no Engine API to forward to
}

interface SyncStatus {
  pending: number;              // local edits the DHP has not taken yet
  conflicts: number;            // records both sides changed: the operator decides
  rejected: number;             // edits the DHP refused (another operator's record, ...)
  waiting: number;              // DHP changes this PC could not apply yet
}

interface SyncConflict {
  tbl: string;                  // database table, e.g. "db_ground_poi"
  key: Record<string, unknown>; // the record's key columns
  kind: "conflict" | "rejected";
  fields: string[];             // clashing fields; "*exists": one side deleted it; "*value": a whole-value clash
  base: unknown;                // the DHP value before this PC's edit
  mine: unknown;                // this PC's value (null = deleted here)
  theirs: unknown;              // the DHP's value (null = deleted there)
  reason: string | null;        // why the DHP refused it (rejected only)
}

interface Settings {
  site_url: string;             // e.g. "http://192.168.1.41:30097"
  mcp_url: string;              // site MCP, for Keycloak sign-in
  install_dir: string;
}

interface InstallProgress {
  stage: "manifest" | "app" | "db" | "src" | "tiles" | "restore" | "finalize";
  stage_index: number;          // 1-based
  stage_count: number;
  done_bytes: number;           // within the current stage
  total_bytes: number;          // of the current stage; 0 when unknown
  done_items: number;           // within the current stage
  total_items: number;
  bytes_per_sec: number;
  message: string | null;       // e.g. "tiles dark/protomaps_20260423: 412/1402 blocks"
}

interface StackEvent {
  component: "postgres" | "api" | "tiles" | "odian";
  state: "starting" | "ready" | "stopped" | "failed";
  message: string | null;
}

interface LogEvent { level: "info" | "warn" | "error"; message: string; at: number }

interface MarketItem {
  key: string;          // stable across versions: "tiles:<style>/<dataset>" | "src:<path>"
  kind: "tiles" | "src";
  title: string;
  description: string;
  bytes: number;        // what installing it downloads, in full
  required: boolean;    // part of every install; never offered for removal
  presence: "installed" | "partial" | "absent";   // partial: a download that stopped
  selected: boolean;    // chosen: installed with the version, kept by updates
}

interface Market {
  version: string;      // the installed version, else the DHP's current one
  installed: boolean;   // false: a choice waits for Install
  core_bytes: number;   // the app and database: what every install downloads besides the items
  items: MarketItem[];  // required first
  free_bytes: number | null;   // free space where ODIAN is (or will be) installed
}

Commands (invoke)

Command Args Returns Notes
get_status — Status cheap; no network
refresh — Status re-probes the site (2 s timeout); also clears an error phase
sign_in — { username: string } opens the system browser (MCP desktop flow) and waits for the loopback callback. Rejects with "Sign-in was cancelled." after cancel_sign_in (phase returns to idle, not error).
cancel_sign_in — void abandons a sign-in that is waiting for the browser
sign_out — Status forgets the operator on this PC: the site sign-in, its launcher session (ended on the site when reachable) and the cached operator. Offline launch needs a new sign-in afterwards, so the UI confirms first
get_notes { version?: string } string Markdown. From the site when online; the cached copy of the installed version offline. Untrusted: the UI must sanitize.
open_external { url: string } void opens an http/https link in the system browser (links in patch notes); anything else is refused
start_install — void installs or updates to available; needs signed_in (the site checks the token); progress arrives as events
start_install_from_drive { path: string } void same, copying from an ODIAN drive (SSD) instead of the site; no sign-in needed. path is the drive's root (a pack store) or a folder holding an install, and every file copied is checked against the manifest. A tile set the PC already records as complete for the same index (.blocks-done.json) is left as it stands instead of being re-read: that record is written last, so a copy stopped halfway still reads as partial and resumes. The copy lands in the install folder of Settings, so a drive can install onto the PC it is plugged into; an install folder that is the drive's own ODIAN (or inside it) is refused, since there is nothing to copy, and so is a drive carrying an older ODIAN than the one installed there
cancel_install — void stops at the next file boundary; everything already verified is kept
launch — void starts the local stack; stack events follow
stop — void stops Odian.exe's stack cleanly
get_stack — StackEvent[] the latest event per component of the current or last launch (for a reloaded UI)
get_settings — Settings
set_settings { settings: Settings } Status
pick_folder — string | null native folder picker
get_market { drive?: string } Market the marketplace: every tile set and source file of the installed version (read from disk), or of the DHP's current one before the first install. With drive, what that drive carries instead (what it cannot install is left out), which is how the UI asks before installing from a drive
apply_market { selected: string[] } void saves the choice of optional items (keys), then applies it to the installed version: unchosen items are removed from the PC, chosen missing ones download (phase installing, install-progress events). Refused while busy or ODIAN runs, and while an update is required; downloading needs the DHP. Before the first install it only saves the choice
set_market { selected: string[], drive?: string } void the choice of optional items (keys) for an install about to run; changes nothing installed. Without drive it is this PC's standing choice and is saved. With drive it filters that one copy and is NOT saved: leaving out a tile set the PC already holds must not read as dropping it, or the next apply_market would delete it
sync_now — void pushes and pulls operator data now; only while ODIAN is stopped (phase syncing meanwhile)
get_sync_conflicts — SyncConflict[] what waits for a decision, oldest first; starts the local Postgres just for the read
resolve_sync_conflict { tbl, key, choice: "theirs" | "mine" | "value" | "dismiss", value?: unknown } SyncConflict[] records one decision and returns what is left. theirs takes the DHP's version; mine keeps this PC's (the next sync sends it); value keeps a field-by-field pick; dismiss keeps a refused edit on this PC only. Links and folder placements held with a record follow its decision

Errors reject the promise with a string message.

Planned, not yet available: the size of available for the Install button.

Where a drive's launcher installs

A launcher that ships on an ODIAN drive (site.json with "portable": true) keeps its settings beside the exe, so the site and the install folder travel with the drive. The folder may be on the drive itself, and ODIAN then runs from the drive on any PC: it is stored without its letter, because the next PC hands the drive a different one. It may also be a folder on a disk of the PC in front of the operator, and the drive then installs ODIAN onto that PC and leaves; that folder is stored whole. A drive's root and a network share are refused: Postgres and the tiles need a real disk.

Marketplace

The DHP decides what is optional: a manifest tile set or source file with "optional": true (plus title and description, from catalog.json at assemble) is left out of Install and listed by get_market. Everything else is part of every install. A manifest from before the marketplace flags nothing, so the launcher applies the same rule to it: the app, the database and the dark tile set come with every install and the rest is offered, which is what makes copying an older version from a drive a choice rather than all 228 GB or nothing. A manifest that flags anything at all is the DHP's to decide and is taken exactly as it stands. The choice is kept in the install's state (extras) and survives updates: an update refreshes the chosen items and nothing else. Installing from a drive asks the same way: the marketplace opens on what the drive carries, set_market records the answer, and start_install_from_drive copies it. A PC installed before the marketplace counts what it has on disk as chosen, so nothing is removed or downloaded again behind the operator's back.

Live mode

When the DHP advertises an Engine API (engine_api_url in its /v1/health), the launcher starts the local API pointed at it and keeps a token file fresh for it. ODIAN always talks to the local API; while the DHP answers, that API forwards the shared-data routes there, so operators see each other's work and tracks are live. The moment the DHP stops answering, the same API serves this PC's own database instead — no restart, and the request that discovers the outage is already served locally. Coming back, the launcher first sends what was edited offline, then hands the token back and ODIAN is live again. status.live says which of the two is happening.

Operator-data sync

When the installed version ships the sync schema (versions\<v>\sync\*.sql) and the DHP is reachable, launch first syncs (push, then pull), then starts the stack. When ODIAN closes, the launcher pushes that session's work. Offline, edits stay on this PC. A sync that cannot finish is logged and never blocks the launch. Conflicts and refused edits wait in the local database until resolve_sync_conflict.

Events (listen)

Event Payload When
status Status whenever any field changes
install-progress InstallProgress at most ~10 per second while installing
stack StackEvent on each component state change
log LogEvent notable backend messages, for the activity panel