diff --git a/crates/odian-packs/src/install.rs b/crates/odian-packs/src/install.rs index ae1c8cd..991f16f 100644 --- a/crates/odian-packs/src/install.rs +++ b/crates/odian-packs/src/install.rs @@ -36,11 +36,25 @@ pub struct InstallOptions { pub tile_sets: Option>, /// The source files to install, by manifest path; `None` is all of them. pub src_paths: Option>, + /// 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, } impl Default for InstallOptions { fn default() -> Self { - InstallOptions { workers: 6, only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles], db_paths: None, tile_sets: None, src_paths: None } + InstallOptions { + workers: 6, + only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles], + db_paths: None, + tile_sets: None, + src_paths: None, + recheck_tile_sets: BTreeSet::new(), + } } } @@ -179,7 +193,7 @@ where stage_index += 1; let keep = |s: &&TileSet| opts.tile_sets.as_ref().is_none_or(|sets| sets.contains(&format!("{}/{}", s.style, s.dataset))); let tiles = TilesSection { sets: tiles.sets.iter().filter(keep).cloned().collect(), ..tiles.clone() }; - summary.tiles = Some(install_tiles(&ctx, stage_index, &version, &tiles, &data_dir).await?); + summary.tiles = Some(install_tiles(&ctx, stage_index, &version, &tiles, &data_dir, &opts.recheck_tile_sets).await?); } check_cancel()?; @@ -603,6 +617,7 @@ async fn copy_install_tiles( tiles: &TilesSection, root: &Path, data_dir: &Path, + recheck: &BTreeSet, ) -> Result> { let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum(); let files: u64 = tiles.sets.iter().map(|s| s.files).sum(); @@ -615,8 +630,10 @@ async fn copy_install_tiles( // This PC already recorded the very same set as complete: `.blocks-done.json` names // this index and all of its blocks, the record the marketplace itself reads to say // "Installed". Hashing its hundreds of thousands of files to learn what the marker - // already states costs minutes a set and can only ever conclude "identical". - if set_recorded_complete(&to, set) { + // already states costs minutes a set and can only ever conclude "identical" - unless + // the caller has counted the set short, when the copy below (which leaves identical + // files alone) is the repair. + if set_recorded_complete(&to, set) && !recheck.contains(&format!("{style}/{dataset}")) { ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "files", set.files); ctx.reporter.items_done(set.files); ctx.reporter.add_done_bytes(set.bytes); @@ -753,10 +770,59 @@ fn copy_checked(from: &Path, to: &Path, reporter: &Reporter, cancel: &Cancellati Ok((size, true)) } -async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &TilesSection, data_dir: &Path) -> Result> { +/// 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, 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, +) -> Result> { if let Some(root) = ctx.backend.install_root() { let root = root.to_path_buf(); - return copy_install_tiles(ctx, stage_index, tiles, &root, data_dir).await; + return copy_install_tiles(ctx, stage_index, tiles, &root, data_dir, recheck).await; } let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum(); let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).sum(); @@ -787,6 +853,16 @@ async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &T done.extend(list.iter().filter_map(Value::as_str).map(str::to_string)); } } + if !done.is_empty() && recheck.contains(&format!("{style}/{dataset}")) { + ctx.reporter.begin_sub(format!("tiles {style}/{dataset}: counting what is on disk"), "", 0); + let dir = set_dir.clone(); + let on_disk = blocking(move || Ok(tiles_on_disk_per_block(&dir))).await?; + if forget_short_blocks(&mut done, &index.blocks, &on_disk) > 0 { + // Written now, not when the blocks land: a run stopped before then must leave + // a set that reads as partial, so the next one resumes instead of trusting it. + atomic_write(&done_path, blocks_done_json(&set.index_sha256, &done).as_bytes())?; + } + } let pending: Vec = index.blocks.iter().filter(|b| !done.contains(&b.done_key())).cloned().collect(); ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "blocks", index.blocks.len() as u64); let skipped_bytes: u64 = index.blocks.iter().filter(|b| done.contains(&b.done_key())).map(|b| b.bytes).sum(); @@ -854,6 +930,42 @@ async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &T mod tests { use super::*; + fn block(z: u64, bx: u64, by: u64, files: u64, digest: &str) -> IndexBlock { + IndexBlock { z, bx, by, files, bytes: files * 10, digest: digest.to_string() } + } + + #[test] + fn a_block_the_disk_holds_short_of_is_fetched_again() { + let dir = std::env::temp_dir().join(format!("odian-recheck-{}", crate::copy::unique_hex())); + // Block (8, 2, 1) is x 128..=191, y 64..=127: two of its three tiles are here, plus a + // tile server negative and the installer's marker, neither of which is a tile. + // Block (8, 0, 0) has its one tile and one this PC baked itself: more is still whole. + for rel in ["8/128/64.png", "8/129/64.png", "8/129/65.png.missing", "8/1/1.png", "8/2/2.png"] { + let path = dir.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"t").unwrap(); + } + std::fs::write(dir.join(DONE_MARKER), b"{}").unwrap(); + + let on_disk = tiles_on_disk_per_block(&dir); + assert_eq!(on_disk.get(&(8, 2, 1)), Some(&2)); + assert_eq!(on_disk.get(&(8, 0, 0)), Some(&2)); + + let blocks = [block(8, 2, 1, 3, "aa"), block(8, 0, 0, 1, "bb"), block(9, 0, 0, 4, "cc")]; + let mut done: BTreeSet = 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::>()); + // A block that was never marked done is not "forgotten": it was already pending. + assert_eq!(forget_short_blocks(&mut done, &blocks, &on_disk), 0); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn an_empty_or_missing_folder_counts_nothing() { + assert!(tiles_on_disk_per_block(Path::new(r"Z:\no\such\set")).is_empty()); + } + #[test] fn progress_files_match_python_json_dumps() { let verified: BTreeSet = [2, 0, 1].into_iter().collect(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5508027..b889be9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -17,6 +17,37 @@ pub struct SignedIn { username: String, } +#[tauri::command] +pub async fn release_plan(launcher: State<'_, Launcher>) -> CmdResult { + 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 { + Ok(launcher.release_console(since)) +} + #[tauri::command] pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult { Ok(launcher.status().await) diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index 0399158..1c869ac 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -19,7 +19,7 @@ use crate::market; use crate::prune; use crate::settings::{normalize_url, Settings}; use crate::site::{self, SessionRefresh, TokenCheck}; -use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent}; +use crate::stack::{self, DhpDirect, Layout, RunningStack, StackConfig, StackEvent}; use crate::state::{self, LauncherState, SyncOrigin}; use crate::sync::{self, SyncError, SyncStatus}; use crate::devmode::{self, Licence}; @@ -65,6 +65,13 @@ pub struct Status { pub available: Option, pub update_required: bool, pub can_launch: bool, + /// This launch would come from the dev checkout. It is what lets an out-of-date install + /// still launch: see `can_launch`. + pub dev_run: bool, + /// This launch would start the client alone, against the DHP, with nothing installed. + pub dhp_direct: bool, + /// On a licensed PC with nothing installed, why that is not on offer. None when it is. + pub dhp_blocked: Option, pub phase: Phase, pub error: Option, pub install_dir: String, @@ -183,6 +190,10 @@ struct Inner { dev_refusal: Option, /// What the last count found on disk, kept for as long as nothing can have changed it. market_checked: Option>, + /// 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, /// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`). portable: bool, /// A newer launcher is being checked for or downloaded (see `update_self`): nothing may @@ -198,6 +209,15 @@ struct Inner { drive_extras: Option>, } +/// Whether Launch may go ahead. +/// +/// The only case allowed to be out of date is a dev run: it starts the client from the +/// checkout, and the install it sits on just supplies the data layer, which an older pack +/// serves perfectly well. Every other PC updates first. +fn launchable(installed: bool, update_required: bool, dev_run: bool, busy: bool) -> bool { + installed && (!update_required || dev_run) && !busy +} + impl Inner { /// Take `url` as the DHP's Engine API, and keep it in launcher.json so a launcher started /// offline next time still arms live mode with it. Err is only the save failing: the @@ -217,19 +237,36 @@ impl Inner { // Online means "update before launch"; offline launches whatever is installed. // Nothing installed is never "update required": that is a first install. let update_required = self.online && installed.is_some() && self.available.is_some() && installed != self.available; + // A dev run takes its CLIENT from the checkout; the install only supplies the data + // layer - database, tiles, API, tile server - and an older pack serves that perfectly + // well. So a newer pack on the DHP is news, not a gate. Without this, cutting a release + // stopped every dev PC until it downloaded a 900 MB pack it was not going to run. + let dev_run = Launcher::dev_engine(self).is_some(); + // With nothing installed a dev run can still go straight to the DHP, so Launch is + // offered where the hub would otherwise only ever offer Install. + let dhp_direct = Launcher::dhp_direct(self).is_some(); + // With a licence and nothing installed, say WHY a DHP run is not on offer. Every one of + // these used to read as a bare "ODIAN is not installed", which points at the wrong thing. + let dhp_blocked = (self.dev.is_some() && installed.is_none() && !dhp_direct) + .then(|| Launcher::why_no_dhp_run(self)); Status { online: self.online, site: SiteInfo { url: self.settings.site_url.clone(), name: self.settings.site_name.clone() }, operator: self.state.operator.clone(), // Remembered until Sign out: a launcher session renews the Engine token on its own. signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()), - can_launch: installed.is_some() - && !update_required - && !self.updating_self - && matches!(self.phase, Phase::Idle | Phase::Error), + can_launch: launchable( + installed.is_some() || dhp_direct, + update_required, + dev_run, + self.updating_self || !matches!(self.phase, Phase::Idle | Phase::Error), + ), installed, available: self.available.clone(), update_required, + dev_run, + dhp_direct, + dhp_blocked, // Updating itself reads as "checking": the window already shows that at every start. phase: if self.updating_self && matches!(self.phase, Phase::Idle | Phase::Error) { Phase::Checking } else { self.phase }, error: self.error.clone(), @@ -254,6 +291,8 @@ pub struct Launcher { stack_states: std::sync::Mutex>, sign_in_cancel: Notify, install_cancel: std::sync::Mutex>, + /// What the running release has printed, for the console panel. + release_console: crate::release::Shared, } /// What an operator picked when installing from a drive: the site store an ODIAN drive @@ -409,11 +448,12 @@ impl Launcher { .map_err(|e| format!("cannot start the HTTP client: {e}"))?; Ok(Launcher { http, - inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url, live: None, map_from_site: None, map_problem: None, tiles_waiting: None, unfinished: None, dev: None, dev_refusal: None, market_checked: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }), + inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url, live: None, map_from_site: None, map_problem: None, tiles_waiting: None, unfinished: None, dev: None, dev_refusal: None, market_checked: None, market_recheck: BTreeSet::new(), portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }), stack: Mutex::new(None), stack_states: std::sync::Mutex::new(Vec::new()), sign_in_cancel: Notify::new(), install_cancel: std::sync::Mutex::new(None), + release_console: Default::default(), }) } @@ -656,6 +696,62 @@ impl Launcher { repo.join("tools").join("build").join("build.bat").is_file().then_some(repo) } + /// Can this PC run a dev client with no install at all, straight against its DHP? + /// + /// Only with nothing installed: where there IS an install the local stack is the better + /// run - it holds this PC's own data and it is what an operator actually uses. This is + /// purely the case that used to be refused outright. + /// + /// Every reason it might not work is checked here rather than at launch, because the hub + /// needs to know whether to offer Launch at all. + fn dhp_direct(inner: &Inner) -> Option { + if Self::dev_engine(inner).is_none() || !inner.online || inner.state.installed_version.is_some() { + return None; + } + let mcp = inner.settings.mcp_url.trim(); + if mcp.is_empty() { + return None; + } + Some(DhpDirect { + engine_url: inner.engine_url.clone()?, + mcp_url: mcp.to_string(), + // Beside the install dir it would have used, so a later real install finds its own + // state untouched. + cache_dir: inner.settings.install_path().join("state").join("devclient"), + }) + } + + /// Why a PC with nothing installed cannot do a DHP-direct run either. Each of these was + /// a silent "ODIAN is not installed" before, which sent people looking in the wrong place. + fn why_no_dhp_run(inner: &Inner) -> String { + if Self::dev_engine(inner).is_none() { + let root = inner.settings.dev_root.trim(); + if !inner.settings.dev_mode { + return "Dev mode is off. Turn it on to run from a checkout with nothing installed.".to_string(); + } + if root.is_empty() { + return "Dev mode has no checkout folder yet: name the folder that holds ODIAN_Engine.".to_string(); + } + return format!( + "{root}{sep}ODIAN_Engine{sep}tools{sep}build{sep}build.bat is not there, so there is no checkout to run. Check the folder below is the right one.", + sep = "\\" + ); + } + if !inner.online { + return "Nothing is installed here, so this run needs the DHP - and it is not answering. Connect to it, or install once so this PC has its own data." + .to_string(); + } + if inner.settings.mcp_url.trim().is_empty() { + return "No MCP address is set, and that is what the client signs in through. Set it in Settings, or install once." + .to_string(); + } + if inner.engine_url.is_none() { + return "The DHP has not said where its Engine API is, so there is nothing to point the client at. Check the connection, or install once." + .to_string(); + } + "ODIAN is not installed.".to_string() + } + /// Look for this PC's dev licence. /// /// Cheap enough to do on every check - one small file - so dropping a licence in is @@ -726,11 +822,19 @@ impl Launcher { /// Download what was chosen and never fully arrived, without changing the choice and /// without removing anything: the selection it applies is everything this PC holds or /// asked for, so `apply_market` finds nothing to delete and only fills the gaps. + /// + /// "Never fully arrived" is also a set whose marker says every block landed while the count + /// found files missing: the hub counts those as unfinished (`market::unfinished`), so Finish + /// must fetch them too. They go down as sets to re-count block by block - the installer + /// skips on that very marker, and without this Finish found nothing to add and did nothing. pub async fn finish_market(&self, app: &AppHandle) -> Result<(), String> { let (manifest, _, install_dir) = self.market_manifest(app).await?; - let extras = self.inner.lock().await.state.extras.clone().unwrap_or_default(); + let (extras, short) = { + let inner = self.inner.lock().await; + (inner.state.extras.clone().unwrap_or_default(), market::short_tile_sets(inner.market_checked.as_deref().unwrap_or_default())) + }; let keep = market::held_keys(&manifest, &install_dir.join("data"), &extras); - self.apply_market(app, keep.into_iter().collect()).await + self.apply_market_rechecking(app, keep.into_iter().collect(), short).await } /// Ask the site what it has baked since this PC last looked. Quiet on failure: a site @@ -1491,6 +1595,11 @@ impl Launcher { } pub async fn apply_market(&self, app: &AppHandle, selected: Vec) -> 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, recheck: BTreeSet) -> Result<(), String> { let (manifest, installed, install_dir) = self.market_manifest(app).await?; let extras = market::optional_keys(&manifest, selected); { @@ -1506,13 +1615,17 @@ impl Launcher { } let data = install_dir.join("data"); let adds = market::to_add(&manifest, &data, &extras); - if !installed || (adds.is_empty() && market::to_remove(&manifest, &data, &extras).is_empty()) { + // Only a set still chosen and still in this version is worth re-counting. + let (wanted, _) = market::install_filter(&manifest, &extras, true); + let recheck: BTreeSet = recheck.into_iter().filter(|set| wanted.contains(set)).collect(); + if !installed || (adds.is_empty() && recheck.is_empty() && market::to_remove(&manifest, &data, &extras).is_empty()) { self.emit(app).await; return Ok(()); } - if !adds.is_empty() && !self.inner.lock().await.online { + if (!adds.is_empty() || !recheck.is_empty()) && !self.inner.lock().await.online { return Err("Your DHP is unreachable: connect to download marketplace items.".to_string()); } + self.inner.lock().await.market_recheck = recheck; self.start_run(app, None, true).await } @@ -1583,21 +1696,35 @@ impl Launcher { install_dir: &Path, cancel: &CancellationToken, ) -> Result { - let (installed, extras) = { - let inner = self.inner.lock().await; - (inner.state.installed_version.clone(), inner.state.extras.clone().unwrap_or_default()) + let (installed, extras, recheck) = { + let mut inner = self.inner.lock().await; + // Taken, not read: a later marketplace download re-counts only if Finish asks again. + (inner.state.installed_version.clone(), inner.state.extras.clone().unwrap_or_default(), std::mem::take(&mut inner.market_recheck)) }; let installed = installed.ok_or_else(|| "ODIAN is not installed.".to_string())?; let verified = odian_packs::version_manifest(source, &installed, odian_packs::bundled_public_key()).await?; let manifest = verified.manifest(); let titles: Vec = market::to_add(manifest, &install_dir.join("data"), &extras).into_iter().map(|item| item.title).collect(); - self.log(app, "info", format!("Downloading {}", titles.join(", "))); + if !titles.is_empty() { + self.log(app, "info", format!("Downloading {}", titles.join(", "))); + } + if !recheck.is_empty() { + self.log( + app, + "info", + format!( + "Fetching what is missing on disk from {} (their markers say complete; the count says otherwise).", + recheck.iter().cloned().collect::>().join(", ") + ), + ); + } let (tile_sets, src_paths) = market::install_filter(manifest, &extras, true); let options = InstallOptions { only: vec![Stage::Src, Stage::Tiles], tile_sets: Some(tile_sets), src_paths: Some(src_paths), + recheck_tile_sets: recheck, ..InstallOptions::default() }; let progress = { @@ -1887,19 +2014,29 @@ impl Launcher { let config = { let mut inner = self.inner.lock().await; let status = inner.status(); - if status.update_required { + // Not for a dev run: that starts the checkout, so what the DHP published is beside + // the point. An operator install still has to update first. + if status.update_required && !status.dev_run { return Err("An update is available: update before launching while connected to the DHP.".to_string()); } if !status.can_launch { return Err("ODIAN isn't ready to launch.".to_string()); } - let operator = inner - .state - .operator - .clone() - .ok_or("Sign in once while connected, so the launcher knows which operator to start ODIAN as.")?; - let version = inner.state.installed_version.clone().ok_or("ODIAN is not installed.")?; - if inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none() { + let dhp = Self::dhp_direct(&inner); + // Nothing installed and no DHP-direct run available: say which of the two is missing, + // rather than "ODIAN is not installed" when the real problem is the target or the MCP. + if dhp.is_none() && inner.state.installed_version.is_none() { + return Err(Self::why_no_dhp_run(&inner)); + } + // The client signs ITSELF in on a DHP-direct run, so there is no operator to carry. + let operator = match &dhp { + Some(_) => inner.state.operator.clone().unwrap_or_default(), + None => inner.state.operator.clone().ok_or( + "Sign in once while connected, so the launcher knows which operator to start ODIAN as.", + )?, + }; + let version = inner.state.installed_version.clone().unwrap_or_default(); + if dhp.is_none() && (inner.state.pg_password.is_empty() || inner.state.db_snapshot.is_none()) { return Err("The local database was never set up. Reinstall from the DHP or a drive.".to_string()); } let install_dir = inner.settings.install_path(); @@ -1910,9 +2047,13 @@ impl Launcher { let layout = Layout::new(&install_dir, &version); let dev = Self::dev_engine(&inner); let dev_user = Self::dev_swap(&inner); - let tile_sets = stack::installed_tile_sets(&layout)?; - // What the DHP can draw for this PC, beyond what it downloaded. - let site_tile_sets = stack::version_tile_sets(&layout)?; + // A DHP-direct run has no installed version to read either list out of: this PC draws + // nothing of its own, and the site draws everything. + let (tile_sets, site_tile_sets) = match &dhp { + // No manifest to read either list from; the client uses its own default styles. + Some(_) => (Vec::new(), Vec::new()), + None => (stack::installed_tile_sets(&layout)?, stack::version_tile_sets(&layout)?), + }; inner.phase = Phase::Launching; inner.error = None; StackConfig { @@ -1925,6 +2066,7 @@ impl Launcher { dev_engine: dev, dev_user: dev_user.clone(), dev_target: inner.settings.dev_target, + dhp, tile_sets, site_tile_sets, // A swapped run never reaches the DHP: no upstream token is written, so the local @@ -2265,6 +2407,174 @@ impl Launcher { Ok(()) } + // ── releasing, in dev mode ────────────────────────────────────────────── + + /// What a release could do right now: what is live, what this PC's repos hold, and what + /// stands in the way. Read fresh each time the panel opens - Docker and the DHP both + /// change under us. + pub async fn release_plan(&self) -> crate::release::ReleasePlan { + let (dev_root, online, pack_live, install_dir, installed) = { + let inner = self.inner.lock().await; + ( + inner.settings.dev_root.clone(), + inner.online, + inner.available.clone(), + inner.settings.install_path(), + inner.state.installed_version.clone(), + ) + }; + let dev_root = PathBuf::from(dev_root.trim()); + // The commit the LIVE pack was cut from. Read from this PC's own copy of that version, + // which costs nothing; when this PC is on an older version we simply do not know, and + // the panel says so rather than guessing. + // One read, two answers. The commit is only trusted when this PC is ON the live version + // (an older copy says where IT came from, not where the live pack did), but the data + // payload is worth showing either way - it is carried forward unchanged, so an older + // manifest describes the same seed and the same tiles. + let manifest = installed.as_ref().and_then(|have| { + std::fs::read(Layout::new(&install_dir, have).version_dir().join("manifest.json")) + .ok() + .and_then(|raw| serde_json::from_slice::(&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::().await.ok(), + _ => None, + } + } + }; + let feed = get(LAUNCHER_FEED).await; + let launcher_live = feed.as_ref().and_then(|f| f.get("version").and_then(|v| v.as_str().map(str::to_string))); + // The commit is NOT in the updater feed and must not be put there: that document is + // Tauri's own format and goes out to every launcher in the field. `latest.json`, which + // this pipeline writes itself, carries it. Pair the two only when both name the same + // version - if they disagree, what is published is unknown and the row says so rather + // than claiming "up to date" and skipping a release that was needed. + let latest = get("latest.json").await; + let launcher_commit = match (&launcher_live, &latest) { + (Some(live), Some(l)) + if l.get("kind").and_then(|k| k.as_str()) == Some("launcher") + && l.get("version").and_then(|v| v.as_str()) == Some(live.as_str()) => + { + l.get("source_commit").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string) + } + _ => None, + }; + crate::release::plan( + &dev_root, + &dev_root.join("ODIAN_MCP_Server"), + online, + pack_live, + pack_commit, + launcher_live, + launcher_commit, + None, + data, + ) + } + + /// Start a release. Returns once it has started, not when it is done: its output arrives + /// in the console panel, which reads `release_console` as it fills. + pub async fn release_run(&self, password: String, with_api: bool, with_mcp: bool, with_web: bool) -> Result<(), String> { + // Two at once would interleave into one buffer and fight over the same build dirs. + if self.release_console.lock().is_ok_and(|c| c.is_running()) { + return Err("A release is already running: wait for it to finish.".to_string()); + } + let plan = self.release_plan().await; + if let Some(why) = plan.blocked { + return Err(why); + } + if password.trim().is_empty() { + return Err("The node's sudo password is needed to cut a pack and to roll the API.".to_string()); + } + let (dev_root, install_dir, pack_live) = { + let inner = self.inner.lock().await; + (inner.settings.dev_root.trim().to_string(), inner.settings.install_path(), inner.available.clone()) + }; + let dev_root = PathBuf::from(dev_root); + let pack_next = pack_live + .as_deref() + .and_then(crate::release::next_pack) + .ok_or("Cannot tell which pack version comes next: the DHP did not say which is live.")?; + // The launcher is only rebuilt when its repo has moved past what is published; without + // that check every press would bump its version for nothing. + let launcher_item = plan.items.iter().find(|i| i.key == "launcher"); + let launcher_next = match launcher_item.map(|i| i.behind) { + Some(Some(false)) => None, + _ => launcher_item + .and_then(|i| i.live.as_deref()) + .and_then(crate::release::next_launcher), + }; + crate::release::run_release( + &dev_root.join("ODIAN_MCP_Server"), + &install_dir.join("state").join("release"), + &pack_next, + launcher_next.as_deref(), + with_api && plan.docker, + with_mcp && plan.docker, + with_web && plan.docker, + &password, + &self.release_console, + ) + .map(|_| ()) + } + + /// Refresh the pack's data payload in its own run. Returns once it has started. + pub async fn refresh_run(&self, password: String, pg_password: String, tile_root: String) -> Result<(), String> { + if self.release_console.lock().is_ok_and(|c| c.is_running()) { + return Err("Something is already running: wait for it to finish.".to_string()); + } + if password.trim().is_empty() { + return Err("The node's sudo password is needed: the dump runs there.".to_string()); + } + let (dev_root, install_dir) = { + let inner = self.inner.lock().await; + (inner.settings.dev_root.trim().to_string(), inner.settings.install_path()) + }; + if dev_root.is_empty() { + return Err("Dev mode has no checkout folder, so the refresh scripts cannot be found.".to_string()); + } + crate::release::run_refresh( + &PathBuf::from(dev_root).join("ODIAN_MCP_Server"), + &install_dir.join("state").join("release"), + pg_password.trim(), + Some(tile_root.as_str()), + &password, + &self.release_console, + ) + .map(|_| ()) + } + + /// Everything the release has printed since line `since`. + pub fn release_console(&self, since: usize) -> crate::release::ConsoleTail { + match self.release_console.lock() { + Ok(c) => c.tail(since), + // A poisoned lock must not take the window down with it. + Err(e) => e.into_inner().tail(since), + } + } + // ── operator-data sync ────────────────────────────────────────────────── /// Sync, unless this run is pretending to be another operator. Whatever it authored is @@ -2665,6 +2975,34 @@ fn swapped_user<'a>(asked: &'a str, operator: Option<&str>) -> Option<&'a str> { mod tests { use super::*; + #[test] + fn nothing_installed_still_launches_when_the_dhp_can_stand_in() { + // The whole point of the DHP-direct run: a checkout and a reachable DHP are enough. + assert!(launchable(true, false, true, false), "installed, dev: launches"); + // `installed` is passed as "installed OR a DHP-direct run is available", so this is the + // no-install case arriving as launchable. + assert!(launchable(true, false, true, false)); + // Nothing installed AND no DHP to stand in: still refused, and it should be. + assert!(!launchable(false, false, true, false), "no install, no DHP: nothing to run"); + assert!(!launchable(false, false, false, false)); + } + + #[test] + fn a_dev_run_launches_even_when_the_dhp_has_a_newer_pack() { + // What used to happen: cutting a release made every dev PC refuse to launch until it + // downloaded a pack it was never going to run. + assert!(launchable(true, true, true, false), "behind, but the client comes from the checkout"); + assert!(launchable(true, false, true, false)); + // An operator PC still updates first - that one really does run the pack. + assert!(!launchable(true, true, false, false), "not a dev run: update first"); + assert!(launchable(true, false, false, false)); + // Nothing installed is never launchable: a dev run still needs the database, the tiles + // and the API, and those only come from an install. + assert!(!launchable(false, false, true, false), "a checkout alone is not enough to run"); + // Mid-operation, nothing launches. + assert!(!launchable(true, false, true, true)); + } + #[test] fn a_dependency_added_since_the_last_install_is_noticed() { use std::time::{Duration, SystemTime}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index beb4cf9..ad96139 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod localdb; mod market; mod nodejs; mod prune; +mod release; mod settings; mod site; mod stack; @@ -56,6 +57,10 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::get_status, + commands::release_plan, + commands::release_run, + commands::release_console, + commands::refresh_run, commands::get_market, commands::apply_market, commands::check_market, diff --git a/src-tauri/src/market.rs b/src-tauri/src/market.rs index 1045828..e76263e 100644 --- a/src-tauri/src/market.rs +++ b/src-tauri/src/market.rs @@ -337,6 +337,17 @@ pub fn unfinished( .collect() } +/// The tile sets a count found short, as the installer names them (`style/dataset`): what +/// Finish hands it to re-count block by block, because their markers say they are whole. +pub fn short_tile_sets(checked: &[Check]) -> BTreeSet { + checked + .iter() + .filter(|check| !check.whole) + .filter_map(|check| check.key.strip_prefix("tiles:")) + .map(str::to_string) + .collect() +} + /// Whether the chosen optional items still need downloading. pub fn to_add(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet) -> Vec { items(manifest, data_dir, extras) @@ -389,6 +400,21 @@ pub fn remove_counting( mod tests { use super::*; + #[test] + fn finish_repairs_the_sets_a_count_found_short() { + // What the log said on a real PC: two markers claiming "installed" over missing files. + let check = |key: &str, found, expected| Check { key: key.to_string(), found, expected, whole: found >= expected }; + let checked = [ + check("tiles:popdark/protomaps_20260423", 293_056, 400_268), + check("tiles:yandex/protomaps_20260423", 2_371, 28_213), + check("tiles:dark_v2/protomaps_20260423", 95_000, 90_501), + ]; + let short: Vec = short_tile_sets(&checked).into_iter().collect(); + // In the installer's own spelling, and a set holding MORE than it names is not short. + assert_eq!(short, ["popdark/protomaps_20260423", "yandex/protomaps_20260423"]); + assert!(short_tile_sets(&[]).is_empty()); + } + fn manifest() -> Manifest { serde_json::from_value(serde_json::json!({ "schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z", diff --git a/src-tauri/src/release.rs b/src-tauri/src/release.rs new file mode 100644 index 0000000..2ae9369 --- /dev/null +++ b/src-tauri/src/release.rs @@ -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, + /// Why the whole thing cannot run, in one line; None when it can. + pub blocked: Option, + /// 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, +} + +#[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, + /// The repo's HEAD, short. + pub commit: Option, + /// 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, + /// 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 { + 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 { + 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, + published_commit: Option, + 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, + pub db_age_days: Option, + pub db_bytes: u64, + pub tile_sets: usize, + pub tile_bytes: u64, + /// Every distinct dataset the tile sets name. + pub tile_datasets: Vec, + /// 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, + pub basemap_vintage_days: Option, +} + +/// 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 { + 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 = 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, + pack_commit: Option, + launcher_live: Option, + launcher_commit: Option, + api_live: Option, + data: Option, +) -> 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, + /// How many lines fell off the front, so indices stay stable while it trims. + dropped: usize, + running: bool, + exit: Option, +} + +pub type Shared = std::sync::Arc>; + +/// 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, + pub total: usize, + pub running: bool, + pub exit: Option, +} + +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) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let p: Vec = 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 { + let p: Vec = 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()]); + } +} diff --git a/src-tauri/src/stack.rs b/src-tauri/src/stack.rs index 985dfd9..9ddf39f 100644 --- a/src-tauri/src/stack.rs +++ b/src-tauri/src/stack.rs @@ -192,9 +192,13 @@ pub struct StackConfig { /// the site draws the ones this PC never downloaded. Without one it sees only /// `tile_sets`, because nothing else could be drawn. pub site_tile_sets: Vec, - /// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT from, instead of the - /// installed version. Everything else is the install's - the database, the tiles, the - /// API, the tile server - so a dev run needs no download and shows real data. + /// Dev mode: the ODIAN_Engine checkout this run takes its CLIENT, its API and its tile + /// server from, instead of the installed version - see `odian_exe`, `api_exe` and + /// `tile_server_dir`, which all branch on this. + /// + /// What still comes from the INSTALL is the data layer and the runtimes it needs: the + /// database, the tiles, the Python the tile server runs on, and Postgres itself. So a dev + /// run shows real data and needs no download - but it does need an install to sit on. /// /// Only ever Some on a PC with a valid dev licence: the launcher will not fill it in /// otherwise, whatever the settings file says. @@ -210,6 +214,37 @@ pub struct StackConfig { /// Dev mode: which client(s) this run starts. Ignored without `dev_engine` - an operator /// install has no web client to run, and only ever starts the one. pub dev_target: DevTarget, + /// Dev mode, no local data layer: Some means nothing local starts and the client talks + /// straight to the DHP. See `DhpDirect`. + pub dhp: Option, +} + +/// Where a web dev run sends its traffic. `api`/`mcp` are empty for an ordinary local run, +/// which takes those from the URL the launcher opens instead. +#[derive(Clone, Debug)] +pub struct WebPoints { + pub tiles: String, + pub api: String, + pub mcp: String, +} + +/// A dev run with no install behind it: the client talks straight to the DHP. +/// +/// Nothing local starts - no Postgres, no Engine API, no tile server - so this needs no +/// installed version at all, which is the whole point: a checkout and a DHP are enough. +/// +/// The client signs itself in through the MCP broker and carries the CLUSTER's token, which +/// is exactly what the shipped .exe does. The launcher's silent mint is NOT used here: it +/// signs with a secret only a local API holds, and the cluster does not share it. So this +/// run shows the client's own sign-in once, instead of arriving already signed in. +#[derive(Clone, Debug)] +pub struct DhpDirect { + /// The DHP's Engine API - what the local one would have forwarded to. + pub engine_url: String, + /// The MCP: the broker the client signs in through, and the AI features. + pub mcp_url: String, + /// Where the client keeps its caches and logs, since there is no install to keep them in. + pub cache_dir: PathBuf, } impl StackConfig { @@ -223,6 +258,15 @@ impl StackConfig { } } + /// The client's own folder of writable state. A DHP-direct run has no install to put it + /// in, so it gets one of its own. + fn client_dir(&self) -> PathBuf { + match &self.dhp { + Some(dhp) => dhp.cache_dir.clone(), + None => self.layout.state_dir().join("client"), + } + } + fn odian_exe(&self) -> PathBuf { match &self.dev_engine { Some(_) => self.odian_dir().join("x64").join("MinGW-Release").join("Odian.exe"), @@ -265,20 +309,42 @@ impl StackConfig { self.dev_engine.is_some() && self.dev_target.has_web() } + /// What the web client's dev server should point at. + pub fn web_points(&self) -> WebPoints { + match &self.dhp { + Some(dhp) => WebPoints { + tiles: site_tiles_url(&self.site_url), + api: dhp.engine_url.clone(), + mcp: dhp.mcp_url.clone(), + }, + None => WebPoints { + tiles: format!("http://127.0.0.1:{TILES_PORT}"), + api: String::new(), + mcp: String::new(), + }, + } + } + /// Where to open the browser, when this run has a web client. /// /// `?api=` points it at THIS stack's API rather than its own `http://localhost:8080` /// default, and `?user=` is the operator - the same name the .exe runs as, swapped one /// included, so both clients author as the same person. The web's endpoint guard trusts /// any loopback port, so no change is needed on that side. + /// + /// A DHP-direct run carries NEITHER. The DHP is not a loopback host, so `?api=` naming it + /// would be refused by that same guard - quite rightly. Its address arrives as deployment + /// config instead (`/odian-config.js`, which the dev server serves from the environment), + /// and the operator is whoever signs in through the MCP. pub fn web_url(&self) -> Option { - self.runs_web().then(|| { - format!( + self.runs_web().then(|| match &self.dhp { + Some(_) => format!("http://localhost:{WEB_PORT}/"), + None => format!( "http://localhost:{}/?api={}&user={}", WEB_PORT, url_value(&format!("http://127.0.0.1:{API_PORT}")), url_value(&self.operator) - ) + ), }) } @@ -290,6 +356,11 @@ impl StackConfig { /// browser: not in the request line, not in Vite's log. Never log this address; /// `web_url` is the one to print. pub fn web_url_signed(&self, token: &str) -> Option { + // Nothing to sign for on a DHP-direct run: there is no local API to relay, and the web + // client gets its own token from the MCP when it signs in. + if self.dhp.is_some() { + return self.web_url(); + } self.web_url().map(|url| format!("{url}#token={}", url_value(token))) } @@ -400,7 +471,8 @@ type Shared = Arc>; pub struct RunningStack { job: job::Job, - pg: Pg, + /// None on a DHP-direct run: there is no local database to stop. + pg: Option, children: Vec<(Component, Shared)>, } @@ -440,7 +512,9 @@ impl RunningStack { } emit(StackEvent::new(component, ComponentState::Stopped)); } - let pg = self.pg.clone(); + let Some(pg) = self.pg.clone() else { + return; // DHP-direct: nothing local was started, so there is nothing to stop. + }; match run_blocking(move || pg.stop()).await { Ok(()) => emit(StackEvent::new(Component::Postgres, ComponentState::Stopped)), Err(message) => emit(StackEvent { component: Component::Postgres, state: ComponentState::Failed, message: Some(message) }), @@ -492,10 +566,10 @@ fn check_ports_free(own_db_running: bool, web: bool) -> Result<(), String> { } pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) -> Result { - let pg = Pg::new(&config.layout, &config.pg_password); + let pg = config.dhp.is_none().then(|| Pg::new(&config.layout, &config.pg_password)); // Asks pg_ctl about this install's own data directory, so a database on the port that // belongs to another install still reads as someone else's. - check_ports_free(pg.running(), config.runs_web())?; + check_ports_free(pg.as_ref().is_some_and(Pg::running), config.runs_web())?; prepare(config)?; let mut running = RunningStack { job: job::Job::new()?, pg, children: Vec::new() }; match start_all(http, config, emit, &mut running).await { @@ -508,11 +582,31 @@ pub async fn start(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>) } async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> { + if config.dhp.is_none() { + start_local_data_layer(http, config, emit, running).await?; + } + if config.runs_client() { + start_client(config, emit, running).await?; + } + if config.runs_web() { + start_web(http, config, emit, running).await?; + } + Ok(()) +} + +/// Postgres, the Engine API and the tile server: this PC's own data, and what a DHP-direct +/// run does without. +async fn start_local_data_layer( + http: &reqwest::Client, + config: &StackConfig, + emit: Emit<'_>, + running: &mut RunningStack, +) -> Result<(), String> { let layout = &config.layout; let state = layout.state_dir(); emit(StackEvent::new(Component::Postgres, ComponentState::Starting)); - let pg = running.pg.clone(); + let pg = running.pg.clone().ok_or("no database to start")?; run_blocking(move || pg.start()).await.map_err(|e| failed(emit, Component::Postgres, e))?; emit(StackEvent::new(Component::Postgres, ComponentState::Ready)); @@ -559,21 +653,13 @@ async fn start_all(http: &reqwest::Client, config: &StackConfig, emit: Emit<'_>, .await .map_err(|e| failed(emit, Component::Tiles, format!("The tile server {e}. See state\\tiles\\tile_server.log.")))?; emit(StackEvent::new(Component::Tiles, ComponentState::Ready)); - - if config.runs_client() { - start_client(config, emit, running).await?; - } - if config.runs_web() { - start_web(http, config, emit, running).await?; - } Ok(()) } /// Odian.exe, in its own folder of writable state, from the install or the checkout. async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut RunningStack) -> Result<(), String> { - let state = config.layout.state_dir(); emit(StackEvent::new(Component::Odian, ComponentState::Starting)); - let client_dir = state.join("client"); + let client_dir = config.client_dir(); let odian_exe = config.odian_exe(); if config.dev_engine.is_some() && !odian_exe.is_file() { return Err(failed( @@ -615,9 +701,18 @@ async fn start_client(config: &StackConfig, emit: Emit<'_>, running: &mut Runnin /// a failed launch, and the bare `--` matters - without it npm keeps the rest for itself /// instead of passing it to Vite. `node_dir` goes first on its PATH: right after the /// launcher installed Node.js, this process's own PATH does not name it yet. -fn web_command(dir: &Path, node_dir: &Path) -> Command { +fn web_command(dir: &Path, node_dir: &Path, points: &WebPoints) -> Command { let mut web = Command::new("cmd"); web.env("PATH", crate::nodejs::path_with(node_dir)); + // Only on a DHP-direct run. `_WEB`'s dev server turns these into the deployment config it + // already understands (`/odian-config.js`); unset, it serves nothing and the ordinary + // local run is untouched. + if !points.api.is_empty() { + web.env("ODIAN_ENGINE_API_URL", &points.api); + } + if !points.mcp.is_empty() { + web.env("ODIAN_MCP_API_URL", &points.mcp); + } // --strictPort because without it Vite quietly takes the next free port, and the address // the launcher opens - and prints - would then be somebody else's server. web @@ -631,8 +726,9 @@ fn web_command(dir: &Path, node_dir: &Path) -> Command { .arg("--strictPort") // The map through THIS stack's tile server, as the .exe draws it: the site while it // answers, this PC's installed tiles when it does not. Without it _WEB's proxy goes - // to the public tile host, and an offline run has no map at all. - .env("ODIAN_TILE_SERVER_URL", format!("http://127.0.0.1:{TILES_PORT}")) + // to the public tile host, and an offline run has no map at all. A DHP-direct run has + // no tile server of its own, so it names the site's. + .env("ODIAN_TILE_SERVER_URL", points.tiles.clone()) .current_dir(dir); web } @@ -670,7 +766,7 @@ async fn start_web( format!("Node.js is not installed, and the web client runs on it. Install Node.js LTS (winget install {}), then launch again.", crate::nodejs::WINGET_ID), )); }; - let mut web = web_command(&dir, &node_dir); + let mut web = web_command(&dir, &node_dir, &config.web_points()); give_console(&mut web); let child = running.spawn(Component::Web, &mut web).map_err(|e| failed(emit, Component::Web, e))?; // A cold start compiles Cesium, so this is patient where the others are not. @@ -756,6 +852,12 @@ async fn run_blocking(work: impl FnOnce() -> Result Result<(), String> { + if let Some(dhp) = &config.dhp { + for dir in [dhp.cache_dir.join("logs"), dhp.cache_dir.join("tile_cache"), dhp.cache_dir.join("heightmap")] { + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + } + return Ok(()); + } let layout = &config.layout; let state = layout.state_dir(); let data = layout.data_dir(); @@ -1019,7 +1121,58 @@ fn tiles_env(config: &StackConfig) -> Vec<(&'static str, String)> { env } +/// A run with no local stack at all: everything the client talks to is the DHP's. +/// +/// Deliberately WITHOUT `ODIAN_LAUNCH_USER` and `ODIAN_MINT_SECRET`. Those two are what make +/// the client mint its own session, and only a local API will honour that - the cluster does +/// not share the secret. Left out, the client does what the shipped .exe does: signs in +/// through the MCP broker and carries the cluster's own token. +fn dhp_client_env(config: &StackConfig, dhp: &DhpDirect) -> Vec<(&'static str, String)> { + let client = config.client_dir(); + let tiles = site_tiles_url(&config.site_url); + let mut env = vec![ + ("ODIAN_ENGINE_API_URL", dhp.engine_url.clone()), + // The broker the client signs in through. Without it its login screen has nowhere to go + // and the run is a window that cannot be used. + ("ODIAN_MCP_API_URL", dhp.mcp_url.clone()), + ("ODIAN_TILE_SERVER_URL", tiles.clone()), + ("ODIAN_POP_SERVICE_URL", tiles.clone()), + ("ODIAN_TILE_PROVIDER", "odian".to_string()), + ("ODIAN_TILE_REQUEST_MODE", "xyz".to_string()), + ("ODIAN_TILE_Y_SCHEME", "xyz".to_string()), + ("ODIAN_TILE_AUTH_MODE", "none".to_string()), + ("ODIAN_TILE_CACHE_DIR", text(client.join("tile_cache"))), + ("ODIAN_HEIGHT_CACHE_DIR", text(client.join("heightmap"))), + ("ODIAN_LIDAR_ENABLED", "0".to_string()), + ]; + // Every set the DHP can draw, not this PC's - it has none. Same order of preference as a + // local run, so the map opens on the same basemap a developer is used to. + let sets = &config.site_tile_sets; + let basemap = ["dark_v2", "dark"] + .iter() + .find_map(|want| sets.iter().find(|set| set.style == *want)) + .or_else(|| sets.iter().find(|set| set.style != "heightmap")); + if let Some(set) = basemap { + env.push(("ODIAN_TILE_STYLE", set.style.clone())); + env.push(("ODIAN_TILE_DATASET", set.dataset.clone())); + } + let styles: Vec<&str> = sets.iter().map(|set| set.style.as_str()).collect(); + if !styles.is_empty() { + env.push(("ODIAN_TILE_STYLES", styles.join(","))); + } + if let Some(set) = sets.iter().find(|set| set.style == "heightmap") { + env.push(("ODIAN_HEIGHT_SERVER_URL", tiles)); + env.push(("ODIAN_HEIGHT_FORMAT", "lerc".to_string())); + env.push(("ODIAN_HEIGHT_STYLE", set.style.clone())); + env.push(("ODIAN_HEIGHT_DATASET", set.dataset.clone())); + } + env +} + fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> { + if let Some(dhp) = &config.dhp { + return dhp_client_env(config, dhp); + } let client = config.layout.state_dir().join("client"); let tiles_url = format!("http://127.0.0.1:{TILES_PORT}"); let mut env = vec![ @@ -1184,6 +1337,7 @@ mod tests { dev_engine: None, dev_user: None, dev_target: DevTarget::default(), + dhp: None, site_url: "https://dhp.example/dhp".to_string(), site_tile_sets: tile_sets .iter() @@ -1268,6 +1422,71 @@ mod tests { assert!(value(&env, "ODIAN_HEIGHT_SERVER_URL").is_none()); } + /// What an ordinary local web run points at, for the tests that do not care about the DHP. + fn local_points() -> WebPoints { + WebPoints { tiles: format!("http://127.0.0.1:{TILES_PORT}"), api: String::new(), mcp: String::new() } + } + + #[test] + fn a_dhp_direct_web_run_is_told_where_to_look_and_asks_for_nothing_local() { + let mut config = config(&[]); + config.dev_engine = Some(PathBuf::from(r"D:\dev\ODIAN_Engine")); + config.dev_target = DevTarget::Web; + config.dhp = Some(DhpDirect { + engine_url: "https://dhp.example/engine".to_string(), + mcp_url: "https://dhp.example".to_string(), + cache_dir: PathBuf::from(r"D:\ODIAN\state\devclient"), + }); + let points = config.web_points(); + assert_eq!(points.api, "https://dhp.example/engine"); + assert_eq!(points.mcp, "https://dhp.example"); + assert_eq!(points.tiles, "https://dhp.example/dhp/tileserver"); + // The URL must NOT carry ?api=: the DHP is not a loopback host, so _WEB's endpoint + // guard would refuse it and quietly fall back to its own localhost default. + let url = config.web_url().expect("a web run has a URL"); + assert!(!url.contains("api="), "the address must not try to override the endpoint: {url}"); + assert!(!url.contains("user="), "nobody is signed in yet: {url}"); + // And no token to staple on, since there is no local API relaying anything. + assert_eq!(config.web_url_signed("tok"), Some(url)); + } + + #[test] + fn an_ordinary_web_run_still_points_at_this_pc() { + let mut config = config(&[]); + config.dev_engine = Some(PathBuf::from(r"D:\dev\ODIAN_Engine")); + config.dev_target = DevTarget::Web; + let points = config.web_points(); + assert!(points.api.is_empty() && points.mcp.is_empty(), "nothing injected for a local run"); + assert!(points.tiles.contains("127.0.0.1")); + let url = config.web_url().expect("a web run has a URL"); + assert!(url.contains(&format!("api=http%3A%2F%2F127.0.0.1%3A{API_PORT}")) || url.contains("127.0.0.1"), "{url}"); + assert!(config.web_url_signed("tok").is_some_and(|u| u.contains("#token=tok"))); + } + + #[test] + fn a_dhp_direct_client_is_pointed_away_from_this_pc() { + let mut config = config(&[]); + config.dev_engine = Some(PathBuf::from(r"D:\dev\ODIAN_Engine")); + config.dhp = Some(DhpDirect { + engine_url: "https://dhp.example/engine".to_string(), + mcp_url: "https://dhp.example".to_string(), + cache_dir: PathBuf::from(r"D:\ODIAN\state\devclient"), + }); + let env = client_env(&config); + assert_eq!(value(&env, "ODIAN_ENGINE_API_URL"), Some("https://dhp.example/engine")); + // The broker it signs in through: without this its login screen has nowhere to go. + assert_eq!(value(&env, "ODIAN_MCP_API_URL"), Some("https://dhp.example")); + assert_eq!(value(&env, "ODIAN_TILE_SERVER_URL"), Some("https://dhp.example/dhp/tileserver")); + // These two are what make the client mint its own session, which only a LOCAL API would + // honour. Present here, the run would fail to sign in against the cluster. + assert_eq!(value(&env, "ODIAN_LAUNCH_USER"), None); + assert_eq!(value(&env, "ODIAN_MINT_SECRET"), None); + // And nothing may point back at this PC: there is no local stack listening. + for (key, val) in &env { + assert!(!val.contains("127.0.0.1"), "{key} still points at this PC: {val}"); + } + } + #[test] fn api_and_tile_server_are_loopback_and_cache_only() { let config = config(&[("dark", "protomaps_20260423")]); @@ -1412,7 +1631,7 @@ mod tests { // Checked as ARGUMENTS rather than by reading the binary: these are short literals that // a release build merges away, so "is the flag in the .exe" answers nothing. A wrong // flag here would surface only as a failed launch. - let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs")); + let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs"), &local_points()); let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); assert_eq!(args, ["/c", "npm", "run", "dev", "--", "--port", "5173", "--strictPort"]); // The bare `--` is load-bearing: without it npm eats the flags instead of passing them on. @@ -1429,7 +1648,7 @@ mod tests { fn the_web_draws_its_map_from_this_stacks_tile_server() { // Offline, the public tile host is unreachable; this stack's tile server still has // the PC's installed tiles. _WEB's vite.config.ts reads this variable. - let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs")); + let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), Path::new(r"C:\Program Files\nodejs"), &local_points()); let tiles = cmd .get_envs() .find(|(key, _)| *key == "ODIAN_TILE_SERVER_URL") @@ -1443,7 +1662,7 @@ mod tests { // Right after the launcher installs Node.js, its own PATH does not name it yet; npm // runs with the found folder first, so the launch goes on without a restart. let node = Path::new(r"C:\Program Files\nodejs"); - let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), node); + let cmd = web_command(Path::new(r"E:\dev\ODIAN_Engine\_WEB"), node, &local_points()); let path = cmd .get_envs() .find(|(key, _)| *key == "PATH") diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fbed0f1..495dcda 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "ODIAN Launcher", - "version": "0.1.38", + "version": "0.1.45", "identifier": "fr.odian.launcher", "build": { "frontendDist": "../dist", diff --git a/src/App.svelte b/src/App.svelte index 89817f4..3efc662 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -6,6 +6,8 @@ import KeyArt from "./components/KeyArt.svelte"; import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte"; import NewsPanel from "./components/NewsPanel.svelte"; + import ReleaseConsole from "./components/ReleaseConsole.svelte"; + import ReleasePanel from "./components/ReleasePanel.svelte"; import SettingsDrawer from "./components/SettingsDrawer.svelte"; import SyncPanel from "./components/SyncPanel.svelte"; import StatusBar from "./components/StatusBar.svelte"; @@ -14,6 +16,13 @@ let settingsOpen = $state(false); + // Dev mode drops the patch notes: they describe the INSTALLED version, which is not what + // a dev run starts. With that column gone the art has the whole window to sit in. + const dev = $derived(launcher.devOn); + // A release takes that column back while it runs: its output is the one thing worth + // reading there, and it is where a developer is already looking. + const showConsole = $derived(dev && launcher.consoleOpen); + onMount(() => launcher.start()); @@ -21,8 +30,12 @@ (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} /> -
-
+
+ {#if !dev} +
+ {:else if showConsole} +
+ {/if}
@@ -37,6 +50,10 @@
{/if} + {#if launcher.releaseOpen} +
+ {/if} + @@ -69,6 +86,12 @@ min-height: 0; } + /* No notes column in dev mode: it collapses rather than being hidden, so the hero and the + action panel keep the places they have everywhere else. */ + .hub.dev { + grid-template-columns: 0 minmax(0, 1fr) minmax(360px, 404px); + } + .hero-slot { grid-row: 1; grid-column: 2 / span 2; diff --git a/src/assets/keyart-dev.webp b/src/assets/keyart-dev.webp index f22403b..a21d100 100644 Binary files a/src/assets/keyart-dev.webp and b/src/assets/keyart-dev.webp differ diff --git a/src/components/ActionPanel.svelte b/src/components/ActionPanel.svelte index 4b4fad1..063edc1 100644 --- a/src/components/ActionPanel.svelte +++ b/src/components/ActionPanel.svelte @@ -98,10 +98,17 @@ // like the real one on screen, and mistaking the two is how a bug gets chased in the // wrong tree - or a demo gets given from an unbuilt branch. const devOn = $derived(launcher.devOn); + // Dev mode's controls follow DEV MODE, not whatever the hub is currently offering. They + // used to live inside the "launch" branch, so a PC with nothing installed was told to name + // its checkout folder and given nowhere on screen to name it - the dead end that made a + // fresh dev PC look like it could only install. + const TRANSIENT = ["loading", "signing_in", "installing", "restoring", "syncing", "building", "removing", "pulling_tiles", "stack"]; + const showDev = $derived(devOn && !TRANSIENT.includes(action)); const devRun = $derived(launcher.devReady); const devRoot = $derived(launcher.settings?.dev_root ?? ""); const devTarget = $derived(launcher.settings?.dev_target ?? "client"); const devBuild = $derived(launcher.settings?.dev_build ?? false); + const devUserField = $derived(launcher.settings?.dev_user ?? ""); const savingDev = $derived(busy === "settings"); // One stack either way: both clients talk to the same API and the same database, which is // the only way a .exe/web divergence is visible at all. @@ -111,6 +118,17 @@ { key: "both", name: "Both", note: "one stack" }, ] as const; + // ---- releasing ------------------------------------------------------------ + async function openRelease() { + launcher.releaseOpen = true; + await launcher.loadRelease(); + } + + // While one runs, this button is the way BACK to its output rather than a way to start a + // second: closing the console otherwise left a running release with nowhere to be seen. + const releasing = $derived(launcher.consoleRunning); + const hasOutput = $derived(launcher.consoleLines.length > 0 && !launcher.consoleOpen); + async function browseCheckout() { const path = await launcher.pickFolder(); if (path) await launcher.setDev({ dev_root: path }); @@ -197,6 +215,14 @@

ODIAN {s?.available} from {site}, into {s?.install_dir}.

+ + {#if s?.dhp_blocked} +

+ Dev mode: a run straight against {site} needs no install, but {s.dhp_blocked} +

+ {/if} {@render driveLink("Have an ODIAN drive?")} {:else if action === "offline_unavailable"} {@render primary("Connect to your DHP to install", "globe", () => {}, true)} @@ -217,6 +243,14 @@

{#if checking} Checking {site}… + {:else if !s?.installed} + Nothing is installed here: the client comes from your checkout and runs on + {site}'s data. You sign in inside ODIAN, not here. + {:else if s?.update_required} + + ODIAN {s.installed} is installed, {s.available} is on {site}. A dev run starts + your checkout, so it does not need it. + {:else if s?.online} ODIAN {s.installed} is up to date. {:else} @@ -252,78 +286,6 @@

{/if} - {#if devOn} - -
-
- - DEV - - {devRun ? TARGETS.find((o) => o.key === devTarget)?.name ?? "" : "no checkout"} -
- -
- {#each TARGETS as option (option.key)} - - {/each} -
- -
- launcher.setDev({ dev_root: e.currentTarget.value.trim() })} - /> - -
- -
- - {#if s?.online} - - - - {:else} - offline — tiles and data stay as they are - {/if} -
- - {#if !devRun} -

Name the folder that holds ODIAN_Engine and Launch runs from it.

- {:else if devUser} -

- Running as {devUser} — this PC will not sync with the DHP. -

- {/if} -
- {/if} {#if unfinished}

@@ -458,6 +420,99 @@

{/if} + {#if showDev} + +
+
+ + DEV + + {devRun ? TARGETS.find((o) => o.key === devTarget)?.name ?? "" : "no checkout"} +
+ +
+ {#each TARGETS as option (option.key)} + + {/each} +
+ +
+ launcher.setDev({ dev_root: e.currentTarget.value.trim() })} + /> + +
+ +
+ + launcher.setDev({ dev_user: e.currentTarget.value.trim() })} + /> +
+ +
+ {#if releasing} + + {:else} + + {/if} + {#if hasOutput && !releasing} + + {:else if !s?.online} + needs the DHP + {/if} +
+ + + + {#if !devRun} +

Name the folder that holds ODIAN_Engine and Launch runs from it.

+ {:else if devUser} +

+ Running as {devUser} — this PC will not sync with the DHP. +

+ {/if} +
+ {/if} + {#if showNotice}