fix
This commit is contained in:
+148
-16
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user