update
This commit is contained in:
@@ -56,6 +56,99 @@ The site token lives in Windows Credential Manager under "ODIAN Launcher", one
|
||||
entry per site URL. The install id is generated on the PC the first time it saves
|
||||
its state, so a cloned drive never carries one.
|
||||
|
||||
## Dev mode
|
||||
|
||||
A developer's PC can run the whole stack out of git checkouts instead of the
|
||||
installed version, so a code change is one Launch away without cutting a pack.
|
||||
It is deliberately not a checkbox anyone can find: the Dev section of Settings
|
||||
appears only on a PC holding a **dev licence**, an Ed25519 document signed with
|
||||
the same key as a pack manifest and verified by the same code
|
||||
(`src-tauri/src/devmode.rs` over `odian_packs::signing::verify`).
|
||||
|
||||
**This repo carries one.** `dev-licence.json` at the root is a *team* licence: it names no
|
||||
machine and no install, and says so in its signed body (`"scope": "team"`). Whoever can
|
||||
clone this repo can run dev mode — repo access is the credential, on purpose, because an
|
||||
operator has the installed launcher and never the source.
|
||||
|
||||
- A launcher started **from this repo** (`npm run tauri dev`) finds it by itself: the
|
||||
launcher looks beside its own exe and two levels up, which is the repo root.
|
||||
- For the launcher **installed on your PC**, run it once:
|
||||
`powershell -ExecutionPolicy Bypass -File tools\enable-dev-mode.ps1`
|
||||
|
||||
It still expires, and it is still refused if the signature does not match the release key.
|
||||
A licence that simply *forgot* its binding is refused as unbound — only one that claims the
|
||||
team scope is let through, so the weaker rule can never be reached by accident.
|
||||
|
||||
For a stricter licence — a single PC, or a kit on one drive — mint one on the machine that
|
||||
has the signing key:
|
||||
|
||||
```bash
|
||||
python -m odian_packs dev-licence --machine ODIAN-DEV-01 --expires 2027-01-31 --out licence.json
|
||||
```
|
||||
|
||||
`--machine` is `%COMPUTERNAME%`, or use `--install-id` to bind it to one install
|
||||
instead (the id in `state\launcher.json`). A licence with neither binding is
|
||||
refused — an unbound one would work on every PC it is copied to.
|
||||
|
||||
The launcher looks in these places on every refresh, best first:
|
||||
|
||||
1. `<checkout folder>\dev-licence.json` — where a developer's kit lives, so the licence
|
||||
travels with the checkouts on the same drive.
|
||||
2. Beside the running executable, and two levels above it — the repo root for a launcher
|
||||
built from source, which is what makes the committed team licence work with no setup.
|
||||
3. `<install_dir>\state\dev-licence.json` — always supported, and 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. This is where `enable-dev-mode.ps1` puts it.
|
||||
|
||||
It is signed and bound either way, so where it is read from changes nothing about what it
|
||||
permits. A refusal is logged once, naming the file it refused.
|
||||
|
||||
With a licence, Settings gains:
|
||||
|
||||
| Control | What it does |
|
||||
|---|---|
|
||||
| Checkout folder | The folder holding `ODIAN_Engine` (e.g. `E:\ODIAN_Dev`) |
|
||||
| Launch the whole stack from the checkout | Runs the checkout's API, tile server and `Odian.exe` |
|
||||
| Build it first, every launch | Runs `_APIuild.bat` then `toolsuilduild.bat Release` before launching |
|
||||
| Run as | An operator name to run as, instead of the one signed in |
|
||||
| Launch starts | ODIAN, the web client, or **both** |
|
||||
|
||||
**Both is the one worth having.** There is one Postgres, one API and one tile server either
|
||||
way, so the `.exe` and the browser show the same rows from the same database at the same
|
||||
moment. This codebase holds the two clients to each other in a dozen places — the satellite
|
||||
propagator, the network graph, the track filters, the APP-6 placement and sizing — and a
|
||||
divergence there is invisible in review and obvious the moment both are on screen.
|
||||
|
||||
The web client is the checkout's `_WEB` on Vite's own `:5173`, with hot reload, and the
|
||||
launcher opens a browser at
|
||||
`http://localhost:5173/?api=http://127.0.0.1:18080&user=<operator>` — `?api=` because the
|
||||
web's own default is `localhost:8080` (the repo's `api.bat`), not this stack's API, and
|
||||
`?user=` so both clients author as the same operator, a swapped one included. Its endpoint
|
||||
guard already trusts any loopback port, so nothing in `_WEB` needed changing. On first run
|
||||
"Build it first, every launch" also runs the web client's `npm install`, whenever
|
||||
`package.json` or `package-lock.json` is newer than `node_modules` — so a dependency that
|
||||
arrives with a pull is fetched, without paying for npm on every launch. (It has to be the
|
||||
manifest times, not "does node_modules exist": a pull leaves the folder in place and Vite
|
||||
then fails with `Failed to resolve import "<name>"`, which reads like broken source.)
|
||||
|
||||
One thing is **not** local yet: `_WEB/vite.config.ts` proxies `/basemap`, `/netlayer` and
|
||||
`/mvt` to `https://tiles.odian.fr`, so the browser's map comes from the internet while the
|
||||
`.exe` beside it draws from this PC's tiles. The data — the part the two clients are held to
|
||||
agree on — is local for both.
|
||||
|
||||
In dev mode every process gets **its own console window** — the whole point is
|
||||
seeing the API's and the client's output — and the same database, tiles and
|
||||
Postgres as an operator run, on the launcher's own ports (55432 / 18080 / 18081),
|
||||
so a dev run and the repo's `run-local.bat` (8080) do not collide. Stop still
|
||||
cuts everything: the job object owns the consoles too.
|
||||
|
||||
**Run as switches the identity without Keycloak**, because the local API mints
|
||||
its own session from a shared secret and the launcher tells it who to be
|
||||
(`ODIAN_LAUNCH_USER`). What it cannot do is carry that identity to the DHP — work
|
||||
authored as someone else would arrive there under the account that signed in — so
|
||||
**while a different name is set, the launcher does not sync at all**, and says so.
|
||||
Typing your own name in changes nothing and costs nothing.
|
||||
|
||||
## The local stack
|
||||
|
||||
`launch` starts four processes, all on 127.0.0.1, in this order, each waiting for the previous one:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"expires_at": "2027-03-31",
|
||||
"note": "ODIAN_Launcher repo: whoever can clone the source gets dev mode",
|
||||
"product": "odian-dev",
|
||||
"scope": "team",
|
||||
"signature": "ed25519:r01JWE4z16UcjgPyvhZA0Ut+QecOubX1Y6+ILWlet4kPGFygjLwUM+r7XWkWlPbsDrXki9qi1ZJOxXV7pG9ABQ=="
|
||||
}
|
||||
+62
-3
@@ -17,6 +17,9 @@ type Phase =
|
||||
| "installing" // downloading + verifying a version
|
||||
| "restoring" // loading the database snapshot into local Postgres
|
||||
| "syncing" // exchanging operator data with the DHP (ODIAN is not running)
|
||||
| "pulling_tiles" // fetching tiles the DHP baked while this PC was away
|
||||
| "removing" // deleting marketplace items the operator unticked
|
||||
| "building" // dev mode only: compiling the checkout before it launches
|
||||
| "launching" // starting Postgres → API → tile server → Odian.exe
|
||||
| "running" // Odian.exe is open
|
||||
| "error"; // the last long operation failed; see `error`
|
||||
@@ -41,6 +44,36 @@ interface Status {
|
||||
live: boolean | null; // while ODIAN runs: true = its data comes from the DHP,
|
||||
// false = from this PC. null when it is not running, or
|
||||
// the DHP offers no Engine API to forward to
|
||||
map_from_site: boolean | null; // while ODIAN runs: false = the map is this PC's own tiles
|
||||
// because the site's tile service did not answer
|
||||
map_problem: string | null; // why, in one line, when map_from_site is false
|
||||
tiles_waiting: Waiting | null; // tiles the DHP baked for the sets this PC holds since its
|
||||
// last sync: what a sync would fetch. null offline, with
|
||||
// nothing installed, or when the DHP is too old to ask
|
||||
unfinished: Unfinished | null; // chosen marketplace items not fully on this PC. ODIAN
|
||||
// still runs, with less map than was asked for
|
||||
dev: Licence | null; // this PC's dev licence. null on every operator PC, and
|
||||
// the window then shows no trace of dev mode at all
|
||||
}
|
||||
|
||||
interface Waiting {
|
||||
tiles: number; // 0 means this PC is level with the site
|
||||
sets: number; // how many of its tile sets have something waiting
|
||||
capped: boolean; // the DHP stopped counting: "50000+"
|
||||
}
|
||||
|
||||
interface Unfinished {
|
||||
items: number;
|
||||
bytes: number; // what finishing them downloads IN FULL — the whole item,
|
||||
// not the gap, which is what the installer skips through
|
||||
titles: string[]; // the first few, to name in one line
|
||||
}
|
||||
|
||||
interface Licence {
|
||||
machine: string | null; // the PC it was issued for; null = not tied to one
|
||||
install_id: string | null; // the install it was issued for; null = not tied to one
|
||||
expires_at: string; // "YYYY-MM-DD", the last day it works
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
interface SyncStatus {
|
||||
@@ -62,10 +95,34 @@ interface SyncConflict {
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
site_url: string; // e.g. "http://192.168.1.41:30097"
|
||||
mcp_url: string; // site MCP, for Keycloak sign-in
|
||||
site_url: string; // e.g. "http://192.168.1.41:30097"; "" = no DHP, run offline
|
||||
mcp_url: string; // site MCP, for Keycloak sign-in; "" = no sign-in, run offline
|
||||
ai_url: string; // MCP serving /api/ai/... over the internet; "" = none
|
||||
install_dir: string;
|
||||
// Dev mode. Only honoured on a PC whose `dev` licence is valid: `set_settings` stores
|
||||
// dev_mode as false without one, whatever the window sends.
|
||||
dev_root: string; // folder holding the checkouts (ODIAN_Engine); "" = none
|
||||
dev_mode: boolean; // run the stack from the checkout, each part in its own console
|
||||
dev_user: string; // run as this operator instead of the signed-in one; "" = them.
|
||||
// A DIFFERENT name switches sync OFF for that run
|
||||
dev_build: boolean; // build the checkout before launching (phase "building")
|
||||
dev_target: "client" | "web" | "both"; // which client(s) Launch starts. "both" runs them
|
||||
// against the ONE stack: same API, same database
|
||||
}
|
||||
|
||||
interface TileSyncProgress {
|
||||
tiles: number; // tiles written so far
|
||||
bytes: number;
|
||||
page_tiles: number; // tiles in the page being written
|
||||
page_done: number; // of them, written
|
||||
}
|
||||
|
||||
interface RemoveProgress {
|
||||
title: string; // the item being deleted
|
||||
done: number; // bytes deleted of it
|
||||
total: number;
|
||||
index: number; // which of them, 1-based: one Apply can drop several
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface InstallProgress {
|
||||
@@ -81,7 +138,7 @@ interface InstallProgress {
|
||||
}
|
||||
|
||||
interface StackEvent {
|
||||
component: "postgres" | "api" | "tiles" | "odian";
|
||||
component: "postgres" | "api" | "tiles" | "odian" | "web"; // "web" only on a dev run
|
||||
state: "starting" | "ready" | "stopped" | "failed";
|
||||
message: string | null;
|
||||
}
|
||||
@@ -217,4 +274,6 @@ edits wait in the local database until `resolve_sync_conflict`.
|
||||
| `status` | `Status` | whenever any field changes |
|
||||
| `install-progress` | `InstallProgress` | at most ~10 per second while installing |
|
||||
| `stack` | `StackEvent` | on each component state change |
|
||||
| `tile-sync-progress` | `TileSyncProgress` | while pulling tiles the DHP baked (phase `pulling_tiles`) |
|
||||
| `remove-progress` | `RemoveProgress` | while deleting unticked marketplace items (phase `removing`) |
|
||||
| `log` | `LogEvent` | notable backend messages, for the activity panel |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "ODIAN Launcher",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.34",
|
||||
"identifier": "fr.odian.launcher",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"expires_at": "2026-12-31",
|
||||
"install_id": "drive-install",
|
||||
"note": "the SSD kit",
|
||||
"product": "odian-dev",
|
||||
"signature": "ed25519:+ZRrXmI7lKecjBOmv1ySA9sHjAK87YdF5w9B8mDpOvWLfz2po1v6n4GTdHwS52bT8H6inbw0ZK9vdxGexmbhDA=="
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"expires_at": "2026-12-31",
|
||||
"note": "travels with the source repo",
|
||||
"product": "odian-dev",
|
||||
"scope": "team",
|
||||
"signature": "ed25519:HpcstGGqeTJEU3upVPh/MaZATzpUQ6ahH3wjbccu24kimgpFwyfwGMPTMMrZF8uIBPU0TKVVXEuLkyWkhqPlBw=="
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ed25519:A9/VD/SZC0Dy9DeLrorodVIPTi3XlBui4QQVZAg/5NQ=
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"expires_at": "2026-12-31",
|
||||
"note": "tied to nothing",
|
||||
"product": "odian-dev",
|
||||
"signature": "ed25519:NyRFydxgXrnVgbVBKybvG/nMQtBvjlbj/fpBBK6oh+9vV278cSZ6nyikb+sSyDqC+BAh+BJ9UxklDhf/f6ieBA=="
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"expires_at": "2026-12-31",
|
||||
"install_id": "install-1",
|
||||
"machine": "DEV-PC",
|
||||
"note": "offline dev",
|
||||
"product": "odian-dev",
|
||||
"signature": "ed25519:2XdzYs1rSCAMFvifSbHixd8WYBxezKppbQHycY8hPXX1/FxzDb7s6R2Toea/DpOsV9jmN48ScFZE7C012J31Bw=="
|
||||
}
|
||||
@@ -66,6 +66,8 @@
|
||||
return { label: "Taking map tiles", tone: "accent", pulse: true, meta: tileMeta };
|
||||
case "removing":
|
||||
return { label: "Removing", tone: "accent", pulse: true, meta: rp ? `${rp.index}/${rp.count}` : null };
|
||||
case "building":
|
||||
return { label: "Building the checkout", tone: "accent", pulse: true, meta: null };
|
||||
case "update":
|
||||
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
|
||||
case "launch":
|
||||
@@ -92,6 +94,16 @@
|
||||
return (unfinished?.items ?? 0) > titles.length || titles.length > 2 ? `${shown}…` : shown;
|
||||
});
|
||||
|
||||
// Said out loud whenever this run is not the installed ODIAN. A dev client looks exactly
|
||||
// like the real one on screen, and mistaking the two is how a bug gets chased in the
|
||||
// wrong tree - or a demo gets given from an unbuilt branch.
|
||||
const devRun = $derived((s?.dev ?? null) !== null && (launcher.settings?.dev_mode ?? false));
|
||||
// Named only when it is someone ELSE: running as yourself is not worth a warning.
|
||||
const devUser = $derived.by(() => {
|
||||
const asked = launcher.settings?.dev_user?.trim() ?? "";
|
||||
return devRun && asked && asked !== s?.operator ? asked : "";
|
||||
});
|
||||
|
||||
const tiles = $derived(s?.tiles_waiting ?? null);
|
||||
const tilesOffered = $derived(tiles !== null && tiles.tiles > 0);
|
||||
const tilesCount = $derived(tiles === null ? "" : `${formatCount(tiles.tiles)}${tiles.more ? "+" : ""}`);
|
||||
@@ -209,6 +221,16 @@
|
||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
|
||||
</p>
|
||||
{/if}
|
||||
{#if devRun}
|
||||
<p class="secondary attention">
|
||||
<Icon name="alert" size={14} />
|
||||
<span>
|
||||
Dev mode: ODIAN runs from your checkout, not from the installed
|
||||
<strong>{s?.installed ?? "version"}</strong>.{#if devUser}
|
||||
Running as <strong>{devUser}</strong> — sync is off.{/if}
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if unfinished}
|
||||
<p class="secondary attention">
|
||||
<Icon name="alert" size={14} />
|
||||
@@ -286,6 +308,14 @@
|
||||
<ProgressBar value={null} label="Syncing with the DHP" />
|
||||
</div>
|
||||
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p>
|
||||
{:else if action === "building"}
|
||||
<div class="progress">
|
||||
<div class="stage-line">
|
||||
<span class="stage-name">Compiling ODIAN from your checkout</span>
|
||||
</div>
|
||||
<ProgressBar value={null} label="Building" />
|
||||
</div>
|
||||
<p class="secondary">The build's own window has its output. A first build takes a while.</p>
|
||||
{:else if action === "removing"}
|
||||
<div class="progress">
|
||||
<div class="stage-line">
|
||||
|
||||
@@ -10,7 +10,17 @@
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
const EMPTY: Settings = { site_url: "", mcp_url: "", ai_url: "", install_dir: "" };
|
||||
const EMPTY: Settings = {
|
||||
site_url: "",
|
||||
mcp_url: "",
|
||||
ai_url: "",
|
||||
install_dir: "",
|
||||
dev_root: "",
|
||||
dev_mode: false,
|
||||
dev_user: "",
|
||||
dev_build: false,
|
||||
dev_target: "client",
|
||||
};
|
||||
|
||||
let dialog: HTMLDialogElement | undefined = $state();
|
||||
let draft = $state<Settings>({ ...EMPTY });
|
||||
@@ -33,18 +43,32 @@
|
||||
mcp_url: draft.mcp_url.trim(),
|
||||
ai_url: draft.ai_url.trim(),
|
||||
install_dir: draft.install_dir.trim(),
|
||||
dev_root: draft.dev_root.trim(),
|
||||
dev_mode: draft.dev_mode,
|
||||
dev_user: draft.dev_user.trim(),
|
||||
dev_build: draft.dev_build,
|
||||
dev_target: draft.dev_target,
|
||||
});
|
||||
const dirty = $derived(
|
||||
current !== null &&
|
||||
(trimmed.site_url !== current.site_url ||
|
||||
trimmed.mcp_url !== current.mcp_url ||
|
||||
trimmed.ai_url !== current.ai_url ||
|
||||
trimmed.install_dir !== current.install_dir),
|
||||
trimmed.install_dir !== current.install_dir ||
|
||||
trimmed.dev_root !== current.dev_root ||
|
||||
trimmed.dev_mode !== current.dev_mode ||
|
||||
trimmed.dev_user !== current.dev_user ||
|
||||
trimmed.dev_build !== current.dev_build ||
|
||||
trimmed.dev_target !== current.dev_target),
|
||||
);
|
||||
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
|
||||
const dev = $derived(launcher.status?.dev ?? null);
|
||||
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
|
||||
const siteError = $derived(urlProblem(trimmed.site_url));
|
||||
const mcpError = $derived(urlProblem(trimmed.mcp_url));
|
||||
// Blank is a deliberate answer for all three: no DHP, no sign-in service, no AI. It is
|
||||
// how a PC is put fully offline on purpose - and how an offline site is rehearsed on a
|
||||
// PC that does have a line out.
|
||||
const siteError = $derived(trimmed.site_url === "" ? null : urlProblem(trimmed.site_url));
|
||||
const mcpError = $derived(trimmed.mcp_url === "" ? null : urlProblem(trimmed.mcp_url));
|
||||
/** Optional: an empty AI address is how an install with no line out is left. */
|
||||
const aiError = $derived(trimmed.ai_url === "" ? null : urlProblem(trimmed.ai_url));
|
||||
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
|
||||
@@ -53,7 +77,6 @@
|
||||
);
|
||||
|
||||
function urlProblem(value: string): string | null {
|
||||
if (!value) return "Required.";
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? null : "Use an http:// or https:// address.";
|
||||
@@ -138,6 +161,19 @@
|
||||
dialog?.querySelector<HTMLButtonElement>("button:not([disabled])")?.focus();
|
||||
}
|
||||
|
||||
// One stack either way: the web client talks to the same API and database as the .exe,
|
||||
// which is the whole reason running both at once is worth offering.
|
||||
const TARGETS = [
|
||||
{ key: "client", name: "ODIAN", note: "Odian.exe from the checkout" },
|
||||
{ key: "web", name: "Web", note: "the _WEB dev server on :5173" },
|
||||
{ key: "both", name: "Both", note: "one stack, two clients, same data" },
|
||||
] as const;
|
||||
|
||||
async function browseDev() {
|
||||
const path = await launcher.pickFolder();
|
||||
if (path) draft.dev_root = path;
|
||||
}
|
||||
|
||||
async function browse() {
|
||||
const path = await launcher.pickFolder();
|
||||
if (path) draft.install_dir = path;
|
||||
@@ -186,7 +222,7 @@
|
||||
aria-describedby="set-site-url-help"
|
||||
/>
|
||||
<span id="set-site-url-help" class={dirty && siteError ? "field-error" : "field-help"}>
|
||||
{dirty && siteError ? siteError : "Update service of your DHP."}
|
||||
{dirty && siteError ? siteError : "Update service of your DHP. Leave empty to run offline."}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -202,7 +238,7 @@
|
||||
aria-describedby="set-mcp-url-help"
|
||||
/>
|
||||
<span id="set-mcp-url-help" class={dirty && mcpError ? "field-error" : "field-help"}>
|
||||
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in."}
|
||||
{dirty && mcpError ? mcpError : "The DHP's MCP handles Keycloak sign-in. Leave empty to run offline."}
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -258,6 +294,82 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if dev}
|
||||
<!-- Only ever rendered on a PC holding a valid signed licence. An operator's
|
||||
launcher has no dev section at all, which is the point of the signature. -->
|
||||
<section class="group" aria-labelledby="set-dev-title">
|
||||
<h3 id="set-dev-title" class="eyebrow">Dev</h3>
|
||||
<p class="text">
|
||||
{#if dev.machine}This PC{:else}This install{/if} carries a dev licence, valid until
|
||||
<strong>{dev.expires_at}</strong>{#if dev.note} — {dev.note}{/if}. Dev mode runs the
|
||||
client from your checkout, in a console, over this install's database and tiles.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label class="field-label" for="set-dev-root">Checkout folder</label>
|
||||
<div class="row">
|
||||
<input
|
||||
id="set-dev-root"
|
||||
class="input"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
placeholder="E:\ODIAN_Dev"
|
||||
bind:value={draft.dev_root}
|
||||
disabled={locked || busy}
|
||||
/>
|
||||
<button type="button" class="btn" disabled={locked || busy} onclick={browseDev}>Browse…</button>
|
||||
</div>
|
||||
<p class="hint">The folder that holds <code>ODIAN_Engine</code>.</p>
|
||||
</div>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" bind:checked={draft.dev_mode} disabled={locked || busy || !draft.dev_root.trim()} />
|
||||
<span>Launch the whole stack from the checkout instead of the installed version</span>
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input type="checkbox" bind:checked={draft.dev_build} disabled={locked || busy || !draft.dev_mode} />
|
||||
<span>Build it first, every launch</span>
|
||||
</label>
|
||||
<div class="field">
|
||||
<span class="field-label" id="set-dev-target-label">Launch starts</span>
|
||||
<div class="choices" role="radiogroup" aria-labelledby="set-dev-target-label">
|
||||
{#each TARGETS as option (option.key)}
|
||||
<label class="choice" class:on={draft.dev_target === option.key}>
|
||||
<input
|
||||
type="radio"
|
||||
name="dev-target"
|
||||
value={option.key}
|
||||
bind:group={draft.dev_target}
|
||||
disabled={locked || busy || !draft.dev_mode}
|
||||
/>
|
||||
<span class="choice-name">{option.name}</span>
|
||||
<span class="choice-note">{option.note}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="set-dev-user">Run as</label>
|
||||
<input
|
||||
id="set-dev-user"
|
||||
class="input"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
placeholder={launcher.status?.operator ?? "the operator who signed in"}
|
||||
bind:value={draft.dev_user}
|
||||
disabled={locked || busy || !draft.dev_mode}
|
||||
/>
|
||||
<p class="hint">
|
||||
{#if draft.dev_user.trim() && draft.dev_user.trim() !== launcher.status?.operator}
|
||||
This PC will <strong>not sync with the DHP</strong> while it runs as someone else: work
|
||||
authored as them would arrive there under your account.
|
||||
{:else}
|
||||
Another operator's name, to test what they see. Sync is switched off while it differs
|
||||
from yours.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="group" aria-labelledby="set-drive-title">
|
||||
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
|
||||
<p class="text">
|
||||
@@ -335,6 +447,56 @@
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
/* The Dev section's own rows. Only ever drawn on a licensed PC. */
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.check-row input {
|
||||
margin-top: 0.15rem;
|
||||
flex: none;
|
||||
}
|
||||
.hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-dim, #9aa3ad);
|
||||
}
|
||||
.choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.choice {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border: 1px solid var(--border, #2a3139);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.choice.on {
|
||||
border-color: var(--accent, #5b8cff);
|
||||
background: color-mix(in srgb, var(--accent, #5b8cff) 12%, transparent);
|
||||
}
|
||||
.choice:has(input:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.choice-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.choice-note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim, #9aa3ad);
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
failed: "danger",
|
||||
};
|
||||
|
||||
// Dev runs add the web client, and a web-only run has no ODIAN row to show at all.
|
||||
const shown = $derived.by(() => {
|
||||
const rows = STACK_ORDER.filter((c) => c !== "odian" || stack.odian || !stack.web);
|
||||
return stack.web ? [...rows, "web" as StackComponent] : rows;
|
||||
});
|
||||
|
||||
function shownState(component: StackComponent): Shown {
|
||||
const event = stack[component];
|
||||
if (event) return event.state;
|
||||
@@ -30,7 +36,7 @@
|
||||
</script>
|
||||
|
||||
<ol class="stack" aria-label="Local stack">
|
||||
{#each STACK_ORDER as component, index (component)}
|
||||
{#each shown as component, index (component)}
|
||||
{@const state = shownState(component)}
|
||||
{@const message = stack[component]?.message}
|
||||
<li class="row {state}">
|
||||
|
||||
@@ -76,6 +76,7 @@ export const COMPONENT_LABEL: Record<StackComponent, string> = {
|
||||
api: "Engine API",
|
||||
tiles: "Tile server",
|
||||
odian: "ODIAN",
|
||||
web: "Web client",
|
||||
};
|
||||
|
||||
export const COMPONENT_DETAIL: Record<StackComponent, string> = {
|
||||
@@ -83,6 +84,7 @@ export const COMPONENT_DETAIL: Record<StackComponent, string> = {
|
||||
api: "odian-engine-api",
|
||||
tiles: "Local tiles",
|
||||
odian: "Odian.exe",
|
||||
web: "localhost:5173",
|
||||
};
|
||||
|
||||
export const STACK_STATE_LABEL: Record<StackState | "waiting", string> = {
|
||||
|
||||
@@ -32,11 +32,15 @@ export type ActionKind =
|
||||
| "syncing"
|
||||
| "pulling_tiles"
|
||||
| "removing"
|
||||
| "building"
|
||||
| "update"
|
||||
| "launch"
|
||||
| "stack"
|
||||
| "error";
|
||||
|
||||
// The web client is NOT here: it only exists on a dev run, and a permanent row saying
|
||||
// "waiting" on every operator PC would be a component they do not have. StackList adds it
|
||||
// when the backend reports one.
|
||||
export const STACK_ORDER: readonly StackComponent[] = ["postgres", "api", "tiles", "odian"];
|
||||
|
||||
export type StackMap = Record<StackComponent, StackEvent | null>;
|
||||
@@ -59,6 +63,8 @@ export function deriveAction(s: Status | null): ActionKind {
|
||||
return "pulling_tiles";
|
||||
case "removing":
|
||||
return "removing";
|
||||
case "building":
|
||||
return "building";
|
||||
case "launching":
|
||||
case "running":
|
||||
return "stack";
|
||||
@@ -95,7 +101,7 @@ const LOCKED_PHASES: ReadonlySet<Phase> = new Set([
|
||||
]);
|
||||
const MAX_LOGS = 250;
|
||||
const FOCUS_REFRESH_MS = 30_000;
|
||||
const emptyStack = (): StackMap => ({ postgres: null, api: null, tiles: null, odian: null });
|
||||
const emptyStack = (): StackMap => ({ postgres: null, api: null, tiles: null, odian: null, web: null });
|
||||
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
class Launcher {
|
||||
|
||||
+33
-4
@@ -2,7 +2,8 @@
|
||||
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
|
||||
//
|
||||
// ?scenario=first-run | update-required | ready | offline-installed |
|
||||
// offline-not-installed | installing | running | sync-conflicts | error
|
||||
// offline-not-installed | installing | running | sync-conflicts | error |
|
||||
// dev (a licensed developer PC: the Dev section of Settings appears)
|
||||
// &fail=sign_in | install | restore | launch | notes make that flow fail
|
||||
// &speed=4 run timers 4x faster
|
||||
// ¬es=no-roadmap notes without a Roadmap
|
||||
@@ -247,6 +248,7 @@ function initialStatus(scenario: MockScenario): Status {
|
||||
map_problem: null,
|
||||
tiles_waiting: null,
|
||||
unfinished: null,
|
||||
dev: null,
|
||||
};
|
||||
switch (scenario) {
|
||||
case "first-run":
|
||||
@@ -277,6 +279,16 @@ function initialStatus(scenario: MockScenario): Status {
|
||||
sync: { pending: 1, conflicts: 2, rejected: 1, waiting: 0 },
|
||||
});
|
||||
break;
|
||||
case "dev":
|
||||
// A licensed PC, already set up to run from a checkout, so the Dev section and the
|
||||
// dev-run marker can be seen without a licence file.
|
||||
Object.assign(s, {
|
||||
installed: CURRENT,
|
||||
operator: OPERATOR,
|
||||
signed_in: true,
|
||||
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
Object.assign(s, {
|
||||
operator: OPERATOR,
|
||||
@@ -332,7 +344,10 @@ class MockBackend implements Backend {
|
||||
this.status = initialStatus(scenario);
|
||||
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
|
||||
this.network = this.status.online;
|
||||
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR };
|
||||
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR, dev_root: "", dev_mode: false, dev_user: "", dev_build: false, dev_target: "client" };
|
||||
// Already pointed at a checkout and switched on, so the dev controls and the dev-run
|
||||
// marker show as they do on the real thing.
|
||||
if (scenario === "dev") Object.assign(this.settings, { dev_root: "E:" + String.fromCharCode(92) + "ODIAN_Dev", dev_mode: true });
|
||||
if (this.status.installed) {
|
||||
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
||||
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
||||
@@ -700,9 +715,10 @@ class MockBackend implements Backend {
|
||||
|
||||
async setSettings(settings: Settings): Promise<Status> {
|
||||
// The AI address is optional: empty means the client stays offline.
|
||||
// All three may be blank: that is how a PC is taken offline on purpose.
|
||||
for (const [label, url] of ([
|
||||
["DHP URL", settings.site_url],
|
||||
["MCP URL", settings.mcp_url],
|
||||
...(settings.site_url.trim() ? [["DHP URL", settings.site_url] as const] : []),
|
||||
...(settings.mcp_url.trim() ? [["MCP URL", settings.mcp_url] as const] : []),
|
||||
...(settings.ai_url.trim() ? [["AI URL", settings.ai_url] as const] : []),
|
||||
] as const)) {
|
||||
try {
|
||||
@@ -714,6 +730,8 @@ class MockBackend implements Backend {
|
||||
if (!settings.install_dir.trim()) throw "Install folder cannot be empty.";
|
||||
const siteChanged = settings.site_url !== this.settings.site_url;
|
||||
this.settings = structuredClone(settings);
|
||||
// As the backend does: a PC with no licence cannot turn dev mode on, whatever it sends.
|
||||
if (!this.status.dev) this.settings.dev_mode = false;
|
||||
this.commit({
|
||||
site: { url: settings.site_url, name: settings.site_url === SITE_URL ? SITE_NAME : null },
|
||||
install_dir: settings.install_dir,
|
||||
@@ -736,9 +754,20 @@ class MockBackend implements Backend {
|
||||
// The mock has no DHP to take tiles from; the button is simply a no-op there.
|
||||
}
|
||||
|
||||
/** Who dev mode would run as, when that is not the operator who signed in. */
|
||||
private devSwap(): string | null {
|
||||
if (!this.status.dev || !this.settings.dev_mode) return null;
|
||||
const asked = this.settings.dev_user.trim();
|
||||
return asked && asked !== this.status.operator ? asked : null;
|
||||
}
|
||||
|
||||
async syncNow(): Promise<void> {
|
||||
if (!this.status.online) throw "Your DHP is unreachable: changes stay on this PC until the next connected sync.";
|
||||
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: sync once it has stopped.";
|
||||
const swap = this.devSwap();
|
||||
if (swap) {
|
||||
throw `Dev mode is set to run as ${swap}, so this PC does not sync. Clear the Run as field in Settings to sync as yourself.`;
|
||||
}
|
||||
const sent = this.status.sync?.pending ?? 0;
|
||||
this.commit({ phase: "syncing", error: null });
|
||||
await this.wait(1400);
|
||||
|
||||
+33
-1
@@ -7,6 +7,7 @@ export type Phase =
|
||||
| "signing_in" // browser sign-in in progress
|
||||
| "pulling_tiles" // taking tiles the DHP baked while this PC was away
|
||||
| "removing" // deleting what was unticked in the marketplace
|
||||
| "building" // dev mode: compiling the checkout before it launches
|
||||
| "installing" // downloading + verifying a version
|
||||
| "restoring" // loading the database snapshot into local Postgres
|
||||
| "syncing" // exchanging operator data with the DHP (ODIAN is not running)
|
||||
@@ -70,6 +71,20 @@ export interface Status {
|
||||
* installed or nothing is missing. ODIAN still launches - with less map than was asked for.
|
||||
*/
|
||||
unfinished: Unfinished | null;
|
||||
/**
|
||||
* This PC's dev licence, when it has a valid one. null on every operator PC — and while
|
||||
* it is null the window shows no trace of dev mode: no switch, no setting, no mention.
|
||||
*/
|
||||
dev: DevLicence | null;
|
||||
}
|
||||
|
||||
/** A signed permission for ONE machine to run ODIAN from source. */
|
||||
export interface DevLicence {
|
||||
machine: string;
|
||||
install_id: string | null;
|
||||
/** YYYY-MM-DD, the last day it works. */
|
||||
expires_at: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** What is missing of what was chosen. */
|
||||
@@ -185,6 +200,19 @@ export interface SyncConflict {
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
/** Dev mode: the folder holding the checkouts (it contains ODIAN_Engine). */
|
||||
dev_root: string;
|
||||
/** Run the client from `dev_root`. Only honoured on a PC with a valid dev licence. */
|
||||
dev_mode: boolean;
|
||||
/** Run as this operator instead of the one signed in. Empty for the signed-in one. */
|
||||
dev_user: string;
|
||||
/** Build the checkout before launching. */
|
||||
dev_build: boolean;
|
||||
/**
|
||||
* Which client(s) Launch starts in dev mode. "both" runs them against the one stack —
|
||||
* same database, same API — which is how a .exe/web divergence becomes visible.
|
||||
*/
|
||||
dev_target: DevTarget;
|
||||
/** e.g. "http://192.168.1.41:30097". */
|
||||
site_url: string;
|
||||
/** Site MCP, for Keycloak sign-in. */
|
||||
@@ -221,7 +249,7 @@ export interface InstallProgress {
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export type StackComponent = "postgres" | "api" | "tiles" | "odian";
|
||||
export type StackComponent = "postgres" | "api" | "tiles" | "odian" | "web";
|
||||
export type StackState = "starting" | "ready" | "stopped" | "failed";
|
||||
|
||||
export interface StackEvent {
|
||||
@@ -257,6 +285,9 @@ export interface WindowControls {
|
||||
onResized(handler: () => void): Promise<Unlisten>;
|
||||
}
|
||||
|
||||
export const DEV_TARGETS = ["client", "web", "both"] as const;
|
||||
export type DevTarget = (typeof DEV_TARGETS)[number];
|
||||
|
||||
export const MOCK_SCENARIOS = [
|
||||
"first-run",
|
||||
"update-required",
|
||||
@@ -267,6 +298,7 @@ export const MOCK_SCENARIOS = [
|
||||
"running",
|
||||
"sync-conflicts",
|
||||
"error",
|
||||
"dev",
|
||||
] as const;
|
||||
|
||||
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# ============================================================================
|
||||
# enable-dev-mode.ps1 -- turn on dev mode for the launcher INSTALLED on this PC.
|
||||
#
|
||||
# The repo's dev-licence.json is a TEAM licence: it names no machine and no
|
||||
# install, and says so in its signed body (scope: team). Whoever can clone this
|
||||
# repo can run dev mode - repo access is the credential, deliberately, because
|
||||
# an operator has the installed launcher and never the source.
|
||||
#
|
||||
# A launcher started FROM this repo (npm run tauri dev) finds the licence by
|
||||
# itself: it looks beside its own exe and two levels up, which is this folder.
|
||||
# An INSTALLED launcher looks in its own install folder, so this script puts a
|
||||
# copy there. Run it once; then open Settings and the Dev section is there.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File tools\enable-dev-mode.ps1
|
||||
# ============================================================================
|
||||
[CmdletBinding()]
|
||||
param([string]$InstallDir)
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repo = Split-Path -Parent $PSScriptRoot
|
||||
$licence = Join-Path $repo "dev-licence.json"
|
||||
if (-not (Test-Path -LiteralPath $licence)) { throw "No dev-licence.json at the repo root ($licence)." }
|
||||
|
||||
# Where ODIAN is installed: what the launcher's own settings say, else the usual place.
|
||||
if (-not $InstallDir) {
|
||||
$settings = Join-Path $env:APPDATA "fr.odian.launcher\settings.json"
|
||||
if (Test-Path -LiteralPath $settings) {
|
||||
$InstallDir = (Get-Content -LiteralPath $settings -Raw | ConvertFrom-Json).install_dir
|
||||
}
|
||||
}
|
||||
if (-not $InstallDir) { $InstallDir = if (Test-Path "D:\") { "D:\ODIAN" } else { "C:\ODIAN" } }
|
||||
if (-not (Test-Path -LiteralPath $InstallDir)) { throw "No ODIAN install at $InstallDir - pass -InstallDir." }
|
||||
|
||||
$state = Join-Path $InstallDir "state"
|
||||
New-Item -ItemType Directory -Force -Path $state | Out-Null
|
||||
$target = Join-Path $state "dev-licence.json"
|
||||
Copy-Item -LiteralPath $licence -Destination $target -Force
|
||||
|
||||
$expires = (Get-Content -LiteralPath $licence -Raw | ConvertFrom-Json).expires_at
|
||||
Write-Host "Dev mode licensed on this PC until $expires" -ForegroundColor Green
|
||||
Write-Host " $target"
|
||||
Write-Host "Restart the launcher, then Settings -> Dev -> point the checkout folder at the folder holding ODIAN_Engine."
|
||||
Reference in New Issue
Block a user