updtae
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 32 KiB |
@@ -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<Vec<market::Check>> {
|
||||
launcher.check_market(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn apply_market(app: AppHandle, launcher: State<'_, Launcher>, selected: Vec<String>) -> CmdResult<()> {
|
||||
launcher.apply_market(&app, selected).await
|
||||
@@ -115,6 +126,11 @@ pub async fn get_stack(launcher: State<'_, Launcher>) -> CmdResult<Vec<StackEven
|
||||
Ok(launcher.stack_states())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sync_tiles(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
|
||||
launcher.sync_tiles(&app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sync_now(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<()> {
|
||||
launcher.sync_now(&app).await
|
||||
|
||||
@@ -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<bool>,
|
||||
/// Why, in one line, when `map_from_site` is false.
|
||||
pub map_problem: Option<String>,
|
||||
/// 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<Waiting>,
|
||||
/// 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<Unfinished>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// What the marketplace shows (`get_market`).
|
||||
@@ -87,6 +112,17 @@ pub struct Market {
|
||||
pub free_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
/// 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<bool>,
|
||||
map_from_site: Option<bool>,
|
||||
map_problem: Option<String>,
|
||||
tiles_waiting: Option<Waiting>,
|
||||
unfinished: Option<Unfinished>,
|
||||
/// What the last count found on disk, kept for as long as nothing can have changed it.
|
||||
market_checked: Option<Vec<market::Check>>,
|
||||
/// 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<stack::TileSet>, 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<String, String> {
|
||||
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<Vec<market::Check>, 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<String>) -> 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<String> =
|
||||
market::to_add(manifest, &install_dir.join("data"), &extras).into_iter().map(|item| item.title).collect();
|
||||
self.log(app, "info", format!("Downloading {}", titles.join(", ")));
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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<String>) -> Vec<Check> {
|
||||
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<String>) ->
|
||||
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<String>) ->
|
||||
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<String>) ->
|
||||
|
||||
/// 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<String>) -> (BTreeSet<String>, BTreeSet<String>) {
|
||||
///
|
||||
/// `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<String>,
|
||||
from_dhp: bool,
|
||||
) -> (BTreeSet<String>, BTreeSet<String>) {
|
||||
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<String>) -> (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<String>
|
||||
.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<String>) -> BTreeSet<String> {
|
||||
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<String>,
|
||||
checked: &[Check],
|
||||
) -> Vec<Item> {
|
||||
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<String>) -> Vec<Item> {
|
||||
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<String> = ["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<String> = ["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::<Vec<_>>(),
|
||||
["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::<Vec<_>>(), 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()]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
/// 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<Vec<TileSet>, 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 }]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<std::collections::BTreeSet<String>>,
|
||||
/// 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<TileSync>,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Row>,
|
||||
}
|
||||
|
||||
/// `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<Page, String> {
|
||||
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::<Page>().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<PathBuf> {
|
||||
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<Waiting, String> {
|
||||
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::<Waiting>().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<Fetched, String> {
|
||||
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<String> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||