This commit is contained in:
Kelorastus
2026-09-30 15:01:58 +02:00
parent 3863809ae8
commit 8988ad8b06
7 changed files with 448 additions and 48 deletions
+148 -16
View File
@@ -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<String>,
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<String>,
pub phase: Phase,
pub error: Option<String>,
pub install_dir: String,
@@ -198,6 +205,15 @@ struct Inner {
drive_extras: Option<BTreeSet<String>>,
}
/// 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<DhpDirect> {
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};
+246 -27
View File
@@ -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<TileSet>,
/// 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<DhpDirect>,
}
/// 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<String> {
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<String> {
// 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<Mutex<Child>>;
pub struct RunningStack {
job: job::Job,
pg: Pg,
/// None on a DHP-direct run: there is no local database to stop.
pg: Option<Pg>,
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<RunningStack, String> {
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<T: Send + 'static>(work: impl FnOnce() -> Result<T, String
}
fn prepare(config: &StackConfig) -> 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<String> = 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")
+1 -1
View File
@@ -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",
+16
View File
@@ -209,6 +209,14 @@
<p class="secondary">
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
</p>
<!-- A licensed PC with nothing installed should be running from its checkout against the
DHP. When it cannot, say which single thing is in the way - this screen used to give
no clue at all, which sent people looking at the install folder every time. -->
{#if s?.dhp_blocked}
<p class="secondary small">
<strong>Dev mode:</strong> a run straight against {site} needs no install, but {s.dhp_blocked}
</p>
{/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 @@
<p class="secondary">
{#if checking}
Checking {site}…
{:else if !s?.installed}
Nothing is installed here: the client comes from your checkout and runs on
<strong>{site}</strong>'s data. You sign in inside ODIAN, not here.
{:else if s?.update_required}
<!-- Only reachable on a dev run: any other PC would be on the "update" action. -->
ODIAN <strong>{s.installed}</strong> is installed, <strong>{s.available}</strong> is on {site}. A dev run starts
your checkout, so it does not need it.
<button class="btn-link" disabled={blocked} onclick={() => launcher.install()}>Update anyway</button>
{:else if s?.online}
ODIAN <strong>{s.installed}</strong> is up to date.
{:else}
+7 -1
View File
@@ -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";
+23 -3
View File
@@ -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");
+7
View File
@@ -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];