This commit is contained in:
Kelorastus
2026-09-29 10:29:52 +02:00
parent 73a8f2c30c
commit 80dd1e7c7d
22 changed files with 1739 additions and 44 deletions
+93
View File
@@ -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 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. 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 ## The local stack
`launch` starts four processes, all on 127.0.0.1, in this order, each waiting for the previous one: `launch` starts four processes, all on 127.0.0.1, in this order, each waiting for the previous one:
+7
View File
@@ -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
View File
@@ -17,6 +17,9 @@ type Phase =
| "installing" // downloading + verifying a version | "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres | "restoring" // loading the database snapshot into local Postgres
| "syncing" // exchanging operator data with the DHP (ODIAN is not running) | "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 | "launching" // starting Postgres → API → tile server → Odian.exe
| "running" // Odian.exe is open | "running" // Odian.exe is open
| "error"; // the last long operation failed; see `error` | "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, live: boolean | null; // while ODIAN runs: true = its data comes from the DHP,
// false = from this PC. null when it is not running, or // false = from this PC. null when it is not running, or
// the DHP offers no Engine API to forward to // 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 { interface SyncStatus {
@@ -62,10 +95,34 @@ interface SyncConflict {
} }
interface Settings { interface Settings {
site_url: string; // e.g. "http://192.168.1.41:30097" site_url: string; // e.g. "http://192.168.1.41:30097"; "" = no DHP, run offline
mcp_url: string; // site MCP, for Keycloak sign-in 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 ai_url: string; // MCP serving /api/ai/... over the internet; "" = none
install_dir: string; 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 { interface InstallProgress {
@@ -81,7 +138,7 @@ interface InstallProgress {
} }
interface StackEvent { interface StackEvent {
component: "postgres" | "api" | "tiles" | "odian"; component: "postgres" | "api" | "tiles" | "odian" | "web"; // "web" only on a dev run
state: "starting" | "ready" | "stopped" | "failed"; state: "starting" | "ready" | "stopped" | "failed";
message: string | null; message: string | null;
} }
@@ -217,4 +274,6 @@ edits wait in the local database until `resolve_sync_conflict`.
| `status` | `Status` | whenever any field changes | | `status` | `Status` | whenever any field changes |
| `install-progress` | `InstallProgress` | at most ~10 per second while installing | | `install-progress` | `InstallProgress` | at most ~10 per second while installing |
| `stack` | `StackEvent` | on each component state change | | `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 | | `log` | `LogEvent` | notable backend messages, for the activity panel |
+417
View File
@@ -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
View File
@@ -9,6 +9,7 @@ use std::sync::Arc;
use odian_packs::{InstallOptions, Progress, Source, Stage}; use odian_packs::{InstallOptions, Progress, Source, Stage};
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_opener::OpenerExt;
use tokio::sync::{Mutex, Notify}; use tokio::sync::{Mutex, Notify};
use tokio_util::sync::CancellationToken; 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::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
use crate::state::{self, LauncherState, SyncOrigin}; use crate::state::{self, LauncherState, SyncOrigin};
use crate::sync::{self, SyncError, SyncStatus}; use crate::sync::{self, SyncError, SyncStatus};
use crate::devmode::{self, Licence};
use crate::tilesync::{self, Waiting}; use crate::tilesync::{self, Waiting};
use crate::tokens::{self, SiteToken}; 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 /// Taking tiles the DHP baked while this PC was away. The map files are being written
/// under ODIAN, so it does not start meanwhile. /// under ODIAN, so it does not start meanwhile.
PullingTiles, PullingTiles,
/// Dev mode: compiling the checkout before it launches.
Building,
/// Deleting what the operator unticked in the marketplace. A tile set is hundreds of /// 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. /// thousands of files, so this is minutes of work with something to show for it.
Removing, Removing,
@@ -86,6 +90,9 @@ pub struct Status {
/// Marketplace items the operator chose that are not all on this PC. None when nothing /// 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. /// is installed or nothing is missing: ODIAN still runs, with less map than was asked for.
pub unfinished: Option<Unfinished>, 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. /// What is missing of what was chosen.
@@ -170,6 +177,10 @@ struct Inner {
map_problem: Option<String>, map_problem: Option<String>,
tiles_waiting: Option<Waiting>, tiles_waiting: Option<Waiting>,
unfinished: Option<Unfinished>, 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. /// What the last count found on disk, kept for as long as nothing can have changed it.
market_checked: Option<Vec<market::Check>>, market_checked: Option<Vec<market::Check>>,
/// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`). /// 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(), map_problem: self.map_problem.clone(),
tiles_waiting: self.tiles_waiting.clone(), tiles_waiting: self.tiles_waiting.clone(),
unfinished: self.unfinished.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}"))?; .map_err(|e| format!("cannot start the HTTP client: {e}"))?;
Ok(Launcher { Ok(Launcher {
http, 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: Mutex::new(None),
stack_states: std::sync::Mutex::new(Vec::new()), stack_states: std::sync::Mutex::new(Vec::new()),
sign_in_cancel: Notify::new(), sign_in_cancel: Notify::new(),
@@ -498,7 +510,13 @@ impl Launcher {
fresh fresh
}; };
if let Some(why) = announce { 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; let mut inner = self.inner.lock().await;
@@ -523,6 +541,7 @@ impl Launcher {
} }
self.check_tiles(app).await; self.check_tiles(app).await;
self.read_unfinished(app).await; self.read_unfinished(app).await;
self.read_dev_licence(app).await;
{ {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
if inner.phase == Phase::Checking { if inner.phase == Phase::Checking {
@@ -555,6 +574,73 @@ impl Launcher {
Some((site_url, token, sets, seq, layout.data_dir())) 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 /// 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. /// it costs eight small files rather than a walk of the tile store.
async fn read_unfinished(&self, app: &AppHandle) { async fn read_unfinished(&self, app: &AppHandle) {
@@ -737,6 +823,13 @@ impl Launcher {
ai_url: normalize_url(&incoming.ai_url), ai_url: normalize_url(&incoming.ai_url),
install_dir: incoming.install_dir.trim().to_string(), install_dir: incoming.install_dir.trim().to_string(),
site_name: incoming.site_name.or_else(|| inner.settings.site_name.clone()), 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() { if next.install_dir.is_empty() {
next.install_dir = inner.settings.install_dir.clone(); 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()) { if let Some(problem) = crate::settings::portable_folder_problem(&next.install_path()) {
return Err(problem); 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)?; next.save(app)?;
if next.install_dir != inner.settings.install_dir { if next.install_dir != inner.settings.install_dir {
inner.state = LauncherState::load(&next.install_path())?; inner.state = LauncherState::load(&next.install_path())?;
@@ -1666,6 +1769,8 @@ impl Launcher {
inner.state.save(&install_dir)?; inner.state.save(&install_dir)?;
} }
let layout = Layout::new(&install_dir, &version); 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)?; let tile_sets = stack::installed_tile_sets(&layout)?;
// What the DHP can draw for this PC, beyond what it downloaded. // What the DHP can draw for this PC, beyond what it downloaded.
let site_tile_sets = stack::version_tile_sets(&layout)?; let site_tile_sets = stack::version_tile_sets(&layout)?;
@@ -1673,13 +1778,20 @@ impl Launcher {
inner.error = None; inner.error = None;
StackConfig { StackConfig {
layout, 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(), mint_secret: inner.state.mint_secret.clone(),
pg_password: inner.state.pg_password.clone(), pg_password: inner.state.pg_password.clone(),
install_id: inner.state.install_id.clone(), install_id: inner.state.install_id.clone(),
dev_engine: dev,
dev_user: dev_user.clone(),
dev_target: inner.settings.dev_target,
tile_sets, tile_sets,
site_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(), ai_url: inner.settings.ai_url.clone(),
site_url: inner.settings.site_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) { 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. // A PC that installed before this version keeps its database: give it the new tables.
self.add_missing_tables(app, &config).await; self.add_missing_tables(app, &config).await;
// Take in the other operators' work before the API loads it. Never blocks the launch. // Take in the other operators' work before the API loads it. Never blocks the launch.
self.set_phase(app, Phase::Syncing).await; self.sync_unless_swapped(app, &config, true).await;
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, true).await;
self.set_phase(app, Phase::Launching).await; self.set_phase(app, Phase::Launching).await;
// Live mode: hand the API the DHP token before it starts, so a launch made online // 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 { if live_mode {
let token = self.site_access_token(app).await.ok(); let token = self.site_access_token(app).await.ok();
self.write_upstream_token(&config.layout, token.as_deref()); 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); let emit = |event: StackEvent| self.record_stack(app, event);
@@ -1780,7 +1995,11 @@ impl Launcher {
return self.finish(app, Some(message)).await; 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.stack.lock().await = Some(running);
self.set_phase(app, Phase::Running).await; self.set_phase(app, Phase::Running).await;
@@ -1795,8 +2014,16 @@ impl Launcher {
}); });
} }
if let Some(odian) = odian { if let Some(url) = config.web_url() {
stack::wait_exit(&odian).await; 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. // Odian.exe closed. If Stop already took the stack, it stopped the rest too.
live_cancel.cancel(); live_cancel.cancel();
@@ -1807,8 +2034,7 @@ impl Launcher {
running.stop(&emit).await; running.stop(&emit).await;
} }
// Send this session's work while it is fresh; pulling waits for the next launch. // Send this session's work while it is fresh; pulling waits for the next launch.
self.set_phase(app, Phase::Syncing).await; self.sync_unless_swapped(app, &config, false).await;
self.sync_run(app, &config.layout, &config.pg_password, &config.install_id, false).await;
self.finish(app, None).await; self.finish(app, None).await;
} }
@@ -1823,6 +2049,21 @@ impl Launcher {
// ── operator-data sync ────────────────────────────────────────────────── // ── 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: /// 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. /// 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) { 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) { if !matches!(inner.phase, Phase::Idle | Phase::Error) {
return Err("ODIAN is busy: sync once it has stopped.".to_string()); 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.")?; 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() { 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()); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn log_times_read_as_utc_dates() { fn log_times_read_as_utc_dates() {
assert_eq!(utc_text(0), "1970-01-01 00:00:00Z"); assert_eq!(utc_text(0), "1970-01-01 00:00:00Z");
+1
View File
@@ -2,6 +2,7 @@
mod auth; mod auth;
mod commands; mod commands;
mod devmode;
mod fsutil; mod fsutil;
mod install; mod install;
mod launcher; mod launcher;
+25
View File
@@ -27,6 +27,26 @@ pub struct Settings {
pub ai_url: String, pub ai_url: String,
#[serde(default)] #[serde(default)]
pub install_dir: String, 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. /// Display name from site.json; the UI's settings form does not edit it.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub site_name: Option<String>, pub site_name: Option<String>,
@@ -272,6 +292,11 @@ impl Settings {
ai_url: normalize_url(&site.ai_url), ai_url: normalize_url(&site.ai_url),
install_dir: default_install_dir(), install_dir: default_install_dir(),
site_name: site.name, 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.save(app)?;
settings settings
+442 -12
View File
@@ -11,7 +11,7 @@ use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use serde::Serialize; use serde::{Deserialize, Serialize};
use crate::fsutil::{write_atomic, write_json_atomic}; use crate::fsutil::{write_atomic, write_json_atomic};
use crate::localdb::Pg; use crate::localdb::Pg;
@@ -19,6 +19,9 @@ use crate::localdb::Pg;
pub const PG_PORT: u16 = 55432; pub const PG_PORT: u16 = 55432;
pub const API_PORT: u16 = 18080; pub const API_PORT: u16 = 18080;
pub const TILES_PORT: u16 = 18081; 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)] #[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000; const CREATE_NO_WINDOW: u32 = 0x0800_0000;
@@ -39,6 +42,8 @@ pub enum Component {
Api, Api,
Tiles, Tiles,
Odian, Odian,
/// The browser client's dev server (`_WEB`). Dev mode only.
Web,
} }
impl Component { impl Component {
@@ -47,6 +52,7 @@ impl Component {
Component::Postgres => "Postgres", Component::Postgres => "Postgres",
Component::Api => "the Engine API", Component::Api => "the Engine API",
Component::Tiles => "the tile server", Component::Tiles => "the tile server",
Component::Web => "the web client",
Component::Odian => "ODIAN", 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 struct StackConfig {
pub layout: Layout, pub layout: Layout,
pub operator: String, pub operator: String,
@@ -160,9 +192,108 @@ pub struct StackConfig {
/// the site draws the ones this PC never downloaded. Without one it sees only /// the site draws the ones this PC never downloaded. Without one it sees only
/// `tile_sets`, because nothing else could be drawn. /// `tile_sets`, because nothing else could be drawn.
pub site_tile_sets: Vec<TileSet>, pub site_tile_sets: Vec<TileSet>,
/// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT from, instead of the
/// 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 { 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 /// 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 /// 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. /// 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 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`. /// Append the process's stdout and stderr to `log`.
pub fn log_output(cmd: &mut Command, log: &Path) -> Result<(), String> { pub fn log_output(cmd: &mut Command, log: &Path) -> Result<(), String> {
if let Some(dir) = log.parent() { if let Some(dir) = log.parent() {
@@ -244,7 +405,17 @@ impl RunningStack {
} }
pub fn odian(&self) -> Option<Shared> { 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. /// 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 /// 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 /// 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). /// 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> { 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")] { 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 { if port == PG_PORT && own_db_running {
continue; 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_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!( 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." "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); 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 // Asks pg_ctl about this install's own data directory, so a database on the port that
// belongs to another install still reads as someone else's. // belongs to another install still reads as someone else's.
check_ports_free(pg.running())?; check_ports_free(pg.running(), config.runs_web())?;
prepare(config)?; prepare(config)?;
let mut running = RunningStack { job: job::Job::new()?, pg, children: Vec::new() }; let mut running = RunningStack { job: job::Job::new()?, pg, children: Vec::new() };
match start_all(http, config, emit, &mut running).await { match start_all(http, config, emit, &mut running).await {
@@ -319,10 +507,21 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>,
emit(StackEvent::new(Component::Api, ComponentState::Starting)); emit(StackEvent::new(Component::Api, ComponentState::Starting));
let api_dir = state.join("api"); 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)]); let mut api = command(&api_exe, &api_dir, &[parent(&api_exe)]);
api.arg(API_PORT.to_string()).envs(api_env(config)); api.arg(API_PORT.to_string()).envs(api_env(config));
if config.dev_engine.is_some() {
give_console(&mut api);
} else {
log_output(&mut api, &api_dir.join("logs").join("console.log"))?; 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))?; 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)) wait_http(http, &format!("http://127.0.0.1:{API_PORT}/health"), &child, Duration::from_secs(90))
.await .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)]); let mut tiles = command(&python, &tiles_dir, &[parent(&python)]);
tiles tiles
.args(["-m", "uvicorn", "--app-dir"]) .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"]) .args(["app:app", "--host", "127.0.0.1", "--log-level", "warning", "--port"])
.arg(TILES_PORT.to_string()) .arg(TILES_PORT.to_string())
.envs(tiles_env(config)); .envs(tiles_env(config));
if config.dev_engine.is_some() {
give_console(&mut tiles);
} else {
log_output(&mut tiles, &tiles_dir.join("tile_server.log"))?; 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))?; 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)) wait_http(http, &format!("http://127.0.0.1:{TILES_PORT}/health"), &child, Duration::from_secs(120))
.await .await
.map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?; .map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?;
emit(StackEvent::new(Component::Tiles, ComponentState::Ready)); emit(StackEvent::new(Component::Tiles, ComponentState::Ready));
if config.runs_client() {
start_client(config, emit, running).await?;
}
if config.runs_web() {
start_web(http, config, emit, running).await?;
}
Ok(())
}
/// 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)); emit(StackEvent::new(Component::Odian, ComponentState::Starting));
let client_dir = state.join("client"); 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)]); let mut odian = command(&odian_exe, &client_dir, &[parent(&odian_exe)]);
odian.envs(client_env(config)); odian.envs(client_env(config));
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"))?; 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))?; 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. // Odian.exe has no health endpoint; a client that survives its first seconds has signed in.
tokio::time::sleep(Duration::from_secs(3)).await; tokio::time::sleep(Duration::from_secs(3)).await;
@@ -366,6 +598,63 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>,
Ok(()) 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 { fn failed(emit: Emit<'_>, component: Component, message: String) -> String {
emit(StackEvent { component, state: ComponentState::Failed, message: Some(message.clone()) }); emit(StackEvent { component, state: ComponentState::Failed, message: Some(message.clone()) });
message message
@@ -434,16 +723,19 @@ fn prepare(config: &StackConfig) -> Result<(), String> {
// config\ and logs\ next to them. Junctions give it both at once: this // 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. // version's read-only files, inside a folder of its own it may write to.
let client = state.join("client"); 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"] { 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_atomic(&state.join("api").join("pg.ini"), pg_ini(config).as_bytes())?;
write_json_atomic(&state.join("tiles").join("datasets.json"), &datasets(config)) 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() { 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() { if std::fs::symlink_metadata(link).is_ok() {
// On a junction this removes only the link; a real folder with files in it refuses. // 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 /// 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. /// to bake from. Without both the tile server stays cache-only, exactly as before.
fn baker(config: &StackConfig) -> Option<PathBuf> { 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()); let any_archive = config.tile_sets.iter().any(|set| pmtiles_of(config, &set.dataset).is_some());
(any_archive && exe.is_file()).then_some(exe) (any_archive && exe.is_file()).then_some(exe)
} }
@@ -827,6 +1126,10 @@ mod tests {
.collect(), .collect(),
upstream_url: None, upstream_url: None,
ai_url: String::new(), 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_url: "https://dhp.example/dhp".to_string(),
site_tile_sets: tile_sets site_tile_sets: tile_sets
.iter() .iter()
@@ -956,6 +1259,133 @@ mod tests {
assert_eq!(doc["planet_heightmap"]["styles"]["heightmap"]["tile_ext"], ".lerc"); 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] #[test]
fn a_fetched_style_is_never_drawn_from_the_vector_archive() { 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 // OpenStreetMap is a DRAWN map, but the office draws none of it: it asks
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher", "productName": "ODIAN Launcher",
"version": "0.1.24", "version": "0.1.34",
"identifier": "fr.odian.launcher", "identifier": "fr.odian.launcher",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
+7
View File
@@ -0,0 +1,7 @@
{
"expires_at": "2026-12-31",
"install_id": "drive-install",
"note": "the SSD kit",
"product": "odian-dev",
"signature": "ed25519:+ZRrXmI7lKecjBOmv1ySA9sHjAK87YdF5w9B8mDpOvWLfz2po1v6n4GTdHwS52bT8H6inbw0ZK9vdxGexmbhDA=="
}
+7
View File
@@ -0,0 +1,7 @@
{
"expires_at": "2026-12-31",
"note": "travels with the source repo",
"product": "odian-dev",
"scope": "team",
"signature": "ed25519:HpcstGGqeTJEU3upVPh/MaZATzpUQ6ahH3wjbccu24kimgpFwyfwGMPTMMrZF8uIBPU0TKVVXEuLkyWkhqPlBw=="
}
+1
View File
@@ -0,0 +1 @@
ed25519:A9/VD/SZC0Dy9DeLrorodVIPTi3XlBui4QQVZAg/5NQ=
+6
View File
@@ -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=="
}
+8
View File
@@ -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=="
}
+30
View File
@@ -66,6 +66,8 @@
return { label: "Taking map tiles", tone: "accent", pulse: true, meta: tileMeta }; return { label: "Taking map tiles", tone: "accent", pulse: true, meta: tileMeta };
case "removing": case "removing":
return { label: "Removing", tone: "accent", pulse: true, meta: rp ? `${rp.index}/${rp.count}` : null }; 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": case "update":
return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` }; return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` };
case "launch": case "launch":
@@ -92,6 +94,16 @@
return (unfinished?.items ?? 0) > titles.length || titles.length > 2 ? `${shown}…` : shown; 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 tiles = $derived(s?.tiles_waiting ?? null);
const tilesOffered = $derived(tiles !== null && tiles.tiles > 0); const tilesOffered = $derived(tiles !== null && tiles.tiles > 0);
const tilesCount = $derived(tiles === null ? "" : `${formatCount(tiles.tiles)}${tiles.more ? "+" : ""}`); 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> <button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p> </p>
{/if} {/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} {#if unfinished}
<p class="secondary attention"> <p class="secondary attention">
<Icon name="alert" size={14} /> <Icon name="alert" size={14} />
@@ -286,6 +308,14 @@
<ProgressBar value={null} label="Syncing with the DHP" /> <ProgressBar value={null} label="Syncing with the DHP" />
</div> </div>
<p class="secondary">Sending this PC's changes to {site} and taking in the other operators' work.</p> <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"} {:else if action === "removing"}
<div class="progress"> <div class="progress">
<div class="stage-line"> <div class="stage-line">
+169 -7
View File
@@ -10,7 +10,17 @@
let { open = $bindable(false) }: Props = $props(); 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 dialog: HTMLDialogElement | undefined = $state();
let draft = $state<Settings>({ ...EMPTY }); let draft = $state<Settings>({ ...EMPTY });
@@ -33,18 +43,32 @@
mcp_url: draft.mcp_url.trim(), mcp_url: draft.mcp_url.trim(),
ai_url: draft.ai_url.trim(), ai_url: draft.ai_url.trim(),
install_dir: draft.install_dir.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( const dirty = $derived(
current !== null && current !== null &&
(trimmed.site_url !== current.site_url || (trimmed.site_url !== current.site_url ||
trimmed.mcp_url !== current.mcp_url || trimmed.mcp_url !== current.mcp_url ||
trimmed.ai_url !== current.ai_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. */ /** 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 offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
const siteError = $derived(urlProblem(trimmed.site_url)); // Blank is a deliberate answer for all three: no DHP, no sign-in service, no AI. It is
const mcpError = $derived(urlProblem(trimmed.mcp_url)); // 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. */ /** 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 aiError = $derived(trimmed.ai_url === "" ? null : urlProblem(trimmed.ai_url));
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed."); const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
@@ -53,7 +77,6 @@
); );
function urlProblem(value: string): string | null { function urlProblem(value: string): string | null {
if (!value) return "Required.";
try { try {
const url = new URL(value); const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? null : "Use an http:// or https:// address."; 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(); 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() { async function browse() {
const path = await launcher.pickFolder(); const path = await launcher.pickFolder();
if (path) draft.install_dir = path; if (path) draft.install_dir = path;
@@ -186,7 +222,7 @@
aria-describedby="set-site-url-help" aria-describedby="set-site-url-help"
/> />
<span id="set-site-url-help" class={dirty && siteError ? "field-error" : "field-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> </span>
</div> </div>
<div class="field"> <div class="field">
@@ -202,7 +238,7 @@
aria-describedby="set-mcp-url-help" aria-describedby="set-mcp-url-help"
/> />
<span id="set-mcp-url-help" class={dirty && mcpError ? "field-error" : "field-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> </span>
</div> </div>
<div class="field"> <div class="field">
@@ -258,6 +294,82 @@
</div> </div>
</fieldset> </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"> <section class="group" aria-labelledby="set-drive-title">
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3> <h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
<p class="text"> <p class="text">
@@ -335,6 +447,56 @@
</dialog> </dialog>
<style> <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 { .drawer {
position: fixed; position: fixed;
inset: 0 0 0 auto; inset: 0 0 0 auto;
+7 -1
View File
@@ -21,6 +21,12 @@
failed: "danger", 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 { function shownState(component: StackComponent): Shown {
const event = stack[component]; const event = stack[component];
if (event) return event.state; if (event) return event.state;
@@ -30,7 +36,7 @@
</script> </script>
<ol class="stack" aria-label="Local stack"> <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 state = shownState(component)}
{@const message = stack[component]?.message} {@const message = stack[component]?.message}
<li class="row {state}"> <li class="row {state}">
+2
View File
@@ -76,6 +76,7 @@ export const COMPONENT_LABEL: Record<StackComponent, string> = {
api: "Engine API", api: "Engine API",
tiles: "Tile server", tiles: "Tile server",
odian: "ODIAN", odian: "ODIAN",
web: "Web client",
}; };
export const COMPONENT_DETAIL: Record<StackComponent, string> = { export const COMPONENT_DETAIL: Record<StackComponent, string> = {
@@ -83,6 +84,7 @@ export const COMPONENT_DETAIL: Record<StackComponent, string> = {
api: "odian-engine-api", api: "odian-engine-api",
tiles: "Local tiles", tiles: "Local tiles",
odian: "Odian.exe", odian: "Odian.exe",
web: "localhost:5173",
}; };
export const STACK_STATE_LABEL: Record<StackState | "waiting", string> = { export const STACK_STATE_LABEL: Record<StackState | "waiting", string> = {
+7 -1
View File
@@ -32,11 +32,15 @@ export type ActionKind =
| "syncing" | "syncing"
| "pulling_tiles" | "pulling_tiles"
| "removing" | "removing"
| "building"
| "update" | "update"
| "launch" | "launch"
| "stack" | "stack"
| "error"; | "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 const STACK_ORDER: readonly StackComponent[] = ["postgres", "api", "tiles", "odian"];
export type StackMap = Record<StackComponent, StackEvent | null>; export type StackMap = Record<StackComponent, StackEvent | null>;
@@ -59,6 +63,8 @@ export function deriveAction(s: Status | null): ActionKind {
return "pulling_tiles"; return "pulling_tiles";
case "removing": case "removing":
return "removing"; return "removing";
case "building":
return "building";
case "launching": case "launching":
case "running": case "running":
return "stack"; return "stack";
@@ -95,7 +101,7 @@ const LOCKED_PHASES: ReadonlySet<Phase> = new Set([
]); ]);
const MAX_LOGS = 250; const MAX_LOGS = 250;
const FOCUS_REFRESH_MS = 30_000; 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); const nowSeconds = () => Math.floor(Date.now() / 1000);
class Launcher { class Launcher {
+33 -4
View File
@@ -2,7 +2,8 @@
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers. // `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
// //
// ?scenario=first-run | update-required | ready | offline-installed | // ?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 // &fail=sign_in | install | restore | launch | notes make that flow fail
// &speed=4 run timers 4x faster // &speed=4 run timers 4x faster
// &notes=no-roadmap notes without a Roadmap // &notes=no-roadmap notes without a Roadmap
@@ -247,6 +248,7 @@ function initialStatus(scenario: MockScenario): Status {
map_problem: null, map_problem: null,
tiles_waiting: null, tiles_waiting: null,
unfinished: null, unfinished: null,
dev: null,
}; };
switch (scenario) { switch (scenario) {
case "first-run": case "first-run":
@@ -277,6 +279,16 @@ function initialStatus(scenario: MockScenario): Status {
sync: { pending: 1, conflicts: 2, rejected: 1, waiting: 0 }, sync: { pending: 1, conflicts: 2, rejected: 1, waiting: 0 },
}); });
break; 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": case "error":
Object.assign(s, { Object.assign(s, {
operator: OPERATOR, operator: OPERATOR,
@@ -332,7 +344,10 @@ class MockBackend implements Backend {
this.status = initialStatus(scenario); this.status = initialStatus(scenario);
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : []; this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
this.network = this.status.online; 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) { if (this.status.installed) {
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download. // An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed"); for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
@@ -700,9 +715,10 @@ class MockBackend implements Backend {
async setSettings(settings: Settings): Promise<Status> { async setSettings(settings: Settings): Promise<Status> {
// The AI address is optional: empty means the client stays offline. // 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 ([ for (const [label, url] of ([
["DHP URL", settings.site_url], ...(settings.site_url.trim() ? [["DHP URL", settings.site_url] as const] : []),
["MCP URL", settings.mcp_url], ...(settings.mcp_url.trim() ? [["MCP URL", settings.mcp_url] as const] : []),
...(settings.ai_url.trim() ? [["AI URL", settings.ai_url] as const] : []), ...(settings.ai_url.trim() ? [["AI URL", settings.ai_url] as const] : []),
] as const)) { ] as const)) {
try { try {
@@ -714,6 +730,8 @@ class MockBackend implements Backend {
if (!settings.install_dir.trim()) throw "Install folder cannot be empty."; if (!settings.install_dir.trim()) throw "Install folder cannot be empty.";
const siteChanged = settings.site_url !== this.settings.site_url; const siteChanged = settings.site_url !== this.settings.site_url;
this.settings = structuredClone(settings); 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({ this.commit({
site: { url: settings.site_url, name: settings.site_url === SITE_URL ? SITE_NAME : null }, site: { url: settings.site_url, name: settings.site_url === SITE_URL ? SITE_NAME : null },
install_dir: settings.install_dir, 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. // 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> { 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.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."; 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; const sent = this.status.sync?.pending ?? 0;
this.commit({ phase: "syncing", error: null }); this.commit({ phase: "syncing", error: null });
await this.wait(1400); await this.wait(1400);
+33 -1
View File
@@ -7,6 +7,7 @@ export type Phase =
| "signing_in" // browser sign-in in progress | "signing_in" // browser sign-in in progress
| "pulling_tiles" // taking tiles the DHP baked while this PC was away | "pulling_tiles" // taking tiles the DHP baked while this PC was away
| "removing" // deleting what was unticked in the marketplace | "removing" // deleting what was unticked in the marketplace
| "building" // dev mode: compiling the checkout before it launches
| "installing" // downloading + verifying a version | "installing" // downloading + verifying a version
| "restoring" // loading the database snapshot into local Postgres | "restoring" // loading the database snapshot into local Postgres
| "syncing" // exchanging operator data with the DHP (ODIAN is not running) | "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. * installed or nothing is missing. ODIAN still launches - with less map than was asked for.
*/ */
unfinished: Unfinished | null; 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. */ /** What is missing of what was chosen. */
@@ -185,6 +200,19 @@ export interface SyncConflict {
} }
export interface Settings { 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". */ /** e.g. "http://192.168.1.41:30097". */
site_url: string; site_url: string;
/** Site MCP, for Keycloak sign-in. */ /** Site MCP, for Keycloak sign-in. */
@@ -221,7 +249,7 @@ export interface InstallProgress {
message: string | null; 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 type StackState = "starting" | "ready" | "stopped" | "failed";
export interface StackEvent { export interface StackEvent {
@@ -257,6 +285,9 @@ export interface WindowControls {
onResized(handler: () => void): Promise<Unlisten>; 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 = [ export const MOCK_SCENARIOS = [
"first-run", "first-run",
"update-required", "update-required",
@@ -267,6 +298,7 @@ export const MOCK_SCENARIOS = [
"running", "running",
"sync-conflicts", "sync-conflicts",
"error", "error",
"dev",
] as const; ] as const;
export type MockScenario = (typeof MOCK_SCENARIOS)[number]; export type MockScenario = (typeof MOCK_SCENARIOS)[number];
+42
View File
@@ -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."