update
@@ -22,7 +22,7 @@ type Phase =
|
|||||||
| "error"; // the last long operation failed; see `error`
|
| "error"; // the last long operation failed; see `error`
|
||||||
|
|
||||||
interface Status {
|
interface Status {
|
||||||
online: boolean; // the site's update service answered /v1/health
|
online: boolean; // the site's update service answered /v1/health (2 s, then 10 s if that timed out: a hotspot is slow, not absent)
|
||||||
site: { url: string; name: string | null };
|
site: { url: string; name: string | null };
|
||||||
operator: string | null; // last signed-in ODIAN username (cached; valid offline)
|
operator: string | null; // last signed-in ODIAN username (cached; valid offline)
|
||||||
signed_in: boolean; // this PC remembers the operator's site sign-in until Sign out: the
|
signed_in: boolean; // this PC remembers the operator's site sign-in until Sign out: the
|
||||||
@@ -64,6 +64,7 @@ interface SyncConflict {
|
|||||||
interface Settings {
|
interface Settings {
|
||||||
site_url: string; // e.g. "http://192.168.1.41:30097"
|
site_url: string; // e.g. "http://192.168.1.41:30097"
|
||||||
mcp_url: string; // site MCP, for Keycloak sign-in
|
mcp_url: string; // site MCP, for Keycloak sign-in
|
||||||
|
ai_url: string; // MCP serving /api/ai/... over the internet; "" = none
|
||||||
install_dir: string;
|
install_dir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,8 +164,35 @@ nothing else. Installing from a drive asks the same way: the marketplace opens o
|
|||||||
drive carries, `set_market` records the answer, and `start_install_from_drive` copies it. A PC installed before the marketplace counts what it has on disk as chosen,
|
drive carries, `set_market` records the answer, and `start_install_from_drive` copies it. A PC installed before the marketplace counts what it has on disk as chosen,
|
||||||
so nothing is removed or downloaded again behind the operator's back.
|
so nothing is removed or downloaded again behind the operator's back.
|
||||||
|
|
||||||
|
## AI over the internet
|
||||||
|
|
||||||
|
The client is started with `ODIAN_OFFLINE=1`, which blanks the MCP address whatever the
|
||||||
|
environment holds: the AI features (destination prediction, chat, vision) answer "not
|
||||||
|
available offline" rather than hanging on a call that cannot arrive. An `ai_url` in Settings
|
||||||
|
says this PC has a line out: the flag is left off and that address is passed as
|
||||||
|
`ODIAN_MCP_API_URL`. Nothing else moves - the data, the tiles and the map stay this PC's own
|
||||||
|
local stack - and emptying the field puts the client back to wholly offline. The AI routes
|
||||||
|
need no token of their own; `site.json` may carry an `ai_url` so a site ships with one.
|
||||||
|
|
||||||
|
## Overlays out of this PC's database
|
||||||
|
|
||||||
|
The tile server draws its overlays (aeroways, data centres, gas, hospitals, military bases,
|
||||||
|
oil, places of worship, schools) from PostGIS. A site reads the extractor's SQLite files; a
|
||||||
|
PC has none of those, so the launcher names each layer in with the DSN of
|
||||||
|
the local database, whose snapshot carries the same tables. One key per layer is required:
|
||||||
|
the server looks for that key, then for a DSN under ITS OWN env name, and a layer it cannot
|
||||||
|
resolve draws nothing at all rather than failing loudly.
|
||||||
|
|
||||||
## Live mode
|
## Live mode
|
||||||
|
|
||||||
|
The DHP names where live mode forwards. `/v1/health` offers `engine_api_urls`, best first:
|
||||||
|
the address its own network reaches, then the one that answers from outside. The PC tries
|
||||||
|
the one it already uses, then that list in order, and keeps the first that answers - a
|
||||||
|
laptop is at the office in the morning and at home in the evening, and neither address
|
||||||
|
suits both. `engine_api_url` remains the single address a launcher that ignores the list
|
||||||
|
will use, so a site must make that one the address that works from anywhere. None of them
|
||||||
|
answering is not an error: the local API serves this PC's own data, as it does offline.
|
||||||
|
|
||||||
When the DHP advertises an Engine API (`engine_api_url` in its `/v1/health`), the
|
When the DHP advertises an Engine API (`engine_api_url` in its `/v1/health`), the
|
||||||
launcher starts the local API pointed at it and keeps a token file fresh for it. ODIAN
|
launcher starts the local API pointed at it and keeps a token file fresh for it. ODIAN
|
||||||
always talks to the local API; while the DHP answers, that API forwards the shared-data
|
always talks to the local API; while the DHP answers, that API forwards the shared-data
|
||||||
|
|||||||
|
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",
|
"name": "ODIAN test DHP",
|
||||||
"site_url": "http://192.168.1.41:30097",
|
"site_url": "https://engine.odian.fr/dhp",
|
||||||
"mcp_url": "http://192.168.1.41:30091"
|
"mcp_url": "https://engine.odian.fr",
|
||||||
|
"ai_url": "https://engine.odian.fr"
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,13 @@ pub struct Status {
|
|||||||
/// While ODIAN runs: true when its data is coming from the DHP, false when from
|
/// While ODIAN runs: true when its data is coming from the DHP, false when from
|
||||||
/// this PC. None when ODIAN is not running, or the DHP offers no Engine API.
|
/// this PC. None when ODIAN is not running, or the DHP offers no Engine API.
|
||||||
pub live: Option<bool>,
|
pub live: Option<bool>,
|
||||||
|
/// While ODIAN runs: false when the map is being drawn from this PC because the
|
||||||
|
/// site's tile service did not answer. None when ODIAN is not running, or no site
|
||||||
|
/// is configured. The operator is told: a map quietly older than the site's, with
|
||||||
|
/// no overlays this PC never downloaded, otherwise just looks wrong.
|
||||||
|
pub map_from_site: Option<bool>,
|
||||||
|
/// Why, in one line, when `map_from_site` is false.
|
||||||
|
pub map_problem: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the marketplace shows (`get_market`).
|
/// What the marketplace shows (`get_market`).
|
||||||
@@ -123,11 +130,16 @@ struct Inner {
|
|||||||
/// during a blip still knows where live mode will forward.
|
/// during a blip still knows where live mode will forward.
|
||||||
engine_url: Option<String>,
|
engine_url: Option<String>,
|
||||||
live: Option<bool>,
|
live: Option<bool>,
|
||||||
|
map_from_site: Option<bool>,
|
||||||
|
map_problem: Option<String>,
|
||||||
/// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`).
|
/// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`).
|
||||||
portable: bool,
|
portable: bool,
|
||||||
/// A newer launcher is being checked for or downloaded (see `update_self`): nothing may
|
/// A newer launcher is being checked for or downloaded (see `update_self`): nothing may
|
||||||
/// launch or install meanwhile, since installing it closes this launcher.
|
/// launch or install meanwhile, since installing it closes this launcher.
|
||||||
updating_self: bool,
|
updating_self: bool,
|
||||||
|
/// The last reason the DHP was not reachable, so the log carries one line per
|
||||||
|
/// distinct fault instead of the same one at every check.
|
||||||
|
offline_reason: Option<String>,
|
||||||
/// What the marketplace picked for the copy about to run from a drive. It filters that one
|
/// What the marketplace picked for the copy about to run from a drive. It filters that one
|
||||||
/// copy and is never written down: a tile set left out because this PC already has it must
|
/// copy and is never written down: a tile set left out because this PC already has it must
|
||||||
/// not read as "the operator dropped it", or the next Apply in the marketplace would delete
|
/// not read as "the operator dropped it", or the next Apply in the marketplace would delete
|
||||||
@@ -161,6 +173,8 @@ impl Inner {
|
|||||||
portable: self.portable,
|
portable: self.portable,
|
||||||
sync: self.sync.clone(),
|
sync: self.sync.clone(),
|
||||||
live: self.live,
|
live: self.live,
|
||||||
|
map_from_site: self.map_from_site,
|
||||||
|
map_problem: self.map_problem.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,7 +296,7 @@ impl Launcher {
|
|||||||
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
|
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
|
||||||
Ok(Launcher {
|
Ok(Launcher {
|
||||||
http,
|
http,
|
||||||
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, drive_extras: None }),
|
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, map_from_site: None, map_problem: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }),
|
||||||
stack: Mutex::new(None),
|
stack: Mutex::new(None),
|
||||||
stack_states: std::sync::Mutex::new(Vec::new()),
|
stack_states: std::sync::Mutex::new(Vec::new()),
|
||||||
sign_in_cancel: Notify::new(),
|
sign_in_cancel: Notify::new(),
|
||||||
@@ -385,6 +399,38 @@ impl Launcher {
|
|||||||
/// Re-probe the site and re-check the stored sign-in, renewing it quietly when the site
|
/// Re-probe the site and re-check the stored sign-in, renewing it quietly when the site
|
||||||
/// no longer takes it. Never fails: an unreachable site is "offline". Also how the UI
|
/// no longer takes it. Never fails: an unreachable site is "offline". Also how the UI
|
||||||
/// dismisses an error.
|
/// dismisses an error.
|
||||||
|
/// Which of the DHP's Engine API addresses this PC can reach. The one already in use is
|
||||||
|
/// tried first (an address that still answers is not swapped under a running ODIAN), then
|
||||||
|
/// the list in order. None means none answered: the caller keeps what it had, and the
|
||||||
|
/// local API serves this PC's own data until one comes back.
|
||||||
|
///
|
||||||
|
/// Probed outside the lock: `engine_answers` waits on the network, and holding the state
|
||||||
|
/// across it would stall every other task for as long as the far end takes.
|
||||||
|
async fn pick_engine(&self, app: &AppHandle, probe: &site::Probe) -> Option<String> {
|
||||||
|
if probe.engine_urls.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let kept = self.inner.lock().await.engine_url.clone();
|
||||||
|
if let Some(url) = kept.clone().filter(|url| probe.engine_urls.contains(url)) {
|
||||||
|
if site::engine_answers(&self.http, &url).await {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let found = site::first_engine(&self.http, &probe.engine_urls).await;
|
||||||
|
match (&found, &kept) {
|
||||||
|
(Some(url), kept) if kept.as_ref() != Some(url) => {
|
||||||
|
self.log(app, "info", format!("Live data comes from {url}."));
|
||||||
|
}
|
||||||
|
(None, Some(_)) => self.log(
|
||||||
|
app,
|
||||||
|
"warn",
|
||||||
|
"None of the DHP's Engine API addresses answered: ODIAN works from this PC's own data.",
|
||||||
|
),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn refresh(&self, app: &AppHandle) -> Status {
|
pub async fn refresh(&self, app: &AppHandle) -> Status {
|
||||||
let (site_url, token) = {
|
let (site_url, token) = {
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
@@ -397,13 +443,25 @@ impl Launcher {
|
|||||||
self.emit(app).await;
|
self.emit(app).await;
|
||||||
|
|
||||||
let probe = site::probe(&self.http, &site_url).await;
|
let probe = site::probe(&self.http, &site_url).await;
|
||||||
|
let engine = self.pick_engine(app, &probe).await;
|
||||||
|
// Say why, once per fault. "Offline" alone sends an operator hunting the network
|
||||||
|
// when it is a certificate, a proxy or a name that does not resolve.
|
||||||
|
let announce = {
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
let fresh = probe.problem.clone().filter(|why| inner.offline_reason.as_ref() != Some(why));
|
||||||
|
inner.offline_reason = probe.problem.clone();
|
||||||
|
fresh
|
||||||
|
};
|
||||||
|
if let Some(why) = announce {
|
||||||
|
self.log(app, "warn", format!("Your DHP did not answer. {why}"));
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
inner.online = probe.online;
|
inner.online = probe.online;
|
||||||
inner.available = probe.version;
|
inner.available = probe.version;
|
||||||
// Keep the last known address while offline: it is where live mode resumes.
|
// Keep the last known address while offline: it is where live mode resumes.
|
||||||
if probe.engine_url.is_some() {
|
if engine.is_some() {
|
||||||
inner.engine_url = probe.engine_url;
|
inner.engine_url = engine;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Offline nothing is checked or forgotten: the remembered operator still launches.
|
// Offline nothing is checked or forgotten: the remembered operator still launches.
|
||||||
@@ -509,6 +567,8 @@ impl Launcher {
|
|||||||
let mut next = Settings {
|
let mut next = Settings {
|
||||||
site_url: normalize_url(&incoming.site_url),
|
site_url: normalize_url(&incoming.site_url),
|
||||||
mcp_url: normalize_url(&incoming.mcp_url),
|
mcp_url: normalize_url(&incoming.mcp_url),
|
||||||
|
// Blank on purpose is a normal answer here: no AI, and the client stays offline.
|
||||||
|
ai_url: normalize_url(&incoming.ai_url),
|
||||||
install_dir: incoming.install_dir.trim().to_string(),
|
install_dir: incoming.install_dir.trim().to_string(),
|
||||||
site_name: incoming.site_name.or_else(|| inner.settings.site_name.clone()),
|
site_name: incoming.site_name.or_else(|| inner.settings.site_name.clone()),
|
||||||
};
|
};
|
||||||
@@ -782,18 +842,29 @@ impl Launcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// An ODIAN drive is carried between PCs: its install must not belong to this PC's account.
|
// An ODIAN drive is carried between PCs: its install must not belong to this PC's account.
|
||||||
// An install onto the PC itself is that PC's own, and keeps the permissions it has.
|
// An install onto the PC itself is that PC's own, and keeps the permissions it has, and a
|
||||||
|
// folder that already holds one is left alone (see `grant_still_needed`).
|
||||||
let portable = self.inner.lock().await.portable;
|
let portable = self.inner.lock().await.portable;
|
||||||
if portable && crate::settings::installs_on_the_drive(install_dir) {
|
if portable
|
||||||
|
&& crate::settings::installs_on_the_drive(install_dir)
|
||||||
|
&& crate::settings::grant_still_needed(install_dir)
|
||||||
|
{
|
||||||
let _ = std::fs::create_dir_all(install_dir);
|
let _ = std::fs::create_dir_all(install_dir);
|
||||||
if let Err(e) = crate::settings::share_with_every_account(install_dir) {
|
if let Err(e) = crate::settings::share_with_every_account(install_dir) {
|
||||||
self.log(app, "warn", format!("This drive's install may only be usable by you ({e})."));
|
self.log(app, "warn", format!("This drive's install may only be usable by you ({e})."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The marketplace items the operator chose: for a copy, the popup's answer, used once.
|
// The marketplace items the operator chose: for a copy, the popup's answer, used once.
|
||||||
let extras = match self.inner.lock().await.drive_extras.take() {
|
//
|
||||||
Some(chosen) if !matches!(source, Source::Site { .. }) => chosen,
|
// The lock is taken and dropped on its own line. Held across the `match`, as a temporary
|
||||||
_ => self.chosen_extras(install_dir, manifest).await?,
|
// in the scrutinee is, the other arm's `chosen_extras` waits for a lock this very task
|
||||||
|
// owns: the install parks for good, right between the manifest and the first file, with
|
||||||
|
// the DHP still being told the slot is in use.
|
||||||
|
let from_drive = !matches!(source, Source::Site { .. });
|
||||||
|
let picked = if from_drive { self.inner.lock().await.drive_extras.take() } else { None };
|
||||||
|
let extras = match picked {
|
||||||
|
Some(chosen) => chosen,
|
||||||
|
None => self.chosen_extras(install_dir, manifest).await?,
|
||||||
};
|
};
|
||||||
// A USB key cannot carry this: Postgres restores 16 GB into it, and unplugging the key
|
// A USB key cannot carry this: Postgres restores 16 GB into it, and unplugging the key
|
||||||
// would take the install with it. The default folder already avoids removable drives.
|
// would take the install with it. The default folder already avoids removable drives.
|
||||||
@@ -1360,6 +1431,8 @@ impl Launcher {
|
|||||||
}
|
}
|
||||||
let layout = Layout::new(&install_dir, &version);
|
let layout = Layout::new(&install_dir, &version);
|
||||||
let tile_sets = stack::installed_tile_sets(&layout)?;
|
let tile_sets = stack::installed_tile_sets(&layout)?;
|
||||||
|
// What the DHP can draw for this PC, beyond what it downloaded.
|
||||||
|
let site_tile_sets = stack::version_tile_sets(&layout)?;
|
||||||
inner.phase = Phase::Launching;
|
inner.phase = Phase::Launching;
|
||||||
inner.error = None;
|
inner.error = None;
|
||||||
StackConfig {
|
StackConfig {
|
||||||
@@ -1369,7 +1442,10 @@ impl Launcher {
|
|||||||
pg_password: inner.state.pg_password.clone(),
|
pg_password: inner.state.pg_password.clone(),
|
||||||
install_id: inner.state.install_id.clone(),
|
install_id: inner.state.install_id.clone(),
|
||||||
tile_sets,
|
tile_sets,
|
||||||
|
site_tile_sets,
|
||||||
upstream_url: inner.engine_url.clone(),
|
upstream_url: inner.engine_url.clone(),
|
||||||
|
ai_url: inner.settings.ai_url.clone(),
|
||||||
|
site_url: inner.settings.site_url.clone(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Ok(mut states) = self.stack_states.lock() {
|
if let Ok(mut states) = self.stack_states.lock() {
|
||||||
@@ -1650,6 +1726,29 @@ impl Launcher {
|
|||||||
self.inner.lock().await.live = live;
|
self.inner.lock().await.live = live;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which side is drawing the map, from the tile server's own account of it. Its
|
||||||
|
/// `/health` is answered locally on purpose - asking the site whether the site is
|
||||||
|
/// reachable would only ever say yes.
|
||||||
|
async fn read_map_state(&self) {
|
||||||
|
let url = format!("http://127.0.0.1:{}/health", stack::TILES_PORT);
|
||||||
|
let (from_site, problem) = match self.http.get(url).timeout(Duration::from_secs(3)).send().await {
|
||||||
|
Ok(response) => match response.json::<serde_json::Value>().await {
|
||||||
|
Ok(body) => {
|
||||||
|
let site = body.get("site").cloned().unwrap_or_default();
|
||||||
|
let configured = site.get("url").and_then(|u| u.as_str()).is_some_and(|u| !u.is_empty());
|
||||||
|
let reachable = site.get("reachable").and_then(|r| r.as_bool());
|
||||||
|
let why = site.get("last_error").and_then(|e| e.as_str()).filter(|e| !e.is_empty()).map(str::to_string);
|
||||||
|
if configured { (reachable, why) } else { (None, None) }
|
||||||
|
}
|
||||||
|
Err(_) => (None, None),
|
||||||
|
},
|
||||||
|
Err(_) => (None, None),
|
||||||
|
};
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
inner.map_from_site = from_site;
|
||||||
|
inner.map_problem = problem;
|
||||||
|
}
|
||||||
|
|
||||||
/// While ODIAN runs against a DHP: keep its token fresh, notice the connection coming
|
/// While ODIAN runs against a DHP: keep its token fresh, notice the connection coming
|
||||||
/// and going, send this PC's offline work the moment it returns, and keep this PC's
|
/// and going, send this PC's offline work the moment it returns, and keep this PC's
|
||||||
/// own copy warm so an abrupt drop lands on fresh data rather than an old snapshot.
|
/// own copy warm so an abrupt drop lands on fresh data rather than an old snapshot.
|
||||||
@@ -1674,11 +1773,12 @@ impl Launcher {
|
|||||||
_ = tokio::time::sleep(TICK) => {}
|
_ = tokio::time::sleep(TICK) => {}
|
||||||
}
|
}
|
||||||
let probe = site::probe(&self.http, &site_url).await;
|
let probe = site::probe(&self.http, &site_url).await;
|
||||||
|
let engine = self.pick_engine(app, &probe).await;
|
||||||
{
|
{
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
inner.online = probe.online;
|
inner.online = probe.online;
|
||||||
if probe.engine_url.is_some() {
|
if engine.is_some() {
|
||||||
inner.engine_url = probe.engine_url.clone();
|
inner.engine_url = engine;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !probe.online {
|
if !probe.online {
|
||||||
@@ -1687,6 +1787,7 @@ impl Launcher {
|
|||||||
}
|
}
|
||||||
was_online = false;
|
was_online = false;
|
||||||
self.read_live_state().await;
|
self.read_live_state().await;
|
||||||
|
self.read_map_state().await;
|
||||||
self.emit(app).await;
|
self.emit(app).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ pub struct Settings {
|
|||||||
/// The site's MCP, which runs the Keycloak sign-in.
|
/// The site's MCP, which runs the Keycloak sign-in.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mcp_url: String,
|
pub mcp_url: String,
|
||||||
|
/// Where ODIAN's AI features live (destination prediction, chat, vision): the MCP that
|
||||||
|
/// serves `/api/ai/...`, reached over the internet. Empty keeps ODIAN wholly offline, which
|
||||||
|
/// is what an install with no line out wants; set, it is the one address the client is
|
||||||
|
/// allowed to reach, and the AI features stop saying "not available offline".
|
||||||
|
#[serde(default)]
|
||||||
|
pub ai_url: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub install_dir: String,
|
pub install_dir: String,
|
||||||
/// Display name from site.json; the UI's settings form does not edit it.
|
/// Display name from site.json; the UI's settings form does not edit it.
|
||||||
@@ -34,6 +40,8 @@ struct SiteFile {
|
|||||||
site_url: String,
|
site_url: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
mcp_url: String,
|
mcp_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
ai_url: String,
|
||||||
/// This launcher ships on an ODIAN drive: see `portable_root`.
|
/// This launcher ships on an ODIAN drive: see `portable_root`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
portable: bool,
|
portable: bool,
|
||||||
@@ -190,6 +198,15 @@ fn folder_problem_on(folder: &Path, drive: &Path) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a drive's install folder still needs the grant below: only while nothing has
|
||||||
|
/// been installed into it. Handing it out on a folder that already holds an install makes
|
||||||
|
/// Windows walk every file under it to push the inheritable entry down - on a drive
|
||||||
|
/// carrying a million map tiles that grinds for hours, with the install waiting on it.
|
||||||
|
/// Everything written into the folder afterwards inherits the grant on its own.
|
||||||
|
pub fn grant_still_needed(install_dir: &Path) -> bool {
|
||||||
|
!install_dir.join("versions").exists()
|
||||||
|
}
|
||||||
|
|
||||||
/// Is this folder on the drive the launcher runs from? Only then does the install travel,
|
/// Is this folder on the drive the launcher runs from? Only then does the install travel,
|
||||||
/// and only then is it worth opening to every account of every PC the drive is plugged into.
|
/// and only then is it worth opening to every account of every PC the drive is plugged into.
|
||||||
pub fn installs_on_the_drive(folder: &Path) -> bool {
|
pub fn installs_on_the_drive(folder: &Path) -> bool {
|
||||||
@@ -252,6 +269,7 @@ impl Settings {
|
|||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
site_url: normalize_url(&site.site_url),
|
site_url: normalize_url(&site.site_url),
|
||||||
mcp_url: normalize_url(&site.mcp_url),
|
mcp_url: normalize_url(&site.mcp_url),
|
||||||
|
ai_url: normalize_url(&site.ai_url),
|
||||||
install_dir: default_install_dir(),
|
install_dir: default_install_dir(),
|
||||||
site_name: site.name,
|
site_name: site.name,
|
||||||
};
|
};
|
||||||
@@ -296,6 +314,16 @@ impl Settings {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_account_grant_is_for_an_empty_folder_only() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("odian-grant-{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
assert!(grant_still_needed(&dir), "nothing installed yet: hand it out");
|
||||||
|
std::fs::create_dir_all(dir.join("versions").join("0.1.0")).unwrap();
|
||||||
|
assert!(!grant_still_needed(&dir), "an install is there: walking it would take hours");
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_drives_own_launcher_installs_on_that_drive_whatever_its_letter() {
|
fn a_drives_own_launcher_installs_on_that_drive_whatever_its_letter() {
|
||||||
for (exe, want) in [
|
for (exe, want) in [
|
||||||
|
|||||||
@@ -4,32 +4,116 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
/// How long a launch waits for the site before treating the PC as offline.
|
/// How long a launch waits for the site before treating the PC as offline. Short on
|
||||||
|
/// purpose: a PC with no DHP at all must not sit through a stall at every start.
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// The second chance, for a link that answers slowly rather than not at all. Over a
|
||||||
|
/// phone's hotspot the first HTTPS round trip to a DHP - name, connection, handshake,
|
||||||
|
/// answer - runs past two seconds often enough that the DHP read as offline while a
|
||||||
|
/// browser on the same PC reached it fine. Only a TIMEOUT is retried: a refused
|
||||||
|
/// connection or a name that does not resolve is what an absent DHP looks like, and
|
||||||
|
/// waiting ten seconds on those at every start is exactly what the short budget avoids.
|
||||||
|
const SLOW_LINK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
pub struct Probe {
|
pub struct Probe {
|
||||||
pub online: bool,
|
pub online: bool,
|
||||||
|
/// Why the DHP is not online, in one line. Without it "offline" is all the operator
|
||||||
|
/// gets, and a URL that a browser on the same PC opens fine leaves nothing to go on.
|
||||||
|
pub problem: Option<String>,
|
||||||
/// The site's CURRENT version.
|
/// The site's CURRENT version.
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
/// The DHP's Engine API, as this PC can reach it: where live mode forwards while
|
/// The DHP's Engine API, as this PC can reach it: where live mode forwards while
|
||||||
/// online. Absent when the DHP offers none, and this PC then serves its own data.
|
/// online. Absent when the DHP offers none, and this PC then serves its own data.
|
||||||
pub engine_url: Option<String>,
|
pub engine_url: Option<String>,
|
||||||
|
/// Every address the DHP offers for it, best first: the site's own network, then the
|
||||||
|
/// one that answers from outside. A PC cannot be told which applies to it - a laptop
|
||||||
|
/// is at the office in the morning and at home in the evening - so it tries them in
|
||||||
|
/// turn. Older DHPs name one address and this holds just that one.
|
||||||
|
pub engine_urls: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long one Engine API gets to answer before the next address is tried.
|
||||||
|
const ENGINE_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Does this Engine API answer? `/health` is its open route: no token, no data.
|
||||||
|
pub async fn engine_answers(http: &reqwest::Client, engine_url: &str) -> bool {
|
||||||
|
let url = format!("{}/health", engine_url.trim_end_matches('/'));
|
||||||
|
matches!(http.get(url).timeout(ENGINE_PROBE_TIMEOUT).send().await, Ok(answer) if answer.status().is_success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first of `urls` that answers, or None when this PC can reach none of them - which
|
||||||
|
/// is not an error: the local API then serves this PC's own data, as it does offline.
|
||||||
|
pub async fn first_engine(http: &reqwest::Client, urls: &[String]) -> Option<String> {
|
||||||
|
for url in urls {
|
||||||
|
if engine_answers(http, url).await {
|
||||||
|
return Some(url.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unreachable(url: &str, what: impl std::fmt::Display) -> Probe {
|
||||||
|
Probe { online: false, problem: Some(format!("{url}: {what}")), version: None, engine_url: None, engine_urls: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reason, short enough for one log line: reqwest nests its causes, and the one
|
||||||
|
/// that names the fault (a refused connection, an expired certificate, a bad name) is
|
||||||
|
/// the innermost.
|
||||||
|
fn cause(error: &reqwest::Error) -> String {
|
||||||
|
let mut source: &dyn std::error::Error = error;
|
||||||
|
let mut text = error.to_string();
|
||||||
|
while let Some(inner) = source.source() {
|
||||||
|
text = inner.to_string();
|
||||||
|
source = inner;
|
||||||
|
}
|
||||||
|
text
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
|
pub async fn probe(http: &reqwest::Client, site_url: &str) -> Probe {
|
||||||
let offline = Probe { online: false, version: None, engine_url: None };
|
|
||||||
if site_url.is_empty() {
|
if site_url.is_empty() {
|
||||||
return offline;
|
return Probe {
|
||||||
|
online: false,
|
||||||
|
problem: Some("no DHP address is set".to_string()),
|
||||||
|
version: None,
|
||||||
|
engine_url: None,
|
||||||
|
engine_urls: Vec::new(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let Ok(response) = http.get(format!("{site_url}/v1/health")).timeout(PROBE_TIMEOUT).send().await else {
|
let url = format!("{site_url}/v1/health");
|
||||||
return offline;
|
let response = match http.get(&url).timeout(PROBE_TIMEOUT).send().await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(quick) if quick.is_timeout() => match http.get(&url).timeout(SLOW_LINK_TIMEOUT).send().await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(slow) if slow.is_timeout() => {
|
||||||
|
return unreachable(&url, format!("no answer within {}s", SLOW_LINK_TIMEOUT.as_secs()))
|
||||||
|
}
|
||||||
|
Err(slow) => return unreachable(&url, cause(&slow)),
|
||||||
|
},
|
||||||
|
Err(quick) => return unreachable(&url, cause(&quick)),
|
||||||
};
|
};
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return offline;
|
return unreachable(&url, format!("answered {}", response.status()));
|
||||||
}
|
}
|
||||||
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
let body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||||
let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string);
|
let text = |key: &str| body.get(key).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string);
|
||||||
Probe { online: true, version: text("version"), engine_url: text("engine_api_url") }
|
let engine_url = text("engine_api_url");
|
||||||
|
let engine_urls = engine_urls_from(&body, engine_url.as_deref());
|
||||||
|
Probe { online: true, problem: None, version: text("version"), engine_url, engine_urls }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The addresses a health answer offers for the Engine API, in the order to try them.
|
||||||
|
/// A DHP from before the list names one, and it is the whole list then.
|
||||||
|
fn engine_urls_from(body: &serde_json::Value, single: Option<&str>) -> Vec<String> {
|
||||||
|
let listed: Vec<String> = body
|
||||||
|
.get("engine_api_urls")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|urls| urls.iter().filter_map(|u| u.as_str()).map(str::trim).filter(|u| !u.is_empty()).map(str::to_string).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if listed.is_empty() {
|
||||||
|
return single.filter(|u| !u.is_empty()).map(str::to_string).into_iter().collect();
|
||||||
|
}
|
||||||
|
listed
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum TokenCheck {
|
pub enum TokenCheck {
|
||||||
@@ -180,3 +264,32 @@ pub async fn notes(http: &reqwest::Client, site_url: &str, token: &str, version:
|
|||||||
s => Err(format!("the DHP answered HTTP {s}")),
|
s => Err(format!("the DHP answered HTTP {s}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_addresses_are_taken_in_the_order_the_dhp_gives_them() {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"engine_api_urls": ["http://192.168.1.41:30684", "https://engine-api.odian.fr"],
|
||||||
|
"engine_api_url": "https://engine-api.odian.fr"
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
engine_urls_from(&body, Some("https://engine-api.odian.fr")),
|
||||||
|
vec!["http://192.168.1.41:30684".to_string(), "https://engine-api.odian.fr".to_string()],
|
||||||
|
"the site's own network first, so an office PC does not go out and back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dhp_that_names_one_address_still_works() {
|
||||||
|
// Every DHP in the field before the list: one address, and it is the whole list.
|
||||||
|
let body = serde_json::json!({"ok": true, "engine_api_url": "http://192.168.1.41:30684"});
|
||||||
|
assert_eq!(engine_urls_from(&body, Some("http://192.168.1.41:30684")), vec!["http://192.168.1.41:30684".to_string()]);
|
||||||
|
// And a DHP offering none leaves the PC on its own data.
|
||||||
|
assert!(engine_urls_from(&serde_json::json!({"ok": true}), None).is_empty());
|
||||||
|
assert!(engine_urls_from(&serde_json::json!({"engine_api_urls": ["", " "]}), None).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,6 +151,32 @@ pub struct StackConfig {
|
|||||||
/// The DHP's Engine API (from its health answer). With one, the local API serves
|
/// The DHP's Engine API (from its health answer). With one, the local API serves
|
||||||
/// the DHP's data while it is reachable; without, this PC always serves its own.
|
/// the DHP's data while it is reachable; without, this PC always serves its own.
|
||||||
pub upstream_url: Option<String>,
|
pub upstream_url: Option<String>,
|
||||||
|
/// The MCP serving ODIAN's AI features, when the operator named one (Settings).
|
||||||
|
pub ai_url: String,
|
||||||
|
/// The DHP this PC belongs to, for the tile service it proxies. Empty when the
|
||||||
|
/// operator named none, and the tile server then serves this PC alone.
|
||||||
|
pub site_url: String,
|
||||||
|
/// Every set the version names. With a site, the client is offered all of them:
|
||||||
|
/// the site draws the ones this PC never downloaded. Without one it sees only
|
||||||
|
/// `tile_sets`, because nothing else could be drawn.
|
||||||
|
pub site_tile_sets: Vec<TileSet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StackConfig {
|
||||||
|
/// The sets to offer the client: this PC's own always - they draw with no DHP at
|
||||||
|
/// all - plus the version's rest when a site can draw those. Deduplicated by
|
||||||
|
/// style and dataset, this PC's entry first so its own extension wins.
|
||||||
|
fn offered(&self) -> Vec<TileSet> {
|
||||||
|
let mut out = self.tile_sets.clone();
|
||||||
|
if !self.site_url.is_empty() {
|
||||||
|
for set in &self.site_tile_sets {
|
||||||
|
if !out.iter().any(|have| have.style == set.style && have.dataset == set.dataset) {
|
||||||
|
out.push(set.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the launcher keeps the DHP token the local API forwards with. A file, not an
|
/// Where the launcher keeps the DHP token the local API forwards with. A file, not an
|
||||||
@@ -442,11 +468,25 @@ fn pg_ini(config: &StackConfig) -> String {
|
|||||||
/// How the tile server reaches this PC's database (a libpq connection string).
|
/// How the tile server reaches this PC's database (a libpq connection string).
|
||||||
const TILES_POSTGIS_DSN_ENV: &str = "ODIAN_TILE_POSTGIS_DSN";
|
const TILES_POSTGIS_DSN_ENV: &str = "ODIAN_TILE_POSTGIS_DSN";
|
||||||
|
|
||||||
/// The Theatre's POI layers -- hospitals, schools, places of worship, data centres. A site
|
/// Every overlay the tile server draws out of PostGIS. A site serves them from the
|
||||||
/// serves them from the extractor's SQLite files; this PC ships none of those, but holds the
|
/// extractor's SQLite files; this PC ships none of those, but the database snapshot carries
|
||||||
/// same tables in its own database, and the tile server reads them there instead.
|
/// the same tables, and the tile server reads them there instead.
|
||||||
const NEARBY_POSTGIS_SOURCES: [&str; 4] =
|
///
|
||||||
["hospital_postgis", "school_postgis", "place_of_worship_postgis", "datacenter_postgis"];
|
/// One key per layer, because that is how the server resolves a source: no key and no DSN
|
||||||
|
/// under ITS name (it looks for `ODIAN_OIL_POSTGIS_DSN` and the like, never the one name the
|
||||||
|
/// launcher sets) leaves the layer with nowhere to read, and it draws nothing at all. That is
|
||||||
|
/// what kept oil, gas, aeroways and military bases off an offline map while their tables sat
|
||||||
|
/// in the database unused - only the four Theatre layers below were ever named here.
|
||||||
|
const POSTGIS_SOURCES: [&str; 8] = [
|
||||||
|
"aeroway_postgis",
|
||||||
|
"datacenter_postgis",
|
||||||
|
"gas_postgis",
|
||||||
|
"hospital_postgis",
|
||||||
|
"military_base_postgis",
|
||||||
|
"oil_postgis",
|
||||||
|
"place_of_worship_postgis",
|
||||||
|
"school_postgis",
|
||||||
|
];
|
||||||
|
|
||||||
fn pg_dsn(config: &StackConfig) -> String {
|
fn pg_dsn(config: &StackConfig) -> String {
|
||||||
let quote = |value: &str| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"));
|
let quote = |value: &str| format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"));
|
||||||
@@ -472,7 +512,11 @@ fn media_type(ext: &str) -> &'static str {
|
|||||||
fn datasets(config: &StackConfig) -> serde_json::Value {
|
fn datasets(config: &StackConfig) -> serde_json::Value {
|
||||||
let tiles = config.layout.data_dir().join("tiles");
|
let tiles = config.layout.data_dir().join("tiles");
|
||||||
let mut doc = serde_json::Map::new();
|
let mut doc = serde_json::Map::new();
|
||||||
for set in &config.tile_sets {
|
// Every offered style needs its entry, or the client asks for the wrong file name
|
||||||
|
// (imagery is .jpg, a drawn basemap .png) and every tile of it is a 404 - including
|
||||||
|
// the ones the site would have answered. What this PC can bake is decided below, by
|
||||||
|
// the archives actually on disk, not by what is offered.
|
||||||
|
for set in &config.offered() {
|
||||||
let entry = doc.entry(set.dataset.clone()).or_insert_with(|| {
|
let entry = doc.entry(set.dataset.clone()).or_insert_with(|| {
|
||||||
let mut entry = serde_json::json!({ "cache_dir": text(tiles.clone()) });
|
let mut entry = serde_json::json!({ "cache_dir": text(tiles.clone()) });
|
||||||
match pmtiles_of(config, &set.dataset) {
|
match pmtiles_of(config, &set.dataset) {
|
||||||
@@ -481,7 +525,7 @@ fn datasets(config: &StackConfig) -> serde_json::Value {
|
|||||||
// Without the flag a baking server refuses every request for it, hits included.
|
// Without the flag a baking server refuses every request for it, hits included.
|
||||||
None => entry["cache_only"] = serde_json::Value::Bool(true),
|
None => entry["cache_only"] = serde_json::Value::Bool(true),
|
||||||
}
|
}
|
||||||
for key in NEARBY_POSTGIS_SOURCES {
|
for key in POSTGIS_SOURCES {
|
||||||
entry[key] = serde_json::json!({ "dsn_env": TILES_POSTGIS_DSN_ENV });
|
entry[key] = serde_json::json!({ "dsn_env": TILES_POSTGIS_DSN_ENV });
|
||||||
}
|
}
|
||||||
entry
|
entry
|
||||||
@@ -567,13 +611,20 @@ fn baker(config: &StackConfig) -> Option<PathBuf> {
|
|||||||
(any_archive && exe.is_file()).then_some(exe)
|
(any_archive && exe.is_file()).then_some(exe)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The site's tile service, behind the DHP: `/tileserver` of the DHP's own address.
|
||||||
|
/// The Data PC that runs it is not on the internet, so this is the only way to it,
|
||||||
|
/// and the DHP's token check covers it like every other route.
|
||||||
|
fn site_tiles_url(site_url: &str) -> String {
|
||||||
|
format!("{}/tileserver", site_url.trim_end_matches('/'))
|
||||||
|
}
|
||||||
|
|
||||||
fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
||||||
let tiles = config.layout.data_dir().join("tiles");
|
let tiles = config.layout.data_dir().join("tiles");
|
||||||
// With a baker the server draws any basemap tile the cache lacks -- the Theatre asks
|
// With a baker the server draws any basemap tile the cache lacks -- the Theatre asks
|
||||||
// for far more than was ever cached. Everything it cannot draw stays cache-only per
|
// for far more than was ever cached. Everything it cannot draw stays cache-only per
|
||||||
// dataset or style (see `datasets`), and an offline PC never reaches a provider.
|
// dataset or style (see `datasets`), and an offline PC never reaches a provider.
|
||||||
let baker = baker(config);
|
let baker = baker(config);
|
||||||
vec![
|
let mut env = vec![
|
||||||
("ODIAN_TILE_DATASETS", text(config.layout.state_dir().join("tiles").join("datasets.json"))),
|
("ODIAN_TILE_DATASETS", text(config.layout.state_dir().join("tiles").join("datasets.json"))),
|
||||||
("ODIAN_TILE_CACHE_ONLY", if baker.is_some() { "0" } else { "1" }.to_string()),
|
("ODIAN_TILE_CACHE_ONLY", if baker.is_some() { "0" } else { "1" }.to_string()),
|
||||||
("ODIAN_TILE_NO_UPSTREAM", "1".to_string()),
|
("ODIAN_TILE_NO_UPSTREAM", "1".to_string()),
|
||||||
@@ -585,7 +636,17 @@ fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
|||||||
("PYTHONDONTWRITEBYTECODE", "1".to_string()),
|
("PYTHONDONTWRITEBYTECODE", "1".to_string()),
|
||||||
("PYTHONNOUSERSITE", "1".to_string()),
|
("PYTHONNOUSERSITE", "1".to_string()),
|
||||||
("PYTHONUTF8", "1".to_string()),
|
("PYTHONUTF8", "1".to_string()),
|
||||||
]
|
];
|
||||||
|
// Where the map comes from while the DHP answers: the site draws from the whole
|
||||||
|
// archive, so a PC that installed none of the optional tiles or sources still has
|
||||||
|
// the full map, the overlays and the 3-D buildings. The moment the site cannot
|
||||||
|
// answer, the tile server serves what this PC holds - the same token file the
|
||||||
|
// local API forwards with, re-read whenever the launcher renews it.
|
||||||
|
if !config.site_url.is_empty() {
|
||||||
|
env.push(("ODIAN_TILE_SITE_URL", site_tiles_url(&config.site_url)));
|
||||||
|
env.push(("ODIAN_TILE_SITE_TOKEN_FILE", text(upstream_token_file(&config.layout))));
|
||||||
|
}
|
||||||
|
env
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
||||||
@@ -595,7 +656,6 @@ fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
|||||||
("ODIAN_ENGINE_API_URL", format!("http://127.0.0.1:{API_PORT}")),
|
("ODIAN_ENGINE_API_URL", format!("http://127.0.0.1:{API_PORT}")),
|
||||||
("ODIAN_LAUNCH_USER", config.operator.clone()),
|
("ODIAN_LAUNCH_USER", config.operator.clone()),
|
||||||
("ODIAN_MINT_SECRET", config.mint_secret.clone()),
|
("ODIAN_MINT_SECRET", config.mint_secret.clone()),
|
||||||
("ODIAN_OFFLINE", "1".to_string()),
|
|
||||||
("ODIAN_TILE_SERVER_URL", tiles_url.clone()),
|
("ODIAN_TILE_SERVER_URL", tiles_url.clone()),
|
||||||
("ODIAN_TILE_PROVIDER", "odian".to_string()),
|
("ODIAN_TILE_PROVIDER", "odian".to_string()),
|
||||||
("ODIAN_TILE_REQUEST_MODE", "xyz".to_string()),
|
("ODIAN_TILE_REQUEST_MODE", "xyz".to_string()),
|
||||||
@@ -606,6 +666,16 @@ fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
|||||||
("ODIAN_LIDAR_ENABLED", "0".to_string()),
|
("ODIAN_LIDAR_ENABLED", "0".to_string()),
|
||||||
("ODIAN_HEIGHT_CACHE_DIR", text(client.join("heightmap"))),
|
("ODIAN_HEIGHT_CACHE_DIR", text(client.join("heightmap"))),
|
||||||
];
|
];
|
||||||
|
// ODIAN_OFFLINE is what tells the client it has no line out: it blanks the MCP address
|
||||||
|
// whatever the environment holds, so the AI features answer "not available offline" instead
|
||||||
|
// of hanging on a call that cannot arrive. An operator who names an AI address has a line
|
||||||
|
// out and wants those features, so the flag goes and that address is passed. Everything
|
||||||
|
// else still points at this PC: the API, the tiles and the map all stay local.
|
||||||
|
if config.ai_url.is_empty() {
|
||||||
|
env.push(("ODIAN_OFFLINE", "1".to_string()));
|
||||||
|
} else {
|
||||||
|
env.push(("ODIAN_MCP_API_URL", config.ai_url.clone()));
|
||||||
|
}
|
||||||
let basemap = config
|
let basemap = config
|
||||||
.tile_sets
|
.tile_sets
|
||||||
.iter()
|
.iter()
|
||||||
@@ -618,11 +688,15 @@ fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
|||||||
// Every style this install carries, so the client's basemap picker offers exactly
|
// Every style this install carries, so the client's basemap picker offers exactly
|
||||||
// what this PC can draw offline. Without it the client falls back to a fixed list
|
// what this PC can draw offline. Without it the client falls back to a fixed list
|
||||||
// of three, which is what hid the satellite and orthophoto maps 0.54.0 ships.
|
// of three, which is what hid the satellite and orthophoto maps 0.54.0 ships.
|
||||||
let styles: Vec<&str> = config.tile_sets.iter().map(|set| set.style.as_str()).collect();
|
let offered = config.offered();
|
||||||
|
let styles: Vec<&str> = offered.iter().map(|set| set.style.as_str()).collect();
|
||||||
if !styles.is_empty() {
|
if !styles.is_empty() {
|
||||||
env.push(("ODIAN_TILE_STYLES", styles.join(",")));
|
env.push(("ODIAN_TILE_STYLES", styles.join(",")));
|
||||||
}
|
}
|
||||||
match config.tile_sets.iter().find(|set| set.style == "heightmap") {
|
// Heights follow the same rule as the styles above: a PC that never downloaded the
|
||||||
|
// terrain set still gets elevation while its site can draw it, because the height
|
||||||
|
// server it is pointed at is this PC's own tile server, which asks the site first.
|
||||||
|
match offered.iter().find(|set| set.style == "heightmap") {
|
||||||
Some(set) => {
|
Some(set) => {
|
||||||
env.push(("ODIAN_HEIGHT_SERVER_URL", tiles_url));
|
env.push(("ODIAN_HEIGHT_SERVER_URL", tiles_url));
|
||||||
env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string()));
|
env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string()));
|
||||||
@@ -656,7 +730,10 @@ pub fn installed_db(layout: &Layout) -> Option<odian_packs::manifest::DbSection>
|
|||||||
/// The tile sets the installed version's manifest names.
|
/// The tile sets the installed version's manifest names.
|
||||||
/// The version's tile sets this PC has (a marketplace set the operator did not choose has no
|
/// The version's tile sets this PC has (a marketplace set the operator did not choose has no
|
||||||
/// folder): the client is offered, and the tile server serves, only those.
|
/// folder): the client is offered, and the tile server serves, only those.
|
||||||
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
|
/// Every tile set the installed version names, whether or not this PC downloaded it.
|
||||||
|
/// With a DHP these are all drawable - the site has them - so they are what the client
|
||||||
|
/// is offered; `installed_tile_sets` is the subset this PC can draw on its own.
|
||||||
|
pub fn version_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
|
||||||
let path = layout.version_dir().join("manifest.json");
|
let path = layout.version_dir().join("manifest.json");
|
||||||
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
|
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
|
||||||
let manifest: serde_json::Value =
|
let manifest: serde_json::Value =
|
||||||
@@ -673,12 +750,22 @@ pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
|
|||||||
ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(),
|
ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.filter(|set| layout.data_dir().join("tiles").join(&set.style).join(&set.dataset).is_dir())
|
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sets whose tiles are on this PC: what it can draw with no DHP at all.
|
||||||
|
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
|
||||||
|
let here = layout.data_dir().join("tiles");
|
||||||
|
Ok(
|
||||||
|
version_tile_sets(layout)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|set| here.join(&set.style).join(&set.dataset).is_dir())
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
@@ -703,6 +790,12 @@ mod tests {
|
|||||||
.map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() })
|
.map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() })
|
||||||
.collect(),
|
.collect(),
|
||||||
upstream_url: None,
|
upstream_url: None,
|
||||||
|
ai_url: String::new(),
|
||||||
|
site_url: "https://dhp.example/dhp".to_string(),
|
||||||
|
site_tile_sets: tile_sets
|
||||||
|
.iter()
|
||||||
|
.map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() })
|
||||||
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,6 +838,20 @@ mod tests {
|
|||||||
assert!(value(&env, "ODIAN_MCP_API_URL").is_none(), "an offline client must not know the MCP");
|
assert!(value(&env, "ODIAN_MCP_API_URL").is_none(), "an offline client must not know the MCP");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ai_address_is_the_one_thing_the_client_may_reach() {
|
||||||
|
let mut config = config(&[("dark", "protomaps_20260423")]);
|
||||||
|
config.ai_url = "https://engine.odian.fr".to_string();
|
||||||
|
let env = client_env(&config);
|
||||||
|
assert_eq!(value(&env, "ODIAN_MCP_API_URL"), Some("https://engine.odian.fr"));
|
||||||
|
// The client blanks the MCP address while this flag is set, so the AI would stay dark.
|
||||||
|
assert!(value(&env, "ODIAN_OFFLINE").is_none(), "the client is told it has a line out");
|
||||||
|
// And nothing else changes: its data, its tiles and its map are still this PC's.
|
||||||
|
assert_eq!(value(&env, "ODIAN_ENGINE_API_URL"), Some("http://127.0.0.1:18080"));
|
||||||
|
assert_eq!(value(&env, "ODIAN_TILE_SERVER_URL"), Some("http://127.0.0.1:18081"));
|
||||||
|
assert_eq!(value(&env, "ODIAN_LIDAR_ENABLED"), Some("0"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_client_is_told_every_style_this_install_carries() {
|
fn the_client_is_told_every_style_this_install_carries() {
|
||||||
// The picker offers what the PC holds. Naming only the default basemap is what
|
// The picker offers what the PC holds. Naming only the default basemap is what
|
||||||
@@ -808,11 +915,71 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_theatre_pois_are_read_from_this_pcs_database() {
|
fn a_pc_that_downloaded_little_is_still_offered_the_sites_maps() {
|
||||||
|
// What this install actually holds: the one set every install gets.
|
||||||
|
let mut cfg = config(&[]);
|
||||||
|
cfg.tile_sets =
|
||||||
|
vec![TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string() }];
|
||||||
|
// What the version names, and the site can therefore draw.
|
||||||
|
cfg.site_tile_sets = vec![
|
||||||
|
TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string() },
|
||||||
|
TileSet { style: "satellite_hd".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".jpg".to_string() },
|
||||||
|
TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: ".lerc".to_string() },
|
||||||
|
];
|
||||||
|
|
||||||
|
let env = client_env(&cfg);
|
||||||
|
assert_eq!(
|
||||||
|
value(&env, "ODIAN_TILE_STYLES"),
|
||||||
|
Some("dark,satellite_hd,heightmap"),
|
||||||
|
"the picker offers what the site can draw, not only what was downloaded"
|
||||||
|
);
|
||||||
|
// The default basemap stays one this PC holds, so a start with no DHP still draws.
|
||||||
|
assert_eq!(value(&env, "ODIAN_TILE_STYLE"), Some("dark"));
|
||||||
|
|
||||||
|
// Terrain too: the set is not on this PC, but the site draws it, so the client is
|
||||||
|
// pointed at this PC's tile server rather than told there is no elevation at all.
|
||||||
|
assert_eq!(value(&env, "ODIAN_HEIGHT_DATASET"), Some("planet_heightmap"));
|
||||||
|
assert!(value(&env, "ODIAN_HEIGHT_ENABLED").is_none());
|
||||||
|
|
||||||
|
// And each offered style is described, or the client asks for the wrong file name.
|
||||||
|
let doc = datasets(&cfg);
|
||||||
|
assert_eq!(doc["protomaps_20260423"]["styles"]["satellite_hd"]["tile_ext"], ".jpg");
|
||||||
|
assert_eq!(doc["planet_heightmap"]["styles"]["heightmap"]["tile_ext"], ".lerc");
|
||||||
|
|
||||||
|
// With no DHP, only what this PC can draw is offered: the rest would be a blank map.
|
||||||
|
cfg.site_url = String::new();
|
||||||
|
assert_eq!(value(&client_env(&cfg), "ODIAN_TILE_STYLES"), Some("dark"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tile_server_is_told_where_its_site_is() {
|
||||||
|
let mut cfg = config(&[("dark", "protomaps_20260423")]);
|
||||||
|
let env = tiles_env(&cfg);
|
||||||
|
// The DHP proxies the Data PC's tile service; that path is the only way to it.
|
||||||
|
assert_eq!(value(&env, "ODIAN_TILE_SITE_URL"), Some("https://dhp.example/dhp/tileserver"));
|
||||||
|
// The same token the local API forwards with, re-read by the tile server on change.
|
||||||
|
assert_eq!(
|
||||||
|
value(&env, "ODIAN_TILE_SITE_TOKEN_FILE"),
|
||||||
|
Some(text(upstream_token_file(&cfg.layout)).as_str())
|
||||||
|
);
|
||||||
|
// No DHP: the tile server serves this PC alone, exactly as before.
|
||||||
|
cfg.site_url = String::new();
|
||||||
|
let env = tiles_env(&cfg);
|
||||||
|
assert!(value(&env, "ODIAN_TILE_SITE_URL").is_none());
|
||||||
|
assert!(value(&env, "ODIAN_TILE_SITE_TOKEN_FILE").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_postgis_overlay_is_read_from_this_pcs_database() {
|
||||||
let mut cfg = config(&[("dark", "protomaps_20260423"), ("heightmap", "planet_heightmap")]);
|
let mut cfg = config(&[("dark", "protomaps_20260423"), ("heightmap", "planet_heightmap")]);
|
||||||
let doc = datasets(&cfg);
|
let doc = datasets(&cfg);
|
||||||
for dataset in ["protomaps_20260423", "planet_heightmap"] {
|
for dataset in ["protomaps_20260423", "planet_heightmap"] {
|
||||||
for key in NEARBY_POSTGIS_SOURCES {
|
for key in POSTGIS_SOURCES {
|
||||||
|
assert_eq!(doc[dataset][key]["dsn_env"], TILES_POSTGIS_DSN_ENV, "{dataset} {key}");
|
||||||
|
}
|
||||||
|
// The ones that were missing, named outright: they are why an offline map had
|
||||||
|
// hospitals and schools but no oil, gas, aeroways or military bases.
|
||||||
|
for key in ["oil_postgis", "gas_postgis", "aeroway_postgis", "military_base_postgis"] {
|
||||||
assert_eq!(doc[dataset][key]["dsn_env"], TILES_POSTGIS_DSN_ENV, "{dataset} {key}");
|
assert_eq!(doc[dataset][key]["dsn_env"], TILES_POSTGIS_DSN_ENV, "{dataset} {key}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "ODIAN Launcher",
|
"productName": "ODIAN Launcher",
|
||||||
"version": "0.1.3",
|
"version": "0.1.12",
|
||||||
"identifier": "fr.odian.launcher",
|
"identifier": "fr.odian.launcher",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -41,7 +41,12 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
],
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installerIcon": "icons/icon.ico"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
|
|||||||
|
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>
|
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
|
||||||
{:else if action === "stack"}
|
{:else if action === "stack"}
|
||||||
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
|
<StackList stack={launcher.stack} phase={s?.phase ?? "launching"} />
|
||||||
|
{#if s?.map_from_site === false}
|
||||||
|
<!-- The map is this PC's own. Said plainly, because it looks like nothing is
|
||||||
|
wrong: tiles still draw, they are simply the ones this PC downloaded. -->
|
||||||
|
<div class="notice" role="status">
|
||||||
|
<Icon name="alert" size={14} />
|
||||||
|
<p class="selectable">
|
||||||
|
{site}'s map service is unreachable: drawing from this PC's own tiles.
|
||||||
|
{#if s?.map_problem}<span class="mono dim"> ({s.map_problem})</span>{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
|
<button class="btn btn-danger wide" disabled={launcher.stopping || busy !== null} onclick={() => launcher.stop()}>
|
||||||
<Icon name="stop" size={12} />
|
<Icon name="stop" size={12} />
|
||||||
{launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"}
|
{launcher.stopping ? "Stopping…" : s?.phase === "running" ? "Stop ODIAN" : "Stop"}
|
||||||
@@ -509,6 +520,10 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.error-box p,
|
.error-box p,
|
||||||
|
.notice .dim {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.notice p {
|
.notice p {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: var(--ink-2);
|
color: var(--ink-2);
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
|
<!-- No wordmark here: the key art behind this panel carries ODIAN's own, and two
|
||||||
|
of them read as a mistake. -->
|
||||||
<p class="eyebrow tagline">Tactical map console</p>
|
<p class="eyebrow tagline">Tactical map console</p>
|
||||||
<h1 class="wordmark">ODIAN</h1>
|
|
||||||
{#if status}
|
{#if status}
|
||||||
<p class="meta mono">
|
<p class="meta mono">
|
||||||
<span>{status.installed ? `v${status.installed}` : "Not installed"}</span>
|
<span>{status.installed ? `v${status.installed}` : "Not installed"}</span>
|
||||||
@@ -40,17 +41,6 @@
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wordmark {
|
|
||||||
margin: 0 calc(var(--wordmark-tracking) * -1) 0 0;
|
|
||||||
color: var(--ink);
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: clamp(52px, 5.4vw, 72px);
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: var(--wordmark-tracking);
|
|
||||||
text-shadow: 0 0 48px rgba(91, 140, 255, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,252 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Original key art, generated here: an orthographic wireframe globe with a
|
// The launcher's backdrop: ODIAN's own key art, the picture the product is
|
||||||
// bearing ring, a slow orbit, a few track markers and faint topographic
|
// presented with. It replaces the globe this file used to draw in SVG.
|
||||||
// contours. Static except for one orbiting point (off with reduced motion).
|
//
|
||||||
import { prefersReducedMotion } from "svelte/motion";
|
// It starts where the news panel ends, so the wordmark is never half-hidden
|
||||||
|
// behind it and the panel's side of the window stays plain black.
|
||||||
const W = 1280;
|
import keyart from "../assets/keyart.webp";
|
||||||
const H = 760;
|
|
||||||
const CX = 930;
|
|
||||||
const CY = 392;
|
|
||||||
const R = 288;
|
|
||||||
const TILT = rad(24); // view latitude
|
|
||||||
const SPIN = rad(-20); // view longitude
|
|
||||||
|
|
||||||
function rad(deg: number) {
|
|
||||||
return (deg * Math.PI) / 180;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Pt {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
visible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function project(latDeg: number, lonDeg: number): Pt {
|
|
||||||
const phi = rad(latDeg);
|
|
||||||
const lambda = rad(lonDeg) - SPIN;
|
|
||||||
const cosPhi = Math.cos(phi);
|
|
||||||
const x = R * cosPhi * Math.sin(lambda);
|
|
||||||
const y = R * (Math.cos(TILT) * Math.sin(phi) - Math.sin(TILT) * cosPhi * Math.cos(lambda));
|
|
||||||
const cosC = Math.sin(TILT) * Math.sin(phi) + Math.cos(TILT) * cosPhi * Math.cos(lambda);
|
|
||||||
return { x: CX + x, y: CY - y, visible: cosC >= 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmt = (n: number) => n.toFixed(1);
|
|
||||||
|
|
||||||
/** Splits a projected polyline into its near-side and far-side runs. */
|
|
||||||
function trace(points: Pt[]) {
|
|
||||||
let front = "";
|
|
||||||
let back = "";
|
|
||||||
let prev: boolean | null = null;
|
|
||||||
for (const p of points) {
|
|
||||||
const seg = `${prev === p.visible ? "L" : "M"}${fmt(p.x)} ${fmt(p.y)}`;
|
|
||||||
if (p.visible) front += seg;
|
|
||||||
else back += seg;
|
|
||||||
prev = p.visible;
|
|
||||||
}
|
|
||||||
return { front, back };
|
|
||||||
}
|
|
||||||
|
|
||||||
function range(from: number, to: number, step: number) {
|
|
||||||
const out: number[] = [];
|
|
||||||
for (let v = from; v <= to + 1e-9; v += step) out.push(v);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
...range(-180, 165, 15).map((lon) => trace(range(-90, 90, 2).map((lat) => project(lat, lon)))),
|
|
||||||
...range(-75, 75, 15).map((lat) => trace(range(-180, 180, 2).map((lon) => project(lat, lon)))),
|
|
||||||
];
|
|
||||||
const gridFront = lines.map((l) => l.front).join("");
|
|
||||||
const gridBack = lines.map((l) => l.back).join("");
|
|
||||||
const equator = trace(range(-180, 180, 2).map((lon) => project(0, lon))).front;
|
|
||||||
|
|
||||||
// Bearing ring around the globe.
|
|
||||||
const RING = R + 26;
|
|
||||||
const ticks = range(0, 355, 5)
|
|
||||||
.map((deg) => {
|
|
||||||
const a = rad(deg - 90);
|
|
||||||
const len = deg % 30 === 0 ? 9 : deg % 10 === 0 ? 5 : 2.5;
|
|
||||||
const c = Math.cos(a);
|
|
||||||
const s = Math.sin(a);
|
|
||||||
return `M${fmt(CX + c * RING)} ${fmt(CY + s * RING)}L${fmt(CX + c * (RING + len))} ${fmt(CY + s * (RING + len))}`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
// Labels in the upper-left quadrant would sit under the hero wordmark.
|
|
||||||
const ringLabels = range(0, 330, 30)
|
|
||||||
.filter((deg) => deg < 285 || deg > 345)
|
|
||||||
.map((deg) => {
|
|
||||||
const a = rad(deg - 90);
|
|
||||||
return { x: CX + Math.cos(a) * (RING + 24), y: CY + Math.sin(a) * (RING + 24), text: String(deg).padStart(3, "0") };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track markers joined by great-circle legs.
|
|
||||||
type Vec = [number, number, number];
|
|
||||||
const toVec = (lat: number, lon: number): Vec => [
|
|
||||||
Math.cos(rad(lat)) * Math.cos(rad(lon)),
|
|
||||||
Math.cos(rad(lat)) * Math.sin(rad(lon)),
|
|
||||||
Math.sin(rad(lat)),
|
|
||||||
];
|
|
||||||
function leg(a: [number, number], b: [number, number]) {
|
|
||||||
const va = toVec(...a);
|
|
||||||
const vb = toVec(...b);
|
|
||||||
const omega = Math.acos(Math.min(1, va[0] * vb[0] + va[1] * vb[1] + va[2] * vb[2]));
|
|
||||||
const pts: Pt[] = [];
|
|
||||||
for (let i = 0; i <= 48; i++) {
|
|
||||||
const t = i / 48;
|
|
||||||
const ka = Math.sin((1 - t) * omega) / Math.sin(omega);
|
|
||||||
const kb = Math.sin(t * omega) / Math.sin(omega);
|
|
||||||
const v = [0, 1, 2].map((k) => ka * va[k] + kb * vb[k]);
|
|
||||||
const lat = (Math.asin(v[2]) * 180) / Math.PI;
|
|
||||||
const lon = (Math.atan2(v[1], v[0]) * 180) / Math.PI;
|
|
||||||
pts.push(project(lat, lon));
|
|
||||||
}
|
|
||||||
return trace(pts).front;
|
|
||||||
}
|
|
||||||
|
|
||||||
const waypoints: { lat: number; lon: number; label: string }[] = [
|
|
||||||
{ lat: 14, lon: -36, label: "WPT K7" },
|
|
||||||
{ lat: 33, lon: -12, label: "TRK 1187" },
|
|
||||||
{ lat: 47, lon: 5, label: "TRK 0412" },
|
|
||||||
];
|
|
||||||
const markers = waypoints.map((w) => ({ ...w, ...project(w.lat, w.lon) })).filter((m) => m.visible);
|
|
||||||
const route =
|
|
||||||
leg([waypoints[0].lat, waypoints[0].lon], [waypoints[1].lat, waypoints[1].lon]) +
|
|
||||||
leg([waypoints[1].lat, waypoints[1].lon], [waypoints[2].lat, waypoints[2].lon]);
|
|
||||||
|
|
||||||
// Orbit: an inclined ellipse; the far half is hidden behind the globe.
|
|
||||||
const ORBIT_TILT = -14;
|
|
||||||
const RX = R * 1.42;
|
|
||||||
const RY = R * 0.34;
|
|
||||||
const orbitBack = `M${fmt(CX - RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX + RX)} ${CY}`;
|
|
||||||
const orbitFront = `M${fmt(CX + RX)} ${CY}A${fmt(RX)} ${fmt(RY)} 0 0 1 ${fmt(CX - RX)} ${CY}`;
|
|
||||||
const orbitPath = `${orbitBack}${orbitFront.replace(/^M[^A]+/, "")}`;
|
|
||||||
const staticSat = { x: CX + RX * Math.cos(rad(62)), y: CY + RY * Math.sin(rad(62)) };
|
|
||||||
|
|
||||||
// Topographic contours: nested wobbly rings.
|
|
||||||
function contours(cx: number, cy: number, count: number, seed: number, sx: number, sy: number) {
|
|
||||||
return range(0, count - 1, 1).map((i) => {
|
|
||||||
const pts = range(0, 120, 1).map((k) => {
|
|
||||||
const th = (k / 120) * Math.PI * 2;
|
|
||||||
const r =
|
|
||||||
28 +
|
|
||||||
i * 26 +
|
|
||||||
(10 + i * 1.5) * Math.sin(3 * th + seed + i * 0.35) +
|
|
||||||
(6 + i) * Math.sin(5 * th - seed * 1.7 + i * 0.2) +
|
|
||||||
4 * Math.sin(2 * th + i * 0.5);
|
|
||||||
return `${fmt(cx + r * Math.cos(th) * sx)} ${fmt(cy + r * Math.sin(th) * sy)}`;
|
|
||||||
});
|
|
||||||
return `M${pts.join("L")}Z`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const topo = [...contours(520, 780, 12, 1.3, 1.35, 0.72), ...contours(1270, -10, 8, 4.1, 1.1, 0.9)];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="keyart" aria-hidden="true">
|
<div class="keyart" aria-hidden="true">
|
||||||
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
|
<div class="frame">
|
||||||
<defs>
|
<img class="art" src={keyart} alt="" draggable="false" />
|
||||||
<radialGradient id="ka-glow" cx={CX} cy={CY} r={R * 1.9} gradientUnits="userSpaceOnUse">
|
</div>
|
||||||
<stop offset="0" stop-color="#5b8cff" stop-opacity="0.2" />
|
|
||||||
<stop offset="0.42" stop-color="#5b8cff" stop-opacity="0.06" />
|
|
||||||
<stop offset="1" stop-color="#5b8cff" stop-opacity="0" />
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="ka-sphere" cx={CX - R * 0.38} cy={CY - R * 0.42} r={R * 1.55} gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#172241" />
|
|
||||||
<stop offset="0.55" stop-color="#0d1019" />
|
|
||||||
<stop offset="1" stop-color="#09090b" />
|
|
||||||
</radialGradient>
|
|
||||||
<radialGradient id="ka-fade" cx="0.72" cy="0.5" r="0.62">
|
|
||||||
<stop offset="0" stop-color="#fff" stop-opacity="1" />
|
|
||||||
<stop offset="1" stop-color="#fff" stop-opacity="0" />
|
|
||||||
</radialGradient>
|
|
||||||
<pattern id="ka-dots" width="24" height="24" patternUnits="userSpaceOnUse">
|
|
||||||
<rect width="1" height="1" fill="#4a4a52" />
|
|
||||||
</pattern>
|
|
||||||
<mask id="ka-dots-mask">
|
|
||||||
<rect width={W} height={H} fill="url(#ka-fade)" />
|
|
||||||
</mask>
|
|
||||||
<mask id="ka-behind" maskUnits="userSpaceOnUse" x="-600" y="-600" width={W + 1200} height={H + 1200}>
|
|
||||||
<rect x="-600" y="-600" width={W + 1200} height={H + 1200} fill="#fff" />
|
|
||||||
<circle cx={CX} cy={CY} r={R} fill="#000" />
|
|
||||||
</mask>
|
|
||||||
<path id="ka-orbit" d={orbitPath} />
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width={W} height={H} fill="#09090b" />
|
|
||||||
<rect width={W} height={H} fill="url(#ka-dots)" mask="url(#ka-dots-mask)" />
|
|
||||||
|
|
||||||
<g fill="none" stroke="#9a999e" stroke-opacity="0.075">
|
|
||||||
{#each topo as d, i (i)}
|
|
||||||
<path {d} />
|
|
||||||
{/each}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<circle cx={CX} cy={CY} r={R * 1.9} fill="url(#ka-glow)" />
|
|
||||||
|
|
||||||
<g stroke="#9a999e" fill="none">
|
|
||||||
<circle cx={CX} cy={CY} r={RING} stroke-opacity="0.12" />
|
|
||||||
<path d={ticks} stroke-opacity="0.22" />
|
|
||||||
</g>
|
|
||||||
<g class="ring-labels" fill="#9a999e" fill-opacity="0.34">
|
|
||||||
{#each ringLabels as l (l.text)}
|
|
||||||
<text x={fmt(l.x)} y={fmt(l.y)} text-anchor="middle" dominant-baseline="central">{l.text}</text>
|
|
||||||
{/each}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
|
|
||||||
<path d={orbitBack} stroke-opacity="0.22" stroke-dasharray="2 6" mask="url(#ka-behind)" />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<circle cx={CX} cy={CY} r={R} fill="url(#ka-sphere)" />
|
|
||||||
<path d={gridBack} fill="none" stroke="#5b8cff" stroke-opacity="0.06" />
|
|
||||||
<path d={gridFront} fill="none" stroke="#7aa2ff" stroke-opacity="0.2" stroke-width="0.9" />
|
|
||||||
<path d={equator} fill="none" stroke="#7aa2ff" stroke-opacity="0.38" />
|
|
||||||
<circle cx={CX} cy={CY} r={R} fill="none" stroke="#5b8cff" stroke-opacity="0.55" />
|
|
||||||
<circle cx={CX} cy={CY} r={R + 5} fill="none" stroke="#5b8cff" stroke-opacity="0.12" />
|
|
||||||
|
|
||||||
<path d={route} fill="none" stroke="#5b8cff" stroke-opacity="0.75" stroke-dasharray="5 4" />
|
|
||||||
<g class="markers">
|
|
||||||
{#each markers as m (m.label)}
|
|
||||||
<g transform="translate({fmt(m.x)} {fmt(m.y)})">
|
|
||||||
<rect x="-4.5" y="-4.5" width="9" height="9" fill="#09090b" stroke="#a9c1ff" stroke-opacity="0.9" />
|
|
||||||
<rect x="-1" y="-1" width="2" height="2" fill="#d6e2ff" />
|
|
||||||
<path d="M5 -5L16 -16H64" fill="none" stroke="#9a999e" stroke-opacity="0.4" />
|
|
||||||
<text x="20" y="-21" fill="#d4d3d0" fill-opacity="0.62">{m.label}</text>
|
|
||||||
</g>
|
|
||||||
{/each}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g transform="rotate({ORBIT_TILT} {CX} {CY})" fill="none" stroke="#7aa2ff">
|
|
||||||
<path d={orbitFront} stroke-opacity="0.34" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Separate layer so the static art above never repaints. -->
|
|
||||||
<svg class="layer" viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid slice">
|
|
||||||
<g transform="rotate({ORBIT_TILT} {CX} {CY})">
|
|
||||||
{#if prefersReducedMotion.current}
|
|
||||||
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="7" fill="#5b8cff" fill-opacity="0.18" />
|
|
||||||
<circle cx={fmt(staticSat.x)} cy={fmt(staticSat.y)} r="2.5" fill="#d6e2ff" />
|
|
||||||
{:else}
|
|
||||||
<g mask="url(#ka-behind)">
|
|
||||||
<g>
|
|
||||||
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
|
|
||||||
<circle r="2.5" fill="#d6e2ff" />
|
|
||||||
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
|
|
||||||
<animate attributeName="opacity" values="1;0" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g opacity="0">
|
|
||||||
<circle r="7" fill="#5b8cff" fill-opacity="0.18" />
|
|
||||||
<circle r="2.5" fill="#d6e2ff" />
|
|
||||||
<animateMotion dur="96s" repeatCount="indefinite"><mpath href="#ka-orbit" /></animateMotion>
|
|
||||||
<animate attributeName="opacity" values="0;1" keyTimes="0;0.5" calcMode="discrete" dur="96s" repeatCount="indefinite" />
|
|
||||||
</g>
|
|
||||||
{/if}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="vignette"></div>
|
<div class="vignette"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,27 +24,48 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layer {
|
/* `left` is where the hub's first column ends: its padding plus the column's
|
||||||
|
width, kept in step with App.svelte's grid (and its 1140px breakpoint). The
|
||||||
|
frame is what crops the picture - an <img> left to itself takes its own
|
||||||
|
aspect ratio, whatever the box says, and `object-position` then has nothing
|
||||||
|
to slide. The left edge fades in, so the black beside it is a backdrop
|
||||||
|
rather than a seam. */
|
||||||
|
.frame {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 432px;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 4%);
|
||||||
|
mask-image: linear-gradient(90deg, transparent 0%, #000 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cover, then slide: this picks the whole wordmark and the near half of the
|
||||||
|
globe, with the mark starting just clear of the panel. */
|
||||||
|
.art {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: 50% center;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring-labels,
|
/* Only the edges, where the title bar and the status bar sit: the wordmark and
|
||||||
.markers {
|
the globe are the point, and a left-hand wash would swallow them. */
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vignette {
|
.vignette {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(9, 9, 11, 0.9) 0%, rgba(9, 9, 11, 0.5) 30%, rgba(9, 9, 11, 0) 52%),
|
linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 28%),
|
||||||
linear-gradient(0deg, rgba(9, 9, 11, 0.85) 0%, rgba(9, 9, 11, 0) 32%),
|
linear-gradient(180deg, rgba(9, 9, 11, 0.6) 0%, rgba(9, 9, 11, 0) 16%),
|
||||||
linear-gradient(180deg, rgba(9, 9, 11, 0.55) 0%, rgba(9, 9, 11, 0) 18%),
|
radial-gradient(120% 100% at 62% 50%, transparent 58%, rgba(9, 9, 11, 0.5) 100%);
|
||||||
radial-gradient(130% 100% at 72% 50%, transparent 55%, rgba(9, 9, 11, 0.65) 100%);
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1140px) {
|
||||||
|
.frame {
|
||||||
|
left: 384px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -105,10 +105,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while the DHP would draw this item itself, so the PC lacking it shows nothing. */
|
||||||
|
const drawnBySite = (item: MarketItem) =>
|
||||||
|
item.presence === "absent" && drive === null && (s?.online ?? false);
|
||||||
|
|
||||||
function presenceLabel(item: MarketItem): string {
|
function presenceLabel(item: MarketItem): string {
|
||||||
if (item.presence === "installed") return "Installed";
|
if (item.presence === "installed") return "Installed";
|
||||||
if (item.presence === "partial") return "Partly downloaded";
|
if (item.presence === "partial") return "Partly downloaded";
|
||||||
return "Not installed";
|
// "Not installed" would read as missing when nothing is missing: while the DHP
|
||||||
|
// answers it draws this itself. What this PC lacks is the copy that works offline.
|
||||||
|
return drawnBySite(item) ? "On the DHP" : "Not installed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function presenceHelp(item: MarketItem): string | undefined {
|
||||||
|
if (item.presence !== "absent" || drive !== null) return undefined;
|
||||||
|
return drawnBySite(item)
|
||||||
|
? "Your DHP draws this while you are connected. Download it to keep it when you are not."
|
||||||
|
: "Not on this PC, and your DHP is out of reach.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeNote(item: MarketItem): string | null {
|
function changeNote(item: MarketItem): string | null {
|
||||||
@@ -246,7 +259,9 @@
|
|||||||
</span>
|
</span>
|
||||||
{#if item.description}<span class="desc">{item.description}</span>{/if}
|
{#if item.description}<span class="desc">{item.description}</span>{/if}
|
||||||
<span class="meta">
|
<span class="meta">
|
||||||
<span class="pill {item.presence}">{presenceLabel(item)}</span>
|
<span class="pill {item.presence}" class:from-site={drawnBySite(item)} title={presenceHelp(item)}>
|
||||||
|
{presenceLabel(item)}
|
||||||
|
</span>
|
||||||
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
|
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -532,6 +547,11 @@
|
|||||||
background: var(--warn-soft);
|
background: var(--warn-soft);
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
|
/* Drawn by the DHP: a working state, not a warning. */
|
||||||
|
.pill.from-site {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
.change {
|
.change {
|
||||||
color: var(--accent-hover);
|
color: var(--accent-hover);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
let { open = $bindable(false) }: Props = $props();
|
let { open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
const EMPTY: Settings = { site_url: "", mcp_url: "", install_dir: "" };
|
const EMPTY: Settings = { site_url: "", mcp_url: "", ai_url: "", install_dir: "" };
|
||||||
|
|
||||||
let dialog: HTMLDialogElement | undefined = $state();
|
let dialog: HTMLDialogElement | undefined = $state();
|
||||||
let draft = $state<Settings>({ ...EMPTY });
|
let draft = $state<Settings>({ ...EMPTY });
|
||||||
@@ -31,20 +31,26 @@
|
|||||||
const trimmed = $derived<Settings>({
|
const trimmed = $derived<Settings>({
|
||||||
site_url: draft.site_url.trim(),
|
site_url: draft.site_url.trim(),
|
||||||
mcp_url: draft.mcp_url.trim(),
|
mcp_url: draft.mcp_url.trim(),
|
||||||
|
ai_url: draft.ai_url.trim(),
|
||||||
install_dir: draft.install_dir.trim(),
|
install_dir: draft.install_dir.trim(),
|
||||||
});
|
});
|
||||||
const dirty = $derived(
|
const dirty = $derived(
|
||||||
current !== null &&
|
current !== null &&
|
||||||
(trimmed.site_url !== current.site_url ||
|
(trimmed.site_url !== current.site_url ||
|
||||||
trimmed.mcp_url !== current.mcp_url ||
|
trimmed.mcp_url !== current.mcp_url ||
|
||||||
|
trimmed.ai_url !== current.ai_url ||
|
||||||
trimmed.install_dir !== current.install_dir),
|
trimmed.install_dir !== current.install_dir),
|
||||||
);
|
);
|
||||||
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
|
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
|
||||||
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
|
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
|
||||||
const siteError = $derived(urlProblem(trimmed.site_url));
|
const siteError = $derived(urlProblem(trimmed.site_url));
|
||||||
const mcpError = $derived(urlProblem(trimmed.mcp_url));
|
const mcpError = $derived(urlProblem(trimmed.mcp_url));
|
||||||
|
/** Optional: an empty AI address is how an install with no line out is left. */
|
||||||
|
const aiError = $derived(trimmed.ai_url === "" ? null : urlProblem(trimmed.ai_url));
|
||||||
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
|
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
|
||||||
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null || offDrive);
|
const invalid = $derived(
|
||||||
|
siteError !== null || mcpError !== null || aiError !== null || dirError !== null || offDrive,
|
||||||
|
);
|
||||||
|
|
||||||
function urlProblem(value: string): string | null {
|
function urlProblem(value: string): string | null {
|
||||||
if (!value) return "Required.";
|
if (!value) return "Required.";
|
||||||
@@ -199,6 +205,25 @@
|
|||||||
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
|
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="set-ai-url">AI URL</label>
|
||||||
|
<input
|
||||||
|
id="set-ai-url"
|
||||||
|
class="input"
|
||||||
|
type="url"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="https://engine.odian.fr"
|
||||||
|
bind:value={draft.ai_url}
|
||||||
|
aria-invalid={dirty && aiError !== null}
|
||||||
|
aria-describedby="set-ai-url-help"
|
||||||
|
/>
|
||||||
|
<span id="set-ai-url-help" class={dirty && aiError ? "field-error" : "field-help"}>
|
||||||
|
{dirty && aiError
|
||||||
|
? aiError
|
||||||
|
: "Destination prediction, chat and vision, served over the internet. Leave empty and ODIAN runs wholly offline."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="group" disabled={locked || saving}>
|
<fieldset class="group" disabled={locked || saving}>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
import { launcher } from "../lib/launcher.svelte";
|
import { launcher } from "../lib/launcher.svelte";
|
||||||
|
// ODIAN's own mark, the same one the window and the taskbar carry.
|
||||||
|
import mark from "../assets/mark.png";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
@@ -44,11 +46,7 @@
|
|||||||
|
|
||||||
<header class="titlebar" data-tauri-drag-region>
|
<header class="titlebar" data-tauri-drag-region>
|
||||||
<div class="brand" data-tauri-drag-region>
|
<div class="brand" data-tauri-drag-region>
|
||||||
<svg class="mark" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true" data-tauri-drag-region>
|
<img class="mark" src={mark} width="18" height="18" alt="" draggable="false" data-tauri-drag-region />
|
||||||
<rect x="0.5" y="0.5" width="17" height="17" fill="none" stroke="currentColor" stroke-opacity="0.45" />
|
|
||||||
<path d="M9 3.5L14.5 9 9 14.5 3.5 9z" fill="none" stroke="currentColor" stroke-width="1.5" />
|
|
||||||
<rect x="8" y="8" width="2" height="2" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
<span class="wordmark" data-tauri-drag-region>ODIAN</span>
|
<span class="wordmark" data-tauri-drag-region>ODIAN</span>
|
||||||
<span class="product" data-tauri-drag-region>Launcher</span>
|
<span class="product" data-tauri-drag-region>Launcher</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +128,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mark {
|
.mark {
|
||||||
color: var(--accent);
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wordmark {
|
.wordmark {
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ function initialStatus(scenario: MockScenario): Status {
|
|||||||
portable: false,
|
portable: false,
|
||||||
sync: null,
|
sync: null,
|
||||||
live: null,
|
live: null,
|
||||||
|
map_from_site: null,
|
||||||
|
map_problem: null,
|
||||||
};
|
};
|
||||||
switch (scenario) {
|
switch (scenario) {
|
||||||
case "first-run":
|
case "first-run":
|
||||||
@@ -319,7 +321,7 @@ class MockBackend implements Backend {
|
|||||||
this.status = initialStatus(scenario);
|
this.status = initialStatus(scenario);
|
||||||
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
|
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
|
||||||
this.network = this.status.online;
|
this.network = this.status.online;
|
||||||
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
|
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR };
|
||||||
if (this.status.installed) {
|
if (this.status.installed) {
|
||||||
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
||||||
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
||||||
@@ -605,10 +607,12 @@ class MockBackend implements Backend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setSettings(settings: Settings): Promise<Status> {
|
async setSettings(settings: Settings): Promise<Status> {
|
||||||
for (const [label, url] of [
|
// The AI address is optional: empty means the client stays offline.
|
||||||
|
for (const [label, url] of ([
|
||||||
["DHP URL", settings.site_url],
|
["DHP URL", settings.site_url],
|
||||||
["MCP URL", settings.mcp_url],
|
["MCP URL", settings.mcp_url],
|
||||||
] as const) {
|
...(settings.ai_url.trim() ? [["AI URL", settings.ai_url] as const] : []),
|
||||||
|
] as const)) {
|
||||||
try {
|
try {
|
||||||
new URL(url);
|
new URL(url);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ export interface Status {
|
|||||||
* this PC. null when ODIAN is not running, or the DHP offers no Engine API.
|
* this PC. null when ODIAN is not running, or the DHP offers no Engine API.
|
||||||
*/
|
*/
|
||||||
live: boolean | null;
|
live: boolean | null;
|
||||||
|
/**
|
||||||
|
* While ODIAN runs: false when the map is being drawn from this PC because the site's
|
||||||
|
* tile service did not answer. null when ODIAN is not running, or no site is set.
|
||||||
|
*/
|
||||||
|
map_from_site: boolean | null;
|
||||||
|
/** Why, in one line, when `map_from_site` is false. */
|
||||||
|
map_problem: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A marketplace item: one tile set or source file of the version. */
|
/** A marketplace item: one tile set or source file of the version. */
|
||||||
@@ -118,6 +125,11 @@ export interface Settings {
|
|||||||
site_url: string;
|
site_url: string;
|
||||||
/** Site MCP, for Keycloak sign-in. */
|
/** Site MCP, for Keycloak sign-in. */
|
||||||
mcp_url: string;
|
mcp_url: string;
|
||||||
|
/**
|
||||||
|
* The MCP serving ODIAN's AI features (`/api/ai/...`), reached over the internet. Empty
|
||||||
|
* keeps the client wholly offline; set, it is the one address the client may reach.
|
||||||
|
*/
|
||||||
|
ai_url: string;
|
||||||
install_dir: string;
|
install_dir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||