Compare commits

...

38 Commits

Author SHA1 Message Date
Kelorastus f69ba7c016 fix 2026-10-02 16:53:01 +02:00
Kelorastus 540f694f01 Revert "dev mode: prefer the DHP's data, fall back to the local install"
This reverts commit 53bba10b3e.
2026-10-02 16:19:24 +02:00
Kelorastus ee2d56b705 update 2026-10-02 16:10:51 +02:00
Kelorastus 53bba10b3e dev mode: prefer the DHP's data, fall back to the local install
A dev run is for working on the client, and the data worth working against lives
on the DHP. Deciding this by "is anything installed" guessed wrong both ways: a PC
with an old install silently ran on stale local data, with nothing on screen
saying so, while a PC with none was refused outright.

Now: dev mode + the DHP answering -> the client runs from the checkout against the
DHP's API and tiles, installed or not. No DHP -> the local stack, as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 16:10:07 +02:00
Valentin 7a97afe046 Update tauri.conf.json 2026-10-02 14:36:51 +02:00
Valentin 9d4da39cb6 Update tauri.conf.json 2026-10-02 14:28:06 +02:00
Valentin 4450722428 Update tauri.conf.json 2026-10-02 14:20:36 +02:00
Kelorastus 167dbd6d3a Merge branch 'main' of http://192.168.1.41:30061/admin/ODIAN_Launcher 2026-10-02 13:10:50 +02:00
Kelorastus 30123e5576 update 2026-10-02 13:10:49 +02:00
JIMMY_JFEXP 6af563793b Merge branch 'main' of http://192.168.1.41:30061/admin/ODIAN_Launcher 2026-10-02 13:06:05 +02:00
Kelorastus dbe7d43387 fix 2026-10-02 11:57:37 +02:00
Kelorastus 9a3094bebc fix 2026-10-02 11:43:22 +02:00
Valentin 7843d48c8f Update tauri.conf.json 2026-10-02 11:00:43 +02:00
Kelorastus 3a9de4013f fix 2026-10-01 13:54:02 +02:00
Kelorastus c1a3bf03b8 fix 2026-10-01 11:56:40 +02:00
Kelorastus 064c1b9098 fix 2026-10-01 09:35:18 +02:00
Kelorastus 1c25891ab7 Merge branch 'main' of http://192.168.1.41:30061/admin/ODIAN_Launcher 2026-09-30 16:02:34 +02:00
Kelorastus e28101ada5 fix 2026-09-30 16:02:30 +02:00
JIMMY_JFEXP a1cc4e086d Marketplace Finish: repair sets whose marker says complete but files are missing
"2 chosen maps are only partly downloaded (Population density, Yandex
satellite imagery). Finish" - and Finish did nothing, silently.

Root cause: the banner and the button asked two different questions.
market::unfinished counts a set as unfinished when check_installed's file
count found it short (launcher.log: "popdark says it is installed but holds
293056 of 400268 files", "yandex ... 2371 of 28213"). Finish went through
apply_market, whose market::to_add only reads the markers - .blocks-done.json
listing every block - so it found nothing to add, nothing to remove, and
returned Ok. And even a forced install could not have repaired it: the
installer skips every block the marker lists (install.rs `pending`).

- odian-packs: InstallOptions::recheck_tile_sets. For those sets install_tiles
  counts the tiles on disk per block (tiles_on_disk_per_block: z/x/y.ext names
  only, so .blocks-done.json and the tile server's .missing negatives never
  count), takes off `done` every block holding fewer than the index names
  (forget_short_blocks - more is still whole: a PC bakes its own tiles in),
  rewrites the marker at once so a stopped run reads as partial, and fetches
  just those blocks; finish_block's rename replaces what is there. The
  drive-copy path skips its "recorded complete" shortcut for them too.
- launcher: Finish passes market::short_tile_sets(market_checked) through
  Inner::market_recheck (taken by install_market_items), apply_market runs when
  a re-check is all there is to do, and the log says which sets are repaired.

Counted, not hashed: the index names a block's tiles only by digest, so a
block whose lost tiles were replaced by as many baked ones reads whole.

Spot-check: odian-packs install::tests (a short block is dropped, a whole one
and one with extra baked tiles kept, markers not counted); launcher
market::tests::finish_repairs_the_sets_a_count_found_short with the counts
from the real log.
2026-09-30 15:49:53 +02:00
Kelorastus 8988ad8b06 fix 2026-09-30 15:01:58 +02:00
Kelorastus 3863809ae8 new console 2026-09-30 13:36:14 +02:00
Kelorastus feed0fa869 fix 2026-09-30 13:13:22 +02:00
Kelorastus 20227eda0e fix 2026-09-30 12:27:14 +02:00
Kelorastus e4aeb6794e fix 2026-09-30 11:48:25 +02:00
Kelorastus df2f30af17 fix 2026-09-30 11:24:46 +02:00
Kelorastus d780c12b71 fix 2026-09-30 11:15:30 +02:00
Kelorastus ffbb8fc9a5 fix 2026-09-30 10:53:46 +02:00
JIMMY_JFEXP 52f77c814c Launcher window: say "Not signed in on this PC" under Launch, with Sign in
The dev drive on another PC still had no live aircraft after that PC's launcher
went live: its API log kept saying "the DHP answered, but this PC has no DHP
sign-in yet", and launcher.json was never rewritten, i.e. no sign-in ever
completed there. Root cause is the window, not the operator: the action panel
offers Sign in only when nothing is installed, an update is due, or no operator
is remembered (actionFor). The operator is remembered IN launcher.json, which
travels with the drive, while the sign-in lives in that PC's Credential
Manager - so a drive on a new PC shows Launch as if nothing were missing, and
Sign in sits in Settings > Account where nothing points to it.

Under Launch, while online and not signed in, the panel now says ODIAN will run
on this PC's own data without live aircraft, with a Sign in link. Launch stays
the button: a sign-in that cannot complete must not stop ODIAN.

New mock scenario `new-pc` (online, installed, operator remembered, no
sign-in). Spot-check: npm run dev -> ?scenario=new-pc shows the line; its Sign
in link runs the sign-in and the line goes, the status bar then reading
"Signed in as". svelte-check: 0 errors.
2026-09-29 16:22:41 +02:00
JIMMY_JFEXP 58ebd1dded Say it when a live launch has no DHP sign-in on this PC
The dev drive on another PC still showed no live aircraft after ce36cf0. Its
API log, on the drive: "now LIVE on the DHP (the DHP answered, but this PC has
no DHP sign-in yet)". The operator in launcher.json travels with the drive; the
DHP sign-in lives in that PC's Credential Manager and does not. So the launcher
launched as the drive's operator, wrote an empty upstream.token, and the API
and the tile server served the drive's own data - with nothing in the launcher's
window to say why.

run_stack now logs, when live mode has no token: no DHP sign-in on this PC, what
that costs (no live aircraft, no site tiles), and to stop ODIAN and sign in,
per PC.
2026-09-29 16:05:00 +02:00
Kelorastus cf8f0d28a6 Merge branch 'main' of http://192.168.1.41:30061/admin/ODIAN_Launcher 2026-09-29 15:54:30 +02:00
Kelorastus 0252f95e35 fixed Dev mode 2026-09-29 15:54:26 +02:00
JIMMY_JFEXP 8b69e445b5 Merge remote-tracking branch 'origin/main' 2026-09-29 15:29:46 +02:00
JIMMY_JFEXP ce36cf0f37 Live mode on a PC that started offline: re-probe at launch, remember the Engine API
The dev drive, plugged into another PC that was offline and then connected,
ran two sessions with no live aircraft. Its API log, on the drive, says why:
"upstream: live mode off (ODIAN_UPSTREAM_URL is unset)" - both times.

Root cause: live mode is decided when the stack starts (StackConfig::
upstream_url = Inner::engine_url), and engine_url was only ever learned from a
site probe and held in MEMORY. A launcher opened offline has none, and it probes
again only on window focus (30 s debounce) or the refresh button - so a Launch
pressed after connecting still used the offline answer, the API got no
ODIAN_UPSTREAM_URL, and live_loop (which would have synced the offline edits and
switched the API to the DHP when the network came) never started.

- launch() re-probes the site first when the launcher thinks it is offline (one
  probe, 2 s at most on a PC that really is). It is also the update check a
  launch from a stale "offline" skipped.
- The Engine API address is kept in launcher.json (LauncherState::engine, keyed
  by site like tile_sync) and loaded at start, so a session started offline
  still arms live mode and goes live by itself once the network is back - the
  path live_loop already handles. Changing the DHP address in Settings drops it.

Spot-check: state::tests - per-site, unchanged-is-no-write, survives save/load,
and a launcher.json from before the field still loads.
2026-09-29 15:29:09 +02:00
Kelorastus afbb597044 fix 2026-09-29 14:57:33 +02:00
JIMMY_JFEXP b46b81ac12 Dev: install Node.js with winget when a web run finds none
A dev web launch on a PC without Node.js - a fresh one, or another PC the dev
drive was plugged into - failed with nothing but "stopped while starting (exit
code: 1)": `cmd /c npm run dev` had no npm to run, so nothing said why.

New nodejs.rs finds the folder holding node.exe AND npm.cmd - this process's
PATH first, then %ProgramFiles%\nodejs, where the installer puts it (an
install adds itself to the SYSTEM PATH, which the running launcher never sees).
Launcher::ensure_node runs before a web run's build/start: with no Node.js it
logs why and runs `winget install --id OpenJS.NodeJS.LTS --exact --source
winget --accept-*-agreements` in its own console (progress, Windows' permission
prompt and any failure stay on screen), then judges by what is on disk - not
by winget's exit code, which is non-zero for "already installed" too. No Node
afterwards stops the launch with the manual step spelled out.

`npm run dev` (web_command) and `npm install` (install_web) now get that folder
first on their PATH, so the launch goes on straight after the install without
restarting the launcher. start_web keeps a guard with a real message.

Spot-check: `winget show --id OpenJS.NodeJS.LTS --exact --source winget` names
the nodejs.org x64 MSI; nodejs::tests cover PATH-first, the fresh-install
folder, npm-without-node, and the argument list.
2026-09-29 14:48:28 +02:00
JIMMY_JFEXP 2d79664dc7 Dev: route the web's map through this stack's tile server; power grid offline
Two reasons an offline dev run's web client had no map:

1. Vite proxied every tile to the public tile host. web_command now sets
   ODIAN_TILE_SERVER_URL=http://127.0.0.1:18081 for the Vite it starts, and
   ODIAN_Engine's _WEB/vite.config.ts sends /basemap, /mvt and /netlayer there:
   the site while it answers, this PC's installed tiles when it does not - the
   same path the .exe already draws from.

