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
+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
/// 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;
}
+28
View File
@@ -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
View File
@@ -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
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 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}");
}
}