This commit is contained in:
Kelorastus
2026-09-25 10:14:44 +02:00
parent 4d620fc5ad
commit 21d6e6bffb
34 changed files with 615 additions and 321 deletions
+29 -1
View File
@@ -22,7 +22,7 @@ type Phase =
| "error"; // the last long operation failed; see `error` | "error"; // the last long operation failed; see `error`
interface Status { interface Status {
online: boolean; // the site's update service answered /v1/health 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 }; site: { url: string; name: string | null };
operator: string | null; // last signed-in ODIAN username (cached; valid offline) 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 signed_in: boolean; // this PC remembers the operator's site sign-in until Sign out: the
@@ -64,6 +64,7 @@ interface SyncConflict {
interface Settings { interface Settings {
site_url: string; // e.g. "http://192.168.1.41:30097" site_url: string; // e.g. "http://192.168.1.41:30097"
mcp_url: string; // site MCP, for Keycloak sign-in mcp_url: string; // site MCP, for Keycloak sign-in
ai_url: string; // MCP serving /api/ai/... over the internet; "" = none
install_dir: string; install_dir: string;
} }
@@ -163,8 +164,35 @@ nothing else. Installing from a drive asks the same way: the marketplace opens o
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, 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. 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 ## 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 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 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 always talks to the local API; while the DHP answers, that API forwards the shared-data
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+3 -2
View File
@@ -1,5 +1,6 @@
{ {
"name": "ODIAN test DHP", "name": "ODIAN test DHP",
"site_url": "http://192.168.1.41:30097", "site_url": "https://engine.odian.fr/dhp",
"mcp_url": "http://192.168.1.41:30091" "mcp_url": "https://engine.odian.fr",
"ai_url": "https://engine.odian.fr"
} }
+111 -10
View File
@@ -65,6 +65,13 @@ pub struct Status {
/// While ODIAN runs: true when its data is coming from the DHP, false when from /// While ODIAN runs: true when its data is coming from the DHP, false when from
/// this PC. None when ODIAN is not running, or the DHP offers no Engine API. /// this PC. None when ODIAN is not running, or the DHP offers no Engine API.
pub live: Option<bool>, pub live: Option<bool>,
/// While ODIAN runs: false when the map is being drawn from this PC because the
/// site's tile service did not answer. None when ODIAN is not running, or no site
/// is configured. The operator is told: a map quietly older than the site's, with
/// no overlays this PC never downloaded, otherwise just looks wrong.
pub map_from_site: Option<bool>,
/// Why, in one line, when `map_from_site` is false.
pub map_problem: Option<String>,
} }
/// What the marketplace shows (`get_market`). /// What the marketplace shows (`get_market`).
@@ -123,11 +130,16 @@ struct Inner {
/// during a blip still knows where live mode will forward. /// during a blip still knows where live mode will forward.
engine_url: Option<String>, engine_url: Option<String>,
live: Option<bool>, live: Option<bool>,
map_from_site: Option<bool>,
map_problem: Option<String>,
/// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`). /// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`).
portable: bool, portable: bool,
/// A newer launcher is being checked for or downloaded (see `update_self`): nothing may /// A newer launcher is being checked for or downloaded (see `update_self`): nothing may
/// launch or install meanwhile, since installing it closes this launcher. /// launch or install meanwhile, since installing it closes this launcher.
updating_self: bool, updating_self: bool,
/// The last reason the DHP was not reachable, so the log carries one line per
/// distinct fault instead of the same one at every check.
offline_reason: Option<String>,
/// What the marketplace picked for the copy about to run from a drive. It filters that one /// What the marketplace picked for the copy about to run from a drive. It filters that one
/// copy and is never written down: a tile set left out because this PC already has it must /// copy and is never written down: a tile set left out because this PC already has it must
/// not read as "the operator dropped it", or the next Apply in the marketplace would delete /// not read as "the operator dropped it", or the next Apply in the marketplace would delete
@@ -161,6 +173,8 @@ impl Inner {
portable: self.portable, portable: self.portable,
sync: self.sync.clone(), sync: self.sync.clone(),
live: self.live, live: self.live,
map_from_site: self.map_from_site,
map_problem: self.map_problem.clone(),
} }
} }
} }
@@ -282,7 +296,7 @@ impl Launcher {
.map_err(|e| format!("cannot start the HTTP client: {e}"))?; .map_err(|e| format!("cannot start the HTTP client: {e}"))?;
Ok(Launcher { Ok(Launcher {
http, http,
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, drive_extras: None }), inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, map_from_site: None, map_problem: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }),
stack: Mutex::new(None), stack: Mutex::new(None),
stack_states: std::sync::Mutex::new(Vec::new()), stack_states: std::sync::Mutex::new(Vec::new()),
sign_in_cancel: Notify::new(), sign_in_cancel: Notify::new(),
@@ -385,6 +399,38 @@ impl Launcher {
/// Re-probe the site and re-check the stored sign-in, renewing it quietly when the site /// Re-probe the site and re-check the stored sign-in, renewing it quietly when the site
/// no longer takes it. Never fails: an unreachable site is "offline". Also how the UI /// no longer takes it. Never fails: an unreachable site is "offline". Also how the UI
/// dismisses an error. /// dismisses an error.
/// Which of the DHP's Engine API addresses this PC can reach. The one already in use is
/// tried first (an address that still answers is not swapped under a running ODIAN), then
/// the list in order. None means none answered: the caller keeps what it had, and the
/// local API serves this PC's own data until one comes back.
///
/// Probed outside the lock: `engine_answers` waits on the network, and holding the state
/// across it would stall every other task for as long as the far end takes.
async fn pick_engine(&self, app: &AppHandle, probe: &site::Probe) -> Option<String> {
if probe.engine_urls.is_empty() {
return None;
}
let kept = self.inner.lock().await.engine_url.clone();
if let Some(url) = kept.clone().filter(|url| probe.engine_urls.contains(url)) {
if site::engine_answers(&self.http, &url).await {
return Some(url);
}
}
let found = site::first_engine(&self.http, &probe.engine_urls).await;
match (&found, &kept) {
(Some(url), kept) if kept.as_ref() != Some(url) => {
self.log(app, "info", format!("Live data comes from {url}."));
}
(None, Some(_)) => self.log(
app,
"warn",
"None of the DHP's Engine API addresses answered: ODIAN works from this PC's own data.",
),
_ => {}
}
found
}
pub async fn refresh(&self, app: &AppHandle) -> Status { pub async fn refresh(&self, app: &AppHandle) -> Status {
let (site_url, token) = { let (site_url, token) = {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
@@ -397,13 +443,25 @@ impl Launcher {
self.emit(app).await; self.emit(app).await;
let probe = site::probe(&self.http, &site_url).await; let probe = site::probe(&self.http, &site_url).await;
let engine = self.pick_engine(app, &probe).await;
// Say why, once per fault. "Offline" alone sends an operator hunting the network
// when it is a certificate, a proxy or a name that does not resolve.
let announce = {
let mut inner = self.inner.lock().await;
let fresh = probe.problem.clone().filter(|why| inner.offline_reason.as_ref() != Some(why));
inner.offline_reason = probe.problem.clone();
fresh
};
if let Some(why) = announce {
self.log(app, "warn", format!("Your DHP did not answer. {why}"));
}
{ {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
inner.online = probe.online; inner.online = probe.online;
inner.available = probe.version; inner.available = probe.version;
// Keep the last known address while offline: it is where live mode resumes. // Keep the last known address while offline: it is where live mode resumes.
if probe.engine_url.is_some() { if engine.is_some() {
inner.engine_url = probe.engine_url; inner.engine_url = engine;
} }
} }
// Offline nothing is checked or forgotten: the remembered operator still launches. // Offline nothing is checked or forgotten: the remembered operator still launches.
@@ -509,6 +567,8 @@ impl Launcher {
let mut next = Settings { let mut next = Settings {
site_url: normalize_url(&incoming.site_url), site_url: normalize_url(&incoming.site_url),
mcp_url: normalize_url(&incoming.mcp_url), mcp_url: normalize_url(&incoming.mcp_url),
// Blank on purpose is a normal answer here: no AI, and the client stays offline.
ai_url: normalize_url(&incoming.ai_url),
install_dir: incoming.install_dir.trim().to_string(), install_dir: incoming.install_dir.trim().to_string(),
site_name: incoming.site_name.or_else(|| inner.settings.site_name.clone()), site_name: incoming.site_name.or_else(|| inner.settings.site_name.clone()),
}; };
@@ -782,18 +842,29 @@ impl Launcher {
} }
} }
// An ODIAN drive is carried between PCs: its install must not belong to this PC's account. // An ODIAN drive is carried between PCs: its install must not belong to this PC's account.
// An install onto the PC itself is that PC's own, and keeps the permissions it has. // An install onto the PC itself is that PC's own, and keeps the permissions it has, and a
// folder that already holds one is left alone (see `grant_still_needed`).
let portable = self.inner.lock().await.portable; let portable = self.inner.lock().await.portable;
if portable && crate::settings::installs_on_the_drive(install_dir) { if portable
&& crate::settings::installs_on_the_drive(install_dir)
&& crate::settings::grant_still_needed(install_dir)
{
let _ = std::fs::create_dir_all(install_dir); let _ = std::fs::create_dir_all(install_dir);
if let Err(e) = crate::settings::share_with_every_account(install_dir) { if let Err(e) = crate::settings::share_with_every_account(install_dir) {
self.log(app, "warn", format!("This drive's install may only be usable by you ({e}).")); self.log(app, "warn", format!("This drive's install may only be usable by you ({e})."));
} }
} }
// The marketplace items the operator chose: for a copy, the popup's answer, used once. // The marketplace items the operator chose: for a copy, the popup's answer, used once.
let extras = match self.inner.lock().await.drive_extras.take() { //
Some(chosen) if !matches!(source, Source::Site { .. }) => chosen, // The lock is taken and dropped on its own line. Held across the `match`, as a temporary
_ => self.chosen_extras(install_dir, manifest).await?, // in the scrutinee is, the other arm's `chosen_extras` waits for a lock this very task
// owns: the install parks for good, right between the manifest and the first file, with
// the DHP still being told the slot is in use.
let from_drive = !matches!(source, Source::Site { .. });
let picked = if from_drive { self.inner.lock().await.drive_extras.take() } else { None };
let extras = match picked {
Some(chosen) => chosen,
None => self.chosen_extras(install_dir, manifest).await?,
}; };
// A USB key cannot carry this: Postgres restores 16 GB into it, and unplugging the key // A USB key cannot carry this: Postgres restores 16 GB into it, and unplugging the key
// would take the install with it. The default folder already avoids removable drives. // would take the install with it. The default folder already avoids removable drives.
@@ -1360,6 +1431,8 @@ impl Launcher {
} }
let layout = Layout::new(&install_dir, &version); let layout = Layout::new(&install_dir, &version);
let tile_sets = stack::installed_tile_sets(&layout)?; let tile_sets = stack::installed_tile_sets(&layout)?;
// What the DHP can draw for this PC, beyond what it downloaded.
let site_tile_sets = stack::version_tile_sets(&layout)?;
inner.phase = Phase::Launching; inner.phase = Phase::Launching;
inner.error = None; inner.error = None;
StackConfig { StackConfig {
@@ -1369,7 +1442,10 @@ impl Launcher {
pg_password: inner.state.pg_password.clone(), pg_password: inner.state.pg_password.clone(),
install_id: inner.state.install_id.clone(), install_id: inner.state.install_id.clone(),
tile_sets, tile_sets,
site_tile_sets,
upstream_url: inner.engine_url.clone(), upstream_url: inner.engine_url.clone(),
ai_url: inner.settings.ai_url.clone(),
site_url: inner.settings.site_url.clone(),
} }
}; };
if let Ok(mut states) = self.stack_states.lock() { if let Ok(mut states) = self.stack_states.lock() {
@@ -1650,6 +1726,29 @@ impl Launcher {
self.inner.lock().await.live = live; self.inner.lock().await.live = live;
} }
/// Which side is drawing the map, from the tile server's own account of it. Its
/// `/health` is answered locally on purpose - asking the site whether the site is
/// reachable would only ever say yes.
async fn read_map_state(&self) {
let url = format!("http://127.0.0.1:{}/health", stack::TILES_PORT);
let (from_site, problem) = match self.http.get(url).timeout(Duration::from_secs(3)).send().await {
Ok(response) => match response.json::<serde_json::Value>().await {
Ok(body) => {
let site = body.get("site").cloned().unwrap_or_default();
let configured = site.get("url").and_then(|u| u.as_str()).is_some_and(|u| !u.is_empty());
let reachable = site.get("reachable").and_then(|r| r.as_bool());
let why = site.get("last_error").and_then(|e| e.as_str()).filter(|e| !e.is_empty()).map(str::to_string);
if configured { (reachable, why) } else { (None, None) }
}
Err(_) => (None, None),
},
Err(_) => (None, None),
};
let mut inner = self.inner.lock().await;
inner.map_from_site = from_site;
inner.map_problem = problem;
}
/// While ODIAN runs against a DHP: keep its token fresh, notice the connection coming /// While ODIAN runs against a DHP: keep its token fresh, notice the connection coming
/// and going, send this PC's offline work the moment it returns, and keep this PC's /// and going, send this PC's offline work the moment it returns, and keep this PC's
/// own copy warm so an abrupt drop lands on fresh data rather than an old snapshot. /// own copy warm so an abrupt drop lands on fresh data rather than an old snapshot.
@@ -1674,11 +1773,12 @@ impl Launcher {
_ = tokio::time::sleep(TICK) => {} _ = tokio::time::sleep(TICK) => {}
} }
let probe = site::probe(&self.http, &site_url).await; let probe = site::probe(&self.http, &site_url).await;
let engine = self.pick_engine(app, &probe).await;
{ {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
inner.online = probe.online; inner.online = probe.online;
if probe.engine_url.is_some() { if engine.is_some() {
inner.engine_url = probe.engine_url.clone(); inner.engine_url = engine;
} }
} }
if !probe.online { if !probe.online {
@@ -1687,6 +1787,7 @@ impl Launcher {
} }
was_online = false; was_online = false;
self.read_live_state().await; self.read_live_state().await;
self.read_map_state().await;
self.emit(app).await; self.emit(app).await;
continue; continue;
} }
+28
View File
@@ -19,6 +19,12 @@ pub struct Settings {
/// The site's MCP, which runs the Keycloak sign-in. /// The site's MCP, which runs the Keycloak sign-in.
#[serde(default)] #[serde(default)]
pub mcp_url: String, pub mcp_url: String,
/// Where ODIAN's AI features live (destination prediction, chat, vision): the MCP that
/// serves `/api/ai/...`, reached over the internet. Empty keeps ODIAN wholly offline, which
/// is what an install with no line out wants; set, it is the one address the client is
/// allowed to reach, and the AI features stop saying "not available offline".
#[serde(default)]
pub ai_url: String,
#[serde(default)] #[serde(default)]
pub install_dir: String, pub install_dir: String,
/// Display name from site.json; the UI's settings form does not edit it. /// Display name from site.json; the UI's settings form does not edit it.
@@ -34,6 +40,8 @@ struct SiteFile {
site_url: String, site_url: String,
#[serde(default)] #[serde(default)]
mcp_url: String, mcp_url: String,
#[serde(default)]
ai_url: String,
/// This launcher ships on an ODIAN drive: see `portable_root`. /// This launcher ships on an ODIAN drive: see `portable_root`.
#[serde(default)] #[serde(default)]
portable: bool, portable: bool,
@@ -190,6 +198,15 @@ fn folder_problem_on(folder: &Path, drive: &Path) -> Option<String> {
} }
} }
/// Whether a drive's install folder still needs the grant below: only while nothing has
/// been installed into it. Handing it out on a folder that already holds an install makes
/// Windows walk every file under it to push the inheritable entry down - on a drive
/// carrying a million map tiles that grinds for hours, with the install waiting on it.
/// Everything written into the folder afterwards inherits the grant on its own.
pub fn grant_still_needed(install_dir: &Path) -> bool {
!install_dir.join("versions").exists()
}
/// Is this folder on the drive the launcher runs from? Only then does the install travel, /// Is this folder on the drive the launcher runs from? Only then does the install travel,
/// and only then is it worth opening to every account of every PC the drive is plugged into. /// and only then is it worth opening to every account of every PC the drive is plugged into.
pub fn installs_on_the_drive(folder: &Path) -> bool { pub fn installs_on_the_drive(folder: &Path) -> bool {
@@ -252,6 +269,7 @@ impl Settings {
let settings = Settings { let settings = Settings {
site_url: normalize_url(&site.site_url), site_url: normalize_url(&site.site_url),
mcp_url: normalize_url(&site.mcp_url), mcp_url: normalize_url(&site.mcp_url),
ai_url: normalize_url(&site.ai_url),
install_dir: default_install_dir(), install_dir: default_install_dir(),
site_name: site.name, site_name: site.name,
}; };
@@ -296,6 +314,16 @@ impl Settings {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn the_account_grant_is_for_an_empty_folder_only() {
let dir = std::env::temp_dir().join(format!("odian-grant-{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
assert!(grant_still_needed(&dir), "nothing installed yet: hand it out");
std::fs::create_dir_all(dir.join("versions").join("0.1.0")).unwrap();
assert!(!grant_still_needed(&dir), "an install is there: walking it would take hours");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test] #[test]
fn a_drives_own_launcher_installs_on_that_drive_whatever_its_letter() { fn a_drives_own_launcher_installs_on_that_drive_whatever_its_letter() {
for (exe, want) in [ for (exe, want) in [
+120 -7
View File
@@ -4,32 +4,116 @@ use std::time::Duration;
use reqwest::StatusCode; use reqwest::StatusCode;
/// How long a launch waits for the site before treating the PC as offline. /// How long a launch waits for the site before treating the PC as offline. Short on
/// purpose: a PC with no DHP at all must not sit through a stall at every start.
const PROBE_TIMEOUT: Duration = Duration::from_secs(2); const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// The second chance, for a link that answers slowly rather than not at all. Over a
/// phone's hotspot the first HTTPS round trip to a DHP - name, connection, handshake,
/// answer - runs past two seconds often enough that the DHP read as offline while a
/// browser on the same PC reached it fine. Only a TIMEOUT is retried: a refused
/// connection or a name that does not resolve is what an absent DHP looks like, and
/// waiting ten seconds on those at every start is exactly what the short budget avoids.
const SLOW_LINK_TIMEOUT: Duration = Duration::from_secs(10);
pub struct Probe { pub struct Probe {
pub online: bool, pub online: bool,
/// Why the DHP is not online, in one line. Without it "offline" is all the operator
/// gets, and a URL that a browser on the same PC opens fine leaves nothing to go on.
pub problem: Option<String>,
/// The site's CURRENT version. /// The site's CURRENT version.
pub version: Option<String>, pub version: Option<String>,
/// The DHP's Engine API, as this PC can reach it: where live mode forwards while /// The DHP's Engine API, as this PC can reach it: where live mode forwards while
/// online. Absent when the DHP offers none, and this PC then serves its own data. /// online. Absent when the DHP offers none, and this PC then serves its own data.
pub engine_url: Option<String>, pub engine_url: Option<String>,
/// Every address the DHP offers for it, best first: the site's own network, then the
/// one that answers from outside. A PC cannot be told which applies to it - a laptop
/// is at the office in the morning and at home in the evening - so it tries them in
/// turn. Older DHPs name one address and this holds just that one.
pub engine_urls: Vec<String>,
}
/// How long one Engine API gets to answer before the next address is tried.
const ENGINE_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
/// Does this Engine API answer? `/health` is its open route: no token, no data.
pub async fn engine_answers(http: &reqwest::Client, engine_url: &str) -> bool {
let url = format!("{}/health", engine_url.trim_end_matches('/'));
matches!(http.get(url).timeout(ENGINE_PROBE_TIMEOUT).send().await, Ok(answer) if answer.status().is_success())
}
/// The first of `urls` that answers, or None when this PC can reach none of them - which
/// is not an error: the local API then serves this PC's own data, as it does offline.
pub async fn first_engine(http: &reqwest::Client, urls: &[String]) -> Option<String> {
for url in urls {
if engine_answers(http, url).await {
return Some(url.clone());
}
}
None
}
fn unreachable(url: &str, what: impl std::fmt::Display) -> Probe {
Probe { online: false, problem: Some(format!("{url}: {what}")), version: None, engine_url: None, engine_urls: Vec::new() }
}
/// The reason, short enough for one log line: reqwest nests its causes, and the one
/// that names the fault (a refused connection, an expired certificate, a bad name) is
/// the innermost.
fn cause(error: &reqwest::Error) -> String {
let mut source: &dyn std::error::Error = error;
let mut text = error.to_string();
while let Some(inner) = source.source() {
text = inner.to_string();
source = inner;
}
text
} }
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe { pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
let offline = Probe { online: false, version: None, engine_url: None };
if site_url.is_empty() { if site_url.is_empty() {
return offline; return Probe {
online: false,
problem: Some("no DHP address is set".to_string()),
version: None,
engine_url: None,
engine_urls: Vec::new(),
};
} }
let Ok(response) = http.get(format!("{site_url}/v1/health")).timeout(PROBE_TIMEOUT).send().await else { let url = format!("{site_url}/v1/health");
return offline; let response = match http.get(&url).timeout(PROBE_TIMEOUT).send().await {
Ok(response) => response,
Err(quick) if quick.is_timeout() => match http.get(&url).timeout(SLOW_LINK_TIMEOUT).send().await {
Ok(response) => response,
Err(slow) if slow.is_timeout() => {
return unreachable(&url, format!("no answer within {}s", SLOW_LINK_TIMEOUT.as_secs()))
}
Err(slow) => return unreachable(&url, cause(&slow)),
},
Err(quick) => return unreachable(&url, cause(&quick)),
}; };
if !response.status().is_success() { if !response.status().is_success() {
return offline; return unreachable(&url, format!("answered {}", response.status()));
} }
let body: serde_json::Value = response.json().await.unwrap_or_default(); let body: serde_json::Value = response.json().await.unwrap_or_default();
let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string); let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string);
Probe { online: true, version: text("version"), engine_url: text("engine_api_url") } let engine_url = text("engine_api_url");
let engine_urls = engine_urls_from(&body, engine_url.as_deref());
Probe { online: true, problem: None, version: text("version"), engine_url, engine_urls }
}
/// The addresses a health answer offers for the Engine API, in the order to try them.
/// A DHP from before the list names one, and it is the whole list then.
fn engine_urls_from(body: &serde_json::Value, single: Option<&str>) -> Vec<String> {
let listed: Vec<String> = body
.get("engine_api_urls")
.and_then(|v| v.as_array())
.map(|urls| urls.iter().filter_map(|u| u.as_str()).map(str::trim).filter(|u| !u.is_empty()).map(str::to_string).collect())
.unwrap_or_default();
if listed.is_empty() {
return single.filter(|u| !u.is_empty()).map(str::to_string).into_iter().collect();
}
listed
} }
pub enum TokenCheck { pub enum TokenCheck {
@@ -180,3 +264,32 @@ pub async fn notes(http: &reqwest::Client, site_url: &str, token: &str, version:
s => Err(format!("the DHP answered HTTP {s}")), s => Err(format!("the DHP answered HTTP {s}")),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_addresses_are_taken_in_the_order_the_dhp_gives_them() {
let body = serde_json::json!({
"ok": true,
"engine_api_urls": ["http://192.168.1.41:30684", "https://engine-api.odian.fr"],
"engine_api_url": "https://engine-api.odian.fr"
});
assert_eq!(
engine_urls_from(&body, Some("https://engine-api.odian.fr")),
vec!["http://192.168.1.41:30684".to_string(), "https://engine-api.odian.fr".to_string()],
"the site's own network first, so an office PC does not go out and back"
);
}
#[test]
fn a_dhp_that_names_one_address_still_works() {
// Every DHP in the field before the list: one address, and it is the whole list.
let body = serde_json::json!({"ok": true, "engine_api_url": "http://192.168.1.41:30684"});
assert_eq!(engine_urls_from(&body, Some("http://192.168.1.41:30684")), vec!["http://192.168.1.41:30684".to_string()]);
// And a DHP offering none leaves the PC on its own data.
assert!(engine_urls_from(&serde_json::json!({"ok": true}), None).is_empty());
assert!(engine_urls_from(&serde_json::json!({"engine_api_urls": ["", " "]}), None).is_empty());
}
}
+183 -16
View File
@@ -151,6 +151,32 @@ pub struct StackConfig {
/// The DHP's Engine API (from its health answer). With one, the local API serves /// The DHP's Engine API (from its health answer). With one, the local API serves
/// the DHP's data while it is reachable; without, this PC always serves its own. /// the DHP's data while it is reachable; without, this PC always serves its own.
pub upstream_url: Option<String>, pub upstream_url: Option<String>,
/// The MCP serving ODIAN's AI features, when the operator named one (Settings).
pub ai_url: String,
/// The DHP this PC belongs to, for the tile service it proxies. Empty when the
/// operator named none, and the tile server then serves this PC alone.
pub site_url: String,
/// Every set the version names. With a site, the client is offered all of them:
/// the site draws the ones this PC never downloaded. Without one it sees only
/// `tile_sets`, because nothing else could be drawn.
pub site_tile_sets: Vec<TileSet>,
}
impl StackConfig {
/// The sets to offer the client: this PC's own always - they draw with no DHP at
/// all - plus the version's rest when a site can draw those. Deduplicated by
/// style and dataset, this PC's entry first so its own extension wins.
fn offered(&self) -> Vec<TileSet> {
let mut out = self.tile_sets.clone();
if !self.site_url.is_empty() {
for set in &self.site_tile_sets {
if !out.iter().any(|have| have.style == set.style && have.dataset == set.dataset) {
out.push(set.clone());
}
}
}
out
}
} }
/// Where the launcher keeps the DHP token the local API forwards with. A file, not an /// Where the launcher keeps the DHP token the local API forwards with. A file, not an
@@ -442,11 +468,25 @@ fn pg_ini(config: &StackConfig) -> String {
/// How the tile server reaches this PC's database (a libpq connection string). /// How the tile server reaches this PC's database (a libpq connection string).
const TILES_POSTGIS_DSN_ENV: &str = "ODIAN_TILE_POSTGIS_DSN"; const TILES_POSTGIS_DSN_ENV: &str = "ODIAN_TILE_POSTGIS_DSN";
/// The Theatre's POI layers -- hospitals, schools, places of worship, data centres. A site /// Every overlay the tile server draws out of PostGIS. A site serves them from the
/// serves them from the extractor's SQLite files; this PC ships none of those, but holds the /// extractor's SQLite files; this PC ships none of those, but the database snapshot carries
/// same tables in its own database, and the tile server reads them there instead. /// the same tables, and the tile server reads them there instead.
const NEARBY_POSTGIS_SOURCES: [&str; 4] = ///
["hospital_postgis", "school_postgis", "place_of_worship_postgis", "datacenter_postgis"]; /// One key per layer, because that is how the server resolves a source: no key and no DSN
/// under ITS name (it looks for `ODIAN_OIL_POSTGIS_DSN` and the like, never the one name the
/// launcher sets) leaves the layer with nowhere to read, and it draws nothing at all. That is
/// what kept oil, gas, aeroways and military bases off an offline map while their tables sat
/// in the database unused - only the four Theatre layers below were ever named here.
const POSTGIS_SOURCES: [&str; 8] = [
"aeroway_postgis",
"datacenter_postgis",
"gas_postgis",
"hospital_postgis",
"military_base_postgis",
"oil_postgis",
"place_of_worship_postgis",
"school_postgis",
];
fn pg_dsn(config: &StackConfig) -> String { fn pg_dsn(config: &StackConfig) -> String {
let quote = |value: &str| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")); let quote = |value: &str| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"));
@@ -472,7 +512,11 @@ fn media_type(ext: &str) -> &'static str {
fn datasets(config: &StackConfig) -> serde_json::Value { fn datasets(config: &StackConfig) -> serde_json::Value {
let tiles = config.layout.data_dir().join("tiles"); let tiles = config.layout.data_dir().join("tiles");
let mut doc = serde_json::Map::new(); let mut doc = serde_json::Map::new();
for set in &config.tile_sets { // Every offered style needs its entry, or the client asks for the wrong file name
// (imagery is .jpg, a drawn basemap .png) and every tile of it is a 404 - including
// the ones the site would have answered. What this PC can bake is decided below, by
// the archives actually on disk, not by what is offered.
for set in &config.offered() {
let entry = doc.entry(set.dataset.clone()).or_insert_with(|| { let entry = doc.entry(set.dataset.clone()).or_insert_with(|| {
let mut entry = serde_json::json!({ "cache_dir": text(tiles.clone()) }); let mut entry = serde_json::json!({ "cache_dir": text(tiles.clone()) });
match pmtiles_of(config, &set.dataset) { match pmtiles_of(config, &set.dataset) {
@@ -481,7 +525,7 @@ fn datasets(config: &StackConfig) -> serde_json::Value {
// Without the flag a baking server refuses every request for it, hits included. // Without the flag a baking server refuses every request for it, hits included.
None => entry["cache_only"] = serde_json::Value::Bool(true), None => entry["cache_only"] = serde_json::Value::Bool(true),
} }
for key in NEARBY_POSTGIS_SOURCES { for key in POSTGIS_SOURCES {
entry[key] = serde_json::json!({ "dsn_env": TILES_POSTGIS_DSN_ENV }); entry[key] = serde_json::json!({ "dsn_env": TILES_POSTGIS_DSN_ENV });
} }
entry entry
@@ -567,13 +611,20 @@ fn baker(config: &StackConfig) -> Option<PathBuf> {
(any_archive && exe.is_file()).then_some(exe) (any_archive && exe.is_file()).then_some(exe)
} }
/// The site's tile service, behind the DHP: `/tileserver` of the DHP's own address.
/// The Data PC that runs it is not on the internet, so this is the only way to it,
/// and the DHP's token check covers it like every other route.
fn site_tiles_url(site_url: &str) -> String {
format!("{}/tileserver", site_url.trim_end_matches('/'))
}
fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> { fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
let tiles = config.layout.data_dir().join("tiles"); let tiles = config.layout.data_dir().join("tiles");
// With a baker the server draws any basemap tile the cache lacks -- the Theatre asks // With a baker the server draws any basemap tile the cache lacks -- the Theatre asks
// for far more than was ever cached. Everything it cannot draw stays cache-only per // for far more than was ever cached. Everything it cannot draw stays cache-only per
// dataset or style (see `datasets`), and an offline PC never reaches a provider. // dataset or style (see `datasets`), and an offline PC never reaches a provider.
let baker = baker(config); let baker = baker(config);
vec![ let mut env = vec![
("ODIAN_TILE_DATASETS", text(config.layout.state_dir().join("tiles").join("datasets.json"))), ("ODIAN_TILE_DATASETS", text(config.layout.state_dir().join("tiles").join("datasets.json"))),
("ODIAN_TILE_CACHE_ONLY", if baker.is_some() { "0" } else { "1" }.to_string()), ("ODIAN_TILE_CACHE_ONLY", if baker.is_some() { "0" } else { "1" }.to_string()),
("ODIAN_TILE_NO_UPSTREAM", "1".to_string()), ("ODIAN_TILE_NO_UPSTREAM", "1".to_string()),
@@ -585,7 +636,17 @@ fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
("PYTHONDONTWRITEBYTECODE", "1".to_string()), ("PYTHONDONTWRITEBYTECODE", "1".to_string()),
("PYTHONNOUSERSITE", "1".to_string()), ("PYTHONNOUSERSITE", "1".to_string()),
("PYTHONUTF8", "1".to_string()), ("PYTHONUTF8", "1".to_string()),
] ];
// Where the map comes from while the DHP answers: the site draws from the whole
// archive, so a PC that installed none of the optional tiles or sources still has
// the full map, the overlays and the 3-D buildings. The moment the site cannot
// answer, the tile server serves what this PC holds - the same token file the
// local API forwards with, re-read whenever the launcher renews it.
if !config.site_url.is_empty() {
env.push(("ODIAN_TILE_SITE_URL", site_tiles_url(&config.site_url)));
env.push(("ODIAN_TILE_SITE_TOKEN_FILE", text(upstream_token_file(&config.layout))));
}
env
} }
fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> { fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
@@ -595,7 +656,6 @@ fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
("ODIAN_ENGINE_API_URL", format!("http://127.0.0.1:{API_PORT}")), ("ODIAN_ENGINE_API_URL", format!("http://127.0.0.1:{API_PORT}")),
("ODIAN_LAUNCH_USER", config.operator.clone()), ("ODIAN_LAUNCH_USER", config.operator.clone()),
("ODIAN_MINT_SECRET", config.mint_secret.clone()), ("ODIAN_MINT_SECRET", config.mint_secret.clone()),
("ODIAN_OFFLINE", "1".to_string()),
("ODIAN_TILE_SERVER_URL", tiles_url.clone()), ("ODIAN_TILE_SERVER_URL", tiles_url.clone()),
("ODIAN_TILE_PROVIDER", "odian".to_string()), ("ODIAN_TILE_PROVIDER", "odian".to_string()),
("ODIAN_TILE_REQUEST_MODE", "xyz".to_string()), ("ODIAN_TILE_REQUEST_MODE", "xyz".to_string()),
@@ -606,6 +666,16 @@ fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
("ODIAN_LIDAR_ENABLED", "0".to_string()), ("ODIAN_LIDAR_ENABLED", "0".to_string()),
("ODIAN_HEIGHT_CACHE_DIR", text(client.join("heightmap"))), ("ODIAN_HEIGHT_CACHE_DIR", text(client.join("heightmap"))),
]; ];
// ODIAN_OFFLINE is what tells the client it has no line out: it blanks the MCP address
// whatever the environment holds, so the AI features answer "not available offline" instead
// of hanging on a call that cannot arrive. An operator who names an AI address has a line
// out and wants those features, so the flag goes and that address is passed. Everything
// else still points at this PC: the API, the tiles and the map all stay local.
if config.ai_url.is_empty() {
env.push(("ODIAN_OFFLINE", "1".to_string()));
} else {
env.push(("ODIAN_MCP_API_URL", config.ai_url.clone()));
}
let basemap = config let basemap = config
.tile_sets .tile_sets
.iter() .iter()
@@ -618,11 +688,15 @@ fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
// Every style this install carries, so the client's basemap picker offers exactly // Every style this install carries, so the client's basemap picker offers exactly
// what this PC can draw offline. Without it the client falls back to a fixed list // what this PC can draw offline. Without it the client falls back to a fixed list
// of three, which is what hid the satellite and orthophoto maps 0.54.0 ships. // of three, which is what hid the satellite and orthophoto maps 0.54.0 ships.
let styles: Vec<&str> = config.tile_sets.iter().map(|set| set.style.as_str()).collect(); let offered = config.offered();
let styles: Vec<&str> = offered.iter().map(|set| set.style.as_str()).collect();
if !styles.is_empty() { if !styles.is_empty() {
env.push(("ODIAN_TILE_STYLES", styles.join(","))); env.push(("ODIAN_TILE_STYLES", styles.join(",")));
} }
match config.tile_sets.iter().find(|set| set.style == "heightmap") { // Heights follow the same rule as the styles above: a PC that never downloaded the
// terrain set still gets elevation while its site can draw it, because the height
// server it is pointed at is this PC's own tile server, which asks the site first.
match offered.iter().find(|set| set.style == "heightmap") {
Some(set) => { Some(set) => {
env.push(("ODIAN_HEIGHT_SERVER_URL", tiles_url)); env.push(("ODIAN_HEIGHT_SERVER_URL", tiles_url));
env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string())); env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string()));
@@ -656,7 +730,10 @@ pub fn installed_db(layout: &Layout) -> Option<odian_packs::manifest::DbSection>
/// The tile sets the installed version's manifest names. /// The tile sets the installed version's manifest names.
/// The version's tile sets this PC has (a marketplace set the operator did not choose has no /// The version's tile sets this PC has (a marketplace set the operator did not choose has no
/// folder): the client is offered, and the tile server serves, only those. /// folder): the client is offered, and the tile server serves, only those.
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> { /// Every tile set the installed version names, whether or not this PC downloaded it.
/// With a DHP these are all drawable - the site has them - so they are what the client
/// is offered; `installed_tile_sets` is the subset this PC can draw on its own.
pub fn version_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
let path = layout.version_dir().join("manifest.json"); let path = layout.version_dir().join("manifest.json");
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?; let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
let manifest: serde_json::Value = let manifest: serde_json::Value =
@@ -673,12 +750,22 @@ pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(), ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(),
}) })
}) })
.filter(|set| layout.data_dir().join("tiles").join(&set.style).join(&set.dataset).is_dir())
.collect() .collect()
}) })
.unwrap_or_default()) .unwrap_or_default())
} }
/// The sets whose tiles are on this PC: what it can draw with no DHP at all.
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
let here = layout.data_dir().join("tiles");
Ok(
version_tile_sets(layout)?
.into_iter()
.filter(|set| here.join(&set.style).join(&set.dataset).is_dir())
.collect(),
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
@@ -703,6 +790,12 @@ mod tests {
.map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() }) .map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() })
.collect(), .collect(),
upstream_url: None, upstream_url: None,
ai_url: String::new(),
site_url: "https://dhp.example/dhp".to_string(),
site_tile_sets: tile_sets
.iter()
.map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() })
.collect(),
} }
} }
@@ -745,6 +838,20 @@ mod tests {
assert!(value(&env, "ODIAN_MCP_API_URL").is_none(), "an offline client must not know the MCP"); assert!(value(&env, "ODIAN_MCP_API_URL").is_none(), "an offline client must not know the MCP");
} }
#[test]
fn an_ai_address_is_the_one_thing_the_client_may_reach() {
let mut config = config(&[("dark", "protomaps_20260423")]);
config.ai_url = "https://engine.odian.fr".to_string();
let env = client_env(&config);
assert_eq!(value(&env, "ODIAN_MCP_API_URL"), Some("https://engine.odian.fr"));
// The client blanks the MCP address while this flag is set, so the AI would stay dark.
assert!(value(&env, "ODIAN_OFFLINE").is_none(), "the client is told it has a line out");
// And nothing else changes: its data, its tiles and its map are still this PC's.
assert_eq!(value(&env, "ODIAN_ENGINE_API_URL"), Some("http://127.0.0.1:18080"));
assert_eq!(value(&env, "ODIAN_TILE_SERVER_URL"), Some("http://127.0.0.1:18081"));
assert_eq!(value(&env, "ODIAN_LIDAR_ENABLED"), Some("0"));
}
#[test] #[test]
fn the_client_is_told_every_style_this_install_carries() { fn the_client_is_told_every_style_this_install_carries() {
// The picker offers what the PC holds. Naming only the default basemap is what // The picker offers what the PC holds. Naming only the default basemap is what
@@ -808,11 +915,71 @@ mod tests {
} }
#[test] #[test]
fn the_theatre_pois_are_read_from_this_pcs_database() { fn a_pc_that_downloaded_little_is_still_offered_the_sites_maps() {
// What this install actually holds: the one set every install gets.
let mut cfg = config(&[]);
cfg.tile_sets =
vec![TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string() }];
// What the version names, and the site can therefore draw.
cfg.site_tile_sets = vec![
TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string() },
TileSet { style: "satellite_hd".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".jpg".to_string() },
TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: ".lerc".to_string() },
];
let env = client_env(&cfg);
assert_eq!(
value(&env, "ODIAN_TILE_STYLES"),
Some("dark,satellite_hd,heightmap"),
"the picker offers what the site can draw, not only what was downloaded"
);
// The default basemap stays one this PC holds, so a start with no DHP still draws.
assert_eq!(value(&env, "ODIAN_TILE_STYLE"), Some("dark"));
// Terrain too: the set is not on this PC, but the site draws it, so the client is
// pointed at this PC's tile server rather than told there is no elevation at all.
assert_eq!(value(&env, "ODIAN_HEIGHT_DATASET"), Some("planet_heightmap"));
assert!(value(&env, "ODIAN_HEIGHT_ENABLED").is_none());
// And each offered style is described, or the client asks for the wrong file name.
let doc = datasets(&cfg);
assert_eq!(doc["protomaps_20260423"]["styles"]["satellite_hd"]["tile_ext"], ".jpg");
assert_eq!(doc["planet_heightmap"]["styles"]["heightmap"]["tile_ext"], ".lerc");
// With no DHP, only what this PC can draw is offered: the rest would be a blank map.
cfg.site_url = String::new();
assert_eq!(value(&client_env(&cfg), "ODIAN_TILE_STYLES"), Some("dark"));
}
#[test]
fn the_tile_server_is_told_where_its_site_is() {
let mut cfg = config(&[("dark", "protomaps_20260423")]);
let env = tiles_env(&cfg);
// The DHP proxies the Data PC's tile service; that path is the only way to it.
assert_eq!(value(&env, "ODIAN_TILE_SITE_URL"), Some("https://dhp.example/dhp/tileserver"));
// The same token the local API forwards with, re-read by the tile server on change.
assert_eq!(
value(&env, "ODIAN_TILE_SITE_TOKEN_FILE"),
Some(text(upstream_token_file(&cfg.layout)).as_str())
);
// No DHP: the tile server serves this PC alone, exactly as before.
cfg.site_url = String::new();
let env = tiles_env(&cfg);
assert!(value(&env, "ODIAN_TILE_SITE_URL").is_none());
assert!(value(&env, "ODIAN_TILE_SITE_TOKEN_FILE").is_none());
}
#[test]
fn every_postgis_overlay_is_read_from_this_pcs_database() {
let mut cfg = config(&[("dark", "protomaps_20260423"), ("heightmap", "planet_heightmap")]); let mut cfg = config(&[("dark", "protomaps_20260423"), ("heightmap", "planet_heightmap")]);
let doc = datasets(&cfg); let doc = datasets(&cfg);
for dataset in ["protomaps_20260423", "planet_heightmap"] { for dataset in ["protomaps_20260423", "planet_heightmap"] {
for key in NEARBY_POSTGIS_SOURCES { for key in POSTGIS_SOURCES {
assert_eq!(doc[dataset][key]["dsn_env"], TILES_POSTGIS_DSN_ENV, "{dataset} {key}");
}
// The ones that were missing, named outright: they are why an offline map had
// hospitals and schools but no oil, gas, aeroways or military bases.
for key in ["oil_postgis", "gas_postgis", "aeroway_postgis", "military_base_postgis"] {
assert_eq!(doc[dataset][key]["dsn_env"], TILES_POSTGIS_DSN_ENV, "{dataset} {key}"); assert_eq!(doc[dataset][key]["dsn_env"], TILES_POSTGIS_DSN_ENV, "{dataset} {key}");
} }
} }
+7 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher", "productName": "ODIAN Launcher",
"version": "0.1.3", "version": "0.1.12",
"identifier": "fr.odian.launcher", "identifier": "fr.odian.launcher",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -41,7 +41,12 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ],
"windows": {
"nsis": {
"installerIcon": "icons/icon.ico"
}
}
}, },
"plugins": { "plugins": {
"updater": { "updater": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

+15
View File
@@ -238,6 +238,17 @@
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p> <p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
{:else if action === "stack"} {:else if action === "stack"}
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} /> <StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
{#if s?.map_from_site === false}
<!-- The map is this PC's own. Said plainly, because it looks like nothing is
wrong: tiles still draw, they are simply the ones this PC downloaded. -->
<div class="notice" role="status">
<Icon name="alert" size={14} />
<p class="selectable">
{site}'s map service is unreachable: drawing from this PC's own tiles.
{#if s?.map_problem}<span class="mono dim"> ({s.map_problem})</span>{/if}
</p>
</div>
{/if}
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}> <button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
<Icon name="stop" size={12} /> <Icon name="stop" size={12} />
{launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"} {launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"}
@@ -509,6 +520,10 @@
color: var(--danger); color: var(--danger);
} }
.error-box p, .error-box p,
.notice .dim {
color: var(--muted);
}
.notice p { .notice p {
flex: 1; flex: 1;
color: var(--ink-2); color: var(--ink-2);
+2 -12
View File
@@ -5,8 +5,9 @@
</script> </script>
<div class="hero"> <div class="hero">
<!-- No wordmark here: the key art behind this panel carries ODIAN's own, and two
of them read as a mistake. -->
<p class="eyebrow tagline">Tactical map console</p> <p class="eyebrow tagline">Tactical map console</p>
<h1 class="wordmark">ODIAN</h1>
{#if status} {#if status}
<p class="meta mono"> <p class="meta mono">
<span>{status.installed ? `v${status.installed}` : "Not installed"}</span> <span>{status.installed ? `v${status.installed}` : "Not installed"}</span>
@@ -40,17 +41,6 @@
background: var(--accent); background: var(--accent);
} }
.wordmark {
margin: 0 calc(var(--wordmark-tracking) * -1) 0 0;
color: var(--ink);
font-family: var(--font-display);
font-size: clamp(52px, 5.4vw, 72px);
font-weight: 600;
line-height: 1;
letter-spacing: var(--wordmark-tracking);
text-shadow: 0 0 48px rgba(91, 140, 255, 0.28);
}
.meta { .meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+43 -258
View File
@@ -1,252 +1,16 @@
<script lang="ts"> <script lang="ts">
// Original key art, generated here: an orthographic wireframe globe with a // The launcher's backdrop: ODIAN's own key art, the picture the product is
// bearing ring, a slow orbit, a few track markers and faint topographic // presented with. It replaces the globe this file used to draw in SVG.
// contours. Static except for one orbiting point (off with reduced motion). //
import { prefersReducedMotion } from "svelte/motion"; // It starts where the news panel ends, so the wordmark is never half-hidden
// behind it and the panel's side of the window stays plain black.
const W = 1280; import keyart from "../assets/keyart.webp";
const H = 760;
const CX = 930;
const CY = 392;
const R = 288;
const TILT = rad(24); // view latitude
const SPIN = rad(-20); // view longitude
function rad(deg: number) {
return (deg * Math.PI) / 180;
}
interface Pt {
x: number;
y: number;
visible: boolean;
}
function project(latDeg: number, lonDeg: number): Pt {
const phi = rad(latDeg);
const lambda = rad(lonDeg) - SPIN;
const cosPhi = Math.cos(phi);
const x = R * cosPhi * Math.sin(lambda);
const y = R * (Math.cos(TILT) * Math.sin(phi) - Math.sin(TILT) * cosPhi * Math.cos(lambda));
const cosC = Math.sin(TILT) * Math.sin(phi) + Math.cos(TILT) * cosPhi * Math.cos(lambda);
return { x: CX + x, y: CY - y, visible: cosC >= 0 };
}
const fmt = (n: number) => n.toFixed(1);
/** Splits a projected polyline into its near-side and far-side runs. */
function trace(points: Pt[]) {
let front = "";
let back = "";
let prev: boolean | null = null;
for (const p of points) {
const seg = `${prev === p.visible ? "L" : "M"}${fmt(p.x)} ${fmt(p.y)}`;
if (p.visible) front += seg;
else back += seg;
prev = p.visible;
}
return { front, back };
}
function range(from: number, to: number, step: number) {
const out: number[] = [];
for (let v = from; v <= to + 1e-9; v += step) out.push(v);
return out;
}
const lines = [
...range(-180, 165, 15).map((lon) => trace(range(-90, 90, 2).map((lat) => project(lat, lon)))),
...range(-75, 75, 15).map((lat) => trace(range(-180, 180, 2).map((lon) => project(lat, lon)))),
];
const gridFront = lines.map((l) => l.front).join("");
const gridBack = lines.map((l) => l.back).join("");
const equator = trace(range(-180, 180, 2).map((lon) => project(0, lon))).front;
// Bearing ring around the globe.
const RING = R + 26;
const ticks = range(0, 355, 5)
.map((deg) => {
const a = rad(deg - 90);
const len = deg % 30 === 0 ? 9 : deg % 10 === 0 ? 5 : 2.5;
const c = Math.cos(a);
const s = Math.sin(a);
return `M${fmt(CX + c * RING)} ${fmt(CY + s * RING)}L${fmt(CX + c * (RING + len))} ${fmt(CY + s * (RING + len))}`;
})
.join("");
// Labels in the upper-left quadrant would sit under the hero wordmark.
const ringLabels = range(0, 330, 30)
.filter((deg) => deg < 285 || deg > 345)
.map((deg) => {
const a = rad(deg - 90);
return { x: CX + Math.cos(a) * (RING + 24), y: CY + Math.sin(a) * (RING + 24), text: String(deg).padStart(3, "0") };
});
// Track markers joined by great-circle legs.
type Vec = [number, number, number];
const toVec = (lat: number, lon: number): Vec => [
Math.cos(rad(lat)) * Math.cos(rad(lon)),
Math.cos(rad(lat)) * Math.sin(rad(lon)),
Math.sin(rad(lat)),
];
function leg(a: [number, number], b: [number, number]) {
const va = toVec(...a);
const vb = toVec(...b);
const omega = Math.acos(Math.min(1, va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2]));
const pts: Pt[] = [];
for (let i = 0; i <= 48; i++) {
const t = i / 48;
const ka = Math.sin((1 - t) * omega) / Math.sin(omega);
const kb = Math.sin(t * omega) / Math.sin(omega);
const v = [0, 1, 2].map((k) => ka * va[k] + kb * vb[k]);
const lat = (Math.asin(v[2]) * 180) / Math.PI;
const lon = (Math.atan2(v[1], v[0]) * 180) / Math.PI;
pts.push(project(lat, lon));
}
return trace(pts).front;
}
const waypoints: { lat: number; lon: number; label: string }[] = [
{ lat: 14, lon: -36, label: "WPT K7" },
{ lat: 33, lon: -12, label: "TRK 1187" },
{ lat: 47, lon: 5, label: "TRK 0412" },
];
const markers = waypoints.map((w) => ({ ...w, ...project(w.lat, w.lon) })).filter((m) => m.visible);
const route =
leg([waypoints[0].lat, waypoints[0].lon], [waypoints[1].lat, waypoints[1].lon]) +
leg([waypoints[1].lat, waypoints[1].lon], [waypoints[2].lat, waypoints[2].lon]);
// Orbit: an inclined ellipse; the far half is hidden behind the globe.
const ORBIT_TILT = -14;
const RX = R * 1.42;
const RY = R * 0.34;
const orbitBack = `M${fmt(CX - RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX + RX)} ${CY}`;
const orbitFront = `M${fmt(CX + RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX - RX)} ${CY}`;
const orbitPath = `${orbitBack}${orbitFront.replace(/^M[^A]+/, "")}`;
const staticSat = { x: CX + RX * Math.cos(rad(62)), y: CY + RY * Math.sin(rad(62)) };
// Topographic contours: nested wobbly rings.
function contours(cx: number, cy: number, count: number, seed: number, sx: number, sy: number) {
return range(0, count - 1, 1).map((i) => {
const pts = range(0, 120, 1).map((k) => {
const th = (k / 120) * Math.PI * 2;
const r =
28 +
i * 26 +
(10 + i * 1.5) * Math.sin(3 * th + seed + i * 0.35) +
(6 + i) * Math.sin(5 * th - seed * 1.7 + i * 0.2) +
4 * Math.sin(2 * th + i * 0.5);
return `${fmt(cx + r * Math.cos(th) * sx)} ${fmt(cy + r * Math.sin(th) * sy)}`;
});
return `M${pts.join("L")}Z`;
});
}
const topo = [...contours(520, 780, 12, 1.3, 1.35, 0.72), ...contours(1270, -10, 8, 4.1, 1.1, 0.9)];
</script> </script>
<div class="keyart" aria-hidden="true"> <div class="keyart" aria-hidden="true">
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice"> <div class="frame">
<defs> <img class="art" src={keyart} alt="" draggable="false" />
<radialGradient id="ka-glow" cx={CX} cy={CY} r={R * 1.9} gradientUnits="userSpaceOnUse"> </div>
<stop offset="0" stop-color="#5b8cff" stop-opacity="0.2" />
<stop offset="0.42" stop-color="#5b8cff" stop-opacity="0.06" />
<stop offset="1" stop-color="#5b8cff" stop-opacity="0" />
</radialGradient>
<radialGradient id="ka-sphere" cx={CX - R * 0.38} cy={CY - R * 0.42} r={R * 1.55} gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#172241" />
<stop offset="0.55" stop-color="#0d1019" />
<stop offset="1" stop-color="#09090b" />
</radialGradient>
<radialGradient id="ka-fade" cx="0.72" cy="0.5" r="0.62">
<stop offset="0" stop-color="#fff" stop-opacity="1" />
<stop offset="1" stop-color="#fff" stop-opacity="0" />
</radialGradient>
<pattern id="ka-dots" width="24" height="24" patternUnits="userSpaceOnUse">
<rect width="1" height="1" fill="#4a4a52" />
</pattern>
<mask id="ka-dots-mask">
<rect width={W} height={H} fill="url(#ka-fade)" />
</mask>
<mask id="ka-behind" maskUnits="userSpaceOnUse" x="-600" y="-600" width={W + 1200} height={H + 1200}>
<rect x="-600" y="-600" width={W + 1200} height={H + 1200} fill="#fff" />
<circle cx={CX} cy={CY} r={R} fill="#000" />
</mask>
<path id="ka-orbit" d={orbitPath} />
</defs>
<rect width={W} height={H} fill="#09090b" />
<rect width={W} height={H} fill="url(#ka-dots)" mask="url(#ka-dots-mask)" />
<g fill="none" stroke="#9a999e" stroke-opacity="0.075">
{#each topo as d, i (i)}
<path {d} />
{/each}
</g>
<circle cx={CX} cy={CY} r={R * 1.9} fill="url(#ka-glow)" />
<g stroke="#9a999e" fill="none">
<circle cx={CX} cy={CY} r={RING} stroke-opacity="0.12" />
<path d={ticks} stroke-opacity="0.22" />
</g>
<g class="ring-labels" fill="#9a999e" fill-opacity="0.34">
{#each ringLabels as l (l.text)}
<text x={fmt(l.x)} y={fmt(l.y)} text-anchor="middle" dominant-baseline="central">{l.text}</text>
{/each}
</g>
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
<path d={orbitBack} stroke-opacity="0.22" stroke-dasharray="2 6" mask="url(#ka-behind)" />
</g>
<circle cx={CX} cy={CY} r={R} fill="url(#ka-sphere)" />
<path d={gridBack} fill="none" stroke="#5b8cff" stroke-opacity="0.06" />
<path d={gridFront} fill="none" stroke="#7aa2ff" stroke-opacity="0.2" stroke-width="0.9" />
<path d={equator} fill="none" stroke="#7aa2ff" stroke-opacity="0.38" />
<circle cx={CX} cy={CY} r={R} fill="none" stroke="#5b8cff" stroke-opacity="0.55" />
<circle cx={CX} cy={CY} r={R + 5} fill="none" stroke="#5b8cff" stroke-opacity="0.12" />
<path d={route} fill="none" stroke="#5b8cff" stroke-opacity="0.75" stroke-dasharray="5 4" />
<g class="markers">
{#each markers as m (m.label)}
<g transform="translate({fmt(m.x)} {fmt(m.y)})">
<rect x="-4.5" y="-4.5" width="9" height="9" fill="#09090b" stroke="#a9c1ff" stroke-opacity="0.9" />
<rect x="-1" y="-1" width="2" height="2" fill="#d6e2ff" />
<path d="M5 -5L16 -16H64" fill="none" stroke="#9a999e" stroke-opacity="0.4" />
<text x="20" y="-21" fill="#d4d3d0" fill-opacity="0.62">{m.label}</text>
</g>
{/each}
</g>
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
<path d={orbitFront} stroke-opacity="0.34" />
</g>
</svg>
<!-- Separate layer so the static art above never repaints. -->
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
<g transform="rotate({ORBIT_TILT} {CX} {CY})">
{#if prefersReducedMotion.current}
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="7" fill="#5b8cff" fill-opacity="0.18" />
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="2.5" fill="#d6e2ff" />
{:else}
<g mask="url(#ka-behind)">
<g>
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
<circle r="2.5" fill="#d6e2ff" />
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
<animate attributeName="opacity" values="1;0" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
</g>
</g>
<g opacity="0">
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
<circle r="2.5" fill="#d6e2ff" />
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
<animate attributeName="opacity" values="0;1" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
</g>
{/if}
</g>
</svg>
<div class="vignette"></div> <div class="vignette"></div>
</div> </div>
@@ -260,27 +24,48 @@
pointer-events: none; pointer-events: none;
} }
.layer { /* `left` is where the hub's first column ends: its padding plus the column's
width, kept in step with App.svelte's grid (and its 1140px breakpoint). The
frame is what crops the picture - an <img> left to itself takes its own
aspect ratio, whatever the box says, and `object-position` then has nothing
to slide. The left edge fades in, so the black beside it is a backdrop
rather than a seam. */
.frame {
position: absolute; position: absolute;
inset: 0; top: 0;
right: 0;
bottom: 0;
left: 432px;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 4%);
mask-image: linear-gradient(90deg, transparent 0%, #000 4%);
}
/* Cover, then slide: this picks the whole wordmark and the near half of the
globe, with the mark starting just clear of the panel. */
.art {
display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
object-position: 50% center;
user-select: none;
} }
.ring-labels, /* Only the edges, where the title bar and the status bar sit: the wordmark and
.markers { the globe are the point, and a left-hand wash would swallow them. */
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.06em;
}
.vignette { .vignette {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: background:
linear-gradient(90deg, rgba(9, 9, 11, 0.9) 0%, rgba(9, 9, 11, 0.5) 30%, rgba(9, 9, 11, 0) 52%), linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 28%),
linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 32%), linear-gradient(180deg, rgba(9, 9, 11, 0.6) 0%, rgba(9, 9, 11, 0) 16%),
linear-gradient(180deg, rgba(9, 9, 11, 0.55) 0%, rgba(9, 9, 11, 0) 18%), radial-gradient(120% 100% at 62% 50%, transparent 58%, rgba(9, 9, 11, 0.5) 100%);
radial-gradient(130% 100% at 72% 50%, transparent 55%, rgba(9, 9, 11, 0.65) 100%); }
@media (max-width: 1140px) {
.frame {
left: 384px;
}
} }
</style> </style>
+22 -2
View File
@@ -105,10 +105,23 @@
} }
} }
/** True while the DHP would draw this item itself, so the PC lacking it shows nothing. */
const drawnBySite = (item: MarketItem) =>
item.presence === "absent" && drive === null && (s?.online ?? false);
function presenceLabel(item: MarketItem): string { function presenceLabel(item: MarketItem): string {
if (item.presence === "installed") return "Installed"; if (item.presence === "installed") return "Installed";
if (item.presence === "partial") return "Partly downloaded"; if (item.presence === "partial") return "Partly downloaded";
return "Not installed"; // "Not installed" would read as missing when nothing is missing: while the DHP
// answers it draws this itself. What this PC lacks is the copy that works offline.
return drawnBySite(item) ? "On the DHP" : "Not installed";
}
function presenceHelp(item: MarketItem): string | undefined {
if (item.presence !== "absent" || drive !== null) return undefined;
return drawnBySite(item)
? "Your DHP draws this while you are connected. Download it to keep it when you are not."
: "Not on this PC, and your DHP is out of reach.";
} }
function changeNote(item: MarketItem): string | null { function changeNote(item: MarketItem): string | null {
@@ -246,7 +259,9 @@
</span> </span>
{#if item.description}<span class="desc">{item.description}</span>{/if} {#if item.description}<span class="desc">{item.description}</span>{/if}
<span class="meta"> <span class="meta">
<span class="pill {item.presence}">{presenceLabel(item)}</span> <span class="pill {item.presence}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
{presenceLabel(item)}
</span>
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if} {#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
</span> </span>
</span> </span>
@@ -532,6 +547,11 @@
background: var(--warn-soft); background: var(--warn-soft);
color: var(--warn); color: var(--warn);
} }
/* Drawn by the DHP: a working state, not a warning. */
.pill.from-site {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
color: var(--accent);
}
.change { .change {
color: var(--accent-hover); color: var(--accent-hover);
font-size: var(--text-xs); font-size: var(--text-xs);
+27 -2
View File
@@ -10,7 +10,7 @@
let { open = $bindable(false) }: Props = $props(); let { open = $bindable(false) }: Props = $props();
const EMPTY: Settings = { site_url: "", mcp_url: "", install_dir: "" }; const EMPTY: Settings = { site_url: "", mcp_url: "", ai_url: "", install_dir: "" };
let dialog: HTMLDialogElement | undefined = $state(); let dialog: HTMLDialogElement | undefined = $state();
let draft = $state<Settings>({ ...EMPTY }); let draft = $state<Settings>({ ...EMPTY });
@@ -31,20 +31,26 @@
const trimmed = $derived<Settings>({ const trimmed = $derived<Settings>({
site_url: draft.site_url.trim(), site_url: draft.site_url.trim(),
mcp_url: draft.mcp_url.trim(), mcp_url: draft.mcp_url.trim(),
ai_url: draft.ai_url.trim(),
install_dir: draft.install_dir.trim(), install_dir: draft.install_dir.trim(),
}); });
const dirty = $derived( const dirty = $derived(
current !== null && current !== null &&
(trimmed.site_url !== current.site_url || (trimmed.site_url !== current.site_url ||
trimmed.mcp_url !== current.mcp_url || trimmed.mcp_url !== current.mcp_url ||
trimmed.ai_url !== current.ai_url ||
trimmed.install_dir !== current.install_dir), trimmed.install_dir !== current.install_dir),
); );
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */ /** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir)); const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
const siteError = $derived(urlProblem(trimmed.site_url)); const siteError = $derived(urlProblem(trimmed.site_url));
const mcpError = $derived(urlProblem(trimmed.mcp_url)); const mcpError = $derived(urlProblem(trimmed.mcp_url));
/** Optional: an empty AI address is how an install with no line out is left. */
const aiError = $derived(trimmed.ai_url === "" ? null : urlProblem(trimmed.ai_url));
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed."); const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null || offDrive); const invalid = $derived(
siteError !== null || mcpError !== null || aiError !== null || dirError !== null || offDrive,
);
function urlProblem(value: string): string | null { function urlProblem(value: string): string | null {
if (!value) return "Required."; if (!value) return "Required.";
@@ -199,6 +205,25 @@
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."} {dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
</span> </span>
</div> </div>
<div class="field">
<label class="field-label" for="set-ai-url">AI URL</label>
<input
id="set-ai-url"
class="input"
type="url"
spellcheck="false"
autocomplete="off"
placeholder="https://engine.odian.fr"
bind:value={draft.ai_url}
aria-invalid={dirty && aiError !== null}
aria-describedby="set-ai-url-help"
/>
<span id="set-ai-url-help" class={dirty && aiError ? "field-error" : "field-help"}>
{dirty && aiError
? aiError
: "Destination prediction, chat and vision, served over the internet. Leave empty and ODIAN runs wholly offline."}
</span>
</div>
</fieldset> </fieldset>
<fieldset class="group" disabled={locked || saving}> <fieldset class="group" disabled={locked || saving}>
+6 -6
View File
@@ -2,6 +2,8 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte"; import { launcher } from "../lib/launcher.svelte";
// ODIAN's own mark, the same one the window and the taskbar carry.
import mark from "../assets/mark.png";
interface Props { interface Props {
onOpenSettings: () => void; onOpenSettings: () => void;
@@ -44,11 +46,7 @@
<header class="titlebar" data-tauri-drag-region> <header class="titlebar" data-tauri-drag-region>
<div class="brand" data-tauri-drag-region> <div class="brand" data-tauri-drag-region>
<svg class="mark" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" data-tauri-drag-region> <img class="mark" src={mark} width="18" height="18" alt="" draggable="false" data-tauri-drag-region />
<rect x="0.5" y="0.5" width="17" height="17" fill="none" stroke="currentColor" stroke-opacity="0.45" />
<path d="M9 3.5L14.5 9 9 14.5 3.5 9z" fill="none" stroke="currentColor" stroke-width="1.5" />
<rect x="8" y="8" width="2" height="2" fill="currentColor" />
</svg>
<span class="wordmark" data-tauri-drag-region>ODIAN</span> <span class="wordmark" data-tauri-drag-region>ODIAN</span>
<span class="product" data-tauri-drag-region>Launcher</span> <span class="product" data-tauri-drag-region>Launcher</span>
</div> </div>
@@ -130,7 +128,9 @@
} }
.mark { .mark {
color: var(--accent); display: block;
border-radius: 4px;
user-select: none;
} }
.wordmark { .wordmark {
+7 -3
View File
@@ -236,6 +236,8 @@ function initialStatus(scenario: MockScenario): Status {
portable: false, portable: false,
sync: null, sync: null,
live: null, live: null,
map_from_site: null,
map_problem: null,
}; };
switch (scenario) { switch (scenario) {
case "first-run": case "first-run":
@@ -319,7 +321,7 @@ class MockBackend implements Backend {
this.status = initialStatus(scenario); this.status = initialStatus(scenario);
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : []; this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
this.network = this.status.online; this.network = this.status.online;
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR }; this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR };
if (this.status.installed) { if (this.status.installed) {
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download. // An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed"); for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
@@ -605,10 +607,12 @@ class MockBackend implements Backend {
} }
async setSettings(settings: Settings): Promise<Status> { async setSettings(settings: Settings): Promise<Status> {
for (const [label, url] of [ // The AI address is optional: empty means the client stays offline.
for (const [label, url] of ([
["DHP URL", settings.site_url], ["DHP URL", settings.site_url],
["MCP URL", settings.mcp_url], ["MCP URL", settings.mcp_url],
] as const) { ...(settings.ai_url.trim() ? [["AI URL", settings.ai_url] as const] : []),
] as const)) {
try { try {
new URL(url); new URL(url);
} catch { } catch {
+12
View File
@@ -50,6 +50,13 @@ export interface Status {
* this PC. null when ODIAN is not running, or the DHP offers no Engine API. * this PC. null when ODIAN is not running, or the DHP offers no Engine API.
*/ */
live: boolean | null; live: boolean | null;
/**
* While ODIAN runs: false when the map is being drawn from this PC because the site's
* tile service did not answer. null when ODIAN is not running, or no site is set.
*/
map_from_site: boolean | null;
/** Why, in one line, when `map_from_site` is false. */
map_problem: string | null;
} }
/** A marketplace item: one tile set or source file of the version. */ /** A marketplace item: one tile set or source file of the version. */
@@ -118,6 +125,11 @@ export interface Settings {
site_url: string; site_url: string;
/** Site MCP, for Keycloak sign-in. */ /** Site MCP, for Keycloak sign-in. */
mcp_url: string; mcp_url: string;
/**
* The MCP serving ODIAN's AI features (`/api/ai/...`), reached over the internet. Empty
* keeps the client wholly offline; set, it is the one address the client may reach.
*/
ai_url: string;
install_dir: string; install_dir: string;
} }