2. The power-grid netlayer had no source offline. datasets.json named every
   PostGIS overlay but `powerv2_postgis`, so the tile server answered
   /netlayer/<ds>/powerv2/* with 404 "powerv2_render_sqlite not found" while
   postgis_power_entities / _links / _topology sat in the local snapshot. Added
   to POSTGIS_SOURCES; the tile server resolves it through the same
   ODIAN_TILE_POSTGIS_DSN as the others.

Spot-check: with the site unreachable, /netlayer/<ds>/powerv2/bbox/8/128/86/131/89.json
answers 200 (721064 B, the same body the site serves) and topology.bin 200.
2026-09-29 14:00:28 +02:00
JIMMY_JFEXP d90dda1df5 Dev: keep Vite off the workspace's target/ so tauri dev starts
`npm run tauri dev` died before the window opened: Vite's watcher crashed
with EBUSY on target\debug\deps\odian_launcher_lib.dll. Root cause: the Cargo
workspace builds into target/ at the repo root, not under src-tauri/, and
vite.config.ts only excluded src-tauri - so Vite watched the DLLs the Rust
build holds locked.

Spot-check: a fresh clone, `npm install`, `npm run tauri dev` opens the
launcher window instead of "beforeDevCommand terminated".
2026-09-29 12:14:30 +02:00
JIMMY_JFEXP 425acebe0d Dev mode: sign the web client in so live mode reaches it
A dev run's web client showed no live tracks while the .exe beside it did.
Root cause: the local API relays a request to the DHP only when it carries a
token that API minted itself (main.cpp's forwarder, the guard that keeps it
from being an open proxy). The .exe mints one with ODIAN_MINT_SECRET; the web
client was opened with ?user= only, so every /session/* call it made was
served from this PC's database.

The launcher now mints a token from this stack's API for the run's operator
(stack::mint_web_token) and opens the browser with it in the URL FRAGMENT
(StackConfig::web_url_signed). A fragment never reaches a request line or a
server log, and the launcher's own log still prints the tokenless address.
If the mint fails the web opens as before and the log says it shows this PC's
data only. Needs ODIAN_Engine's _WEB/src/auth.ts to read #token=.

Spot-check: launch dev mode with the web target while online; the API log
reads "now LIVE on the DHP" and the browser shows live tracks.
2026-09-29 12:11:38 +02:00
24 changed files with 3415 additions and 222 deletions
+118 -6
View File
@@ -36,11 +36,25 @@ pub struct InstallOptions {
pub tile_sets: Option<BTreeSet<String>>,
/// The source files to install, by manifest path; `None` is all of them.
pub src_paths: Option<BTreeSet<String>>,
/// Tile sets (`style/dataset`) whose `.blocks-done.json` is NOT to be taken at its word:
/// each block it lists is counted on disk first, and one holding fewer tiles than the
/// index names is fetched again. For a set a count has already found short - its marker
/// says every block landed, the folder says otherwise (tiles deleted by hand, a disk
/// cleaner, a copy that lost files) - which without this no install could ever repair,
/// since the marker is exactly what an install skips on.
pub recheck_tile_sets: BTreeSet<String>,
}
impl Default for InstallOptions {
fn default() -> Self {
InstallOptions { workers: 6, only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles], db_paths: None, tile_sets: None, src_paths: None }
InstallOptions {
workers: 6,
only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles],
db_paths: None,
tile_sets: None,
src_paths: None,
recheck_tile_sets: BTreeSet::new(),
}
}
}
@@ -179,7 +193,7 @@ where
stage_index += 1;
let keep = |s: &&TileSet| opts.tile_sets.as_ref().is_none_or(|sets| sets.contains(&format!("{}/{}", s.style, s.dataset)));
let tiles = TilesSection { sets: tiles.sets.iter().filter(keep).cloned().collect(), ..tiles.clone() };
summary.tiles = Some(install_tiles(&ctx, stage_index, &version, &tiles, &data_dir).await?);
summary.tiles = Some(install_tiles(&ctx, stage_index, &version, &tiles, &data_dir, &opts.recheck_tile_sets).await?);
}
check_cancel()?;
@@ -603,6 +617,7 @@ async fn copy_install_tiles(
tiles: &TilesSection,
root: &Path,
data_dir: &Path,
recheck: &BTreeSet<String>,
) -> Result<Vec<TileSetSummary>> {
let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
let files: u64 = tiles.sets.iter().map(|s| s.files).sum();
@@ -615,8 +630,10 @@ async fn copy_install_tiles(
// This PC already recorded the very same set as complete: `.blocks-done.json` names
// this index and all of its blocks, the record the marketplace itself reads to say
// "Installed". Hashing its hundreds of thousands of files to learn what the marker
// already states costs minutes a set and can only ever conclude "identical".
if set_recorded_complete(&to, set) {
// already states costs minutes a set and can only ever conclude "identical" - unless
// the caller has counted the set short, when the copy below (which leaves identical
// files alone) is the repair.
if set_recorded_complete(&to, set) && !recheck.contains(&format!("{style}/{dataset}")) {
ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "files", set.files);
ctx.reporter.items_done(set.files);
ctx.reporter.add_done_bytes(set.bytes);
@@ -753,10 +770,59 @@ fn copy_checked(from: &Path, to: &Path, reporter: &Reporter, cancel: &Cancellati
Ok((size, true))
}
async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &TilesSection, data_dir: &Path) -> Result<Vec<TileSetSummary>> {
/// How many tiles `set_dir` holds, per block. Only names the installer itself would write
/// count (`z/x/y.ext`, `parse_tile_name`'s rule), so the markers beside them - its own
/// `.blocks-done.json`, the tile server's `.missing` negatives - never pass for tiles.
fn tiles_on_disk_per_block(set_dir: &Path) -> HashMap<(u64, u64, u64), u64> {
let mut counts = HashMap::new();
let Ok(zooms) = std::fs::read_dir(set_dir) else { return counts };
for zoom in zooms.flatten() {
let Ok(columns) = std::fs::read_dir(zoom.path()) else { continue };
let z = zoom.file_name().to_string_lossy().into_owned();
for column in columns.flatten() {
let Ok(rows) = std::fs::read_dir(column.path()) else { continue };
let x = column.file_name().to_string_lossy().into_owned();
for row in rows.flatten() {
if !row.file_type().is_ok_and(|kind| kind.is_file()) {
continue;
}
let name = format!("{z}/{x}/{}", row.file_name().to_string_lossy());
if let Some((z, x, y, _)) = crate::tiles::parse_tile_name(&name) {
*counts.entry(crate::tiles::block_of(z, x, y)).or_default() += 1;
}
}
}
}
counts
}
/// Take off `done` every block the disk holds fewer tiles of than the index names, and say
/// how many. A block holding MORE is whole: a PC bakes tiles of its own into the same folders.
/// Counted, not hashed - the index names a block's tiles only through their digest - so a
/// block whose missing tiles were replaced by as many baked ones reads whole; what a count
/// cannot see, a hash of every tile of the set could, at minutes a set.
fn forget_short_blocks(done: &mut BTreeSet<String>, blocks: &[IndexBlock], on_disk: &HashMap<(u64, u64, u64), u64>) -> usize {
let mut dropped = 0;
for block in blocks {
let held = on_disk.get(&(block.z, block.bx, block.by)).copied().unwrap_or(0);
if held < block.files && done.remove(&block.done_key()) {
dropped += 1;
}
}
dropped
}
async fn install_tiles(
ctx: &Ctx<'_>,
stage_index: u32,
version: &str,
tiles: &TilesSection,
data_dir: &Path,
recheck: &BTreeSet<String>,
) -> Result<Vec<TileSetSummary>> {
if let Some(root) = ctx.backend.install_root() {
let root = root.to_path_buf();
return copy_install_tiles(ctx, stage_index, tiles, &root, data_dir).await;
return copy_install_tiles(ctx, stage_index, tiles, &root, data_dir, recheck).await;
}
let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).sum();
@@ -787,6 +853,16 @@ async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &T
done.extend(list.iter().filter_map(Value::as_str).map(str::to_string));
}
}
if !done.is_empty() && recheck.contains(&format!("{style}/{dataset}")) {
ctx.reporter.begin_sub(format!("tiles {style}/{dataset}: counting what is on disk"), "", 0);
let dir = set_dir.clone();
let on_disk = blocking(move || Ok(tiles_on_disk_per_block(&dir))).await?;
if forget_short_blocks(&mut done, &index.blocks, &on_disk) > 0 {
// Written now, not when the blocks land: a run stopped before then must leave
// a set that reads as partial, so the next one resumes instead of trusting it.
atomic_write(&done_path, blocks_done_json(&set.index_sha256, &done).as_bytes())?;
}
}
let pending: Vec<IndexBlock> = index.blocks.iter().filter(|b| !done.contains(&b.done_key())).cloned().collect();
ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "blocks", index.blocks.len() as u64);
let skipped_bytes: u64 = index.blocks.iter().filter(|b| done.contains(&b.done_key())).map(|b| b.bytes).sum();
@@ -854,6 +930,42 @@ async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &T
mod tests {
use super::*;
fn block(z: u64, bx: u64, by: u64, files: u64, digest: &str) -> IndexBlock {
IndexBlock { z, bx, by, files, bytes: files * 10, digest: digest.to_string() }
}
#[test]
fn a_block_the_disk_holds_short_of_is_fetched_again() {
let dir = std::env::temp_dir().join(format!("odian-recheck-{}", crate::copy::unique_hex()));
// Block (8, 2, 1) is x 128..=191, y 64..=127: two of its three tiles are here, plus a
// tile server negative and the installer's marker, neither of which is a tile.
// Block (8, 0, 0) has its one tile and one this PC baked itself: more is still whole.
for rel in ["8/128/64.png", "8/129/64.png", "8/129/65.png.missing", "8/1/1.png", "8/2/2.png"] {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"t").unwrap();
}
std::fs::write(dir.join(DONE_MARKER), b"{}").unwrap();
let on_disk = tiles_on_disk_per_block(&dir);
assert_eq!(on_disk.get(&(8, 2, 1)), Some(&2));
assert_eq!(on_disk.get(&(8, 0, 0)), Some(&2));
let blocks = [block(8, 2, 1, 3, "aa"), block(8, 0, 0, 1, "bb"), block(9, 0, 0, 4, "cc")];
let mut done: BTreeSet<String> = blocks.iter().map(IndexBlock::done_key).collect();
// The short block and the one with nothing on disk go; the whole one stays.
assert_eq!(forget_short_blocks(&mut done, &blocks, &on_disk), 2);
assert_eq!(done, ["8/0/0:bb".to_string()].into_iter().collect::<BTreeSet<_>>());
// A block that was never marked done is not "forgotten": it was already pending.
assert_eq!(forget_short_blocks(&mut done, &blocks, &on_disk), 0);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_empty_or_missing_folder_counts_nothing() {
assert!(tiles_on_disk_per_block(Path::new(r"Z:\no\such\set")).is_empty());
}
#[test]
fn progress_files_match_python_json_dumps() {
let verified: BTreeSet<u64> = [2, 0, 1].into_iter().collect();
+31
View File
@@ -17,6 +17,37 @@ pub struct SignedIn {
username: String,
}
#[tauri::command]
pub async fn release_plan(launcher: State<'_, Launcher>) -> CmdResult<crate::release::ReleasePlan> {
Ok(launcher.release_plan().await)
}
#[tauri::command]
pub async fn release_run(
launcher: State<'_, Launcher>,
password: String,
with_api: bool,
with_mcp: bool,
with_web: bool,
) -> CmdResult<()> {
launcher.release_run(password, with_api, with_mcp, with_web).await
}
#[tauri::command]
pub async fn refresh_run(
launcher: State<'_, Launcher>,
password: String,
pg_password: String,
tile_root: String,
) -> CmdResult<()> {
launcher.refresh_run(password, pg_password, tile_root).await
}
#[tauri::command]
pub fn release_console(launcher: State<'_, Launcher>, since: usize) -> CmdResult<crate::release::ConsoleTail> {
Ok(launcher.release_console(since))
}
#[tauri::command]
pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
Ok(launcher.status().await)
+615 -39
View File
@@ -19,7 +19,7 @@ use crate::market;
use crate::prune;
use crate::settings::{normalize_url, Settings};
use crate::site::{self, SessionRefresh, TokenCheck};
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
use crate::stack::{self, DhpDirect, Layout, RunningStack, StackConfig, StackEvent};
use crate::state::{self, LauncherState, SyncOrigin};
use crate::sync::{self, SyncError, SyncStatus};
use crate::devmode::{self, Licence};
@@ -65,6 +65,13 @@ pub struct Status {
pub available: Option<String>,
pub update_required: bool,
pub can_launch: bool,
/// This launch would come from the dev checkout. It is what lets an out-of-date install
/// still launch: see `can_launch`.
pub dev_run: bool,
/// This launch would start the client alone, against the DHP, with nothing installed.
pub dhp_direct: bool,
/// On a licensed PC with nothing installed, why that is not on offer. None when it is.
pub dhp_blocked: Option<String>,
pub phase: Phase,
pub error: Option<String>,
pub install_dir: String,
@@ -183,6 +190,10 @@ struct Inner {
dev_refusal: Option<String>,
/// What the last count found on disk, kept for as long as nothing can have changed it.
market_checked: Option<Vec<market::Check>>,
/// Tile sets (`style/dataset`) the next marketplace download re-counts block by block
/// instead of trusting their markers: the ones `market_checked` found short, handed over by
/// Finish and taken by `install_market_items`.
market_recheck: BTreeSet<String>,
/// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`).
portable: bool,
/// A newer launcher is being checked for or downloaded (see `update_self`): nothing may
@@ -198,25 +209,64 @@ struct Inner {
drive_extras: Option<BTreeSet<String>>,
}
/// Whether Launch may go ahead.
///
/// The only case allowed to be out of date is a dev run: it starts the client from the
/// checkout, and the install it sits on just supplies the data layer, which an older pack
/// serves perfectly well. Every other PC updates first.
fn launchable(installed: bool, update_required: bool, dev_run: bool, busy: bool) -> bool {
installed && (!update_required || dev_run) && !busy
}
impl Inner {
/// Take `url` as the DHP's Engine API, and keep it in launcher.json so a launcher started
/// offline next time still arms live mode with it. Err is only the save failing: the
/// address is in use either way.
fn adopt_engine(&mut self, url: String) -> Result<(), String> {
let site = self.settings.site_url.clone();
let changed = self.state.remember_engine(&site, &url);
self.engine_url = Some(url);
if changed {
self.state.save(&self.settings.install_path())?;
}
Ok(())
}
fn status(&self) -> Status {
let installed = self.state.installed_version.clone();
// Online means "update before launch"; offline launches whatever is installed.
// Nothing installed is never "update required": that is a first install.
let update_required = self.online && installed.is_some() && self.available.is_some() && installed != self.available;
// A dev run takes its CLIENT from the checkout; the install only supplies the data
// layer - database, tiles, API, tile server - and an older pack serves that perfectly
// well. So a newer pack on the DHP is news, not a gate. Without this, cutting a release
// stopped every dev PC until it downloaded a 900 MB pack it was not going to run.
let dev_run = Launcher::dev_engine(self).is_some();
// With nothing installed a dev run can still go straight to the DHP, so Launch is
// offered where the hub would otherwise only ever offer Install.
let dhp_direct = Launcher::dhp_direct(self).is_some();
// With a licence and nothing installed, say WHY a DHP run is not on offer. Every one of
// these used to read as a bare "ODIAN is not installed", which points at the wrong thing.
let dhp_blocked = (self.dev.is_some() && installed.is_none() && !dhp_direct)
.then(|| Launcher::why_no_dhp_run(self));
Status {
online: self.online,
site: SiteInfo { url: self.settings.site_url.clone(), name: self.settings.site_name.clone() },
operator: self.state.operator.clone(),
// Remembered until Sign out: a launcher session renews the Engine token on its own.
signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()),
can_launch: installed.is_some()
&& !update_required
&& !self.updating_self
&& matches!(self.phase, Phase::Idle | Phase::Error),
can_launch: launchable(
installed.is_some() || dhp_direct,
update_required,
dev_run,
self.updating_self || !matches!(self.phase, Phase::Idle | Phase::Error),
),
installed,
available: self.available.clone(),
update_required,
dev_run,
dhp_direct,
dhp_blocked,
// Updating itself reads as "checking": the window already shows that at every start.
phase: if self.updating_self && matches!(self.phase, Phase::Idle | Phase::Error) { Phase::Checking } else { self.phase },
error: self.error.clone(),
@@ -241,6 +291,8 @@ pub struct Launcher {
stack_states: std::sync::Mutex<Vec<StackEvent>>,
sign_in_cancel: Notify,
install_cancel: std::sync::Mutex<Option<CancellationToken>>,
/// What the running release has printed, for the console panel.
release_console: crate::release::Shared,
}
/// What an operator picked when installing from a drive: the site store an ODIAN drive
@@ -304,6 +356,50 @@ fn holds_a_version(root: &Path) -> bool {
/// The launcher's update feed on the download page (written by package_launcher.ps1).
const LAUNCHER_FEED: &str = "launcher-update.json";
/// The same, for a launcher that runs from an ODIAN drive and replaces its own exe.
const PORTABLE_FEED: &str = "launcher-portable.json";
/// Is `offered` a later version than `running`? Plain numeric compare, field by field.
fn newer(offered: &str, running: &str) -> bool {
fn parts(v: &str) -> Vec<u64> {
v.split(['.', '-']).map_while(|p| p.parse().ok()).collect()
}
let (a, b) = (parts(offered), parts(running));
for i in 0..a.len().max(b.len()) {
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
if x != y {
return x > y;
}
}
false
}
/// Put `bytes` where this executable is, moving the running one aside.
///
/// The `.old` copy is what makes this safe to do to yourself: if the new exe never starts,
/// the previous one is still on the drive next to it. `prune_old_exe` removes it at the
/// next start, which only happens once the new one has run.
fn swap_exe(bytes: &[u8]) -> Result<(), String> {
let exe = std::env::current_exe().map_err(|e| format!("cannot find this launcher: {e}"))?;
let old = exe.with_extension("old.exe");
let fresh = exe.with_extension("new.exe");
let _ = std::fs::remove_file(&old);
std::fs::write(&fresh, bytes).map_err(|e| format!("cannot write {}: {e}", fresh.display()))?;
std::fs::rename(&exe, &old).map_err(|e| format!("cannot move the running launcher aside: {e}"))?;
if let Err(e) = std::fs::rename(&fresh, &exe) {
let _ = std::fs::rename(&old, &exe); // put it back rather than leave the drive with none
return Err(format!("cannot put the new launcher in place: {e}"));
}
Ok(())
}
/// Delete the exe a portable update moved aside. Called at start: reaching here at all is
/// the new launcher proving it runs.
pub fn prune_old_exe() {
if let Ok(exe) = std::env::current_exe() {
let _ = std::fs::remove_file(exe.with_extension("old.exe"));
}
}
/// Append one line to `<dir>/launcher.log`, keeping one older file past 2 MB. Best effort:
/// a log that cannot be written never stops the launcher.
@@ -343,6 +439,8 @@ impl Launcher {
pub fn load(app: &AppHandle) -> Result<Launcher, String> {
let settings = Settings::load(app)?;
let state = LauncherState::load(&settings.install_path())?;
// Where live mode forwards, known before the first probe: offline, there is no probe.
let engine_url = state.engine_url(&settings.site_url);
let token = tokens::load(&settings.site_url);
let http = reqwest::Client::builder()
.user_agent(concat!("ODIAN-Launcher/", env!("CARGO_PKG_VERSION")))
@@ -350,11 +448,12 @@ impl Launcher {
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
Ok(Launcher {
http,
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, map_from_site: None, map_problem: None, tiles_waiting: None, unfinished: None, dev: None, dev_refusal: 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, live: None, map_from_site: None, map_problem: None, tiles_waiting: None, unfinished: None, dev: None, dev_refusal: None, market_checked: None, market_recheck: BTreeSet::new(), portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }),
stack: Mutex::new(None),
stack_states: std::sync::Mutex::new(Vec::new()),
sign_in_cancel: Notify::new(),
install_cancel: std::sync::Mutex::new(None),
release_console: Default::default(),
})
}
@@ -518,14 +617,15 @@ impl Launcher {
false => self.log(app, "warn", format!("Your DHP did not answer. {why}")),
}
}
{
let kept = {
let mut inner = self.inner.lock().await;
inner.online = probe.online;
inner.available = probe.version;
// Keep the last known address while offline: it is where live mode resumes.
if engine.is_some() {
inner.engine_url = engine;
}
engine.map(|url| inner.adopt_engine(url)).unwrap_or(Ok(()))
};
if let Err(e) = kept {
self.log(app, "warn", format!("Could not remember the DHP's Engine API address: {e}"));
}
// Offline nothing is checked or forgotten: the remembered operator still launches.
if let (true, Some(token)) = (probe.online, token) {
@@ -596,6 +696,62 @@ impl Launcher {
repo.join("tools").join("build").join("build.bat").is_file().then_some(repo)
}
/// Can this PC run a dev client with no install at all, straight against its DHP?
///
/// Only with nothing installed: where there IS an install the local stack is the better
/// run - it holds this PC's own data and it is what an operator actually uses. This is
/// purely the case that used to be refused outright.
///
/// Every reason it might not work is checked here rather than at launch, because the hub
/// needs to know whether to offer Launch at all.
fn dhp_direct(inner: &Inner) -> Option<DhpDirect> {
if Self::dev_engine(inner).is_none() || !inner.online || inner.state.installed_version.is_some() {
return None;
}
let mcp = inner.settings.mcp_url.trim();
if mcp.is_empty() {
return None;
}
Some(DhpDirect {
engine_url: inner.engine_url.clone()?,
mcp_url: mcp.to_string(),
// Beside the install dir it would have used, so a later real install finds its own
// state untouched.
cache_dir: inner.settings.install_path().join("state").join("devclient"),
})
}
/// Why a PC with nothing installed cannot do a DHP-direct run either. Each of these was
/// a silent "ODIAN is not installed" before, which sent people looking in the wrong place.
fn why_no_dhp_run(inner: &Inner) -> String {
if Self::dev_engine(inner).is_none() {
let root = inner.settings.dev_root.trim();
if !inner.settings.dev_mode {
return "Dev mode is off. Turn it on to run from a checkout with nothing installed.".to_string();
}
if root.is_empty() {
return "Dev mode has no checkout folder yet: name the folder that holds ODIAN_Engine.".to_string();
}
return format!(
"{root}{sep}ODIAN_Engine{sep}tools{sep}build{sep}build.bat is not there, so there is no checkout to run. Check the folder below is the right one.",
sep = "\\"
);
}
if !inner.online {
return "Nothing is installed here, so this run needs the DHP - and it is not answering. Connect to it, or install once so this PC has its own data."
.to_string();
}
if inner.settings.mcp_url.trim().is_empty() {
return "No MCP address is set, and that is what the client signs in through. Set it in Settings, or install once."
.to_string();
}
if inner.engine_url.is_none() {
return "The DHP has not said where its Engine API is, so there is nothing to point the client at. Check the connection, or install once."
.to_string();
}
"ODIAN is not installed.".to_string()
}
/// Look for this PC's dev licence.
///
/// Cheap enough to do on every check - one small file - so dropping a licence in is
@@ -666,11 +822,19 @@ impl Launcher {
/// Download what was chosen and never fully arrived, without changing the choice and
/// without removing anything: the selection it applies is everything this PC holds or
/// asked for, so `apply_market` finds nothing to delete and only fills the gaps.
///
/// "Never fully arrived" is also a set whose marker says every block landed while the count
/// found files missing: the hub counts those as unfinished (`market::unfinished`), so Finish
/// must fetch them too. They go down as sets to re-count block by block - the installer
/// skips on that very marker, and without this Finish found nothing to add and did nothing.
pub async fn finish_market(&self, app: &AppHandle) -> Result<(), String> {
let (manifest, _, install_dir) = self.market_manifest(app).await?;
let extras = self.inner.lock().await.state.extras.clone().unwrap_or_default();
let (extras, short) = {
let inner = self.inner.lock().await;
(inner.state.extras.clone().unwrap_or_default(), market::short_tile_sets(inner.market_checked.as_deref().unwrap_or_default()))
};
let keep = market::held_keys(&manifest, &install_dir.join("data"), &extras);
self.apply_market(app, keep.into_iter().collect()).await
self.apply_market_rechecking(app, keep.into_iter().collect(), short).await
}
/// Ask the site what it has baked since this PC last looked. Quiet on failure: a site
@@ -843,7 +1007,10 @@ impl Launcher {
// 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 {
// Only a folder that was NAMED is checked. Dev mode is switched on from the hub now,
// and the checkout is chosen next to the Launch button afterwards, so refusing the
// switch for an empty folder would refuse the first half of a two-step job.
if next.dev_mode && !next.dev_root.is_empty() {
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()));
@@ -856,6 +1023,9 @@ impl Launcher {
if next.site_url != inner.settings.site_url {
inner.token = tokens::load(&next.site_url);
inner.available = None;
// Another DHP's Engine API is not this one's: only what this PC learned from the
// new site, if it was ever on it.
inner.engine_url = inner.state.engine_url(&next.site_url);
}
inner.settings = next;
}
@@ -1425,6 +1595,11 @@ impl Launcher {
}
pub async fn apply_market(&self, app: &AppHandle, selected: Vec<String>) -> Result<(), String> {
self.apply_market_rechecking(app, selected, BTreeSet::new()).await
}
/// `apply_market`, also repairing `recheck` (`style/dataset`): sets counted short on disk.
async fn apply_market_rechecking(&self, app: &AppHandle, selected: Vec<String>, recheck: BTreeSet<String>) -> Result<(), String> {
let (manifest, installed, install_dir) = self.market_manifest(app).await?;
let extras = market::optional_keys(&manifest, selected);
{
@@ -1440,13 +1615,17 @@ impl Launcher {
}
let data = install_dir.join("data");
let adds = market::to_add(&manifest, &data, &extras);
if !installed || (adds.is_empty() && market::to_remove(&manifest, &data, &extras).is_empty()) {
// Only a set still chosen and still in this version is worth re-counting.
let (wanted, _) = market::install_filter(&manifest, &extras, true);
let recheck: BTreeSet<String> = recheck.into_iter().filter(|set| wanted.contains(set)).collect();
if !installed || (adds.is_empty() && recheck.is_empty() && market::to_remove(&manifest, &data, &extras).is_empty()) {
self.emit(app).await;
return Ok(());
}
if !adds.is_empty() && !self.inner.lock().await.online {
if (!adds.is_empty() || !recheck.is_empty()) && !self.inner.lock().await.online {
return Err("Your DHP is unreachable: connect to download marketplace items.".to_string());
}
self.inner.lock().await.market_recheck = recheck;
self.start_run(app, None, true).await
}
@@ -1517,21 +1696,35 @@ impl Launcher {
install_dir: &Path,
cancel: &CancellationToken,
) -> Result<String, InstallFailure> {
let (installed, extras) = {
let inner = self.inner.lock().await;
(inner.state.installed_version.clone(), inner.state.extras.clone().unwrap_or_default())
let (installed, extras, recheck) = {
let mut inner = self.inner.lock().await;
// Taken, not read: a later marketplace download re-counts only if Finish asks again.
(inner.state.installed_version.clone(), inner.state.extras.clone().unwrap_or_default(), std::mem::take(&mut inner.market_recheck))
};
let installed = installed.ok_or_else(|| "ODIAN is not installed.".to_string())?;
let verified = odian_packs::version_manifest(source, &installed, odian_packs::bundled_public_key()).await?;
let manifest = verified.manifest();
let titles: Vec<String> =
market::to_add(manifest, &install_dir.join("data"), &extras).into_iter().map(|item| item.title).collect();
self.log(app, "info", format!("Downloading {}", titles.join(", ")));
if !titles.is_empty() {
self.log(app, "info", format!("Downloading {}", titles.join(", ")));
}
if !recheck.is_empty() {
self.log(
app,
"info",
format!(
"Fetching what is missing on disk from {} (their markers say complete; the count says otherwise).",
recheck.iter().cloned().collect::<Vec<_>>().join(", ")
),
);
}
let (tile_sets, src_paths) = market::install_filter(manifest, &extras, true);
let options = InstallOptions {
only: vec![Stage::Src, Stage::Tiles],
tile_sets: Some(tile_sets),
src_paths: Some(src_paths),
recheck_tile_sets: recheck,
..InstallOptions::default()
};
let progress = {
@@ -1611,12 +1804,79 @@ impl Launcher {
/// run its installer, which restarts the launcher. Runs first at start; `updating_self`
/// keeps anything from launching or installing meanwhile (the window reads "checking").
/// Offline, with no page configured, or with anything wrong, this launcher carries on.
/// Update a launcher that runs from an ODIAN drive, by replacing its own exe.
///
/// The feed is `launcher-portable.json`: a small document signed with the release key, the
/// same key and the same `signing_payload` rule as a pack manifest, naming the raw exe and
/// its sha256. Both are checked before anything is swapped - the signature says the DHP
/// meant to publish this, the hash says the bytes arrived whole.
///
/// Windows will not let a running exe be overwritten, but it WILL let it be renamed, so
/// the old one is moved aside and the new one takes its name. The old copy is deleted at
/// the next start, which is to say once the new one has proved it runs.
async fn update_portable(&self, app: &AppHandle) {
let mcp_url = self.inner.lock().await.settings.mcp_url.clone();
if mcp_url.is_empty() {
return;
}
let url = format!("{}/download/{PORTABLE_FEED}", mcp_url.trim_end_matches('/'));
let Ok(response) = self.http.get(&url).timeout(Duration::from_secs(20)).send().await else {
return; // offline is the normal case away from the office
};
if !response.status().is_success() {
return; // a site that publishes no portable build simply has nothing to offer
}
let Ok(feed) = response.json::<serde_json::Value>().await else { return };
if let Err(e) = odian_packs::signing::verify(&feed, odian_packs::bundled_public_key()) {
self.log(app, "warn", format!("The portable launcher feed is not signed by the ODIAN release key: {e}"));
return;
}
let (Some(version), Some(file), Some(want_sha)) = (
feed.get("version").and_then(|v| v.as_str()),
feed.get("file").and_then(|v| v.as_str()),
feed.get("sha256").and_then(|v| v.as_str()),
) else {
return;
};
if !newer(version, env!("CARGO_PKG_VERSION")) {
return;
}
// A file name from the feed must never escape the download folder.
if file.contains(['/', '\\']) || file.contains("..") {
self.log(app, "warn", format!("The portable launcher feed names a suspect file: {file}"));
return;
}
self.log(app, "info", format!("Updating this drive's launcher to {version}…"));
let bytes = match self.http.get(format!("{}/download/{file}", mcp_url.trim_end_matches('/')))
.timeout(Duration::from_secs(300)).send().await
{
Ok(r) if r.status().is_success() => match r.bytes().await {
Ok(b) => b,
Err(e) => return self.log(app, "warn", format!("The new launcher did not download: {e}")),
},
_ => return self.log(app, "warn", "The new launcher did not download."),
};
let got = odian_packs::hashing::sha256_hex(&bytes);
if got != want_sha {
self.log(app, "warn", "The new launcher failed its checksum and was not installed.");
return;
}
if let Err(e) = swap_exe(&bytes) {
self.log(app, "warn", format!("Could not replace this drive's launcher: {e}"));
return;
}
self.log(app, "info", format!("This drive's launcher is now {version}. Restarting…"));
app.restart();
}
pub async fn update_self(&self, app: &AppHandle) {
use tauri_plugin_updater::UpdaterExt;
// A launcher that ships on an ODIAN drive is part of the drive: installing a newer one
// would put it on this PC instead, beside an install it does not carry.
// A launcher that ships on an ODIAN drive cannot take the ordinary update: that runs the
// NSIS setup, which installs onto whatever PC the drive is plugged into - leaving a
// launcher behind on a borrowed machine, pointed at an install that PC does not have.
// The drive's copy replaces its own exe instead, in place, from a signed raw build.
if self.inner.lock().await.portable {
self.log(app, "info", "This launcher runs from its ODIAN drive: it is updated with the drive.");
self.update_portable(app).await;
return;
}
let mcp_url = self.inner.lock().await.settings.mcp_url.clone();
@@ -1745,22 +2005,38 @@ impl Launcher {
/// Start the local stack. Returns once launching has begun; `stack` and `status` events follow.
pub async fn launch(&self, app: &AppHandle) -> Result<(), String> {
// The DHP is looked at on start, on focus and on request. A PC that was offline then and
// has connected since would otherwise launch on that stale answer - no live mode for the
// whole session, and no update check. Offline it costs one probe (2 s at most).
if !self.inner.lock().await.online {
self.refresh(app).await;
}
let config = {
let mut inner = self.inner.lock().await;
let status = inner.status();
if status.update_required {
// Not for a dev run: that starts the checkout, so what the DHP published is beside
// the point. An operator install still has to update first.
if status.update_required && !status.dev_run {
return Err("An update is available: update before launching while connected to the DHP.".to_string());
}
if !status.can_launch {
return Err("ODIAN isn't ready to launch.".to_string());
}
let operator = inner
.state
.operator
.clone()
.ok_or("Sign in once while connected, so the launcher knows which operator to start ODIAN as.")?;
let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?;
if inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none() {
let dhp = Self::dhp_direct(&inner);
// Nothing installed and no DHP-direct run available: say which of the two is missing,
// rather than "ODIAN is not installed" when the real problem is the target or the MCP.
if dhp.is_none() && inner.state.installed_version.is_none() {
return Err(Self::why_no_dhp_run(&inner));
}
// The client signs ITSELF in on a DHP-direct run, so there is no operator to carry.
let operator = match &dhp {
Some(_) => inner.state.operator.clone().unwrap_or_default(),
None => inner.state.operator.clone().ok_or(
"Sign in once while connected, so the launcher knows which operator to start ODIAN as.",
)?,
};
let version = inner.state.installed_version.clone().unwrap_or_default();
if dhp.is_none() && (inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none()) {
return Err("The local database was never set up. Reinstall from the DHP or a drive.".to_string());
}
let install_dir = inner.settings.install_path();
@@ -1771,9 +2047,13 @@ impl Launcher {
let layout = Layout::new(&install_dir, &version);
let dev = Self::dev_engine(&inner);
let dev_user = Self::dev_swap(&inner);
let tile_sets = stack::installed_tile_sets(&layout)?;
// What the DHP can draw for this PC, beyond what it downloaded.
let site_tile_sets = stack::version_tile_sets(&layout)?;
// A DHP-direct run has no installed version to read either list out of: this PC draws
// nothing of its own, and the site draws everything.
let (tile_sets, site_tile_sets) = match &dhp {
// No manifest to read either list from; the client uses its own default styles.
Some(_) => (Vec::new(), Vec::new()),
None => (stack::installed_tile_sets(&layout)?, stack::version_tile_sets(&layout)?),
};
inner.phase = Phase::Launching;
inner.error = None;
StackConfig {
@@ -1786,6 +2066,7 @@ impl Launcher {
dev_engine: dev,
dev_user: dev_user.clone(),
dev_target: inner.settings.dev_target,
dhp,
tile_sets,
site_tile_sets,
// A swapped run never reaches the DHP: no upstream token is written, so the local
@@ -1936,9 +2217,14 @@ impl Launcher {
return Ok(());
}
self.log(app, "info", format!("Installing the web client's dependencies in {}", web.display()));
let node_dir = crate::nodejs::find();
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);
// Found, not assumed on PATH: right after ensure_node installed it, it is not.
if let Some(dir) = &node_dir {
cmd.env("PATH", crate::nodejs::path_with(dir));
}
#[cfg(windows)]
stack::give_console(&mut cmd);
cmd.status()
@@ -1952,7 +2238,56 @@ impl Launcher {
Ok(())
}
/// Node.js for this run's web client, installed through winget when this PC has none.
///
/// A dev drive moves between PCs, and the web client needs Node.js on whichever one it
/// lands on - without it the launch failed with npm's bare "exit code: 1". winget runs in
/// a console of its own: the install shows its progress there, Windows asks for
/// permission, and a failure (no network, usually) leaves its reason on screen.
async fn ensure_node(&self, app: &AppHandle) -> Result<(), String> {
if crate::nodejs::find().is_some() {
return Ok(());
}
self.log(
app,
"warn",
format!(
"Node.js is not installed, and the web client runs on it: installing Node.js LTS with winget ({}). Windows may ask for permission.",
crate::nodejs::WINGET_ID
),
);
let status = tauri::async_runtime::spawn_blocking(|| {
let mut cmd = crate::nodejs::winget_command();
#[cfg(windows)]
stack::give_console(&mut cmd);
cmd.status()
})
.await
.map_err(|e| format!("could not run winget: {e}"))?;
// Judged by what is on disk afterwards, not by winget's exit code: "already installed"
// and "no newer version" are non-zero too, and both leave a working Node.js.
if let Some(dir) = crate::nodejs::find() {
self.log(app, "info", format!("Node.js is installed in {}.", dir.display()));
return Ok(());
}
let manual = "Install Node.js LTS from https://nodejs.org, then launch again.";
Err(match status {
Err(e) => format!("Node.js is not installed, and winget could not be started ({e}). {manual}"),
Ok(s) => format!(
"Node.js is not installed, and installing it with winget did not work ({s}); its window says why - no network is the usual reason. {manual}"
),
})
}
async fn run_stack(&self, app: &AppHandle, config: StackConfig) {
// Before the build too, which runs `npm install` for the web client.
if config.runs_web() {
if let Err(message) = self.ensure_node(app).await {
self.log(app, "error", message.clone());
self.finish(app, Some(message)).await;
return;
}
}
// 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 {
@@ -1971,7 +2306,23 @@ impl Launcher {
// comes up on the DHP's data instead of switching a moment later.
let live_mode = config.upstream_url.is_some();
if live_mode {
let token = self.site_access_token(app).await.ok();
let token = match self.site_access_token(app).await {
Ok(token) => Some(token),
Err(why) => {
// Said out loud: the operator named in launcher.json travels with a drive, the
// sign-in (Credential Manager) does not. A drive moved to another PC launches
// there as its operator and quietly shows that PC's data - the API's log was the
// only place that said "no DHP sign-in yet".
self.log(
app,
"warn",
format!(
"No DHP sign-in on this PC ({why}): ODIAN shows this PC's own data - no live aircraft, no tiles from the site. Stop ODIAN and sign in; a sign-in is kept per PC, so a drive moved to another one needs it there too."
),
);
None
}
};
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
@@ -2016,7 +2367,16 @@ impl Launcher {
if let Some(url) = config.web_url() {
self.log(app, "info", format!("The web client is at {url}"));
if let Err(e) = app.opener().open_url(url.as_str(), None::<&str>) {
// Signed in the way the .exe is, so live mode reaches the browser too. The token
// rides only in the address handed to the browser, never in the line logged above.
let opened = match stack::mint_web_token(&self.http, &config).await {
Ok(token) => config.web_url_signed(&token).unwrap_or(url),
Err(e) => {
self.log(app, "warn", format!("The web client opens without a session ({e}), so it shows this PC's data only."));
url
}
};
if let Err(e) = app.opener().open_url(opened.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."));
@@ -2047,6 +2407,174 @@ impl Launcher {
Ok(())
}
// ── releasing, in dev mode ──────────────────────────────────────────────
/// What a release could do right now: what is live, what this PC's repos hold, and what
/// stands in the way. Read fresh each time the panel opens - Docker and the DHP both
/// change under us.
pub async fn release_plan(&self) -> crate::release::ReleasePlan {
let (dev_root, online, pack_live, install_dir, installed) = {
let inner = self.inner.lock().await;
(
inner.settings.dev_root.clone(),
inner.online,
inner.available.clone(),
inner.settings.install_path(),
inner.state.installed_version.clone(),
)
};
let dev_root = PathBuf::from(dev_root.trim());
// The commit the LIVE pack was cut from. Read from this PC's own copy of that version,
// which costs nothing; when this PC is on an older version we simply do not know, and
// the panel says so rather than guessing.
// One read, two answers. The commit is only trusted when this PC is ON the live version
// (an older copy says where IT came from, not where the live pack did), but the data
// payload is worth showing either way - it is carried forward unchanged, so an older
// manifest describes the same seed and the same tiles.
let manifest = installed.as_ref().and_then(|have| {
std::fs::read(Layout::new(&install_dir, have).version_dir().join("manifest.json"))
.ok()
.and_then(|raw| serde_json::from_slice::<serde_json::Value>(&raw).ok())
});
let is_live = matches!((&installed, &pack_live), (Some(have), Some(live)) if have == live);
let pack_commit = match (is_live, &manifest) {
(true, Some(m)) => m.get("source_commit").and_then(|c| c.as_str().map(str::to_string)),
_ => None,
};
let data = match (&manifest, &installed) {
(Some(m), Some(have)) => {
Some(crate::release::data_from_manifest(m, have, is_live, now_secs() as i64))
}
_ => None,
};
// The launcher's own feeds are public, so this needs no token.
let mcp = self.inner.lock().await.settings.mcp_url.clone();
let base = mcp.trim_end_matches('/').to_string();
let get = |name: &str| {
let url = format!("{base}/download/{name}");
let http = self.http.clone();
let skip = mcp.is_empty();
async move {
if skip {
return None;
}
match http.get(url).timeout(Duration::from_secs(10)).send().await {
Ok(r) if r.status().is_success() => r.json::<serde_json::Value>().await.ok(),
_ => None,
}
}
};
let feed = get(LAUNCHER_FEED).await;
let launcher_live = feed.as_ref().and_then(|f| f.get("version").and_then(|v| v.as_str().map(str::to_string)));
// The commit is NOT in the updater feed and must not be put there: that document is
// Tauri's own format and goes out to every launcher in the field. `latest.json`, which
// this pipeline writes itself, carries it. Pair the two only when both name the same
// version - if they disagree, what is published is unknown and the row says so rather
// than claiming "up to date" and skipping a release that was needed.
let latest = get("latest.json").await;
let launcher_commit = match (&launcher_live, &latest) {
(Some(live), Some(l))
if l.get("kind").and_then(|k| k.as_str()) == Some("launcher")
&& l.get("version").and_then(|v| v.as_str()) == Some(live.as_str()) =>
{
l.get("source_commit").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string)
}
_ => None,
};
crate::release::plan(
&dev_root,
&dev_root.join("ODIAN_MCP_Server"),
online,
pack_live,
pack_commit,
launcher_live,
launcher_commit,
None,
data,
)
}
/// Start a release. Returns once it has started, not when it is done: its output arrives
/// in the console panel, which reads `release_console` as it fills.
pub async fn release_run(&self, password: String, with_api: bool, with_mcp: bool, with_web: bool) -> Result<(), String> {
// Two at once would interleave into one buffer and fight over the same build dirs.
if self.release_console.lock().is_ok_and(|c| c.is_running()) {
return Err("A release is already running: wait for it to finish.".to_string());
}
let plan = self.release_plan().await;
if let Some(why) = plan.blocked {
return Err(why);
}
if password.trim().is_empty() {
return Err("The node's sudo password is needed to cut a pack and to roll the API.".to_string());
}
let (dev_root, install_dir, pack_live) = {
let inner = self.inner.lock().await;
(inner.settings.dev_root.trim().to_string(), inner.settings.install_path(), inner.available.clone())
};
let dev_root = PathBuf::from(dev_root);
let pack_next = pack_live
.as_deref()
.and_then(crate::release::next_pack)
.ok_or("Cannot tell which pack version comes next: the DHP did not say which is live.")?;
// The launcher is only rebuilt when its repo has moved past what is published; without
// that check every press would bump its version for nothing.
let launcher_item = plan.items.iter().find(|i| i.key == "launcher");
let launcher_next = match launcher_item.map(|i| i.behind) {
Some(Some(false)) => None,
_ => launcher_item
.and_then(|i| i.live.as_deref())
.and_then(crate::release::next_launcher),
};
crate::release::run_release(
&dev_root.join("ODIAN_MCP_Server"),
&install_dir.join("state").join("release"),
&pack_next,
launcher_next.as_deref(),
with_api && plan.docker,
with_mcp && plan.docker,
with_web && plan.docker,
&password,
&self.release_console,
)
.map(|_| ())
}
/// Refresh the pack's data payload in its own run. Returns once it has started.
pub async fn refresh_run(&self, password: String, pg_password: String, tile_root: String) -> Result<(), String> {
if self.release_console.lock().is_ok_and(|c| c.is_running()) {
return Err("Something is already running: wait for it to finish.".to_string());
}
if password.trim().is_empty() {
return Err("The node's sudo password is needed: the dump runs there.".to_string());
}
let (dev_root, install_dir) = {
let inner = self.inner.lock().await;
(inner.settings.dev_root.trim().to_string(), inner.settings.install_path())
};
if dev_root.is_empty() {
return Err("Dev mode has no checkout folder, so the refresh scripts cannot be found.".to_string());
}
crate::release::run_refresh(
&PathBuf::from(dev_root).join("ODIAN_MCP_Server"),
&install_dir.join("state").join("release"),
pg_password.trim(),
Some(tile_root.as_str()),
&password,
&self.release_console,
)
.map(|_| ())
}
/// Everything the release has printed since line `since`.
pub fn release_console(&self, since: usize) -> crate::release::ConsoleTail {
match self.release_console.lock() {
Ok(c) => c.tail(since),
// A poisoned lock must not take the window down with it.
Err(e) => e.into_inner().tail(since),
}
}
// ── operator-data sync ──────────────────────────────────────────────────
/// Sync, unless this run is pretending to be another operator. Whatever it authored is
@@ -2251,12 +2779,13 @@ impl Launcher {
}
let probe = site::probe(&self.http, &site_url).await;
let engine = self.pick_engine(app, &probe).await;
{
let kept = {
let mut inner = self.inner.lock().await;
inner.online = probe.online;
if engine.is_some() {
inner.engine_url = engine;
}
engine.map(|url| inner.adopt_engine(url)).unwrap_or(Ok(()))
};
if let Err(e) = kept {
self.log(app, "warn", format!("Could not remember the DHP's Engine API address: {e}"));
}
if !probe.online {
if was_online {
@@ -2446,6 +2975,34 @@ fn swapped_user<'a>(asked: &'a str, operator: Option<&str>) -> Option<&'a str> {
mod tests {
use super::*;
#[test]
fn nothing_installed_still_launches_when_the_dhp_can_stand_in() {
// The whole point of the DHP-direct run: a checkout and a reachable DHP are enough.
assert!(launchable(true, false, true, false), "installed, dev: launches");
// `installed` is passed as "installed OR a DHP-direct run is available", so this is the
// no-install case arriving as launchable.
assert!(launchable(true, false, true, false));
// Nothing installed AND no DHP to stand in: still refused, and it should be.
assert!(!launchable(false, false, true, false), "no install, no DHP: nothing to run");
assert!(!launchable(false, false, false, false));
}
#[test]
fn a_dev_run_launches_even_when_the_dhp_has_a_newer_pack() {
// What used to happen: cutting a release made every dev PC refuse to launch until it
// downloaded a pack it was never going to run.
assert!(launchable(true, true, true, false), "behind, but the client comes from the checkout");
assert!(launchable(true, false, true, false));
// An operator PC still updates first - that one really does run the pack.
assert!(!launchable(true, true, false, false), "not a dev run: update first");
assert!(launchable(true, false, false, false));
// Nothing installed is never launchable: a dev run still needs the database, the tiles
// and the API, and those only come from an install.
assert!(!launchable(false, false, true, false), "a checkout alone is not enough to run");
// Mid-operation, nothing launches.
assert!(!launchable(true, false, true, true));
}
#[test]
fn a_dependency_added_since_the_last_install_is_noticed() {
use std::time::{Duration, SystemTime};
@@ -2479,6 +3036,25 @@ mod tests {
std::fs::remove_dir_all(&web).ok();
}
#[test]
fn a_drive_only_takes_a_genuinely_later_launcher() {
// Numeric, field by field. Text order would read 0.1.9 as later than 0.1.10 and pin
// every drive on the older build for the rest of the 0.1.x line.
assert!(newer("0.1.35", "0.1.34"));
assert!(newer("0.1.10", "0.1.9"), "ten is after nine");
assert!(!newer("0.1.9", "0.1.10"));
assert!(!newer("0.1.34", "0.1.34"), "the same version is not an update");
assert!(!newer("0.1.33", "0.1.34"), "never walk backwards");
assert!(newer("0.2.0", "0.1.99"));
assert!(newer("1.0.0", "0.9.9"));
// A suffixed tag compares on its numbers; nothing after them counts.
assert!(newer("0.1.35-dhp", "0.1.34"));
assert!(!newer("0.1.34-dhp", "0.1.34"));
// Nonsense from a feed must not read as an update.
assert!(!newer("", "0.1.34"));
assert!(!newer("latest", "0.1.34"));
}
#[test]
fn only_another_operators_name_is_a_user_swap() {
assert_eq!(swapped_user("m2op", Some("jimmy")), Some("m2op"));
+9
View File
@@ -8,7 +8,9 @@ mod install;
mod launcher;
mod localdb;
mod market;
mod nodejs;
mod prune;
mod release;
mod settings;
mod site;
mod stack;
@@ -35,6 +37,9 @@ pub fn run() {
}
app.manage(launcher::Launcher::load(app.handle())?);
// Reaching here is the proof a portable update worked, so the exe it moved aside can
// go. Before anything else: it costs one remove of a file that is usually absent.
launcher::prune_old_exe();
// First a newer launcher, if the download page has one (it installs and restarts
// this one), then a look at the site; the window shows "checking" meanwhile.
let handle = app.handle().clone();
@@ -52,6 +57,10 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::release_plan,
commands::release_run,
commands::release_console,
commands::refresh_run,
commands::get_market,
commands::apply_market,
commands::check_market,
+26
View File
@@ -337,6 +337,17 @@ pub fn unfinished(
.collect()
}
/// The tile sets a count found short, as the installer names them (`style/dataset`): what
/// Finish hands it to re-count block by block, because their markers say they are whole.
pub fn short_tile_sets(checked: &[Check]) -> BTreeSet<String> {
checked
.iter()
.filter(|check| !check.whole)
.filter_map(|check| check.key.strip_prefix("tiles:"))
.map(str::to_string)
.collect()
}
/// Whether the chosen optional items still need downloading.
pub fn to_add(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
items(manifest, data_dir, extras)
@@ -389,6 +400,21 @@ pub fn remove_counting(
mod tests {
use super::*;
#[test]
fn finish_repairs_the_sets_a_count_found_short() {
// What the log said on a real PC: two markers claiming "installed" over missing files.
let check = |key: &str, found, expected| Check { key: key.to_string(), found, expected, whole: found >= expected };
let checked = [
check("tiles:popdark/protomaps_20260423", 293_056, 400_268),
check("tiles:yandex/protomaps_20260423", 2_371, 28_213),
check("tiles:dark_v2/protomaps_20260423", 95_000, 90_501),
];
let short: Vec<String> = short_tile_sets(&checked).into_iter().collect();
// In the installer's own spelling, and a set holding MORE than it names is not short.
assert_eq!(short, ["popdark/protomaps_20260423", "yandex/protomaps_20260423"]);
assert!(short_tile_sets(&[]).is_empty());
}
fn manifest() -> Manifest {
serde_json::from_value(serde_json::json!({
"schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z",
+133
View File
@@ -0,0 +1,133 @@
//! Node.js for dev mode's web client: where it is, and installing it when it is not.
//!
//! The web client runs under `npm run dev` in the checkout's `_WEB`, so a dev PC without
//! Node.js failed its launch with nothing but "stopped while starting (exit code: 1)": npm
//! was not there to say anything. Seen on a fresh PC, and again when the dev drive moved to
//! another one.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::Command;
/// The winget package: Node.js LTS, which the web client's Vite accepts (18, or 20 and up).
pub const WINGET_ID: &str = "OpenJS.NodeJS.LTS";
/// Folders that may hold npm, in the order they are tried: this process's PATH, then where
/// the Node.js installer puts it. The second is what makes an install usable at once: the
/// installer adds its folder to the SYSTEM PATH, which a process already running - this
/// launcher - never sees until it is restarted.
fn candidates(path_var: Option<&OsStr>, program_files: Option<&OsStr>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = path_var.map(|p| std::env::split_paths(p).collect()).unwrap_or_default();
if let Some(program_files) = program_files {
dirs.push(Path::new(program_files).join("nodejs"));
}
dirs
}
/// The folder holding both `node.exe` and `npm.cmd`, from the given PATH and Program Files.
/// Both, because npm is a script node runs: an `npm.cmd` left beside no `node.exe` (a
/// half-removed install) fails exactly like no npm at all.
pub fn find_in(path_var: Option<&OsStr>, program_files: Option<&OsStr>) -> Option<PathBuf> {
candidates(path_var, program_files)
.into_iter()
.find(|dir| dir.join("node.exe").is_file() && dir.join("npm.cmd").is_file())
}
/// Node.js on this PC, if any.
pub fn find() -> Option<PathBuf> {
find_in(std::env::var_os("PATH").as_deref(), std::env::var_os("ProgramFiles").as_deref())
}
/// A PATH for a child that runs npm: `node_dir` first, then this process's own.
pub fn path_with(node_dir: &Path) -> OsString {
let rest = std::env::var_os("PATH").unwrap_or_default();
let dirs = std::iter::once(node_dir.to_path_buf()).chain(std::env::split_paths(&rest));
// Only fails on a folder name holding the separator itself, which no real PATH entry has.
std::env::join_paths(dirs).unwrap_or(rest)
}
/// `winget install` for Node.js LTS. Through `cmd` because winget is an app-execution alias
/// in WindowsApps, which cmd resolves the way a developer's own prompt would. No `--silent`
/// on purpose: the console it runs in is the only place its progress and its reason for
/// failing (no network, no winget source) are ever shown.
pub fn winget_command() -> Command {
let mut cmd = Command::new("cmd");
cmd.args([
"/c",
"winget",
"install",
"--id",
WINGET_ID,
"--exact",
"--source",
"winget",
"--accept-package-agreements",
"--accept-source-agreements",
]);
cmd
}
#[cfg(test)]
mod tests {
use super::*;
/// A throwaway folder, optionally holding the two files a Node.js install has.
fn folder(with_node: bool, with_npm: bool) -> PathBuf {
let dir = std::env::temp_dir().join(format!("odian-nodejs-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
if with_node {
std::fs::write(dir.join("node.exe"), b"").unwrap();
}
if with_npm {
std::fs::write(dir.join("npm.cmd"), b"").unwrap();
}
dir
}
#[test]
fn node_is_found_on_path_first() {
let (on_path, program_files) = (folder(true, true), folder(false, false));
std::fs::create_dir_all(program_files.join("nodejs")).unwrap();
let path = std::env::join_paths([folder(false, false), on_path.clone()]).unwrap();
assert_eq!(find_in(Some(&path), Some(program_files.as_os_str())), Some(on_path));
}
#[test]
fn a_fresh_install_is_found_where_the_installer_puts_it() {
// What winget has just done: Program Files\nodejs exists, and this process's PATH -
// read when the launcher started - does not name it.
let program_files = folder(false, false);
let nodejs = program_files.join("nodejs");
std::fs::create_dir_all(&nodejs).unwrap();
std::fs::write(nodejs.join("node.exe"), b"").unwrap();
std::fs::write(nodejs.join("npm.cmd"), b"").unwrap();
let path = std::env::join_paths([folder(false, false)]).unwrap();
assert_eq!(find_in(Some(&path), Some(program_files.as_os_str())), Some(nodejs));
}
#[test]
fn npm_without_node_is_no_node() {
let half = folder(false, true);
let path = std::env::join_paths([half]).unwrap();
assert_eq!(find_in(Some(&path), None), None);
assert_eq!(find_in(None, None), None);
}
#[test]
fn the_child_path_puts_node_first() {
let dir = PathBuf::from(r"C:\Program Files\nodejs");
let path = path_with(&dir);
assert_eq!(std::env::split_paths(&path).next(), Some(dir));
}
#[test]
fn winget_installs_node_lts_without_asking_on_the_console() {
let cmd = winget_command();
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(&args[..3], ["/c", "winget", "install"]);
assert!(args.windows(2).any(|w| w == ["--id", WINGET_ID]), "{args:?}");
// Unanswered, these two prompts wait on the console forever on a first run.
assert!(args.iter().any(|a| a == "--accept-package-agreements"));
assert!(args.iter().any(|a| a == "--accept-source-agreements"));
}
}
+817
View File
@@ -0,0 +1,817 @@
//! Cutting a release from the hub, in dev mode.
//!
//! Three artifacts ship from these repos and each has its own pipeline: the ODIAN pack
//! (client + API + tile server), this launcher, and the Engine API container on the
//! cluster. The scripts that do the work already exist and are the ones a person runs by
//! hand; nothing here reimplements them. This decides WHAT is behind and runs them.
//!
//! "Behind" is answered by the commit each published artifact records it was built from.
//! Comparing versions would not do: a version says what was cut, not from what.
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// What a release could do right now, and what stops it.
#[derive(Clone, Debug, Serialize)]
pub struct ReleasePlan {
/// Docker Desktop answers. Only the Engine API container needs it.
pub docker: bool,
/// The DHP answered its health check: nothing can be published without it.
pub online: bool,
/// The scripts that do the work are where they should be.
pub scripts: bool,
pub items: Vec<ReleaseItem>,
/// Why the whole thing cannot run, in one line; None when it can.
pub blocked: Option<String>,
/// What the pack carries but a release does not rebuild. None when nothing is installed
/// here, so there is no manifest to read it from.
pub data: Option<ReleaseData>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ReleaseItem {
/// `pack`, `launcher` or `api`.
pub key: &'static str,
pub title: &'static str,
/// The version that is live, when it could be read.
pub live: Option<String>,
/// The repo's HEAD, short.
pub commit: Option<String>,
/// True when the repo has moved past what is published. None when nothing published
/// says which commit it came from - the honest answer until a release records one.
pub behind: Option<bool>,
/// Uncommitted changes in its repo: a release from those could not be rebuilt.
pub dirty: bool,
/// What the row says out loud.
pub note: String,
}
/// Is a Docker daemon answering? The Engine API image is built in one.
pub fn docker_running() -> bool {
run_ok(Command::new("docker").args(["info", "--format", "{{.ServerVersion}}"]))
}
fn run_ok(cmd: &mut Command) -> bool {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
cmd.output().map(|o| o.status.success()).unwrap_or(false)
}
fn git(repo: &Path, args: &[&str]) -> Option<String> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(repo).args(args);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
let out = cmd.output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn head(repo: &Path) -> Option<String> {
git(repo, &["rev-parse", "HEAD"])
}
/// `shipped` limits the question to the paths an artifact is actually built from. It matters
/// for the MCP: that repo also holds these release scripts, so an unscoped check let editing
/// a script lock out every other step - the pack and the launcher included.
fn dirty(repo: &Path, shipped: Option<&[&str]>) -> bool {
let mut args = vec!["status", "--porcelain"];
if let Some(paths) = shipped {
args.push("--");
args.extend_from_slice(paths);
}
git(repo, &args).is_some_and(|s| !s.trim().is_empty())
}
/// What lands in the MCP image: `Dockerfile` copies only these.
const MCP_SHIPPED: &[&str] = &["Dockerfile", "requirements.txt", "src"];
/// One artifact's row: is this repo past what is published?
fn item(
key: &'static str,
title: &'static str,
repo: PathBuf,
live: Option<String>,
published_commit: Option<String>,
shipped: Option<&[&str]>,
) -> ReleaseItem {
let exists = repo.is_dir();
let commit = exists.then(|| head(&repo)).flatten();
let dirty = exists && dirty(&repo, shipped);
let behind = match (&commit, &published_commit) {
(Some(now), Some(was)) => Some(now != was),
_ => None,
};
let note = match (exists, &commit, behind, dirty) {
(false, ..) => format!("{} is not in the checkout folder", repo.display()),
(_, None, ..) => "not a git checkout".to_string(),
(_, _, _, true) => "uncommitted changes: commit them before releasing".to_string(),
(_, _, Some(true), _) => "behind: the repo has moved past what is published".to_string(),
(_, _, Some(false), _) => "up to date".to_string(),
(_, _, None, _) => "what is published does not say which commit it came from".to_string(),
};
ReleaseItem { key, title, live, commit: commit.map(|c| c[..c.len().min(7)].to_string()), behind, dirty, note }
}
/// What a pack carries that a code release does NOT rebuild.
///
/// `cut_pack.py` copies the database seed, the source chunks and the tile sets forward from
/// the previous version every time, and asserts they did not change - which is what keeps a
/// code release from quietly altering data. The cost is that they age invisibly: the seed and
/// the tiles are whatever they were the day someone last built them. This puts a date on it.
#[derive(Clone, Debug, Serialize)]
pub struct ReleaseData {
/// The version whose manifest these numbers were read from.
pub version: String,
/// Whether that is also what the DHP serves, so the numbers describe the live pack.
pub is_live: bool,
/// The seed's snapshot stamp, e.g. `20260921T125618Z`.
pub db_snapshot: Option<String>,
pub db_age_days: Option<i64>,
pub db_bytes: u64,
pub tile_sets: usize,
pub tile_bytes: u64,
/// Every distinct dataset the tile sets name.
pub tile_datasets: Vec<String>,
/// The basemap's SOURCE vintage, as `YYYYMMDD`, taken from the dataset name
/// (`protomaps_20260423`). This is the date of the upstream extract the maps were drawn
/// from - NOT when anyone baked or froze them. Nothing in a tile index or a manifest
/// records a bake time, so that age simply does not exist to be shown, and calling this
/// one "old" read as if the pack were stale when it only means the world data is from
/// April. An undated name (`planet_heightmap`) is skipped: that set does not move.
pub basemap_vintage: Option<String>,
pub basemap_vintage_days: Option<i64>,
}
/// Days from 1970-01-01 to y-m-d, proleptic Gregorian. Hinnant's algorithm, which is exact
/// and needs no date crate - the alternative was a dependency to subtract two dates.
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// How many days ago `YYYYMMDD` was, or None when those are not eight sane digits.
fn age_days(stamp: &str, now_secs: i64) -> Option<i64> {
let digits: String = stamp.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.len() < 8 {
return None;
}
let y: i64 = digits[0..4].parse().ok()?;
let m: i64 = digits[4..6].parse().ok()?;
let d: i64 = digits[6..8].parse().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || !(2000..=2999).contains(&y) {
return None;
}
Some(now_secs / 86_400 - days_from_civil(y, m, d))
}
/// The date a dataset name carries, if any: `protomaps_20260423` -> `20260423`.
fn dataset_stamp(dataset: &str) -> Option<&str> {
let tail = dataset.rsplit('_').next()?;
(tail.len() == 8 && tail.chars().all(|c| c.is_ascii_digit())).then_some(tail)
}
/// Read the data payload out of an installed version's manifest.
pub fn data_from_manifest(m: &serde_json::Value, version: &str, is_live: bool, now_secs: i64) -> ReleaseData {
let db = m.get("db");
let db_snapshot = db.and_then(|d| d.get("snapshot")).and_then(|s| s.as_str()).map(str::to_string);
let db_bytes = db
.and_then(|d| d.get("files"))
.and_then(|f| f.as_array())
.map(|files| files.iter().filter_map(|f| f.get("size").and_then(serde_json::Value::as_u64)).sum())
.unwrap_or(0);
let sets = m.get("tiles").and_then(|t| t.get("sets")).and_then(|s| s.as_array());
let tile_bytes = sets
.map(|s| {
s.iter()
.filter_map(|set| set.get("bytes").or_else(|| set.get("size")).and_then(serde_json::Value::as_u64))
.sum()
})
.unwrap_or(0);
let mut tile_datasets: Vec<String> = Vec::new();
for set in sets.map(Vec::as_slice).unwrap_or(&[]) {
if let Some(d) = set.get("dataset").and_then(|d| d.as_str()) {
if !tile_datasets.iter().any(|have| have == d) {
tile_datasets.push(d.to_string());
}
}
}
// The oldest dated extract, since that is the one that limits what the maps show.
let basemap_vintage = tile_datasets
.iter()
.filter_map(|d| dataset_stamp(d))
.filter_map(|s| age_days(s, now_secs).map(|age| (age, s.to_string())))
.max_by_key(|(age, _)| *age)
.map(|(_, s)| s);
ReleaseData {
version: version.to_string(),
is_live,
db_age_days: db_snapshot.as_deref().and_then(|s| age_days(s, now_secs)),
db_snapshot,
db_bytes,
tile_sets: sets.map(|s| s.len()).unwrap_or(0),
tile_bytes,
tile_datasets,
basemap_vintage_days: basemap_vintage.as_deref().and_then(|s| age_days(s, now_secs)),
basemap_vintage,
}
}
/// Everything the Release panel shows.
pub fn plan(
dev_root: &Path,
mcp_repo: &Path,
online: bool,
pack_live: Option<String>,
pack_commit: Option<String>,
launcher_live: Option<String>,
launcher_commit: Option<String>,
api_live: Option<String>,
data: Option<ReleaseData>,
) -> ReleasePlan {
let docker = docker_running();
let scripts = mcp_repo.join("scripts").join("release_pack.ps1").is_file()
&& mcp_repo.join("scripts").join("package_launcher.ps1").is_file();
let items = vec![
item("pack", "ODIAN", dev_root.join("ODIAN_Engine"), pack_live, pack_commit, None),
item("launcher", "Launcher", dev_root.join("ODIAN_Launcher"), launcher_live, launcher_commit, None),
// The container is built from the same Engine checkout as the pack; only its pipeline
// differs, and only it needs Docker.
item("api", "Engine API", dev_root.join("ODIAN_Engine"), api_live, None, None),
item("mcp", "MCP server", dev_root.join("ODIAN_MCP_Server"), None, None, Some(MCP_SHIPPED)),
];
let blocked = if !online {
Some("The DHP is not answering: nothing can be published.".to_string())
} else if !scripts {
Some(format!("The release scripts are not in {}.", mcp_repo.display()))
} else if items.iter().any(|i| i.dirty) {
// Naming them matters: "pack" and "api" are the same checkout, so a bare message sent
// you hunting through three repos for one open file.
let mut who: Vec<&str> = Vec::new();
for i in items.iter().filter(|i| i.dirty) {
if !who.contains(&i.title) {
who.push(i.title);
}
}
Some(format!(
"Commit what is open first ({}): a release built from uncommitted work cannot be rebuilt.",
who.join(", ")
))
} else {
None
};
ReleasePlan { docker, online, scripts, items, blocked, data }
}
/// The Release console's buffer: filled by the running release, read by the window.
///
/// It sits behind a plain (not async) lock because the threads draining the child's pipes
/// have no async context to lock from.
#[derive(Default)]
pub struct Console {
lines: Vec<String>,
/// How many lines fell off the front, so indices stay stable while it trims.
dropped: usize,
running: bool,
exit: Option<i32>,
}
pub type Shared = std::sync::Arc<std::sync::Mutex<Console>>;
/// A release prints a great deal (cargo, npm, docker); keep the tail, not all of it.
const MAX_LINES: usize = 4000;
/// What the window asks for: everything it has not seen yet.
#[derive(Serialize, Clone)]
pub struct ConsoleTail {
/// Index of the first line returned.
pub from: usize,
pub lines: Vec<String>,
pub total: usize,
pub running: bool,
pub exit: Option<i32>,
}
impl Console {
fn start(&mut self) {
self.lines.clear();
self.dropped = 0;
self.running = true;
self.exit = None;
}
fn push(&mut self, line: String) {
self.lines.push(line);
if self.lines.len() > MAX_LINES {
let cut = self.lines.len() - MAX_LINES;
self.lines.drain(..cut);
self.dropped += cut;
}
}
fn finish(&mut self, exit: Option<i32>) {
self.running = false;
self.exit = exit;
}
pub fn is_running(&self) -> bool {
self.running
}
/// Everything from `since` on. A `since` already trimmed away starts at the oldest line
/// still held, rather than pretending the dropped ones are still coming.
pub fn tail(&self, since: usize) -> ConsoleTail {
let total = self.dropped + self.lines.len();
let from = since.clamp(self.dropped, total);
ConsoleTail {
from,
lines: self.lines[from - self.dropped..].to_vec(),
total,
running: self.running,
exit: self.exit,
}
}
}
/// Drain one of the child's streams into the console, a line at a time.
fn pump(stream: impl std::io::Read, console: Shared) {
use std::io::BufRead;
let mut reader = std::io::BufReader::new(stream);
let mut raw = Vec::new();
loop {
raw.clear();
match reader.read_until(b'\n', &mut raw) {
Ok(0) | Err(_) => return,
Ok(_) => {
let text = String::from_utf8_lossy(&raw);
let text = text.trim_end_matches('\n').trim_end_matches('\r');
// A terminal shows only what follows the last carriage return, so a progress line
// that kept rewriting itself does not become a thousand lines in the panel.
let shown = text.rsplit('\r').next().unwrap_or(text);
match console.lock() {
Ok(mut c) => c.push(shown.to_string()),
Err(_) => return,
}
}
}
}
}
/// Cut and publish, into the launcher's own console panel.
///
/// The three scripts are run as a person runs them, in order, and each decides for itself
/// whether it has anything to do: `release_pack.ps1` stops with "nothing changed" when the
/// repo matches the live manifest, and `deploy-api.ps1` skips the roll when the compiled
/// binary is identical. So "release whatever is behind" needs no prediction here - only
/// that they are run in the right order and that the first real failure stops the rest.
///
/// The node's sudo password goes in the ENVIRONMENT, not the command line: arguments are
/// readable by anyone who can list processes.
pub fn run_release(
mcp_repo: &Path,
work_dir: &Path,
pack_version: &str,
launcher_version: Option<&str>,
with_api: bool,
with_mcp: bool,
with_web: bool,
sudo: &str,
console: &Shared,
) -> Result<PathBuf, String> {
let scripts = mcp_repo.join("scripts");
let log = work_dir.join("release.log");
let mut body = String::new();
body.push_str(&format!(
"Write-Host '=== ODIAN pack {pack_version} ===' -ForegroundColor Cyan
& '{}' -Version '{pack_version}' -NodeSudoPw $pw
if ($LASTEXITCODE -eq 2) {{ Write-Host 'pack: nothing to release' -ForegroundColor Yellow }}
elseif ($LASTEXITCODE -ne 0) {{ throw 'the pack step failed' }}
",
scripts.join("release_pack.ps1").display()
));
if let Some(v) = launcher_version {
body.push_str(&format!(
"Write-Host '=== Launcher {v} ===' -ForegroundColor Cyan
& '{}' -Version '{v}' -SiteUrl 'https://engine.odian.fr/dhp' -McpUrl 'https://engine.odian.fr' -AiUrl 'https://engine.odian.fr' -SiteName 'ODIAN test DHP' -Publish
if ($LASTEXITCODE -ne 0) {{ throw 'the launcher step failed' }}
",
scripts.join("package_launcher.ps1").display()
));
}
if with_api {
// It ships with the release skill rather than the repo, so look there when the repo
// has no copy. A missing script used to fail the same way a broken one does.
let api = api_script(&scripts).ok_or(
format!(
"deploy-api.ps1 is not in {}. Pull ODIAN_MCP_Server on this PC, or untick the Engine API.",
scripts.display()
),
)?;
body.push_str(&format!(
"Write-Host '=== Engine API ===' -ForegroundColor Cyan
& '{}' -NodeSudoPw $pw -EngineRoot '{}'
if ($LASTEXITCODE -notin 0,3) {{ throw 'the API step failed' }}
",
api.display(),
// The checkout is the sibling of ODIAN_MCP_Server, which is what `mcp_repo` names.
// Passed explicitly so the script does not have to guess from where it was loaded -
// it can come from the repo or from the release skill, and only one of those is a
// sibling of the Engine.
mcp_repo.parent().unwrap_or(mcp_repo).join("ODIAN_Engine").display()
));
}
if with_web {
// From _WEB in the Engine checkout. Its Dockerfile runs npm inside the image, so this
// needs Docker and nothing else - no Node, no node_modules on this PC.
body.push_str(&format!(
"Write-Host '=== ODIAN WEB ===' -ForegroundColor Cyan
& '{}'
if ($LASTEXITCODE -eq 2) {{ Write-Host 'web: already serving this commit' -ForegroundColor Yellow }}
elseif ($LASTEXITCODE -ne 0) {{ throw 'the web step failed' }}
",
scripts.join("deploy_web.ps1").display()
));
}
if with_mcp {
// LAST, always. The MCP serves /download/* and proxies /dhp, so rolling it interrupts
// the very road the steps above publish over; anything still in flight would fail.
body.push_str(&format!(
"Write-Host '=== MCP server ===' -ForegroundColor Cyan
& '{}'
if ($LASTEXITCODE -eq 2) {{ Write-Host 'MCP: nothing to deploy' -ForegroundColor Yellow }}
elseif ($LASTEXITCODE -ne 0) {{ throw 'the MCP step failed' }}
",
scripts.join("deploy_mcp.ps1").display()
));
}
// Everything inside try/finally, and everything recorded. A step that throws used to
// take the window with it - the release simply vanished, with nothing to read.
//
// No `pause` here any more: this runs into the launcher's own panel, where there is no
// console to press Enter in. The exit code carries the verdict instead - swallowing a
// failure and exiting 0 would make a failed release look like a finished one.
let ps = console_script(&body, &log, "Release finished.");
spawn_console(work_dir, "release.ps1", &ps, &[("ODIAN_NODE_SUDO", sudo)], console)
}
/// Refresh the data a pack carries: the database seed, and optionally the tile indexes.
///
/// Deliberately NOT part of a release, and deliberately its own press. The seed is a
/// per-table dump of a database operators are using, and the tiles are indexed where the
/// tiles are. Neither should ride along with shipping a code change - but both should be one
/// click when refreshing the data IS the intent, which is what this is.
///
/// It only prepares the inputs. Cutting a version from them stays a separate step, so a
/// refresh can be looked at before anything is published.
pub fn run_refresh(
mcp_repo: &Path,
work_dir: &Path,
pg_password: &str,
tile_root: Option<&str>,
sudo: &str,
console: &Shared,
) -> Result<PathBuf, String> {
let script = mcp_repo.join("scripts").join("refresh_data.ps1");
if !script.is_file() {
return Err(format!("refresh_data.ps1 is not in {}. Pull ODIAN_MCP_Server on this PC.", mcp_repo.join("scripts").display()));
}
let log = work_dir.join("refresh.log");
let mut body = String::new();
if !pg_password.is_empty() {
body.push_str(&format!(
"Write-Host '=== Database seed ===' -ForegroundColor Cyan
& '{}' -Seed -PgPassword $pgpw -NodeSudoPw $pw
if ($LASTEXITCODE -ne 0) {{ throw 'the seed refresh failed' }}
",
script.display()
));
}
if let Some(root) = tile_root.filter(|r| !r.trim().is_empty()) {
body.push_str(&format!(
"Write-Host '=== Tile indexes ===' -ForegroundColor Cyan
& '{}' -Tiles -TileRoot '{}' -NodeSudoPw $pw
if ($LASTEXITCODE -ne 0) {{ throw 'the tile refresh failed' }}
",
script.display(),
root.trim()
));
}
if body.is_empty() {
return Err("Nothing to refresh: give the database password, a tile folder, or both.".to_string());
}
let ps = console_script(&body, &log, "Refresh finished. Nothing is published yet.");
spawn_console(work_dir, "refresh.ps1", &ps, &[("ODIAN_NODE_SUDO", sudo), ("ODIAN_PG_PW", pg_password)], console)
}
/// Wrap a body so a step that throws cannot take the window with it, and everything said is
/// also on disk. No `pause`: this runs into the launcher's own panel, where there is nothing
/// to press Enter with, and the exit code carries the verdict instead.
fn console_script(body: &str, log: &Path, done: &str) -> String {
format!(
"$ErrorActionPreference = 'Stop'
# The pipe gets UTF-8 or Windows' own French messages arrive as mojibake.
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
$pw = $env:ODIAN_NODE_SUDO
$env:ODIAN_NODE_SUDO = $null
$pgpw = $env:ODIAN_PG_PW
$env:ODIAN_PG_PW = $null
Start-Transcript -Path '{}' -Force | Out-Null
$ok = $true
try {{
{}
Write-Host ''
Write-Host '{}'
}} catch {{
Write-Host ''
Write-Host \"FAILED: $_\"
$ok = $false
}} finally {{
Stop-Transcript | Out-Null
Write-Host ''
Write-Host 'Log: {}'
}}
if (-not $ok) {{ exit 1 }}
",
log.display(),
body,
done,
log.display()
)
}
/// Write `ps` and run it with its output going to the console panel.
///
/// Secrets go in the ENVIRONMENT, never the command line: arguments are readable by anyone
/// who can list processes.
fn spawn_console(
work_dir: &Path,
name: &str,
ps: &str,
env: &[(&str, &str)],
console: &Shared,
) -> Result<PathBuf, String> {
std::fs::create_dir_all(work_dir).map_err(|e| format!("cannot use {}: {e}", work_dir.display()))?;
let file = work_dir.join(name);
std::fs::write(&file, ps).map_err(|e| format!("cannot write {}: {e}", file.display()))?;
let mut cmd = Command::new("powershell");
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]).arg(&file);
for (k, v) in env {
cmd.env(k, v);
}
// Nothing can type into this, so stdin is closed: a script that ever asks a question
// then fails at once instead of hanging forever with no window to answer in.
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW: the output belongs in the panel
}
let mut child = cmd.spawn().map_err(|e| format!("cannot start the release: {e}"))?;
console.lock().map_err(|_| "the release console is unusable")?.start();
// Both streams, kept separate: merging them with `2>&1` inside PowerShell wraps every
// stderr line from a native exe in a NativeCommandError and makes a clean run look failed.
let mut pumps = Vec::new();
for stream in [child.stdout.take().map(Either::Out), child.stderr.take().map(Either::Err)] {
if let Some(s) = stream {
let c = console.clone();
pumps.push(std::thread::spawn(move || match s {
Either::Out(r) => pump(r, c),
Either::Err(r) => pump(r, c),
}));
}
}
let c = console.clone();
std::thread::spawn(move || {
let code = child.wait().ok().and_then(|s| s.code());
// Drain what is still sitting in the pipes BEFORE saying it finished: the window stops
// asking once running goes false, and the last lines are the ones that say why.
for p in pumps {
let _ = p.join();
}
if let Ok(mut c) = c.lock() {
c.finish(code);
}
});
Ok(file)
}
/// The two pipes have different types; this lets one thread body handle either.
enum Either {
Out(std::process::ChildStdout),
Err(std::process::ChildStderr),
}
/// Where the Engine API deploy script is: beside the other release scripts, or in the
/// release skill that carries it.
fn api_script(scripts: &Path) -> Option<PathBuf> {
let here = scripts.join("deploy-api.ps1");
if here.is_file() {
return Some(here);
}
let skill = dirs_home()?.join(".claude/skills/odian-api-release/scripts/deploy-api.ps1");
skill.is_file().then_some(skill)
}
fn dirs_home() -> Option<PathBuf> {
std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)
}
/// `0.71.0` -> `0.72.0`. A pack release is a minor; the patch place is not used.
pub fn next_pack(live: &str) -> Option<String> {
let p: Vec<u64> = live.split('.').map_while(|x| x.parse().ok()).collect();
(p.len() >= 2).then(|| format!("{}.{}.0", p[0], p[1] + 1))
}
/// `0.1.38` -> `0.1.39`.
pub fn next_launcher(live: &str) -> Option<String> {
let p: Vec<u64> = live.split('.').map_while(|x| x.parse().ok()).collect();
(p.len() >= 3).then(|| format!("{}.{}.{}", p[0], p[1], p[2] + 1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_pack_says_how_old_the_data_it_carries_is() {
// 2026-10-01, the day this was written, as unix seconds.
let now = days_from_civil(2026, 10, 1) * 86_400;
assert_eq!(age_days("20261001T000000Z", now), Some(0), "today");
assert_eq!(age_days("20260921T125618Z", now), Some(10), "the real 0.72.0 seed");
assert_eq!(age_days("20260423", now), Some(161), "the real basemap extract");
// Leap day, and a year boundary: the arithmetic is the whole point of this test.
assert_eq!(age_days("20260228", now), Some(215));
assert_eq!(age_days("20251231", now), Some(274));
// Nonsense must not become a date.
assert_eq!(age_days("", now), None);
assert_eq!(age_days("2026", now), None);
assert_eq!(age_days("20261301", now), None, "month 13");
assert_eq!(age_days("latest", now), None);
assert_eq!(dataset_stamp("protomaps_20260423"), Some("20260423"));
assert_eq!(dataset_stamp("planet_heightmap"), None, "undated: it does not move");
// A manifest shaped like the real one.
let m: serde_json::Value = serde_json::json!({
"db": { "snapshot": "20260921T125618Z",
"files": [ {"size": 1000}, {"size": 2000} ] },
"tiles": { "sets": [
{"style": "dark", "dataset": "protomaps_20260423", "bytes": 10},
{"style": "dark_v2", "dataset": "protomaps_20260423", "bytes": 20},
{"style": "heightmap", "dataset": "planet_heightmap", "bytes": 70}
]}
});
let d = data_from_manifest(&m, "0.72.0", true, now);
assert_eq!(d.db_bytes, 3000);
assert_eq!(d.db_age_days, Some(10));
assert_eq!(d.tile_sets, 3);
assert_eq!(d.tile_bytes, 100);
// Deduplicated, and the undated set does not drag the age to None.
assert_eq!(d.tile_datasets, vec!["protomaps_20260423".to_string(), "planet_heightmap".to_string()]);
// The dataset name carries the upstream extract's date, not a bake time - there is no
// bake time anywhere in a tile index or a manifest, which is why none is shown.
assert_eq!(d.basemap_vintage.as_deref(), Some("20260423"));
assert_eq!(d.basemap_vintage_days, Some(161));
// A manifest with no tiles at all must not panic or invent numbers.
let empty = data_from_manifest(&serde_json::json!({}), "0.1.0", false, now);
assert_eq!(empty.tile_sets, 0);
assert_eq!(empty.db_bytes, 0);
assert_eq!(empty.db_age_days, None);
}
#[test]
fn the_next_version_moves_the_right_place() {
// A pack is a minor (0.71.0 -> 0.72.0); the launcher is a patch (0.1.38 -> 0.1.39).
assert_eq!(next_pack("0.71.0").as_deref(), Some("0.72.0"));
assert_eq!(next_pack("0.9.0").as_deref(), Some("0.10.0"), "ten follows nine");
assert_eq!(next_launcher("0.1.38").as_deref(), Some("0.1.39"));
assert_eq!(next_launcher("0.1.9").as_deref(), Some("0.1.10"));
// Nonsense from a feed must not become a version number.
assert_eq!(next_pack("").as_deref(), None);
assert_eq!(next_launcher("latest").as_deref(), None);
assert_eq!(next_launcher("0.1").as_deref(), None);
}
#[test]
fn a_repo_that_matches_what_is_published_is_not_behind() {
// The comparison is COMMIT against COMMIT. Versions cannot answer it: a version says
// what was cut, not from what - which is how a release gets cut twice from one tree.
let same = item("pack", "ODIAN", PathBuf::from("."), Some("0.71.0".into()), head(Path::new(".")), None);
if same.commit.is_some() {
assert_eq!(same.behind, Some(false), "same commit: nothing to release");
}
// Nothing published says where it came from: say so rather than guess.
let unknown = item("pack", "ODIAN", PathBuf::from("."), Some("0.71.0".into()), None, None);
assert_eq!(unknown.behind, None);
assert!(unknown.note.contains("does not say") || unknown.dirty || unknown.commit.is_none());
// A folder that is not there is named, not silently "up to date".
let missing = item("pack", "ODIAN", PathBuf::from(r"Z:\nothing\here"), None, None, None);
assert_eq!(missing.behind, None);
assert!(missing.note.contains("not in the checkout folder"));
}
/// The regression that adding the MCP step introduced: that repo also holds the release
/// scripts, so an unscoped `git status` made editing a script look like uncommitted
/// release content and locked out the pack and the launcher too.
#[test]
fn a_dirty_file_outside_the_shipped_paths_does_not_block() {
let dir = std::env::temp_dir().join(format!("odian-dirty-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::create_dir_all(dir.join("scripts")).unwrap();
let ok = |args: &[&str]| git(&dir, args).is_some();
if !ok(&["init"]) {
return; // no git here; nothing to prove
}
let _ = git(&dir, &["config", "user.email", "t@t"]);
let _ = git(&dir, &["config", "user.name", "t"]);
std::fs::write(dir.join("src").join("app.py"), "x").unwrap();
std::fs::write(dir.join("requirements.txt"), "y").unwrap();
std::fs::write(dir.join("Dockerfile"), "FROM x").unwrap();
assert!(ok(&["add", "-A"]) && ok(&["commit", "-m", "base"]));
assert!(!dirty(&dir, Some(MCP_SHIPPED)), "clean tree");
// An untracked release script: tooling, never in the image.
std::fs::write(dir.join("scripts").join("deploy_mcp.ps1"), "# tool").unwrap();
assert!(dirty(&dir, None), "the repo really is dirty");
assert!(!dirty(&dir, Some(MCP_SHIPPED)), "but nothing that ships changed");
// Something that does ship: this must still block.
std::fs::write(dir.join("src").join("app.py"), "changed").unwrap();
assert!(dirty(&dir, Some(MCP_SHIPPED)), "shipped source changed: block");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_console_hands_over_only_what_the_window_has_not_seen() {
let mut c = Console::default();
c.start();
for i in 0..3 {
c.push(format!("line {i}"));
}
let first = c.tail(0);
assert_eq!(first.from, 0);
assert_eq!(first.total, 3);
assert_eq!(first.lines.len(), 3);
assert!(first.running, "a started console is running");
// Asking again from where it got to yields nothing, not the whole buffer again.
let again = c.tail(first.total);
assert!(again.lines.is_empty());
assert_eq!(again.total, 3);
c.push("line 3".into());
let more = c.tail(3);
assert_eq!(more.lines, vec!["line 3".to_string()]);
c.finish(Some(1));
let done = c.tail(4);
assert!(!done.running);
assert_eq!(done.exit, Some(1), "a failed release must not look finished");
}
#[test]
fn trimming_a_long_release_keeps_indices_honest() {
let mut c = Console::default();
c.start();
for i in 0..(MAX_LINES + 500) {
c.push(format!("line {i}"));
}
let t = c.tail(0);
assert_eq!(t.total, MAX_LINES + 500, "total counts every line ever printed");
assert_eq!(t.lines.len(), MAX_LINES, "only the tail is kept");
assert_eq!(t.from, 500, "it says where it really starts, not 0");
assert_eq!(t.lines[0], "line 500");
// A window that asked from beyond the end gets nothing rather than a panic.
assert!(c.tail(t.total + 99).lines.is_empty());
}
#[test]
fn a_progress_line_that_rewrites_itself_stays_one_line() {
let stream: &[u8] = b"first\r\nbuilding 10%\rbuilding 90%\rbuilt\nplain\n";
let c: Shared = Default::default();
c.lock().unwrap().start();
pump(stream, c.clone());
let got = c.lock().unwrap().tail(0);
// CRLF is an ending, not content; a lone CR overwrites, exactly as a terminal shows it.
assert_eq!(got.lines, vec!["first".to_string(), "built".to_string(), "plain".to_string()]);
}
}
+343 -26
View File
@@ -192,9 +192,13 @@ pub struct StackConfig {
/// the site draws the ones this PC never downloaded. Without one it sees only
/// `tile_sets`, because nothing else could be drawn.
pub site_tile_sets: Vec<TileSet>,
/// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT from, instead of the
/// installed version. Everything else is the install's - the database, the tiles, the
/// API, the tile server - so a dev run needs no download and shows real data.
/// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT, its API and its tile
/// server from, instead of the installed version - see `odian_exe`, `api_exe` and
/// `tile_server_dir`, which all branch on this.
///
/// What still comes from the INSTALL is the data layer and the runtimes it needs: the
/// database, the tiles, the Python the tile server runs on, and Postgres itself. So a dev
/// run shows real data and needs no download - but it does need an install to sit on.
///
/// Only ever Some on a PC with a valid dev licence: the launcher will not fill it in
/// otherwise, whatever the settings file says.
@@ -210,6 +214,37 @@ pub struct StackConfig {
/// Dev mode: which client(s) this run starts. Ignored without `dev_engine` - an operator
/// install has no web client to run, and only ever starts the one.
pub dev_target: DevTarget,
/// Dev mode, no local data layer: Some means nothing local starts and the client talks
/// straight to the DHP. See `DhpDirect`.
pub dhp: Option<DhpDirect>,
}
/// Where a web dev run sends its traffic. `api`/`mcp` are empty for an ordinary local run,
/// which takes those from the URL the launcher opens instead.
#[derive(Clone, Debug)]
pub struct WebPoints {
pub tiles: String,
pub api: String,
pub mcp: String,
}
/// A dev run with no install behind it: the client talks straight to the DHP.
///
/// Nothing local starts - no Postgres, no Engine API, no tile server - so this needs no
/// installed version at all, which is the whole point: a checkout and a DHP are enough.
///
/// The client signs itself in through the MCP broker and carries the CLUSTER's token, which
/// is exactly what the shipped .exe does. The launcher's silent mint is NOT used here: it
/// signs with a secret only a local API holds, and the cluster does not share it. So this
/// run shows the client's own sign-in once, instead of arriving already signed in.
#[derive(Clone, Debug)]
pub struct DhpDirect {
/// The DHP's Engine API - what the local one would have forwarded to.
pub engine_url: String,
/// The MCP: the broker the client signs in through, and the AI features.
pub mcp_url: String,
/// Where the client keeps its caches and logs, since there is no install to keep them in.
pub cache_dir: PathBuf,
}
impl StackConfig {
@@ -223,6 +258,15 @@ impl StackConfig {
}
}
/// The client's own folder of writable state. A DHP-direct run has no install to put it
/// in, so it gets one of its own.
fn client_dir(&self) -> PathBuf {
match &self.dhp {
Some(dhp) => dhp.cache_dir.clone(),
None => self.layout.state_dir().join("client"),
}
}
fn odian_exe(&self) -> PathBuf {
match &self.dev_engine {
Some(_) => self.odian_dir().join("x64").join("MinGW-Release").join("Odian.exe"),
@@ -265,23 +309,61 @@ impl StackConfig {
self.dev_engine.is_some() && self.dev_target.has_web()
}
/// What the web client's dev server should point at.
pub fn web_points(&self) -> WebPoints {
match &self.dhp {
Some(dhp) => WebPoints {
tiles: site_tiles_url(&self.site_url),
api: dhp.engine_url.clone(),
mcp: dhp.mcp_url.clone(),
},
None => WebPoints {
tiles: format!("http://127.0.0.1:{TILES_PORT}"),
api: String::new(),
mcp: String::new(),
},
}
}
/// Where to open the browser, when this run has a web client.
///
/// `?api=` points it at THIS stack's API rather than its own `http://localhost:8080`
/// default, and `?user=` is the operator - the same name the .exe runs as, swapped one
/// included, so both clients author as the same person. The web's endpoint guard trusts
/// any loopback port, so no change is needed on that side.
///
/// A DHP-direct run carries NEITHER. The DHP is not a loopback host, so `?api=` naming it
/// would be refused by that same guard - quite rightly. Its address arrives as deployment
/// config instead (`/odian-config.js`, which the dev server serves from the environment),
/// and the operator is whoever signs in through the MCP.
pub fn web_url(&self) -> Option<String> {
self.runs_web().then(|| {
format!(
self.runs_web().then(|| match &self.dhp {
Some(_) => format!("http://localhost:{WEB_PORT}/"),
None => format!(
"http://localhost:{}/?api={}&user={}",
WEB_PORT,
url_value(&format!("http://127.0.0.1:{API_PORT}")),
url_value(&self.operator)
)
),
})
}
/// `web_url` with a session token for the web client, in the FRAGMENT.
///
/// The local API relays to the DHP only a request carrying a token it minted itself -
/// the forwarder's guard against being an open proxy - so a tokenless web client stays
/// on this PC's database while the .exe beside it is live. A fragment never leaves the
/// browser: not in the request line, not in Vite's log. Never log this address;
/// `web_url` is the one to print.
pub fn web_url_signed(&self, token: &str) -> Option<String> {
// Nothing to sign for on a DHP-direct run: there is no local API to relay, and the web
// client gets its own token from the MCP when it signs in.
if self.dhp.is_some() {
return self.web_url();
}
self.web_url().map(|url| format!("{url}#token={}", url_value(token)))
}
/// 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 {
@@ -389,7 +471,8 @@ type Shared = Arc<Mutex<Child>>;
pub struct RunningStack {
job: job::Job,
pg: Pg,
/// None on a DHP-direct run: there is no local database to stop.
pg: Option<Pg>,
children: Vec<(Component, Shared)>,
}
@@ -429,7 +512,9 @@ impl RunningStack {
}
emit(StackEvent::new(component, ComponentState::Stopped));
}
let pg = self.pg.clone();
let Some(pg) = self.pg.clone() else {
return; // DHP-direct: nothing local was started, so there is nothing to stop.
};
match run_blocking(move || pg.stop()).await {
Ok(()) => emit(StackEvent::new(Component::Postgres, ComponentState::Stopped)),
Err(message) => emit(StackEvent { component: Component::Postgres, state: ComponentState::Failed, message: Some(message) }),
@@ -481,10 +566,10 @@ fn check_ports_free(own_db_running: bool, web: bool) -> Result<(), String> {
}
pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) -> Result<RunningStack, String> {
let pg = Pg::new(&config.layout, &config.pg_password);
let pg = config.dhp.is_none().then(|| Pg::new(&config.layout, &config.pg_password));
// Asks pg_ctl about this install's own data directory, so a database on the port that
// belongs to another install still reads as someone else's.
check_ports_free(pg.running(), config.runs_web())?;
check_ports_free(pg.as_ref().is_some_and(Pg::running), config.runs_web())?;
prepare(config)?;
let mut running = RunningStack { job: job::Job::new()?, pg, children: Vec::new() };
match start_all(http, config, emit, &mut running).await {
@@ -497,11 +582,31 @@ pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>)
}
async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> {
if config.dhp.is_none() {
start_local_data_layer(http, config, emit, running).await?;
}
if config.runs_client() {
start_client(config, emit, running).await?;
}
if config.runs_web() {
start_web(http, config, emit, running).await?;
}
Ok(())
}
/// Postgres, the Engine API and the tile server: this PC's own data, and what a DHP-direct
/// run does without.
async fn start_local_data_layer(
http: &reqwest::Client,
config: &StackConfig,
emit: Emit<'_>,
running: &mut RunningStack,
) -> Result<(), String> {
let layout = &config.layout;
let state = layout.state_dir();
emit(StackEvent::new(Component::Postgres, ComponentState::Starting));
let pg = running.pg.clone();
let pg = running.pg.clone().ok_or("no database to start")?;
run_blocking(move || pg.start()).await.map_err(|e| failed(emit, Component::Postgres, e))?;
emit(StackEvent::new(Component::Postgres, ComponentState::Ready));
@@ -548,21 +653,13 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>,
.await
.map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?;
emit(StackEvent::new(Component::Tiles, ComponentState::Ready));
if config.runs_client() {
start_client(config, emit, running).await?;
}
if config.runs_web() {
start_web(http, config, emit, running).await?;
}
Ok(())
}
/// Odian.exe, in its own folder of writable state, from the install or the checkout.
async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> {
let state = config.layout.state_dir();
emit(StackEvent::new(Component::Odian, ComponentState::Starting));
let client_dir = state.join("client");
let client_dir = config.client_dir();
let odian_exe = config.odian_exe();
if config.dev_engine.is_some() && !odian_exe.is_file() {
return Err(failed(
@@ -602,9 +699,20 @@ async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut Runnin
///
/// 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 {
/// instead of passing it to Vite. `node_dir` goes first on its PATH: right after the
/// launcher installed Node.js, this process's own PATH does not name it yet.
fn web_command(dir: &Path, node_dir: &Path, points: &WebPoints) -> Command {
let mut web = Command::new("cmd");
web.env("PATH", crate::nodejs::path_with(node_dir));
// Only on a DHP-direct run. `_WEB`'s dev server turns these into the deployment config it
// already understands (`/odian-config.js`); unset, it serves nothing and the ordinary
// local run is untouched.
if !points.api.is_empty() {
web.env("ODIAN_ENGINE_API_URL", &points.api);
}
if !points.mcp.is_empty() {
web.env("ODIAN_MCP_API_URL", &points.mcp);
}
// --strictPort because without it Vite quietly takes the next free port, and the address
// the launcher opens - and prints - would then be somebody else's server.
web
@@ -616,6 +724,11 @@ fn web_command(dir: &Path) -> Command {
.arg("--port")
.arg(WEB_PORT.to_string())
.arg("--strictPort")
// The map through THIS stack's tile server, as the .exe draws it: the site while it
// answers, this PC's installed tiles when it does not. Without it _WEB's proxy goes
// to the public tile host, and an offline run has no map at all. A DHP-direct run has
// no tile server of its own, so it names the site's.
.env("ODIAN_TILE_SERVER_URL", points.tiles.clone())
.current_dir(dir);
web
}
@@ -644,7 +757,16 @@ async fn start_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);
// The launcher installs Node.js before a web run starts (Launcher::ensure_node), so this
// only guards a PC where that was refused - and says so, instead of npm's bare exit 1.
let Some(node_dir) = crate::nodejs::find() else {
return Err(failed(
emit,
Component::Web,
format!("Node.js is not installed, and the web client runs on it. Install Node.js LTS (winget install {}), then launch again.", crate::nodejs::WINGET_ID),
));
};
let mut web = web_command(&dir, &node_dir, &config.web_points());
give_console(&mut web);
let child = running.spawn(Component::Web, &mut web).map_err(|e| failed(emit, Component::Web, e))?;
// A cold start compiles Cesium, so this is patient where the others are not.
@@ -655,6 +777,31 @@ async fn start_web(
Ok(())
}
/// A session token from THIS stack's API for the web client, minted with the secret the
/// .exe signs in with and for the same operator - so both clients reach the DHP alike.
pub async fn mint_web_token(http: &reqwest::Client, config: &StackConfig) -> Result<String, String> {
#[derive(Deserialize)]
struct Minted {
access_token: String,
}
let response = http
.post(format!("http://127.0.0.1:{API_PORT}/auth/mint"))
.header("x-mint-secret", &config.mint_secret)
.json(&serde_json::json!({ "username": config.operator }))
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("the local API did not answer: {e}"))?;
if !response.status().is_success() {
return Err(format!("the local API refused it (HTTP {})", response.status().as_u16()));
}
response
.json::<Minted>()
.await
.map(|minted| minted.access_token)
.map_err(|e| format!("the local API's answer made no sense: {e}"))
}
fn failed(emit: Emit<'_>, component: Component, message: String) -> String {
emit(StackEvent { component, state: ComponentState::Failed, message: Some(message.clone()) });
message
@@ -705,6 +852,12 @@ async fn run_blocking<T: Send + 'static>(work: impl FnOnce() -> Result<T, String
}
fn prepare(config: &StackConfig) -> Result<(), String> {
if let Some(dhp) = &config.dhp {
for dir in [dhp.cache_dir.join("logs"), dhp.cache_dir.join("tile_cache"), dhp.cache_dir.join("heightmap")] {
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
}
return Ok(());
}
let layout = &config.layout;
let state = layout.state_dir();
let data = layout.data_dir();
@@ -769,7 +922,9 @@ const TILES_POSTGIS_DSN_ENV: &str = "ODIAN_TILE_POSTGIS_DSN";
/// launcher sets) leaves the layer with nowhere to read, and it draws nothing at all. That is
/// what kept oil, gas, aeroways and military bases off an offline map while their tables sat
/// in the database unused - only the four Theatre layers below were ever named here.
const POSTGIS_SOURCES: [&str; 8] = [
/// `powerv2_postgis` (the power grid netlayer) was the last one missing: its tables
/// (`postgis_power_entities` / `_links` / `_topology`) are in the snapshot too.
const POSTGIS_SOURCES: [&str; 9] = [
"aeroway_postgis",
"datacenter_postgis",
"gas_postgis",
@@ -777,6 +932,7 @@ const POSTGIS_SOURCES: [&str; 8] = [
"military_base_postgis",
"oil_postgis",
"place_of_worship_postgis",
"powerv2_postgis",
"school_postgis",
];
@@ -965,7 +1121,58 @@ fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
env
}
/// A run with no local stack at all: everything the client talks to is the DHP's.
///
/// Deliberately WITHOUT `ODIAN_LAUNCH_USER` and `ODIAN_MINT_SECRET`. Those two are what make
/// the client mint its own session, and only a local API will honour that - the cluster does
/// not share the secret. Left out, the client does what the shipped .exe does: signs in
/// through the MCP broker and carries the cluster's own token.
fn dhp_client_env(config: &StackConfig, dhp: &DhpDirect) -> Vec<(&'static str, String)> {
let client = config.client_dir();
let tiles = site_tiles_url(&config.site_url);
let mut env = vec![
("ODIAN_ENGINE_API_URL", dhp.engine_url.clone()),
// The broker the client signs in through. Without it its login screen has nowhere to go
// and the run is a window that cannot be used.
("ODIAN_MCP_API_URL", dhp.mcp_url.clone()),
("ODIAN_TILE_SERVER_URL", tiles.clone()),
("ODIAN_POP_SERVICE_URL", tiles.clone()),
("ODIAN_TILE_PROVIDER", "odian".to_string()),
("ODIAN_TILE_REQUEST_MODE", "xyz".to_string()),
("ODIAN_TILE_Y_SCHEME", "xyz".to_string()),
("ODIAN_TILE_AUTH_MODE", "none".to_string()),
("ODIAN_TILE_CACHE_DIR", text(client.join("tile_cache"))),
("ODIAN_HEIGHT_CACHE_DIR", text(client.join("heightmap"))),
("ODIAN_LIDAR_ENABLED", "0".to_string()),
];
// Every set the DHP can draw, not this PC's - it has none. Same order of preference as a
// local run, so the map opens on the same basemap a developer is used to.
let sets = &config.site_tile_sets;
let basemap = ["dark_v2", "dark"]
.iter()
.find_map(|want| sets.iter().find(|set| set.style == *want))
.or_else(|| sets.iter().find(|set| set.style != "heightmap"));
if let Some(set) = basemap {
env.push(("ODIAN_TILE_STYLE", set.style.clone()));
env.push(("ODIAN_TILE_DATASET", set.dataset.clone()));
}
let styles: Vec<&str> = sets.iter().map(|set| set.style.as_str()).collect();
if !styles.is_empty() {
env.push(("ODIAN_TILE_STYLES", styles.join(",")));
}
if let Some(set) = sets.iter().find(|set| set.style == "heightmap") {
env.push(("ODIAN_HEIGHT_SERVER_URL", tiles));
env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string()));
env.push(("ODIAN_HEIGHT_STYLE", set.style.clone()));
env.push(("ODIAN_HEIGHT_DATASET", set.dataset.clone()));
}
env
}
fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> {
if let Some(dhp) = &config.dhp {
return dhp_client_env(config, dhp);
}
let client = config.layout.state_dir().join("client");
let tiles_url = format!("http://127.0.0.1:{TILES_PORT}");
let mut env = vec![
@@ -1130,6 +1337,7 @@ mod tests {
dev_engine: None,
dev_user: None,
dev_target: DevTarget::default(),
dhp: None,
site_url: "https://dhp.example/dhp".to_string(),
site_tile_sets: tile_sets
.iter()
@@ -1214,6 +1422,71 @@ mod tests {
assert!(value(&env, "ODIAN_HEIGHT_SERVER_URL").is_none());
}
/// What an ordinary local web run points at, for the tests that do not care about the DHP.
fn local_points() -> WebPoints {
WebPoints { tiles: format!("http://127.0.0.1:{TILES_PORT}"), api: String::new(), mcp: String::new() }
}
#[test]
fn a_dhp_direct_web_run_is_told_where_to_look_and_asks_for_nothing_local() {
let mut config = config(&[]);
config.dev_engine = Some(PathBuf::from(r"D:\dev\ODIAN_Engine"));
config.dev_target = DevTarget::Web;
config.dhp = Some(DhpDirect {
engine_url: "https://dhp.example/engine".to_string(),
mcp_url: "https://dhp.example".to_string(),
cache_dir: PathBuf::from(r"D:\ODIAN\state\devclient"),
});
let points = config.web_points();
assert_eq!(points.api, "https://dhp.example/engine");
assert_eq!(points.mcp, "https://dhp.example");
assert_eq!(points.tiles, "https://dhp.example/dhp/tileserver");
// The URL must NOT carry ?api=: the DHP is not a loopback host, so _WEB's endpoint
// guard would refuse it and quietly fall back to its own localhost default.
let url = config.web_url().expect("a web run has a URL");
assert!(!url.contains("api="), "the address must not try to override the endpoint: {url}");
assert!(!url.contains("user="), "nobody is signed in yet: {url}");
// And no token to staple on, since there is no local API relaying anything.
assert_eq!(config.web_url_signed("tok"), Some(url));
}
#[test]
fn an_ordinary_web_run_still_points_at_this_pc() {
let mut config = config(&[]);
config.dev_engine = Some(PathBuf::from(r"D:\dev\ODIAN_Engine"));
config.dev_target = DevTarget::Web;
let points = config.web_points();
assert!(points.api.is_empty() && points.mcp.is_empty(), "nothing injected for a local run");
assert!(points.tiles.contains("127.0.0.1"));
let url = config.web_url().expect("a web run has a URL");
assert!(url.contains(&format!("api=http%3A%2F%2F127.0.0.1%3A{API_PORT}")) || url.contains("127.0.0.1"), "{url}");
assert!(config.web_url_signed("tok").is_some_and(|u| u.contains("#token=tok")));
}
#[test]
fn a_dhp_direct_client_is_pointed_away_from_this_pc() {
let mut config = config(&[]);
config.dev_engine = Some(PathBuf::from(r"D:\dev\ODIAN_Engine"));
config.dhp = Some(DhpDirect {
engine_url: "https://dhp.example/engine".to_string(),
mcp_url: "https://dhp.example".to_string(),
cache_dir: PathBuf::from(r"D:\ODIAN\state\devclient"),
});
let env = client_env(&config);
assert_eq!(value(&env, "ODIAN_ENGINE_API_URL"), Some("https://dhp.example/engine"));
// The broker it signs in through: without this its login screen has nowhere to go.
assert_eq!(value(&env, "ODIAN_MCP_API_URL"), Some("https://dhp.example"));
assert_eq!(value(&env, "ODIAN_TILE_SERVER_URL"), Some("https://dhp.example/dhp/tileserver"));
// These two are what make the client mint its own session, which only a LOCAL API would
// honour. Present here, the run would fail to sign in against the cluster.
assert_eq!(value(&env, "ODIAN_LAUNCH_USER"), None);
assert_eq!(value(&env, "ODIAN_MINT_SECRET"), None);
// And nothing may point back at this PC: there is no local stack listening.
for (key, val) in &env {
assert!(!val.contains("127.0.0.1"), "{key} still points at this PC: {val}");
}
}
#[test]
fn api_and_tile_server_are_loopback_and_cache_only() {
let config = config(&[("dark", "protomaps_20260423")]);
@@ -1337,12 +1610,28 @@ mod tests {
assert!(cfg.web_url().unwrap().ends_with("&user=m2op"));
}
#[test]
fn the_web_token_rides_in_the_fragment_and_never_in_the_printed_address() {
let mut cfg = config(&[("dark", "protomaps_20260423")]);
cfg.dev_engine = Some(PathBuf::from(r"E:\dev\ODIAN_Engine"));
cfg.dev_target = DevTarget::Web;
let plain = cfg.web_url().unwrap();
let signed = cfg.web_url_signed("a+b/c=").unwrap();
// The query is untouched - the token is after `#`, so the browser never sends it.
assert_eq!(signed, format!("{plain}#token=a%2Bb%2Fc%3D"));
assert!(!plain.contains("token"), "the address the launcher logs carries no token");
// No web client this run, no address at all.
cfg.dev_target = DevTarget::Client;
assert_eq!(cfg.web_url_signed("t"), None);
}
#[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 cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs"), &local_points());
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
assert_eq!(args, ["/c", "npm", "run", "dev", "--", "--port", "5173", "--strictPort"]);
// The bare `--` is load-bearing: without it npm eats the flags instead of passing them on.
@@ -1355,6 +1644,33 @@ mod tests {
assert_eq!(cmd.get_current_dir(), Some(Path::new(r"E:\dev\ODIAN_Engine\_WEB")));
}
#[test]
fn the_web_draws_its_map_from_this_stacks_tile_server() {
// Offline, the public tile host is unreachable; this stack's tile server still has
// the PC's installed tiles. _WEB's vite.config.ts reads this variable.
let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs"), &local_points());
let tiles = cmd
.get_envs()
.find(|(key, _)| *key == "ODIAN_TILE_SERVER_URL")
.and_then(|(_, value)| value)
.map(|value| value.to_string_lossy().into_owned());
assert_eq!(tiles.as_deref(), Some("http://127.0.0.1:18081"));
}
#[test]
fn npm_finds_the_node_the_launcher_found() {
// Right after the launcher installs Node.js, its own PATH does not name it yet; npm
// runs with the found folder first, so the launch goes on without a restart.
let node = Path::new(r"C:\Program Files\nodejs");
let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), node, &local_points());
let path = cmd
.get_envs()
.find(|(key, _)| *key == "PATH")
.and_then(|(_, value)| value)
.expect("web_command sets PATH");
assert_eq!(std::env::split_paths(path).next().as_deref(), Some(node));
}
#[test]
fn a_query_value_cannot_end_its_own_parameter() {
// The address is built here, but the operator name comes from a text field.
@@ -1495,7 +1811,8 @@ mod tests {
}
// The ones that were missing, named outright: they are why an offline map had
// hospitals and schools but no oil, gas, aeroways or military bases.
for key in ["oil_postgis", "gas_postgis", "aeroway_postgis", "military_base_postgis"] {
// And the power grid, missing after that: offline, the web's power layer 404'd.
for key in ["oil_postgis", "gas_postgis", "aeroway_postgis", "military_base_postgis", "powerv2_postgis"] {
assert_eq!(doc[dataset][key]["dsn_env"], TILES_POSTGIS_DSN_ENV, "{dataset} {key}");
}
}
+68
View File
@@ -42,6 +42,20 @@ pub struct LauncherState {
/// while it was away. None until the first sync.
#[serde(default)]
pub tile_sync: Option<TileSync>,
/// The DHP's Engine API this PC last reached, so a launcher started OFFLINE still arms
/// live mode: the local API then switches to the DHP by itself once the network comes.
/// Held only in memory before, so a PC that started the launcher offline ran the whole
/// session on its own data even after it connected.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engine: Option<KnownEngine>,
}
/// Whose Engine API, and where. The site is named for the same reason as `TileSync`: an
/// address learned from one DHP means nothing to a PC pointed at another.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct KnownEngine {
pub site: String,
pub url: String,
}
/// Whose journal, and how far through it. The site is named because a PC moved to another
@@ -60,6 +74,21 @@ impl LauncherState {
_ => 0,
}
}
/// The Engine API this PC last reached on `site`, None for a site it never reached.
pub fn engine_url(&self, site: &str) -> Option<String> {
self.engine.as_ref().filter(|known| known.site == site && !site.is_empty()).map(|known| known.url.clone())
}
/// Remember `url` as `site`'s Engine API. True when that changed what is on disk.
pub fn remember_engine(&mut self, site: &str, url: &str) -> bool {
let known = KnownEngine { site: site.to_string(), url: url.to_string() };
if site.is_empty() || self.engine.as_ref() == Some(&known) {
return false;
}
self.engine = Some(known);
true
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -105,3 +134,42 @@ pub fn cached_notes(install_dir: &Path, version: &str) -> Option<String> {
pub fn cache_notes(install_dir: &Path, version: &str, markdown: &str) -> Result<(), String> {
write_atomic(&state_dir(install_dir).join("notes").join(format!("{version}.md")), markdown.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
const SITE: &str = "https://engine.odian.fr/dhp";
const ENGINE: &str = "https://engine-api.odian.fr";
#[test]
fn the_engine_api_is_remembered_per_site() {
let mut state = LauncherState::default();
assert_eq!(state.engine_url(SITE), None, "a PC that never reached the DHP knows no address");
assert!(state.remember_engine(SITE, ENGINE));
assert_eq!(state.engine_url(SITE).as_deref(), Some(ENGINE));
// Another DHP's address is not this one's.
assert_eq!(state.engine_url("http://192.168.7.2:30097"), None);
// Unchanged is not a change: no write for every probe that finds the same address.
assert!(!state.remember_engine(SITE, ENGINE));
// And a PC set offline on purpose (no DHP address) remembers nothing.
assert!(!state.remember_engine("", ENGINE));
assert_eq!(state.engine_url(""), None);
}
#[test]
fn the_engine_api_survives_a_restart_and_old_files_still_load() {
let dir = std::env::temp_dir().join(format!("odian-state-{}", uuid::Uuid::new_v4()));
let mut state = LauncherState::default();
state.remember_engine(SITE, ENGINE);
state.save(&dir).unwrap();
// What a launcher started offline reads: the address it had before it was closed.
assert_eq!(LauncherState::load(&dir).unwrap().engine_url(SITE).as_deref(), Some(ENGINE));
// A launcher.json from before this field loads, with no address.
std::fs::write(dir.join("state").join("launcher.json"), br#"{"install_id":"abc","operator":"jimmy"}"#).unwrap();
let old = LauncherState::load(&dir).unwrap();
assert_eq!((old.install_id.as_str(), old.engine_url(SITE)), ("abc", None));
let _ = std::fs::remove_dir_all(&dir);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher",
"version": "0.1.34",
"version": "0.1.51",
"identifier": "fr.odian.launcher",
"build": {
"frontendDist": "../dist",
+25 -2
View File
@@ -6,6 +6,8 @@
import KeyArt from "./components/KeyArt.svelte";
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
import NewsPanel from "./components/NewsPanel.svelte";
import ReleaseConsole from "./components/ReleaseConsole.svelte";
import ReleasePanel from "./components/ReleasePanel.svelte";
import SettingsDrawer from "./components/SettingsDrawer.svelte";
import SyncPanel from "./components/SyncPanel.svelte";
import StatusBar from "./components/StatusBar.svelte";
@@ -14,6 +16,13 @@
let settingsOpen = $state(false);
// Dev mode drops the patch notes: they describe the INSTALLED version, which is not what
// a dev run starts. With that column gone the art has the whole window to sit in.
const dev = $derived(launcher.devOn);
// A release takes that column back while it runs: its output is the one thing worth
// reading there, and it is where a developer is already looking.
const showConsole = $derived(dev && launcher.consoleOpen);
onMount(() => launcher.start());
</script>
@@ -21,8 +30,12 @@
<KeyArt />
<TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
<main class="hub">
<div class="news-slot"><NewsPanel /></div>
<main class="hub" class:dev={dev && !showConsole}>
{#if !dev}
<div class="news-slot"><NewsPanel /></div>
{:else if showConsole}
<div class="news-slot"><ReleaseConsole /></div>
{/if}
<div class="hero-slot"><Hero /></div>
<div class="action-slot"><ActionPanel /></div>
</main>
@@ -37,6 +50,10 @@
<div class="sync-slot"><SyncPanel /></div>
{/if}
{#if launcher.releaseOpen}
<div class="sync-slot"><ReleasePanel /></div>
{/if}
<SettingsDrawer bind:open={settingsOpen} />
<MarketplaceDrawer />
</div>
@@ -69,6 +86,12 @@
min-height: 0;
}
/* No notes column in dev mode: it collapses rather than being hidden, so the hero and the
action panel keep the places they have everywhere else. */
.hub.dev {
grid-template-columns: 0 minmax(0, 1fr) minmax(360px, 404px);
}
.hero-slot {
grid-row: 1;
grid-column: 2 / span 2;
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

+274 -11
View File
@@ -97,7 +97,42 @@
// 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));
const devOn = $derived(launcher.devOn);
// Dev mode's controls follow DEV MODE, not whatever the hub is currently offering. They
// used to live inside the "launch" branch, so a PC with nothing installed was told to name
// its checkout folder and given nowhere on screen to name it - the dead end that made a
// fresh dev PC look like it could only install.
const TRANSIENT = ["loading", "signing_in", "installing", "restoring", "syncing", "building", "removing", "pulling_tiles", "stack"];
const showDev = $derived(devOn && !TRANSIENT.includes(action));
const devRun = $derived(launcher.devReady);
const devRoot = $derived(launcher.settings?.dev_root ?? "");
const devTarget = $derived(launcher.settings?.dev_target ?? "client");
const devBuild = $derived(launcher.settings?.dev_build ?? false);
const devUserField = $derived(launcher.settings?.dev_user ?? "");
const savingDev = $derived(busy === "settings");
// One stack either way: both clients talk to the same API and the same database, which is
// the only way a .exe/web divergence is visible at all.
const TARGETS = [
{ key: "client", name: "ODIAN", note: "Odian.exe" },
{ key: "web", name: "Web", note: ":5173" },
{ key: "both", name: "Both", note: "one stack" },
] as const;
// ---- releasing ------------------------------------------------------------
async function openRelease() {
launcher.releaseOpen = true;
await launcher.loadRelease();
}
// While one runs, this button is the way BACK to its output rather than a way to start a
// second: closing the console otherwise left a running release with nowhere to be seen.
const releasing = $derived(launcher.consoleRunning);
const hasOutput = $derived(launcher.consoleLines.length > 0 && !launcher.consoleOpen);
async function browseCheckout() {
const path = await launcher.pickFolder();
if (path) await launcher.setDev({ dev_root: path });
}
// 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() ?? "";
@@ -180,6 +215,14 @@
<p class="secondary">
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
</p>
<!-- A licensed PC with nothing installed should be running from its checkout against the
DHP. When it cannot, say which single thing is in the way - this screen used to give
no clue at all, which sent people looking at the install folder every time. -->
{#if s?.dhp_blocked}
<p class="secondary small">
<strong>Dev mode:</strong> a run straight against {site} needs no install, but {s.dhp_blocked}
</p>
{/if}
{@render driveLink("Have an ODIAN drive?")}
{:else if action === "offline_unavailable"}
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
@@ -200,12 +243,34 @@
<p class="secondary">
{#if checking}
Checking {site}…
{:else if !s?.installed}
Nothing is installed here: the client comes from your checkout and runs on
<strong>{site}</strong>'s data. You sign in inside ODIAN, not here.
{:else if s?.update_required}
<!-- Only reachable on a dev run: any other PC would be on the "update" action. -->
ODIAN <strong>{s.installed}</strong> is installed, <strong>{s.available}</strong> is on {site}. A dev run starts
your checkout, so it does not need it.
<button class="btn-link" disabled={blocked} onclick={() => launcher.install()}>Update anyway</button>
{:else if s?.online}
ODIAN <strong>{s.installed}</strong> is up to date.
{:else}
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
{/if}
</p>
{#if s?.online && !s.signed_in && !checking}
<!-- The operator is remembered with the install (launcher.json travels with a drive),
the DHP sign-in is not (Credential Manager, per PC). So a drive on another PC offers
Launch as if nothing were missing, and ODIAN then runs on that PC's own data: no live
aircraft, no tiles from the site. Launch stays the button - a sign-in that cannot
complete must not stop ODIAN - but the gap is said here, with the way out. -->
<p class="secondary attention">
<Icon name="alert" size={14} />
<span>
Not signed in on this PC: ODIAN will run on this PC's own data, without live aircraft.
<button class="btn-link" disabled={blocked} onclick={() => launcher.signIn()}>Sign in</button>
</span>
</p>
{/if}
{#if launcher.syncAttention > 0}
<p class="secondary attention">
<Icon name="alert" size={14} />
@@ -221,16 +286,7 @@
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p>
{/if}
{#if devRun}
<p class="secondary attention">
<Icon name="alert" size={14} />
<span>
Dev mode: ODIAN runs from your checkout, not from the installed
<strong>{s?.installed ?? "version"}</strong>.{#if devUser}
Running as <strong>{devUser}</strong> — sync is off.{/if}
</span>
</p>
{/if}
{#if unfinished}
<p class="secondary attention">
<Icon name="alert" size={14} />
@@ -364,6 +420,102 @@
</p>
{/if}
{#if showDev}
<!-- Dev mode's own controls, where the launching happens. Everything that decides
what Launch does is here rather than behind Settings: the target, the checkout,
and whether it builds first. -->
<section class="dev" aria-label="Dev mode">
<header class="dev-head">
<Icon name="code" size={13} />
<span class="dev-title">DEV</span>
<!-- What Launch will actually start, in three words. The folder is not repeated
here: it is in the field two rows down. -->
<span class="dev-from mono">{devRun ? TARGETS.find((o) => o.key === devTarget)?.name ?? "" : "no checkout"}</span>
</header>
<div class="dev-targets" role="radiogroup" aria-label="Launch starts">
{#each TARGETS as option (option.key)}
<label class="dev-target" class:on={devTarget === option.key}>
<input
type="radio"
name="dev-target"
value={option.key}
checked={devTarget === option.key}
disabled={blocked || savingDev}
onchange={() => launcher.setDev({ dev_target: option.key })}
/>
<span class="dev-target-name">{option.name}</span>
<span class="dev-target-note mono">{option.note}</span>
</label>
{/each}
</div>
<div class="dev-row">
<input
class="dev-path"
type="text"
spellcheck="false"
placeholder="Folder holding ODIAN_Engine"
value={devRoot}
disabled={blocked || savingDev}
onchange={(e) => launcher.setDev({ dev_root: e.currentTarget.value.trim() })}
/>
<button type="button" class="btn" disabled={blocked || savingDev} onclick={browseCheckout}>Browse…</button>
</div>
<div class="dev-row">
<label class="dev-label" for="dev-run-as">Run as</label>
<input
id="dev-run-as"
class="dev-path"
type="text"
spellcheck="false"
autocomplete="off"
placeholder={s?.operator ?? "the operator who signed in"}
value={devUserField}
disabled={blocked || savingDev}
onchange={(e) => launcher.setDev({ dev_user: e.currentTarget.value.trim() })}
/>
</div>
<div class="dev-row">
{#if releasing}
<button class="btn dev-release" onclick={() => launcher.showConsole()}>Release running…</button>
{:else}
<button class="btn dev-release" disabled={blocked || !s?.online} onclick={openRelease}>
Build &amp; deploy…
</button>
{/if}
{#if hasOutput && !releasing}
<button class="btn-link" onclick={() => launcher.showConsole()}>last output</button>
{:else if !s?.online}
<span class="dev-note">needs the DHP</span>
{/if}
</div>
<label class="dev-check">
<input
type="checkbox"
checked={devBuild}
disabled={blocked || savingDev}
onchange={(e) => launcher.setDev({ dev_build: e.currentTarget.checked })}
/>
<span>Build first</span>
</label>
{#if !devRun}
<p class="dev-note">Name the folder that holds <code>ODIAN_Engine</code> and Launch runs from it.</p>
{:else if devUser}
<!-- One line, and ONE flex child: `.attention` is display:flex, so loose text and
<strong> each become a column. "no live data" earns its place - the old wording
said only "no sync", and the missing tracks were hunted through the whole stack. -->
<p class="dev-note attention">
<span>Running as <strong>{devUser}</strong> — no sync, no live data.</span>
</p>
{/if}
</section>
{/if}
{#if showNotice}
<div class="notice" role="alert">
<Icon name="alert" size={14} />
@@ -376,6 +528,117 @@
</section>
<style>
/* Dev mode's controls. Deliberately compact and deliberately here: what Launch will do
is decided in the same place you press it, not two drawers away. */
.dev {
margin-top: 0.75rem;
padding: 0.7rem 0.75rem;
border: 1px solid color-mix(in srgb, #46d17f 32%, transparent);
border-radius: 8px;
background: color-mix(in srgb, #46d17f 7%, transparent);
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.dev-head {
display: flex;
align-items: center;
gap: 0.4rem;
color: #46d17f;
}
.dev-title {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
}
.dev-from {
margin-left: auto;
font-size: 0.72rem;
color: var(--text-dim, #9aa3ad);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 55%;
direction: rtl; /* keep the END of a long path, which is the part that differs */
text-align: right;
}
.dev-targets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.35rem;
}
.dev-target {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
padding: 0.35rem 0.25rem;
border: 1px solid var(--line, #2a3139);
border-radius: 6px;
cursor: pointer;
line-height: 1.2;
}
.dev-target.on {
border-color: color-mix(in srgb, #46d17f 60%, transparent);
background: color-mix(in srgb, #46d17f 14%, transparent);
}
.dev-target:has(input:disabled) {
opacity: 0.55;
cursor: default;
}
.dev-target input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.dev-target-name {
font-size: 0.82rem;
font-weight: 600;
}
.dev-target-note {
font-size: 0.68rem;
color: var(--text-dim, #9aa3ad);
}
.dev-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.dev-path {
flex: 1 1 auto;
min-width: 0;
padding: 0.3rem 0.45rem;
font-size: 0.78rem;
font-family: inherit;
color: inherit;
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--line, #2a3139);
border-radius: 6px;
}
.dev-label {
flex: none;
font-size: 0.78rem;
color: var(--text-dim, #9aa3ad);
}
.dev-check {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
white-space: nowrap;
}
.dev-note {
font-size: 0.75rem;
color: var(--text-dim, #9aa3ad);
margin: 0;
}
.dev-note code {
font-size: 0.72rem;
}
.dev-release {
font-weight: 600;
}
.action {
display: grid;
gap: 14px;
+5 -1
View File
@@ -19,7 +19,8 @@
| "signout"
| "key"
| "globe"
| "market";
| "market"
| "code";
</script>
<script lang="ts">
@@ -98,6 +99,9 @@
<rect x="9" y="2.5" width="4.5" height="4.5" />
<rect x="2.5" y="9" width="4.5" height="4.5" />
<path d="M11.25 9v4.5M9 11.25h4.5" />
{:else if name === "code"}
<path d="M5.75 4.5 2 8l3.75 3.5" />
<path d="M10.25 4.5 14 8l-3.75 3.5" />
{/if}
</svg>
+30 -2
View File
@@ -5,11 +5,19 @@
// It starts where the news panel ends, so the wordmark is never half-hidden
// behind it and the panel's side of the window stays plain black.
import keyart from "../assets/keyart.webp";
import keyartDev from "../assets/keyart-dev.webp";
import { launcher } from "../lib/launcher.svelte";
// Dev mode gets its own picture. Not decoration: the backdrop is the one thing on screen
// you cannot miss, so a glance says which launcher you are looking at before you read a
// word of it. It follows the SWITCH, not the licence - a licensed PC with dev mode off is
// an ordinary launcher and should look like one.
const art = $derived(launcher.devOn ? keyartDev : keyart);
</script>
<div class="keyart" aria-hidden="true">
<div class="keyart" class:dev={launcher.devOn} aria-hidden="true">
<div class="frame">
<img class="art" src={keyart} alt="" draggable="false" />
<img class="art" src={art} alt="" draggable="false" />
</div>
<div class="vignette"></div>
</div>
@@ -63,9 +71,29 @@
radial-gradient(120% 100% at 62% 50%, transparent 58%, rgba(9, 9, 11, 0.5) 100%);
}
/* Dev mode has no notes panel, so the picture gets the whole window and is centred in
it - and the left-edge fade goes with the panel it was hiding a seam against. */
.keyart.dev .frame {
left: 0;
-webkit-mask-image: none;
mask-image: none;
}
/* The dev picture is 3:2 in a window that is wider than that, so what gets cut is HEIGHT
- 11% of it at the default size. Taken off the middle it clips the top of her head and
the wordmark; taken mostly off the bottom it costs foreground crates the action panel
half-covers anyway. Hence 30% rather than centre. The other picture keeps its own
framing: it was placed against the notes panel, which dev mode does not draw. */
.keyart.dev .art {
object-position: 50% 30%;
}
@media (max-width: 1140px) {
.frame {
left: 384px;
}
.keyart.dev .frame {
left: 0;
}
}
</style>
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
let scroller: HTMLElement | undefined = $state();
const lines = $derived(launcher.consoleLines);
const running = $derived(launcher.consoleRunning);
const exit = $derived(launcher.consoleExit);
// Follow the output, but only while the reader is already at the bottom: scrolling back to
// read an error must not be yanked away by the next line that arrives.
let follow = $state(true);
function onScroll() {
const el = scroller;
if (!el) return;
follow = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
}
$effect(() => {
void lines.length;
if (follow && scroller) scroller.scrollTop = scroller.scrollHeight;
});
// A step header is worth picking out of a wall of build output, as is a failure.
function kind(line: string): string {
if (/^\s*={3}/.test(line)) return "step";
if (/^FAILED:|^\s*Erreur|error[: ]/i.test(line)) return "bad";
if (/nothing to (release|deploy)|No pack cut|Release finished/i.test(line)) return "note";
return "";
}
</script>
<section class="console glass" aria-label="Release output">
<header class="head">
<h2 class="eyebrow">Release</h2>
<span class="state mono" class:live={running} class:bad={!running && exit !== null && exit !== 0}>
{#if running}
running
{:else if exit === null}
idle
{:else if exit === 0}
finished
{:else}
failed ({exit})
{/if}
</span>
<span class="grow"></span>
<span class="total mono">{lines.length}</span>
<button class="icon-btn" aria-label="Close release output" onclick={() => launcher.closeConsole()}>
<Icon name="close" size={14} />
</button>
</header>
<!-- Both are deliberate: `log` is the role for streamed output, and it has to be
focusable or a keyboard cannot scroll back through a failed build. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div bind:this={scroller} class="body mono selectable" onscroll={onScroll} tabindex="0" role="log">
{#if lines.length}
{#each lines as line, i (i)}
<div class="line {kind(line)}">{line || " "}</div>
{/each}
{:else}
<p class="empty">Waiting for the first line…</p>
{/if}
</div>
{#if !running && exit !== null}
<footer class="foot">
{exit === 0 ? "Closing this keeps the output until the next release." : "The full transcript is in state\\release\\release.log."}
</footer>
{/if}
</section>
<style>
.console {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
background: var(--glass-strong);
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--line);
}
.grow {
flex: 1;
}
.total,
.state {
color: var(--muted);
font-size: var(--text-xs);
}
/* The dot earns its place: from across the desk it is the only thing that says whether a
release is still going. */
.state.live::before,
.state.bad::before {
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
border-radius: 50%;
background: var(--accent, #4ade80);
content: "";
vertical-align: middle;
}
.state.live {
color: var(--ink-2);
}
.state.bad {
color: var(--danger);
}
.state.bad::before {
background: var(--danger);
}
.body {
flex: 1;
padding: 8px 0;
overflow-y: auto;
min-height: 0;
font-size: var(--text-xs);
line-height: 1.55;
}
.line {
padding: 0 16px;
color: var(--ink-2);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.line.step {
margin-top: 6px;
color: var(--ink);
font-weight: 600;
}
.line.bad {
color: var(--danger);
}
.line.note {
color: var(--warn);
}
.empty {
padding: 8px 16px;
color: var(--muted);
font-size: var(--text-sm);
}
.foot {
padding: 8px 16px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: var(--text-xs);
}
</style>
+399
View File
@@ -0,0 +1,399 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { prefersReducedMotion } from "svelte/motion";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
import { formatBytes } from "../lib/format";
// Its own panel rather than a block inside the action card: the card is anchored to the
// bottom of the window and cannot grow, so three rows plus a password field pushed the
// Release button off the bottom - visible, unreachable, and reported as "it doesn't work".
const plan = $derived(launcher.release);
const busy = $derived(launcher.busy === "release");
let password = $state("");
let withApi = $state(true);
// Off by default, deliberately: it serves /download/* and proxies /dhp, so it is the one
// step that interrupts the road everything else publishes over.
let withMcp = $state(false);
// The browser client at web.odian.fr. Built from _WEB in the Engine checkout, in Docker.
let withWeb = $state(false);
async function release() {
await launcher.releaseRun(password, withApi, withMcp, withWeb);
password = ""; // held for the one run, never kept
}
/** The short version of a row's state, for the column that has room for three words.
NOT called `state`: that shadows the `$state` rune in this file, and everything
declared with it silently stops being reactive. */
// The refresh is its own press, with its own password: it dumps a database operators are
// using, so it must not be reachable by the same click that ships a code change.
let refreshOpen = $state(false);
let pgPassword = $state("");
let tileRoot = $state("");
const canRefresh = $derived(
password.trim().length > 0 && (pgPassword.trim().length > 0 || tileRoot.trim().length > 0),
);
async function refresh() {
if (!canRefresh || busy) return;
await launcher.refreshRun(password, pgPassword, tileRoot);
pgPassword = "";
}
/** `20260921T125618Z` -> `21 Sep 2026`; anything else is shown as it came. */
function stamped(snapshot: string | null): string {
if (!snapshot || snapshot.length < 8) return snapshot ?? "unknown";
const y = Number(snapshot.slice(0, 4));
const m = Number(snapshot.slice(4, 6));
const d = Number(snapshot.slice(6, 8));
if (!y || !m || !d) return snapshot;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${d} ${MONTHS[m - 1] ?? "?"} ${y}`;
}
/** `20260423` -> `Apr 2026`. The vintage of the extract, not an age. */
function monthOf(stamp: string): string {
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const m = Number(stamp.slice(4, 6));
return `${MONTHS[m - 1] ?? "?"} ${stamp.slice(0, 4)}`;
}
function aged(days: number | null): string {
if (days === null) return "";
if (days <= 0) return "today";
if (days === 1) return "1 day old";
return `${days} days old`;
}
function rowState(row: { behind: boolean | null; dirty: boolean }): { text: string; tone: string } {
if (row.dirty) return { text: "uncommitted", tone: "warn" };
if (row.behind === true) return { text: "behind", tone: "go" };
if (row.behind === false) return { text: "up to date", tone: "dim" };
return { text: "unknown", tone: "dim" };
}
</script>
<section
class="release glass"
aria-label="Build and deploy"
transition:fly={{ y: 8, duration: prefersReducedMotion.current ? 0 : 200 }}
>
<header class="head">
<Icon name="code" size={14} />
<h2 class="eyebrow">Build &amp; deploy</h2>
<span class="grow"></span>
<button class="icon-btn" aria-label="Close" onclick={() => (launcher.releaseOpen = false)}>
<Icon name="close" size={14} />
</button>
</header>
<div class="body">
{#if !plan}
<p class="note">Looking at what is live and what this PC holds…</p>
{:else}
<ul class="plan">
{#each plan.items as row (row.key)}
{@const st = rowState(row)}
<li>
<span class="name">{row.title}</span>
<span class="live mono">{row.live ?? "—"}</span>
<span class="state {st.tone}">{st.text}</span>
<span class="commit mono">{row.commit ?? ""}</span>
</li>
{/each}
</ul>
<!-- The payload a release does NOT rebuild. It is copied forward from the previous
version every time, so it ages quietly: this is the only place that says by how
much, which is what turns "the tiles are old" into a decision. -->
{#if plan.data}
{@const d = plan.data}
<div class="payload">
<span class="payload-head">Carried forward — a release does not rebuild these</span>
<div class="payload-row">
<span class="payload-name">Database seed</span>
<span class="payload-val mono">{stamped(d.db_snapshot)}</span>
<span class="payload-age" class:stale={(d.db_age_days ?? 0) > 30}>{aged(d.db_age_days)}</span>
<span class="payload-size mono">{formatBytes(d.db_bytes)}</span>
</div>
<div class="payload-row">
<span class="payload-name">Map tiles</span>
<span class="payload-val mono">{d.tile_sets} sets</span>
<!-- Deliberately NOT an age. Nothing records when tiles were baked, and the only
date available is the upstream extract's, which said "161 days old" about
work that is three weeks old. -->
<span class="payload-age" class:stale={(d.basemap_vintage_days ?? 0) > 365}>
{d.basemap_vintage ? `map data ${monthOf(d.basemap_vintage)}` : ""}
</span>
<span class="payload-size mono">{formatBytes(d.tile_bytes)}</span>
</div>
<span class="payload-foot mono">{d.tile_datasets.join(" · ")}</span>
<span class="payload-foot">
Tiles record no bake date, so only the source extract's vintage is known.
</span>
<div class="payload-act">
<button class="btn-link" onclick={() => (refreshOpen = !refreshOpen)}>
{refreshOpen ? "Never mind" : "Bring up to date…"}
</button>
</div>
{#if refreshOpen}
<div class="refresh">
<p class="refresh-why">
This rebuilds the seed from the live database and re-indexes the tiles. It
<strong>publishes nothing</strong> — it prepares the inputs, and the console says how to cut a
version from them.
</p>
<input
class="input"
type="password"
autocomplete="off"
placeholder="database password (Data PC) — leave empty to skip the seed"
bind:value={pgPassword}
disabled={busy}
/>
<input
class="input"
type="text"
autocomplete="off"
spellcheck="false"
placeholder="tile folder laid out as style\dataset — leave empty to skip the tiles"
bind:value={tileRoot}
disabled={busy}
/>
<div class="row">
<span class="refresh-note">
The seed is minutes of load on a database people are using. Uses the node password above.
</span>
<button class="btn" disabled={!canRefresh || busy} onclick={refresh}>Refresh</button>
</div>
</div>
{/if}
{#if !d.is_live}
<span class="payload-foot">Read from this PC's {d.version}, which is not what the DHP serves.</span>
{/if}
</div>
{/if}
<div class="checks mono">
<span class:ok={plan.online}>DHP {plan.online ? "✓" : "✗"}</span>
<span class:ok={plan.docker}>Docker {plan.docker ? "✓" : "✗"}</span>
<span class:ok={plan.scripts}>scripts {plan.scripts ? "✓" : "✗"}</span>
</div>
{#if plan.blocked}
<p class="note warn">{plan.blocked}</p>
{:else}
<p class="note">
Each step decides for itself whether it has anything to do, so a press releases only
what has moved. It runs in its own window.
</p>
<label class="check">
<input type="checkbox" bind:checked={withApi} disabled={!plan.docker || busy} />
<span>
Also roll the Engine API{plan.docker ? "" : " — needs Docker"}
<em>— every operator is logged out</em>
</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={withWeb} disabled={!plan.docker || busy} />
<span>
Also deploy ODIAN WEB{plan.docker ? "" : " — needs Docker"}
<em>— web.odian.fr, from the Engine checkout</em>
</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={withMcp} disabled={!plan.docker || busy} />
<span>
Also deploy the MCP server{plan.docker ? "" : " — needs Docker"}
<em>— runs last; it serves the download feeds</em>
</span>
</label>
<div class="row">
<input
class="input"
type="password"
autocomplete="off"
placeholder="node sudo password"
bind:value={password}
disabled={busy}
/>
<button class="btn primary-ish" disabled={busy || !password.trim()} onclick={release}>
{busy ? "Starting…" : "Release"}
</button>
</div>
{/if}
{/if}
</div>
</section>
<style>
/* The data payload. Quiet by default - it is context, not a control - but the age goes
amber once it is old enough to matter, which is the whole point of showing it. */
.payload {
display: flex;
flex-direction: column;
gap: 2px;
margin: 10px 0 2px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 8px;
}
.payload-head {
color: var(--muted);
font-size: var(--text-xs);
margin-bottom: 4px;
}
.payload-row {
display: grid;
grid-template-columns: 7.5rem 7rem minmax(0, 1fr) auto;
gap: 8px;
align-items: baseline;
font-size: var(--text-xs);
}
.payload-name {
color: var(--ink-2);
}
.payload-val,
.payload-size {
color: var(--muted);
}
.payload-age {
color: var(--muted);
}
.payload-age.stale {
color: var(--warn);
}
.payload-act {
margin-top: 6px;
}
.refresh {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.refresh-why,
.refresh-note {
margin: 0;
color: var(--muted);
font-size: var(--text-xs);
line-height: 1.5;
}
.refresh .row {
display: flex;
align-items: center;
gap: 10px;
}
.refresh .row .refresh-note {
flex: 1;
}
.payload-foot {
margin-top: 4px;
color: var(--muted);
font-size: var(--text-xs);
overflow-wrap: anywhere;
}
.release {
display: flex;
flex-direction: column;
max-height: min(460px, calc(100vh - 180px));
background: var(--glass-strong);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.8);
}
.head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 16px;
border-bottom: 1px solid var(--line);
color: #46d17f;
}
.grow {
flex: 1;
}
.body {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 12px 16px 14px;
overflow: auto;
}
.plan {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.plan li {
display: grid;
grid-template-columns: 6.5rem 4.5rem 6rem 1fr;
gap: 0.5rem;
align-items: baseline;
font-size: 0.8rem;
}
.name {
font-weight: 600;
}
.live,
.commit {
color: var(--muted);
font-size: 0.75rem;
}
.state.go {
color: #46d17f;
font-weight: 600;
}
.state.warn {
color: #e6b35a;
}
.state.dim {
color: var(--muted);
}
.checks {
display: flex;
gap: 0.8rem;
font-size: 0.74rem;
color: #e06c6c;
}
.checks span.ok {
color: #46d17f;
}
.note {
margin: 0;
font-size: 0.78rem;
line-height: 1.45;
color: var(--muted);
}
.note.warn {
color: #e6b35a;
}
.check {
display: flex;
align-items: flex-start;
gap: 0.45rem;
font-size: 0.8rem;
line-height: 1.35;
}
.check em {
color: #e6b35a;
font-style: normal;
}
.row {
display: flex;
gap: 0.4rem;
}
.row .input {
flex: 1 1 auto;
min-width: 0;
}
.primary-ish {
font-weight: 600;
}
</style>
-127
View File
@@ -62,7 +62,6 @@
trimmed.dev_target !== current.dev_target),
);
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
const dev = $derived(launcher.status?.dev ?? null);
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
// Blank is a deliberate answer for all three: no DHP, no sign-in service, no AI. It is
// how a PC is put fully offline on purpose - and how an offline site is rehearsed on a
@@ -294,82 +293,6 @@
</div>
</fieldset>
{#if dev}
<!-- Only ever rendered on a PC holding a valid signed licence. An operator's
launcher has no dev section at all, which is the point of the signature. -->
<section class="group" aria-labelledby="set-dev-title">
<h3 id="set-dev-title" class="eyebrow">Dev</h3>
<p class="text">
{#if dev.machine}This PC{:else}This install{/if} carries a dev licence, valid until
<strong>{dev.expires_at}</strong>{#if dev.note} — {dev.note}{/if}. Dev mode runs the
client from your checkout, in a console, over this install's database and tiles.
</p>
<div class="field">
<label class="field-label" for="set-dev-root">Checkout folder</label>
<div class="row">
<input
id="set-dev-root"
class="input"
type="text"
spellcheck="false"
placeholder="E:\ODIAN_Dev"
bind:value={draft.dev_root}
disabled={locked || busy}
/>
<button type="button" class="btn" disabled={locked || busy} onclick={browseDev}>Browse…</button>
</div>
<p class="hint">The folder that holds <code>ODIAN_Engine</code>.</p>
</div>
<label class="check-row">
<input type="checkbox" bind:checked={draft.dev_mode} disabled={locked || busy || !draft.dev_root.trim()} />
<span>Launch the whole stack from the checkout instead of the installed version</span>
</label>
<label class="check-row">
<input type="checkbox" bind:checked={draft.dev_build} disabled={locked || busy || !draft.dev_mode} />
<span>Build it first, every launch</span>
</label>
<div class="field">
<span class="field-label" id="set-dev-target-label">Launch starts</span>
<div class="choices" role="radiogroup" aria-labelledby="set-dev-target-label">
{#each TARGETS as option (option.key)}
<label class="choice" class:on={draft.dev_target === option.key}>
<input
type="radio"
name="dev-target"
value={option.key}
bind:group={draft.dev_target}
disabled={locked || busy || !draft.dev_mode}
/>
<span class="choice-name">{option.name}</span>
<span class="choice-note">{option.note}</span>
</label>
{/each}
</div>
</div>
<div class="field">
<label class="field-label" for="set-dev-user">Run as</label>
<input
id="set-dev-user"
class="input"
type="text"
spellcheck="false"
placeholder={launcher.status?.operator ?? "the operator who signed in"}
bind:value={draft.dev_user}
disabled={locked || busy || !draft.dev_mode}
/>
<p class="hint">
{#if draft.dev_user.trim() && draft.dev_user.trim() !== launcher.status?.operator}
This PC will <strong>not sync with the DHP</strong> while it runs as someone else: work
authored as them would arrive there under your account.
{:else}
Another operator's name, to test what they see. Sync is switched off while it differs
from yours.
{/if}
</p>
</div>
</section>
{/if}
<section class="group" aria-labelledby="set-drive-title">
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
<p class="text">
@@ -447,56 +370,6 @@
</dialog>
<style>
/* The Dev section's own rows. Only ever drawn on a licensed PC. */
.check-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin-top: 0.6rem;
line-height: 1.35;
}
.check-row input {
margin-top: 0.15rem;
flex: none;
}
.hint {
margin: 0.35rem 0 0;
font-size: 0.82rem;
line-height: 1.45;
color: var(--text-dim, #9aa3ad);
}
.choices {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.4rem;
}
.choice {
display: grid;
grid-template-columns: auto auto 1fr;
align-items: baseline;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--border, #2a3139);
border-radius: 6px;
cursor: pointer;
}
.choice.on {
border-color: var(--accent, #5b8cff);
background: color-mix(in srgb, var(--accent, #5b8cff) 12%, transparent);
}
.choice:has(input:disabled) {
opacity: 0.5;
cursor: default;
}
.choice-name {
font-weight: 600;
}
.choice-note {
font-size: 0.8rem;
color: var(--text-dim, #9aa3ad);
}
.drawer {
position: fixed;
inset: 0 0 0 auto;
+32
View File
@@ -16,6 +16,10 @@
const status = $derived(launcher.status);
const checking = $derived(launcher.refreshing || status?.phase === "checking");
// The licence, not a setting: an operator's launcher must show no trace of dev mode.
const licensed = $derived((status?.dev ?? null) !== null);
const devOn = $derived(launcher.devOn);
const saving = $derived(launcher.busy === "settings");
function windowAction(task: () => Promise<void>) {
return () => {
@@ -80,6 +84,22 @@
<div class="drag" data-tauri-drag-region></div>
{#if licensed}
<!-- Only on a PC that holds a dev licence. An operator's launcher never draws it, which
is the whole point of the licence - so this sits behind `status.dev`, not a setting. -->
<button
class="icon-btn dev"
class:on={devOn}
aria-label={devOn ? "Dev mode is on" : "Turn dev mode on"}
title={devOn ? "Dev mode is ON - ODIAN runs from your checkout" : "Dev mode"}
aria-pressed={devOn}
disabled={saving}
onclick={() => launcher.setDev({ dev_mode: !devOn })}
>
<Icon name="code" />
</button>
{/if}
<button class="icon-btn market" aria-label="Marketplace" title="Marketplace" aria-haspopup="dialog" onclick={onOpenMarket}>
<Icon name="market" />
</button>
@@ -107,6 +127,18 @@
</header>
<style>
/* The dev switch. Off it is one more grey icon button; on it is unmistakably GREEN,
because "am I running my own build or the installed one" is the question this whole
mode exists to answer, and guessing it wrong wastes an afternoon. */
.icon-btn.dev.on {
color: #46d17f;
border-color: color-mix(in srgb, #46d17f 55%, transparent);
background: color-mix(in srgb, #46d17f 16%, transparent);
}
.icon-btn.dev.on:hover:not(:disabled) {
background: color-mix(in srgb, #46d17f 26%, transparent);
}
.titlebar {
position: relative;
z-index: 20;
+7
View File
@@ -15,6 +15,8 @@ import type {
SignInResult,
StackEvent,
Status,
ReleaseConsoleTail,
ReleasePlan,
RemoveProgress,
SyncConflict,
TileSyncProgress,
@@ -70,6 +72,11 @@ function createTauriBackend(): Backend {
onStatus: (handler) => subscribe<Status>("status", handler),
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", handler),
releasePlan: () => invoke<ReleasePlan>("release_plan"),
releaseRun: (password, withApi, withMcp, withWeb) =>
invoke<void>("release_run", { password, withApi, withMcp, withWeb }),
releaseConsole: (since) => invoke<ReleaseConsoleTail>("release_console", { since }),
refreshRun: (password, pgPassword, tileRoot) => invoke<void>("refresh_run", { password, pgPassword, tileRoot }),
onTileSyncProgress: (handler) => subscribe<TileSyncProgress>("tile-sync-progress", handler),
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
onStack: (handler) => subscribe<StackEvent>("stack", handler),
+109 -1
View File
@@ -10,6 +10,7 @@ import type {
LogLevel,
Market,
Phase,
ReleasePlan,
Settings,
StackComponent,
StackEvent,
@@ -71,10 +72,16 @@ export function deriveAction(s: Status | null): ActionKind {
}
// idle or checking
if (s.installed === null) {
// Nothing installed, but a dev checkout and a reachable DHP are enough on their own:
// the client runs against the DHP and signs itself in, so neither an install nor a
// launcher sign-in stands in the way.
if (s.dhp_direct) return "launch";
if (!s.online) return "offline_unavailable";
return s.signed_in ? "install" : "sign_in";
}
if (s.update_required) return s.signed_in ? "update" : "sign_in";
// A dev run starts the checkout, so a newer pack is worth saying but not worth
// stopping for; the hub keeps Launch and mentions the update beside it.
if (s.update_required && !s.dev_run) return s.signed_in ? "update" : "sign_in";
// Launching runs ODIAN as the remembered operator; after Sign out there is none.
if (s.operator === null) return s.online ? "sign_in" : "signed_out_offline";
return "launch";
@@ -402,6 +409,107 @@ class Launcher {
return this.launch();
}
/** What a release could do right now; null until the panel asks. */
release = $state<ReleasePlan | null>(null);
releaseOpen = $state(false);
async loadRelease() {
this.release = null;
await this.run("release", async () => {
this.release = await backend.releasePlan();
});
}
releaseRun(password: string, withApi: boolean, withMcp: boolean, withWeb: boolean) {
return this.run("release", async () => {
await backend.releaseRun(password, withApi, withMcp, withWeb);
this.releaseOpen = false;
// Straight into the console: the release is running with nowhere else to show itself.
this.consoleLines = [];
this.consoleTotal = 0;
this.consoleExit = null;
this.consoleRunning = true;
this.consoleOpen = true;
this.pollConsole();
});
}
// ---- the release console -------------------------------------------------------------
/** Shown in the notes column in dev mode, where the patch notes sit otherwise. */
consoleOpen = $state(false);
consoleLines = $state<string[]>([]);
consoleRunning = $state(false);
consoleExit = $state<number | null>(null);
/** How many lines have been taken, so each poll asks only for what is new. */
private consoleTotal = 0;
private polling = false;
/** Polled rather than pushed: reopening the panel then needs no replay, and a slow window
* cannot fall behind a build that prints thousands of lines. */
private async pollConsole() {
if (this.polling) return;
this.polling = true;
try {
for (;;) {
const tail = await backend.releaseConsole(this.consoleTotal);
if (tail.from > this.consoleTotal && this.consoleLines.length) {
this.consoleLines.push(`... ${tail.from - this.consoleTotal} earlier lines dropped`);
}
if (tail.lines.length) this.consoleLines.push(...tail.lines);
this.consoleTotal = tail.total;
this.consoleRunning = tail.running;
this.consoleExit = tail.exit;
if (!tail.running) return;
await new Promise((r) => setTimeout(r, 400));
}
} catch {
this.consoleRunning = false;
} finally {
this.polling = false;
}
}
/** Rebuild the pack's data payload. Streams into the same console as a release. */
refreshRun(password: string, pgPassword: string, tileRoot: string) {
return this.run("release", async () => {
await backend.refreshRun(password, pgPassword, tileRoot);
this.releaseOpen = false;
this.consoleLines = [];
this.consoleTotal = 0;
this.consoleExit = null;
this.consoleRunning = true;
this.consoleOpen = true;
this.pollConsole();
});
}
/** Reopen it after it was closed; a finished release keeps its output to be read. */
showConsole() {
this.consoleOpen = true;
if (this.consoleRunning) this.pollConsole();
}
closeConsole() {
this.consoleOpen = false;
}
/** Change part of the settings and save, for the dev controls on the hub. */
setDev(change: Partial<Settings>) {
const current = this.settings;
if (!current) return Promise.resolve();
return this.saveSettings({ ...current, ...change });
}
/** Dev mode is ON: this PC has a licence and the switch is thrown. */
get devOn(): boolean {
return (this.status?.dev ?? null) !== null && (this.settings?.dev_mode ?? false);
}
/** ...and a checkout has been named, so a launch will really come from it. */
get devReady(): boolean {
return this.devOn && (this.settings?.dev_root ?? "").trim() !== "";
}
saveSettings(next: Settings) {
return this.run("settings", async () => {
this.applyStatus(await backend.setSettings(next));
+138 -4
View File
@@ -1,7 +1,7 @@
// Browser-only stand-in for the Rust backend, so the hub can be designed with
// `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 | new-pc | offline-installed |
// 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
@@ -24,6 +24,8 @@ import {
type MarketItem,
type MockControls,
type MockScenario,
type ReleaseConsoleTail,
type ReleasePlan,
type Settings,
type SignInResult,
type StackComponent,
@@ -224,7 +226,8 @@ function derive(s: Status): Status {
available,
signed_in: s.online && s.signed_in,
update_required,
can_launch: s.installed !== null && !update_required && s.phase === "idle",
// A dev run launches the checkout, so an available pack does not hold it back.
can_launch: (s.installed !== null || s.dhp_direct) && (!update_required || s.dev_run) && s.phase === "idle",
};
}
@@ -238,6 +241,9 @@ function initialStatus(scenario: MockScenario): Status {
available: CURRENT,
update_required: false,
can_launch: false,
dev_run: false,
dhp_direct: false,
dhp_blocked: null,
phase: "idle",
error: null,
install_dir: DEFAULT_DIR,
@@ -259,6 +265,11 @@ function initialStatus(scenario: MockScenario): Status {
case "ready":
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
break;
case "new-pc":
// A drive on a PC it has never been on: the install and its operator came with it,
// the DHP sign-in (per PC) did not. Online, launchable, and not signed in.
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: false });
break;
case "offline-installed":
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
break;
@@ -283,12 +294,41 @@ function initialStatus(scenario: MockScenario): Status {
// 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,
// Deliberately BEHIND what the DHP has: this is the case that used to refuse to
// launch at all, so the scenario should show it working.
installed: "0.71.0",
operator: OPERATOR,
signed_in: true,
dev_run: true,
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
});
break;
case "dev-dhp":
// A licensed dev PC with NOTHING installed, on a reachable DHP: the case that used to
// offer only Install.
Object.assign(s, {
installed: null,
operator: OPERATOR,
signed_in: true,
dev_run: true,
dhp_direct: true,
dhp_blocked: null,
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
});
break;
case "dev-no-checkout":
// A fresh dev PC: launcher downloaded, licence dropped in, DHP reachable - but no
// ODIAN_Engine checkout yet. It used to show Install and no way to point at one.
Object.assign(s, {
installed: null,
operator: OPERATOR,
signed_in: true,
dev_run: false,
dhp_direct: false,
dhp_blocked: "dev mode has no checkout folder yet: name the folder that holds ODIAN_Engine.",
dev: { machine: "ODIAN-DEV-02", install_id: null, expires_at: "2027-01-31", note: "test kit" },
});
break;
case "error":
Object.assign(s, {
operator: OPERATOR,
@@ -347,7 +387,8 @@ class MockBackend implements Backend {
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR, dev_root: "", dev_mode: false, dev_user: "", dev_build: false, dev_target: "client" };
// 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 (scenario === "dev" || scenario === "dev-dhp") Object.assign(this.settings, { dev_root: "E:" + String.fromCharCode(92) + "ODIAN_Dev", dev_mode: true });
if (scenario === "dev-no-checkout") Object.assign(this.settings, { dev_root: "", dev_mode: true });
if (this.status.installed) {
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
@@ -761,6 +802,99 @@ class MockBackend implements Backend {
return asked && asked !== this.status.operator ? asked : null;
}
async releasePlan(): Promise<ReleasePlan> {
await this.wait(200);
const dev = this.status.dev !== null;
return {
docker: dev,
online: this.status.online,
scripts: dev,
blocked: this.status.online ? null : "The DHP is not answering: nothing can be published.",
items: [
{ key: "pack", title: "ODIAN", live: this.status.available, commit: "a1b2c3d", behind: true,
dirty: false, note: "behind: the repo has moved past what is published" },
{ key: "launcher", title: "Launcher", live: "0.1.38", commit: "e4f5a6b", behind: false,
dirty: false, note: "up to date" },
{ key: "api", title: "Engine API", live: null, commit: "a1b2c3d", behind: null,
dirty: false, note: "what is published does not say which commit it came from" },
{ key: "mcp", title: "MCP server", live: null, commit: "c7d8e9f", behind: null,
dirty: false, note: "the script asks the node whether it is already running this commit" },
{ key: "web", title: "ODIAN WEB", live: "0.1.7", commit: "a1b2c3d", behind: null,
dirty: false, note: "web.odian.fr; the script asks whether it already serves this commit" },
],
// The real numbers from pack 0.72.0, so the panel can be judged on what it will show.
data: {
version: this.status.installed ?? "0.72.0",
is_live: true,
db_snapshot: "20260921T125618Z",
db_age_days: 10,
db_bytes: 15_354_000_000,
tile_sets: 8,
tile_bytes: 102_650_000_000,
tile_datasets: ["protomaps_20260423", "planet_heightmap"],
basemap_vintage: "20260423",
basemap_vintage_days: 161,
},
};
}
async releaseRun(password: string, withApi: boolean, withMcp: boolean, withWeb: boolean): Promise<void> {
await this.wait(400);
if (!password.trim()) throw "The node's sudo password is needed to cut a pack and to roll the API.";
// Nothing runs in the browser, so play a short script into the console instead: enough
// to see the panel fill, scroll and finish.
this.consoleLines = ["=== ODIAN pack 0.74.0 ===", "", "=== Preflight ==="];
this.consoleDone = null;
const rest = [
"engine at 1255873...",
"Nothing that ships has changed. No pack cut.",
"pack: nothing to release",
`=== Launcher 0.1.40 === (API ${withApi}, MCP ${withMcp}, web ${withWeb})`,
"npx tauri build ...",
"Release finished.",
];
void (async () => {
for (const line of rest) {
await this.wait(700);
this.consoleLines.push(line);
}
this.consoleDone = 0;
})();
}
async refreshRun(password: string, pgPassword: string, tileRoot: string): Promise<void> {
await this.wait(300);
if (!password.trim()) throw "The node's sudo password is needed: the dump runs there.";
if (!pgPassword.trim() && !tileRoot.trim()) throw "Nothing to refresh: give the database password, a tile folder, or both.";
this.consoleLines = ["=== Database seed ==="];
this.consoleDone = null;
const rest = pgPassword.trim()
? ["live version : 0.75.0", "dumping odian from 192.168.1.13 ...", "snapshot : 20261001T090000Z (was 20260921T125618Z)",
"tables : 302 bytes: 15.40 GB", "Refresh finished. Nothing is published yet."]
: ["=== Tile indexes ===", "re-indexing 8 sets...", "Refresh finished. Nothing is published yet."];
void (async () => {
for (const line of rest) {
await this.wait(600);
this.consoleLines.push(line);
}
this.consoleDone = 0;
})();
}
private consoleLines: string[] = [];
private consoleDone: number | null = 0;
async releaseConsole(since: number): Promise<ReleaseConsoleTail> {
await this.wait(60);
return {
from: Math.min(since, this.consoleLines.length),
lines: this.consoleLines.slice(since),
total: this.consoleLines.length,
running: this.consoleDone === null,
exit: this.consoleDone,
};
}
async syncNow(): Promise<void> {
if (!this.status.online) throw "Your DHP is unreachable: changes stay on this PC until the next connected sync.";
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: sync once it has stopped.";
+65
View File
@@ -36,6 +36,12 @@ export interface Status {
update_required: boolean;
/** installed && !update_required && phase is idle. */
can_launch: boolean;
/** This launch would come from the dev checkout, so an available pack does not gate it. */
dev_run: boolean;
/** Nothing is installed, but a dev run can still start the client against the DHP. */
dhp_direct: boolean;
/** On a licensed PC with nothing installed, why a DHP run is not on offer. */
dhp_blocked: string | null;
phase: Phase;
/** Last error, human readable. */
error: string | null;
@@ -285,6 +291,57 @@ export interface WindowControls {
onResized(handler: () => void): Promise<Unlisten>;
}
/** What a release could do right now (dev mode only). */
export interface ReleaseItem {
key: string;
title: string;
live: string | null;
commit: string | null;
/** true = the repo has moved past what is published; null = nothing published says. */
behind: boolean | null;
dirty: boolean;
note: string;
}
/** What a pack carries but a code release does not rebuild: the DB seed and the tile sets. */
export interface ReleaseData {
version: string;
/** The numbers describe the live pack, not just this PC's older copy. */
is_live: boolean;
db_snapshot: string | null;
db_age_days: number | null;
db_bytes: number;
tile_sets: number;
tile_bytes: number;
tile_datasets: string[];
/** The upstream extract the basemap was drawn from, `YYYYMMDD`. NOT a bake time: tiles
* record none anywhere, so there is no "how old are our tiles" to show. */
basemap_vintage: string | null;
basemap_vintage_days: number | null;
}
export interface ReleasePlan {
docker: boolean;
online: boolean;
scripts: boolean;
items: ReleaseItem[];
/** Why a release cannot run at all; null when it can. */
blocked: string | null;
/** Null when nothing is installed here, so there is no manifest to read it from. */
data: ReleaseData | null;
}
/** A slice of the running release's output: what the window has not shown yet. */
export interface ReleaseConsoleTail {
/** Index of the first line returned; higher than asked for when older lines were trimmed. */
from: number;
lines: string[];
total: number;
running: boolean;
/** Null while running; 0 means it finished, anything else means it failed. */
exit: number | null;
}
export const DEV_TARGETS = ["client", "web", "both"] as const;
export type DevTarget = (typeof DEV_TARGETS)[number];
@@ -292,6 +349,7 @@ export const MOCK_SCENARIOS = [
"first-run",
"update-required",
"ready",
"new-pc",
"offline-installed",
"offline-not-installed",
"installing",
@@ -299,6 +357,8 @@ export const MOCK_SCENARIOS = [
"sync-conflicts",
"error",
"dev",
"dev-dhp",
"dev-no-checkout",
] as const;
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
@@ -352,6 +412,11 @@ export interface Backend {
syncNow(): Promise<void>;
/** Take the tiles the DHP baked while this PC was away. */
syncTiles(): Promise<void>;
releasePlan(): Promise<ReleasePlan>;
releaseRun(password: string, withApi: boolean, withMcp: boolean, withWeb: boolean): Promise<void>;
releaseConsole(since: number): Promise<ReleaseConsoleTail>;
/** Rebuild the pack's data payload. Prepares inputs only; it publishes nothing. */
refreshRun(password: string, pgPassword: string, tileRoot: string): Promise<void>;
/** What the operator has to decide, oldest first. */
getSyncConflicts(): Promise<SyncConflict[]>;
/** Records one decision; resolves with what is left to decide. */
+4 -2
View File
@@ -2,14 +2,16 @@ import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
// Tauri loads the dev server from a fixed port and fails if it is taken, and
// rebuilds the Rust side itself, so Vite must not watch src-tauri.
// rebuilds the Rust side itself, so Vite must not watch src-tauri - nor the
// workspace's target/ at the repo root, whose DLLs the build holds locked
// (watching one crashes Vite with EBUSY).
export default defineConfig({
plugins: [svelte()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: { ignored: ["**/src-tauri/**"] },
watch: { ignored: ["**/src-tauri/**", "**/target/**"] },
},
envPrefix: ["VITE_", "TAURI_ENV_"],
build: {