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 |