diff --git a/Cargo.lock b/Cargo.lock index 31216ec..c1f9a12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -546,6 +555,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -569,7 +588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -582,7 +601,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -799,6 +818,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1758,9 +1788,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2063,6 +2095,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2291,6 +2353,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2558,6 +2626,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2631,6 +2711,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-updater", "tokio", "tokio-util", "url", @@ -2675,6 +2756,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2691,6 +2778,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "pango" version = "0.18.3" @@ -3239,15 +3340,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3364,6 +3470,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3374,6 +3492,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -3406,6 +3551,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3463,6 +3617,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3705,6 +3882,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3908,6 +4101,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3929,7 +4143,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -3939,7 +4153,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3980,6 +4194,7 @@ checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", + "xattr", ] [[package]] @@ -4005,7 +4220,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4202,6 +4417,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.5", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4212,7 +4460,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -4235,7 +4483,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -5039,6 +5287,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -5233,6 +5490,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5582,7 +5850,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5629,6 +5897,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5782,6 +6060,18 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.2", + "memchr", +] + [[package]] name = "zlib-rs" version = "0.6.7" diff --git a/crates/odian-packs/src/install.rs b/crates/odian-packs/src/install.rs index 8815046..ae1c8cd 100644 --- a/crates/odian-packs/src/install.rs +++ b/crates/odian-packs/src/install.rs @@ -1,6 +1,6 @@ //! The installer (fetch.py's `fetch`): verified manifest in, verified install out. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::future::Future; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -15,7 +15,7 @@ use tokio_util::sync::CancellationToken; use crate::copy::{atomic_write, copy_body, with_suffix}; use crate::error::{Error, IntegrityKind, Result}; use crate::hashing::{chunk_range, hash_file_prefix, hex, sha256_file}; -use crate::manifest::{safe_relpath, AppFile, DbFile, SrcFile, SrcSection, TilesSection, VerifiedManifest}; +use crate::manifest::{safe_relpath, AppFile, DbFile, SrcFile, SrcSection, TileSet, TilesSection, VerifiedManifest}; use crate::progress::{Progress, Reporter, Stage}; use crate::source::{blocking, Backend, Pack, Source}; use crate::tileblocks::{copy_drive_block, fetch_site_block, DriveBlock}; @@ -28,11 +28,19 @@ pub struct InstallOptions { pub workers: usize, /// Stages to run; `App`, `Db`, `Src` and `Tiles` are meaningful, others are ignored. pub only: Vec, + /// The database files this PC needs, by manifest path; `None` is all of them. A PC that + /// keeps its own database needs only the dumps of tables it lacks, not the whole snapshot. + pub db_paths: Option>, + /// The tile sets to install, as `style/dataset`; `None` is all of them. The launcher + /// leaves out the marketplace sets the operator did not choose. + pub tile_sets: Option>, + /// The source files to install, by manifest path; `None` is all of them. + pub src_paths: Option>, } impl Default for InstallOptions { fn default() -> Self { - InstallOptions { workers: 6, only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles] } + InstallOptions { workers: 6, only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles], db_paths: None, tile_sets: None, src_paths: None } } } @@ -41,6 +49,8 @@ pub struct AppSummary { pub files: usize, pub downloaded: usize, pub skipped: usize, + /// Identical files taken from another installed version instead of downloaded. + pub reused: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -114,6 +124,7 @@ where let backend = Backend::new(source)?; let m = manifest.manifest(); let version = m.version.clone(); + backend.use_version(&version); let wants = |stage: Stage| opts.only.contains(&stage); let do_app = wants(Stage::App); @@ -146,23 +157,29 @@ where if do_app { check_cancel()?; stage_index += 1; - summary.app = Some(install_app(&ctx, stage_index, &m.app.files, &version_dir).await?); + let (dest, current) = (dest.to_path_buf(), version.clone()); + let reuse = blocking(move || Ok(installed_app_files(&dest, ¤t))).await?; + summary.app = Some(install_app(&ctx, stage_index, &m.app.files, &reuse, &version_dir).await?); } if let (true, Some(db)) = (do_db, &m.db) { check_cancel()?; stage_index += 1; - let (files, downloaded, skipped) = install_db(&ctx, stage_index, &db.files, &data_dir).await?; + let (files, downloaded, skipped) = install_db(&ctx, stage_index, &db.files, opts.db_paths.as_ref(), &data_dir).await?; summary.db = Some(DbSummary { snapshot: db.snapshot.clone(), files, downloaded, skipped }); } if let (true, Some(src)) = (do_src, &m.src) { check_cancel()?; stage_index += 1; - summary.src = Some(install_src(&ctx, stage_index, src, &data_dir).await?); + let keep = |f: &&SrcFile| opts.src_paths.as_ref().is_none_or(|paths| paths.contains(&f.path)); + let src = SrcSection { files: src.files.iter().filter(keep).cloned().collect(), ..src.clone() }; + summary.src = Some(install_src(&ctx, stage_index, &src, &data_dir).await?); } if let (true, Some(tiles)) = (do_tiles, &m.tiles) { check_cancel()?; stage_index += 1; - summary.tiles = Some(install_tiles(&ctx, stage_index, &version, tiles, &data_dir).await?); + let keep = |s: &&TileSet| opts.tile_sets.as_ref().is_none_or(|sets| sets.contains(&format!("{}/{}", s.style, s.dataset))); + let tiles = TilesSection { sets: tiles.sets.iter().filter(keep).cloned().collect(), ..tiles.clone() }; + summary.tiles = Some(install_tiles(&ctx, stage_index, &version, &tiles, &data_dir).await?); } check_cancel()?; @@ -312,37 +329,103 @@ async fn download_file(ctx: &Ctx<'_>, pack: Pack<'_>, dest: &Path, size: u64, sh )) } -async fn install_app(ctx: &Ctx<'_>, stage_index: u32, files: &[AppFile], version_dir: &Path) -> Result { +/// The app files other fully installed versions hold (their manifest.json is written last), +/// by content. An update takes an unchanged file from there instead of downloading it again. +fn installed_app_files(dest: &Path, except_version: &str) -> HashMap { + let mut found = HashMap::new(); + let Ok(entries) = std::fs::read_dir(dest.join("versions")) else { return found }; + for entry in entries.flatten() { + if entry.file_name().to_string_lossy() == except_version { + continue; + } + let dir = entry.path(); + let Ok(raw) = std::fs::read(dir.join("manifest.json")) else { continue }; + let Ok(doc) = serde_json::from_slice::(&raw) else { continue }; + for file in doc["app"]["files"].as_array().into_iter().flatten() { + let (Some(path), Some(sha256)) = (file["path"].as_str(), file["sha256"].as_str()) else { continue }; + if let Ok(full) = join_rel(&dir, path) { + found.entry(sha256.to_string()).or_insert(full); + } + } + } + found +} + +/// Put a copy of `from` at `target`: a hard link where the volume allows one (no bytes +/// written, and nothing left behind when the old version is removed), else a real copy. +/// Best effort: the caller verifies `target` like any file on disk, and downloads over it +/// when it is missing or wrong -- that write goes to `.part` and replaces the link, so it +/// never touches `from`. +fn link_or_copy(from: &Path, target: &Path) -> bool { + if target.exists() || !from.is_file() { + return false; + } + if let Some(parent) = target.parent() { + if std::fs::create_dir_all(parent).is_err() { + return false; + } + } + std::fs::hard_link(from, target).is_ok() || std::fs::copy(from, target).is_ok() +} + +async fn install_app( + ctx: &Ctx<'_>, + stage_index: u32, + files: &[AppFile], + reuse: &HashMap, + version_dir: &Path, +) -> Result { let total: u64 = files.iter().map(|f| f.size).sum(); ctx.reporter.begin_stage(Stage::App, stage_index, total, files.len() as u64); ctx.reporter.begin_sub("app".into(), "files", files.len() as u64); - let (mut downloaded, mut skipped) = (0, 0); + let (mut downloaded, mut skipped, mut reused) = (0, 0, 0); run_pool( files.iter().collect(), ctx.workers, &ctx.cancel, |entry: &AppFile, stop| async move { let target = join_rel(version_dir, &entry.path)?; - download_file(ctx, Pack::App(&entry.sha256), &target, entry.size, &entry.sha256, &stop).await + let linked = match reuse.get(&entry.sha256) { + Some(from) => { + let (from, to) = (from.clone(), target.clone()); + blocking(move || Ok(link_or_copy(&from, &to))).await? + } + None => false, + }; + let pack = Pack::App { sha: &entry.sha256, path: &entry.path }; + let outcome = download_file(ctx, pack, &target, entry.size, &entry.sha256, &stop).await?; + Ok((outcome, linked)) }, - |outcome| { - if outcome == Outcome::Downloaded { downloaded += 1 } else { skipped += 1 } + |(outcome, linked)| { + match (outcome, linked) { + (Outcome::Downloaded, _) => downloaded += 1, + (Outcome::Skipped, true) => reused += 1, + (Outcome::Skipped, false) => skipped += 1, + } ctx.reporter.items_done(1); Ok(()) }, ) .await?; ctx.reporter.finish_stage(); - Ok(AppSummary { files: files.len(), downloaded, skipped }) + Ok(AppSummary { files: files.len(), downloaded, skipped, reused }) } -async fn install_db(ctx: &Ctx<'_>, stage_index: u32, files: &[DbFile], data_dir: &Path) -> Result<(usize, usize, usize)> { +async fn install_db( + ctx: &Ctx<'_>, + stage_index: u32, + files: &[DbFile], + only: Option<&BTreeSet>, + data_dir: &Path, +) -> Result<(usize, usize, usize)> { + let files: Vec<&DbFile> = files.iter().filter(|f| only.is_none_or(|keep| keep.contains(&f.path))).collect(); + let count = files.len(); let total: u64 = files.iter().map(|f| f.size).sum(); - ctx.reporter.begin_stage(Stage::Db, stage_index, total, files.len() as u64); - ctx.reporter.begin_sub("db".into(), "files", files.len() as u64); + ctx.reporter.begin_stage(Stage::Db, stage_index, total, count as u64); + ctx.reporter.begin_sub("db".into(), "files", count as u64); let (mut downloaded, mut skipped) = (0, 0); run_pool( - files.iter().collect(), + files, ctx.workers.min(4), &ctx.cancel, |entry: &DbFile, stop| async move { @@ -357,7 +440,7 @@ async fn install_db(ctx: &Ctx<'_>, stage_index: u32, files: &[DbFile], data_dir: ) .await?; ctx.reporter.finish_stage(); - Ok((files.len(), downloaded, skipped)) + Ok((count, downloaded, skipped)) } // ---- source files ----------------------------------------------------------------- @@ -495,13 +578,186 @@ async fn fetch_chunk(ctx: &Ctx<'_>, entry: &SrcFile, chunk_bytes: u64, dest: &Pa // ---- tiles -------------------------------------------------------------------------- +/// The installer's record of which blocks of a set a PC holds, beside the set's tiles. +const DONE_MARKER: &str = ".blocks-done.json"; + /// `{"index_sha256": "...", "blocks": ["z/bx/by:digest", ...]}`, as Python's `json.dumps` writes it. fn blocks_done_json(index_sha256: &str, done: &BTreeSet) -> String { let list: Vec = done.iter().map(|k| format!("\"{k}\"")).collect(); format!("{{\"index_sha256\": \"{index_sha256}\", \"blocks\": [{}]}}", list.join(", ")) } + +/// Tile sets from another install, copied as they lie. +/// +/// An install keeps no per-block index (only a site store has one), so the block-by-block +/// verification of a download has nothing to check against here. What makes this copy +/// trustworthy instead is every single file: its bytes are hashed at the source and read back +/// from the disk they landed on, and a mismatch fails the install rather than being installed. +/// The source's own record of which blocks it holds (`.blocks-done.json`) travels with the +/// tiles, so the PC knows what it has and an update fills in only the rest. Tiles the source +/// drew itself, beyond the blocks, come along: they are valid tiles of the same set. +async fn copy_install_tiles( + ctx: &Ctx<'_>, + stage_index: u32, + tiles: &TilesSection, + root: &Path, + data_dir: &Path, +) -> Result> { + let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum(); + let files: u64 = tiles.sets.iter().map(|s| s.files).sum(); + ctx.reporter.begin_stage(Stage::Tiles, stage_index, total, files); + let mut out = Vec::with_capacity(tiles.sets.len()); + for set in &tiles.sets { + let (style, dataset) = (set.style.as_str(), set.dataset.as_str()); + let from = root.join("data").join("tiles").join(style).join(dataset); + let to = data_dir.join("tiles").join(style).join(dataset); + // This PC already recorded the very same set as complete: `.blocks-done.json` names + // this index and all of its blocks, the record the marketplace itself reads to say + // "Installed". Hashing its hundreds of thousands of files to learn what the marker + // already states costs minutes a set and can only ever conclude "identical". + if set_recorded_complete(&to, set) { + ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "files", set.files); + ctx.reporter.items_done(set.files); + ctx.reporter.add_done_bytes(set.bytes); + out.push(TileSetSummary { + set: format!("{style}/{dataset}"), + blocks: set.blocks as usize, + downloaded: 0, + files: set.files, + bytes: set.bytes, + }); + continue; + } + if !from.is_dir() { + return Err(Error::NotFound { what: format!("{}: tiles {style}/{dataset}", from.display()) }); + } + ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "files", set.files); + let (mut copied, mut seen, mut bytes) = (0usize, 0usize, 0u64); + // The marker is the claim that the whole set is here, so it is written last, not + // copied somewhere in the middle of the walk: a copy stopped halfway must leave a + // set that still reads as partial, and resume rather than be trusted whole. + let marker = from.join(DONE_MARKER); + let mut folders = vec![from.clone()]; + while let Some(folder) = folders.pop() { + if ctx.cancel.is_cancelled() { + return Err(Error::Cancelled); + } + let (batch, mut deeper) = { + let (folder, from, to) = (folder.clone(), from.clone(), to.clone()); + blocking(move || list_folder(&folder, &from, &to)).await? + }; + folders.append(&mut deeper); + let batch: Vec<(PathBuf, PathBuf)> = batch.into_iter().filter(|(file, _)| *file != marker).collect(); + seen += batch.len(); + let counted = ctx.reporter.clone(); + run_pool( + batch, + ctx.workers, + &ctx.cancel, + |pair: (PathBuf, PathBuf), stop| { + let reporter = ctx.reporter.clone(); + async move { blocking(move || copy_checked(&pair.0, &pair.1, &reporter, &stop)).await } + }, + |(size, fresh)| { + bytes += size; + copied += usize::from(fresh); + counted.items_done(1); + Ok(()) + }, + ) + .await?; + } + // Every file of the set landed and was read back: what the source recorded about it + // is now true here too. + if marker.is_file() { + let raw = blocking({ + let marker = marker.clone(); + move || std::fs::read(&marker).map_err(Error::io(&marker)) + }) + .await?; + atomic_write(&to.join(DONE_MARKER), &raw)?; + } + out.push(TileSetSummary { + set: format!("{style}/{dataset}"), + blocks: set.blocks as usize, + downloaded: copied, + files: seen as u64, + bytes, + }); + } + ctx.reporter.finish_stage(); + Ok(out) +} + +/// Whether `dir` holds the whole of `set` by the installer's own record: the marker names +/// this index and lists every block of it. Written by a download (each block verified against +/// the site's index) or by a copy (every file hashed in and read back), so either way the set +/// it describes is this set. +fn set_recorded_complete(dir: &Path, set: &TileSet) -> bool { + let Ok(raw) = std::fs::read(dir.join(DONE_MARKER)) else { + return false; + }; + let Ok(saved) = serde_json::from_slice::(&raw) else { + return false; + }; + saved.get("index_sha256").and_then(Value::as_str) == Some(set.index_sha256.as_str()) + && saved.get("blocks").and_then(Value::as_array).is_some_and(|blocks| blocks.len() as u64 >= set.blocks) +} + +/// One folder of a tile set: the files in it (source and destination), and the folders under it. +fn list_folder(folder: &Path, from: &Path, to: &Path) -> Result<(Vec<(PathBuf, PathBuf)>, Vec)> { + let mut files = Vec::new(); + let mut folders = Vec::new(); + for entry in std::fs::read_dir(folder).map_err(Error::io(folder))? { + let entry = entry.map_err(Error::io(folder))?; + let path = entry.path(); + let kind = entry.file_type().map_err(Error::io(&path))?; + if kind.is_dir() { + folders.push(path); + } else if kind.is_file() { + let rel = path.strip_prefix(from).map_err(|_| Error::io(&path)(std::io::Error::other("outside the set")))?; + files.push((path.clone(), to.join(rel))); + } + } + Ok((files, folders)) +} + +/// Copy one file and prove it: the source's bytes are hashed, and so are the bytes read back +/// from where they landed. Returns its size and whether this run wrote it (a file already +/// there and identical is left alone, so a copy that stopped resumes without redoing it). +fn copy_checked(from: &Path, to: &Path, reporter: &Reporter, cancel: &CancellationToken) -> Result<(u64, bool)> { + let (size, want) = sha256_file(from, cancel)?; + let there = std::fs::metadata(to).is_ok_and(|md| md.is_file() && md.len() == size) + && sha256_file(to, cancel).is_ok_and(|(_, got)| got == want); + if there { + reporter.add_done_bytes(size); + return Ok((size, false)); + } + if let Some(parent) = to.parent() { + std::fs::create_dir_all(parent).map_err(Error::io(parent))?; + } + let part = with_suffix(to, ".part"); + std::fs::copy(from, &part).map_err(Error::io(&part))?; + let (_, got) = sha256_file(&part, cancel)?; + if got != want { + let _ = std::fs::remove_file(&part); + return Err(Error::integrity( + IntegrityKind::FileHash, + from.display().to_string(), + format!("copied as {got}, expected {want}"), + )); + } + std::fs::rename(&part, to).map_err(Error::io(to))?; + reporter.add_transferred(size); + Ok((size, true)) +} + async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &TilesSection, data_dir: &Path) -> Result> { + if let Some(root) = ctx.backend.install_root() { + let root = root.to_path_buf(); + return copy_install_tiles(ctx, stage_index, tiles, &root, data_dir).await; + } let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum(); let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).sum(); ctx.reporter.begin_stage(Stage::Tiles, stage_index, total, blocks); @@ -523,7 +779,7 @@ async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &T let index = verify_index(&raw, &set.index_sha256, &what)?; let set_dir = data_dir.join("tiles").join(style).join(dataset); - let done_path = set_dir.join(".blocks-done.json"); + let done_path = set_dir.join(DONE_MARKER); let mut done: BTreeSet = BTreeSet::new(); if let Some(saved) = std::fs::read(&done_path).ok().and_then(|raw| serde_json::from_slice::(&raw).ok()) { if saved.get("index_sha256").and_then(Value::as_str) == Some(set.index_sha256.as_str()) { diff --git a/crates/odian-packs/src/manifest.rs b/crates/odian-packs/src/manifest.rs index ee5e641..874d8d9 100644 --- a/crates/odian-packs/src/manifest.rs +++ b/crates/odian-packs/src/manifest.rs @@ -194,6 +194,24 @@ fn object<'a>(value: &'a Value, message: &str) -> Result<&'a Map> /// Error unless `manifest` is a well-formed schema-1 manifest. Structure only; the /// signature is checked by [`crate::signing::verify`]. +/// The marketplace fields a tile set or source file may carry (manifest.py `_check_catalog`). +/// An item without `"optional": true` is part of every install. +const CATALOG_TEXT_LIMITS: [(&str, usize); 2] = [("title", 80), ("description", 1000)]; + +fn check_catalog(entry: &Map, place: &str) -> Result<()> { + if let Some(optional) = entry.get("optional") { + require(optional.is_boolean(), || format!("{place}: optional must be true or false"))?; + } + for (key, limit) in CATALOG_TEXT_LIMITS { + if let Some(text) = entry.get(key) { + // Characters, as Python's len() counts them. + let ok = text.as_str().is_some_and(|s| !s.trim().is_empty() && s.chars().count() <= limit); + require(ok, || format!("{place}: {key} must be text of 1 to {limit} characters"))?; + } + } + Ok(()) +} + pub fn validate(manifest: &Value) -> Result<()> { let m = object(manifest, "manifest must be a JSON object")?; require(m.get("schema").and_then(Value::as_u64) == Some(SCHEMA), || format!("schema must be {SCHEMA}"))?; @@ -280,6 +298,7 @@ pub fn validate(manifest: &Value) -> Result<()> { require(entry.get("index").and_then(Value::as_str) == Some(expected.as_str()), || { format!("{place}: index must be {expected:?}") })?; + check_catalog(entry, &place)?; sets_seen.insert((style, dataset)); } } @@ -309,6 +328,7 @@ pub fn validate(manifest: &Value) -> Result<()> { require(entry.get("chunks_sha256").and_then(Value::as_str) == Some(chunks_digest(&hashes).as_str()), || { format!("{place}: chunks_sha256 does not match chunks") })?; + check_catalog(entry, &place)?; } } @@ -377,6 +397,13 @@ pub struct TileSet { pub bytes: u64, pub index: String, pub index_sha256: String, + /// Offered in the launcher's marketplace instead of installed with everything else. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub optional: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -391,6 +418,13 @@ pub struct SrcFile { pub size: u64, pub chunks: Vec, pub chunks_sha256: String, + /// Offered in the launcher's marketplace instead of installed with everything else. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub optional: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -481,6 +515,50 @@ mod tests { } } + #[test] + fn marketplace_fields_are_checked_like_python_and_default_to_part_of_the_install() { + let chunks = [hex(&Sha256::digest(b"c0"))]; + let manifest = |set_extra: Value, src_extra: Value| { + let mut set = serde_json::json!({ + "style": "dark_v2", "dataset": "ds", "journal_seq": 1, "blocks": 1, "files": 1, "bytes": 9, + "index": tile_index_name("dark_v2", "ds"), "index_sha256": "a".repeat(64), + }); + let mut src = serde_json::json!({ + "path": "x.pmtiles", "size": 5, "chunks": chunks, "chunks_sha256": chunks_digest(&[chunks[0].as_str()]), + }); + set.as_object_mut().unwrap().extend(set_extra.as_object().unwrap().clone()); + src.as_object_mut().unwrap().extend(src_extra.as_object().unwrap().clone()); + serde_json::json!({ + "schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z", + "app": {"files": []}, + "tiles": {"block_shift": BLOCK_SHIFT, "sets": [set]}, + "src": {"enabled": true, "chunk_bytes": 10, "files": [src]}, + }) + }; + let good = manifest( + serde_json::json!({"optional": true, "title": "Dark V2 map", "description": "Buildings and forests."}), + serde_json::json!({"optional": true, "title": "Map source"}), + ); + validate(&good).unwrap(); + let typed: Manifest = serde_json::from_value(good).unwrap(); + let (set, src) = (&typed.tiles.as_ref().unwrap().sets[0], &typed.src.as_ref().unwrap().files[0]); + assert!(set.optional && src.optional); + assert_eq!((set.title.as_deref(), src.description.as_deref()), (Some("Dark V2 map"), None)); + let plain: Manifest = serde_json::from_value(manifest(serde_json::json!({}), serde_json::json!({}))).unwrap(); + assert!(!plain.tiles.unwrap().sets[0].optional, "no field: part of every install"); + + // A 1000-character description passes however many bytes its letters take. + validate(&manifest(serde_json::json!({"description": "é".repeat(1000)}), serde_json::json!({}))).unwrap(); + for (set_extra, src_extra) in [ + (serde_json::json!({"optional": "yes"}), serde_json::json!({})), + (serde_json::json!({"title": " "}), serde_json::json!({})), + (serde_json::json!({}), serde_json::json!({"description": "x".repeat(1001)})), + (serde_json::json!({}), serde_json::json!({"title": 7})), + ] { + assert!(validate(&manifest(set_extra.clone(), src_extra.clone())).is_err(), "{set_extra} {src_extra}"); + } + } + #[test] fn versions() { assert_eq!(parse_version("0.51.0").unwrap(), (0, 51, 0)); diff --git a/crates/odian-packs/src/source.rs b/crates/odian-packs/src/source.rs index 69d48e5..1d72f4e 100644 --- a/crates/odian-packs/src/source.rs +++ b/crates/odian-packs/src/source.rs @@ -1,5 +1,6 @@ -//! Where a version comes from: a site's update service over HTTP, or an ODIAN drive that -//! holds the site store layout (`packs/`, `tiles/`, `src/`). +//! Where a version comes from: a site's update service over HTTP, an ODIAN drive that holds +//! the site store layout (`packs/`, `tiles/`, `src/`), or another ODIAN install, whose files +//! are copied out of it (a drive an operator carries holds one of those, not a store). use std::io::{Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; @@ -21,6 +22,9 @@ pub enum Source { Site { base_url: String, token: String }, /// An ODIAN drive (SSD) whose root holds `packs/`, `tiles/` (with `tiles.db`) and `src/`. Drive { root: PathBuf }, + /// Another ODIAN install (`versions\\`, `data\`), e.g. the one a drive carries. Its + /// files are copied, and checked against the signed manifest of the version it holds. + Install { root: PathBuf }, } const RETRIES: u32 = 4; @@ -97,7 +101,9 @@ pub(crate) enum Body { #[derive(Clone, Copy)] pub(crate) enum Pack<'a> { /// `app/`. - App(&'a str), + /// By content where the store is content-addressed (a site, a drive), by path inside + /// the version folder of another install. + App { sha: &'a str, path: &'a str }, /// A manifest `db.files[].path` (`db//...`). Db(&'a str), } @@ -112,9 +118,16 @@ pub(crate) struct Drive { root: PathBuf, } +pub(crate) struct Install { + root: PathBuf, + /// The version whose manifest was read: where its app files are. Set when the manifest is. + version: Mutex, +} + pub(crate) enum Backend { Site(Site), Drive(Drive), + Install(Install), } impl Backend { @@ -130,6 +143,9 @@ impl Backend { Ok(Backend::Site(Site { base: base_url.trim_end_matches('/').to_string(), token: token.clone(), client })) } Source::Drive { root } => Ok(Backend::Drive(Drive { root: root.clone() })), + Source::Install { root } => { + Ok(Backend::Install(Install { root: root.clone(), version: Mutex::new(String::new()) })) + } } } @@ -151,6 +167,12 @@ impl Backend { .map_err(|_| Error::manifest(format!("{} does not hold a valid version", pointer.display())))?; drive.read(join_rel(&drive.root, &format!("packs/versions/{text}/manifest.json"))).await } + Backend::Install(install) => { + let root = install.root.clone(); + let (version, raw) = blocking(move || newest_installed_version(&root)).await?; + install.set_version(&version); + Ok(raw) + } } } @@ -159,6 +181,11 @@ impl Backend { match self { Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/manifest"), cancel).await, Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/manifest.json"))).await, + Backend::Install(install) => { + let raw = read_file(install.root.join("versions").join(version).join("manifest.json")).await?; + install.set_version(version); + Ok(raw) + } } } @@ -167,6 +194,8 @@ impl Backend { match self { Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/notes"), cancel).await, Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/notes.md"))).await, + // An install keeps its notes with its own state, not with the version. + Backend::Install(_) => Err(Error::NotFound { what: format!("notes for {version}") }), } } @@ -180,13 +209,18 @@ impl Backend { let rel = format!("packs/versions/{version}/{}", tile_index_name(style, dataset)); drive.read(join_rel(&drive.root, &rel)).await } + // An install keeps no index: its tile sets are copied as they lie (see install_tiles). + Backend::Install(_) => { + Err(Error::NotFound { what: format!("tile index {style}/{dataset} of {version}") }) + } } } pub fn pack_label(&self, pack: Pack<'_>) -> String { - match pack { - Pack::App(sha) => format!("/packs/app/{sha}"), - Pack::Db(path) => format!("/packs/{path}"), + match (self, pack) { + (Backend::Install(_), Pack::App { path, .. }) => path.to_string(), + (_, Pack::App { sha, .. }) => format!("/packs/app/{sha}"), + (_, Pack::Db(path)) => format!("/packs/{path}"), } } @@ -196,7 +230,7 @@ impl Backend { match self { Backend::Site(site) => { let path = match pack { - Pack::App(sha) => format!("/packs/app/{}", encode_path(sha)), + Pack::App { sha, .. } => format!("/packs/app/{}", encode_path(sha)), Pack::Db(rel) => format!("/packs/{}", encode_path(rel)), }; let range = (offset > 0).then(|| format!("bytes={offset}-")); @@ -206,10 +240,18 @@ impl Backend { } Backend::Drive(drive) => { let path = match pack { - Pack::App(sha) => drive.root.join("packs").join("app").join(sha), + Pack::App { sha, .. } => drive.root.join("packs").join("app").join(sha), Pack::Db(rel) => join_rel(&drive.root.join("packs"), rel), }; - let body = drive.open_range(path, offset, u64::MAX).await?; + let body = open_range(path, offset, u64::MAX).await?; + Ok((body, true)) + } + Backend::Install(install) => { + let path = match pack { + Pack::App { path, .. } => join_rel(&install.version_dir(), path), + Pack::Db(rel) => join_rel(&install.root.join("data"), rel), + }; + let body = open_range(path, offset, u64::MAX).await?; Ok((body, true)) } } @@ -230,25 +272,95 @@ impl Backend { } Ok(Body::Http { response, url }) } - Backend::Drive(drive) => drive.open_range(join_rel(&drive.root.join("src"), rel), start, end - start + 1).await, + Backend::Drive(drive) => open_range(join_rel(&drive.root.join("src"), rel), start, end - start + 1).await, + Backend::Install(install) => { + open_range(join_rel(&install.root.join("data").join("src"), rel), start, end - start + 1).await + } + } + } + + /// Which version's files to copy, when the source is another install: the manifest is + /// read through a backend of its own, so the one doing the work is told separately. + pub fn use_version(&self, version: &str) { + if let Backend::Install(install) = self { + install.set_version(version); + } + } + + /// The root of another install this one is being copied from. + pub fn install_root(&self) -> Option<&Path> { + match self { + Backend::Install(install) => Some(&install.root), + _ => None, } } pub fn drive_root(&self) -> Option<&Path> { match self { Backend::Drive(drive) => Some(&drive.root), - Backend::Site(_) => None, + _ => None, } } pub fn site(&self) -> Option<&Site> { match self { Backend::Site(site) => Some(site), - Backend::Drive(_) => None, + _ => None, } } } +impl Install { + fn set_version(&self, version: &str) { + *self.version.lock().unwrap_or_else(|e| e.into_inner()) = version.to_string(); + } + + fn version_dir(&self) -> PathBuf { + let version = self.version.lock().unwrap_or_else(|e| e.into_inner()).clone(); + self.root.join("versions").join(version) + } +} + +/// The newest whole version another install carries. `versions\\manifest.json` is written +/// last, so a folder without one is a download that stopped part way and is not offered. +fn newest_installed_version(root: &Path) -> Result<(String, Vec)> { + let versions = root.join("versions"); + let entries = std::fs::read_dir(&versions).map_err(open_error(&versions))?; + let mut best: Option<((u64, u64, u64), String)> = None; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let Ok(parsed) = parse_version(&name) else { continue }; + if !entry.path().join("manifest.json").is_file() { + continue; + } + if best.as_ref().is_none_or(|(best, _)| *best < parsed) { + best = Some((parsed, name)); + } + } + let version = best + .ok_or_else(|| Error::NotFound { what: format!("{}: no ODIAN version installed here", versions.display()) })? + .1; + let path = versions.join(&version).join("manifest.json"); + let raw = std::fs::read(&path).map_err(open_error(&path))?; + Ok((version, raw)) +} + +pub(crate) async fn read_file(path: PathBuf) -> Result> { + blocking(move || std::fs::read(&path).map_err(open_error(&path))).await +} + +/// `len` bytes from `start` of a local file. +pub(crate) async fn open_range(path: PathBuf, start: u64, len: u64) -> Result { + blocking(move || { + let mut file = std::fs::File::open(&path).map_err(open_error(&path))?; + if start > 0 { + file.seek(SeekFrom::Start(start)).map_err(Error::io(&path))?; + } + Ok(Body::File { reader: file.take(len), path }) + }) + .await +} + impl Site { /// GET with the Bearer token. Retries 502/503/504 (honouring Retry-After) and /// connection errors with a doubling delay, like fetch.py's `Site.open`. @@ -326,18 +438,7 @@ impl Site { impl Drive { async fn read(&self, path: PathBuf) -> Result> { - blocking(move || std::fs::read(&path).map_err(open_error(&path))).await - } - - async fn open_range(&self, path: PathBuf, start: u64, len: u64) -> Result { - blocking(move || { - let mut file = std::fs::File::open(&path).map_err(open_error(&path))?; - if start > 0 { - file.seek(SeekFrom::Start(start)).map_err(Error::io(&path))?; - } - Ok(Body::File { reader: file.take(len), path }) - }) - .await + read_file(path).await } } diff --git a/crates/odian-packs/tests/e2e_site.rs b/crates/odian-packs/tests/e2e_site.rs index cbf926a..43bea34 100644 --- a/crates/odian-packs/tests/e2e_site.rs +++ b/crates/odian-packs/tests/e2e_site.rs @@ -13,7 +13,7 @@ //! //! `ODIAN_PACKS_PYTHON` picks the interpreter (default `python`). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; @@ -290,6 +290,141 @@ async fn site_install_verifies_everything_then_rerun_downloads_nothing() { assert!(matches!(version_manifest(&site, "9.9.9", &key).await, Err(Error::NotFound { .. }))); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"] +async fn an_update_reuses_the_installed_files_and_fetches_only_the_db_files_asked_for() { + let stub = Stub::start(&[]); + let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install")); + install_from(&site, &dest, &key, only(&[Stage::App])).await.unwrap(); + // What is installed becomes an older version: installing the current one is an update. + let old = dest.join("versions").join("0.50.0"); + std::fs::rename(dest.join("versions").join("0.51.0"), &old).unwrap(); + // One old file went bad on disk: taken from there anyway, it must be caught. + let m = stub.store_manifest(); + let bad = m["app"]["files"].as_array().unwrap().iter().find(|f| f["size"].as_u64().unwrap() > 0).unwrap(); + let bad_path = bad["path"].as_str().unwrap().to_string(); + flip(&old.join(&bad_path), 0); + let bad_old = std::fs::read(old.join(&bad_path)).unwrap(); + + stub.ctl("/_ctl/reset").await; + let first_db = m["db"]["files"][0]["path"].as_str().unwrap().to_string(); + let opts = InstallOptions { + only: vec![Stage::App, Stage::Db], + db_paths: Some([first_db.clone()].into_iter().collect()), + ..InstallOptions::default() + }; + let summary = install_from(&site, &dest, &key, opts).await.unwrap(); + + let app = summary.app.unwrap(); + assert_eq!((app.files, app.downloaded, app.reused, app.skipped), (5, 1, 4, 0), "{app:?}"); + let app_requests: Vec = + stub.requests().await.into_iter().map(|(p, _)| p).filter(|p| p.starts_with("/packs/app/")).collect(); + assert_eq!(app_requests, vec![format!("/packs/app/{}", stub.app_sha(&bad_path))], "only the bad file is downloaded"); + let new = dest.join("versions").join("0.51.0"); + for f in m["app"]["files"].as_array().unwrap() { + let path = new.join(f["path"].as_str().unwrap()); + assert_eq!(sha256_hex(&std::fs::read(&path).unwrap()), f["sha256"].as_str().unwrap(), "{}", path.display()); + } + // The download replaced the link; it never wrote through it into the old version. + assert_eq!(std::fs::read(old.join(&bad_path)).unwrap(), bad_old); + + let db = summary.db.unwrap(); + assert_eq!((db.files, db.downloaded), (1, 1)); + for f in m["db"]["files"].as_array().unwrap() { + let path = f["path"].as_str().unwrap(); + assert_eq!(dest.join("data").join(path).is_file(), path == first_db, "{path}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"] +async fn only_the_chosen_tile_sets_and_source_files_are_installed() { + let stub = Stub::start(&[]); + let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install")); + let opts = InstallOptions { + only: vec![Stage::Src, Stage::Tiles], + tile_sets: Some(BTreeSet::new()), + src_paths: Some(["osm_source/small.bin".to_string()].into_iter().collect()), + ..InstallOptions::default() + }; + let summary = install_from(&site, &dest, &key, opts).await.unwrap(); + let src: Vec = summary.src.unwrap().into_iter().map(|s| s.path).collect(); + assert_eq!(src, ["osm_source/small.bin"]); + assert!(summary.tiles.unwrap().is_empty()); + assert!(dest.join("data/src/osm_source/small.bin").is_file()); + assert!(!dest.join("data/src/osm_source/world_aeroway_v1.sqlite").exists()); + assert!(!dest.join("data/tiles/dark/test_ds").exists(), "no tile set was chosen"); + let log = stub.requests().await; + assert!(!log.iter().any(|(p, _)| p.contains("/block/")), "no tile block fetched: {log:?}"); + + // Choosing the tile set later adds it; what is installed stays as it is. + let opts = InstallOptions { + only: vec![Stage::Src, Stage::Tiles], + tile_sets: Some(["dark/test_ds".to_string()].into_iter().collect()), + src_paths: Some(["osm_source/small.bin".to_string()].into_iter().collect()), + ..InstallOptions::default() + }; + let summary = install_from(&site, &dest, &key, opts).await.unwrap(); + assert_eq!(summary.tiles.unwrap()[0].downloaded, 4); + assert_eq!(summary.src.unwrap()[0].chunks, 0, "the source file was already there"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"] +async fn a_pc_installs_by_copying_another_install_and_checks_every_file() { + let stub = Stub::start(&[]); + let (site, key) = (stub.site(), stub.key()); + let first = stub.dest("first"); + install_from(&site, &first, &key, InstallOptions::default()).await.unwrap(); + + // The drive an operator carries holds that install, not a store: the second PC copies it. + stub.ctl("/_ctl/reset").await; + let drive = Source::Install { root: first.clone() }; + let second = stub.dest("second"); + let summary = install_from(&drive, &second, &key, InstallOptions::default()).await.unwrap(); + assert!(stub.requests().await.is_empty(), "nothing was asked of the site"); + assert_eq!(summary.version, "0.51.0"); + assert_eq!(files(&second.join("versions").join("0.51.0")), files(&first.join("versions").join("0.51.0"))); + assert_eq!(files(&second.join("data")), files(&first.join("data")), "same data, tile by tile"); + let done = second.join("data/tiles/dark/test_ds/.blocks-done.json"); + assert!(done.is_file(), "the blocks the source holds travel with the tiles"); + + // Running it again copies nothing: what is already there is recognised. + let again = install_from(&drive, &second, &key, InstallOptions::default()).await.unwrap(); + assert_eq!(again.app.unwrap().downloaded, 0); + assert_eq!(again.tiles.unwrap().iter().map(|t| t.downloaded).sum::(), 0); + + // A set this PC already recorded as complete is not read again: the marker names this + // index and every block of it, so what lies here IS the set. Hundreds of thousands of + // files are not hashed twice to conclude "identical", and a tile that changes on the + // drive afterwards stays there, as a site update leaves a complete set alone too. + let on_drive = first.join("data/tiles/dark/test_ds/0/0/0.png"); + let here = second.join("data/tiles/dark/test_ds/0/0/0.png"); + let kept = std::fs::read(&here).unwrap(); + flip(&on_drive, 3); + install_from(&drive, &second, &key, only(&[Stage::Tiles])).await.unwrap(); + assert_eq!(std::fs::read(&here).unwrap(), kept, "the set was recorded complete: it was left alone"); + flip(&on_drive, 3); + + // An app file that went bad on the drive is caught: the manifest says what it should be. + let third = stub.dest("third"); + flip(&first.join("versions").join("0.51.0").join("Odian").join("Odian.exe"), 3); + let what = integrity(install_from(&drive, &third, &key, InstallOptions::default()).await, IntegrityKind::FileHash); + assert!(what.contains("Odian.exe"), "{what}"); + + // A TILE cannot be checked that way: only a site's index carries per-tile hashes, and an + // install keeps none. What is checked is the copy - the bytes that land are the bytes read. + let fourth = stub.dest("fourth"); + let tile = first.join("data/tiles/dark/test_ds/0/0/0.png"); + flip(&tile, 3); + install_from(&drive, &fourth, &key, only(&[Stage::Tiles])).await.unwrap(); + assert_eq!( + std::fs::read(fourth.join("data/tiles/dark/test_ds/0/0/0.png")).unwrap(), + std::fs::read(&tile).unwrap(), + "faithful to the drive, whatever the drive holds" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"] async fn wrong_token_is_unauthorized_and_edited_manifest_is_a_signature_error() { @@ -480,6 +615,50 @@ async fn cancel_stops_and_keeps_verified_work() { assert!(dest.join("versions/0.51.0/manifest.json").exists()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"] +async fn a_copy_stopped_halfway_leaves_the_set_partial() { + let stub = Stub::start(&[]); + let (site, key) = (stub.site(), stub.key()); + let drive_install = stub.dest("drive"); + install_from(&site, &drive_install, &key, InstallOptions::default()).await.unwrap(); + let marker = ".blocks-done.json"; + let carried = drive_install.join("data/tiles/dark/test_ds"); + assert!(carried.join(marker).is_file(), "the drive holds the whole set"); + // A set of a few tiles copies faster than the first progress report: give the drive enough + // of them (tiles drawn beyond the index, which travel too) to be stopped in the middle. + for x in 0..40 { + let dir = carried.join("9").join(x.to_string()); + std::fs::create_dir_all(&dir).unwrap(); + for y in 0..10 { + std::fs::write(dir.join(format!("{y}.png")), vec![b't'; 4096]).unwrap(); + } + } + + // Stop the copy in the middle of the tiles. + let drive = Source::Install { root: drive_install.clone() }; + let manifest = current_manifest(&drive, &key).await.unwrap(); + let dest = stub.dest("half"); + let cancel = CancellationToken::new(); + let trigger = cancel.clone(); + let result = install(&drive, &dest, &manifest, only(&[Stage::Tiles]), move |p| { + if p.stage == Stage::Tiles && p.done_items >= 1 { + trigger.cancel(); + } + }, cancel) + .await; + assert!(matches!(result, Err(Error::Cancelled)), "{result:?}"); + let set_dir = dest.join("data/tiles/dark/test_ds"); + // Tiles landed, but nothing here claims the set is whole: the next run copies the rest + // instead of trusting a record written before the files it speaks for. + assert!(!set_dir.join(marker).exists(), "a half-copied set must not read as complete"); + + let summary = install(&drive, &dest, &manifest, only(&[Stage::Tiles]), |_| {}, CancellationToken::new()).await.unwrap(); + assert!(set_dir.join(marker).is_file(), "finished, so it is recorded"); + assert!(summary.tiles.unwrap().iter().map(|t| t.downloaded).sum::() > 0, "the rest was copied"); + assert_eq!(files(&set_dir), files(&carried), "same set, tile by tile"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"] async fn drive_install_equals_site_install() { diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index b5615c4..ccb910e 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -35,6 +35,8 @@ interface Status { phase: Phase; error: string | null; // set with phase "error"; cleared by the next command or `refresh` install_dir: string; // e.g. "D:\\ODIAN" + portable: boolean; // this launcher ships on an ODIAN drive: it runs that drive's + // install (any letter), the folder is fixed, no self-update sync: SyncStatus | null; // counts from the last sync this session; null before one live: boolean | null; // while ODIAN runs: true = its data comes from the DHP, // false = from this PC. null when it is not running, or @@ -84,6 +86,25 @@ interface StackEvent { } interface LogEvent { level: "info" | "warn" | "error"; message: string; at: number } + +interface MarketItem { + key: string; // stable across versions: "tiles: diff --git a/src/components/SettingsDrawer.svelte b/src/components/SettingsDrawer.svelte index dee74ba..01fb40f 100644 --- a/src/components/SettingsDrawer.svelte +++ b/src/components/SettingsDrawer.svelte @@ -23,6 +23,9 @@ const current = $derived(launcher.settings); const locked = $derived(launcher.locked); const saving = $derived(launcher.busy === "settings"); + /** A launcher that ships on an ODIAN drive installs on that drive to run ODIAN from it, + * or on a disk of the PC it is plugged into to hand ODIAN over. */ + const portable = $derived(s?.portable ?? false); const busy = $derived(launcher.busy !== null); const trimmed = $derived({ @@ -36,10 +39,12 @@ trimmed.mcp_url !== current.mcp_url || trimmed.install_dir !== current.install_dir), ); + /** A drive's launcher needs a folder on a disk: a root or a share holds no install. */ + const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir)); const siteError = $derived(urlProblem(trimmed.site_url)); const mcpError = $derived(urlProblem(trimmed.mcp_url)); const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed."); - const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null); + const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null || offDrive); function urlProblem(value: string): string | null { if (!value) return "Required."; @@ -134,7 +139,7 @@ function installFromDrive() { open = false; - void launcher.installFromDrive(); + void launcher.chooseDrive(); } function signIn() { @@ -216,14 +221,24 @@ - {dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."} + {#if offDrive} + Give a folder on a disk, like D:\ODIAN — not a drive's root, and not a network share. + {:else if portable} + On the ODIAN drive this launcher runs from, ODIAN runs from the drive and follows it to the + next PC. A folder on this PC's own disk installs ODIAN onto the PC instead. + {:else} + {dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."} + {/if}

