diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index ccb910e..29e405a 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -22,7 +22,7 @@ type Phase = | "error"; // the last long operation failed; see `error` 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 }; 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 @@ -64,6 +64,7 @@ interface SyncConflict { interface Settings { site_url: string; // e.g. "http://192.168.1.41:30097" mcp_url: string; // site MCP, for Keycloak sign-in + ai_url: string; // MCP serving /api/ai/... over the internet; "" = none 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, 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 diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 77e7d23..51dddab 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 0f7976f..e645c50 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 98fda06..bd8f59a 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..e7181a3 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index f35d84f..c144cbc 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 1823bb2..73ca675 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index dc2b22c..cae90a1 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 0ed3984..a86df8d 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 60bf0ea..7fc69a5 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index c8ca0ad..28148c8 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 8756459..82cc0e0 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 2c8023c..9cbf935 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 2c5e603..b3d27f1 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 17d142c..2170fc0 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index a2993ad..54893f7 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 06c23c8..dcb19af 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index d1756ce..92b86a5 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/site.json b/src-tauri/site.json index 59da76c..c33958b 100644 --- a/src-tauri/site.json +++ b/src-tauri/site.json @@ -1,5 +1,6 @@ { "name": "ODIAN test DHP", - "site_url": "http://192.168.1.41:30097", - "mcp_url": "http://192.168.1.41:30091" + "site_url": "https://engine.odian.fr/dhp", + "mcp_url": "https://engine.odian.fr", + "ai_url": "https://engine.odian.fr" } \ No newline at end of file diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index cae17a5..168d92f 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -65,6 +65,13 @@ pub struct Status { /// 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. pub live: Option, + /// 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, + /// Why, in one line, when `map_from_site` is false. + pub map_problem: Option, } /// What the marketplace shows (`get_market`). @@ -123,11 +130,16 @@ struct Inner { /// during a blip still knows where live mode will forward. engine_url: Option, live: Option, + map_from_site: Option, + map_problem: Option, /// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`). portable: bool, /// A newer launcher is being checked for or downloaded (see `update_self`): nothing may /// launch or install meanwhile, since installing it closes this launcher. 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, /// 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 /// 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, sync: self.sync.clone(), 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}"))?; Ok(Launcher { 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_states: std::sync::Mutex::new(Vec::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 /// no longer takes it. Never fails: an unreachable site is "offline". Also how the UI /// 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 { + 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 { let (site_url, token) = { let mut inner = self.inner.lock().await; @@ -397,13 +443,25 @@ impl Launcher { self.emit(app).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; inner.online = probe.online; inner.available = probe.version; // Keep the last known address while offline: it is where live mode resumes. - if probe.engine_url.is_some() { - inner.engine_url = probe.engine_url; + if engine.is_some() { + inner.engine_url = engine; } } // Offline nothing is checked or forgotten: the remembered operator still launches. @@ -509,6 +567,8 @@ impl Launcher { let mut next = Settings { site_url: normalize_url(&incoming.site_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(), 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 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; - 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); 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}).")); } } // 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, - _ => self.chosen_extras(install_dir, manifest).await?, + // + // The lock is taken and dropped on its own line. Held across the `match`, as a temporary + // 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 // 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 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.error = None; StackConfig { @@ -1369,7 +1442,10 @@ impl Launcher { pg_password: inner.state.pg_password.clone(), install_id: inner.state.install_id.clone(), tile_sets, + site_tile_sets, 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() { @@ -1650,6 +1726,29 @@ impl Launcher { 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::().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 /// 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. @@ -1674,11 +1773,12 @@ impl Launcher { _ = tokio::time::sleep(TICK) => {} } let probe = site::probe(&self.http, &site_url).await; + let engine = self.pick_engine(app, &probe).await; { let mut inner = self.inner.lock().await; inner.online = probe.online; - if probe.engine_url.is_some() { - inner.engine_url = probe.engine_url.clone(); + if engine.is_some() { + inner.engine_url = engine; } } if !probe.online { @@ -1687,6 +1787,7 @@ impl Launcher { } was_online = false; self.read_live_state().await; + self.read_map_state().await; self.emit(app).await; continue; } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index e087fe2..188b4b2 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -19,6 +19,12 @@ pub struct Settings { /// The site's MCP, which runs the Keycloak sign-in. #[serde(default)] 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)] pub install_dir: String, /// Display name from site.json; the UI's settings form does not edit it. @@ -34,6 +40,8 @@ struct SiteFile { site_url: String, #[serde(default)] mcp_url: String, + #[serde(default)] + ai_url: String, /// This launcher ships on an ODIAN drive: see `portable_root`. #[serde(default)] portable: bool, @@ -190,6 +198,15 @@ fn folder_problem_on(folder: &Path, drive: &Path) -> Option { } } +/// 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, /// 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 { @@ -252,6 +269,7 @@ impl Settings { let settings = Settings { site_url: normalize_url(&site.site_url), mcp_url: normalize_url(&site.mcp_url), + ai_url: normalize_url(&site.ai_url), install_dir: default_install_dir(), site_name: site.name, }; @@ -296,6 +314,16 @@ impl Settings { mod tests { 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] fn a_drives_own_launcher_installs_on_that_drive_whatever_its_letter() { for (exe, want) in [ diff --git a/src-tauri/src/site.rs b/src-tauri/src/site.rs index a47a9c8..30fde0e 100644 --- a/src-tauri/src/site.rs +++ b/src-tauri/src/site.rs @@ -4,32 +4,116 @@ use std::time::Duration; 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); +/// 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 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, /// The site's CURRENT version. pub version: Option, /// 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. pub engine_url: Option, + /// 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, +} + +/// 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 { + 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 { - let offline = Probe { online: false, version: None, engine_url: None }; 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 { - return offline; + let url = format!("{site_url}/v1/health"); + 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() { - return offline; + return unreachable(&url, format!("answered {}", response.status())); } 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); - 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 { + let listed: Vec = 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 { @@ -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}")), } } + +#[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()); + } +} diff --git a/src-tauri/src/stack.rs b/src-tauri/src/stack.rs index f285d19..b1713d8 100644 --- a/src-tauri/src/stack.rs +++ b/src-tauri/src/stack.rs @@ -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 data while it is reachable; without, this PC always serves its own. pub upstream_url: Option, + /// 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, +} + +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 { + 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 @@ -442,11 +468,25 @@ fn pg_ini(config: &StackConfig) -> String { /// How the tile server reaches this PC's database (a libpq connection string). const TILES_POSTGIS_DSN_ENV: &str = "ODIAN_TILE_POSTGIS_DSN"; -/// The Theatre's POI layers -- hospitals, schools, places of worship, data centres. A site -/// serves them from the extractor's SQLite files; this PC ships none of those, but holds the -/// same tables in its own database, and the tile server reads them there instead. -const NEARBY_POSTGIS_SOURCES: [&str; 4] = - ["hospital_postgis", "school_postgis", "place_of_worship_postgis", "datacenter_postgis"]; +/// Every overlay the tile server draws out of PostGIS. A site serves them from the +/// extractor's SQLite files; this PC ships none of those, but the database snapshot carries +/// the same tables, and the tile server reads them there instead. +/// +/// 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 { 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 { let tiles = config.layout.data_dir().join("tiles"); 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 mut entry = serde_json::json!({ "cache_dir": text(tiles.clone()) }); 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. 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 @@ -567,13 +611,20 @@ fn baker(config: &StackConfig) -> Option { (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)> { let tiles = config.layout.data_dir().join("tiles"); // 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 // dataset or style (see `datasets`), and an offline PC never reaches a provider. let baker = baker(config); - vec![ + let mut env = vec![ ("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_NO_UPSTREAM", "1".to_string()), @@ -585,7 +636,17 @@ fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> { ("PYTHONDONTWRITEBYTECODE", "1".to_string()), ("PYTHONNOUSERSITE", "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)> { @@ -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_LAUNCH_USER", config.operator.clone()), ("ODIAN_MINT_SECRET", config.mint_secret.clone()), - ("ODIAN_OFFLINE", "1".to_string()), ("ODIAN_TILE_SERVER_URL", tiles_url.clone()), ("ODIAN_TILE_PROVIDER", "odian".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_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 .tile_sets .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 // 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. - 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() { 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) => { env.push(("ODIAN_HEIGHT_SERVER_URL", tiles_url)); env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string())); @@ -656,7 +730,10 @@ pub fn installed_db(layout: &Layout) -> Option /// 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 /// folder): the client is offered, and the tile server serves, only those. -pub fn installed_tile_sets(layout: &Layout) -> Result, 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, String> { 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 manifest: serde_json::Value = @@ -673,12 +750,22 @@ pub fn installed_tile_sets(layout: &Layout) -> Result, 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() }) .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, 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)] mod tests { #[test] @@ -703,6 +790,12 @@ mod tests { .map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() }) .collect(), 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"); } + #[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] fn the_client_is_told_every_style_this_install_carries() { // The picker offers what the PC holds. Naming only the default basemap is what @@ -808,11 +915,71 @@ mod tests { } #[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 doc = datasets(&cfg); 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}"); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c517950..e12a6f6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ODIAN Launcher", - "version": "0.1.3", + "version": "0.1.12", "identifier": "fr.odian.launcher", "build": { "frontendDist": "../dist", @@ -41,7 +41,12 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "windows": { + "nsis": { + "installerIcon": "icons/icon.ico" + } + } }, "plugins": { "updater": { diff --git a/src/assets/keyart.webp b/src/assets/keyart.webp new file mode 100644 index 0000000..d9ea234 Binary files /dev/null and b/src/assets/keyart.webp differ diff --git a/src/assets/mark.png b/src/assets/mark.png new file mode 100644 index 0000000..51dddab Binary files /dev/null and b/src/assets/mark.png differ diff --git a/src/components/ActionPanel.svelte b/src/components/ActionPanel.svelte index ce16b61..f099a8d 100644 --- a/src/components/ActionPanel.svelte +++ b/src/components/ActionPanel.svelte @@ -238,6 +238,17 @@

Sending this PC's changes to {site} and taking in the other operators' work.

{:else if action === "stack"} + {#if s?.map_from_site === false} + +
+ +

+ {site}'s map service is unreachable: drawing from this PC's own tiles. + {#if s?.map_problem} ({s.map_problem}){/if} +

+
+ {/if}