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