Compare commits
29 Commits
cf8f0d28a6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f69ba7c016 | |||
| 540f694f01 | |||
| ee2d56b705 | |||
| 53bba10b3e | |||
| 7a97afe046 | |||
| 9d4da39cb6 | |||
| 4450722428 | |||
| 167dbd6d3a | |||
| 30123e5576 | |||
| 6af563793b | |||
| dbe7d43387 | |||
| 9a3094bebc | |||
| 7843d48c8f | |||
| 3a9de4013f | |||
| c1a3bf03b8 | |||
| 064c1b9098 | |||
| 1c25891ab7 | |||
| e28101ada5 | |||
| a1cc4e086d | |||
| 8988ad8b06 | |||
| 3863809ae8 | |||
| feed0fa869 | |||
| 20227eda0e | |||
| e4aeb6794e | |||
| df2f30af17 | |||
| d780c12b71 | |||
| ffbb8fc9a5 | |||
| 52f77c814c | |||
| 58ebd1dded |
@@ -36,11 +36,25 @@ pub struct InstallOptions {
|
|||||||
pub tile_sets: Option<BTreeSet<String>>,
|
pub tile_sets: Option<BTreeSet<String>>,
|
||||||
/// The source files to install, by manifest path; `None` is all of them.
|
/// The source files to install, by manifest path; `None` is all of them.
|
||||||
pub src_paths: Option<BTreeSet<String>>,
|
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 {
|
impl Default for InstallOptions {
|
||||||
fn default() -> Self {
|
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;
|
stage_index += 1;
|
||||||
let keep = |s: &&TileSet| opts.tile_sets.as_ref().is_none_or(|sets| sets.contains(&format!("{}/{}", s.style, s.dataset)));
|
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() };
|
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()?;
|
check_cancel()?;
|
||||||
@@ -603,6 +617,7 @@ async fn copy_install_tiles(
|
|||||||
tiles: &TilesSection,
|
tiles: &TilesSection,
|
||||||
root: &Path,
|
root: &Path,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
|
recheck: &BTreeSet<String>,
|
||||||
) -> Result<Vec<TileSetSummary>> {
|
) -> Result<Vec<TileSetSummary>> {
|
||||||
let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
|
let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
|
||||||
let files: u64 = tiles.sets.iter().map(|s| s.files).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 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
|
// 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
|
// "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".
|
// already states costs minutes a set and can only ever conclude "identical" - unless
|
||||||
if set_recorded_complete(&to, set) {
|
// 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.begin_sub(format!("tiles {style}/{dataset}"), "files", set.files);
|
||||||
ctx.reporter.items_done(set.files);
|
ctx.reporter.items_done(set.files);
|
||||||
ctx.reporter.add_done_bytes(set.bytes);
|
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))
|
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() {
|
if let Some(root) = ctx.backend.install_root() {
|
||||||
let root = root.to_path_buf();
|
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 total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
|
||||||
let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).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));
|
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();
|
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);
|
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();
|
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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn progress_files_match_python_json_dumps() {
|
fn progress_files_match_python_json_dumps() {
|
||||||
let verified: BTreeSet<u64> = [2, 0, 1].into_iter().collect();
|
let verified: BTreeSet<u64> = [2, 0, 1].into_iter().collect();
|
||||||
|
|||||||
@@ -17,6 +17,37 @@ pub struct SignedIn {
|
|||||||
username: String,
|
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]
|
#[tauri::command]
|
||||||
pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
||||||
Ok(launcher.status().await)
|
Ok(launcher.status().await)
|
||||||
|
|||||||
+379
-25
@@ -19,7 +19,7 @@ use crate::market;
|
|||||||
use crate::prune;
|
use crate::prune;
|
||||||
use crate::settings::{normalize_url, Settings};
|
use crate::settings::{normalize_url, Settings};
|
||||||
use crate::site::{self, SessionRefresh, TokenCheck};
|
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::state::{self, LauncherState, SyncOrigin};
|
||||||
use crate::sync::{self, SyncError, SyncStatus};
|
use crate::sync::{self, SyncError, SyncStatus};
|
||||||
use crate::devmode::{self, Licence};
|
use crate::devmode::{self, Licence};
|
||||||
@@ -65,6 +65,13 @@ pub struct Status {
|
|||||||
pub available: Option<String>,
|
pub available: Option<String>,
|
||||||
pub update_required: bool,
|
pub update_required: bool,
|
||||||
pub can_launch: 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 phase: Phase,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub install_dir: String,
|
pub install_dir: String,
|
||||||
@@ -183,6 +190,10 @@ struct Inner {
|
|||||||
dev_refusal: Option<String>,
|
dev_refusal: Option<String>,
|
||||||
/// What the last count found on disk, kept for as long as nothing can have changed it.
|
/// What the last count found on disk, kept for as long as nothing can have changed it.
|
||||||
market_checked: Option<Vec<market::Check>>,
|
market_checked: Option<Vec<market::Check>>,
|
||||||
|
/// 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`).
|
/// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`).
|
||||||
portable: bool,
|
portable: bool,
|
||||||
/// A newer launcher is being checked for or downloaded (see `update_self`): nothing may
|
/// 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>>,
|
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 {
|
impl Inner {
|
||||||
/// Take `url` as the DHP's Engine API, and keep it in launcher.json so a launcher started
|
/// 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
|
/// 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.
|
// Online means "update before launch"; offline launches whatever is installed.
|
||||||
// Nothing installed is never "update required": that is a first install.
|
// 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;
|
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 {
|
Status {
|
||||||
online: self.online,
|
online: self.online,
|
||||||
site: SiteInfo { url: self.settings.site_url.clone(), name: self.settings.site_name.clone() },
|
site: SiteInfo { url: self.settings.site_url.clone(), name: self.settings.site_name.clone() },
|
||||||
operator: self.state.operator.clone(),
|
operator: self.state.operator.clone(),
|
||||||
// Remembered until Sign out: a launcher session renews the Engine token on its own.
|
// 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()),
|
signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()),
|
||||||
can_launch: installed.is_some()
|
can_launch: launchable(
|
||||||
&& !update_required
|
installed.is_some() || dhp_direct,
|
||||||
&& !self.updating_self
|
update_required,
|
||||||
&& matches!(self.phase, Phase::Idle | Phase::Error),
|
dev_run,
|
||||||
|
self.updating_self || !matches!(self.phase, Phase::Idle | Phase::Error),
|
||||||
|
),
|
||||||
installed,
|
installed,
|
||||||
available: self.available.clone(),
|
available: self.available.clone(),
|
||||||
update_required,
|
update_required,
|
||||||
|
dev_run,
|
||||||
|
dhp_direct,
|
||||||
|
dhp_blocked,
|
||||||
// Updating itself reads as "checking": the window already shows that at every start.
|
// 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 },
|
phase: if self.updating_self && matches!(self.phase, Phase::Idle | Phase::Error) { Phase::Checking } else { self.phase },
|
||||||
error: self.error.clone(),
|
error: self.error.clone(),
|
||||||
@@ -254,6 +291,8 @@ pub struct Launcher {
|
|||||||
stack_states: std::sync::Mutex<Vec<StackEvent>>,
|
stack_states: std::sync::Mutex<Vec<StackEvent>>,
|
||||||
sign_in_cancel: Notify,
|
sign_in_cancel: Notify,
|
||||||
install_cancel: std::sync::Mutex<Option<CancellationToken>>,
|
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
|
/// 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}"))?;
|
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
|
||||||
Ok(Launcher {
|
Ok(Launcher {
|
||||||
http,
|
http,
|
||||||
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url, 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: Mutex::new(None),
|
||||||
stack_states: std::sync::Mutex::new(Vec::new()),
|
stack_states: std::sync::Mutex::new(Vec::new()),
|
||||||
sign_in_cancel: Notify::new(),
|
sign_in_cancel: Notify::new(),
|
||||||
install_cancel: std::sync::Mutex::new(None),
|
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)
|
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.
|
/// Look for this PC's dev licence.
|
||||||
///
|
///
|
||||||
/// Cheap enough to do on every check - one small file - so dropping a licence in is
|
/// 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
|
/// 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
|
/// 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.
|
/// 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> {
|
pub async fn finish_market(&self, app: &AppHandle) -> Result<(), String> {
|
||||||
let (manifest, _, install_dir) = self.market_manifest(app).await?;
|
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);
|
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
|
/// 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> {
|
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 (manifest, installed, install_dir) = self.market_manifest(app).await?;
|
||||||
let extras = market::optional_keys(&manifest, selected);
|
let extras = market::optional_keys(&manifest, selected);
|
||||||
{
|
{
|
||||||
@@ -1506,13 +1615,17 @@ impl Launcher {
|
|||||||
}
|
}
|
||||||
let data = install_dir.join("data");
|
let data = install_dir.join("data");
|
||||||
let adds = market::to_add(&manifest, &data, &extras);
|
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;
|
self.emit(app).await;
|
||||||
return Ok(());
|
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());
|
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
|
self.start_run(app, None, true).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1583,21 +1696,35 @@ impl Launcher {
|
|||||||
install_dir: &Path,
|
install_dir: &Path,
|
||||||
cancel: &CancellationToken,
|
cancel: &CancellationToken,
|
||||||
) -> Result<String, InstallFailure> {
|
) -> Result<String, InstallFailure> {
|
||||||
let (installed, extras) = {
|
let (installed, extras, recheck) = {
|
||||||
let inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
(inner.state.installed_version.clone(), inner.state.extras.clone().unwrap_or_default())
|
// 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 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 verified = odian_packs::version_manifest(source, &installed, odian_packs::bundled_public_key()).await?;
|
||||||
let manifest = verified.manifest();
|
let manifest = verified.manifest();
|
||||||
let titles: Vec<String> =
|
let titles: Vec<String> =
|
||||||
market::to_add(manifest, &install_dir.join("data"), &extras).into_iter().map(|item| item.title).collect();
|
market::to_add(manifest, &install_dir.join("data"), &extras).into_iter().map(|item| item.title).collect();
|
||||||
|
if !titles.is_empty() {
|
||||||
self.log(app, "info", format!("Downloading {}", titles.join(", ")));
|
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 (tile_sets, src_paths) = market::install_filter(manifest, &extras, true);
|
||||||
let options = InstallOptions {
|
let options = InstallOptions {
|
||||||
only: vec![Stage::Src, Stage::Tiles],
|
only: vec![Stage::Src, Stage::Tiles],
|
||||||
tile_sets: Some(tile_sets),
|
tile_sets: Some(tile_sets),
|
||||||
src_paths: Some(src_paths),
|
src_paths: Some(src_paths),
|
||||||
|
recheck_tile_sets: recheck,
|
||||||
..InstallOptions::default()
|
..InstallOptions::default()
|
||||||
};
|
};
|
||||||
let progress = {
|
let progress = {
|
||||||
@@ -1887,19 +2014,29 @@ impl Launcher {
|
|||||||
let config = {
|
let config = {
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
let status = inner.status();
|
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());
|
return Err("An update is available: update before launching while connected to the DHP.".to_string());
|
||||||
}
|
}
|
||||||
if !status.can_launch {
|
if !status.can_launch {
|
||||||
return Err("ODIAN isn't ready to launch.".to_string());
|
return Err("ODIAN isn't ready to launch.".to_string());
|
||||||
}
|
}
|
||||||
let operator = inner
|
let dhp = Self::dhp_direct(&inner);
|
||||||
.state
|
// Nothing installed and no DHP-direct run available: say which of the two is missing,
|
||||||
.operator
|
// rather than "ODIAN is not installed" when the real problem is the target or the MCP.
|
||||||
.clone()
|
if dhp.is_none() && inner.state.installed_version.is_none() {
|
||||||
.ok_or("Sign in once while connected, so the launcher knows which operator to start ODIAN as.")?;
|
return Err(Self::why_no_dhp_run(&inner));
|
||||||
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() {
|
// 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());
|
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();
|
let install_dir = inner.settings.install_path();
|
||||||
@@ -1910,9 +2047,13 @@ impl Launcher {
|
|||||||
let layout = Layout::new(&install_dir, &version);
|
let layout = Layout::new(&install_dir, &version);
|
||||||
let dev = Self::dev_engine(&inner);
|
let dev = Self::dev_engine(&inner);
|
||||||
let dev_user = Self::dev_swap(&inner);
|
let dev_user = Self::dev_swap(&inner);
|
||||||
let tile_sets = stack::installed_tile_sets(&layout)?;
|
// A DHP-direct run has no installed version to read either list out of: this PC draws
|
||||||
// What the DHP can draw for this PC, beyond what it downloaded.
|
// nothing of its own, and the site draws everything.
|
||||||
let site_tile_sets = stack::version_tile_sets(&layout)?;
|
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.phase = Phase::Launching;
|
||||||
inner.error = None;
|
inner.error = None;
|
||||||
StackConfig {
|
StackConfig {
|
||||||
@@ -1925,6 +2066,7 @@ impl Launcher {
|
|||||||
dev_engine: dev,
|
dev_engine: dev,
|
||||||
dev_user: dev_user.clone(),
|
dev_user: dev_user.clone(),
|
||||||
dev_target: inner.settings.dev_target,
|
dev_target: inner.settings.dev_target,
|
||||||
|
dhp,
|
||||||
tile_sets,
|
tile_sets,
|
||||||
site_tile_sets,
|
site_tile_sets,
|
||||||
// A swapped run never reaches the DHP: no upstream token is written, so the local
|
// A swapped run never reaches the DHP: no upstream token is written, so the local
|
||||||
@@ -2164,7 +2306,23 @@ impl Launcher {
|
|||||||
// comes up on the DHP's data instead of switching a moment later.
|
// comes up on the DHP's data instead of switching a moment later.
|
||||||
let live_mode = config.upstream_url.is_some();
|
let live_mode = config.upstream_url.is_some();
|
||||||
if live_mode {
|
if live_mode {
|
||||||
let token = self.site_access_token(app).await.ok();
|
let token = match self.site_access_token(app).await {
|
||||||
|
Ok(token) => Some(token),
|
||||||
|
Err(why) => {
|
||||||
|
// Said out loud: the operator named in launcher.json travels with a drive, the
|
||||||
|
// sign-in (Credential Manager) does not. A drive moved to another PC launches
|
||||||
|
// there as its operator and quietly shows that PC's data - the API's log was the
|
||||||
|
// only place that said "no DHP sign-in yet".
|
||||||
|
self.log(
|
||||||
|
app,
|
||||||
|
"warn",
|
||||||
|
format!(
|
||||||
|
"No DHP sign-in on this PC ({why}): ODIAN shows this PC's own data - no live aircraft, no tiles from the site. Stop ODIAN and sign in; a sign-in is kept per PC, so a drive moved to another one needs it there too."
|
||||||
|
),
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
self.write_upstream_token(&config.layout, token.as_deref());
|
self.write_upstream_token(&config.layout, token.as_deref());
|
||||||
} else {
|
} else {
|
||||||
// No upstream means none: a token left behind by a run that ended without clearing
|
// No upstream means none: a token left behind by a run that ended without clearing
|
||||||
@@ -2249,6 +2407,174 @@ impl Launcher {
|
|||||||
Ok(())
|
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 ──────────────────────────────────────────────────
|
// ── operator-data sync ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Sync, unless this run is pretending to be another operator. Whatever it authored is
|
/// Sync, unless this run is pretending to be another operator. Whatever it authored is
|
||||||
@@ -2649,6 +2975,34 @@ fn swapped_user<'a>(asked: &'a str, operator: Option<&str>) -> Option<&'a str> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn a_dependency_added_since_the_last_install_is_noticed() {
|
fn a_dependency_added_since_the_last_install_is_noticed() {
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mod localdb;
|
|||||||
mod market;
|
mod market;
|
||||||
mod nodejs;
|
mod nodejs;
|
||||||
mod prune;
|
mod prune;
|
||||||
|
mod release;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod site;
|
mod site;
|
||||||
mod stack;
|
mod stack;
|
||||||
@@ -56,6 +57,10 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::get_status,
|
commands::get_status,
|
||||||
|
commands::release_plan,
|
||||||
|
commands::release_run,
|
||||||
|
commands::release_console,
|
||||||
|
commands::refresh_run,
|
||||||
commands::get_market,
|
commands::get_market,
|
||||||
commands::apply_market,
|
commands::apply_market,
|
||||||
commands::check_market,
|
commands::check_market,
|
||||||
|
|||||||
@@ -337,6 +337,17 @@ pub fn unfinished(
|
|||||||
.collect()
|
.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.
|
/// Whether the chosen optional items still need downloading.
|
||||||
pub fn to_add(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
pub fn to_add(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
||||||
items(manifest, data_dir, extras)
|
items(manifest, data_dir, extras)
|
||||||
@@ -389,6 +400,21 @@ pub fn remove_counting(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn manifest() -> Manifest {
|
||||||
serde_json::from_value(serde_json::json!({
|
serde_json::from_value(serde_json::json!({
|
||||||
"schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z",
|
"schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z",
|
||||||
|
|||||||
@@ -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
@@ -192,9 +192,13 @@ pub struct StackConfig {
|
|||||||
/// the site draws the ones this PC never downloaded. Without one it sees only
|
/// the site draws the ones this PC never downloaded. Without one it sees only
|
||||||
/// `tile_sets`, because nothing else could be drawn.
|
/// `tile_sets`, because nothing else could be drawn.
|
||||||
pub site_tile_sets: Vec<TileSet>,
|
pub site_tile_sets: Vec<TileSet>,
|
||||||
/// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT from, instead of the
|
/// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT, its API and its tile
|
||||||
/// installed version. Everything else is the install's - the database, the tiles, the
|
/// server from, instead of the installed version - see `odian_exe`, `api_exe` and
|
||||||
/// API, the tile server - so a dev run needs no download and shows real data.
|
/// `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
|
/// Only ever Some on a PC with a valid dev licence: the launcher will not fill it in
|
||||||
/// otherwise, whatever the settings file says.
|
/// 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
|
/// 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.
|
/// install has no web client to run, and only ever starts the one.
|
||||||
pub dev_target: DevTarget,
|
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 {
|
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 {
|
fn odian_exe(&self) -> PathBuf {
|
||||||
match &self.dev_engine {
|
match &self.dev_engine {
|
||||||
Some(_) => self.odian_dir().join("x64").join("MinGW-Release").join("Odian.exe"),
|
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()
|
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.
|
/// 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`
|
/// `?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
|
/// 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
|
/// 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.
|
/// 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> {
|
pub fn web_url(&self) -> Option<String> {
|
||||||
self.runs_web().then(|| {
|
self.runs_web().then(|| match &self.dhp {
|
||||||
format!(
|
Some(_) => format!("http://localhost:{WEB_PORT}/"),
|
||||||
|
None => format!(
|
||||||
"http://localhost:{}/?api={}&user={}",
|
"http://localhost:{}/?api={}&user={}",
|
||||||
WEB_PORT,
|
WEB_PORT,
|
||||||
url_value(&format!("http://127.0.0.1:{API_PORT}")),
|
url_value(&format!("http://127.0.0.1:{API_PORT}")),
|
||||||
url_value(&self.operator)
|
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;
|
/// browser: not in the request line, not in Vite's log. Never log this address;
|
||||||
/// `web_url` is the one to print.
|
/// `web_url` is the one to print.
|
||||||
pub fn web_url_signed(&self, token: &str) -> Option<String> {
|
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)))
|
self.web_url().map(|url| format!("{url}#token={}", url_value(token)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +471,8 @@ type Shared = Arc<Mutex<Child>>;
|
|||||||
|
|
||||||
pub struct RunningStack {
|
pub struct RunningStack {
|
||||||
job: job::Job,
|
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)>,
|
children: Vec<(Component, Shared)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +512,9 @@ impl RunningStack {
|
|||||||
}
|
}
|
||||||
emit(StackEvent::new(component, ComponentState::Stopped));
|
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 {
|
match run_blocking(move || pg.stop()).await {
|
||||||
Ok(()) => emit(StackEvent::new(Component::Postgres, ComponentState::Stopped)),
|
Ok(()) => emit(StackEvent::new(Component::Postgres, ComponentState::Stopped)),
|
||||||
Err(message) => emit(StackEvent { component: Component::Postgres, state: ComponentState::Failed, message: Some(message) }),
|
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> {
|
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
|
// Asks pg_ctl about this install's own data directory, so a database on the port that
|
||||||
// belongs to another install still reads as someone else's.
|
// belongs to another install still reads as someone else's.
|
||||||
check_ports_free(pg.running(), config.runs_web())?;
|
check_ports_free(pg.as_ref().is_some_and(Pg::running), config.runs_web())?;
|
||||||
prepare(config)?;
|
prepare(config)?;
|
||||||
let mut running = RunningStack { job: job::Job::new()?, pg, children: Vec::new() };
|
let mut running = RunningStack { job: job::Job::new()?, pg, children: Vec::new() };
|
||||||
match start_all(http, config, emit, &mut running).await {
|
match start_all(http, config, emit, &mut running).await {
|
||||||
@@ -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> {
|
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 layout = &config.layout;
|
||||||
let state = layout.state_dir();
|
let state = layout.state_dir();
|
||||||
|
|
||||||
emit(StackEvent::new(Component::Postgres, ComponentState::Starting));
|
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))?;
|
run_blocking(move || pg.start()).await.map_err(|e| failed(emit, Component::Postgres, e))?;
|
||||||
emit(StackEvent::new(Component::Postgres, ComponentState::Ready));
|
emit(StackEvent::new(Component::Postgres, ComponentState::Ready));
|
||||||
|
|
||||||
@@ -559,21 +653,13 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>,
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?;
|
.map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?;
|
||||||
emit(StackEvent::new(Component::Tiles, ComponentState::Ready));
|
emit(StackEvent::new(Component::Tiles, ComponentState::Ready));
|
||||||
|
|
||||||
if config.runs_client() {
|
|
||||||
start_client(config, emit, running).await?;
|
|
||||||
}
|
|
||||||
if config.runs_web() {
|
|
||||||
start_web(http, config, emit, running).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Odian.exe, in its own folder of writable state, from the install or the checkout.
|
/// 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> {
|
async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> {
|
||||||
let state = config.layout.state_dir();
|
|
||||||
emit(StackEvent::new(Component::Odian, ComponentState::Starting));
|
emit(StackEvent::new(Component::Odian, ComponentState::Starting));
|
||||||
let client_dir = state.join("client");
|
let client_dir = config.client_dir();
|
||||||
let odian_exe = config.odian_exe();
|
let odian_exe = config.odian_exe();
|
||||||
if config.dev_engine.is_some() && !odian_exe.is_file() {
|
if config.dev_engine.is_some() && !odian_exe.is_file() {
|
||||||
return Err(failed(
|
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
|
/// 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
|
/// 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.
|
/// 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");
|
let mut web = Command::new("cmd");
|
||||||
web.env("PATH", crate::nodejs::path_with(node_dir));
|
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
|
// --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.
|
// the launcher opens - and prints - would then be somebody else's server.
|
||||||
web
|
web
|
||||||
@@ -631,8 +726,9 @@ fn web_command(dir: &Path, node_dir: &Path) -> Command {
|
|||||||
.arg("--strictPort")
|
.arg("--strictPort")
|
||||||
// The map through THIS stack's tile server, as the .exe draws it: the site while it
|
// 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
|
// 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.
|
// to the public tile host, and an offline run has no map at all. A DHP-direct run has
|
||||||
.env("ODIAN_TILE_SERVER_URL", format!("http://127.0.0.1:{TILES_PORT}"))
|
// no tile server of its own, so it names the site's.
|
||||||
|
.env("ODIAN_TILE_SERVER_URL", points.tiles.clone())
|
||||||
.current_dir(dir);
|
.current_dir(dir);
|
||||||
web
|
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),
|
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);
|
give_console(&mut web);
|
||||||
let child = running.spawn(Component::Web, &mut web).map_err(|e| failed(emit, Component::Web, e))?;
|
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.
|
// 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> {
|
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 layout = &config.layout;
|
||||||
let state = layout.state_dir();
|
let state = layout.state_dir();
|
||||||
let data = layout.data_dir();
|
let data = layout.data_dir();
|
||||||
@@ -1019,7 +1121,58 @@ fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
|||||||
env
|
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)> {
|
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 client = config.layout.state_dir().join("client");
|
||||||
let tiles_url = format!("http://127.0.0.1:{TILES_PORT}");
|
let tiles_url = format!("http://127.0.0.1:{TILES_PORT}");
|
||||||
let mut env = vec![
|
let mut env = vec![
|
||||||
@@ -1184,6 +1337,7 @@ mod tests {
|
|||||||
dev_engine: None,
|
dev_engine: None,
|
||||||
dev_user: None,
|
dev_user: None,
|
||||||
dev_target: DevTarget::default(),
|
dev_target: DevTarget::default(),
|
||||||
|
dhp: None,
|
||||||
site_url: "https://dhp.example/dhp".to_string(),
|
site_url: "https://dhp.example/dhp".to_string(),
|
||||||
site_tile_sets: tile_sets
|
site_tile_sets: tile_sets
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1268,6 +1422,71 @@ mod tests {
|
|||||||
assert!(value(&env, "ODIAN_HEIGHT_SERVER_URL").is_none());
|
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]
|
#[test]
|
||||||
fn api_and_tile_server_are_loopback_and_cache_only() {
|
fn api_and_tile_server_are_loopback_and_cache_only() {
|
||||||
let config = config(&[("dark", "protomaps_20260423")]);
|
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
|
// 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
|
// 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.
|
// 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();
|
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"]);
|
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.
|
// 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() {
|
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
|
// 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.
|
// 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
|
let tiles = cmd
|
||||||
.get_envs()
|
.get_envs()
|
||||||
.find(|(key, _)| *key == "ODIAN_TILE_SERVER_URL")
|
.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
|
// 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.
|
// runs with the found folder first, so the launch goes on without a restart.
|
||||||
let node = Path::new(r"C:\Program Files\nodejs");
|
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
|
let path = cmd
|
||||||
.get_envs()
|
.get_envs()
|
||||||
.find(|(key, _)| *key == "PATH")
|
.find(|(key, _)| *key == "PATH")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "ODIAN Launcher",
|
"productName": "ODIAN Launcher",
|
||||||
"version": "0.1.38",
|
"version": "0.1.51",
|
||||||
"identifier": "fr.odian.launcher",
|
"identifier": "fr.odian.launcher",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
+24
-1
@@ -6,6 +6,8 @@
|
|||||||
import KeyArt from "./components/KeyArt.svelte";
|
import KeyArt from "./components/KeyArt.svelte";
|
||||||
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
|
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
|
||||||
import NewsPanel from "./components/NewsPanel.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 SettingsDrawer from "./components/SettingsDrawer.svelte";
|
||||||
import SyncPanel from "./components/SyncPanel.svelte";
|
import SyncPanel from "./components/SyncPanel.svelte";
|
||||||
import StatusBar from "./components/StatusBar.svelte";
|
import StatusBar from "./components/StatusBar.svelte";
|
||||||
@@ -14,6 +16,13 @@
|
|||||||
|
|
||||||
let settingsOpen = $state(false);
|
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());
|
onMount(() => launcher.start());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -21,8 +30,12 @@
|
|||||||
<KeyArt />
|
<KeyArt />
|
||||||
<TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
|
<TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
|
||||||
|
|
||||||
<main class="hub">
|
<main class="hub" class:dev={dev && !showConsole}>
|
||||||
|
{#if !dev}
|
||||||
<div class="news-slot"><NewsPanel /></div>
|
<div class="news-slot"><NewsPanel /></div>
|
||||||
|
{:else if showConsole}
|
||||||
|
<div class="news-slot"><ReleaseConsole /></div>
|
||||||
|
{/if}
|
||||||
<div class="hero-slot"><Hero /></div>
|
<div class="hero-slot"><Hero /></div>
|
||||||
<div class="action-slot"><ActionPanel /></div>
|
<div class="action-slot"><ActionPanel /></div>
|
||||||
</main>
|
</main>
|
||||||
@@ -37,6 +50,10 @@
|
|||||||
<div class="sync-slot"><SyncPanel /></div>
|
<div class="sync-slot"><SyncPanel /></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if launcher.releaseOpen}
|
||||||
|
<div class="sync-slot"><ReleasePanel /></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<SettingsDrawer bind:open={settingsOpen} />
|
<SettingsDrawer bind:open={settingsOpen} />
|
||||||
<MarketplaceDrawer />
|
<MarketplaceDrawer />
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +86,12 @@
|
|||||||
min-height: 0;
|
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 {
|
.hero-slot {
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
grid-column: 2 / span 2;
|
grid-column: 2 / span 2;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 190 KiB |
@@ -98,10 +98,17 @@
|
|||||||
// like the real one on screen, and mistaking the two is how a bug gets chased in the
|
// 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.
|
// wrong tree - or a demo gets given from an unbuilt branch.
|
||||||
const devOn = $derived(launcher.devOn);
|
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 devRun = $derived(launcher.devReady);
|
||||||
const devRoot = $derived(launcher.settings?.dev_root ?? "");
|
const devRoot = $derived(launcher.settings?.dev_root ?? "");
|
||||||
const devTarget = $derived(launcher.settings?.dev_target ?? "client");
|
const devTarget = $derived(launcher.settings?.dev_target ?? "client");
|
||||||
const devBuild = $derived(launcher.settings?.dev_build ?? false);
|
const devBuild = $derived(launcher.settings?.dev_build ?? false);
|
||||||
|
const devUserField = $derived(launcher.settings?.dev_user ?? "");
|
||||||
const savingDev = $derived(busy === "settings");
|
const savingDev = $derived(busy === "settings");
|
||||||
// One stack either way: both clients talk to the same API and the same database, which is
|
// 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.
|
// the only way a .exe/web divergence is visible at all.
|
||||||
@@ -111,6 +118,17 @@
|
|||||||
{ key: "both", name: "Both", note: "one stack" },
|
{ key: "both", name: "Both", note: "one stack" },
|
||||||
] as const;
|
] 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() {
|
async function browseCheckout() {
|
||||||
const path = await launcher.pickFolder();
|
const path = await launcher.pickFolder();
|
||||||
if (path) await launcher.setDev({ dev_root: path });
|
if (path) await launcher.setDev({ dev_root: path });
|
||||||
@@ -197,6 +215,14 @@
|
|||||||
<p class="secondary">
|
<p class="secondary">
|
||||||
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
|
ODIAN <strong>{s?.available}</strong> from {site}, into <span class="mono path">{s?.install_dir}</span>.
|
||||||
</p>
|
</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?")}
|
{@render driveLink("Have an ODIAN drive?")}
|
||||||
{:else if action === "offline_unavailable"}
|
{:else if action === "offline_unavailable"}
|
||||||
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
|
{@render primary("Connect to your DHP to install", "globe", () => {}, true)}
|
||||||
@@ -217,12 +243,34 @@
|
|||||||
<p class="secondary">
|
<p class="secondary">
|
||||||
{#if checking}
|
{#if checking}
|
||||||
Checking {site}…
|
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}
|
{:else if s?.online}
|
||||||
ODIAN <strong>{s.installed}</strong> is up to date.
|
ODIAN <strong>{s.installed}</strong> is up to date.
|
||||||
{:else}
|
{:else}
|
||||||
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
|
Offline: ODIAN <strong>{s?.installed}</strong> runs on local data and map tiles.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
{#if s?.online && !s.signed_in && !checking}
|
||||||
|
<!-- The operator is remembered with the install (launcher.json travels with a drive),
|
||||||
|
the DHP sign-in is not (Credential Manager, per PC). So a drive on another PC offers
|
||||||
|
Launch as if nothing were missing, and ODIAN then runs on that PC's own data: no live
|
||||||
|
aircraft, no tiles from the site. Launch stays the button - a sign-in that cannot
|
||||||
|
complete must not stop ODIAN - but the gap is said here, with the way out. -->
|
||||||
|
<p class="secondary attention">
|
||||||
|
<Icon name="alert" size={14} />
|
||||||
|
<span>
|
||||||
|
Not signed in on this PC: ODIAN will run on this PC's own data, without live aircraft.
|
||||||
|
<button class="btn-link" disabled={blocked} onclick={() => launcher.signIn()}>Sign in</button>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#if launcher.syncAttention > 0}
|
{#if launcher.syncAttention > 0}
|
||||||
<p class="secondary attention">
|
<p class="secondary attention">
|
||||||
<Icon name="alert" size={14} />
|
<Icon name="alert" size={14} />
|
||||||
@@ -238,78 +286,6 @@
|
|||||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
|
<button class="btn-link" disabled={blocked} onclick={() => launcher.openSync()}>Sync</button>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if 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}
|
{#if unfinished}
|
||||||
<p class="secondary attention">
|
<p class="secondary attention">
|
||||||
@@ -444,6 +420,102 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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 & deploy…
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if hasOutput && !releasing}
|
||||||
|
<button class="btn-link" onclick={() => launcher.showConsole()}>last output</button>
|
||||||
|
{:else if !s?.online}
|
||||||
|
<span class="dev-note">needs the DHP</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="dev-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={devBuild}
|
||||||
|
disabled={blocked || savingDev}
|
||||||
|
onchange={(e) => launcher.setDev({ dev_build: e.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span>Build first</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if !devRun}
|
||||||
|
<p class="dev-note">Name the folder that holds <code>ODIAN_Engine</code> and Launch runs from it.</p>
|
||||||
|
{:else if devUser}
|
||||||
|
<!-- One line, and ONE flex child: `.attention` is display:flex, so loose text and
|
||||||
|
<strong> each become a column. "no live data" earns its place - the old wording
|
||||||
|
said only "no sync", and the missing tracks were hunted through the whole stack. -->
|
||||||
|
<p class="dev-note attention">
|
||||||
|
<span>Running as <strong>{devUser}</strong> — no sync, no live data.</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if showNotice}
|
{#if showNotice}
|
||||||
<div class="notice" role="alert">
|
<div class="notice" role="alert">
|
||||||
<Icon name="alert" size={14} />
|
<Icon name="alert" size={14} />
|
||||||
@@ -532,10 +604,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.dev-row.wrap {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
row-gap: 0.3rem;
|
|
||||||
}
|
|
||||||
.dev-path {
|
.dev-path {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -547,6 +615,11 @@
|
|||||||
border: 1px solid var(--line, #2a3139);
|
border: 1px solid var(--line, #2a3139);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
.dev-label {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-dim, #9aa3ad);
|
||||||
|
}
|
||||||
.dev-check {
|
.dev-check {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -554,7 +627,6 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.dev-offline,
|
|
||||||
.dev-note {
|
.dev-note {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-dim, #9aa3ad);
|
color: var(--text-dim, #9aa3ad);
|
||||||
@@ -563,6 +635,9 @@
|
|||||||
.dev-note code {
|
.dev-note code {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
.dev-release {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.action {
|
.action {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
const art = $derived(launcher.devOn ? keyartDev : keyart);
|
const art = $derived(launcher.devOn ? keyartDev : keyart);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="keyart" aria-hidden="true">
|
<div class="keyart" class:dev={launcher.devOn} aria-hidden="true">
|
||||||
<div class="frame">
|
<div class="frame">
|
||||||
<img class="art" src={art} alt="" draggable="false" />
|
<img class="art" src={art} alt="" draggable="false" />
|
||||||
</div>
|
</div>
|
||||||
@@ -71,9 +71,29 @@
|
|||||||
radial-gradient(120% 100% at 62% 50%, transparent 58%, rgba(9, 9, 11, 0.5) 100%);
|
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) {
|
@media (max-width: 1140px) {
|
||||||
.frame {
|
.frame {
|
||||||
left: 384px;
|
left: 384px;
|
||||||
}
|
}
|
||||||
|
.keyart.dev .frame {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 & 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>
|
||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
SignInResult,
|
SignInResult,
|
||||||
StackEvent,
|
StackEvent,
|
||||||
Status,
|
Status,
|
||||||
|
ReleaseConsoleTail,
|
||||||
|
ReleasePlan,
|
||||||
RemoveProgress,
|
RemoveProgress,
|
||||||
SyncConflict,
|
SyncConflict,
|
||||||
TileSyncProgress,
|
TileSyncProgress,
|
||||||
@@ -70,6 +72,11 @@ function createTauriBackend(): Backend {
|
|||||||
|
|
||||||
onStatus: (handler) => subscribe<Status>("status", handler),
|
onStatus: (handler) => subscribe<Status>("status", handler),
|
||||||
onInstallProgress: (handler) => subscribe<InstallProgress>("install-progress", 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),
|
onTileSyncProgress: (handler) => subscribe<TileSyncProgress>("tile-sync-progress", handler),
|
||||||
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
|
onRemoveProgress: (handler) => subscribe<RemoveProgress>("remove-progress", handler),
|
||||||
onStack: (handler) => subscribe<StackEvent>("stack", handler),
|
onStack: (handler) => subscribe<StackEvent>("stack", handler),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
LogLevel,
|
LogLevel,
|
||||||
Market,
|
Market,
|
||||||
Phase,
|
Phase,
|
||||||
|
ReleasePlan,
|
||||||
Settings,
|
Settings,
|
||||||
StackComponent,
|
StackComponent,
|
||||||
StackEvent,
|
StackEvent,
|
||||||
@@ -71,10 +72,16 @@ export function deriveAction(s: Status | null): ActionKind {
|
|||||||
}
|
}
|
||||||
// idle or checking
|
// idle or checking
|
||||||
if (s.installed === null) {
|
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";
|
if (!s.online) return "offline_unavailable";
|
||||||
return s.signed_in ? "install" : "sign_in";
|
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.
|
// 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";
|
if (s.operator === null) return s.online ? "sign_in" : "signed_out_offline";
|
||||||
return "launch";
|
return "launch";
|
||||||
@@ -402,6 +409,90 @@ class Launcher {
|
|||||||
return this.launch();
|
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. */
|
/** Change part of the settings and save, for the dev controls on the hub. */
|
||||||
setDev(change: Partial<Settings>) {
|
setDev(change: Partial<Settings>) {
|
||||||
const current = this.settings;
|
const current = this.settings;
|
||||||
|
|||||||
+138
-4
@@ -1,7 +1,7 @@
|
|||||||
// Browser-only stand-in for the Rust backend, so the hub can be designed with
|
// Browser-only stand-in for the Rust backend, so the hub can be designed with
|
||||||
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
|
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
|
||||||
//
|
//
|
||||||
// ?scenario=first-run | update-required | ready | offline-installed |
|
// ?scenario=first-run | update-required | ready | new-pc | offline-installed |
|
||||||
// offline-not-installed | installing | running | sync-conflicts | error |
|
// offline-not-installed | installing | running | sync-conflicts | error |
|
||||||
// dev (a licensed developer PC: the Dev section of Settings appears)
|
// dev (a licensed developer PC: the Dev section of Settings appears)
|
||||||
// &fail=sign_in | install | restore | launch | notes make that flow fail
|
// &fail=sign_in | install | restore | launch | notes make that flow fail
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
type MarketItem,
|
type MarketItem,
|
||||||
type MockControls,
|
type MockControls,
|
||||||
type MockScenario,
|
type MockScenario,
|
||||||
|
type ReleaseConsoleTail,
|
||||||
|
type ReleasePlan,
|
||||||
type Settings,
|
type Settings,
|
||||||
type SignInResult,
|
type SignInResult,
|
||||||
type StackComponent,
|
type StackComponent,
|
||||||
@@ -224,7 +226,8 @@ function derive(s: Status): Status {
|
|||||||
available,
|
available,
|
||||||
signed_in: s.online && s.signed_in,
|
signed_in: s.online && s.signed_in,
|
||||||
update_required,
|
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,
|
available: CURRENT,
|
||||||
update_required: false,
|
update_required: false,
|
||||||
can_launch: false,
|
can_launch: false,
|
||||||
|
dev_run: false,
|
||||||
|
dhp_direct: false,
|
||||||
|
dhp_blocked: null,
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
error: null,
|
error: null,
|
||||||
install_dir: DEFAULT_DIR,
|
install_dir: DEFAULT_DIR,
|
||||||
@@ -259,6 +265,11 @@ function initialStatus(scenario: MockScenario): Status {
|
|||||||
case "ready":
|
case "ready":
|
||||||
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
|
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
|
||||||
break;
|
break;
|
||||||
|
case "new-pc":
|
||||||
|
// A drive on a PC it has never been on: the install and its operator came with it,
|
||||||
|
// the DHP sign-in (per PC) did not. Online, launchable, and not signed in.
|
||||||
|
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: false });
|
||||||
|
break;
|
||||||
case "offline-installed":
|
case "offline-installed":
|
||||||
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
|
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
|
||||||
break;
|
break;
|
||||||
@@ -283,12 +294,41 @@ function initialStatus(scenario: MockScenario): Status {
|
|||||||
// A licensed PC, already set up to run from a checkout, so the Dev section and the
|
// 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.
|
// dev-run marker can be seen without a licence file.
|
||||||
Object.assign(s, {
|
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,
|
operator: OPERATOR,
|
||||||
signed_in: true,
|
signed_in: true,
|
||||||
|
dev_run: true,
|
||||||
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
|
dev: { machine: "ODIAN-DEV-01", install_id: null, expires_at: "2027-01-31", note: "test kit" },
|
||||||
});
|
});
|
||||||
break;
|
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":
|
case "error":
|
||||||
Object.assign(s, {
|
Object.assign(s, {
|
||||||
operator: OPERATOR,
|
operator: OPERATOR,
|
||||||
@@ -347,7 +387,8 @@ class MockBackend implements Backend {
|
|||||||
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR, dev_root: "", dev_mode: false, dev_user: "", dev_build: false, dev_target: "client" };
|
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
|
// 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.
|
// 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) {
|
if (this.status.installed) {
|
||||||
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
||||||
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
||||||
@@ -761,6 +802,99 @@ class MockBackend implements Backend {
|
|||||||
return asked && asked !== this.status.operator ? asked : null;
|
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> {
|
async syncNow(): Promise<void> {
|
||||||
if (!this.status.online) throw "Your DHP is unreachable: changes stay on this PC until the next connected sync.";
|
if (!this.status.online) throw "Your DHP is unreachable: changes stay on this PC until the next connected sync.";
|
||||||
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: sync once it has stopped.";
|
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: sync once it has stopped.";
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ export interface Status {
|
|||||||
update_required: boolean;
|
update_required: boolean;
|
||||||
/** installed && !update_required && phase is idle. */
|
/** installed && !update_required && phase is idle. */
|
||||||
can_launch: boolean;
|
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;
|
phase: Phase;
|
||||||
/** Last error, human readable. */
|
/** Last error, human readable. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -285,6 +291,57 @@ export interface WindowControls {
|
|||||||
onResized(handler: () => void): Promise<Unlisten>;
|
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 const DEV_TARGETS = ["client", "web", "both"] as const;
|
||||||
export type DevTarget = (typeof DEV_TARGETS)[number];
|
export type DevTarget = (typeof DEV_TARGETS)[number];
|
||||||
|
|
||||||
@@ -292,6 +349,7 @@ export const MOCK_SCENARIOS = [
|
|||||||
"first-run",
|
"first-run",
|
||||||
"update-required",
|
"update-required",
|
||||||
"ready",
|
"ready",
|
||||||
|
"new-pc",
|
||||||
"offline-installed",
|
"offline-installed",
|
||||||
"offline-not-installed",
|
"offline-not-installed",
|
||||||
"installing",
|
"installing",
|
||||||
@@ -299,6 +357,8 @@ export const MOCK_SCENARIOS = [
|
|||||||
"sync-conflicts",
|
"sync-conflicts",
|
||||||
"error",
|
"error",
|
||||||
"dev",
|
"dev",
|
||||||
|
"dev-dhp",
|
||||||
|
"dev-no-checkout",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
|
export type MockScenario = (typeof MOCK_SCENARIOS)[number];
|
||||||
@@ -352,6 +412,11 @@ export interface Backend {
|
|||||||
syncNow(): Promise<void>;
|
syncNow(): Promise<void>;
|
||||||
/** Take the tiles the DHP baked while this PC was away. */
|
/** Take the tiles the DHP baked while this PC was away. */
|
||||||
syncTiles(): Promise<void>;
|
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. */
|
/** What the operator has to decide, oldest first. */
|
||||||
getSyncConflicts(): Promise<SyncConflict[]>;
|
getSyncConflicts(): Promise<SyncConflict[]>;
|
||||||
/** Records one decision; resolves with what is left to decide. */
|
/** Records one decision; resolves with what is left to decide. */
|
||||||
|
|||||||
Reference in New Issue
Block a user