update
This commit is contained in:
+111
-10
@@ -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<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`).
|
||||
@@ -123,11 +130,16 @@ struct Inner {
|
||||
/// during a blip still knows where live mode will forward.
|
||||
engine_url: Option<String>,
|
||||
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`).
|
||||
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<String>,
|
||||
/// 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<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 {
|
||||
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::<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
|
||||
/// 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;
|
||||
}
|
||||
|
||||
@@ -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<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,
|
||||
/// 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 [
|
||||
|
||||
+120
-7
@@ -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<String>,
|
||||
/// The site's CURRENT version.
|
||||
pub version: Option<String>,
|
||||
/// 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<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 {
|
||||
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<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 {
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+183
-16
@@ -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<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
|
||||
@@ -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<PathBuf> {
|
||||
(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<odian_packs::manifest::DbSection>
|
||||
/// 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<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 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<Vec<TileSet>, 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<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)]
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user