update
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
//! The marketplace: the tile sets and source files of a version an operator may choose.
|
||||
//!
|
||||
//! The DHP decides what is optional: a manifest item with `"optional": true` (and a title and
|
||||
//! description) is left out of Install and offered here; everything else is part of every
|
||||
//! install. A manifest from before the marketplace flags nothing, and the same rule is applied
|
||||
//! to it here instead (see `pre_catalog`). What the operator chose is
|
||||
//! `LauncherState::extras`, a set of item keys that outlive versions (`tiles:<style>/<dataset>`,
|
||||
//! `src:<path>`), so an update refreshes exactly the chosen items.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use odian_packs::manifest::{Manifest, SrcFile, TileSet};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Kind {
|
||||
Tiles,
|
||||
Src,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Presence {
|
||||
/// Every byte of this version's item is on the PC.
|
||||
Installed,
|
||||
/// Part of it is: a download that stopped, or an older version's copy. Installing resumes.
|
||||
Partial,
|
||||
Absent,
|
||||
}
|
||||
|
||||
/// One item as the marketplace shows it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Item {
|
||||
pub key: String,
|
||||
pub kind: Kind,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
/// What installing it downloads, in full.
|
||||
pub bytes: 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,
|
||||
}
|
||||
|
||||
/// The map ODIAN opens on, kept required by `catalog.json`.
|
||||
const ALWAYS_INSTALLED_STYLE: &str = "dark";
|
||||
|
||||
/// A manifest from before the marketplace (pack 0.61.0) flags nothing optional, so everything
|
||||
/// in it would be part of every install: copying such a version from a drive would offer no
|
||||
/// choice at all, and the operator would face the whole 228 GB or nothing. The catalog's own
|
||||
/// rule is applied to it instead - the app, the database and the dark map come with every
|
||||
/// install, the rest is offered - so an older version is chosen from like a current one. A
|
||||
/// manifest that flags anything is the DHP's to decide and is taken exactly as it stands.
|
||||
fn pre_catalog(manifest: &Manifest) -> bool {
|
||||
!manifest.tiles.iter().flat_map(|tiles| &tiles.sets).any(|set| set.optional)
|
||||
&& !manifest.src.iter().flat_map(|src| &src.files).any(|file| file.optional)
|
||||
}
|
||||
|
||||
fn tiles_optional(set: &TileSet, pre_catalog: bool) -> bool {
|
||||
set.optional || (pre_catalog && set.style != ALWAYS_INSTALLED_STYLE)
|
||||
}
|
||||
|
||||
fn src_optional(file: &SrcFile, pre_catalog: bool) -> bool {
|
||||
file.optional || pre_catalog
|
||||
}
|
||||
|
||||
pub fn tiles_key(set: &TileSet) -> String {
|
||||
format!("tiles:{}/{}", set.style, set.dataset)
|
||||
}
|
||||
|
||||
pub fn src_key(file: &SrcFile) -> String {
|
||||
format!("src:{}", file.path)
|
||||
}
|
||||
|
||||
fn tile_set_dir(data_dir: &Path, set: &TileSet) -> PathBuf {
|
||||
data_dir.join("tiles").join(&set.style).join(&set.dataset)
|
||||
}
|
||||
|
||||
fn src_path(data_dir: &Path, file: &SrcFile) -> PathBuf {
|
||||
file.path.split('/').fold(data_dir.join("src"), |path, part| path.join(part))
|
||||
}
|
||||
|
||||
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(suffix);
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// A tile set is complete when the installer's `.blocks-done.json` names this index and lists
|
||||
/// all its blocks; it is partial when its folder exists at all.
|
||||
fn tile_set_presence(data_dir: &Path, set: &TileSet) -> Presence {
|
||||
let dir = tile_set_dir(data_dir, set);
|
||||
let done = std::fs::read(dir.join(".blocks-done.json"))
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_slice::<serde_json::Value>(&raw).ok())
|
||||
.filter(|done| done["index_sha256"].as_str() == Some(set.index_sha256.as_str()))
|
||||
.and_then(|done| done["blocks"].as_array().map(Vec::len));
|
||||
match done {
|
||||
Some(blocks) if blocks as u64 >= set.blocks => Presence::Installed,
|
||||
_ if dir.is_dir() => Presence::Partial,
|
||||
_ => Presence::Absent,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let path = src_path(data_dir, file);
|
||||
let marker_ok = std::fs::read_to_string(with_suffix(&path, ".ok")).is_ok_and(|text| text.trim() == file.chunks_sha256);
|
||||
let size_ok = std::fs::metadata(&path).is_ok_and(|md| md.is_file() && md.len() == file.size);
|
||||
match (marker_ok && size_ok, path.exists()) {
|
||||
(true, _) => Presence::Installed,
|
||||
(false, true) => Presence::Partial,
|
||||
(false, false) => Presence::Absent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tile set and source file of `manifest`, required ones first, as the marketplace shows
|
||||
/// them. `extras` is the operator's choice.
|
||||
pub fn items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
||||
items_from(manifest, data_dir, None, extras)
|
||||
}
|
||||
|
||||
/// The same, for an install that copies another one: an item the source does not carry cannot
|
||||
/// be offered, whatever the version's manifest lists. `presence` stays what THIS PC has.
|
||||
pub fn items_from(
|
||||
manifest: &Manifest,
|
||||
data_dir: &Path,
|
||||
source_data: Option<&Path>,
|
||||
extras: &BTreeSet<String>,
|
||||
) -> Vec<Item> {
|
||||
let mut out = all_items(manifest, data_dir, extras);
|
||||
if let Some(source) = source_data {
|
||||
let carried: BTreeSet<String> = all_items(manifest, source, extras)
|
||||
.into_iter()
|
||||
.filter(|item| item.presence != Presence::Absent)
|
||||
.map(|item| item.key)
|
||||
.collect();
|
||||
out.retain(|item| carried.contains(&item.key));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn all_items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
||||
let old = pre_catalog(manifest);
|
||||
let mut out = Vec::new();
|
||||
for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) {
|
||||
let key = tiles_key(set);
|
||||
let optional = tiles_optional(set, old);
|
||||
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,
|
||||
required: !optional,
|
||||
presence: tile_set_presence(data_dir, set),
|
||||
kind: Kind::Tiles,
|
||||
key,
|
||||
});
|
||||
}
|
||||
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);
|
||||
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,
|
||||
required: !optional,
|
||||
presence: src_presence(data_dir, file),
|
||||
kind: Kind::Src,
|
||||
key,
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|item| !item.required);
|
||||
out
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
let old = pre_catalog(manifest);
|
||||
let tile_sets = manifest
|
||||
.tiles
|
||||
.iter()
|
||||
.flat_map(|tiles| &tiles.sets)
|
||||
.filter(|set| !tiles_optional(set, old) || extras.contains(&tiles_key(set)))
|
||||
.map(|set| format!("{}/{}", set.style, set.dataset))
|
||||
.collect();
|
||||
let src_paths = manifest
|
||||
.src
|
||||
.iter()
|
||||
.flat_map(|src| &src.files)
|
||||
.filter(|file| !src_optional(file, old) || extras.contains(&src_key(file)))
|
||||
.map(|file| file.path.clone())
|
||||
.collect();
|
||||
(tile_sets, src_paths)
|
||||
}
|
||||
|
||||
/// The optional items this PC already has, complete or not. A PC installed before the
|
||||
/// marketplace has all of them: its first choice starts from what is on disk, so nothing is
|
||||
/// removed or downloaded again behind the operator's back.
|
||||
pub fn present_extras(manifest: &Manifest, data_dir: &Path) -> BTreeSet<String> {
|
||||
items(manifest, data_dir, &BTreeSet::new())
|
||||
.into_iter()
|
||||
.filter(|item| !item.required && item.presence != Presence::Absent)
|
||||
.map(|item| item.key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep only the keys of optional items `manifest` offers: a stale or required key means nothing.
|
||||
pub fn optional_keys(manifest: &Manifest, keys: impl IntoIterator<Item = String>) -> BTreeSet<String> {
|
||||
let offered: BTreeSet<String> =
|
||||
items(manifest, Path::new(""), &BTreeSet::new()).into_iter().filter(|item| !item.required).map(|item| item.key).collect();
|
||||
keys.into_iter().filter(|key| offered.contains(key)).collect()
|
||||
}
|
||||
|
||||
/// The optional items on this PC the operator no longer wants.
|
||||
pub fn to_remove(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
||||
items(manifest, data_dir, extras)
|
||||
.into_iter()
|
||||
.filter(|item| !item.required && !item.selected && item.presence != Presence::Absent)
|
||||
.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()
|
||||
.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> {
|
||||
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(()) };
|
||||
}
|
||||
}
|
||||
for file in manifest.src.iter().flat_map(|src| &src.files) {
|
||||
if src_key(file) == key {
|
||||
let path = src_path(data_dir, file);
|
||||
for extra in ["", ".ok", ".chunks.json", ".part"] {
|
||||
let target = with_suffix(&path, extra);
|
||||
if target.exists() {
|
||||
std::fs::remove_file(&target).map_err(|e| format!("cannot remove {}: {e}", target.display()))?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("{key} is not in this version"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn manifest() -> Manifest {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z",
|
||||
"app": {"files": []},
|
||||
"tiles": {"block_shift": 6, "sets": [
|
||||
{"style": "dark", "dataset": "ds", "journal_seq": 1, "blocks": 2, "files": 3, "bytes": 100,
|
||||
"index": "tiles/dark.ds.index.json", "index_sha256": "a".repeat(64), "title": "Dark map"},
|
||||
{"style": "dark_v2", "dataset": "ds", "journal_seq": 1, "blocks": 2, "files": 3, "bytes": 200,
|
||||
"index": "tiles/dark_v2.ds.index.json", "index_sha256": "b".repeat(64), "optional": true,
|
||||
"title": "Dark V2 map", "description": "Buildings."}
|
||||
]},
|
||||
"src": {"enabled": true, "chunk_bytes": 10, "files": [
|
||||
{"path": "osm/x.pmtiles", "size": 4, "chunks": ["c".repeat(64)], "chunks_sha256": "d".repeat(64), "optional": true}
|
||||
]}
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A version from before the catalog: nothing in it is flagged.
|
||||
fn old_manifest() -> Manifest {
|
||||
let mut manifest = manifest();
|
||||
manifest.version = "0.60.0".to_string();
|
||||
for set in manifest.tiles.iter_mut().flat_map(|tiles| &mut tiles.sets) {
|
||||
set.optional = false;
|
||||
set.description = None;
|
||||
}
|
||||
for file in manifest.src.iter_mut().flat_map(|src| &mut src.files) {
|
||||
file.optional = false;
|
||||
}
|
||||
manifest
|
||||
}
|
||||
|
||||
fn data_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("odian-market-test-{}", uuid::Uuid::new_v4().simple()))
|
||||
}
|
||||
|
||||
fn complete_tile_set(data: &Path, style: &str, index: char, blocks: usize) {
|
||||
let dir = data.join("tiles").join(style).join("ds");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let list: Vec<String> = (0..blocks).map(|i| format!("7/{i}/0:x")).collect();
|
||||
let done = serde_json::json!({"index_sha256": index.to_string().repeat(64), "blocks": list});
|
||||
std::fs::write(dir.join(".blocks-done.json"), done.to_string()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_items_come_first_and_the_choice_marks_the_rest() {
|
||||
let data = data_dir();
|
||||
let listed = items(&manifest(), &data, &["src:osm/x.pmtiles".to_string()].into_iter().collect());
|
||||
let summary: Vec<(&str, bool, bool)> = listed.iter().map(|i| (i.key.as_str(), i.required, i.selected)).collect();
|
||||
assert_eq!(summary, [("tiles:dark/ds", true, true), ("tiles:dark_v2/ds", false, false), ("src:osm/x.pmtiles", false, true)]);
|
||||
assert_eq!((listed[1].title.as_str(), listed[1].description.as_str(), listed[1].bytes), ("Dark V2 map", "Buildings.", 200));
|
||||
assert_eq!(listed[2].title, "osm/x.pmtiles", "no title: the path");
|
||||
assert!(listed.iter().all(|i| i.presence == Presence::Absent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_install_fetches_the_required_items_and_the_chosen_ones() {
|
||||
let (sets, paths) = install_filter(&manifest(), &BTreeSet::new());
|
||||
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());
|
||||
assert_eq!(sets.len(), 2);
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_follows_the_installers_own_markers() {
|
||||
let data = data_dir();
|
||||
let m = manifest();
|
||||
complete_tile_set(&data, "dark_v2", 'b', 2);
|
||||
complete_tile_set(&data, "dark", 'z', 2); // an older index: it resumes, it is not done
|
||||
let src = data.join("src").join("osm").join("x.pmtiles");
|
||||
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
|
||||
std::fs::write(&src, b"PMTi").unwrap();
|
||||
let presence = |key: &str| items(&m, &data, &BTreeSet::new()).into_iter().find(|i| i.key == key).unwrap().presence;
|
||||
assert_eq!(presence("tiles:dark_v2/ds"), Presence::Installed);
|
||||
assert_eq!(presence("tiles:dark/ds"), Presence::Partial);
|
||||
assert_eq!(presence("src:osm/x.pmtiles"), Presence::Partial, "no .ok marker yet");
|
||||
std::fs::write(with_suffix(&src, ".ok"), "d".repeat(64)).unwrap();
|
||||
assert_eq!(presence("src:osm/x.pmtiles"), Presence::Installed);
|
||||
// A PC from before the marketplace keeps what it has.
|
||||
assert_eq!(present_extras(&m, &data).into_iter().collect::<Vec<_>>(), ["src:osm/x.pmtiles", "tiles:dark_v2/ds"]);
|
||||
std::fs::remove_dir_all(&data).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchosen_items_are_removed_and_chosen_missing_ones_added() {
|
||||
let data = data_dir();
|
||||
let m = manifest();
|
||||
complete_tile_set(&data, "dark_v2", 'b', 2);
|
||||
let src = data.join("src").join("osm").join("x.pmtiles");
|
||||
let chosen: BTreeSet<String> = ["src:osm/x.pmtiles".to_string()].into_iter().collect();
|
||||
let keys = |items: Vec<Item>| items.into_iter().map(|i| i.key).collect::<Vec<_>>();
|
||||
assert_eq!(keys(to_remove(&m, &data, &chosen)), ["tiles:dark_v2/ds"]);
|
||||
assert_eq!(keys(to_add(&m, &data, &chosen)), ["src:osm/x.pmtiles"]);
|
||||
|
||||
remove(&m, &data, "tiles:dark_v2/ds").unwrap();
|
||||
assert!(!data.join("tiles").join("dark_v2").join("ds").exists());
|
||||
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
|
||||
std::fs::write(&src, b"PMTi").unwrap();
|
||||
std::fs::write(with_suffix(&src, ".chunks.json"), b"{}").unwrap();
|
||||
remove(&m, &data, "src:osm/x.pmtiles").unwrap();
|
||||
assert!(!src.exists() && !with_suffix(&src, ".chunks.json").exists());
|
||||
assert!(remove(&m, &data, "tiles:nope/ds").is_err());
|
||||
std::fs::remove_dir_all(&data).unwrap();
|
||||
}
|
||||
|
||||
/// The marketplace a real published manifest gives a real install, after checking its
|
||||
/// signature with the release key. Reads only. `ODIAN_MARKET_MANIFEST=<manifest.json>
|
||||
/// ODIAN_MARKET_DATA=C:\ODIAN\data cargo test -p odian-launcher real_manifest -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore = "reads a real manifest named by ODIAN_MARKET_MANIFEST"]
|
||||
fn show_the_marketplace_of_a_real_manifest() {
|
||||
let raw = std::fs::read(std::env::var("ODIAN_MARKET_MANIFEST").unwrap()).unwrap();
|
||||
let verified = odian_packs::VerifiedManifest::from_bytes(raw, odian_packs::bundled_public_key()).unwrap();
|
||||
let data = std::env::var("ODIAN_MARKET_DATA").map(PathBuf::from).unwrap_or_default();
|
||||
let manifest = verified.manifest();
|
||||
for item in items(manifest, &data, &present_extras(manifest, &data)) {
|
||||
println!(
|
||||
"{:>7.1} GB {:<9} required={:<5} selected={:<5} {}",
|
||||
item.bytes as f64 / 1e9,
|
||||
format!("{:?}", item.presence),
|
||||
item.required,
|
||||
item.selected,
|
||||
item.title
|
||||
);
|
||||
}
|
||||
let (sets, paths) = install_filter(manifest, &BTreeSet::new());
|
||||
println!("a new PC installs: {sets:?} {paths:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_offered_optional_keys_are_kept() {
|
||||
let keys = ["tiles:dark/ds", "tiles:dark_v2/ds", "src:gone.pmtiles"].map(str::to_string);
|
||||
assert_eq!(optional_keys(&manifest(), keys).into_iter().collect::<Vec<_>>(), ["tiles:dark_v2/ds"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_from_before_the_catalog_is_still_chosen_from() {
|
||||
let items = items(&old_manifest(), Path::new(""), &BTreeSet::new());
|
||||
let required: Vec<&str> = items.iter().filter(|item| item.required).map(|item| item.key.as_str()).collect();
|
||||
// Only the map ODIAN opens on comes with every install; the rest is the operator's to pick.
|
||||
assert_eq!(required, vec!["tiles:dark/ds"]);
|
||||
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());
|
||||
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);
|
||||
assert_eq!(src, BTreeSet::from(["osm/x.pmtiles".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manifest_that_flags_anything_is_taken_as_it_stands() {
|
||||
// The DHP decides for a catalog version: `dark_v2` is optional there, and nothing else moves.
|
||||
let items = items(&manifest(), Path::new(""), &BTreeSet::new());
|
||||
let required: Vec<&str> = items.iter().filter(|item| item.required).map(|item| item.key.as_str()).collect();
|
||||
assert_eq!(required, vec!["tiles:dark/ds"]);
|
||||
assert!(!pre_catalog(&manifest()) && pre_catalog(&old_manifest()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user