diff --git a/crates/odian-packs/src/manifest.rs b/crates/odian-packs/src/manifest.rs index 874d8d9..ebcd263 100644 --- a/crates/odian-packs/src/manifest.rs +++ b/crates/odian-packs/src/manifest.rs @@ -404,6 +404,11 @@ pub struct TileSet { pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + /// False when the store does not hold this item's bytes and never will. It is still + /// listed and described - an operator has to know it exists - but it arrives on a + /// drive, not over the network. Absent means true: everything else is downloadable. + #[serde(default = "carried", skip_serializing_if = "is_carried")] + pub on_dhp: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -425,6 +430,19 @@ pub struct SrcFile { pub title: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + /// See `TileSet::on_dhp`. The world height map source is the case this exists for: 705 GB + /// that no DHP will ever serve over a link, and that an operator still needs to know about. + #[serde(default = "carried", skip_serializing_if = "is_carried")] + pub on_dhp: bool, +} + +/// An item says nothing when the store carries it, which is nearly always. +fn carried() -> bool { + true +} + +fn is_carried(on_dhp: &bool) -> bool { + *on_dhp } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 51dddab..a97a044 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e645c50..2b8be2b 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index bd8f59a..0e5af02 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index e7181a3..e0b3c33 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index c144cbc..5f12789 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 73ca675..ab7d555 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index cae90a1..5a50382 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index a86df8d..ae7099e 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 7fc69a5..4e9e8e4 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 28148c8..b3d1678 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 82cc0e0..b00fe43 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 9cbf935..fdf8f60 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index b3d27f1..5de923d 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 2170fc0..18cac4f 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 54893f7..583d3c4 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index dcb19af..7db1aa5 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 92b86a5..4ef2c01 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 606763b..5508027 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,6 +5,7 @@ use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; use crate::launcher::{Launcher, Market, Status}; +use crate::market; use crate::settings::Settings; use crate::stack::StackEvent; use crate::sync::Conflict; @@ -41,6 +42,16 @@ pub async fn set_market( } /// Choose the optional items (their keys) and apply the choice; see `Launcher::apply_market`. +#[tauri::command] +pub async fn finish_market(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> { + launcher.finish_market(&app).await +} + +#[tauri::command] +pub async fn check_market(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult> { + launcher.check_market(&app).await +} + #[tauri::command] pub async fn apply_market(app: AppHandle, launcher: State<'_, Launcher>, selected: Vec) -> CmdResult<()> { launcher.apply_market(&app, selected).await @@ -115,6 +126,11 @@ pub async fn get_stack(launcher: State<'_, Launcher>) -> CmdResult) -> CmdResult<()> { + launcher.sync_tiles(&app).await +} + #[tauri::command] pub async fn sync_now(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> { launcher.sync_now(&app).await diff --git a/src-tauri/src/launcher.rs b/src-tauri/src/launcher.rs index 168d92f..b795efb 100644 --- a/src-tauri/src/launcher.rs +++ b/src-tauri/src/launcher.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use odian_packs::{InstallOptions, Progress, Source, Stage}; @@ -21,6 +21,7 @@ use crate::site::{self, SessionRefresh, TokenCheck}; use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent}; use crate::state::{self, LauncherState, SyncOrigin}; use crate::sync::{self, SyncError, SyncStatus}; +use crate::tilesync::{self, Waiting}; use crate::tokens::{self, SiteToken}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] @@ -33,6 +34,12 @@ pub enum Phase { Restoring, /// Exchanging operator data with the DHP (ODIAN is not running meanwhile). Syncing, + /// Taking tiles the DHP baked while this PC was away. The map files are being written + /// under ODIAN, so it does not start meanwhile. + PullingTiles, + /// Deleting what the operator unticked in the marketplace. A tile set is hundreds of + /// thousands of files, so this is minutes of work with something to show for it. + Removing, Launching, Running, Error, @@ -72,6 +79,24 @@ pub struct Status { pub map_from_site: Option, /// Why, in one line, when `map_from_site` is false. pub map_problem: Option, + /// Tiles the DHP has baked for the sets this PC holds since it last synced: what a sync + /// would fetch. None offline, with nothing installed, or when the DHP's tile service is + /// too old to be asked. Zero tiles means this PC is level with the site. + pub tiles_waiting: Option, + /// Marketplace items the operator chose that are not all on this PC. None when nothing + /// is installed or nothing is missing: ODIAN still runs, with less map than was asked for. + pub unfinished: Option, +} + +/// What is missing of what was chosen. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Unfinished { + pub items: u32, + /// What finishing them would download, in full - the whole item, not just the gap, which + /// is what the installer skips its way through. + pub bytes: u64, + /// The first few, to name in one line. + pub titles: Vec, } /// What the marketplace shows (`get_market`). @@ -87,6 +112,17 @@ pub struct Market { pub free_bytes: Option, } +/// Told to the window while unticked items are being deleted. +#[derive(Clone, Serialize)] +struct RemoveProgress { + title: String, + done: u64, + total: u64, + /// Which of them, and how many in all: one Apply can drop several. + index: u32, + count: u32, +} + #[derive(Clone, Serialize)] struct LogEvent { level: &'static str, @@ -132,6 +168,10 @@ struct Inner { live: Option, map_from_site: Option, map_problem: Option, + tiles_waiting: Option, + unfinished: Option, + /// What the last count found on disk, kept for as long as nothing can have changed it. + market_checked: Option>, /// 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 @@ -175,6 +215,8 @@ impl Inner { live: self.live, map_from_site: self.map_from_site, map_problem: self.map_problem.clone(), + tiles_waiting: self.tiles_waiting.clone(), + unfinished: self.unfinished.clone(), } } } @@ -296,7 +338,7 @@ impl Launcher { .map_err(|e| format!("cannot start the HTTP client: {e}"))?; Ok(Launcher { http, - inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, map_from_site: None, map_problem: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, offline_reason: None, drive_extras: None }), + inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, map_from_site: None, map_problem: None, tiles_waiting: None, unfinished: None, market_checked: None, 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(), @@ -329,6 +371,9 @@ impl Launcher { let mut inner = self.inner.lock().await; inner.phase = if error.is_some() { Phase::Error } else { Phase::Idle }; inner.error = error; + // Installing, removing and updating are the only things that change what is on disk, + // and all of them end here: the count is worth nothing now and is taken again. + inner.market_checked = None; } self.emit(app).await; } @@ -476,6 +521,8 @@ impl Launcher { let _ = self.renew(app).await; } } + self.check_tiles(app).await; + self.read_unfinished(app).await; { let mut inner = self.inner.lock().await; if inner.phase == Phase::Checking { @@ -485,6 +532,125 @@ impl Launcher { self.emit(app).await } + /// What this PC needs to ask the site for: the sets it holds, its token, and how far + /// it has replayed. None when there is nothing to ask with or about. + async fn tile_sync_inputs(&self, app: &AppHandle) -> Option<(String, String, Vec, i64, PathBuf)> { + let (site_url, version, install_dir) = { + let inner = self.inner.lock().await; + if !inner.online || inner.settings.site_url.is_empty() { + return None; + } + (inner.settings.site_url.clone(), inner.state.installed_version.clone()?, inner.settings.install_path()) + }; + let layout = Layout::new(&install_dir, &version); + // The sets this PC HOLDS, not the ones the version names: a set it never downloaded + // has no folder to fill, and asking for it would offer the operator a sync of tiles + // his tile server would not read. + let sets = stack::installed_tile_sets(&layout).ok()?; + if sets.is_empty() { + return None; + } + let token = self.site_access_token(app).await.ok()?; + let seq = self.inner.lock().await.state.tile_seq(&site_url); + Some((site_url, token, sets, seq, layout.data_dir())) + } + + /// What the operator chose and has not fully got. Read from the installers' markers, so + /// it costs eight small files rather than a walk of the tile store. + async fn read_unfinished(&self, app: &AppHandle) { + let found = match self.market_manifest(app).await { + Ok((manifest, true, install_dir)) => { + let (extras, checked) = { + let inner = self.inner.lock().await; + (inner.state.extras.clone().unwrap_or_default(), inner.market_checked.clone().unwrap_or_default()) + }; + let data = install_dir.join("data"); + let waiting = market::unfinished(&manifest, &data, &extras, &checked); + (!waiting.is_empty()).then(|| Unfinished { + items: waiting.len() as u32, + bytes: waiting.iter().map(|item| item.bytes).sum(), + titles: waiting.iter().take(3).map(|item| item.title.clone()).collect(), + }) + } + _ => None, + }; + self.inner.lock().await.unfinished = found; + } + + /// 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. + 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 keep = market::held_keys(&manifest, &install_dir.join("data"), &extras); + self.apply_market(app, keep.into_iter().collect()).await + } + + /// Ask the site what it has baked since this PC last looked. Quiet on failure: a site + /// whose tile service predates the journal simply has nothing to say, and that is not + /// something to put in front of an operator. + async fn check_tiles(&self, app: &AppHandle) { + let waiting = match self.tile_sync_inputs(app).await { + Some((site_url, token, sets, seq, _)) => { + tilesync::waiting(&self.http, &site_url, &token, &sets, seq).await.ok() + } + None => None, + }; + self.inner.lock().await.tiles_waiting = waiting; + } + + /// Take them. The seq only moves over pages that landed whole, so stopping early - + /// cancelled, or a DHP that went away mid-pull - costs the tiles already written + /// nothing and resumes where it stopped. + pub async fn sync_tiles(&self, app: &AppHandle) -> Result<(), String> { + let Some((site_url, token, sets, seq, data_dir)) = self.tile_sync_inputs(app).await else { + return Err("Connect to your DHP first: tiles come from it.".to_string()); + }; + { + let mut inner = self.inner.lock().await; + if !matches!(inner.phase, Phase::Idle | Phase::Error) { + return Err("The launcher is busy: sync the tiles once it has finished.".to_string()); + } + inner.phase = Phase::PullingTiles; + inner.error = None; + } + let cancel = CancellationToken::new(); + *self.install_cancel.lock().unwrap() = Some(cancel.clone()); + self.emit(app).await; + + let handle = app.clone(); + let pulled = tilesync::pull(&self.http, &site_url, &token, &sets, &data_dir, seq, &cancel, move |progress| { + let _ = handle.emit("tile-sync-progress", &progress); + }) + .await; + *self.install_cancel.lock().unwrap() = None; + + { + let mut inner = self.inner.lock().await; + let install_dir = inner.settings.install_path(); + inner.state.tile_sync = Some(crate::state::TileSync { site: site_url, seq: pulled.seq }); + if let Err(e) = inner.state.save(&install_dir) { + // The tiles are on disk either way; only the bookmark is lost, so the next sync + // offers them again. Worth saying, not worth failing over. + inner.tiles_waiting = None; + drop(inner); + self.log(app, "warn", format!("The tiles are here, but this PC could not remember how far it got: {e}")); + } + } + if pulled.tiles > 0 || pulled.gone > 0 { + self.log( + app, + "info", + format!("Took {} tiles ({}) from your DHP.", pulled.tiles, crate::prune::size_text(pulled.bytes)), + ); + } + let problem = pulled.problem.clone(); + self.check_tiles(app).await; + self.finish(app, problem.map(|why| format!("Not all the tiles arrived: {why}"))).await; + Ok(()) + } + pub async fn sign_in(&self, app: &AppHandle) -> Result { let (mcp_url, site_url) = { let mut inner = self.inner.lock().await; @@ -905,7 +1071,8 @@ impl Launcher { (Some(db), true) => self.db_files_needed(app, install_dir, db).await, _ => None, }; - let (tile_sets, src_paths) = market::install_filter(manifest, &extras); + // From a drive, an item the DHP does not carry is exactly what the drive is FOR. + let (tile_sets, src_paths) = market::install_filter(manifest, &extras, matches!(source, Source::Site { .. })); let options = InstallOptions { db_paths, tile_sets: Some(tile_sets), src_paths: Some(src_paths), ..InstallOptions::default() }; odian_packs::install(source, install_dir, &verified, options, progress, cancel.clone()).await?; @@ -1120,6 +1287,40 @@ impl Launcher { /// Save the operator's choice of optional items (`selected`, item keys), then apply it to /// the installed version: the unchosen items leave this PC, the chosen missing ones are /// downloaded. With nothing installed yet the choice only waits for Install. + /// Count what is really on disk for the items the markers call installed. + /// + /// Separate from `get_market` on purpose: this walks every tile of every installed set - + /// nearly two million files on a full install - and the marketplace has to open at once. + /// The answer is remembered per version, because the only things that change it are an + /// install and a removal, and both clear it. + pub async fn check_market(&self, app: &AppHandle) -> Result, String> { + let (manifest, _, install_dir) = self.market_manifest(app).await?; + let (extras, cached) = { + let inner = self.inner.lock().await; + (inner.state.extras.clone().unwrap_or_default(), inner.market_checked.clone()) + }; + if let Some(checks) = cached { + return Ok(checks); + } + let data = install_dir.join("data"); + let checks = + tauri::async_runtime::spawn_blocking(move || market::check_installed(&manifest, &data, &extras)) + .await + .map_err(|e| format!("could not check what is installed: {e}"))?; + for check in checks.iter().filter(|check| !check.whole) { + self.log( + app, + "warn", + format!("{} says it is installed but holds {} of {} files.", check.key, check.found, check.expected), + ); + } + self.inner.lock().await.market_checked = Some(checks.clone()); + // The hub said "Launch" on the markers' word; the count may have just contradicted it. + self.read_unfinished(app).await; + self.emit(app).await; + Ok(checks) + } + pub async fn apply_market(&self, app: &AppHandle, selected: Vec) -> Result<(), String> { let (manifest, installed, install_dir) = self.market_manifest(app).await?; let extras = market::optional_keys(&manifest, selected); @@ -1158,15 +1359,50 @@ impl Launcher { }; let extras = self.inner.lock().await.state.extras.clone().unwrap_or_default(); let data = install_dir.join("data"); - for item in market::to_remove(&manifest, &data, &extras) { + let going = market::to_remove(&manifest, &data, &extras); + if !going.is_empty() { + self.set_phase(app, Phase::Removing).await; + } + let count = going.len() as u32; + for (i, item) in going.iter().enumerate() { let (m, d, key) = (manifest.clone(), data.clone(), item.key.clone()); - match tauri::async_runtime::spawn_blocking(move || market::remove(&m, &d, &key)).await { + // The blocking thread counts; this side reads that count on a timer and tells the + // window. Deleting 376 000 files is minutes, and a bar that does not move is + // indistinguishable from a launcher that has stopped. + let done = Arc::new(AtomicU64::new(0)); + let ticker = { + let (handle, done, title, total) = + (app.clone(), done.clone(), item.title.clone(), item.files.max(1)); + let (index, count) = (i as u32 + 1, count); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + let _ = handle.emit( + "remove-progress", + &RemoveProgress { title: title.clone(), done: done.load(Ordering::Relaxed), total, index, count }, + ); + } + }) + }; + let counted = done.clone(); + let outcome = + tauri::async_runtime::spawn_blocking(move || market::remove_counting(&m, &d, &key, &mut |n| { + counted.fetch_add(n, Ordering::Relaxed); + })) + .await; + ticker.abort(); + match outcome { Ok(Ok(())) => self.log(app, "info", format!("Removed {} ({}).", item.title, prune::size_text(item.bytes))), Ok(Err(e)) => self.log(app, "warn", format!("Could not remove {}: {e}", item.title)), Err(e) => self.log(app, "warn", format!("Could not remove {}: {e}", item.title)), } } - !market::to_add(&manifest, &data, &extras).is_empty() + let more = !market::to_add(&manifest, &data, &extras).is_empty(); + if more { + // The download stages report themselves from here on. + self.set_phase(app, Phase::Installing).await; + } + more } /// Download the chosen marketplace items of the installed version (tiles and source files @@ -1188,7 +1424,7 @@ impl Launcher { 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(", "))); - let (tile_sets, src_paths) = market::install_filter(manifest, &extras); + 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), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81e06eb..813a0ef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod site; mod stack; mod state; mod sync; +mod tilesync; mod tokens; use tauri::Manager; @@ -52,6 +53,8 @@ pub fn run() { commands::get_status, commands::get_market, commands::apply_market, + commands::check_market, + commands::finish_market, commands::set_market, commands::refresh, commands::sign_in, @@ -69,6 +72,7 @@ pub fn run() { commands::set_settings, commands::pick_folder, commands::sync_now, + commands::sync_tiles, commands::get_sync_conflicts, commands::resolve_sync_conflict, ]) diff --git a/src-tauri/src/market.rs b/src-tauri/src/market.rs index 6e9fb2c..1045828 100644 --- a/src-tauri/src/market.rs +++ b/src-tauri/src/market.rs @@ -39,14 +39,22 @@ pub struct Item { pub description: String, /// What installing it downloads, in full. pub bytes: u64, + /// How many files it is made of: what a removal counts down. + pub files: u64, /// Part of every install: shown, never offered for removal. pub required: bool, pub presence: Presence, /// Chosen: installed with the version and kept by updates (required items always are). pub selected: bool, + /// False when the DHP does not carry this item's bytes: it is listed so an operator knows + /// it exists, but only an ODIAN drive can install it. + pub on_dhp: bool, } -/// The map ODIAN opens on, kept required by `catalog.json`. +/// The map a version from BEFORE the marketplace opens on. Those versions flag nothing, so +/// the catalog's rule is applied to them here - and what they shipped as their one certain +/// basemap was `dark`. Current versions say it themselves (`catalog.json` now keeps +/// `dark_v2` required instead), and this is not read for them. const ALWAYS_INSTALLED_STYLE: &str = "dark"; /// A manifest from before the marketplace (pack 0.61.0) flags nothing optional, so everything @@ -106,6 +114,55 @@ fn tile_set_presence(data_dir: &Path, set: &TileSet) -> Presence { } } +/// One item weighed against what the version says it holds. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Check { + pub key: String, + pub found: u64, + pub expected: u64, + /// True when at least everything the version names is on disk. MORE than that is normal + /// and not a fault: a PC bakes tiles of its own into the same folders, and the tile sync + /// brings in what other operators baked. + pub whole: bool, +} + +/// What a tile set's folder really holds. Dot-files are the installer's own markers +/// (`.blocks-done.json`) and the tile server's negative ones (`.missing`), never tiles. +fn count_tiles(dir: &Path) -> u64 { + let Ok(entries) = std::fs::read_dir(dir) else { return 0 }; + let mut found = 0; + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { + continue; + } + match entry.file_type() { + Ok(kind) if kind.is_dir() => found += count_tiles(&entry.path()), + Ok(kind) if kind.is_file() => found += 1, + _ => {} + } + } + found +} + +/// Count what is on disk for every tile set a marker calls installed. +/// +/// This is the slow, true answer - a walk of hundreds of thousands of files - so it is +/// never on the path that opens the marketplace. The window asks for it after, and moves +/// the badge when it comes back. +pub fn check_installed(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet) -> Vec { + let mut out = Vec::new(); + for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) { + if tile_set_presence(data_dir, set) != Presence::Installed { + continue; + } + let found = count_tiles(&tile_set_dir(data_dir, set)); + out.push(Check { key: tiles_key(set), found, expected: set.files, whole: found >= set.files }); + } + let _ = extras; + out +} + /// A source file is complete when its `.ok` marker names its chunk list and the size is right /// (the installer's own test); partial when the file exists. fn src_presence(data_dir: &Path, file: &SrcFile) -> Presence { @@ -151,13 +208,16 @@ fn all_items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet) -> for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) { let key = tiles_key(set); let optional = tiles_optional(set, old); + let presence = tile_set_presence(data_dir, set); out.push(Item { selected: !optional || extras.contains(&key), title: set.title.clone().unwrap_or_else(|| format!("{} map ({})", set.style, set.dataset)), description: set.description.clone().unwrap_or_default(), bytes: set.bytes, + files: set.files, required: !optional, - presence: tile_set_presence(data_dir, set), + presence, + on_dhp: set.on_dhp, kind: Kind::Tiles, key, }); @@ -165,13 +225,17 @@ fn all_items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet) -> for file in manifest.src.iter().filter(|src| src.enabled).flat_map(|src| &src.files) { let key = src_key(file); let optional = src_optional(file, old); + let presence = src_presence(data_dir, file); out.push(Item { selected: !optional || extras.contains(&key), title: file.title.clone().unwrap_or_else(|| file.path.clone()), description: file.description.clone().unwrap_or_default(), bytes: file.size, + // A source is one file (plus its small markers, not worth counting). + files: 1, required: !optional, - presence: src_presence(data_dir, file), + presence, + on_dhp: file.on_dhp, kind: Kind::Src, key, }); @@ -182,12 +246,24 @@ fn all_items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet) -> /// What installing `manifest` fetches: every required item and the chosen optional ones, as /// the installer's `tile_sets` (`style/dataset`) and `src_paths` filters. -pub fn install_filter(manifest: &Manifest, extras: &BTreeSet) -> (BTreeSet, BTreeSet) { +/// +/// `from_dhp` drops what the store does not carry. The same choice is honoured when the +/// bytes come from a drive - that is where such an item comes from - but asked of the DHP +/// it would be a 404 per chunk and a failed install, for an item nobody could have got that +/// way. It is dropped here rather than refused at the checkbox because the choice outlives +/// the version it was made against: a PC that took the height map off a drive keeps it in +/// `extras`, and its next update from the DHP must simply leave it alone. +pub fn install_filter( + manifest: &Manifest, + extras: &BTreeSet, + from_dhp: bool, +) -> (BTreeSet, BTreeSet) { let old = pre_catalog(manifest); let tile_sets = manifest .tiles .iter() .flat_map(|tiles| &tiles.sets) + .filter(|set| set.on_dhp || !from_dhp) .filter(|set| !tiles_optional(set, old) || extras.contains(&tiles_key(set))) .map(|set| format!("{}/{}", set.style, set.dataset)) .collect(); @@ -195,6 +271,7 @@ pub fn install_filter(manifest: &Manifest, extras: &BTreeSet) -> (BTreeS .src .iter() .flat_map(|src| &src.files) + .filter(|file| file.on_dhp || !from_dhp) .filter(|file| !src_optional(file, old) || extras.contains(&src_key(file))) .map(|file| file.path.clone()) .collect(); @@ -227,20 +304,67 @@ pub fn to_remove(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet .collect() } +/// What this PC has settled on: what the operator chose, plus whatever is actually here. +/// +/// `extras` records answers about OPTIONAL items, so an item that is HERE and not in it was +/// never a choice the operator could have made - required by the version that installed it +/// (Dark, until Dark V2 took its place), off a drive, or not downloadable at all. Applying +/// `extras` alone as a selection therefore reads as "remove every one of those". +pub fn held_keys(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet) -> BTreeSet { + items(manifest, data_dir, extras) + .into_iter() + .filter(|item| !item.required && (item.selected || item.presence != Presence::Absent)) + .map(|item| item.key) + .collect() +} + +/// The chosen items that are not all here, weighed against a count when one has been taken. +/// +/// `to_add` answers from the installers' markers alone, which is what the marketplace opens +/// on. A set whose marker says "installed" while the disk holds half of it is exactly what +/// `check_installed` is for, so anything it found short is counted as unfinished here too. +pub fn unfinished( + manifest: &Manifest, + data_dir: &Path, + extras: &BTreeSet, + checked: &[Check], +) -> Vec { + let short: BTreeSet<&str> = checked.iter().filter(|c| !c.whole).map(|c| c.key.as_str()).collect(); + items(manifest, data_dir, extras) + .into_iter() + .filter(|item| !item.required && item.selected) + .filter(|item| item.presence != Presence::Installed || short.contains(item.key.as_str())) + .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) .into_iter() + // An item the store does not carry is never downloaded, so it is never announced as + // one: `install_filter` drops it, and a line saying it is being fetched would be a + // promise nothing keeps. + .filter(|item| item.on_dhp) .filter(|item| !item.required && item.selected && item.presence != Presence::Installed) .collect() } /// Remove an item's files from the PC: a tile set's folder, or a source file with its markers. pub fn remove(manifest: &Manifest, data_dir: &Path, key: &str) -> Result<(), String> { + remove_counting(manifest, data_dir, key, &mut |_| {}) +} + +/// The same, calling `removed` with each file as it goes, so a caller can show progress. +pub fn remove_counting( + manifest: &Manifest, + data_dir: &Path, + key: &str, + removed: &mut dyn FnMut(u64), +) -> Result<(), String> { for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) { if tiles_key(set) == key { let dir = tile_set_dir(data_dir, set); - return if dir.exists() { crate::prune::remove_folder(&dir) } else { Ok(()) }; + return if dir.exists() { crate::prune::remove_folder_counting(&dir, removed) } else { Ok(()) }; } } for file in manifest.src.iter().flat_map(|src| &src.files) { @@ -250,6 +374,9 @@ pub fn remove(manifest: &Manifest, data_dir: &Path, key: &str) -> Result<(), Str let target = with_suffix(&path, extra); if target.exists() { std::fs::remove_file(&target).map_err(|e| format!("cannot remove {}: {e}", target.display()))?; + if extra.is_empty() { + removed(1); + } } } return Ok(()); @@ -317,11 +444,85 @@ mod tests { assert!(listed.iter().all(|i| i.presence == Presence::Absent)); } + #[test] + fn an_item_the_store_does_not_carry_is_listed_but_never_asked_of_it() { + // The world height map source is 705 GB and arrives on a drive. Leaving it out of the + // catalogue hid the one thing an operator needs to know - that it exists at all - and + // listing it without this flag would have the installer ask the DHP for 2629 chunks it + // has never had. + let mut carrying = manifest(); + carrying.src.as_mut().unwrap().files.push( + serde_json::from_value(serde_json::json!({ + "path": "planet_heightmap.pmtiles", "size": 8, "chunks": ["e".repeat(64)], + "chunks_sha256": "f".repeat(64), "optional": true, "on_dhp": false, "title": "Terrain source" + })) + .unwrap(), + ); + let dir = std::env::temp_dir().join(format!("odian-market-{}", uuid::Uuid::new_v4().simple())); + let items = items(&carrying, &dir, &BTreeSet::new()); + let terrain = items.iter().find(|item| item.key == "src:planet_heightmap.pmtiles").expect("it is listed"); + assert!(!terrain.on_dhp, "and it says where it does NOT come from"); + assert_eq!(terrain.title, "Terrain source"); + assert!(items.iter().all(|item| item.key == "src:planet_heightmap.pmtiles" || item.on_dhp)); + + // Chosen - off a drive, say - it is still left alone when the DHP is the source. + let chosen: BTreeSet = ["src:planet_heightmap.pmtiles".to_string()].into_iter().collect(); + let (_, from_dhp) = install_filter(&carrying, &chosen, true); + assert!(!from_dhp.contains("planet_heightmap.pmtiles"), "the DHP is never asked for it"); + let (_, from_drive) = install_filter(&carrying, &chosen, false); + assert!(from_drive.contains("planet_heightmap.pmtiles"), "a drive is exactly where it comes from"); + } + + #[test] + fn finishing_a_download_takes_nothing_away() { + // Dark V2 is installed and NOT in `extras`: it was required by the version that put it + // there, so no choice about it was ever recorded (which is exactly what happened when + // Dark V2 and Dark swapped places). Applying `extras` as a selection would read as + // "remove Dark V2", which is what Finish did before `held_keys`. + let data = data_dir(); + complete_tile_set(&data, "dark_v2", 'b', 2); + let extras: BTreeSet = ["src:osm/x.pmtiles".to_string()].into_iter().collect(); + let keep = held_keys(&manifest(), &data, &extras); + let dropped = to_remove(&manifest(), &data, &keep); + std::fs::remove_dir_all(&data).unwrap(); + assert!(keep.contains("tiles:dark_v2/ds"), "what is on disk stays chosen"); + assert!(keep.contains("src:osm/x.pmtiles"), "and so does what was asked for"); + assert!(dropped.is_empty(), "so finishing removes nothing: {dropped:?}"); + } + + #[test] + fn untick_still_means_remove_even_for_what_only_a_drive_replaces() { + // `selected` means "the operator asked for this", and `to_remove` finds what to delete + // by looking for present-and-not-selected. An item that always read as selected - which + // is what "installed counts as chosen" did to it - could never be removed at all. + let root = std::env::temp_dir().join(format!("odian-market-{}", uuid::Uuid::new_v4().simple())); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("planet_heightmap.pmtiles"), b"12345678").unwrap(); + std::fs::write(src.join("planet_heightmap.pmtiles.ok"), "f".repeat(64)).unwrap(); + + let mut carrying = manifest(); + carrying.src.as_mut().unwrap().files.push( + serde_json::from_value(serde_json::json!({ + "path": "planet_heightmap.pmtiles", "size": 8, "chunks": ["e".repeat(64)], + "chunks_sha256": "f".repeat(64), "optional": true, "on_dhp": false, "title": "Terrain source" + })) + .unwrap(), + ); + let dropped = to_remove(&carrying, &root, &BTreeSet::new()); + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!( + dropped.iter().map(|item| item.key.as_str()).collect::>(), + ["src:planet_heightmap.pmtiles"], + "unticked and on disk: it goes" + ); + } + #[test] fn an_install_fetches_the_required_items_and_the_chosen_ones() { - let (sets, paths) = install_filter(&manifest(), &BTreeSet::new()); + let (sets, paths) = install_filter(&manifest(), &BTreeSet::new(), true); assert_eq!((sets.into_iter().collect::>(), paths.len()), (vec!["dark/ds".to_string()], 0)); - let (sets, paths) = install_filter(&manifest(), &["tiles:dark_v2/ds".to_string()].into_iter().collect()); + let (sets, paths) = install_filter(&manifest(), &["tiles:dark_v2/ds".to_string()].into_iter().collect(), true); assert_eq!(sets.len(), 2); assert!(paths.is_empty()); } @@ -388,7 +589,7 @@ mod tests { item.title ); } - let (sets, paths) = install_filter(manifest, &BTreeSet::new()); + let (sets, paths) = install_filter(manifest, &BTreeSet::new(), true); println!("a new PC installs: {sets:?} {paths:?}"); } @@ -407,12 +608,12 @@ mod tests { let offered: Vec<&str> = items.iter().filter(|item| !item.required).map(|item| item.key.as_str()).collect(); assert_eq!(offered, vec!["tiles:dark_v2/ds", "src:osm/x.pmtiles"]); // Unchosen, an install of it brings the dark map alone. - let (tiles, src) = install_filter(&old_manifest(), &BTreeSet::new()); + let (tiles, src) = install_filter(&old_manifest(), &BTreeSet::new(), true); assert_eq!(tiles, BTreeSet::from(["dark/ds".to_string()])); assert!(src.is_empty()); // Chosen, they come along. let chosen = BTreeSet::from(["src:osm/x.pmtiles".to_string()]); - let (_, src) = install_filter(&old_manifest(), &chosen); + let (_, src) = install_filter(&old_manifest(), &chosen, true); assert_eq!(src, BTreeSet::from(["osm/x.pmtiles".to_string()])); } diff --git a/src-tauri/src/prune.rs b/src-tauri/src/prune.rs index 9b83486..32715d5 100644 --- a/src-tauri/src/prune.rs +++ b/src-tauri/src/prune.rs @@ -127,6 +127,12 @@ fn entries(dir: &Path, folders: bool) -> Vec<(String, PathBuf)> { /// Remove a folder the safe way (renamed aside, then emptied without following links). pub(crate) fn remove_folder(folder: &Path) -> Result<(), String> { + remove_folder_counting(folder, &mut |_| {}) +} + +/// The same, calling `removed` once per file deleted. A tile set is hundreds of thousands +/// of them: without a count there is nothing to show but a spinner. +pub(crate) fn remove_folder_counting(folder: &Path, removed: &mut dyn FnMut(u64)) -> Result<(), String> { let name = folder.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let aside = if name.starts_with(TRASH_PREFIX) { folder.to_path_buf() @@ -136,10 +142,10 @@ pub(crate) fn remove_folder(folder: &Path) -> Result<(), String> { std::fs::rename(folder, &aside).map_err(|e| format!("still in use ({e})"))?; aside }; - empty_and_remove(&aside).map_err(|e| format!("partly removed ({e}); the rest goes next time")) + empty_and_remove(&aside, removed).map_err(|e| format!("partly removed ({e}); the rest goes next time")) } -fn empty_and_remove(dir: &Path) -> std::io::Result<()> { +fn empty_and_remove(dir: &Path, removed: &mut dyn FnMut(u64)) -> std::io::Result<()> { for entry in std::fs::read_dir(dir)? { let path = entry?.path(); let meta = std::fs::symlink_metadata(&path)?; @@ -150,8 +156,9 @@ fn empty_and_remove(dir: &Path) -> std::io::Result<()> { } else { std::fs::remove_file(&path)?; } + removed(1); } else if meta.is_dir() { - empty_and_remove(&path)?; + empty_and_remove(&path, removed)?; } else { if meta.permissions().readonly() { let mut writable = meta.permissions(); @@ -160,6 +167,7 @@ fn empty_and_remove(dir: &Path) -> std::io::Result<()> { std::fs::set_permissions(&path, writable)?; } std::fs::remove_file(&path)?; + removed(1); } } std::fs::remove_dir(dir) diff --git a/src-tauri/src/stack.rs b/src-tauri/src/stack.rs index b1713d8..f78792a 100644 --- a/src-tauri/src/stack.rs +++ b/src-tauri/src/stack.rs @@ -530,19 +530,36 @@ fn datasets(config: &StackConfig) -> serde_json::Value { } entry }); + // A style the office fetches rather than draws must never be baked here: a baking + // server asked for a tile it lacks would render the vector map into a satellite + // photograph's place, or into OpenStreetMap's. Marked with its source, a miss is a + // clean 404 instead - this PC has no upstream of its own to ask. + let source = if !set.source.is_empty() { + set.source.clone() + } else if set.ext == ".jpg" || set.ext == ".jpeg" { + // Packs before the manifest said it: imagery was recognised by its name alone. + "wmts".to_string() + } else { + String::new() + }; // Imagery is .jpg, a drawn basemap .png: the tile server assumes .png unless told, // and looking for the wrong name is a 404 for every tile of that style. - if !set.ext.is_empty() { + if !set.ext.is_empty() || !source.is_empty() || set.cache_only { let styles = entry .as_object_mut() .expect("a dataset entry is an object") .entry("styles") .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); - let mut style = serde_json::json!({ "tile_ext": set.ext, "media_type": media_type(&set.ext) }); - // Imagery is photographed, not drawn: a baking server must never render a vector map - // into a satellite tile. Marked remote, a miss is a clean 404 (the PC has no upstream). - if set.ext == ".jpg" || set.ext == ".jpeg" { - style["source"] = serde_json::Value::String("wmts".to_string()); + let mut style = serde_json::json!({}); + if !set.ext.is_empty() { + style["tile_ext"] = serde_json::Value::String(set.ext.clone()); + style["media_type"] = serde_json::Value::String(media_type(&set.ext).to_string()); + } + if !source.is_empty() { + style["source"] = serde_json::Value::String(source); + } + if set.cache_only { + style["cache_only"] = serde_json::Value::Bool(true); } styles[&set.style] = style; } @@ -614,7 +631,7 @@ fn baker(config: &StackConfig) -> Option { /// The site's tile service, behind the DHP: `/tileserver` of the DHP's own address. /// The Data PC that runs it is not on the internet, so this is the only way to it, /// and the DHP's token check covers it like every other route. -fn site_tiles_url(site_url: &str) -> String { +pub(crate) fn site_tiles_url(site_url: &str) -> String { format!("{}/tileserver", site_url.trim_end_matches('/')) } @@ -676,10 +693,12 @@ fn client_env(config: &StackConfig) -> Vec<(&'static str, String)> { } else { env.push(("ODIAN_MCP_API_URL", config.ai_url.clone())); } - let basemap = config - .tile_sets + // Dark V2 is the map every install carries, so it is the one ODIAN opens on. Dark is + // the fallback for an install made before that was true, and anything that is not the + // height map for one that carries neither. + let basemap = ["dark_v2", "dark"] .iter() - .find(|set| set.style == "dark") + .find_map(|want| config.tile_sets.iter().find(|set| set.style == *want)) .or_else(|| config.tile_sets.iter().find(|set| set.style != "heightmap")); if let Some(set) = basemap { env.push(("ODIAN_TILE_STYLE", set.style.clone())); @@ -717,6 +736,21 @@ pub struct TileSet { /// for elevation. Empty when the pack predates this, and the tile server's own default /// (.png) then applies, which is what those older packs carried anyway. pub ext: String, + /// How the office GETS these tiles when they are ones it fetches rather than draws: + /// "wmts" for the satellite, Yandex and OpenStreetMap basemaps. Empty for a style + /// drawn from the vector archive (the dark basemaps) and for elevation. + /// + /// It matters on a PC: this one has no upstream, so a fetched style must answer a + /// miss with a clean 404 rather than let the baker draw the vector map in its place. + /// Imagery used to be recognised by its .jpg name, which OpenStreetMap - a drawn map, + /// fetched from openstreetmap.org as .png - would have failed. + pub source: String, + /// True when NOTHING can make another tile of this set - not this PC, not the office. + /// The population basemap is the case: it was composited once from the world population + /// raster onto the dark tiles. A miss has to be a 404, because the alternative is what + /// used to happen - the baker has no preset for the style, draws a plain road map, and + /// it is cached as population density. + pub cache_only: bool, } /// The installed version's database snapshot: its dump files and the extensions it needs. @@ -748,6 +782,8 @@ pub fn version_tile_sets(layout: &Layout) -> Result, String> { style: set.get("style")?.as_str()?.to_string(), dataset: set.get("dataset")?.as_str()?.to_string(), ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(), + source: set.get("source").and_then(|s| s.as_str()).unwrap_or_default().to_string(), + cache_only: set.get("cache_only").and_then(|c| c.as_bool()).unwrap_or(false), }) }) .collect() @@ -787,14 +823,14 @@ mod tests { install_id: "install".to_string(), tile_sets: tile_sets .iter() - .map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() }) + .map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new(), source: String::new(), cache_only: false }) .collect(), upstream_url: None, ai_url: String::new(), site_url: "https://dhp.example/dhp".to_string(), site_tile_sets: tile_sets .iter() - .map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new() }) + .map(|(s, d)| TileSet { style: s.to_string(), dataset: d.to_string(), ext: String::new(), source: String::new(), cache_only: false }) .collect(), } } @@ -890,10 +926,16 @@ mod tests { #[test] fn datasets_point_every_set_at_the_shared_tile_store() { - let doc = datasets(&config(&[("dark", "protomaps_20260423"), ("heightmap", "planet_heightmap")])); + // An empty folder of its own, not the fixed `D:\ODIAN` the other tests name: whether an + // archive is on disk is the very thing this asserts, and on a PC with an ODIAN drive + // plugged in, D: really does hold planet_heightmap.pmtiles. + let root = std::env::temp_dir().join(format!("odian-launcher-test-{}", uuid::Uuid::new_v4().simple())); + let mut cfg = config(&[("dark", "protomaps_20260423"), ("heightmap", "planet_heightmap")]); + cfg.layout = Layout::new(&root, "0.52.0"); + let doc = datasets(&cfg); let object = doc.as_object().expect("an object"); assert_eq!(object.len(), 2); - assert_eq!(object["protomaps_20260423"]["cache_dir"], r"D:\ODIAN\data\tiles"); + assert_eq!(object["protomaps_20260423"]["cache_dir"], text(cfg.layout.data_dir().join("tiles"))); assert!(object["planet_heightmap"].get("pmtiles").is_none(), "no pmtiles on disk, so none is named"); } @@ -901,9 +943,9 @@ mod tests { fn datasets_name_each_style_extension() { let mut cfg = config(&[]); cfg.tile_sets = vec![ - TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string() }, - TileSet { style: "satellite_hd".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".jpg".to_string() }, - TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: ".lerc".to_string() }, + TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string(), source: String::new(), cache_only: false }, + TileSet { style: "satellite_hd".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".jpg".to_string(), source: String::new(), cache_only: false }, + TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: ".lerc".to_string(), source: String::new(), cache_only: false }, ]; let doc = datasets(&cfg); // Two styles share one dataset: both must be named under it, not overwrite each other. @@ -914,17 +956,61 @@ mod tests { assert_eq!(doc["planet_heightmap"]["styles"]["heightmap"]["tile_ext"], ".lerc"); } + #[test] + fn a_fetched_style_is_never_drawn_from_the_vector_archive() { + // OpenStreetMap is a DRAWN map, but the office draws none of it: it asks + // openstreetmap.org and keeps the answer. Its tiles are .png like the dark basemap, + // so nothing but the manifest's own word distinguishes them - and without that word + // a PC holding the vector archive would render protomaps into OSM's place and call + // the result OpenStreetMap. + let (root, mut cfg) = baking_install(true); + cfg.tile_sets.push(TileSet { + style: "osm".to_string(), + dataset: "protomaps_20260423".to_string(), + ext: ".png".to_string(), + source: "wmts".to_string(), + cache_only: false, + }); + let doc = datasets(&cfg); + std::fs::remove_dir_all(&root).unwrap(); + let styles = &doc["protomaps_20260423"]["styles"]; + assert_eq!(styles["osm"]["source"], "wmts", "the office fetches it, so this PC may not bake it"); + assert_eq!(styles["osm"]["tile_ext"], ".png"); + assert!(styles["dark_v2"].get("source").is_none(), "the dark basemap IS drawn here"); + } + + #[test] + fn a_set_nothing_can_reproduce_is_served_and_never_drawn() { + // Population density is data composited from a raster, not a map anyone draws. + // On a PC holding the vector archive the baker would happily draw SOMETHING for + // it - a plain road map - and the cache would keep it as a census. + let (root, mut cfg) = baking_install(true); + cfg.tile_sets.push(TileSet { + style: "popdark".to_string(), + dataset: "protomaps_20260423".to_string(), + ext: ".png".to_string(), + source: String::new(), + cache_only: true, + }); + let doc = datasets(&cfg); + std::fs::remove_dir_all(&root).unwrap(); + let styles = &doc["protomaps_20260423"]["styles"]; + assert_eq!(styles["popdark"]["cache_only"], true); + assert!(styles["popdark"].get("source").is_none(), "it is not fetched either, just held"); + assert!(doc["protomaps_20260423"].get("cache_only").is_none(), "the dataset still bakes the basemap"); + } + #[test] fn a_pc_that_downloaded_little_is_still_offered_the_sites_maps() { // What this install actually holds: the one set every install gets. let mut cfg = config(&[]); cfg.tile_sets = - vec![TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string() }]; + vec![TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string(), source: String::new(), cache_only: false }]; // What the version names, and the site can therefore draw. cfg.site_tile_sets = vec![ - TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string() }, - TileSet { style: "satellite_hd".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".jpg".to_string() }, - TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: ".lerc".to_string() }, + TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string(), source: String::new(), cache_only: false }, + TileSet { style: "satellite_hd".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".jpg".to_string(), source: String::new(), cache_only: false }, + TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: ".lerc".to_string(), source: String::new(), cache_only: false }, ]; let env = client_env(&cfg); @@ -1005,9 +1091,9 @@ mod tests { let mut cfg = config(&[]); cfg.layout = Layout::new(&root, "0.58.0"); cfg.tile_sets = vec![ - TileSet { style: "dark_v2".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string() }, - TileSet { style: "satellite_hd".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".jpg".to_string() }, - TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: ".lerc".to_string() }, + TileSet { style: "dark_v2".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".png".to_string(), source: String::new(), cache_only: false }, + TileSet { style: "satellite_hd".to_string(), dataset: "protomaps_20260423".to_string(), ext: ".jpg".to_string(), source: String::new(), cache_only: false }, + TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: ".lerc".to_string(), source: String::new(), cache_only: false }, ]; let src = cfg.layout.data_dir().join("src"); std::fs::create_dir_all(&src).unwrap(); @@ -1064,7 +1150,7 @@ mod tests { std::fs::remove_dir_all(&root).unwrap(); assert_eq!( sets, - vec![TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: String::new() }] + vec![TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: String::new(), source: String::new(), cache_only: false }] ); } } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index cd17bb6..c964155 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -38,6 +38,28 @@ pub struct LauncherState { /// an install from before the marketplace then counts what it has on disk as chosen. #[serde(default)] pub extras: Option>, + /// How far this PC has replayed its DHP's tile journal: the tiles other operators baked + /// while it was away. None until the first sync. + #[serde(default)] + pub tile_sync: Option, +} + +/// Whose journal, and how far through it. The site is named because a PC moved to another +/// DHP is at seq 0 there: seq numbers mean nothing outside the journal that issued them. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TileSync { + pub site: String, + pub seq: i64, +} + +impl LauncherState { + /// Where this PC has replayed to on `site`, zero for a site it has never synced with. + pub fn tile_seq(&self, site: &str) -> i64 { + match &self.tile_sync { + Some(sync) if sync.site == site => sync.seq, + _ => 0, + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src-tauri/src/tilesync.rs b/src-tauri/src/tilesync.rs new file mode 100644 index 0000000..aace3ac --- /dev/null +++ b/src-tauri/src/tilesync.rs @@ -0,0 +1,425 @@ +//! Tiles the site baked while this PC was away. +//! +//! An operator who is online already SEES every tile the office has ever baked: his tile +//! server asks his DHP, the DHP proxies to the Data PC that holds the cache, and the answer +//! is drawn. What he does not do is KEEP it - the answer goes to the screen, not to disk - +//! so the day he unplugs he has exactly what he installed and nothing that was baked since. +//! +//! This is the other half of that bridge, asked a different question: "what have you baked +//! since seq N?" The site's journal answers, this PC fetches those tiles over the same +//! proxied, token-checked path, and writes them where its own tile server reads. +//! +//! No signature and no version, deliberately. A signed manifest exists to make an OFFLINE +//! install verifiable from a drive that anyone could have handed over; keeping an install +//! that already exists level with the site it is talking to is a different job, and it is +//! the same trust as the tile that was drawn on screen a moment ago. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; + +use crate::fsutil::write_atomic; +use crate::stack::{site_tiles_url, TileSet}; + +/// Rows per page. The site caps its own answer at 5000; asking for a day of baking in one +/// call would only mean holding it all in memory before writing the first tile. +const PAGE: u32 = 2000; +/// Tiles fetched at once. The site is one PC serving every operator: this is a share, not +/// a race. +const LANES: usize = 6; +const PAGE_TIMEOUT: Duration = Duration::from_secs(20); +/// The "how much is there?" question rides on every check-in, so it waits a short while +/// and then gives up: an offer that is one refresh late costs nothing, a check-in that +/// hangs on a slow link is felt immediately. +const PROBE_TIMEOUT: Duration = Duration::from_secs(6); +const TILE_TIMEOUT: Duration = Duration::from_secs(30); + +/// What a tile file may be called. A journal row names a path this PC will WRITE, so none +/// of it is taken on trust: the style and dataset must be ones this PC already holds (see +/// `wanted`), the coordinates are integers, and the extension comes from this list. Without +/// that, a row saying `style: "..\\..\\versions"` would be a way into the install. +const EXTS: [&str; 6] = [".png", ".jpg", ".jpeg", ".webp", ".lerc", ".tif"]; + +/// What the site would hand over right now. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Waiting { + pub tiles: u64, + pub bytes: u64, + /// The count is one page and the site holds more behind it: the operator is shown + /// "2000+" rather than a number that is quietly wrong. + pub more: bool, +} + +/// How a pull ended. Always returned, even when it stopped early: `seq` is how far this PC +/// may honestly claim to have replayed, and a partial pull is a normal thing to resume. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Pulled { + pub tiles: u64, + pub bytes: u64, + /// Rows whose tile this PC already had, byte for byte. The first sync after an install + /// is mostly these: it replays the site's journal from the beginning, and the pack it + /// installed already carried most of what is in there. + pub held: u64, + /// Rows the site no longer has a tile for. Not an error: a tile can be removed. + pub gone: u64, + pub seq: i64, + pub problem: Option, +} + +/// Told to the window while a pull runs. +#[derive(Clone, Debug, Serialize)] +pub struct Progress { + pub tiles: u64, + pub bytes: u64, + /// What the current page is worth, so a bar can move within it. + pub page_tiles: u64, + pub page_done: u64, +} + +#[derive(Clone, Debug, Deserialize)] +struct Row { + style: String, + dataset: String, + z: i64, + x: i64, + y: i64, + ext: String, + size: u64, + sha256: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct Page { + head: i64, + next: i64, + bytes: u64, + more: bool, + rows: Vec, +} + +/// `style/dataset`, as the journal reader names a set. +fn key(set: &TileSet) -> String { + format!("{}/{}", set.style, set.dataset) +} + +/// One page of "what is new", for the sets this PC holds. +async fn page( + http: &reqwest::Client, + site_url: &str, + token: &str, + sets: &[TileSet], + seq: i64, + limit: u32, +) -> Result { + let mut query: Vec<(&str, String)> = vec![("seq", seq.to_string()), ("limit", limit.to_string())]; + query.extend(sets.iter().map(|set| ("set", key(set)))); + let url = format!("{}/journal/after", site_tiles_url(site_url)); + let response = http + .get(url) + .bearer_auth(token) + .query(&query) + .timeout(PAGE_TIMEOUT) + .send() + .await + .map_err(|e| format!("the DHP did not answer: {e}"))?; + if !response.status().is_success() { + // A site whose tile service predates the journal answers 404 here. That is not a + // failure to report at the operator: it simply has nothing to offer yet. + return Err(format!("the DHP answered {}", response.status())); + } + response.json::().await.map_err(|e| format!("the DHP's answer made no sense: {e}")) +} + +/// Where a row's tile belongs on this PC, or None when the row is not one we asked for. +/// +/// `wanted` is what this PC actually holds. Checking against it is what makes the path +/// safe: `style` and `dataset` are never used as text from the answer, they are matched +/// against strings this PC already had. +fn tile_path(data_dir: &Path, wanted: &[TileSet], row: &Row) -> Option { + let set = wanted.iter().find(|set| set.style == row.style && set.dataset == row.dataset)?; + let ext = EXTS.iter().find(|allowed| row.ext.eq_ignore_ascii_case(allowed))?; + if row.z < 0 || row.x < 0 || row.y < 0 || row.sha256.len() != 64 || !row.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + Some( + data_dir + .join("tiles") + .join(&set.style) + .join(&set.dataset) + .join(row.z.to_string()) + .join(row.x.to_string()) + .join(format!("{}{}", row.y, ext)), + ) +} + +/// What the site has for this PC beyond what it holds: a count and a size, not the rows. +/// Asked on every check-in, so it is deliberately the cheap question - the site counts, +/// and sends nothing it would have to read off its own disk. +pub async fn waiting( + http: &reqwest::Client, + site_url: &str, + token: &str, + sets: &[TileSet], + seq: i64, +) -> Result { + if sets.is_empty() { + return Ok(Waiting::default()); + } + let mut query: Vec<(&str, String)> = vec![("seq", seq.to_string())]; + query.extend(sets.iter().map(|set| ("set", key(set)))); + let url = format!("{}/journal/pending", site_tiles_url(site_url)); + let response = http + .get(url) + .bearer_auth(token) + .query(&query) + .timeout(PROBE_TIMEOUT) + .send() + .await + .map_err(|e| format!("the DHP did not answer: {e}"))?; + if !response.status().is_success() { + // A tile service from before the journal answers 404. Nothing to offer, nothing to say. + return Err(format!("the DHP answered {}", response.status())); + } + response.json::().await.map_err(|e| format!("the DHP's answer made no sense: {e}")) +} + +enum Fetched { + Written(u64), + /// Already on this PC, byte for byte. + Held, + /// The site has no tile there any more. + Gone, +} + +/// Is this exact tile already here? Checked before asking the site, because a PC that has +/// just installed is at seq 0 and will be walked through the site's entire history - nearly +/// all of which its pack already carried. The size is read first and the bytes only when it +/// matches, so a tile that genuinely changed costs one `stat` to find out. +async fn already_held(path: &Path, size: u64, expect: &str) -> bool { + match tokio::fs::metadata(path).await { + Ok(meta) if meta.len() == size => match tokio::fs::read(path).await { + Ok(bytes) => odian_packs::hashing::sha256_hex(&bytes) == expect, + Err(_) => false, + }, + _ => false, + } +} + +async fn fetch( + http: reqwest::Client, + url: String, + token: String, + path: PathBuf, + expect: String, + size: u64, + cancel: CancellationToken, +) -> Result { + if cancel.is_cancelled() { + return Err("cancelled".to_string()); + } + if already_held(&path, size, &expect).await { + return Ok(Fetched::Held); + } + let response = http + .get(&url) + .bearer_auth(&token) + .timeout(TILE_TIMEOUT) + .send() + .await + .map_err(|e| format!("{url}: {e}"))?; + if response.status() == reqwest::StatusCode::NOT_FOUND || response.status() == reqwest::StatusCode::GONE { + return Ok(Fetched::Gone); + } + if !response.status().is_success() { + return Err(format!("{url}: {}", response.status())); + } + let bytes = response.bytes().await.map_err(|e| format!("{url}: {e}"))?; + // The journal said what these bytes are. A tile that arrives truncated would otherwise + // be written, cached and drawn as a hole in the map that nothing ever re-fetches. + let got = odian_packs::hashing::sha256_hex(&bytes); + if got != expect { + return Err(format!("{url}: arrived as {got}, the DHP said {expect}")); + } + write_atomic(&path, &bytes)?; + Ok(Fetched::Written(bytes.len() as u64)) +} + +/// Replay the site's journal from `seq` into this PC's tiles. +/// +/// Returns however far it got. A page is only counted as replayed when every row in it +/// landed, so an interrupted pull resumes from a seq this PC can honestly stand behind +/// rather than skipping the tiles it missed. +pub async fn pull( + http: &reqwest::Client, + site_url: &str, + token: &str, + sets: &[TileSet], + data_dir: &Path, + seq: i64, + cancel: &CancellationToken, + report: impl Fn(Progress), +) -> Pulled { + let mut out = Pulled { seq, ..Pulled::default() }; + if sets.is_empty() { + return out; + } + let base = site_tiles_url(site_url); + loop { + if cancel.is_cancelled() { + return out; + } + let page = match page(http, site_url, token, sets, out.seq, PAGE).await { + Ok(page) => page, + Err(problem) => { + out.problem = Some(problem); + return out; + } + }; + if page.rows.is_empty() { + // Nothing left: this PC is level with the site, including everything the site + // baked for sets this PC does not hold (it will never be offered those). + out.seq = page.head; + return out; + } + let page_tiles = page.rows.len() as u64; + let mut done = 0u64; + let mut work: Vec<(PathBuf, String, String, u64)> = Vec::with_capacity(page.rows.len()); + for row in &page.rows { + match tile_path(data_dir, sets, row) { + Some(path) => { + let url = format!("{}/tiles/{}/{}/{}/{}/{}{}", base, row.style, row.dataset, row.z, row.x, row.y, row.ext); + work.push((path, url, row.sha256.clone(), row.size)); + } + // A row for something this PC did not ask for. It cannot be placed, so it is + // passed over rather than guessed at - and the page still counts as replayed. + None => done += 1, + } + } + + let mut lanes = tokio::task::JoinSet::new(); + let mut queue = work.into_iter(); + let mut failure: Option = None; + for _ in 0..LANES { + let Some((path, url, sha, size)) = queue.next() else { break }; + lanes.spawn(fetch(http.clone(), url, token.to_string(), path, sha, size, cancel.clone())); + } + while let Some(joined) = lanes.join_next().await { + match joined { + Ok(Ok(Fetched::Written(bytes))) => { + out.tiles += 1; + out.bytes += bytes; + } + Ok(Ok(Fetched::Held)) => out.held += 1, + Ok(Ok(Fetched::Gone)) => out.gone += 1, + Ok(Err(problem)) => { + failure.get_or_insert(problem); + } + Err(e) => { + failure.get_or_insert(format!("a tile could not be fetched: {e}")); + } + } + done += 1; + report(Progress { tiles: out.tiles, bytes: out.bytes, page_tiles, page_done: done }); + if failure.is_some() || cancel.is_cancelled() { + lanes.abort_all(); + break; + } + if let Some((path, url, sha, size)) = queue.next() { + lanes.spawn(fetch(http.clone(), url, token.to_string(), path, sha, size, cancel.clone())); + } + } + + if let Some(problem) = failure { + // The seq stays where it was: the tiles that DID land are kept (they are correct, + // and hashed), and the page is simply replayed next time. + out.problem = Some(problem); + return out; + } + if cancel.is_cancelled() { + return out; + } + out.seq = page.next; + if !page.more { + return out; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn set(style: &str, dataset: &str) -> TileSet { + TileSet { + style: style.to_string(), + dataset: dataset.to_string(), + ext: ".png".to_string(), + source: String::new(), + cache_only: false, + } + } + + fn row(style: &str, dataset: &str, ext: &str) -> Row { + Row { + style: style.to_string(), + dataset: dataset.to_string(), + z: 5, + x: 1, + y: 2, + ext: ext.to_string(), + size: 10, + sha256: "a".repeat(64), + } + } + + #[test] + fn a_row_lands_where_the_tile_server_reads() { + let sets = [set("dark", "ds")]; + let path = tile_path(Path::new(r"D:\ODIAN\data"), &sets, &row("dark", "ds", ".png")).unwrap(); + assert_eq!(path, Path::new(r"D:\ODIAN\data\tiles\dark\ds\5\1\2.png")); + } + + #[test] + fn nothing_in_a_row_is_taken_on_trust() { + // The row names a path this PC will write. Everything about it is checked against + // what this PC already holds, so an answer cannot reach outside the tile store. + let sets = [set("dark", "ds")]; + let escape = row(r"..\..\versions", "ds", ".png"); + assert!(tile_path(Path::new("data"), &sets, &escape).is_none(), "a style this PC does not hold"); + assert!(tile_path(Path::new("data"), &sets, &row("dark", "../src", ".png")).is_none(), "nor a dataset"); + assert!(tile_path(Path::new("data"), &sets, &row("dark", "ds", ".exe")).is_none(), "nor any file name"); + + let mut negative = row("dark", "ds", ".png"); + negative.y = -1; + assert!(tile_path(Path::new("data"), &sets, &negative).is_none(), "nor a coordinate that climbs"); + + let mut bad_digest = row("dark", "ds", ".png"); + bad_digest.sha256 = "nonsense".to_string(); + assert!(tile_path(Path::new("data"), &sets, &bad_digest).is_none(), "a row with no usable digest"); + } + + #[tokio::test] + async fn a_tile_this_pc_already_has_is_not_asked_for_again() { + // The first sync after an install walks the site's whole journal, and the pack that + // was just installed carried most of it. Without this every one of those tiles would + // be downloaded a second time to land identical bytes on top of themselves. + let dir = std::env::temp_dir().join(format!("odian-tilesync-{}", uuid::Uuid::new_v4().simple())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("5.png"); + std::fs::write(&path, b"a tile").unwrap(); + let digest = odian_packs::hashing::sha256_hex(b"a tile"); + + assert!(already_held(&path, 6, &digest).await, "the same bytes"); + assert!(!already_held(&path, 6, &"0".repeat(64)).await, "same size, different tile"); + assert!(!already_held(&path, 99, &digest).await, "a size that does not match is not even read"); + assert!(!already_held(&dir.join("nothing.png"), 6, &digest).await, "a tile this PC lacks"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_set_this_pc_holds_is_named_the_way_the_site_names_it() { + assert_eq!(key(&set("popdark", "protomaps_20260423")), "popdark/protomaps_20260423"); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e12a6f6..9d1a3e9 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.12", + "version": "0.1.24", "identifier": "fr.odian.launcher", "build": { "frontendDist": "../dist", diff --git a/src/assets/mark.png b/src/assets/mark.png index 51dddab..2c736a5 100644 Binary files a/src/assets/mark.png and b/src/assets/mark.png differ diff --git a/src/assets/mark.svg b/src/assets/mark.svg new file mode 100644 index 0000000..b1d6db1 --- /dev/null +++ b/src/assets/mark.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + diff --git a/src/components/ActionPanel.svelte b/src/components/ActionPanel.svelte index f099a8d..06322f5 100644 --- a/src/components/ActionPanel.svelte +++ b/src/components/ActionPanel.svelte @@ -62,6 +62,10 @@ return { label: "Restoring database", tone: "accent", pulse: true, meta: stage }; case "syncing": return { label: "Syncing with the DHP", tone: "accent", pulse: true, meta: version(s?.installed) }; + case "pulling_tiles": + return { label: "Taking map tiles", tone: "accent", pulse: true, meta: tileMeta }; + case "removing": + return { label: "Removing", tone: "accent", pulse: true, meta: rp ? `${rp.index}/${rp.count}` : null }; case "update": return { label: "Update required", tone: "warn", pulse: false, meta: `${s?.installed} → ${s?.available}` }; case "launch": @@ -76,6 +80,29 @@ } }); + // What the DHP has baked, for the sets this PC holds, since it last looked. Zero means + // level with the site - which is worth saying nothing at all about. + // Chosen, and not all here. ODIAN runs without it, so this is said beside Launch rather + // than in place of it - but it IS said: the marketplace was the only place that knew. + const unfinished = $derived(s?.unfinished ?? null); + const unfinishedNames = $derived.by(() => { + const titles = unfinished?.titles ?? []; + if (titles.length === 0) return ""; + const shown = titles.slice(0, 2).join(", "); + return (unfinished?.items ?? 0) > titles.length || titles.length > 2 ? `${shown}…` : shown; + }); + + const tiles = $derived(s?.tiles_waiting ?? null); + const tilesOffered = $derived(tiles !== null && tiles.tiles > 0); + const tilesCount = $derived(tiles === null ? "" : `${formatCount(tiles.tiles)}${tiles.more ? "+" : ""}`); + + const rp = $derived(launcher.removeProgress); + const removeFraction = $derived(rp === null || rp.total === 0 ? null : Math.min(rp.done / rp.total, 1)); + + const tp = $derived(launcher.tileProgress); + const tileMeta = $derived(tp === null ? null : `${formatCount(tp.tiles)} tiles`); + const tileFraction = $derived(tp === null || tp.page_tiles === 0 ? null : tp.page_done / tp.page_tiles); + const showNotice = $derived(launcher.notice !== null && !(action === "error" && launcher.notice === s?.error)); @@ -182,6 +209,29 @@

{/if} + {#if unfinished} +

+ + + {unfinished.items} + {unfinished.items === 1 ? "chosen map is" : "chosen maps are"} only partly downloaded + {#if unfinishedNames}({unfinishedNames}){/if}. + + +

+ {/if} + {#if tilesOffered} + +

+ + + {tilesCount} new map tiles on {site} ({formatBytes(tiles?.bytes ?? 0)}). + + +

+ {/if} {:else if action === "installing"}
@@ -236,6 +286,26 @@

Sending this PC's changes to {site} and taking in the other operators' work.

+ {:else if action === "removing"} +
+
+ Removing {rp?.title ?? "what you unticked"} + {#if removeFraction !== null}{Math.floor(removeFraction * 100)}%{/if} +
+ + {#if rp}

{formatCount(rp.done)} / {formatCount(rp.total)} files

{/if} +
+

Deleting a map is hundreds of thousands of files. Nothing is downloaded meanwhile.

+ {:else if action === "pulling_tiles"} +
+
+ Taking map tiles from {site} + {#if tp}{formatBytes(tp.bytes)}{/if} +
+ +
+

Tiles the other operators baked while this PC was away. It can be stopped and resumed.

+ {:else if action === "stack"} {#if s?.map_from_site === false} diff --git a/src/components/MarketplaceDrawer.svelte b/src/components/MarketplaceDrawer.svelte index dfed210..d27fe1a 100644 --- a/src/components/MarketplaceDrawer.svelte +++ b/src/components/MarketplaceDrawer.svelte @@ -1,9 +1,10 @@