Install from drive

-

Install or update from an ODIAN SSD without reaching the DHP. Pick the drive's root folder.

+

+ Copy ODIAN from an ODIAN SSD into your install folder, without reaching the DHP. Pick the drive, + then choose what to copy. +

+ @@ -223,6 +228,10 @@ align-self: stretch; } + .market { + margin-right: var(--space-1); + } + .settings { margin-right: var(--space-2); } diff --git a/src/lib/backend.ts b/src/lib/backend.ts index 4b227f8..2a8314c 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -9,6 +9,7 @@ import type { Backend, InstallProgress, LogEvent, + Market, Settings, SignInResult, StackEvent, @@ -47,6 +48,9 @@ function createTauriBackend(): Backend { getStack: () => invoke("get_stack"), startInstall: () => invoke("start_install"), startInstallFromDrive: (path) => invoke("start_install_from_drive", { path }), + getMarket: (drive) => invoke("get_market", drive === undefined ? {} : { drive }), + applyMarket: (selected) => invoke("apply_market", { selected }), + setMarket: (selected, drive) => invoke("set_market", drive === undefined ? { selected } : { selected, drive }), cancelInstall: () => invoke("cancel_install"), launch: () => invoke("launch"), stop: () => invoke("stop"), diff --git a/src/lib/launcher.svelte.ts b/src/lib/launcher.svelte.ts index 217b029..b35961b 100644 --- a/src/lib/launcher.svelte.ts +++ b/src/lib/launcher.svelte.ts @@ -8,6 +8,7 @@ import type { InstallProgress, LogEvent, LogLevel, + Market, Phase, Settings, StackComponent, @@ -112,6 +113,9 @@ class Launcher { stopping = $state(false); /** The sync review panel. */ syncOpen = $state(false); + /** The marketplace panel, and the drive it describes (null: this PC and its DHP). */ + marketOpen = $state(false); + marketDrive = $state(null); /** Decisions waiting in this PC's database; null until the panel loads them. */ conflicts = $state(null); notes = $state({ @@ -289,6 +293,38 @@ class Launcher { } } + /** The marketplace listing, of this PC's DHP or of a drive. Throws, so the drawer says why. */ + loadMarket(drive?: string): Promise { + return backend.getMarket(drive); + } + + openMarket() { + this.marketDrive = null; + this.marketOpen = true; + } + + /** Installing from a drive starts by looking at what the drive carries. */ + async chooseDrive() { + const path = await this.pickFolder(); + if (!path) return; + this.marketDrive = path; + this.marketOpen = true; + } + + /** Choose what an install about to run will bring; nothing installed is touched. */ + setMarket(selected: string[], drive?: string) { + return this.run("market", () => backend.setMarket(selected, drive)); + } + + /** Save the choice of optional items and apply it (downloads show as an install). */ + applyMarket(selected: string[]) { + return this.run("market", async () => { + this.lastOp = null; + await backend.applyMarket(selected); + this.applyStatus(await backend.getStatus()); + }); + } + install() { return this.run("install", async () => { this.lastOp = { kind: "install" }; @@ -296,9 +332,11 @@ class Launcher { }); } - async installFromDrive() { - const path = await this.pickFolder(); - if (!path) return false; + /** + * Installing from a drive is a copy the operator chooses first: the marketplace opens on what + * the drive carries, and starts this once they have picked. + */ + async installFromDrive(path: string) { return this.run("install", async () => { this.lastOp = { kind: "drive", path }; await backend.startInstallFromDrive(path); diff --git a/src/lib/mock.ts b/src/lib/mock.ts index 730ef25..d92fee5 100644 --- a/src/lib/mock.ts +++ b/src/lib/mock.ts @@ -18,6 +18,8 @@ import { type InstallStage, type LogEvent, type LogLevel, + type Market, + type MarketItem, type MockControls, type MockScenario, type Settings, @@ -182,6 +184,25 @@ type Listeners = { [K in Channel]: Set<(payload: Payloads[K]) => void> }; const now = () => Math.floor(Date.now() / 1000); const busy = "Busy: wait for the current task to finish."; +/** The 0.61.0 catalogue with real sizes: what the marketplace lists in the browser mock. */ +const MOCK_MARKET: Omit[] = [ + { key: "tiles:dark/protomaps_20260423", kind: "tiles", title: "Dark map", bytes: 2_437_523_419, required: true, + description: "The standard ODIAN basemap: roads, water and borders in the dark style, as pre-drawn at the office. Every install carries it." }, + { key: "tiles:dark_v2/protomaps_20260423", kind: "tiles", title: "Dark V2 map", bytes: 1_649_512_829, required: false, + description: "The newer dark basemap, with buildings and forests. It is the Theatre of Operations' default ground: without it (or the Map source) the Theatre shows relief shading until Dark is picked." }, + { key: "tiles:satellite_hd/protomaps_20260423", kind: "tiles", title: "World satellite imagery", bytes: 5_660_999_147, required: false, + description: "Photographic basemap of the whole world, for the map and the Theatre of Operations. It covers what the office pre-cached: this PC cannot fetch more imagery offline." }, + { key: "tiles:orthophotos/protomaps_20260423", kind: "tiles", title: "France orthophotos", bytes: 6_713_284_879, required: false, + description: "High-resolution aerial photographs of France, much sharper than the world imagery over French territory." }, + { key: "tiles:heightmap/planet_heightmap", kind: "tiles", title: "Terrain elevation", bytes: 74_367_050_377, required: false, + description: "The world height map: real 3D relief for the globe and the Theatre of Operations, and ground elevation wherever ODIAN reads it. Without it the ground is flat." }, + { key: "src:osm_source/world_aeroway_v1.sqlite", kind: "src", title: "Aeroway source data (OSM)", bytes: 884_244_480, required: false, + description: "Raw OpenStreetMap airports and runways. ODIAN reads aeroways from its own database, so this is only needed to rebuild that layer." }, + { key: "src:protomaps_20260423.pmtiles", kind: "src", title: "Map source (world, vector)", bytes: 135_176_708_563, required: false, + description: "The vector map data the Dark and Dark V2 basemaps are drawn from. With it this PC draws any tile it lacks, anywhere and at any zoom: the detailed ground the Theatre of Operations needs offline. Without it only the pre-drawn tiles show." }, +]; +const MOCK_CORE_BYTES = 2_275_409_041 + 15_356_491_648; + function pick(value: string | null, allowed: readonly T[]): T | null { return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : null; } @@ -212,6 +233,7 @@ function initialStatus(scenario: MockScenario): Status { phase: "idle", error: null, install_dir: DEFAULT_DIR, + portable: false, sync: null, live: null, }; @@ -276,6 +298,9 @@ class MockBackend implements Backend { private signInCancelled = false; /** What waits for a decision in the mock "local database". */ private conflicts: SyncConflict[]; + /** Marketplace: the optional items chosen, and what is on the mock PC. */ + private marketChosen = new Set(); + private readonly marketPresent = new Map(); /** Latest event per component, as `get_stack` reports it. */ private readonly stackStates = new Map(); private readonly listeners: Listeners = { @@ -295,6 +320,13 @@ class MockBackend implements Backend { this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : []; this.network = this.status.online; this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR }; + if (this.status.installed) { + // An installed mock PC has the basemap, Dark V2 and a stopped satellite download. + for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed"); + this.marketPresent.set("tiles:dark_v2/protomaps_20260423", "installed"); + this.marketPresent.set("tiles:satellite_hd/protomaps_20260423", "partial"); + this.marketChosen = new Set(["tiles:dark_v2/protomaps_20260423", "tiles:satellite_hd/protomaps_20260423"]); + } const self = this; this.mock = { @@ -476,6 +508,74 @@ class MockBackend implements Backend { void this.runInstall(kind, "drive", path); } + async getMarket(drive?: string): Promise { + await this.wait(250); + if (drive !== undefined && !drive.trim()) throw "Pick the root folder of an ODIAN drive."; + if (drive === undefined && !this.status.installed && !this.status.online) { + throw "Connect to your DHP to see what it offers."; + } + return { + version: this.status.installed ?? this.status.available ?? CURRENT, + installed: this.status.installed !== null, + core_bytes: MOCK_CORE_BYTES, + items: MOCK_MARKET.map((item) => ({ + ...item, + presence: this.marketPresent.get(item.key) ?? "absent", + selected: item.required || this.marketChosen.has(item.key), + })), + free_bytes: 297_100_000_000, + }; + } + + async setMarket(selected: string[], _drive?: string): Promise { + const optional = new Set(MOCK_MARKET.filter((item) => !item.required).map((item) => item.key)); + this.marketChosen = new Set(selected.filter((key) => optional.has(key))); + } + + async applyMarket(selected: string[]): Promise { + const s = this.status; + if (s.phase !== "idle" && s.phase !== "error") throw busy; + const optional = new Set(MOCK_MARKET.filter((item) => !item.required).map((item) => item.key)); + this.marketChosen = new Set(selected.filter((key) => optional.has(key))); + if (!s.installed) return; + for (const item of MOCK_MARKET) { + if (!item.required && !this.marketChosen.has(item.key) && this.marketPresent.has(item.key)) { + this.marketPresent.delete(item.key); + this.log("info", `Removed ${item.title}.`); + } + } + const adds = MOCK_MARKET.filter((item) => this.marketChosen.has(item.key) && this.marketPresent.get(item.key) !== "installed"); + if (adds.length === 0) return; + if (!s.online) throw "Your DHP is unreachable: connect to download marketplace items."; + void this.runMarketDownload(adds); + } + + private async runMarketDownload(adds: typeof MOCK_MARKET) { + const job = ++this.job; + this.commit({ phase: "installing", error: null }); + this.log("info", `Downloading ${adds.map((item) => item.title).join(", ")}`); + const total = adds.reduce((sum, item) => sum + item.bytes, 0); + const ticks = 30; + for (let t = 0; t <= ticks; t++) { + if (t > 0) await this.wait(100); + if (job !== this.job) return; + this.emit("install-progress", { + stage: "tiles", + stage_index: 2, + stage_count: 2, + done_bytes: Math.round((total * t) / ticks), + total_bytes: total, + done_items: t, + total_items: ticks, + bytes_per_sec: t < ticks ? 180_000_000 : 0, + message: `tiles ${adds[0].title}: ${t}/${ticks} blocks`, + }); + } + for (const item of adds) this.marketPresent.set(item.key, "installed"); + this.commit({ phase: "idle" }); + this.log("info", "The marketplace items are installed."); + } + async cancelInstall(): Promise { if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started."; if (this.status.phase !== "installing") throw "Nothing is installing."; diff --git a/src/lib/types.ts b/src/lib/types.ts index 315463a..fcd7434 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -38,6 +38,11 @@ export interface Status { error: string | null; /** e.g. "D:\\ODIAN". */ install_dir: string; + /** + * This launcher ships on an ODIAN drive: it runs the install on the drive it was started + * from (whatever letter it gets), the folder cannot be changed, and it never self-updates. + */ + portable: boolean; /** Operator-data sync counts from the last sync this session; null before one. */ sync: SyncStatus | null; /** @@ -47,6 +52,36 @@ export interface Status { live: boolean | null; } +/** A marketplace item: one tile set or source file of the version. */ +export interface MarketItem { + /** Stable across versions: `tiles: