Files
ODIAN_Launcher/docs/CONTRACT.md
T
Kelorastus 80dd1e7c7d update
2026-09-29 10:29:52 +02:00

18 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)
  | "pulling_tiles" // fetching tiles the DHP baked while this PC was away
  | "removing"      // deleting marketplace items the operator unticked
  | "building"      // dev mode only: compiling the checkout before it launches
  | "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 (2 s, then 10 s if that timed out: a hotspot is slow, not absent)
  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
  map_from_site: boolean | null; // while ODIAN runs: false = the map is this PC's own tiles
                                // because the site's tile service did not answer
  map_problem: string | null;    // why, in one line, when map_from_site is false
  tiles_waiting: Waiting | null; // tiles the DHP baked for the sets this PC holds since its
                                 // last sync: what a sync would fetch. null offline, with
                                 // nothing installed, or when the DHP is too old to ask
  unfinished: Unfinished | null; // chosen marketplace items not fully on this PC. ODIAN
                                 // still runs, with less map than was asked for
  dev: Licence | null;           // this PC's dev licence. null on every operator PC, and
                                 // the window then shows no trace of dev mode at all
}

interface Waiting {
  tiles: number;                // 0 means this PC is level with the site
  sets: number;                 // how many of its tile sets have something waiting
  capped: boolean;              // the DHP stopped counting: "50000+"
}

interface Unfinished {
  items: number;
  bytes: number;                // what finishing them downloads IN FULL — the whole item,
                                // not the gap, which is what the installer skips through
  titles: string[];             // the first few, to name in one line
}

interface Licence {
  machine: string | null;       // the PC it was issued for; null = not tied to one
  install_id: string | null;    // the install it was issued for; null = not tied to one
  expires_at: string;           // "YYYY-MM-DD", the last day it works
  note: string | null;
}

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"; "" = no DHP, run offline
  mcp_url: string;              // site MCP, for Keycloak sign-in; "" = no sign-in, run offline
  ai_url: string;               // MCP serving /api/ai/... over the internet; "" = none
  install_dir: string;
  // Dev mode. Only honoured on a PC whose `dev` licence is valid: `set_settings` stores
  // dev_mode as false without one, whatever the window sends.
  dev_root: string;             // folder holding the checkouts (ODIAN_Engine); "" = none
  dev_mode: boolean;            // run the stack from the checkout, each part in its own console
  dev_user: string;             // run as this operator instead of the signed-in one; "" = them.
                                // A DIFFERENT name switches sync OFF for that run
  dev_build: boolean;           // build the checkout before launching (phase "building")
  dev_target: "client" | "web" | "both"; // which client(s) Launch starts. "both" runs them
                                // against the ONE stack: same API, same database
}

interface TileSyncProgress {
  tiles: number;                // tiles written so far
  bytes: number;
  page_tiles: number;           // tiles in the page being written
  page_done: number;            // of them, written
}

interface RemoveProgress {
  title: string;                // the item being deleted
  done: number;                 // bytes deleted of it
  total: number;
  index: number;                // which of them, 1-based: one Apply can drop several
  count: number;
}

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" | "web"; // "web" only on a dev run
  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.

AI over the internet

The client is started with ODIAN_OFFLINE=1, which blanks the MCP address whatever the environment holds: the AI features (destination prediction, chat, vision) answer "not available offline" rather than hanging on a call that cannot arrive. An ai_url in Settings says this PC has a line out: the flag is left off and that address is passed as ODIAN_MCP_API_URL. Nothing else moves - the data, the tiles and the map stay this PC's own local stack - and emptying the field puts the client back to wholly offline. The AI routes need no token of their own; site.json may carry an ai_url so a site ships with one.

Overlays out of this PC's database

The tile server draws its overlays (aeroways, data centres, gas, hospitals, military bases, oil, places of worship, schools) from PostGIS. A site reads the extractor's SQLite files; a PC has none of those, so the launcher names each layer in with the DSN of the local database, whose snapshot carries the same tables. One key per layer is required: the server looks for that key, then for a DSN under ITS OWN env name, and a layer it cannot resolve draws nothing at all rather than failing loudly.

Live mode

The DHP names where live mode forwards. /v1/health offers engine_api_urls, best first: the address its own network reaches, then the one that answers from outside. The PC tries the one it already uses, then that list in order, and keeps the first that answers - a laptop is at the office in the morning and at home in the evening, and neither address suits both. engine_api_url remains the single address a launcher that ignores the list will use, so a site must make that one the address that works from anywhere. None of them answering is not an error: the local API serves this PC's own data, as it does offline.

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
tile-sync-progress TileSyncProgress while pulling tiles the DHP baked (phase pulling_tiles)
remove-progress RemoveProgress while deleting unticked marketplace items (phase removing)
log LogEvent notable backend messages, for the activity panel