update
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
//! Dev mode's key: a licence signed with the release key.
|
||||
//!
|
||||
//! Dev mode runs ODIAN out of a source tree instead of an installed version, with every
|
||||
//! component in its own console. That is exactly what a field PC must never wander into,
|
||||
//! so the launcher shows no trace of it - no switch, no setting, no mention - unless a
|
||||
//! valid licence is sitting in `state\dev-licence.json`.
|
||||
//!
|
||||
//! The licence is a JSON document signed with the SAME Ed25519 key as every pack, verified
|
||||
//! by the same code against the public half already compiled into this binary. No network,
|
||||
//! no second key to look after, and nothing new to get wrong: an operator cannot write one,
|
||||
//! because writing one needs the release key.
|
||||
//!
|
||||
//! What this is NOT: a wall against the person who owns the PC. They can run the repo's
|
||||
//! own .bat files without asking the launcher anything. The point is that a field machine
|
||||
//! cannot DRIFT into dev mode - not by a stray click, not by copying someone's settings -
|
||||
//! and that is a thing a signature can actually promise.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// What the document must call itself. A signed manifest is not a signed licence: without
|
||||
/// this, any signed pack document dropped in as `dev-licence.json` would unlock dev mode.
|
||||
const PRODUCT: &str = "odian-dev";
|
||||
|
||||
/// A licence this PC may use, once it has been checked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Licence {
|
||||
/// The machine it was issued for, when it is tied to one. None lets it travel - which is
|
||||
/// what a dev kit on an SSD wants, since the PC it is plugged into changes.
|
||||
#[serde(default)]
|
||||
pub machine: Option<String>,
|
||||
/// The install it was issued for, when it is tied to one. Binding to the install on a
|
||||
/// drive ties the licence to THE DRIVE: it works wherever the drive is, and nowhere else.
|
||||
#[serde(default)]
|
||||
pub install_id: Option<String>,
|
||||
/// `YYYY-MM-DD`, the last day it works. A leaked licence stops working on its own.
|
||||
pub expires_at: String,
|
||||
/// Free text for whoever has to work out later why this PC has one.
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
/// `"team"` for a licence that names no machine and no install because it is meant to
|
||||
/// live in the launcher's own SOURCE REPO: whoever can clone the source gets dev mode.
|
||||
///
|
||||
/// The credential is repo access, which is a real gate - an operator has the installed
|
||||
/// launcher, never the repo - but it IS weaker than a per-machine licence, so it has to
|
||||
/// be said out loud in the signed document. A licence that merely forgot its binding is
|
||||
/// still refused as `Unbound`; only one that claims this scope is let through.
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
impl Licence {
|
||||
/// Does this licence travel with the source repo rather than name a machine?
|
||||
pub fn is_team(&self) -> bool {
|
||||
self.scope.as_deref() == Some("team")
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a PC does not have dev mode. Everything but `Missing` is worth a line in the log:
|
||||
/// a licence that is present and refused is someone wondering why nothing happened.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// No licence file. The ordinary case on every operator PC, and not a fault.
|
||||
Missing,
|
||||
Unreadable(String),
|
||||
/// Signed by another key, or altered after signing.
|
||||
NotOurs,
|
||||
/// Signed, but not a dev licence.
|
||||
NotALicence,
|
||||
Expired { on: String, today: String },
|
||||
ForAnotherMachine { issued_to: String, this: String },
|
||||
ForAnotherInstall,
|
||||
/// Tied to nothing: it would open dev mode on every PC it was copied to.
|
||||
Unbound,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// One line for the log. `Missing` has none: a PC without a licence is not a problem.
|
||||
pub fn explain(&self) -> Option<String> {
|
||||
Some(match self {
|
||||
Refusal::Missing => return None,
|
||||
Refusal::Unreadable(why) => format!("The dev licence could not be read: {why}"),
|
||||
Refusal::NotOurs => "The dev licence is not signed by the ODIAN release key.".to_string(),
|
||||
Refusal::NotALicence => "That file is signed, but it is not a dev licence.".to_string(),
|
||||
Refusal::Expired { on, today } => format!("The dev licence expired on {on} (today is {today})."),
|
||||
Refusal::ForAnotherMachine { issued_to, this } => {
|
||||
format!("The dev licence was issued for {issued_to}, and this PC is {this}.")
|
||||
}
|
||||
Refusal::ForAnotherInstall => "The dev licence was issued for another install of ODIAN.".to_string(),
|
||||
Refusal::Unbound => "The dev licence names neither a machine nor an install, so it is refused.".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a licence may sit, best first.
|
||||
///
|
||||
/// The checkout folder comes first because that is where a developer keeps their kit: the
|
||||
/// licence travels with the checkouts on the same drive, and a PC that borrows the drive
|
||||
/// borrows the whole thing. The install folder stays supported and is the ONLY place a PC
|
||||
/// with no licence yet can use - the Dev section is hidden until one is found, so there is
|
||||
/// no way to name a checkout folder before then.
|
||||
///
|
||||
/// Signed and bound either way, so where it is read from changes nothing about what it
|
||||
/// permits: a file in either place is refused unless it names this machine or this install.
|
||||
pub fn licence_paths(install_dir: &Path, dev_root: &str) -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
let dev_root = dev_root.trim();
|
||||
if !dev_root.is_empty() {
|
||||
paths.push(Path::new(dev_root).join("dev-licence.json"));
|
||||
}
|
||||
paths.extend(beside_the_exe());
|
||||
paths.push(licence_path(install_dir));
|
||||
paths
|
||||
}
|
||||
|
||||
/// Licence places derived from where this launcher is RUNNING, which is what makes a team
|
||||
/// licence committed to the repo work with nothing to set up.
|
||||
///
|
||||
/// Beside the exe, and two levels above it: a launcher built from the source sits in
|
||||
/// `target\<profile>\`, so two up is the repo root where the licence is committed. An
|
||||
/// INSTALLED launcher finds nothing at either place unless someone deliberately put a
|
||||
/// signed licence there, which an operator cannot produce.
|
||||
fn beside_the_exe() -> Vec<PathBuf> {
|
||||
let Ok(exe) = std::env::current_exe() else { return Vec::new() };
|
||||
let Some(dir) = exe.parent() else { return Vec::new() };
|
||||
let mut out = vec![dir.join("dev-licence.json")];
|
||||
if let Some(repo) = dir.parent().and_then(Path::parent) {
|
||||
out.push(repo.join("dev-licence.json"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn licence_path(install_dir: &Path) -> PathBuf {
|
||||
install_dir.join("state").join("dev-licence.json")
|
||||
}
|
||||
|
||||
/// This PC's name, as a licence names it. Empty when Windows will not say, which no
|
||||
/// licence can match - so dev mode stays shut rather than opening for everyone.
|
||||
pub fn machine_name() -> String {
|
||||
std::env::var("COMPUTERNAME").unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read and check the licence at `path`.
|
||||
///
|
||||
/// `today` is `YYYY-MM-DD`. Dates are compared as text, which is exactly right for that
|
||||
/// shape and needs no date library to get wrong.
|
||||
pub fn read(
|
||||
path: &Path,
|
||||
machine: &str,
|
||||
install_id: &str,
|
||||
public_key: &str,
|
||||
today: &str,
|
||||
) -> Result<Licence, Refusal> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(Refusal::Missing),
|
||||
Err(e) => return Err(Refusal::Unreadable(e.to_string())),
|
||||
};
|
||||
let document: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|e| Refusal::Unreadable(format!("it is not valid JSON: {e}")))?;
|
||||
|
||||
// The signature FIRST: everything below reads fields, and a field is only worth reading
|
||||
// once it is known to be the field that was signed.
|
||||
odian_packs::signing::verify(&document, public_key).map_err(|_| Refusal::NotOurs)?;
|
||||
|
||||
if document.get("product").and_then(serde_json::Value::as_str) != Some(PRODUCT) {
|
||||
return Err(Refusal::NotALicence);
|
||||
}
|
||||
let licence: Licence =
|
||||
serde_json::from_value(document).map_err(|e| Refusal::Unreadable(format!("a field is missing or wrong: {e}")))?;
|
||||
|
||||
if licence.expires_at.as_str() < today {
|
||||
return Err(Refusal::Expired { on: licence.expires_at, today: today.to_string() });
|
||||
}
|
||||
// Every binding it names must hold, and it must name one - unless it says it is a TEAM
|
||||
// licence, which is bound to holding the source repo instead. An unbound licence that
|
||||
// claims nothing is a key that opens dev mode on whatever PC it is copied to, which is
|
||||
// not a licence at all; one that claims `scope: "team"` was signed to be exactly that,
|
||||
// and the signature is what makes the claim trustworthy.
|
||||
if licence.machine.is_none() && licence.install_id.is_none() && !licence.is_team() {
|
||||
return Err(Refusal::Unbound);
|
||||
}
|
||||
if let Some(want) = &licence.machine {
|
||||
// Case-insensitive: Windows reports the machine name in whatever case it likes. An
|
||||
// empty name here is a machine that would not say, and matches nothing.
|
||||
if machine.is_empty() || !want.eq_ignore_ascii_case(machine) {
|
||||
return Err(Refusal::ForAnotherMachine { issued_to: want.clone(), this: machine.to_string() });
|
||||
}
|
||||
}
|
||||
if let Some(want) = &licence.install_id {
|
||||
if want != install_id {
|
||||
return Err(Refusal::ForAnotherInstall);
|
||||
}
|
||||
}
|
||||
Ok(licence)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A licence signed with a THROWAWAY key, and that key's public half. Signed once with
|
||||
/// `odian_packs.signing` so these tests exercise the real verifier rather than a stub,
|
||||
/// without this crate needing to sign anything.
|
||||
#[test]
|
||||
fn a_team_licence_opens_dev_mode_on_any_pc_and_a_bare_unbound_one_does_not() {
|
||||
// The security-critical pair. Both name no machine and no install; the ONLY difference
|
||||
// is the signed `scope: "team"`, so the release key - not the file's shape - is what
|
||||
// decides. This is the whole of the weaker rule, and it must not widen by accident.
|
||||
let dir = std::env::temp_dir().join(format!("odian-team-{}", uuid::Uuid::new_v4().simple()));
|
||||
let team = write(&dir, include_str!("../tests/dev-licence/team.json"));
|
||||
let got = read(&team, "ANY-PC", "any-install", TEST_KEY.trim(), "2026-09-29")
|
||||
.expect("a signed team licence works on a PC it never named");
|
||||
assert!(got.is_team());
|
||||
assert_eq!(got.machine, None);
|
||||
assert_eq!(got.install_id, None);
|
||||
// Same PC, same key, no scope: still refused.
|
||||
let bare = write(&dir.join("bare"), include_str!("../tests/dev-licence/unbound.json"));
|
||||
assert_eq!(
|
||||
read(&bare, "ANY-PC", "any-install", TEST_KEY.trim(), "2026-09-29"),
|
||||
Err(Refusal::Unbound),
|
||||
"a licence that merely forgot its binding must not inherit the team rule"
|
||||
);
|
||||
// And a team licence still expires like any other.
|
||||
assert!(matches!(
|
||||
read(&team, "ANY-PC", "any-install", TEST_KEY.trim(), "2027-01-01"),
|
||||
Err(Refusal::Expired { .. })
|
||||
));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_licence_that_forgot_its_binding_is_still_refused() {
|
||||
// The team scope has to be CLAIMED. Without it, "no machine and no install" stays what
|
||||
// it always was: a key for every PC it is copied to.
|
||||
let mut l = Licence {
|
||||
machine: None,
|
||||
install_id: None,
|
||||
expires_at: "2099-01-01".to_string(),
|
||||
note: None,
|
||||
scope: None,
|
||||
};
|
||||
assert!(!l.is_team());
|
||||
l.scope = Some("machine".to_string());
|
||||
assert!(!l.is_team(), "an unknown scope is not the team scope");
|
||||
l.scope = Some("team".to_string());
|
||||
assert!(l.is_team());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_checkout_folder_is_looked_at_before_the_install() {
|
||||
// A developer keeps the licence with their checkouts, on the same drive; the install
|
||||
// folder stays supported because it is the only place a PC with NO licence yet can use
|
||||
// (the Dev section, and so the checkout folder field, is hidden until one is found).
|
||||
let install = Path::new(r"E:\ODIAN");
|
||||
let both = licence_paths(install, r"E:\ODIAN_Dev");
|
||||
assert_eq!(both.first(), Some(&PathBuf::from(r"E:\ODIAN_Dev\dev-licence.json")), "the kit comes first");
|
||||
assert_eq!(
|
||||
both.last(),
|
||||
Some(&PathBuf::from(r"E:\ODIAN\state\dev-licence.json")),
|
||||
"the install is the last resort, and the only one a PC with no licence yet can use"
|
||||
);
|
||||
|
||||
// No checkout folder named yet: the install is still looked at.
|
||||
let none = licence_paths(install, "");
|
||||
assert!(!none.contains(&PathBuf::from(r"E:\ODIAN_Dev\dev-licence.json")));
|
||||
assert_eq!(none.last(), Some(&PathBuf::from(r"E:\ODIAN\state\dev-licence.json")));
|
||||
assert_eq!(licence_paths(install, " "), none, "blank is not a folder");
|
||||
assert_eq!(licence_paths(install, r" E:\ODIAN_Dev "), both, "trimmed like every other path setting");
|
||||
// Whatever the exe adds, every path is a dev-licence.json and none is a duplicate.
|
||||
assert!(both.iter().all(|p| p.file_name().unwrap() == "dev-licence.json"));
|
||||
}
|
||||
|
||||
const TEST_KEY: &str = include_str!("../tests/dev-licence/test-key.pub");
|
||||
const SIGNED: &str = include_str!("../tests/dev-licence/valid.json");
|
||||
|
||||
fn write(dir: &Path, text: &str) -> PathBuf {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let path = dir.join("dev-licence.json");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn temp() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("odian-devmode-{}", uuid::Uuid::new_v4().simple()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_licence_for_this_machine_opens_dev_mode() {
|
||||
let dir = temp();
|
||||
let path = write(&dir, SIGNED);
|
||||
let got = read(&path, "DEV-PC", "install-1", TEST_KEY.trim(), "2026-09-28");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let licence = got.expect("a valid licence");
|
||||
assert_eq!(licence.machine.as_deref(), Some("DEV-PC"));
|
||||
assert_eq!(licence.note.as_deref(), Some("offline dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_licence_is_the_ordinary_case_and_says_nothing() {
|
||||
let dir = temp();
|
||||
let err = read(&dir.join("dev-licence.json"), "DEV-PC", "install-1", TEST_KEY.trim(), "2026-09-28");
|
||||
assert_eq!(err, Err(Refusal::Missing));
|
||||
assert_eq!(Refusal::Missing.explain(), None, "an operator PC is not misconfigured for lacking one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_but_the_release_key_can_write_one() {
|
||||
// The whole lock. Altering any signed field - here the machine, to borrow someone
|
||||
// else's licence - breaks the signature, and an unsigned file is simply not ours.
|
||||
let dir = temp();
|
||||
let tampered = SIGNED.replace("DEV-PC", "FIELD-PC");
|
||||
let path = write(&dir, &tampered);
|
||||
let got = read(&path, "FIELD-PC", "install-1", TEST_KEY.trim(), "2026-09-28");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(got, Err(Refusal::NotOurs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_licence_signed_by_another_key_is_refused() {
|
||||
let dir = temp();
|
||||
let path = write(&dir, SIGNED);
|
||||
// The real release key: this licence was signed with the throwaway one.
|
||||
let got = read(&path, "DEV-PC", "install-1", odian_packs::bundled_public_key(), "2026-09-28");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(got, Err(Refusal::NotOurs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signed_pack_manifest_is_not_a_licence() {
|
||||
// Both are signed with the same key, so the signature alone cannot tell them apart:
|
||||
// without the product check, any manifest renamed to dev-licence.json would unlock it.
|
||||
let dir = temp();
|
||||
let manifest = SIGNED.replace(r#""product": "odian-dev""#, r#""product": "odian""#);
|
||||
let path = write(&dir, &manifest);
|
||||
let got = read(&path, "DEV-PC", "install-1", TEST_KEY.trim(), "2026-09-28");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
// Changing the product breaks the signature too; what matters is that it is refused.
|
||||
assert!(matches!(got, Err(Refusal::NotOurs) | Err(Refusal::NotALicence)), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_expires_on_its_own() {
|
||||
let dir = temp();
|
||||
let path = write(&dir, SIGNED);
|
||||
let got = read(&path, "DEV-PC", "install-1", TEST_KEY.trim(), "2027-01-01");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(got, Err(Refusal::Expired { on: "2026-12-31".to_string(), today: "2027-01-01".to_string() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_day_still_works() {
|
||||
let dir = temp();
|
||||
let path = write(&dir, SIGNED);
|
||||
let got = read(&path, "DEV-PC", "install-1", TEST_KEY.trim(), "2026-12-31");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(got.is_ok(), "expires_at is the last day it works, not the first it does not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_it_to_another_pc_does_nothing() {
|
||||
let dir = temp();
|
||||
let path = write(&dir, SIGNED);
|
||||
let got = read(&path, "SOMEONE-ELSES-PC", "install-1", TEST_KEY.trim(), "2026-09-28");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
Err(Refusal::ForAnotherMachine { issued_to: "DEV-PC".to_string(), this: "SOMEONE-ELSES-PC".to_string() })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_that_will_not_name_itself_gets_nothing() {
|
||||
// `machine_name()` is empty when Windows does not answer. An empty name must not
|
||||
// match an empty licence field and open dev mode on every PC at once.
|
||||
let dir = temp();
|
||||
let blank = SIGNED.replace(r#""machine": "DEV-PC""#, r#""machine": """#);
|
||||
let path = write(&dir, &blank);
|
||||
let got = read(&path, "", "install-1", TEST_KEY.trim(), "2026-09-28");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(got.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_licence_tied_to_the_drives_install_travels_with_the_drive() {
|
||||
// The dev kit lives on an SSD, so the PC changes and the install does not. Bound this
|
||||
// way the licence works wherever the drive is plugged in - and nowhere without it.
|
||||
let dir = temp();
|
||||
let path = write(&dir, include_str!("../tests/dev-licence/drive.json"));
|
||||
let here = read(&path, "DEV-PC", "drive-install", TEST_KEY.trim(), "2026-09-28");
|
||||
let elsewhere = read(&path, "SOME-OTHER-PC", "drive-install", TEST_KEY.trim(), "2026-09-28");
|
||||
let without_the_drive = read(&path, "DEV-PC", "a-different-install", TEST_KEY.trim(), "2026-09-28");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(here.is_ok(), "{here:?}");
|
||||
assert!(elsewhere.is_ok(), "the PC is not what it is tied to: {elsewhere:?}");
|
||||
assert_eq!(without_the_drive, Err(Refusal::ForAnotherInstall));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_licence_tied_to_nothing_is_refused() {
|
||||
let dir = temp();
|
||||
let path = write(&dir, include_str!("../tests/dev-licence/unbound.json"));
|
||||
let got = read(&path, "DEV-PC", "install-1", TEST_KEY.trim(), "2026-09-28");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(got, Err(Refusal::Unbound), "it would unlock every PC it was copied to");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_licence_tied_to_an_install_follows_that_install() {
|
||||
let dir = temp();
|
||||
let path = write(&dir, SIGNED);
|
||||
let got = read(&path, "DEV-PC", "another-install", TEST_KEY.trim(), "2026-09-28");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(got, Err(Refusal::ForAnotherInstall));
|
||||
}
|
||||
}
|
||||
+336
-11
@@ -9,6 +9,7 @@ use std::sync::Arc;
|
||||
use odian_packs::{InstallOptions, Progress, Source, Stage};
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
@@ -21,6 +22,7 @@ use crate::site::{self, SessionRefresh, TokenCheck};
|
||||
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
|
||||
use crate::state::{self, LauncherState, SyncOrigin};
|
||||
use crate::sync::{self, SyncError, SyncStatus};
|
||||
use crate::devmode::{self, Licence};
|
||||
use crate::tilesync::{self, Waiting};
|
||||
use crate::tokens::{self, SiteToken};
|
||||
|
||||
@@ -37,6 +39,8 @@ pub enum Phase {
|
||||
/// Taking tiles the DHP baked while this PC was away. The map files are being written
|
||||
/// under ODIAN, so it does not start meanwhile.
|
||||
PullingTiles,
|
||||
/// Dev mode: compiling the checkout before it launches.
|
||||
Building,
|
||||
/// Deleting what the operator unticked in the marketplace. A tile set is hundreds of
|
||||
/// thousands of files, so this is minutes of work with something to show for it.
|
||||
Removing,
|
||||
@@ -86,6 +90,9 @@ pub struct Status {
|
||||
/// Marketplace items the operator chose that are not all on this PC. None when nothing
|
||||
/// is installed or nothing is missing: ODIAN still runs, with less map than was asked for.
|
||||
pub unfinished: Option<Unfinished>,
|
||||
/// This PC's dev licence, when it has a valid one. None on every operator PC, and the
|
||||
/// window shows no trace of dev mode while it is None - not a switch, not a mention.
|
||||
pub dev: Option<Licence>,
|
||||
}
|
||||
|
||||
/// What is missing of what was chosen.
|
||||
@@ -170,6 +177,10 @@ struct Inner {
|
||||
map_problem: Option<String>,
|
||||
tiles_waiting: Option<Waiting>,
|
||||
unfinished: Option<Unfinished>,
|
||||
dev: Option<Licence>,
|
||||
/// The last refusal said out loud, so a licence that is present and refused is explained
|
||||
/// once rather than on every check.
|
||||
dev_refusal: Option<String>,
|
||||
/// What the last count found on disk, kept for as long as nothing can have changed it.
|
||||
market_checked: Option<Vec<market::Check>>,
|
||||
/// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`).
|
||||
@@ -217,6 +228,7 @@ impl Inner {
|
||||
map_problem: self.map_problem.clone(),
|
||||
tiles_waiting: self.tiles_waiting.clone(),
|
||||
unfinished: self.unfinished.clone(),
|
||||
dev: self.dev.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,7 +350,7 @@ impl Launcher {
|
||||
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
|
||||
Ok(Launcher {
|
||||
http,
|
||||
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, map_from_site: None, map_problem: None, tiles_waiting: None, unfinished: None, market_checked: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }),
|
||||
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, map_from_site: None, map_problem: None, tiles_waiting: None, unfinished: None, dev: None, dev_refusal: None, market_checked: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }),
|
||||
stack: Mutex::new(None),
|
||||
stack_states: std::sync::Mutex::new(Vec::new()),
|
||||
sign_in_cancel: Notify::new(),
|
||||
@@ -498,7 +510,13 @@ impl Launcher {
|
||||
fresh
|
||||
};
|
||||
if let Some(why) = announce {
|
||||
self.log(app, "warn", format!("Your DHP did not answer. {why}"));
|
||||
// A blank address is a CHOICE - it is how a PC is put offline, and how an offline
|
||||
// site is rehearsed on one that has a line out. Reporting it as a fault would send
|
||||
// someone looking for a network problem they created on purpose.
|
||||
match site_url.is_empty() {
|
||||
true => self.log(app, "info", "No DHP address is set: this PC is running offline."),
|
||||
false => self.log(app, "warn", format!("Your DHP did not answer. {why}")),
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
@@ -523,6 +541,7 @@ impl Launcher {
|
||||
}
|
||||
self.check_tiles(app).await;
|
||||
self.read_unfinished(app).await;
|
||||
self.read_dev_licence(app).await;
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.phase == Phase::Checking {
|
||||
@@ -555,6 +574,73 @@ impl Launcher {
|
||||
Some((site_url, token, sets, seq, layout.data_dir()))
|
||||
}
|
||||
|
||||
/// Who dev mode is running as, when that is somebody OTHER than the operator who signed
|
||||
/// in. The licence is re-checked through `dev_engine`, so this is None on any PC that
|
||||
/// cannot run dev mode at all.
|
||||
fn dev_swap(inner: &Inner) -> Option<String> {
|
||||
Self::dev_engine(inner)?;
|
||||
swapped_user(&inner.settings.dev_user, inner.state.operator.as_deref()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// The checkout a launch should take the client from, or None for the installed version.
|
||||
///
|
||||
/// Three things must hold, in this order: a valid licence, the switch on, and a folder
|
||||
/// that really holds `ODIAN_Engine`. The licence is checked HERE rather than trusted from
|
||||
/// the settings file, so editing settings.json on an operator's PC does nothing at all.
|
||||
fn dev_engine(inner: &Inner) -> Option<PathBuf> {
|
||||
inner.dev.as_ref()?;
|
||||
if !inner.settings.dev_mode || inner.settings.dev_root.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let repo = PathBuf::from(inner.settings.dev_root.trim()).join("ODIAN_Engine");
|
||||
repo.join("tools").join("build").join("build.bat").is_file().then_some(repo)
|
||||
}
|
||||
|
||||
/// Look for this PC's dev licence.
|
||||
///
|
||||
/// Cheap enough to do on every check - one small file - so dropping a licence in is
|
||||
/// picked up without restarting the launcher, and revoking one takes effect the same way.
|
||||
async fn read_dev_licence(&self, app: &AppHandle) {
|
||||
let (install_dir, install_id, dev_root) = {
|
||||
let inner = self.inner.lock().await;
|
||||
(inner.settings.install_path(), inner.state.install_id.clone(), inner.settings.dev_root.clone())
|
||||
};
|
||||
let today = utc_text(now_secs());
|
||||
// The first licence found wins. A refusal is only worth saying when NOTHING was
|
||||
// accepted, and then it is the first real one: "there is no file here" said about a
|
||||
// folder that was never meant to hold one explains nothing.
|
||||
let mut licence = None;
|
||||
let mut refusal = None;
|
||||
for path in devmode::licence_paths(&install_dir, &dev_root) {
|
||||
match devmode::read(
|
||||
&path,
|
||||
&devmode::machine_name(),
|
||||
&install_id,
|
||||
odian_packs::bundled_public_key(),
|
||||
&today[..10],
|
||||
) {
|
||||
Ok(found) => {
|
||||
licence = Some(found);
|
||||
break;
|
||||
}
|
||||
Err(why) => refusal = refusal.or_else(|| why.explain().map(|text| format!("{text} ({})", path.display()))),
|
||||
}
|
||||
}
|
||||
let refusal = licence.is_none().then_some(refusal).flatten();
|
||||
let announce = {
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.dev = licence;
|
||||
let fresh = refusal.clone().filter(|why| inner.dev_refusal.as_ref() != Some(why));
|
||||
inner.dev_refusal = refusal;
|
||||
fresh
|
||||
};
|
||||
// A PC with no licence is the ordinary case and says nothing. One with a licence it
|
||||
// cannot use is someone wondering why dev mode did not appear.
|
||||
if let Some(why) = announce {
|
||||
self.log(app, "warn", why);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the operator chose and has not fully got. Read from the installers' markers, so
|
||||
/// it costs eight small files rather than a walk of the tile store.
|
||||
async fn read_unfinished(&self, app: &AppHandle) {
|
||||
@@ -737,6 +823,13 @@ impl Launcher {
|
||||
ai_url: normalize_url(&incoming.ai_url),
|
||||
install_dir: incoming.install_dir.trim().to_string(),
|
||||
site_name: incoming.site_name.or_else(|| inner.settings.site_name.clone()),
|
||||
dev_root: incoming.dev_root.trim().to_string(),
|
||||
dev_user: incoming.dev_user.trim().to_string(),
|
||||
dev_build: incoming.dev_build,
|
||||
dev_target: incoming.dev_target,
|
||||
// A PC with no licence cannot turn dev mode on, whatever it sends: `dev_engine`
|
||||
// checks the licence anyway, and refusing it here keeps the file honest too.
|
||||
dev_mode: incoming.dev_mode && inner.dev.is_some(),
|
||||
};
|
||||
if next.install_dir.is_empty() {
|
||||
next.install_dir = inner.settings.install_dir.clone();
|
||||
@@ -746,6 +839,16 @@ impl Launcher {
|
||||
if let Some(problem) = crate::settings::portable_folder_problem(&next.install_path()) {
|
||||
return Err(problem);
|
||||
}
|
||||
// A checkout folder that does not hold one is refused HERE rather than shrugged off at
|
||||
// launch: `dev_engine` would quietly hand back the installed version, and the window
|
||||
// would go on saying the run came from the checkout. Being told the folder is wrong
|
||||
// while looking at it beats wondering later why an edit changed nothing.
|
||||
if next.dev_mode {
|
||||
let build = PathBuf::from(&next.dev_root).join("ODIAN_Engine").join("tools").join("build").join("build.bat");
|
||||
if !build.is_file() {
|
||||
return Err(format!("{} is not there: the dev folder is the one HOLDING ODIAN_Engine.", build.display()));
|
||||
}
|
||||
}
|
||||
next.save(app)?;
|
||||
if next.install_dir != inner.settings.install_dir {
|
||||
inner.state = LauncherState::load(&next.install_path())?;
|
||||
@@ -1666,6 +1769,8 @@ impl Launcher {
|
||||
inner.state.save(&install_dir)?;
|
||||
}
|
||||
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)?;
|
||||
@@ -1673,13 +1778,20 @@ impl Launcher {
|
||||
inner.error = None;
|
||||
StackConfig {
|
||||
layout,
|
||||
operator,
|
||||
// The client runs as whoever dev mode named, else as the operator who signed in.
|
||||
operator: dev_user.clone().unwrap_or(operator),
|
||||
mint_secret: inner.state.mint_secret.clone(),
|
||||
pg_password: inner.state.pg_password.clone(),
|
||||
install_id: inner.state.install_id.clone(),
|
||||
dev_engine: dev,
|
||||
dev_user: dev_user.clone(),
|
||||
dev_target: inner.settings.dev_target,
|
||||
tile_sets,
|
||||
site_tile_sets,
|
||||
upstream_url: inner.engine_url.clone(),
|
||||
// A swapped run never reaches the DHP: no upstream token is written, so the local
|
||||
// API keeps this PC's own data and its writes stay here. Off the DHP means off it
|
||||
// whether the work would travel by a sync or by the API forwarding it live.
|
||||
upstream_url: dev_user.is_none().then(|| inner.engine_url.clone()).flatten(),
|
||||
ai_url: inner.settings.ai_url.clone(),
|
||||
site_url: inner.settings.site_url.clone(),
|
||||
}
|
||||
@@ -1756,12 +1868,103 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the checkout's API and client before launching, each in its own console.
|
||||
///
|
||||
/// The repo's own scripts, run as a developer runs them - not reimplemented here. They
|
||||
/// carry guards a launcher has no business duplicating (the layering check, the shader
|
||||
/// check, the dep-file prune that exists because a stale object once shipped a crash),
|
||||
/// and they are what gets fixed when a build breaks.
|
||||
///
|
||||
/// Inherits this process's environment rather than the swept one the stack gets: these
|
||||
/// are build scripts, and they reach for whatever a developer's shell has.
|
||||
async fn build_checkout(&self, app: &AppHandle, config: &StackConfig) -> Result<(), String> {
|
||||
let repo = config.dev_engine.as_ref().expect("only called for a dev run").clone();
|
||||
let repo = repo.as_path();
|
||||
self.set_phase(app, Phase::Building).await;
|
||||
// The API always: both clients read their data from it. The .exe only when this run
|
||||
// starts one - a web-only run has no use for a twenty-minute C++ build.
|
||||
let mut steps = vec![("the Engine API", repo.join("_API").join("build.bat"), Vec::new())];
|
||||
if config.runs_client() {
|
||||
steps.push(("ODIAN", repo.join("tools").join("build").join("build.bat"), vec!["Release"]));
|
||||
}
|
||||
for (what, script, args) in steps {
|
||||
if !script.is_file() {
|
||||
return Err(format!("{} is missing from the checkout.", script.display()));
|
||||
}
|
||||
self.log(app, "info", format!("Building {what} from {}", repo.display()));
|
||||
// `named` stays behind for the error messages; the closure takes its own copy.
|
||||
let named = script.display().to_string();
|
||||
let (dir, script) = (repo.to_path_buf(), script.clone());
|
||||
let status = tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut cmd = std::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg(&script).args(&args).current_dir(&dir);
|
||||
// The client's build script links a GUI-subsystem exe when this is set, and a
|
||||
// GUI exe given a console writes nothing to it. Packaging sets it; a dev build
|
||||
// must not inherit it, or dev mode's consoles come up empty.
|
||||
cmd.env_remove("ODIAN_WINDOWED");
|
||||
#[cfg(windows)]
|
||||
stack::give_console(&mut cmd);
|
||||
cmd.status()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("could not run {named}: {e}"))?
|
||||
.map_err(|e| format!("could not run {named}: {e}"))?;
|
||||
if !status.success() {
|
||||
// The console is still on screen with the compiler's own words, which say more
|
||||
// than any code this could report.
|
||||
return Err(format!("Building {what} failed ({status}). Its window has the errors."));
|
||||
}
|
||||
}
|
||||
if config.runs_web() {
|
||||
self.install_web(app, repo).await?;
|
||||
}
|
||||
self.log(app, "info", "The checkout is built.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `npm install` in `_WEB` when what is installed cannot match what is asked for.
|
||||
///
|
||||
/// Not on every launch - npm is slow even with nothing to do, and this sits in front of a
|
||||
/// Launch - but "node_modules exists" is NOT the question. A pull that adds a dependency
|
||||
/// leaves the folder in place and the package missing, and Vite then fails with
|
||||
/// `Failed to resolve import "<name>"`, which reads like broken source rather than a
|
||||
/// stale install. So compare the MANIFESTS against the folder and install when either is
|
||||
/// newer. (Seen for real: `hls.js` arrived with the Video Module and never got fetched.)
|
||||
async fn install_web(&self, app: &AppHandle, repo: &Path) -> Result<(), String> {
|
||||
let web = repo.join("_WEB");
|
||||
if !web_install_stale(&web) {
|
||||
return Ok(());
|
||||
}
|
||||
self.log(app, "info", format!("Installing the web client's dependencies in {}", web.display()));
|
||||
let status = tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut cmd = std::process::Command::new("cmd");
|
||||
cmd.arg("/c").arg("npm").arg("install").current_dir(&web);
|
||||
#[cfg(windows)]
|
||||
stack::give_console(&mut cmd);
|
||||
cmd.status()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("could not run npm install: {e}"))?
|
||||
.map_err(|e| format!("could not run npm install: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("npm install for the web client failed ({status}). Its window has the errors."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_stack(&self, app: &AppHandle, config: StackConfig) {
|
||||
// Asked for, and only in dev: a Launch that quietly took twenty minutes to compile
|
||||
// would be a worse surprise than a stale binary.
|
||||
if config.dev_engine.is_some() && self.inner.lock().await.settings.dev_build {
|
||||
if let Err(message) = self.build_checkout(app, &config).await {
|
||||
self.finish(app, Some(message)).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A PC that installed before this version keeps its database: give it the new tables.
|
||||
self.add_missing_tables(app, &config).await;
|
||||
// Take in the other operators' work before the API loads it. Never blocks the launch.
|
||||
self.set_phase(app, Phase::Syncing).await;
|
||||
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, true).await;
|
||||
self.sync_unless_swapped(app, &config, true).await;
|
||||
self.set_phase(app, Phase::Launching).await;
|
||||
|
||||
// Live mode: hand the API the DHP token before it starts, so a launch made online
|
||||
@@ -1770,6 +1973,18 @@ impl Launcher {
|
||||
if live_mode {
|
||||
let token = self.site_access_token(app).await.ok();
|
||||
self.write_upstream_token(&config.layout, token.as_deref());
|
||||
} else {
|
||||
// No upstream means none: a token left behind by a run that ended without clearing
|
||||
// it (a crash, a power cut) would have the local API forwarding to the DHP anyway,
|
||||
// which for a swapped dev run is the one thing the interlock exists to prevent.
|
||||
self.write_upstream_token(&config.layout, None);
|
||||
}
|
||||
|
||||
// Written down every dev launch. Which tree a run came from is the first question
|
||||
// asked of any behaviour that surprises someone, and the log outlives the window.
|
||||
let from_checkout = config.dev_parts();
|
||||
if !from_checkout.is_empty() {
|
||||
self.log(app, "warn", format!("Dev mode - from the checkout: {}", from_checkout.join(", ")));
|
||||
}
|
||||
|
||||
let emit = |event: StackEvent| self.record_stack(app, event);
|
||||
@@ -1780,7 +1995,11 @@ impl Launcher {
|
||||
return self.finish(app, Some(message)).await;
|
||||
}
|
||||
};
|
||||
let odian = running.odian();
|
||||
// A web-only run has no Odian.exe to close, so it is watched through the dev server
|
||||
// instead: the run then ends when Stop kills it. With both, closing the .exe ends the
|
||||
// session - the API and the database go with it, so leaving the browser up would only
|
||||
// leave it talking to nothing.
|
||||
let watched = running.odian().or_else(|| running.web());
|
||||
*self.stack.lock().await = Some(running);
|
||||
self.set_phase(app, Phase::Running).await;
|
||||
|
||||
@@ -1795,8 +2014,16 @@ impl Launcher {
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(odian) = odian {
|
||||
stack::wait_exit(&odian).await;
|
||||
if let Some(url) = config.web_url() {
|
||||
self.log(app, "info", format!("The web client is at {url}"));
|
||||
if let Err(e) = app.opener().open_url(url.as_str(), None::<&str>) {
|
||||
// Not a failure: the address is in the log and in the panel, and a developer can
|
||||
// paste it. Refusing the whole launch over a browser that would not open would be.
|
||||
self.log(app, "warn", format!("Could not open a browser: {e}. Open the address above yourself."));
|
||||
}
|
||||
}
|
||||
if let Some(client) = watched {
|
||||
stack::wait_exit(&client).await;
|
||||
}
|
||||
// Odian.exe closed. If Stop already took the stack, it stopped the rest too.
|
||||
live_cancel.cancel();
|
||||
@@ -1807,8 +2034,7 @@ impl Launcher {
|
||||
running.stop(&emit).await;
|
||||
}
|
||||
// Send this session's work while it is fresh; pulling waits for the next launch.
|
||||
self.set_phase(app, Phase::Syncing).await;
|
||||
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, false).await;
|
||||
self.sync_unless_swapped(app, &config, false).await;
|
||||
self.finish(app, None).await;
|
||||
}
|
||||
|
||||
@@ -1823,6 +2049,21 @@ impl Launcher {
|
||||
|
||||
// ── operator-data sync ──────────────────────────────────────────────────
|
||||
|
||||
/// Sync, unless this run is pretending to be another operator. Whatever it authored is
|
||||
/// signed with that name in this PC's database, and the only credential the launcher has
|
||||
/// is the one the REAL operator signed in with - so pushing it would file another
|
||||
/// operator's work under their account, which is the one thing a user swap must not do.
|
||||
/// The run is told so in the log, every time, because a dev who forgets the field is set
|
||||
/// would otherwise take "0 pending" for "everything is up at the DHP".
|
||||
async fn sync_unless_swapped(&self, app: &AppHandle, config: &StackConfig, pull: bool) {
|
||||
if let Some(who) = &config.dev_user {
|
||||
self.log(app, "warn", format!("Dev mode is running as {who}: this PC does not sync with the DHP."));
|
||||
return;
|
||||
}
|
||||
self.set_phase(app, Phase::Syncing).await;
|
||||
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, pull).await;
|
||||
}
|
||||
|
||||
/// Sync with the DHP while the local Engine API is not running. Never fails the caller:
|
||||
/// offline, signed out or refused, the edits stay on this PC for the next connected run.
|
||||
async fn sync_run(&self, app: &AppHandle, layout: &Layout, pg_password: &str, install_id: &str, pull: bool) {
|
||||
@@ -2060,6 +2301,13 @@ impl Launcher {
|
||||
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
|
||||
return Err("ODIAN is busy: sync once it has stopped.".to_string());
|
||||
}
|
||||
// The same interlock a launch applies, said out loud here because the operator asked
|
||||
// for this one by hand and a silent no-op would read as a sync that found nothing.
|
||||
if let Some(who) = Self::dev_swap(&inner) {
|
||||
return Err(format!(
|
||||
"Dev mode is set to run as {who}, so this PC does not sync. Clear the Run as field in Settings to sync as yourself."
|
||||
));
|
||||
}
|
||||
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() {
|
||||
return Err("The local database was never set up. Reinstall from the DHP or a drive.".to_string());
|
||||
@@ -2162,10 +2410,87 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `_WEB` need `npm install` before its dev server will start?
|
||||
///
|
||||
/// True when node_modules is missing, and when `package.json` or `package-lock.json` is
|
||||
/// newer than it: npm stamps the folder as it writes, so a manifest changed since then is
|
||||
/// exactly "what is installed is not what is asked for". A folder whose time cannot be read
|
||||
/// counts as stale - installing needlessly costs seconds, and not installing costs a failed
|
||||
/// launch with a misleading error.
|
||||
fn web_install_stale(web: &Path) -> bool {
|
||||
let modules = web.join("node_modules");
|
||||
if !modules.is_dir() {
|
||||
return true;
|
||||
}
|
||||
let Some(installed) = modified(&modules) else { return true };
|
||||
["package.json", "package-lock.json"]
|
||||
.iter()
|
||||
.filter_map(|name| modified(&web.join(name)))
|
||||
.any(|asked| asked > installed)
|
||||
}
|
||||
|
||||
fn modified(path: &Path) -> Option<std::time::SystemTime> {
|
||||
std::fs::metadata(path).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
/// Only a DIFFERENT name counts as a user swap: typing your own in changes nothing, and
|
||||
/// must not cost you the sync. Compared trimmed, and exactly - two operators whose names
|
||||
/// differ only in case are two accounts on the DHP, so folding case here would take the
|
||||
/// sync away from somebody who swapped to nobody.
|
||||
fn swapped_user<'a>(asked: &'a str, operator: Option<&str>) -> Option<&'a str> {
|
||||
let asked = asked.trim();
|
||||
(!asked.is_empty() && Some(asked) != operator).then_some(asked)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_dependency_added_since_the_last_install_is_noticed() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
// Only FILE times are set here: Windows will not open a directory for writing, so
|
||||
// node_modules keeps the time the filesystem gave it and the manifests are moved
|
||||
// around it. That is also the real shape of the thing - npm stamps the folder, a pull
|
||||
// stamps the manifest.
|
||||
let set = |path: &Path, when: SystemTime| {
|
||||
std::fs::OpenOptions::new().write(true).open(path).unwrap().set_modified(when).unwrap();
|
||||
};
|
||||
let web = std::env::temp_dir().join(format!("odian-web-{}", uuid::Uuid::new_v4().simple()));
|
||||
std::fs::create_dir_all(&web).unwrap();
|
||||
|
||||
assert!(web_install_stale(&web), "no node_modules at all: install");
|
||||
|
||||
std::fs::create_dir_all(web.join("node_modules")).unwrap();
|
||||
std::fs::write(web.join("package.json"), "{}").unwrap();
|
||||
set(&web.join("package.json"), SystemTime::now() - Duration::from_secs(3600));
|
||||
assert!(!web_install_stale(&web), "installed after the manifest: npm must not run every launch");
|
||||
|
||||
// A pull rewrites package.json - hls.js arrives - and node_modules is untouched.
|
||||
set(&web.join("package.json"), SystemTime::now() + Duration::from_secs(3600));
|
||||
assert!(web_install_stale(&web), "a manifest newer than the install means a missing package");
|
||||
|
||||
// The lock file alone is enough, and it is checked independently of package.json.
|
||||
set(&web.join("package.json"), SystemTime::now() - Duration::from_secs(3600));
|
||||
std::fs::write(web.join("package-lock.json"), "{}").unwrap();
|
||||
set(&web.join("package-lock.json"), SystemTime::now() + Duration::from_secs(3600));
|
||||
assert!(web_install_stale(&web), "a newer lock file counts too");
|
||||
|
||||
std::fs::remove_dir_all(&web).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_another_operators_name_is_a_user_swap() {
|
||||
assert_eq!(swapped_user("m2op", Some("jimmy")), Some("m2op"));
|
||||
assert_eq!(swapped_user(" m2op ", Some("jimmy")), Some("m2op"), "trimmed");
|
||||
assert_eq!(swapped_user("jimmy", Some("jimmy")), None, "your own name is not a swap");
|
||||
assert_eq!(swapped_user(" jimmy ", Some("jimmy")), None, "nor is it with spaces round it");
|
||||
assert_eq!(swapped_user("", Some("jimmy")), None, "nothing asked for");
|
||||
assert_eq!(swapped_user(" ", Some("jimmy")), None);
|
||||
assert_eq!(swapped_user("Jimmy", Some("jimmy")), Some("Jimmy"), "another account on the DHP");
|
||||
assert_eq!(swapped_user("m2op", None), Some("m2op"), "nobody signed in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_times_read_as_utc_dates() {
|
||||
assert_eq!(utc_text(0), "1970-01-01 00:00:00Z");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod auth;
|
||||
mod commands;
|
||||
mod devmode;
|
||||
mod fsutil;
|
||||
mod install;
|
||||
mod launcher;
|
||||
|
||||
@@ -27,6 +27,26 @@ pub struct Settings {
|
||||
pub ai_url: String,
|
||||
#[serde(default)]
|
||||
pub install_dir: String,
|
||||
/// Dev mode: the folder holding the checkouts (it contains `ODIAN_Engine`). Empty until
|
||||
/// the operator picks one, and meaningless without a dev licence.
|
||||
#[serde(default)]
|
||||
pub dev_root: String,
|
||||
/// Run the client from `dev_root` instead of the installed version. Honoured ONLY on a
|
||||
/// PC with a valid licence - the launcher checks the licence, not this flag, so setting
|
||||
/// it by hand in settings.json does nothing on an operator's machine.
|
||||
#[serde(default)]
|
||||
pub dev_mode: bool,
|
||||
/// Dev mode: run the client as this operator instead of the one signed in. Empty for the
|
||||
/// signed-in one. Swapping it turns sync OFF for that run - see `StackConfig::dev_user`.
|
||||
#[serde(default)]
|
||||
pub dev_user: String,
|
||||
/// Dev mode: build the checkout before launching.
|
||||
#[serde(default)]
|
||||
pub dev_build: bool,
|
||||
/// Dev mode: which client(s) Launch starts - the .exe, the browser client, or both
|
||||
/// against the one stack. Defaults to the .exe, which is what every launch was before.
|
||||
#[serde(default)]
|
||||
pub dev_target: crate::stack::DevTarget,
|
||||
/// Display name from site.json; the UI's settings form does not edit it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub site_name: Option<String>,
|
||||
@@ -272,6 +292,11 @@ impl Settings {
|
||||
ai_url: normalize_url(&site.ai_url),
|
||||
install_dir: default_install_dir(),
|
||||
site_name: site.name,
|
||||
dev_root: String::new(),
|
||||
dev_mode: false,
|
||||
dev_user: String::new(),
|
||||
dev_build: false,
|
||||
dev_target: crate::stack::DevTarget::default(),
|
||||
};
|
||||
settings.save(app)?;
|
||||
settings
|
||||
|
||||
+445
-15
@@ -11,7 +11,7 @@ use std::process::{Child, Command, ExitStatus, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fsutil::{write_atomic, write_json_atomic};
|
||||
use crate::localdb::Pg;
|
||||
@@ -19,6 +19,9 @@ use crate::localdb::Pg;
|
||||
pub const PG_PORT: u16 = 55432;
|
||||
pub const API_PORT: u16 = 18080;
|
||||
pub const TILES_PORT: u16 = 18081;
|
||||
/// Vite's own default, kept so a developer's bookmarks and the repo's own `npm run dev`
|
||||
/// mean the same address. Only ever used in dev mode.
|
||||
pub const WEB_PORT: u16 = 5173;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
@@ -39,6 +42,8 @@ pub enum Component {
|
||||
Api,
|
||||
Tiles,
|
||||
Odian,
|
||||
/// The browser client's dev server (`_WEB`). Dev mode only.
|
||||
Web,
|
||||
}
|
||||
|
||||
impl Component {
|
||||
@@ -47,6 +52,7 @@ impl Component {
|
||||
Component::Postgres => "Postgres",
|
||||
Component::Api => "the Engine API",
|
||||
Component::Tiles => "the tile server",
|
||||
Component::Web => "the web client",
|
||||
Component::Odian => "ODIAN",
|
||||
}
|
||||
}
|
||||
@@ -140,6 +146,32 @@ impl Layout {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a dev launch puts on screen.
|
||||
///
|
||||
/// The rest of the stack is the same whichever it is - one Postgres, one API, one tile
|
||||
/// server - so the .exe and the browser show the SAME data from the SAME database. That is
|
||||
/// why `Both` exists and is worth the plumbing: the two clients are held to each other all
|
||||
/// over this codebase (the satellite propagator, the network graph, the track filters, the
|
||||
/// APP-6 placement), and a divergence between them is invisible in review and obvious the
|
||||
/// moment they are side by side.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DevTarget {
|
||||
#[default]
|
||||
Client,
|
||||
Web,
|
||||
Both,
|
||||
}
|
||||
|
||||
impl DevTarget {
|
||||
fn has_client(self) -> bool {
|
||||
matches!(self, DevTarget::Client | DevTarget::Both)
|
||||
}
|
||||
fn has_web(self) -> bool {
|
||||
matches!(self, DevTarget::Web | DevTarget::Both)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StackConfig {
|
||||
pub layout: Layout,
|
||||
pub operator: String,
|
||||
@@ -160,9 +192,108 @@ 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.
|
||||
///
|
||||
/// Only ever Some on a PC with a valid dev licence: the launcher will not fill it in
|
||||
/// otherwise, whatever the settings file says.
|
||||
pub dev_engine: Option<PathBuf>,
|
||||
/// Dev mode: the operator this run pretends to be, when that is not the one signed in.
|
||||
///
|
||||
/// The local API mints its own session from a shared secret - Keycloak vouches for the
|
||||
/// NAME before the mint, never during it - so the client can run as anyone this PC names.
|
||||
/// What it cannot do is carry that identity to the DHP: sync pushes under the Keycloak
|
||||
/// token, so work authored as someone else would arrive there under the real account.
|
||||
/// So while this is Some, the launcher does not sync at all.
|
||||
pub dev_user: Option<String>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl StackConfig {
|
||||
/// Where the client's files come from this run: the checkout in dev mode, else the
|
||||
/// installed version. Assets and shaders are junctioned from here, so in dev an edited
|
||||
/// shader or icon is picked up by the next launch with nothing to reinstall.
|
||||
fn odian_dir(&self) -> PathBuf {
|
||||
match &self.dev_engine {
|
||||
Some(repo) => repo.join("Odian"),
|
||||
None => self.layout.odian_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
fn odian_exe(&self) -> PathBuf {
|
||||
match &self.dev_engine {
|
||||
Some(_) => self.odian_dir().join("x64").join("MinGW-Release").join("Odian.exe"),
|
||||
None => self.layout.odian_exe(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Engine API this run uses. In dev it is the checkout's build - and it runs from
|
||||
/// `_API/bin`, where its DLLs already sit beside it.
|
||||
fn api_exe(&self) -> PathBuf {
|
||||
match &self.dev_engine {
|
||||
Some(repo) => repo.join("_API").join("bin").join("odian_api.exe"),
|
||||
None => self.layout.api_exe(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder uvicorn loads `app:app` from: the checkout's tile server in dev, so an
|
||||
/// edit to app.py is live on the next launch. The PYTHON stays the install's - its
|
||||
/// runtime already has the dependencies, and a dev tree has no virtualenv of its own.
|
||||
fn tile_server_dir(&self) -> PathBuf {
|
||||
match &self.dev_engine {
|
||||
Some(repo) => repo.join("misc").join("tile_server"),
|
||||
None => self.layout.tile_server_dir(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `_WEB` in the checkout, when this run starts the browser client.
|
||||
fn web_dir(&self) -> Option<PathBuf> {
|
||||
self.runs_web().then(|| self.dev_engine.as_ref().expect("runs_web implies dev").join("_WEB"))
|
||||
}
|
||||
|
||||
/// Does this run start Odian.exe? Always, outside dev mode - an operator install has one
|
||||
/// client and no choice about it.
|
||||
pub fn runs_client(&self) -> bool {
|
||||
self.dev_engine.is_none() || self.dev_target.has_client()
|
||||
}
|
||||
|
||||
/// Does this run start the web client's dev server? Only ever in dev mode.
|
||||
pub fn runs_web(&self) -> bool {
|
||||
self.dev_engine.is_some() && self.dev_target.has_web()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn web_url(&self) -> Option<String> {
|
||||
self.runs_web().then(|| {
|
||||
format!(
|
||||
"http://localhost:{}/?api={}&user={}",
|
||||
WEB_PORT,
|
||||
url_value(&format!("http://127.0.0.1:{API_PORT}")),
|
||||
url_value(&self.operator)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything this run takes from the checkout rather than the install, for the log.
|
||||
pub fn dev_parts(&self) -> Vec<String> {
|
||||
match &self.dev_engine {
|
||||
None => Vec::new(),
|
||||
Some(_) => vec![
|
||||
format!("client {}", text(self.odian_exe())),
|
||||
format!("API {}", text(self.api_exe())),
|
||||
format!("tile server {}", text(self.tile_server_dir())),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The sets to offer the client: this PC's own always - they draw with no DHP at
|
||||
/// all - plus the version's rest when a site can draw those. Deduplicated by
|
||||
/// style and dataset, this PC's entry first so its own extension wins.
|
||||
@@ -209,6 +340,36 @@ pub fn command(program: &Path, cwd: &Path, search_path: &[PathBuf]) -> Command {
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Percent-encode one query VALUE (RFC 3986 unreserved set kept as-is).
|
||||
///
|
||||
/// Tiny on purpose: the only things that go through it are an address this launcher built
|
||||
/// itself and an operator name. An operator name with an `&` in it would otherwise end the
|
||||
/// parameter and hand the web client a truncated address.
|
||||
fn url_value(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
for byte in value.as_bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(*byte as char),
|
||||
other => out.push_str(&format!("%{other:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Give this process a console window of its own, instead of hiding it.
|
||||
///
|
||||
/// `creation_flags` REPLACES what `command` set, so this undoes CREATE_NO_WINDOW rather
|
||||
/// than adding to it. Pair it with NOT calling `log_output`: output goes to the window.
|
||||
#[cfg(windows)]
|
||||
pub fn give_console(cmd: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_CONSOLE: u32 = 0x0000_0010;
|
||||
cmd.creation_flags(CREATE_NEW_CONSOLE);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn give_console(_cmd: &mut Command) {}
|
||||
|
||||
/// Append the process's stdout and stderr to `log`.
|
||||
pub fn log_output(cmd: &mut Command, log: &Path) -> Result<(), String> {
|
||||
if let Some(dir) = log.parent() {
|
||||
@@ -244,7 +405,17 @@ impl RunningStack {
|
||||
}
|
||||
|
||||
pub fn odian(&self) -> Option<Shared> {
|
||||
self.children.iter().find(|(c, _)| *c == Component::Odian).map(|(_, child)| child.clone())
|
||||
self.child(Component::Odian)
|
||||
}
|
||||
|
||||
/// The web dev server, when this run has one. A web-only run is watched through it: there
|
||||
/// is no Odian.exe to close, so the run ends when Stop kills this.
|
||||
pub fn web(&self) -> Option<Shared> {
|
||||
self.child(Component::Web)
|
||||
}
|
||||
|
||||
fn child(&self, want: Component) -> Option<Shared> {
|
||||
self.children.iter().find(|(c, _)| *c == want).map(|(_, child)| child.clone())
|
||||
}
|
||||
|
||||
/// Stop in reverse start order; Postgres last, and cleanly.
|
||||
@@ -278,12 +449,29 @@ pub fn port_taken(port: u16) -> bool {
|
||||
/// so it outlives the launcher window that started it — close the launcher, or let it
|
||||
/// restart for an update, and the next launch finds its own database on the port. That is
|
||||
/// not another install: the stack adopts it (`Pg::start` returns early when it is running).
|
||||
fn check_ports_free(own_db_running: bool) -> Result<(), String> {
|
||||
for (port, what) in [(PG_PORT, "the database"), (API_PORT, "the Engine API"), (TILES_PORT, "the tile server")] {
|
||||
fn check_ports_free(own_db_running: bool, web: bool) -> Result<(), String> {
|
||||
for (port, what) in [
|
||||
(PG_PORT, "the database"),
|
||||
(API_PORT, "the Engine API"),
|
||||
(TILES_PORT, "the tile server"),
|
||||
(WEB_PORT, "the web client"),
|
||||
] {
|
||||
if port == PG_PORT && own_db_running {
|
||||
continue;
|
||||
}
|
||||
// Only when this run wants it: 5173 is Vite's default, so a developer may well have
|
||||
// `npm run dev` up from a terminal, and that is no reason to refuse a launch of the .exe.
|
||||
if port == WEB_PORT && !web {
|
||||
continue;
|
||||
}
|
||||
if port_taken(port) {
|
||||
if port == WEB_PORT {
|
||||
// Almost always the developer's own `npm run dev`, not another install - and the
|
||||
// fix is theirs, in the window holding it.
|
||||
return Err(format!(
|
||||
"Something already holds 127.0.0.1:{port}, which the web client needs. It is most likely a `npm run dev` you started yourself: stop it, then launch again."
|
||||
));
|
||||
}
|
||||
return Err(format!(
|
||||
"Another ODIAN install is already running on this PC ({what} holds 127.0.0.1:{port}). Stop it in the launcher window that started it, then launch again."
|
||||
));
|
||||
@@ -296,7 +484,7 @@ pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>)
|
||||
let pg = 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())?;
|
||||
check_ports_free(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 {
|
||||
@@ -319,10 +507,21 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>,
|
||||
|
||||
emit(StackEvent::new(Component::Api, ComponentState::Starting));
|
||||
let api_dir = state.join("api");
|
||||
let api_exe = layout.api_exe();
|
||||
let api_exe = config.api_exe();
|
||||
if config.dev_engine.is_some() && !api_exe.is_file() {
|
||||
return Err(failed(
|
||||
emit,
|
||||
Component::Api,
|
||||
format!("{} is not built yet. Build it in the checkout first: _API\\build.bat", api_exe.display()),
|
||||
));
|
||||
}
|
||||
let mut api = command(&api_exe, &api_dir, &[parent(&api_exe)]);
|
||||
api.arg(API_PORT.to_string()).envs(api_env(config));
|
||||
log_output(&mut api, &api_dir.join("logs").join("console.log"))?;
|
||||
if config.dev_engine.is_some() {
|
||||
give_console(&mut api);
|
||||
} else {
|
||||
log_output(&mut api, &api_dir.join("logs").join("console.log"))?;
|
||||
}
|
||||
let child = running.spawn(Component::Api, &mut api).map_err(|e| failed(emit, Component::Api, e))?;
|
||||
wait_http(http, &format!("http://127.0.0.1:{API_PORT}/health"), &child, Duration::from_secs(90))
|
||||
.await
|
||||
@@ -335,23 +534,56 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>,
|
||||
let mut tiles = command(&python, &tiles_dir, &[parent(&python)]);
|
||||
tiles
|
||||
.args(["-m", "uvicorn", "--app-dir"])
|
||||
.arg(layout.tile_server_dir())
|
||||
.arg(config.tile_server_dir())
|
||||
.args(["app:app", "--host", "127.0.0.1", "--log-level", "warning", "--port"])
|
||||
.arg(TILES_PORT.to_string())
|
||||
.envs(tiles_env(config));
|
||||
log_output(&mut tiles, &tiles_dir.join("tile_server.log"))?;
|
||||
if config.dev_engine.is_some() {
|
||||
give_console(&mut tiles);
|
||||
} else {
|
||||
log_output(&mut tiles, &tiles_dir.join("tile_server.log"))?;
|
||||
}
|
||||
let child = running.spawn(Component::Tiles, &mut tiles).map_err(|e| failed(emit, Component::Tiles, e))?;
|
||||
wait_http(http, &format!("http://127.0.0.1:{TILES_PORT}/health"), &child, Duration::from_secs(120))
|
||||
.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 odian_exe = layout.odian_exe();
|
||||
let odian_exe = config.odian_exe();
|
||||
if config.dev_engine.is_some() && !odian_exe.is_file() {
|
||||
return Err(failed(
|
||||
emit,
|
||||
Component::Odian,
|
||||
format!(
|
||||
"{} is not built yet. Build it in the checkout first: tools\\build\\build.bat Release",
|
||||
odian_exe.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut odian = command(&odian_exe, &client_dir, &[parent(&odian_exe)]);
|
||||
odian.envs(client_env(config));
|
||||
log_output(&mut odian, &client_dir.join("logs").join("console.log"))?;
|
||||
if config.dev_engine.is_some() {
|
||||
// A window of its own, and nothing swallowed: watching the client talk is most of what
|
||||
// dev mode is for. (Build it WITHOUT ODIAN_WINDOWED - a GUI-subsystem exe gets a
|
||||
// console here and writes nothing to it.)
|
||||
give_console(&mut odian);
|
||||
} else {
|
||||
log_output(&mut odian, &client_dir.join("logs").join("console.log"))?;
|
||||
}
|
||||
let child = running.spawn(Component::Odian, &mut odian).map_err(|e| failed(emit, Component::Odian, e))?;
|
||||
// Odian.exe has no health endpoint; a client that survives its first seconds has signed in.
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
@@ -366,6 +598,63 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>,
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `npm run dev` on the launcher's terms, in `dir`.
|
||||
///
|
||||
/// Its own function so the argument list is testable: a wrong flag here would only show as
|
||||
/// a failed launch, and the bare `--` matters - without it npm keeps the rest for itself
|
||||
/// instead of passing it to Vite.
|
||||
fn web_command(dir: &Path) -> Command {
|
||||
let mut web = Command::new("cmd");
|
||||
// --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
|
||||
.arg("/c")
|
||||
.arg("npm")
|
||||
.arg("run")
|
||||
.arg("dev")
|
||||
.arg("--")
|
||||
.arg("--port")
|
||||
.arg(WEB_PORT.to_string())
|
||||
.arg("--strictPort")
|
||||
.current_dir(dir);
|
||||
web
|
||||
}
|
||||
|
||||
/// The browser client's Vite dev server, from the checkout's `_WEB`.
|
||||
///
|
||||
/// Deliberately NOT started through `command()`: that sweeps the environment, and npm has
|
||||
/// to find node on the developer's own PATH. It needs none of the ODIAN variables either -
|
||||
/// the web takes the API's address and the operator's name from the URL the launcher opens,
|
||||
/// which is also what lets it point at this stack's 18080 instead of its own 8080 default.
|
||||
async fn start_web(
|
||||
http: &reqwest::Client,
|
||||
config: &StackConfig,
|
||||
emit: Emit<'_>,
|
||||
running: &mut RunningStack,
|
||||
) -> Result<(), String> {
|
||||
let dir = config.web_dir().expect("runs_web implies a checkout");
|
||||
emit(StackEvent::new(Component::Web, ComponentState::Starting));
|
||||
if !dir.is_dir() {
|
||||
return Err(failed(emit, Component::Web, format!("{} is missing from the checkout.", dir.display())));
|
||||
}
|
||||
if !dir.join("node_modules").is_dir() {
|
||||
return Err(failed(
|
||||
emit,
|
||||
Component::Web,
|
||||
format!("{} has no node_modules yet. Tick \"Build it first, every launch\" in Settings, which installs them, or run `npm install` there once.", dir.display()),
|
||||
));
|
||||
}
|
||||
let mut web = web_command(&dir);
|
||||
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.
|
||||
wait_http(http, &format!("http://127.0.0.1:{WEB_PORT}/"), &child, Duration::from_secs(240))
|
||||
.await
|
||||
.map_err(|e| failed(emit, Component::Web, e))?;
|
||||
emit(StackEvent::new(Component::Web, ComponentState::Ready));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn failed(emit: Emit<'_>, component: Component, message: String) -> String {
|
||||
emit(StackEvent { component, state: ComponentState::Failed, message: Some(message.clone()) });
|
||||
message
|
||||
@@ -434,16 +723,19 @@ fn prepare(config: &StackConfig) -> Result<(), String> {
|
||||
// config\ and logs\ next to them. Junctions give it both at once: this
|
||||
// version's read-only files, inside a folder of its own it may write to.
|
||||
let client = state.join("client");
|
||||
// In dev these point at the checkout, where a missing folder means an unbuilt or
|
||||
// half-pulled tree - not a broken install, which is what an operator would be told.
|
||||
let whose = if config.dev_engine.is_some() { "the checkout" } else { "this version" };
|
||||
for name in ["assets", "shaders"] {
|
||||
replace_junction(&client.join(name), &layout.odian_dir().join(name))?;
|
||||
replace_junction(&client.join(name), &config.odian_dir().join(name), whose)?;
|
||||
}
|
||||
write_atomic(&state.join("api").join("pg.ini"), pg_ini(config).as_bytes())?;
|
||||
write_json_atomic(&state.join("tiles").join("datasets.json"), &datasets(config))
|
||||
}
|
||||
|
||||
fn replace_junction(link: &Path, target: &Path) -> Result<(), String> {
|
||||
fn replace_junction(link: &Path, target: &Path, whose: &str) -> Result<(), String> {
|
||||
if !target.is_dir() {
|
||||
return Err(format!("{} is missing from this version", target.display()));
|
||||
return Err(format!("{} is missing from {whose}", target.display()));
|
||||
}
|
||||
if std::fs::symlink_metadata(link).is_ok() {
|
||||
// On a junction this removes only the link; a real folder with files in it refuses.
|
||||
@@ -623,7 +915,14 @@ fn pmtiles_of(config: &StackConfig, dataset: &str) -> Option<PathBuf> {
|
||||
/// The tile baker, when the version ships one AND some installed dataset has an archive
|
||||
/// to bake from. Without both the tile server stays cache-only, exactly as before.
|
||||
fn baker(config: &StackConfig) -> Option<PathBuf> {
|
||||
let exe = parent(&config.layout.app_file(TILE_SERVER_APP)).join("tile_baker.exe");
|
||||
// In dev, the checkout's baker if it has been built - someone changing how tiles are
|
||||
// drawn wants THEIR baker - and the install's otherwise, since it is only a tool.
|
||||
let dev_baker = config
|
||||
.dev_engine
|
||||
.as_ref()
|
||||
.map(|repo| repo.join("tools").join("tile_baker_cli").join("x64").join("Release").join("tile_baker.exe"))
|
||||
.filter(|path| path.is_file());
|
||||
let exe = dev_baker.unwrap_or_else(|| parent(&config.layout.app_file(TILE_SERVER_APP)).join("tile_baker.exe"));
|
||||
let any_archive = config.tile_sets.iter().any(|set| pmtiles_of(config, &set.dataset).is_some());
|
||||
(any_archive && exe.is_file()).then_some(exe)
|
||||
}
|
||||
@@ -827,6 +1126,10 @@ mod tests {
|
||||
.collect(),
|
||||
upstream_url: None,
|
||||
ai_url: String::new(),
|
||||
// The tests describe an ordinary operator install; the dev tests set these themselves.
|
||||
dev_engine: None,
|
||||
dev_user: None,
|
||||
dev_target: DevTarget::default(),
|
||||
site_url: "https://dhp.example/dhp".to_string(),
|
||||
site_tile_sets: tile_sets
|
||||
.iter()
|
||||
@@ -956,6 +1259,133 @@ mod tests {
|
||||
assert_eq!(doc["planet_heightmap"]["styles"]["heightmap"]["tile_ext"], ".lerc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_dev_the_client_and_its_assets_come_from_the_checkout() {
|
||||
// The point of dev mode: this run's client, shaders and assets are the ones in the
|
||||
// repo, while the database, the tiles and the rest of the stack stay the install's.
|
||||
// Junctioning assets and shaders at the checkout is what makes an edited shader show
|
||||
// up on the next launch with nothing to reinstall.
|
||||
let mut cfg = config(&[("dark", "protomaps_20260423")]);
|
||||
let installed = cfg.odian_exe();
|
||||
cfg.dev_engine = Some(PathBuf::from(r"E:\ODIAN_Dev\ODIAN_Engine"));
|
||||
|
||||
assert_eq!(installed, PathBuf::from(r"D:\ODIAN\versions\0.52.0\Odian\x64\MinGW-Release\Odian.exe"));
|
||||
assert_eq!(cfg.odian_exe(), PathBuf::from(r"E:\ODIAN_Dev\ODIAN_Engine\Odian\x64\MinGW-Release\Odian.exe"));
|
||||
assert_eq!(cfg.odian_dir(), PathBuf::from(r"E:\ODIAN_Dev\ODIAN_Engine\Odian"));
|
||||
|
||||
// The API and the tile server come from the checkout too - its code, the install's
|
||||
// python runtime, which already carries the dependencies a fresh clone has not got.
|
||||
assert_eq!(cfg.api_exe(), PathBuf::from(r"E:\ODIAN_Dev\ODIAN_Engine\_API\bin\odian_api.exe"));
|
||||
assert_eq!(cfg.tile_server_dir(), PathBuf::from(r"E:\ODIAN_Dev\ODIAN_Engine\misc\tile_server"));
|
||||
assert_eq!(cfg.dev_parts().len(), 3, "all three are named in the log: {:?}", cfg.dev_parts());
|
||||
|
||||
// Everything else is untouched: same ports, same database, same tiles.
|
||||
let env = client_env(&cfg);
|
||||
assert_eq!(value(&env, "ODIAN_TILE_SERVER_URL"), Some("http://127.0.0.1:18081"));
|
||||
assert_eq!(value(&env, "ODIAN_ENGINE_API_URL"), Some("http://127.0.0.1:18080"));
|
||||
assert_eq!(value(&env, "ODIAN_LAUNCH_USER"), Some("m1op"));
|
||||
assert!(pg_ini(&cfg).contains("port = 55432"), "the dev stack talks to the install's database");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_operator_install_always_runs_the_client_and_never_the_web() {
|
||||
// dev_target is a dev-mode choice. Whatever it says, an install has one client and no
|
||||
// web checkout to serve - so nothing here may ever leave an operator without ODIAN.
|
||||
for target in [DevTarget::Client, DevTarget::Web, DevTarget::Both] {
|
||||
let mut cfg = config(&[("dark", "protomaps_20260423")]);
|
||||
cfg.dev_target = target;
|
||||
assert!(cfg.runs_client(), "{target:?} must still start ODIAN on an operator PC");
|
||||
assert!(!cfg.runs_web(), "{target:?} must not start a web server on an operator PC");
|
||||
assert_eq!(cfg.web_url(), None);
|
||||
assert_eq!(cfg.web_dir(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_dev_the_target_decides_which_clients_start() {
|
||||
let mut cfg = config(&[("dark", "protomaps_20260423")]);
|
||||
cfg.dev_engine = Some(PathBuf::from(r"E:\ODIAN_Dev\ODIAN_Engine"));
|
||||
|
||||
cfg.dev_target = DevTarget::Client;
|
||||
assert!(cfg.runs_client() && !cfg.runs_web());
|
||||
|
||||
cfg.dev_target = DevTarget::Web;
|
||||
assert!(!cfg.runs_client() && cfg.runs_web(), "web only: no Odian.exe, so none is built or waited on");
|
||||
assert_eq!(cfg.web_dir(), Some(PathBuf::from(r"E:\ODIAN_Dev\ODIAN_Engine\_WEB")));
|
||||
|
||||
// Both is the one that earns the plumbing: one Postgres, one API, one tile server, and
|
||||
// the two clients on the same data - which is how a parity break becomes visible.
|
||||
cfg.dev_target = DevTarget::Both;
|
||||
assert!(cfg.runs_client() && cfg.runs_web());
|
||||
assert_eq!(pg_ini(&cfg).contains("port = 55432"), true, "both clients share this install's database");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_web_is_opened_at_this_stacks_api_as_this_runs_operator() {
|
||||
let mut cfg = config(&[("dark", "protomaps_20260423")]);
|
||||
cfg.dev_engine = Some(PathBuf::from(r"E:\dev\ODIAN_Engine"));
|
||||
cfg.dev_target = DevTarget::Web;
|
||||
// Its own default is localhost:8080, which is the repo's api.bat - not this stack's.
|
||||
assert_eq!(
|
||||
cfg.web_url().unwrap(),
|
||||
"http://localhost:5173/?api=http%3A%2F%2F127.0.0.1%3A18080&user=m1op"
|
||||
);
|
||||
|
||||
// A swapped run opens the browser as that operator too, so both clients author alike.
|
||||
cfg.operator = "m2op".to_string();
|
||||
cfg.dev_user = Some("m2op".to_string());
|
||||
assert!(cfg.web_url().unwrap().ends_with("&user=m2op"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_web_server_is_started_on_the_port_the_launcher_advertises() {
|
||||
// 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"));
|
||||
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.
|
||||
assert_eq!(args[4], "--", "npm needs the separator before Vite's own flags");
|
||||
// And the port is the one web_url sends the browser to.
|
||||
let mut cfg = config(&[("dark", "protomaps_20260423")]);
|
||||
cfg.dev_engine = Some(PathBuf::from(r"E:\dev\ODIAN_Engine"));
|
||||
cfg.dev_target = DevTarget::Web;
|
||||
assert!(cfg.web_url().unwrap().contains(&format!(":{}/", args[6])), "opened at the port it serves");
|
||||
assert_eq!(cmd.get_current_dir(), Some(Path::new(r"E:\dev\ODIAN_Engine\_WEB")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_query_value_cannot_end_its_own_parameter() {
|
||||
// The address is built here, but the operator name comes from a text field.
|
||||
assert_eq!(url_value("m1op"), "m1op");
|
||||
assert_eq!(url_value("http://127.0.0.1:18080"), "http%3A%2F%2F127.0.0.1%3A18080");
|
||||
assert_eq!(url_value("a&user=root"), "a%26user%3Droot", "cannot smuggle a second parameter");
|
||||
assert_eq!(url_value("a b"), "a%20b");
|
||||
assert_eq!(url_value("ok-._~"), "ok-._~", "the unreserved set is left alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swapped_dev_user_is_who_the_client_runs_as() {
|
||||
// The local API mints its own session from a shared secret, so the client can run as
|
||||
// anyone this PC names. What that must never do is reach the DHP - which is why the
|
||||
// launcher refuses to sync while this is set (run_stack).
|
||||
let mut cfg = config(&[("dark", "protomaps_20260423")]);
|
||||
cfg.dev_engine = Some(PathBuf::from(r"E:\dev\ODIAN_Engine"));
|
||||
cfg.operator = "m2op".to_string();
|
||||
cfg.dev_user = Some("m2op".to_string());
|
||||
assert_eq!(value(&client_env(&cfg), "ODIAN_LAUNCH_USER"), Some("m2op"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_install_takes_nothing_from_a_checkout() {
|
||||
let cfg = config(&[("dark", "protomaps_20260423")]);
|
||||
assert!(cfg.dev_engine.is_none());
|
||||
assert!(cfg.dev_parts().is_empty(), "nothing to say on an operator PC");
|
||||
assert_eq!(cfg.api_exe(), cfg.layout.api_exe());
|
||||
assert_eq!(cfg.tile_server_dir(), cfg.layout.tile_server_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fetched_style_is_never_drawn_from_the_vector_archive() {
|
||||
// OpenStreetMap is a DRAWN map, but the office draws none of it: it asks
|
||||
|
||||
Reference in New Issue
Block a user