This commit is contained in:
JIMMY_JFEXP
2026-10-02 13:06:05 +02:00
18 changed files with 2648 additions and 143 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)
+363 -25
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,6 +209,15 @@ 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
@@ -217,19 +237,36 @@ impl Inner {
// 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(),
@@ -254,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
@@ -409,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, 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(),
})
}
@@ -656,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
@@ -726,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
@@ -1491,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);
{
@@ -1506,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
}
@@ -1583,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 = {
@@ -1887,19 +2014,29 @@ impl Launcher {
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();
@@ -1910,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 {
@@ -1925,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
@@ -2265,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
@@ -2665,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};
+5
View File
@@ -10,6 +10,7 @@ mod localdb;
mod market;
mod nodejs;
mod prune;
mod release;
mod settings;
mod site;
mod stack;
@@ -56,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",
+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()]);
}
}
+246 -27
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,20 +309,42 @@ 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)
)
),
})
}
@@ -290,6 +356,11 @@ impl StackConfig {
/// 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)))
}
@@ -400,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)>,
}
@@ -440,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) }),
@@ -492,10 +566,10 @@ fn check_ports_free(own_db_running: bool, web: bool) -> Result<(), String> {
}
pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) -> Result<RunningStack, String> {
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 {
@@ -508,11 +582,31 @@ pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>)
}
async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> {
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));
@@ -559,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(
@@ -615,9 +701,18 @@ async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut Runnin
/// a failed launch, and the bare `--` matters - without it npm keeps the rest for itself
/// 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) -> Command {
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
@@ -631,8 +726,9 @@ fn web_command(dir: &Path, node_dir: &Path) -> Command {
.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.
.env("ODIAN_TILE_SERVER_URL", format!("http://127.0.0.1:{TILES_PORT}"))
// 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
}
@@ -670,7 +766,7 @@ async fn start_web(
format!("Node.js is not installed, and the web client runs on it. Install Node.js LTS (winget install {}), then launch again.", crate::nodejs::WINGET_ID),
));
};
let mut web = web_command(&dir, &node_dir);
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.
@@ -756,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();
@@ -1019,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![
@@ -1184,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()
@@ -1268,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")]);
@@ -1412,7 +1631,7 @@ mod tests {
// 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"), Path::new(r"C:\Program Files\nodejs"));
let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs"), &local_points());
let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
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.
@@ -1429,7 +1648,7 @@ mod tests {
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"));
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")
@@ -1443,7 +1662,7 @@ mod tests {
// 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);
let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), node, &local_points());
let path = cmd
.get_envs()
.find(|(key, _)| *key == "PATH")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher",
"version": "0.1.38",
"version": "0.1.45",
"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.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 190 KiB

+135 -77
View File
@@ -98,10 +98,17 @@
// 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 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.
@@ -111,6 +118,17 @@
{ 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 });
@@ -197,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)}
@@ -217,6 +243,14 @@
<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}
@@ -252,78 +286,6 @@
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
</p>
{/if}
{#if devOn}
<!-- 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 wrap">
<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 s?.online}
<!-- The stack is the install's, so its tiles and its data are still worth
refreshing from the DHP while there is a line to it. -->
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncTiles()}>Sync tiles</button>
<button class="btn-link" disabled={blocked} onclick={() => launcher.syncNow()}>Sync database</button>
{:else}
<span class="dev-offline mono">offline — tiles and data stay as they are</span>
{/if}
</div>
{#if !devRun}
<p class="dev-note">Name the folder that holds <code>ODIAN_Engine</code> and Launch runs from it.</p>
{:else if devUser}
<p class="dev-note attention">
Running as <strong>{devUser}</strong> — this PC will not sync with the DHP.
</p>
{/if}
</section>
{/if}
{#if unfinished}
<p class="secondary attention">
@@ -458,6 +420,99 @@
</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}
<p class="dev-note attention">
Running as <strong>{devUser}</strong> — this PC will not sync with the DHP.
</p>
{/if}
</section>
{/if}
{#if showNotice}
<div class="notice" role="alert">
<Icon name="alert" size={14} />
@@ -546,10 +601,6 @@
align-items: center;
gap: 0.4rem;
}
.dev-row.wrap {
flex-wrap: wrap;
row-gap: 0.3rem;
}
.dev-path {
flex: 1 1 auto;
min-width: 0;
@@ -561,6 +612,11 @@
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;
@@ -568,7 +624,6 @@
font-size: 0.8rem;
white-space: nowrap;
}
.dev-offline,
.dev-note {
font-size: 0.75rem;
color: var(--text-dim, #9aa3ad);
@@ -577,6 +632,9 @@
.dev-note code {
font-size: 0.72rem;
}
.dev-release {
font-weight: 600;
}
.action {
display: grid;
+21 -1
View File
@@ -15,7 +15,7 @@
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={art} alt="" draggable="false" />
</div>
@@ -71,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>
+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),
+92 -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,90 @@ 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;
+132 -3
View File
@@ -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,
@@ -288,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,
@@ -352,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");
@@ -766,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.";
+64
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];
@@ -300,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];
@@ -353,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. */