From 8988ad8b06fd8900c1675ada102075be50a79ae2 Mon Sep 17 00:00:00 2001 From: Kelorastus <108483685+Kelorastus@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:01:58 +0200 Subject: [PATCH] fix --- src-tauri/src/launcher.rs | 164 ++++++++++++++++-- src-tauri/src/stack.rs | 273 +++++++++++++++++++++++++++--- src-tauri/tauri.conf.json | 2 +- src/components/ActionPanel.svelte | 16 ++ src/lib/launcher.svelte.ts | 8 +- src/lib/mock.ts | 26 ++- src/lib/types.ts | 7 + 7 files changed, 448 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index fd30b47..1617356 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -19,7 +19,7 @@ use crate::market; use crate::prune; use crate::settings::{normalize_url, Settings}; use crate::site::{self, SessionRefresh, TokenCheck}; -use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent}; +use crate::stack::{self, DhpDirect, Layout, RunningStack, StackConfig, StackEvent}; use crate::state::{self, LauncherState, SyncOrigin}; use crate::sync::{self, SyncError, SyncStatus}; use crate::devmode::{self, Licence}; @@ -65,6 +65,13 @@ pub struct Status { pub available: Option, pub update_required: bool, pub can_launch: bool, + /// This launch would come from the dev checkout. It is what lets an out-of-date install + /// still launch: see `can_launch`. + pub dev_run: bool, + /// This launch would start the client alone, against the DHP, with nothing installed. + pub dhp_direct: bool, + /// On a licensed PC with nothing installed, why that is not on offer. None when it is. + pub dhp_blocked: Option, pub phase: Phase, pub error: Option, pub install_dir: String, @@ -198,6 +205,15 @@ struct Inner { drive_extras: Option>, } +/// Whether Launch may go ahead. +/// +/// The only case allowed to be out of date is a dev run: it starts the client from the +/// checkout, and the install it sits on just supplies the data layer, which an older pack +/// serves perfectly well. Every other PC updates first. +fn launchable(installed: bool, update_required: bool, dev_run: bool, busy: bool) -> bool { + installed && (!update_required || dev_run) && !busy +} + impl Inner { /// Take `url` as the DHP's Engine API, and keep it in launcher.json so a launcher started /// offline next time still arms live mode with it. Err is only the save failing: the @@ -217,19 +233,36 @@ impl Inner { // Online means "update before launch"; offline launches whatever is installed. // Nothing installed is never "update required": that is a first install. let update_required = self.online && installed.is_some() && self.available.is_some() && installed != self.available; + // A dev run takes its CLIENT from the checkout; the install only supplies the data + // layer - database, tiles, API, tile server - and an older pack serves that perfectly + // well. So a newer pack on the DHP is news, not a gate. Without this, cutting a release + // stopped every dev PC until it downloaded a 900 MB pack it was not going to run. + let dev_run = Launcher::dev_engine(self).is_some(); + // With nothing installed a dev run can still go straight to the DHP, so Launch is + // offered where the hub would otherwise only ever offer Install. + let dhp_direct = Launcher::dhp_direct(self).is_some(); + // With a licence and nothing installed, say WHY a DHP run is not on offer. Every one of + // these used to read as a bare "ODIAN is not installed", which points at the wrong thing. + let dhp_blocked = (self.dev.is_some() && installed.is_none() && !dhp_direct) + .then(|| Launcher::why_no_dhp_run(self)); Status { online: self.online, site: SiteInfo { url: self.settings.site_url.clone(), name: self.settings.site_name.clone() }, operator: self.state.operator.clone(), // Remembered until Sign out: a launcher session renews the Engine token on its own. signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()), - can_launch: installed.is_some() - && !update_required - && !self.updating_self - && matches!(self.phase, Phase::Idle | Phase::Error), + can_launch: launchable( + installed.is_some() || dhp_direct, + update_required, + dev_run, + self.updating_self || !matches!(self.phase, Phase::Idle | Phase::Error), + ), installed, available: self.available.clone(), update_required, + dev_run, + dhp_direct, + dhp_blocked, // Updating itself reads as "checking": the window already shows that at every start. phase: if self.updating_self && matches!(self.phase, Phase::Idle | Phase::Error) { Phase::Checking } else { self.phase }, error: self.error.clone(), @@ -659,6 +692,62 @@ impl Launcher { repo.join("tools").join("build").join("build.bat").is_file().then_some(repo) } + /// Can this PC run a dev client with no install at all, straight against its DHP? + /// + /// Only with nothing installed: where there IS an install the local stack is the better + /// run - it holds this PC's own data and it is what an operator actually uses. This is + /// purely the case that used to be refused outright. + /// + /// Every reason it might not work is checked here rather than at launch, because the hub + /// needs to know whether to offer Launch at all. + fn dhp_direct(inner: &Inner) -> Option { + if Self::dev_engine(inner).is_none() || !inner.online || inner.state.installed_version.is_some() { + return None; + } + let mcp = inner.settings.mcp_url.trim(); + if mcp.is_empty() { + return None; + } + Some(DhpDirect { + engine_url: inner.engine_url.clone()?, + mcp_url: mcp.to_string(), + // Beside the install dir it would have used, so a later real install finds its own + // state untouched. + cache_dir: inner.settings.install_path().join("state").join("devclient"), + }) + } + + /// Why a PC with nothing installed cannot do a DHP-direct run either. Each of these was + /// a silent "ODIAN is not installed" before, which sent people looking in the wrong place. + fn why_no_dhp_run(inner: &Inner) -> String { + if Self::dev_engine(inner).is_none() { + let root = inner.settings.dev_root.trim(); + if !inner.settings.dev_mode { + return "Dev mode is off. Turn it on to run from a checkout with nothing installed.".to_string(); + } + if root.is_empty() { + return "Dev mode has no checkout folder yet: name the folder that holds ODIAN_Engine.".to_string(); + } + return format!( + "{root}{sep}ODIAN_Engine{sep}tools{sep}build{sep}build.bat is not there, so there is no checkout to run. Check the folder below is the right one.", + sep = "\\" + ); + } + if !inner.online { + return "Nothing is installed here, so this run needs the DHP - and it is not answering. Connect to it, or install once so this PC has its own data." + .to_string(); + } + if inner.settings.mcp_url.trim().is_empty() { + return "No MCP address is set, and that is what the client signs in through. Set it in Settings, or install once." + .to_string(); + } + if inner.engine_url.is_none() { + return "The DHP has not said where its Engine API is, so there is nothing to point the client at. Check the connection, or install once." + .to_string(); + } + "ODIAN is not installed.".to_string() + } + /// Look for this PC's dev licence. /// /// Cheap enough to do on every check - one small file - so dropping a licence in is @@ -1890,19 +1979,29 @@ impl Launcher { let config = { let mut inner = self.inner.lock().await; let status = inner.status(); - if status.update_required { + // Not for a dev run: that starts the checkout, so what the DHP published is beside + // the point. An operator install still has to update first. + if status.update_required && !status.dev_run { return Err("An update is available: update before launching while connected to the DHP.".to_string()); } if !status.can_launch { return Err("ODIAN isn't ready to launch.".to_string()); } - let operator = inner - .state - .operator - .clone() - .ok_or("Sign in once while connected, so the launcher knows which operator to start ODIAN as.")?; - let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?; - if inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none() { + let dhp = Self::dhp_direct(&inner); + // Nothing installed and no DHP-direct run available: say which of the two is missing, + // rather than "ODIAN is not installed" when the real problem is the target or the MCP. + if dhp.is_none() && inner.state.installed_version.is_none() { + return Err(Self::why_no_dhp_run(&inner)); + } + // The client signs ITSELF in on a DHP-direct run, so there is no operator to carry. + let operator = match &dhp { + Some(_) => inner.state.operator.clone().unwrap_or_default(), + None => inner.state.operator.clone().ok_or( + "Sign in once while connected, so the launcher knows which operator to start ODIAN as.", + )?, + }; + let version = inner.state.installed_version.clone().unwrap_or_default(); + if dhp.is_none() && (inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none()) { return Err("The local database was never set up. Reinstall from the DHP or a drive.".to_string()); } let install_dir = inner.settings.install_path(); @@ -1913,9 +2012,13 @@ impl Launcher { let layout = Layout::new(&install_dir, &version); let dev = Self::dev_engine(&inner); let dev_user = Self::dev_swap(&inner); - 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)?; + // A DHP-direct run has no installed version to read either list out of: this PC draws + // nothing of its own, and the site draws everything. + let (tile_sets, site_tile_sets) = match &dhp { + // No manifest to read either list from; the client uses its own default styles. + Some(_) => (Vec::new(), Vec::new()), + None => (stack::installed_tile_sets(&layout)?, stack::version_tile_sets(&layout)?), + }; inner.phase = Phase::Launching; inner.error = None; StackConfig { @@ -1928,6 +2031,7 @@ impl Launcher { dev_engine: dev, dev_user: dev_user.clone(), dev_target: inner.settings.dev_target, + dhp, tile_sets, site_tile_sets, // A swapped run never reaches the DHP: no upstream token is written, so the local @@ -2795,6 +2899,34 @@ fn swapped_user<'a>(asked: &'a str, operator: Option<&str>) -> Option<&'a str> { mod tests { use super::*; + #[test] + fn nothing_installed_still_launches_when_the_dhp_can_stand_in() { + // The whole point of the DHP-direct run: a checkout and a reachable DHP are enough. + assert!(launchable(true, false, true, false), "installed, dev: launches"); + // `installed` is passed as "installed OR a DHP-direct run is available", so this is the + // no-install case arriving as launchable. + assert!(launchable(true, false, true, false)); + // Nothing installed AND no DHP to stand in: still refused, and it should be. + assert!(!launchable(false, false, true, false), "no install, no DHP: nothing to run"); + assert!(!launchable(false, false, false, false)); + } + + #[test] + fn a_dev_run_launches_even_when_the_dhp_has_a_newer_pack() { + // What used to happen: cutting a release made every dev PC refuse to launch until it + // downloaded a pack it was never going to run. + assert!(launchable(true, true, true, false), "behind, but the client comes from the checkout"); + assert!(launchable(true, false, true, false)); + // An operator PC still updates first - that one really does run the pack. + assert!(!launchable(true, true, false, false), "not a dev run: update first"); + assert!(launchable(true, false, false, false)); + // Nothing installed is never launchable: a dev run still needs the database, the tiles + // and the API, and those only come from an install. + assert!(!launchable(false, false, true, false), "a checkout alone is not enough to run"); + // Mid-operation, nothing launches. + assert!(!launchable(true, false, true, true)); + } + #[test] fn a_dependency_added_since_the_last_install_is_noticed() { use std::time::{Duration, SystemTime}; diff --git a/src-tauri/src/stack.rs b/src-tauri/src/stack.rs index 985dfd9..9ddf39f 100644 --- a/src-tauri/src/stack.rs +++ b/src-tauri/src/stack.rs @@ -192,9 +192,13 @@ pub struct StackConfig { /// 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, - /// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT from, instead of the - /// installed version. Everything else is the install's - the database, the tiles, the - /// API, the tile server - so a dev run needs no download and shows real data. + /// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT, its API and its tile + /// server from, instead of the installed version - see `odian_exe`, `api_exe` and + /// `tile_server_dir`, which all branch on this. + /// + /// What still comes from the INSTALL is the data layer and the runtimes it needs: the + /// database, the tiles, the Python the tile server runs on, and Postgres itself. So a dev + /// run shows real data and needs no download - but it does need an install to sit on. /// /// Only ever Some on a PC with a valid dev licence: the launcher will not fill it in /// otherwise, whatever the settings file says. @@ -210,6 +214,37 @@ pub struct StackConfig { /// Dev mode: which client(s) this run starts. Ignored without `dev_engine` - an operator /// install has no web client to run, and only ever starts the one. pub dev_target: DevTarget, + /// Dev mode, no local data layer: Some means nothing local starts and the client talks + /// straight to the DHP. See `DhpDirect`. + pub dhp: Option, +} + +/// Where a web dev run sends its traffic. `api`/`mcp` are empty for an ordinary local run, +/// which takes those from the URL the launcher opens instead. +#[derive(Clone, Debug)] +pub struct WebPoints { + pub tiles: String, + pub api: String, + pub mcp: String, +} + +/// A dev run with no install behind it: the client talks straight to the DHP. +/// +/// Nothing local starts - no Postgres, no Engine API, no tile server - so this needs no +/// installed version at all, which is the whole point: a checkout and a DHP are enough. +/// +/// The client signs itself in through the MCP broker and carries the CLUSTER's token, which +/// is exactly what the shipped .exe does. The launcher's silent mint is NOT used here: it +/// signs with a secret only a local API holds, and the cluster does not share it. So this +/// run shows the client's own sign-in once, instead of arriving already signed in. +#[derive(Clone, Debug)] +pub struct DhpDirect { + /// The DHP's Engine API - what the local one would have forwarded to. + pub engine_url: String, + /// The MCP: the broker the client signs in through, and the AI features. + pub mcp_url: String, + /// Where the client keeps its caches and logs, since there is no install to keep them in. + pub cache_dir: PathBuf, } impl StackConfig { @@ -223,6 +258,15 @@ impl StackConfig { } } + /// The client's own folder of writable state. A DHP-direct run has no install to put it + /// in, so it gets one of its own. + fn client_dir(&self) -> PathBuf { + match &self.dhp { + Some(dhp) => dhp.cache_dir.clone(), + None => self.layout.state_dir().join("client"), + } + } + fn odian_exe(&self) -> PathBuf { match &self.dev_engine { Some(_) => self.odian_dir().join("x64").join("MinGW-Release").join("Odian.exe"), @@ -265,20 +309,42 @@ impl StackConfig { self.dev_engine.is_some() && self.dev_target.has_web() } + /// What the web client's dev server should point at. + pub fn web_points(&self) -> WebPoints { + match &self.dhp { + Some(dhp) => WebPoints { + tiles: site_tiles_url(&self.site_url), + api: dhp.engine_url.clone(), + mcp: dhp.mcp_url.clone(), + }, + None => WebPoints { + tiles: format!("http://127.0.0.1:{TILES_PORT}"), + api: String::new(), + mcp: String::new(), + }, + } + } + /// Where to open the browser, when this run has a web client. /// /// `?api=` points it at THIS stack's API rather than its own `http://localhost:8080` /// default, and `?user=` is the operator - the same name the .exe runs as, swapped one /// included, so both clients author as the same person. The web's endpoint guard trusts /// any loopback port, so no change is needed on that side. + /// + /// A DHP-direct run carries NEITHER. The DHP is not a loopback host, so `?api=` naming it + /// would be refused by that same guard - quite rightly. Its address arrives as deployment + /// config instead (`/odian-config.js`, which the dev server serves from the environment), + /// and the operator is whoever signs in through the MCP. pub fn web_url(&self) -> Option { - self.runs_web().then(|| { - format!( + self.runs_web().then(|| match &self.dhp { + Some(_) => format!("http://localhost:{WEB_PORT}/"), + None => format!( "http://localhost:{}/?api={}&user={}", WEB_PORT, url_value(&format!("http://127.0.0.1:{API_PORT}")), url_value(&self.operator) - ) + ), }) } @@ -290,6 +356,11 @@ impl StackConfig { /// browser: not in the request line, not in Vite's log. Never log this address; /// `web_url` is the one to print. pub fn web_url_signed(&self, token: &str) -> Option { + // Nothing to sign for on a DHP-direct run: there is no local API to relay, and the web + // client gets its own token from the MCP when it signs in. + if self.dhp.is_some() { + return self.web_url(); + } self.web_url().map(|url| format!("{url}#token={}", url_value(token))) } @@ -400,7 +471,8 @@ type Shared = Arc>; pub struct RunningStack { job: job::Job, - pg: Pg, + /// None on a DHP-direct run: there is no local database to stop. + pg: Option, children: Vec<(Component, Shared)>, } @@ -440,7 +512,9 @@ impl RunningStack { } emit(StackEvent::new(component, ComponentState::Stopped)); } - let pg = self.pg.clone(); + let Some(pg) = self.pg.clone() else { + return; // DHP-direct: nothing local was started, so there is nothing to stop. + }; match run_blocking(move || pg.stop()).await { Ok(()) => emit(StackEvent::new(Component::Postgres, ComponentState::Stopped)), Err(message) => emit(StackEvent { component: Component::Postgres, state: ComponentState::Failed, message: Some(message) }), @@ -492,10 +566,10 @@ fn check_ports_free(own_db_running: bool, web: bool) -> Result<(), String> { } pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) -> Result { - let pg = Pg::new(&config.layout, &config.pg_password); + let pg = config.dhp.is_none().then(|| Pg::new(&config.layout, &config.pg_password)); // Asks pg_ctl about this install's own data directory, so a database on the port that // belongs to another install still reads as someone else's. - check_ports_free(pg.running(), config.runs_web())?; + check_ports_free(pg.as_ref().is_some_and(Pg::running), config.runs_web())?; prepare(config)?; let mut running = RunningStack { job: job::Job::new()?, pg, children: Vec::new() }; match start_all(http, config, emit, &mut running).await { @@ -508,11 +582,31 @@ pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) } async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> { + if config.dhp.is_none() { + start_local_data_layer(http, config, emit, running).await?; + } + if config.runs_client() { + start_client(config, emit, running).await?; + } + if config.runs_web() { + start_web(http, config, emit, running).await?; + } + Ok(()) +} + +/// Postgres, the Engine API and the tile server: this PC's own data, and what a DHP-direct +/// run does without. +async fn start_local_data_layer( + http: &reqwest::Client, + config: &StackConfig, + emit: Emit<'_>, + running: &mut RunningStack, +) -> Result<(), String> { let layout = &config.layout; let state = layout.state_dir(); emit(StackEvent::new(Component::Postgres, ComponentState::Starting)); - let pg = running.pg.clone(); + let pg = running.pg.clone().ok_or("no database to start")?; run_blocking(move || pg.start()).await.map_err(|e| failed(emit, Component::Postgres, e))?; emit(StackEvent::new(Component::Postgres, ComponentState::Ready)); @@ -559,21 +653,13 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>, .await .map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?; emit(StackEvent::new(Component::Tiles, ComponentState::Ready)); - - if config.runs_client() { - start_client(config, emit, running).await?; - } - if config.runs_web() { - start_web(http, config, emit, running).await?; - } Ok(()) } /// Odian.exe, in its own folder of writable state, from the install or the checkout. async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> { - let state = config.layout.state_dir(); emit(StackEvent::new(Component::Odian, ComponentState::Starting)); - let client_dir = state.join("client"); + let client_dir = config.client_dir(); let odian_exe = config.odian_exe(); if config.dev_engine.is_some() && !odian_exe.is_file() { return Err(failed( @@ -615,9 +701,18 @@ async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut Runnin /// a failed launch, and the bare `--` matters - without it npm keeps the rest for itself /// instead of passing it to Vite. `node_dir` goes first on its PATH: right after the /// launcher installed Node.js, this process's own PATH does not name it yet. -fn web_command(dir: &Path, node_dir: &Path) -> Command { +fn web_command(dir: &Path, node_dir: &Path, points: &WebPoints) -> Command { let mut web = Command::new("cmd"); web.env("PATH", crate::nodejs::path_with(node_dir)); + // Only on a DHP-direct run. `_WEB`'s dev server turns these into the deployment config it + // already understands (`/odian-config.js`); unset, it serves nothing and the ordinary + // local run is untouched. + if !points.api.is_empty() { + web.env("ODIAN_ENGINE_API_URL", &points.api); + } + if !points.mcp.is_empty() { + web.env("ODIAN_MCP_API_URL", &points.mcp); + } // --strictPort because without it Vite quietly takes the next free port, and the address // the launcher opens - and prints - would then be somebody else's server. web @@ -631,8 +726,9 @@ fn web_command(dir: &Path, node_dir: &Path) -> Command { .arg("--strictPort") // The map through THIS stack's tile server, as the .exe draws it: the site while it // answers, this PC's installed tiles when it does not. Without it _WEB's proxy goes - // to the public tile host, and an offline run has no map at all. - .env("ODIAN_TILE_SERVER_URL", format!("http://127.0.0.1:{TILES_PORT}")) + // to the public tile host, and an offline run has no map at all. A DHP-direct run has + // no tile server of its own, so it names the site's. + .env("ODIAN_TILE_SERVER_URL", points.tiles.clone()) .current_dir(dir); web } @@ -670,7 +766,7 @@ async fn start_web( format!("Node.js is not installed, and the web client runs on it. Install Node.js LTS (winget install {}), then launch again.", crate::nodejs::WINGET_ID), )); }; - let mut web = web_command(&dir, &node_dir); + let mut web = web_command(&dir, &node_dir, &config.web_points()); give_console(&mut web); let child = running.spawn(Component::Web, &mut web).map_err(|e| failed(emit, Component::Web, e))?; // A cold start compiles Cesium, so this is patient where the others are not. @@ -756,6 +852,12 @@ async fn run_blocking(work: impl FnOnce() -> Result Result<(), String> { + if let Some(dhp) = &config.dhp { + for dir in [dhp.cache_dir.join("logs"), dhp.cache_dir.join("tile_cache"), dhp.cache_dir.join("heightmap")] { + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + } + return Ok(()); + } let layout = &config.layout; let state = layout.state_dir(); let data = layout.data_dir(); @@ -1019,7 +1121,58 @@ fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> { env } +/// A run with no local stack at all: everything the client talks to is the DHP's. +/// +/// Deliberately WITHOUT `ODIAN_LAUNCH_USER` and `ODIAN_MINT_SECRET`. Those two are what make +/// the client mint its own session, and only a local API will honour that - the cluster does +/// not share the secret. Left out, the client does what the shipped .exe does: signs in +/// through the MCP broker and carries the cluster's own token. +fn dhp_client_env(config: &StackConfig, dhp: &DhpDirect) -> Vec<(&'static str, String)> { + let client = config.client_dir(); + let tiles = site_tiles_url(&config.site_url); + let mut env = vec![ + ("ODIAN_ENGINE_API_URL", dhp.engine_url.clone()), + // The broker the client signs in through. Without it its login screen has nowhere to go + // and the run is a window that cannot be used. + ("ODIAN_MCP_API_URL", dhp.mcp_url.clone()), + ("ODIAN_TILE_SERVER_URL", tiles.clone()), + ("ODIAN_POP_SERVICE_URL", tiles.clone()), + ("ODIAN_TILE_PROVIDER", "odian".to_string()), + ("ODIAN_TILE_REQUEST_MODE", "xyz".to_string()), + ("ODIAN_TILE_Y_SCHEME", "xyz".to_string()), + ("ODIAN_TILE_AUTH_MODE", "none".to_string()), + ("ODIAN_TILE_CACHE_DIR", text(client.join("tile_cache"))), + ("ODIAN_HEIGHT_CACHE_DIR", text(client.join("heightmap"))), + ("ODIAN_LIDAR_ENABLED", "0".to_string()), + ]; + // Every set the DHP can draw, not this PC's - it has none. Same order of preference as a + // local run, so the map opens on the same basemap a developer is used to. + let sets = &config.site_tile_sets; + let basemap = ["dark_v2", "dark"] + .iter() + .find_map(|want| sets.iter().find(|set| set.style == *want)) + .or_else(|| sets.iter().find(|set| set.style != "heightmap")); + if let Some(set) = basemap { + env.push(("ODIAN_TILE_STYLE", set.style.clone())); + env.push(("ODIAN_TILE_DATASET", set.dataset.clone())); + } + let styles: Vec<&str> = sets.iter().map(|set| set.style.as_str()).collect(); + if !styles.is_empty() { + env.push(("ODIAN_TILE_STYLES", styles.join(","))); + } + if let Some(set) = sets.iter().find(|set| set.style == "heightmap") { + env.push(("ODIAN_HEIGHT_SERVER_URL", tiles)); + env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string())); + env.push(("ODIAN_HEIGHT_STYLE", set.style.clone())); + env.push(("ODIAN_HEIGHT_DATASET", set.dataset.clone())); + } + env +} + fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> { + if let Some(dhp) = &config.dhp { + return dhp_client_env(config, dhp); + } let client = config.layout.state_dir().join("client"); let tiles_url = format!("http://127.0.0.1:{TILES_PORT}"); let mut env = vec![ @@ -1184,6 +1337,7 @@ mod tests { dev_engine: None, dev_user: None, dev_target: DevTarget::default(), + dhp: None, site_url: "https://dhp.example/dhp".to_string(), site_tile_sets: tile_sets .iter() @@ -1268,6 +1422,71 @@ mod tests { assert!(value(&env, "ODIAN_HEIGHT_SERVER_URL").is_none()); } + /// What an ordinary local web run points at, for the tests that do not care about the DHP. + fn local_points() -> WebPoints { + WebPoints { tiles: format!("http://127.0.0.1:{TILES_PORT}"), api: String::new(), mcp: String::new() } + } + + #[test] + fn a_dhp_direct_web_run_is_told_where_to_look_and_asks_for_nothing_local() { + let mut config = config(&[]); + config.dev_engine = Some(PathBuf::from(r"D:\dev\ODIAN_Engine")); + config.dev_target = DevTarget::Web; + config.dhp = Some(DhpDirect { + engine_url: "https://dhp.example/engine".to_string(), + mcp_url: "https://dhp.example".to_string(), + cache_dir: PathBuf::from(r"D:\ODIAN\state\devclient"), + }); + let points = config.web_points(); + assert_eq!(points.api, "https://dhp.example/engine"); + assert_eq!(points.mcp, "https://dhp.example"); + assert_eq!(points.tiles, "https://dhp.example/dhp/tileserver"); + // The URL must NOT carry ?api=: the DHP is not a loopback host, so _WEB's endpoint + // guard would refuse it and quietly fall back to its own localhost default. + let url = config.web_url().expect("a web run has a URL"); + assert!(!url.contains("api="), "the address must not try to override the endpoint: {url}"); + assert!(!url.contains("user="), "nobody is signed in yet: {url}"); + // And no token to staple on, since there is no local API relaying anything. + assert_eq!(config.web_url_signed("tok"), Some(url)); + } + + #[test] + fn an_ordinary_web_run_still_points_at_this_pc() { + let mut config = config(&[]); + config.dev_engine = Some(PathBuf::from(r"D:\dev\ODIAN_Engine")); + config.dev_target = DevTarget::Web; + let points = config.web_points(); + assert!(points.api.is_empty() && points.mcp.is_empty(), "nothing injected for a local run"); + assert!(points.tiles.contains("127.0.0.1")); + let url = config.web_url().expect("a web run has a URL"); + assert!(url.contains(&format!("api=http%3A%2F%2F127.0.0.1%3A{API_PORT}")) || url.contains("127.0.0.1"), "{url}"); + assert!(config.web_url_signed("tok").is_some_and(|u| u.contains("#token=tok"))); + } + + #[test] + fn a_dhp_direct_client_is_pointed_away_from_this_pc() { + let mut config = config(&[]); + config.dev_engine = Some(PathBuf::from(r"D:\dev\ODIAN_Engine")); + config.dhp = Some(DhpDirect { + engine_url: "https://dhp.example/engine".to_string(), + mcp_url: "https://dhp.example".to_string(), + cache_dir: PathBuf::from(r"D:\ODIAN\state\devclient"), + }); + let env = client_env(&config); + assert_eq!(value(&env, "ODIAN_ENGINE_API_URL"), Some("https://dhp.example/engine")); + // The broker it signs in through: without this its login screen has nowhere to go. + assert_eq!(value(&env, "ODIAN_MCP_API_URL"), Some("https://dhp.example")); + assert_eq!(value(&env, "ODIAN_TILE_SERVER_URL"), Some("https://dhp.example/dhp/tileserver")); + // These two are what make the client mint its own session, which only a LOCAL API would + // honour. Present here, the run would fail to sign in against the cluster. + assert_eq!(value(&env, "ODIAN_LAUNCH_USER"), None); + assert_eq!(value(&env, "ODIAN_MINT_SECRET"), None); + // And nothing may point back at this PC: there is no local stack listening. + for (key, val) in &env { + assert!(!val.contains("127.0.0.1"), "{key} still points at this PC: {val}"); + } + } + #[test] fn api_and_tile_server_are_loopback_and_cache_only() { let config = config(&[("dark", "protomaps_20260423")]); @@ -1412,7 +1631,7 @@ mod tests { // Checked as ARGUMENTS rather than by reading the binary: these are short literals that // a release build merges away, so "is the flag in the .exe" answers nothing. A wrong // flag here would surface only as a failed launch. - let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs")); + let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs"), &local_points()); let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); assert_eq!(args, ["/c", "npm", "run", "dev", "--", "--port", "5173", "--strictPort"]); // The bare `--` is load-bearing: without it npm eats the flags instead of passing them on. @@ -1429,7 +1648,7 @@ mod tests { fn the_web_draws_its_map_from_this_stacks_tile_server() { // Offline, the public tile host is unreachable; this stack's tile server still has // the PC's installed tiles. _WEB's vite.config.ts reads this variable. - let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs")); + let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs"), &local_points()); let tiles = cmd .get_envs() .find(|(key, _)| *key == "ODIAN_TILE_SERVER_URL") @@ -1443,7 +1662,7 @@ mod tests { // Right after the launcher installs Node.js, its own PATH does not name it yet; npm // runs with the found folder first, so the launch goes on without a restart. let node = Path::new(r"C:\Program Files\nodejs"); - let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), node); + let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), node, &local_points()); let path = cmd .get_envs() .find(|(key, _)| *key == "PATH") diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e431079..6697bfc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ODIAN Launcher", - "version": "0.1.39", + "version": "0.1.40", "identifier": "fr.odian.launcher", "build": { "frontendDist": "../dist", diff --git a/src/components/ActionPanel.svelte b/src/components/ActionPanel.svelte index bd82a09..27f68b6 100644 --- a/src/components/ActionPanel.svelte +++ b/src/components/ActionPanel.svelte @@ -209,6 +209,14 @@

ODIAN {s?.available} from {site}, into {s?.install_dir}.

+ + {#if s?.dhp_blocked} +

+ Dev mode: a run straight against {site} needs no install, but {s.dhp_blocked} +

+ {/if} {@render driveLink("Have an ODIAN drive?")} {:else if action === "offline_unavailable"} {@render primary("Connect to your DHP to install", "globe", () => {}, true)} @@ -229,6 +237,14 @@

{#if checking} Checking {site}… + {:else if !s?.installed} + Nothing is installed here: the client comes from your checkout and runs on + {site}'s data. You sign in inside ODIAN, not here. + {:else if s?.update_required} + + ODIAN {s.installed} is installed, {s.available} is on {site}. A dev run starts + your checkout, so it does not need it. + {:else if s?.online} ODIAN {s.installed} is up to date. {:else} diff --git a/src/lib/launcher.svelte.ts b/src/lib/launcher.svelte.ts index 7ca6eff..25cb79a 100644 --- a/src/lib/launcher.svelte.ts +++ b/src/lib/launcher.svelte.ts @@ -72,10 +72,16 @@ export function deriveAction(s: Status | null): ActionKind { } // idle or checking if (s.installed === null) { + // Nothing installed, but a dev checkout and a reachable DHP are enough on their own: + // the client runs against the DHP and signs itself in, so neither an install nor a + // launcher sign-in stands in the way. + if (s.dhp_direct) return "launch"; if (!s.online) return "offline_unavailable"; return s.signed_in ? "install" : "sign_in"; } - if (s.update_required) return s.signed_in ? "update" : "sign_in"; + // A dev run starts the checkout, so a newer pack is worth saying but not worth + // stopping for; the hub keeps Launch and mentions the update beside it. + if (s.update_required && !s.dev_run) return s.signed_in ? "update" : "sign_in"; // Launching runs ODIAN as the remembered operator; after Sign out there is none. if (s.operator === null) return s.online ? "sign_in" : "signed_out_offline"; return "launch"; diff --git a/src/lib/mock.ts b/src/lib/mock.ts index 546277a..2d13722 100644 --- a/src/lib/mock.ts +++ b/src/lib/mock.ts @@ -226,7 +226,8 @@ function derive(s: Status): Status { available, signed_in: s.online && s.signed_in, update_required, - can_launch: s.installed !== null && !update_required && s.phase === "idle", + // A dev run launches the checkout, so an available pack does not hold it back. + can_launch: (s.installed !== null || s.dhp_direct) && (!update_required || s.dev_run) && s.phase === "idle", }; } @@ -240,6 +241,9 @@ function initialStatus(scenario: MockScenario): Status { available: CURRENT, update_required: false, can_launch: false, + dev_run: false, + dhp_direct: false, + dhp_blocked: null, phase: "idle", error: null, install_dir: DEFAULT_DIR, @@ -285,9 +289,25 @@ function initialStatus(scenario: MockScenario): Status { // A licensed PC, already set up to run from a checkout, so the Dev section and the // dev-run marker can be seen without a licence file. Object.assign(s, { - installed: CURRENT, + // Deliberately BEHIND what the DHP has: this is the case that used to refuse to + // launch at all, so the scenario should show it working. + installed: "0.71.0", operator: OPERATOR, signed_in: true, + dev_run: true, + dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" }, + }); + break; + case "dev-dhp": + // A licensed dev PC with NOTHING installed, on a reachable DHP: the case that used to + // offer only Install. + Object.assign(s, { + installed: null, + operator: OPERATOR, + signed_in: true, + dev_run: true, + dhp_direct: true, + dhp_blocked: null, dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" }, }); break; @@ -349,7 +369,7 @@ class MockBackend implements Backend { this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR, dev_root: "", dev_mode: false, dev_user: "", dev_build: false, dev_target: "client" }; // Already pointed at a checkout and switched on, so the dev controls and the dev-run // marker show as they do on the real thing. - if (scenario === "dev") Object.assign(this.settings, { dev_root: "E:" + String.fromCharCode(92) + "ODIAN_Dev", dev_mode: true }); + if (scenario === "dev" || scenario === "dev-dhp") Object.assign(this.settings, { dev_root: "E:" + String.fromCharCode(92) + "ODIAN_Dev", dev_mode: true }); 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"); diff --git a/src/lib/types.ts b/src/lib/types.ts index f72d773..ad7dfd6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -36,6 +36,12 @@ export interface Status { update_required: boolean; /** installed && !update_required && phase is idle. */ can_launch: boolean; + /** This launch would come from the dev checkout, so an available pack does not gate it. */ + dev_run: boolean; + /** Nothing is installed, but a dev run can still start the client against the DHP. */ + dhp_direct: boolean; + /** On a licensed PC with nothing installed, why a DHP run is not on offer. */ + dhp_blocked: string | null; phase: Phase; /** Last error, human readable. */ error: string | null; @@ -331,6 +337,7 @@ export const MOCK_SCENARIOS = [ "sync-conflicts", "error", "dev", + "dev-dhp", ] as const; export type MockScenario = (typeof MOCK_SCENARIOS)[number];