fix
This commit is contained in:
+148
-16
@@ -19,7 +19,7 @@ use crate::market;
|
|||||||
use crate::prune;
|
use crate::prune;
|
||||||
use crate::settings::{normalize_url, Settings};
|
use crate::settings::{normalize_url, Settings};
|
||||||
use crate::site::{self, SessionRefresh, TokenCheck};
|
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::state::{self, LauncherState, SyncOrigin};
|
||||||
use crate::sync::{self, SyncError, SyncStatus};
|
use crate::sync::{self, SyncError, SyncStatus};
|
||||||
use crate::devmode::{self, Licence};
|
use crate::devmode::{self, Licence};
|
||||||
@@ -65,6 +65,13 @@ pub struct Status {
|
|||||||
pub available: Option<String>,
|
pub available: Option<String>,
|
||||||
pub update_required: bool,
|
pub update_required: bool,
|
||||||
pub can_launch: 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 phase: Phase,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub install_dir: String,
|
pub install_dir: String,
|
||||||
@@ -198,6 +205,15 @@ struct Inner {
|
|||||||
drive_extras: Option<BTreeSet<String>>,
|
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 {
|
impl Inner {
|
||||||
/// Take `url` as the DHP's Engine API, and keep it in launcher.json so a launcher started
|
/// 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
|
/// 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.
|
// Online means "update before launch"; offline launches whatever is installed.
|
||||||
// Nothing installed is never "update required": that is a first install.
|
// 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;
|
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 {
|
Status {
|
||||||
online: self.online,
|
online: self.online,
|
||||||
site: SiteInfo { url: self.settings.site_url.clone(), name: self.settings.site_name.clone() },
|
site: SiteInfo { url: self.settings.site_url.clone(), name: self.settings.site_name.clone() },
|
||||||
operator: self.state.operator.clone(),
|
operator: self.state.operator.clone(),
|
||||||
// Remembered until Sign out: a launcher session renews the Engine token on its own.
|
// 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()),
|
signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()),
|
||||||
can_launch: installed.is_some()
|
can_launch: launchable(
|
||||||
&& !update_required
|
installed.is_some() || dhp_direct,
|
||||||
&& !self.updating_self
|
update_required,
|
||||||
&& matches!(self.phase, Phase::Idle | Phase::Error),
|
dev_run,
|
||||||
|
self.updating_self || !matches!(self.phase, Phase::Idle | Phase::Error),
|
||||||
|
),
|
||||||
installed,
|
installed,
|
||||||
available: self.available.clone(),
|
available: self.available.clone(),
|
||||||
update_required,
|
update_required,
|
||||||
|
dev_run,
|
||||||
|
dhp_direct,
|
||||||
|
dhp_blocked,
|
||||||
// Updating itself reads as "checking": the window already shows that at every start.
|
// 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 },
|
phase: if self.updating_self && matches!(self.phase, Phase::Idle | Phase::Error) { Phase::Checking } else { self.phase },
|
||||||
error: self.error.clone(),
|
error: self.error.clone(),
|
||||||
@@ -659,6 +692,62 @@ impl Launcher {
|
|||||||
repo.join("tools").join("build").join("build.bat").is_file().then_some(repo)
|
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.
|
/// Look for this PC's dev licence.
|
||||||
///
|
///
|
||||||
/// Cheap enough to do on every check - one small file - so dropping a licence in is
|
/// 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 config = {
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
let status = inner.status();
|
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());
|
return Err("An update is available: update before launching while connected to the DHP.".to_string());
|
||||||
}
|
}
|
||||||
if !status.can_launch {
|
if !status.can_launch {
|
||||||
return Err("ODIAN isn't ready to launch.".to_string());
|
return Err("ODIAN isn't ready to launch.".to_string());
|
||||||
}
|
}
|
||||||
let operator = inner
|
let dhp = Self::dhp_direct(&inner);
|
||||||
.state
|
// Nothing installed and no DHP-direct run available: say which of the two is missing,
|
||||||
.operator
|
// rather than "ODIAN is not installed" when the real problem is the target or the MCP.
|
||||||
.clone()
|
if dhp.is_none() && inner.state.installed_version.is_none() {
|
||||||
.ok_or("Sign in once while connected, so the launcher knows which operator to start ODIAN as.")?;
|
return Err(Self::why_no_dhp_run(&inner));
|
||||||
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() {
|
// 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());
|
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();
|
let install_dir = inner.settings.install_path();
|
||||||
@@ -1913,9 +2012,13 @@ impl Launcher {
|
|||||||
let layout = Layout::new(&install_dir, &version);
|
let layout = Layout::new(&install_dir, &version);
|
||||||
let dev = Self::dev_engine(&inner);
|
let dev = Self::dev_engine(&inner);
|
||||||
let dev_user = Self::dev_swap(&inner);
|
let dev_user = Self::dev_swap(&inner);
|
||||||
let tile_sets = stack::installed_tile_sets(&layout)?;
|
// A DHP-direct run has no installed version to read either list out of: this PC draws
|
||||||
// What the DHP can draw for this PC, beyond what it downloaded.
|
// nothing of its own, and the site draws everything.
|
||||||
let site_tile_sets = stack::version_tile_sets(&layout)?;
|
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.phase = Phase::Launching;
|
||||||
inner.error = None;
|
inner.error = None;
|
||||||
StackConfig {
|
StackConfig {
|
||||||
@@ -1928,6 +2031,7 @@ impl Launcher {
|
|||||||
dev_engine: dev,
|
dev_engine: dev,
|
||||||
dev_user: dev_user.clone(),
|
dev_user: dev_user.clone(),
|
||||||
dev_target: inner.settings.dev_target,
|
dev_target: inner.settings.dev_target,
|
||||||
|
dhp,
|
||||||
tile_sets,
|
tile_sets,
|
||||||
site_tile_sets,
|
site_tile_sets,
|
||||||
// A swapped run never reaches the DHP: no upstream token is written, so the local
|
// 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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn a_dependency_added_since_the_last_install_is_noticed() {
|
fn a_dependency_added_since_the_last_install_is_noticed() {
|
||||||
use std::time::{Duration, SystemTime};
|
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
|
/// the site draws the ones this PC never downloaded. Without one it sees only
|
||||||
/// `tile_sets`, because nothing else could be drawn.
|
/// `tile_sets`, because nothing else could be drawn.
|
||||||
pub site_tile_sets: Vec<TileSet>,
|
pub site_tile_sets: Vec<TileSet>,
|
||||||
/// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT from, instead of the
|
/// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT, its API and its tile
|
||||||
/// installed version. Everything else is the install's - the database, the tiles, the
|
/// server from, instead of the installed version - see `odian_exe`, `api_exe` and
|
||||||
/// API, the tile server - so a dev run needs no download and shows real data.
|
/// `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
|
/// Only ever Some on a PC with a valid dev licence: the launcher will not fill it in
|
||||||
/// otherwise, whatever the settings file says.
|
/// 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
|
/// 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.
|
/// install has no web client to run, and only ever starts the one.
|
||||||
pub dev_target: DevTarget,
|
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 {
|
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 {
|
fn odian_exe(&self) -> PathBuf {
|
||||||
match &self.dev_engine {
|
match &self.dev_engine {
|
||||||
Some(_) => self.odian_dir().join("x64").join("MinGW-Release").join("Odian.exe"),
|
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()
|
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.
|
/// 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`
|
/// `?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
|
/// 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
|
/// 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.
|
/// 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> {
|
pub fn web_url(&self) -> Option<String> {
|
||||||
self.runs_web().then(|| {
|
self.runs_web().then(|| match &self.dhp {
|
||||||
format!(
|
Some(_) => format!("http://localhost:{WEB_PORT}/"),
|
||||||
|
None => format!(
|
||||||
"http://localhost:{}/?api={}&user={}",
|
"http://localhost:{}/?api={}&user={}",
|
||||||
WEB_PORT,
|
WEB_PORT,
|
||||||
url_value(&format!("http://127.0.0.1:{API_PORT}")),
|
url_value(&format!("http://127.0.0.1:{API_PORT}")),
|
||||||
url_value(&self.operator)
|
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;
|
/// browser: not in the request line, not in Vite's log. Never log this address;
|
||||||
/// `web_url` is the one to print.
|
/// `web_url` is the one to print.
|
||||||
pub fn web_url_signed(&self, token: &str) -> Option<String> {
|
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)))
|
self.web_url().map(|url| format!("{url}#token={}", url_value(token)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +471,8 @@ type Shared = Arc<Mutex<Child>>;
|
|||||||
|
|
||||||
pub struct RunningStack {
|
pub struct RunningStack {
|
||||||
job: job::Job,
|
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)>,
|
children: Vec<(Component, Shared)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +512,9 @@ impl RunningStack {
|
|||||||
}
|
}
|
||||||
emit(StackEvent::new(component, ComponentState::Stopped));
|
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 {
|
match run_blocking(move || pg.stop()).await {
|
||||||
Ok(()) => emit(StackEvent::new(Component::Postgres, ComponentState::Stopped)),
|
Ok(()) => emit(StackEvent::new(Component::Postgres, ComponentState::Stopped)),
|
||||||
Err(message) => emit(StackEvent { component: Component::Postgres, state: ComponentState::Failed, message: Some(message) }),
|
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> {
|
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
|
// 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.
|
// 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)?;
|
prepare(config)?;
|
||||||
let mut running = RunningStack { job: job::Job::new()?, pg, children: Vec::new() };
|
let mut running = RunningStack { job: job::Job::new()?, pg, children: Vec::new() };
|
||||||
match start_all(http, config, emit, &mut running).await {
|
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> {
|
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 layout = &config.layout;
|
||||||
let state = layout.state_dir();
|
let state = layout.state_dir();
|
||||||
|
|
||||||
emit(StackEvent::new(Component::Postgres, ComponentState::Starting));
|
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))?;
|
run_blocking(move || pg.start()).await.map_err(|e| failed(emit, Component::Postgres, e))?;
|
||||||
emit(StackEvent::new(Component::Postgres, ComponentState::Ready));
|
emit(StackEvent::new(Component::Postgres, ComponentState::Ready));
|
||||||
|
|
||||||
@@ -559,21 +653,13 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>,
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?;
|
.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));
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Odian.exe, in its own folder of writable state, from the install or the checkout.
|
/// 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> {
|
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));
|
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();
|
let odian_exe = config.odian_exe();
|
||||||
if config.dev_engine.is_some() && !odian_exe.is_file() {
|
if config.dev_engine.is_some() && !odian_exe.is_file() {
|
||||||
return Err(failed(
|
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
|
/// 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
|
/// 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.
|
/// 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");
|
let mut web = Command::new("cmd");
|
||||||
web.env("PATH", crate::nodejs::path_with(node_dir));
|
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
|
// --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.
|
// the launcher opens - and prints - would then be somebody else's server.
|
||||||
web
|
web
|
||||||
@@ -631,8 +726,9 @@ fn web_command(dir: &Path, node_dir: &Path) -> Command {
|
|||||||
.arg("--strictPort")
|
.arg("--strictPort")
|
||||||
// The map through THIS stack's tile server, as the .exe draws it: the site while it
|
// 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
|
// 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.
|
// to the public tile host, and an offline run has no map at all. A DHP-direct run has
|
||||||
.env("ODIAN_TILE_SERVER_URL", format!("http://127.0.0.1:{TILES_PORT}"))
|
// no tile server of its own, so it names the site's.
|
||||||
|
.env("ODIAN_TILE_SERVER_URL", points.tiles.clone())
|
||||||
.current_dir(dir);
|
.current_dir(dir);
|
||||||
web
|
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),
|
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);
|
give_console(&mut web);
|
||||||
let child = running.spawn(Component::Web, &mut web).map_err(|e| failed(emit, Component::Web, e))?;
|
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.
|
// 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> {
|
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 layout = &config.layout;
|
||||||
let state = layout.state_dir();
|
let state = layout.state_dir();
|
||||||
let data = layout.data_dir();
|
let data = layout.data_dir();
|
||||||
@@ -1019,7 +1121,58 @@ fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
|||||||
env
|
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)> {
|
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 client = config.layout.state_dir().join("client");
|
||||||
let tiles_url = format!("http://127.0.0.1:{TILES_PORT}");
|
let tiles_url = format!("http://127.0.0.1:{TILES_PORT}");
|
||||||
let mut env = vec![
|
let mut env = vec![
|
||||||
@@ -1184,6 +1337,7 @@ mod tests {
|
|||||||
dev_engine: None,
|
dev_engine: None,
|
||||||
dev_user: None,
|
dev_user: None,
|
||||||
dev_target: DevTarget::default(),
|
dev_target: DevTarget::default(),
|
||||||
|
dhp: None,
|
||||||
site_url: "https://dhp.example/dhp".to_string(),
|
site_url: "https://dhp.example/dhp".to_string(),
|
||||||
site_tile_sets: tile_sets
|
site_tile_sets: tile_sets
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1268,6 +1422,71 @@ mod tests {
|
|||||||
assert!(value(&env, "ODIAN_HEIGHT_SERVER_URL").is_none());
|
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]
|
#[test]
|
||||||
fn api_and_tile_server_are_loopback_and_cache_only() {
|
fn api_and_tile_server_are_loopback_and_cache_only() {
|
||||||
let config = config(&[("dark", "protomaps_20260423")]);
|
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
|
// 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
|
// 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.
|
// 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();
|
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"]);
|
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.
|
// 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() {
|
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
|
// 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.
|
// 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
|
let tiles = cmd
|
||||||
.get_envs()
|
.get_envs()
|
||||||
.find(|(key, _)| *key == "ODIAN_TILE_SERVER_URL")
|
.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
|
// 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.
|
// runs with the found folder first, so the launch goes on without a restart.
|
||||||
let node = Path::new(r"C:\Program Files\nodejs");
|
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
|
let path = cmd
|
||||||
.get_envs()
|
.get_envs()
|
||||||
.find(|(key, _)| *key == "PATH")
|
.find(|(key, _)| *key == "PATH")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "ODIAN Launcher",
|
"productName": "ODIAN Launcher",
|
||||||
"version": "0.1.39",
|
"version": "0.1.40",
|
||||||
"identifier": "fr.odian.launcher",
|
"identifier": "fr.odian.launcher",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -209,6 +209,14 @@
|
|||||||
<p class="secondary">
|
<p class="secondary">
|
||||||
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
|
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
|
||||||
</p>
|
</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?")}
|
{@render driveLink("Have an ODIAN drive?")}
|
||||||
{:else if action === "offline_unavailable"}
|
{:else if action === "offline_unavailable"}
|
||||||
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
|
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
|
||||||
@@ -229,6 +237,14 @@
|
|||||||
<p class="secondary">
|
<p class="secondary">
|
||||||
{#if checking}
|
{#if checking}
|
||||||
Checking {site}…
|
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}
|
{:else if s?.online}
|
||||||
ODIAN <strong>{s.installed}</strong> is up to date.
|
ODIAN <strong>{s.installed}</strong> is up to date.
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -72,10 +72,16 @@ export function deriveAction(s: Status | null): ActionKind {
|
|||||||
}
|
}
|
||||||
// idle or checking
|
// idle or checking
|
||||||
if (s.installed === null) {
|
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";
|
if (!s.online) return "offline_unavailable";
|
||||||
return s.signed_in ? "install" : "sign_in";
|
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.
|
// 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";
|
if (s.operator === null) return s.online ? "sign_in" : "signed_out_offline";
|
||||||
return "launch";
|
return "launch";
|
||||||
|
|||||||
+23
-3
@@ -226,7 +226,8 @@ function derive(s: Status): Status {
|
|||||||
available,
|
available,
|
||||||
signed_in: s.online && s.signed_in,
|
signed_in: s.online && s.signed_in,
|
||||||
update_required,
|
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,
|
available: CURRENT,
|
||||||
update_required: false,
|
update_required: false,
|
||||||
can_launch: false,
|
can_launch: false,
|
||||||
|
dev_run: false,
|
||||||
|
dhp_direct: false,
|
||||||
|
dhp_blocked: null,
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
error: null,
|
error: null,
|
||||||
install_dir: DEFAULT_DIR,
|
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
|
// 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.
|
// dev-run marker can be seen without a licence file.
|
||||||
Object.assign(s, {
|
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,
|
operator: OPERATOR,
|
||||||
signed_in: true,
|
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" },
|
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
|
||||||
});
|
});
|
||||||
break;
|
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" };
|
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
|
// 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.
|
// 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) {
|
if (this.status.installed) {
|
||||||
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
||||||
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export interface Status {
|
|||||||
update_required: boolean;
|
update_required: boolean;
|
||||||
/** installed && !update_required && phase is idle. */
|
/** installed && !update_required && phase is idle. */
|
||||||
can_launch: boolean;
|
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;
|
phase: Phase;
|
||||||
/** Last error, human readable. */
|
/** Last error, human readable. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -331,6 +337,7 @@ export const MOCK_SCENARIOS = [
|
|||||||
"sync-conflicts",
|
"sync-conflicts",
|
||||||
"error",
|
"error",
|
||||||
"dev",
|
"dev",
|
||||||
|
"dev-dhp",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
|
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user