This commit is contained in:
Kelorastus
2026-09-23 16:05:19 +02:00
parent 4a92cc0b63
commit 4d620fc5ad
27 changed files with 3571 additions and 104 deletions
Generated
+298 -8
View File
@@ -64,6 +64,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" 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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -546,6 +555,16 @@ dependencies = [
"version_check", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -569,7 +588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
@@ -582,7 +601,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -799,6 +818,17 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "derive_more" name = "derive_more"
version = "2.1.1" version = "2.1.1"
@@ -1758,9 +1788,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -2063,6 +2095,36 @@ dependencies = [
"windows-sys 0.45.0", "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]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -2291,6 +2353,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2558,6 +2626,18 @@ dependencies = [
"objc2-core-foundation", "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]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2631,6 +2711,7 @@ dependencies = [
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-updater",
"tokio", "tokio",
"tokio-util", "tokio-util",
"url", "url",
@@ -2675,6 +2756,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2691,6 +2778,20 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -3239,15 +3340,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3364,6 +3470,18 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.15.1" version = "1.15.1"
@@ -3374,6 +3492,33 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.15" version = "0.103.15"
@@ -3406,6 +3551,15 @@ dependencies = [
"winapi-util", "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]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -3463,6 +3617,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "selectors" name = "selectors"
version = "0.36.1" version = "0.36.1"
@@ -3705,6 +3882,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" 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]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.3" version = "1.0.3"
@@ -3908,6 +4101,27 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3929,7 +4143,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -3939,7 +4153,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -3980,6 +4194,7 @@ checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
"xattr",
] ]
[[package]] [[package]]
@@ -4005,7 +4220,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -4202,6 +4417,39 @@ dependencies = [
"zbus", "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]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.11.3" version = "2.11.3"
@@ -4212,7 +4460,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -4235,7 +4483,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -5039,6 +5287,15 @@ dependencies = [
"system-deps", "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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.9" version = "1.0.9"
@@ -5233,6 +5490,17 @@ dependencies = [
"windows-link 0.1.3", "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]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@@ -5582,7 +5850,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -5629,6 +5897,16 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -5782,6 +6060,18 @@ dependencies = [
"syn 3.0.5", "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]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.7" version = "0.6.7"
+275 -19
View File
@@ -1,6 +1,6 @@
//! The installer (fetch.py's `fetch`): verified manifest in, verified install out. //! 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::future::Future;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -15,7 +15,7 @@ use tokio_util::sync::CancellationToken;
use crate::copy::{atomic_write, copy_body, with_suffix}; use crate::copy::{atomic_write, copy_body, with_suffix};
use crate::error::{Error, IntegrityKind, Result}; use crate::error::{Error, IntegrityKind, Result};
use crate::hashing::{chunk_range, hash_file_prefix, hex, sha256_file}; 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::progress::{Progress, Reporter, Stage};
use crate::source::{blocking, Backend, Pack, Source}; use crate::source::{blocking, Backend, Pack, Source};
use crate::tileblocks::{copy_drive_block, fetch_site_block, DriveBlock}; use crate::tileblocks::{copy_drive_block, fetch_site_block, DriveBlock};
@@ -28,11 +28,19 @@ pub struct InstallOptions {
pub workers: usize, pub workers: usize,
/// Stages to run; `App`, `Db`, `Src` and `Tiles` are meaningful, others are ignored. /// Stages to run; `App`, `Db`, `Src` and `Tiles` are meaningful, others are ignored.
pub only: Vec<Stage>, pub only: Vec<Stage>,
/// 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<BTreeSet<String>>,
/// 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<BTreeSet<String>>,
/// The source files to install, by manifest path; `None` is all of them.
pub src_paths: Option<BTreeSet<String>>,
} }
impl Default for InstallOptions { impl Default for InstallOptions {
fn default() -> Self { 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 files: usize,
pub downloaded: usize, pub downloaded: usize,
pub skipped: usize, pub skipped: usize,
/// Identical files taken from another installed version instead of downloaded.
pub reused: usize,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -114,6 +124,7 @@ where
let backend = Backend::new(source)?; let backend = Backend::new(source)?;
let m = manifest.manifest(); let m = manifest.manifest();
let version = m.version.clone(); let version = m.version.clone();
backend.use_version(&version);
let wants = |stage: Stage| opts.only.contains(&stage); let wants = |stage: Stage| opts.only.contains(&stage);
let do_app = wants(Stage::App); let do_app = wants(Stage::App);
@@ -146,23 +157,29 @@ where
if do_app { if do_app {
check_cancel()?; check_cancel()?;
stage_index += 1; 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, &current))).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) { if let (true, Some(db)) = (do_db, &m.db) {
check_cancel()?; check_cancel()?;
stage_index += 1; 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 }); summary.db = Some(DbSummary { snapshot: db.snapshot.clone(), files, downloaded, skipped });
} }
if let (true, Some(src)) = (do_src, &m.src) { if let (true, Some(src)) = (do_src, &m.src) {
check_cancel()?; check_cancel()?;
stage_index += 1; 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) { if let (true, Some(tiles)) = (do_tiles, &m.tiles) {
check_cancel()?; check_cancel()?;
stage_index += 1; 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()?; 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<AppSummary> { /// 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<String, PathBuf> {
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::<Value>(&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<String, PathBuf>,
version_dir: &Path,
) -> Result<AppSummary> {
let total: u64 = files.iter().map(|f| f.size).sum(); 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_stage(Stage::App, stage_index, total, files.len() as u64);
ctx.reporter.begin_sub("app".into(), "files", 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( run_pool(
files.iter().collect(), files.iter().collect(),
ctx.workers, ctx.workers,
&ctx.cancel, &ctx.cancel,
|entry: &AppFile, stop| async move { |entry: &AppFile, stop| async move {
let target = join_rel(version_dir, &entry.path)?; 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| { |(outcome, linked)| {
if outcome == Outcome::Downloaded { downloaded += 1 } else { skipped += 1 } match (outcome, linked) {
(Outcome::Downloaded, _) => downloaded += 1,
(Outcome::Skipped, true) => reused += 1,
(Outcome::Skipped, false) => skipped += 1,
}
ctx.reporter.items_done(1); ctx.reporter.items_done(1);
Ok(()) Ok(())
}, },
) )
.await?; .await?;
ctx.reporter.finish_stage(); 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<String>>,
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(); 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_stage(Stage::Db, stage_index, total, count as u64);
ctx.reporter.begin_sub("db".into(), "files", files.len() as u64); ctx.reporter.begin_sub("db".into(), "files", count as u64);
let (mut downloaded, mut skipped) = (0, 0); let (mut downloaded, mut skipped) = (0, 0);
run_pool( run_pool(
files.iter().collect(), files,
ctx.workers.min(4), ctx.workers.min(4),
&ctx.cancel, &ctx.cancel,
|entry: &DbFile, stop| async move { |entry: &DbFile, stop| async move {
@@ -357,7 +440,7 @@ async fn install_db(ctx: &Ctx<'_>, stage_index: u32, files: &[DbFile], data_dir:
) )
.await?; .await?;
ctx.reporter.finish_stage(); ctx.reporter.finish_stage();
Ok((files.len(), downloaded, skipped)) Ok((count, downloaded, skipped))
} }
// ---- source files ----------------------------------------------------------------- // ---- source files -----------------------------------------------------------------
@@ -495,13 +578,186 @@ async fn fetch_chunk(ctx: &Ctx<'_>, entry: &SrcFile, chunk_bytes: u64, dest: &Pa
// ---- tiles -------------------------------------------------------------------------- // ---- 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. /// `{"index_sha256": "...", "blocks": ["z/bx/by:digest", ...]}`, as Python's `json.dumps` writes it.
fn blocks_done_json(index_sha256: &str, done: &BTreeSet<String>) -> String { fn blocks_done_json(index_sha256: &str, done: &BTreeSet<String>) -> String {
let list: Vec<String> = done.iter().map(|k| format!("\"{k}\"")).collect(); let list: Vec<String> = done.iter().map(|k| format!("\"{k}\"")).collect();
format!("{{\"index_sha256\": \"{index_sha256}\", \"blocks\": [{}]}}", list.join(", ")) 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<Vec<TileSetSummary>> {
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::<Value>(&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<PathBuf>)> {
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<Vec<TileSetSummary>> { async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &TilesSection, data_dir: &Path) -> Result<Vec<TileSetSummary>> {
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 total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).sum(); let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).sum();
ctx.reporter.begin_stage(Stage::Tiles, stage_index, total, blocks); 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 index = verify_index(&raw, &set.index_sha256, &what)?;
let set_dir = data_dir.join("tiles").join(style).join(dataset); 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<String> = BTreeSet::new(); let mut done: BTreeSet<String> = BTreeSet::new();
if let Some(saved) = std::fs::read(&done_path).ok().and_then(|raw| serde_json::from_slice::<Value>(&raw).ok()) { if let Some(saved) = std::fs::read(&done_path).ok().and_then(|raw| serde_json::from_slice::<Value>(&raw).ok()) {
if saved.get("index_sha256").and_then(Value::as_str) == Some(set.index_sha256.as_str()) { if saved.get("index_sha256").and_then(Value::as_str) == Some(set.index_sha256.as_str()) {
+78
View File
@@ -194,6 +194,24 @@ fn object<'a>(value: &'a Value, message: &str) -> Result<&'a Map<String, Value>>
/// Error unless `manifest` is a well-formed schema-1 manifest. Structure only; the /// Error unless `manifest` is a well-formed schema-1 manifest. Structure only; the
/// signature is checked by [`crate::signing::verify`]. /// 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<String, Value>, 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<()> { pub fn validate(manifest: &Value) -> Result<()> {
let m = object(manifest, "manifest must be a JSON object")?; 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}"))?; 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()), || { require(entry.get("index").and_then(Value::as_str) == Some(expected.as_str()), || {
format!("{place}: index must be {expected:?}") format!("{place}: index must be {expected:?}")
})?; })?;
check_catalog(entry, &place)?;
sets_seen.insert((style, dataset)); 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()), || { require(entry.get("chunks_sha256").and_then(Value::as_str) == Some(chunks_digest(&hashes).as_str()), || {
format!("{place}: chunks_sha256 does not match chunks") format!("{place}: chunks_sha256 does not match chunks")
})?; })?;
check_catalog(entry, &place)?;
} }
} }
@@ -377,6 +397,13 @@ pub struct TileSet {
pub bytes: u64, pub bytes: u64,
pub index: String, pub index: String,
pub index_sha256: 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -391,6 +418,13 @@ pub struct SrcFile {
pub size: u64, pub size: u64,
pub chunks: Vec<String>, pub chunks: Vec<String>,
pub chunks_sha256: String, 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[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] #[test]
fn versions() { fn versions() {
assert_eq!(parse_version("0.51.0").unwrap(), (0, 51, 0)); assert_eq!(parse_version("0.51.0").unwrap(), (0, 51, 0));
+125 -24
View File
@@ -1,5 +1,6 @@
//! Where a version comes from: a site's update service over HTTP, or an ODIAN drive that //! Where a version comes from: a site's update service over HTTP, an ODIAN drive that holds
//! holds the site store layout (`packs/`, `tiles/`, `src/`). //! 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::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -21,6 +22,9 @@ pub enum Source {
Site { base_url: String, token: String }, Site { base_url: String, token: String },
/// An ODIAN drive (SSD) whose root holds `packs/`, `tiles/` (with `tiles.db`) and `src/`. /// An ODIAN drive (SSD) whose root holds `packs/`, `tiles/` (with `tiles.db`) and `src/`.
Drive { root: PathBuf }, Drive { root: PathBuf },
/// Another ODIAN install (`versions\<v>\`, `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; const RETRIES: u32 = 4;
@@ -97,7 +101,9 @@ pub(crate) enum Body {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(crate) enum Pack<'a> { pub(crate) enum Pack<'a> {
/// `app/<sha256>`. /// `app/<sha256>`.
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/<snapshot>/...`). /// A manifest `db.files[].path` (`db/<snapshot>/...`).
Db(&'a str), Db(&'a str),
} }
@@ -112,9 +118,16 @@ pub(crate) struct Drive {
root: PathBuf, 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<String>,
}
pub(crate) enum Backend { pub(crate) enum Backend {
Site(Site), Site(Site),
Drive(Drive), Drive(Drive),
Install(Install),
} }
impl Backend { impl Backend {
@@ -130,6 +143,9 @@ impl Backend {
Ok(Backend::Site(Site { base: base_url.trim_end_matches('/').to_string(), token: token.clone(), client })) 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::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())))?; .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 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 { match self {
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/manifest"), cancel).await, 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::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 { match self {
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/notes"), cancel).await, 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, 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)); let rel = format!("packs/versions/{version}/{}", tile_index_name(style, dataset));
drive.read(join_rel(&drive.root, &rel)).await 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 { pub fn pack_label(&self, pack: Pack<'_>) -> String {
match pack { match (self, pack) {
Pack::App(sha) => format!("/packs/app/{sha}"), (Backend::Install(_), Pack::App { path, .. }) => path.to_string(),
Pack::Db(path) => format!("/packs/{path}"), (_, Pack::App { sha, .. }) => format!("/packs/app/{sha}"),
(_, Pack::Db(path)) => format!("/packs/{path}"),
} }
} }
@@ -196,7 +230,7 @@ impl Backend {
match self { match self {
Backend::Site(site) => { Backend::Site(site) => {
let path = match pack { 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)), Pack::Db(rel) => format!("/packs/{}", encode_path(rel)),
}; };
let range = (offset > 0).then(|| format!("bytes={offset}-")); let range = (offset > 0).then(|| format!("bytes={offset}-"));
@@ -206,10 +240,18 @@ impl Backend {
} }
Backend::Drive(drive) => { Backend::Drive(drive) => {
let path = match pack { 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), 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)) Ok((body, true))
} }
} }
@@ -230,25 +272,95 @@ impl Backend {
} }
Ok(Body::Http { response, url }) 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> { pub fn drive_root(&self) -> Option<&Path> {
match self { match self {
Backend::Drive(drive) => Some(&drive.root), Backend::Drive(drive) => Some(&drive.root),
Backend::Site(_) => None, _ => None,
} }
} }
pub fn site(&self) -> Option<&Site> { pub fn site(&self) -> Option<&Site> {
match self { match self {
Backend::Site(site) => Some(site), 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\<v>\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<u8>)> {
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<Vec<u8>> {
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<Body> {
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 { impl Site {
/// GET with the Bearer token. Retries 502/503/504 (honouring Retry-After) and /// 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`. /// connection errors with a doubling delay, like fetch.py's `Site.open`.
@@ -326,18 +438,7 @@ impl Site {
impl Drive { impl Drive {
async fn read(&self, path: PathBuf) -> Result<Vec<u8>> { async fn read(&self, path: PathBuf) -> Result<Vec<u8>> {
blocking(move || std::fs::read(&path).map_err(open_error(&path))).await read_file(path).await
}
async fn open_range(&self, path: PathBuf, start: u64, len: u64) -> Result<Body> {
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
} }
} }
+180 -1
View File
@@ -13,7 +13,7 @@
//! //!
//! `ODIAN_PACKS_PYTHON` picks the interpreter (default `python`). //! `ODIAN_PACKS_PYTHON` picks the interpreter (default `python`).
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; 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 { .. }))); 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<String> =
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<String> = 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::<usize>(), 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"] #[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() { 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()); 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::<usize>() > 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)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"] #[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn drive_install_equals_site_install() { async fn drive_install_equals_site_install() {
+51 -3
View File
@@ -35,6 +35,8 @@ interface Status {
phase: Phase; phase: Phase;
error: string | null; // set with phase "error"; cleared by the next command or `refresh` error: string | null; // set with phase "error"; cleared by the next command or `refresh`
install_dir: string; // e.g. "D:\\ODIAN" 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 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, live: boolean | null; // while ODIAN runs: true = its data comes from the DHP,
// false = from this PC. null when it is not running, or // 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 LogEvent { level: "info" | "warn" | "error"; message: string; at: number }
interface MarketItem {
key: string; // stable across versions: "tiles:<style>/<dataset>" | "src:<path>"
kind: "tiles" | "src";
title: string;
description: string;
bytes: number; // what installing it downloads, in full
required: boolean; // part of every install; never offered for removal
presence: "installed" | "partial" | "absent"; // partial: a download that stopped
selected: boolean; // chosen: installed with the version, kept by updates
}
interface Market {
version: string; // the installed version, else the DHP's current one
installed: boolean; // false: a choice waits for Install
core_bytes: number; // the app and database: what every install downloads besides the items
items: MarketItem[]; // required first
free_bytes: number | null; // free space where ODIAN is (or will be) installed
}
``` ```
## Commands (`invoke`) ## Commands (`invoke`)
@@ -98,7 +119,7 @@ interface LogEvent { level: "info" | "warn" | "error"; message: string; at: numb
| `get_notes` | `{ version?: string }` | `string` | Markdown. From the site when online; the cached copy of the installed version offline. **Untrusted: the UI must sanitize.** | | `get_notes` | `{ version?: string }` | `string` | Markdown. From the site when online; the cached copy of the installed version offline. **Untrusted: the UI must sanitize.** |
| `open_external` | `{ url: string }` | `void` | opens an `http`/`https` link in the system browser (links in patch notes); anything else is refused | | `open_external` | `{ url: string }` | `void` | opens an `http`/`https` link in the system browser (links in patch notes); anything else is refused |
| `start_install` | — | `void` | installs or updates to `available`; needs `signed_in` (the site checks the token); progress arrives as events | | `start_install` | — | `void` | installs or updates to `available`; needs `signed_in` (the site checks the token); progress arrives as events |
| `start_install_from_drive` | `{ path: string }` | `void` | same, reading an ODIAN drive (SSD) instead of the site; no sign-in needed | | `start_install_from_drive` | `{ path: string }` | `void` | same, copying from an ODIAN drive (SSD) instead of the site; no sign-in needed. `path` is the drive's root (a pack store) or a folder holding an install, and every file copied is checked against the manifest. A tile set the PC already records as complete for the same index (`.blocks-done.json`) is left as it stands instead of being re-read: that record is written last, so a copy stopped halfway still reads as partial and resumes. The copy lands in the install folder of Settings, so a drive can install onto the PC it is plugged into; an install folder that is the drive's own ODIAN (or inside it) is refused, since there is nothing to copy, and so is a drive carrying an older ODIAN than the one installed there |
| `cancel_install` | — | `void` | stops at the next file boundary; everything already verified is kept | | `cancel_install` | — | `void` | stops at the next file boundary; everything already verified is kept |
| `launch` | — | `void` | starts the local stack; `stack` events follow | | `launch` | — | `void` | starts the local stack; `stack` events follow |
| `stop` | — | `void` | stops Odian.exe's stack cleanly | | `stop` | — | `void` | stops Odian.exe's stack cleanly |
@@ -106,14 +127,41 @@ interface LogEvent { level: "info" | "warn" | "error"; message: string; at: numb
| `get_settings` | — | `Settings` | | | `get_settings` | — | `Settings` | |
| `set_settings` | `{ settings: Settings }` | `Status` | | | `set_settings` | `{ settings: Settings }` | `Status` | |
| `pick_folder` | — | `string \| null` | native folder picker | | `pick_folder` | — | `string \| null` | native folder picker |
| `get_market` | `{ drive?: string }` | `Market` | the marketplace: every tile set and source file of the installed version (read from disk), or of the DHP's current one before the first install. With `drive`, what that drive carries instead (what it cannot install is left out), which is how the UI asks before installing from a drive |
| `apply_market` | `{ selected: string[] }` | `void` | saves the choice of optional items (keys), then applies it to the installed version: unchosen items are removed from the PC, chosen missing ones download (phase `installing`, `install-progress` events). Refused while busy or ODIAN runs, and while an update is required; downloading needs the DHP. Before the first install it only saves the choice |
| `set_market` | `{ selected: string[], drive?: string }` | `void` | the choice of optional items (keys) for an install about to run; changes nothing installed. Without `drive` it is this PC's standing choice and is saved. With `drive` it filters that one copy and is NOT saved: leaving out a tile set the PC already holds must not read as dropping it, or the next `apply_market` would delete it |
| `sync_now` | — | `void` | pushes and pulls operator data now; only while ODIAN is stopped (phase `syncing` meanwhile) | | `sync_now` | — | `void` | pushes and pulls operator data now; only while ODIAN is stopped (phase `syncing` meanwhile) |
| `get_sync_conflicts` | — | `SyncConflict[]` | what waits for a decision, oldest first; starts the local Postgres just for the read | | `get_sync_conflicts` | — | `SyncConflict[]` | what waits for a decision, oldest first; starts the local Postgres just for the read |
| `resolve_sync_conflict` | `{ tbl, key, choice: "theirs" \| "mine" \| "value" \| "dismiss", value?: unknown }` | `SyncConflict[]` | records one decision and returns what is left. `theirs` takes the DHP's version; `mine` keeps this PC's (the next sync sends it); `value` keeps a field-by-field pick; `dismiss` keeps a refused edit on this PC only. Links and folder placements held with a record follow its decision | | `resolve_sync_conflict` | `{ tbl, key, choice: "theirs" \| "mine" \| "value" \| "dismiss", value?: unknown }` | `SyncConflict[]` | records one decision and returns what is left. `theirs` takes the DHP's version; `mine` keeps this PC's (the next sync sends it); `value` keeps a field-by-field pick; `dismiss` keeps a refused edit on this PC only. Links and folder placements held with a record follow its decision |
Errors reject the promise with a string message. Errors reject the promise with a string message.
Planned, not yet available: the size of `available` for the Install button, and Planned, not yet available: the size of `available` for the Install button.
inspecting a drive (its version and size) before installing from it.
## Where a drive's launcher installs
A launcher that ships on an ODIAN drive (`site.json` with `"portable": true`) keeps its
settings beside the exe, so the site and the install folder travel with the drive. The
folder may be on the drive itself, and ODIAN then runs from the drive on any PC: it is
stored without its letter, because the next PC hands the drive a different one. It may
also be a folder on a disk of the PC in front of the operator, and the drive then installs
ODIAN onto that PC and leaves; that folder is stored whole. A drive's root and a network
share are refused: Postgres and the tiles need a real disk.
## Marketplace
The DHP decides what is optional: a manifest tile set or source file with
`"optional": true` (plus `title` and `description`, from `catalog.json` at assemble) is
left out of Install and listed by `get_market`. Everything else is part of every install.
A manifest from before the marketplace flags nothing, so the launcher applies the same rule
to it: the app, the database and the `dark` tile set come with every install and the rest is
offered, which is what makes copying an older version from a drive a choice rather than all
228 GB or nothing. A manifest that flags anything at all is the DHP's to decide and is taken
exactly as it stands. The choice is kept in the
install's state (`extras`) and survives updates: an update refreshes the chosen items and
nothing else. Installing from a drive asks the same way: the marketplace opens on what the
drive carries, `set_market` records the answer, and `start_install_from_drive` copies it. A PC installed before the marketplace counts what it has on disk as chosen,
so nothing is removed or downloaded again behind the operator's back.
## Live mode ## Live mode
+1
View File
@@ -24,6 +24,7 @@ tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2" tauri-plugin-log = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tokio = { version = "1", features = ["net", "io-util", "time", "sync", "macros", "rt-multi-thread", "fs"] } tokio = { version = "1", features = ["net", "io-util", "time", "sync", "macros", "rt-multi-thread", "fs"] }
tokio-util = "0.7" tokio-util = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
+26 -1
View File
@@ -4,7 +4,7 @@ use tauri::{AppHandle, State};
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use crate::launcher::{Launcher, Status}; use crate::launcher::{Launcher, Market, Status};
use crate::settings::Settings; use crate::settings::Settings;
use crate::stack::StackEvent; use crate::stack::StackEvent;
use crate::sync::Conflict; use crate::sync::Conflict;
@@ -21,6 +21,31 @@ pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
Ok(launcher.status().await) Ok(launcher.status().await)
} }
/// The marketplace: every tile set and source file of the version, required ones first. With
/// `drive`, what that drive can install instead of the DHP.
#[tauri::command]
pub async fn get_market(app: AppHandle, launcher: State<'_, Launcher>, drive: Option<String>) -> CmdResult<Market> {
launcher.market(&app, drive.map(std::path::PathBuf::from)).await
}
/// Choose the optional items (their keys) for an install about to run, without touching what
/// is installed. `drive` names the drive whose items these are.
#[tauri::command]
pub async fn set_market(
app: AppHandle,
launcher: State<'_, Launcher>,
selected: Vec<String>,
drive: Option<String>,
) -> CmdResult<()> {
launcher.choose_market(&app, selected, drive.map(std::path::PathBuf::from)).await
}
/// Choose the optional items (their keys) and apply the choice; see `Launcher::apply_market`.
#[tauri::command]
pub async fn apply_market(app: AppHandle, launcher: State<'_, Launcher>, selected: Vec<String>) -> CmdResult<()> {
launcher.apply_market(&app, selected).await
}
#[tauri::command] #[tauri::command]
pub async fn refresh(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Status> { pub async fn refresh(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Status> {
Ok(launcher.refresh(&app).await) Ok(launcher.refresh(&app).await)
+654 -13
View File
@@ -1,5 +1,6 @@
//! The launcher's live state behind every command, and the `status` event. //! The launcher's live state behind every command, and the `status` event.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
@@ -13,6 +14,8 @@ use tokio_util::sync::CancellationToken;
use crate::install; use crate::install;
use crate::localdb::{self, Pg}; use crate::localdb::{self, Pg};
use crate::market;
use crate::prune;
use crate::settings::{normalize_url, Settings}; use crate::settings::{normalize_url, Settings};
use crate::site::{self, SessionRefresh, TokenCheck}; use crate::site::{self, SessionRefresh, TokenCheck};
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent}; use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
@@ -54,6 +57,9 @@ pub struct Status {
pub phase: Phase, pub phase: Phase,
pub error: Option<String>, pub error: Option<String>,
pub install_dir: String, pub install_dir: String,
/// This launcher ships on an ODIAN drive: it runs that drive's install, so the folder
/// cannot be changed and it never updates itself.
pub portable: bool,
/// Operator-data sync counts from the last sync this session; None before one. /// Operator-data sync counts from the last sync this session; None before one.
pub sync: Option<SyncStatus>, pub sync: Option<SyncStatus>,
/// While ODIAN runs: true when its data is coming from the DHP, false when from /// While ODIAN runs: true when its data is coming from the DHP, false when from
@@ -61,6 +67,19 @@ pub struct Status {
pub live: Option<bool>, pub live: Option<bool>,
} }
/// What the marketplace shows (`get_market`).
#[derive(Clone, Serialize)]
pub struct Market {
/// The version the items belong to: the installed one, else the DHP's current one.
pub version: String,
pub installed: bool,
/// The app and database: what every install downloads besides the items.
pub core_bytes: u64,
pub items: Vec<market::Item>,
/// Free space where ODIAN is (or will be) installed; None when it cannot be read.
pub free_bytes: Option<u64>,
}
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
struct LogEvent { struct LogEvent {
level: &'static str, level: &'static str,
@@ -104,6 +123,16 @@ struct Inner {
/// during a blip still knows where live mode will forward. /// during a blip still knows where live mode will forward.
engine_url: Option<String>, engine_url: Option<String>,
live: Option<bool>, live: Option<bool>,
/// 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
/// launch or install meanwhile, since installing it closes this launcher.
updating_self: bool,
/// What the marketplace picked for the copy about to run from a drive. It filters that one
/// copy and is never written down: a tile set left out because this PC already has it must
/// not read as "the operator dropped it", or the next Apply in the marketplace would delete
/// it. What this PC stands by is what it holds, which `present_extras` reads back.
drive_extras: Option<BTreeSet<String>>,
} }
impl Inner { impl Inner {
@@ -118,13 +147,18 @@ impl Inner {
operator: self.state.operator.clone(), operator: self.state.operator.clone(),
// Remembered until Sign out: a launcher session renews the Engine token on its own. // Remembered until Sign out: a launcher session renews the Engine token on its own.
signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()), signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()),
can_launch: installed.is_some() && !update_required && matches!(self.phase, Phase::Idle | Phase::Error), can_launch: installed.is_some()
&& !update_required
&& !self.updating_self
&& matches!(self.phase, Phase::Idle | Phase::Error),
installed, installed,
available: self.available.clone(), available: self.available.clone(),
update_required, update_required,
phase: self.phase, // Updating itself reads as "checking": the window already shows that at every start.
phase: if self.updating_self && matches!(self.phase, Phase::Idle | Phase::Error) { Phase::Checking } else { self.phase },
error: self.error.clone(), error: self.error.clone(),
install_dir: self.settings.install_dir.clone(), install_dir: self.settings.install_dir.clone(),
portable: self.portable,
sync: self.sync.clone(), sync: self.sync.clone(),
live: self.live, live: self.live,
} }
@@ -141,6 +175,98 @@ pub struct Launcher {
install_cancel: std::sync::Mutex<Option<CancellationToken>>, install_cancel: std::sync::Mutex<Option<CancellationToken>>,
} }
/// What an operator picked when installing from a drive: the site store an ODIAN drive
/// carries (`packs\versions\CURRENT`), or an ODIAN install to copy - the drive itself, or an
/// `ODIAN` folder on it, which is how a drive an operator runs from is laid out.
fn drive_source(picked: &Path) -> Result<Source, String> {
if picked.join("packs").join("versions").join("CURRENT").is_file() {
return Ok(Source::Drive { root: picked.to_path_buf() });
}
for root in [picked.to_path_buf(), picked.join("ODIAN")] {
if holds_a_version(&root) {
return Ok(Source::Install { root });
}
}
Err(format!(
"{} holds no ODIAN: a drive carries either a pack store (packs\\versions\\CURRENT) or an install (versions\\<version>).",
picked.display()
))
}
/// Copying another install into itself copies nothing and would delete what it reads: the
/// operator has to say where it goes first. A drive's pack store is different: installing
/// from it onto its own drive is how ODIAN runs from an SSD.
fn self_copy_problem(source: &Source, into: &Path) -> Option<String> {
let Source::Install { root } = source else { return None };
let key = |p: &Path| p.to_string_lossy().replace('/', r"\").trim_end_matches('\\').to_lowercase();
let (from, target) = (key(root), key(into));
(target == from || target.starts_with(&format!(r"{from}\"))).then(|| {
format!(
"The install folder is {}, which is the ODIAN this drive carries. Set the install folder to where the copy goes \
(a folder on this PC, or another folder on the drive) in Settings, then install from the drive.",
into.display()
)
})
}
fn version_order(version: &str) -> Option<(u32, u32, u32)> {
let mut parts = version.split(['.', '-']).map(|part| part.parse::<u32>().ok());
Some((parts.next()??, parts.next()??, parts.next()??))
}
/// A drive carrying an older ODIAN than the one installed. Copying it would put the older
/// version (and its database schema) over the newer one, which the next prune then removes:
/// the operator updates from the DHP instead, or copies into another folder.
fn older_version_problem(carried: &str, installed: Option<&str>, into: &Path) -> Option<String> {
let installed = installed?;
(version_order(carried)? < version_order(installed)?).then(|| {
format!(
"This drive carries ODIAN {carried}, older than the {installed} installed in {}. Copying it would replace the \
newer version: update from your DHP instead, or set the install folder to somewhere else.",
into.display()
)
})
}
/// A folder holding at least one whole version: `versions\<v>\manifest.json` is written last.
fn holds_a_version(root: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(root.join("versions")) else { return false };
entries.flatten().any(|entry| entry.path().join("manifest.json").is_file())
}
/// The launcher's update feed on the download page (written by package_launcher.ps1).
const LAUNCHER_FEED: &str = "launcher-update.json";
/// Append one line to `<dir>/launcher.log`, keeping one older file past 2 MB. Best effort:
/// a log that cannot be written never stops the launcher.
fn append_log(dir: &Path, level: &str, message: &str) {
use std::io::Write;
let path = dir.join("launcher.log");
if std::fs::metadata(&path).is_ok_and(|md| md.len() > 2_000_000) {
let _ = std::fs::rename(&path, dir.join("launcher.log.1"));
}
let _ = std::fs::create_dir_all(dir);
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(file, "{} {level:5} {message}", utc_text(now_secs()));
}
}
/// `2026-09-22 13:05:09Z` from Unix seconds (days to a civil date, H. Hinnant's algorithm).
fn utc_text(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rest = secs % 86_400;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}Z", rest / 3_600, rest % 3_600 / 60, rest % 60)
}
fn now_secs() -> u64 { fn now_secs() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
} }
@@ -156,7 +282,7 @@ impl Launcher {
.map_err(|e| format!("cannot start the HTTP client: {e}"))?; .map_err(|e| format!("cannot start the HTTP client: {e}"))?;
Ok(Launcher { Ok(Launcher {
http, http,
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None }), inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, drive_extras: None }),
stack: Mutex::new(None), stack: Mutex::new(None),
stack_states: std::sync::Mutex::new(Vec::new()), stack_states: std::sync::Mutex::new(Vec::new()),
sign_in_cancel: Notify::new(), sign_in_cancel: Notify::new(),
@@ -175,7 +301,13 @@ impl Launcher {
} }
fn log(&self, app: &AppHandle, level: &'static str, message: impl Into<String>) { fn log(&self, app: &AppHandle, level: &'static str, message: impl Into<String>) {
let _ = app.emit("log", &LogEvent { level, message: message.into(), at: now_secs() }); let message = message.into();
// Also on disk: the window shows only what arrives once it listens (the first lines of
// a start come before that), and an operator's launcher.log explains a problem later.
if let Ok(dir) = app.path().app_log_dir() {
append_log(&dir, level, &message);
}
let _ = app.emit("log", &LogEvent { level, message, at: now_secs() });
} }
async fn finish(&self, app: &AppHandle, error: Option<String>) { async fn finish(&self, app: &AppHandle, error: Option<String>) {
@@ -383,6 +515,11 @@ impl Launcher {
if next.install_dir.is_empty() { if next.install_dir.is_empty() {
next.install_dir = inner.settings.install_dir.clone(); next.install_dir = inner.settings.install_dir.clone();
} }
// A drive's own launcher installs on the drive, to run ODIAN from it, or on a disk of
// the PC it is plugged into, to hand ODIAN over. Never a root, never a share.
if let Some(problem) = crate::settings::portable_folder_problem(&next.install_path()) {
return Err(problem);
}
next.save(app)?; next.save(app)?;
if next.install_dir != inner.settings.install_dir { if next.install_dir != inner.settings.install_dir {
inner.state = LauncherState::load(&next.install_path())?; inner.state = LauncherState::load(&next.install_path())?;
@@ -424,15 +561,25 @@ impl Launcher {
/// Install or update from the DHP (`drive: None`) or from an ODIAN drive. /// Install or update from the DHP (`drive: None`) or from an ODIAN drive.
/// Returns once installing has begun; `install-progress`, `log` and `status` events follow. /// Returns once installing has begun; `install-progress`, `log` and `status` events follow.
pub async fn start_install(&self, app: &AppHandle, drive: Option<PathBuf>) -> Result<(), String> { pub async fn start_install(&self, app: &AppHandle, drive: Option<PathBuf>) -> Result<(), String> {
self.start_run(app, drive, false).await
}
/// `market`: remove the optional items no longer chosen and download the chosen ones for the
/// installed version, instead of installing the DHP's current version.
async fn start_run(&self, app: &AppHandle, drive: Option<PathBuf>, market: bool) -> Result<(), String> {
{ {
let inner = self.inner.lock().await; let inner = self.inner.lock().await;
if !matches!(inner.phase, Phase::Idle | Phase::Error) { if !matches!(inner.phase, Phase::Idle | Phase::Error) {
return Err("The launcher is busy.".to_string()); return Err("The launcher is busy.".to_string());
} }
if inner.updating_self {
return Err("The launcher is updating itself: it restarts in a moment.".to_string());
}
match &drive { match &drive {
Some(root) => { Some(root) => {
if !root.join("packs").join("versions").join("CURRENT").is_file() { let source = drive_source(root)?;
return Err(format!("{} is not an ODIAN drive: it has no packs\\versions\\CURRENT.", root.display())); if let Some(problem) = self_copy_problem(&source, &inner.settings.install_path()) {
return Err(problem);
} }
} }
None => { None => {
@@ -448,7 +595,7 @@ impl Launcher {
} }
let install_dir = { let install_dir = {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
if !matches!(inner.phase, Phase::Idle | Phase::Error) { if !matches!(inner.phase, Phase::Idle | Phase::Error) || inner.updating_self {
return Err("The launcher is busy.".to_string()); return Err("The launcher is busy.".to_string());
} }
inner.phase = Phase::Installing; inner.phase = Phase::Installing;
@@ -463,7 +610,7 @@ impl Launcher {
let handle = app.clone(); let handle = app.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
handle.state::<Launcher>().run_install(&handle, drive, install_dir, cancel).await; handle.state::<Launcher>().run_install(&handle, drive, install_dir, cancel, market).await;
}); });
Ok(()) Ok(())
} }
@@ -529,7 +676,15 @@ impl Launcher {
} }
} }
async fn run_install(&self, app: &AppHandle, drive: Option<PathBuf>, install_dir: PathBuf, cancel: CancellationToken) { async fn run_install(&self, app: &AppHandle, drive: Option<PathBuf>, install_dir: PathBuf, cancel: CancellationToken, market: bool) {
if market && !self.remove_unchosen(app, &install_dir).await {
// Nothing left to download: the removals were the whole change.
if let Ok(mut slot) = self.install_cancel.lock() {
*slot = None;
}
self.finish(app, None).await;
return;
}
// Downloading a whole version from the DHP takes a slot; a drive install takes none. // Downloading a whole version from the DHP takes a slot; a drive install takes none.
let hold = CancellationToken::new(); let hold = CancellationToken::new();
if drive.is_none() && !self.wait_for_install_slot(app, &cancel, hold.clone()).await { if drive.is_none() && !self.wait_for_install_slot(app, &cancel, hold.clone()).await {
@@ -541,13 +696,16 @@ impl Launcher {
let mut renewed = false; let mut renewed = false;
let result = loop { let result = loop {
let source = match &drive { let source = match &drive {
Some(root) => Source::Drive { root: root.clone() }, Some(root) => match drive_source(root) {
Ok(source) => source,
Err(message) => break Err(InstallFailure::Failed(message)),
},
None => match self.site_access_token(app).await { None => match self.site_access_token(app).await {
Ok(token) => Source::Site { base_url: self.inner.lock().await.settings.site_url.clone(), token }, Ok(token) => Source::Site { base_url: self.inner.lock().await.settings.site_url.clone(), token },
Err(message) => break Err(InstallFailure::Failed(message)), Err(message) => break Err(InstallFailure::Failed(message)),
}, },
}; };
match self.install_steps(app, &source, &install_dir, &cancel).await { match self.install_steps(app, &source, &install_dir, &cancel, market).await {
// The site's API restarted (or the token aged out) mid-way: renew once and resume. // The site's API restarted (or the token aged out) mid-way: renew once and resume.
// Everything already verified is skipped on the second pass. // Everything already verified is skipped on the second pass.
Err(InstallFailure::Unauthorized) if drive.is_none() && !renewed => { Err(InstallFailure::Unauthorized) if drive.is_none() && !renewed => {
@@ -566,12 +724,18 @@ impl Launcher {
*slot = None; *slot = None;
} }
match result { match result {
Ok(_) if market => {
self.log(app, "info", "The marketplace items are installed.");
self.finish(app, None).await;
}
Ok(version) => { Ok(version) => {
self.log(app, "info", format!("ODIAN {version} is installed in {}", install_dir.display())); self.log(app, "info", format!("ODIAN {version} is installed in {}", install_dir.display()));
self.stop_replaced_db(app, &install_dir, &version).await;
self.finish(app, None).await; self.finish(app, None).await;
if drive.is_none() { if drive.is_none() {
self.refresh(app).await; self.refresh(app).await;
} }
self.prune(app).await;
} }
Err(InstallFailure::Cancelled) => { Err(InstallFailure::Cancelled) => {
self.log(app, "warn", "Install cancelled. Everything already verified was kept; installing again resumes from there."); self.log(app, "warn", "Install cancelled. Everything already verified was kept; installing again resumes from there.");
@@ -595,18 +759,46 @@ impl Launcher {
source: &Source, source: &Source,
install_dir: &Path, install_dir: &Path,
cancel: &CancellationToken, cancel: &CancellationToken,
market: bool,
) -> Result<String, InstallFailure> { ) -> Result<String, InstallFailure> {
if market {
return self.install_market_items(app, source, install_dir, cancel).await;
}
match source { match source {
Source::Site { base_url, .. } => self.log(app, "info", format!("Reading the current version from {base_url}")), Source::Site { base_url, .. } => self.log(app, "info", format!("Reading the current version from {base_url}")),
Source::Drive { root } => self.log(app, "info", format!("Reading the ODIAN drive at {}", root.display())), Source::Drive { root } => self.log(app, "info", format!("Reading the ODIAN drive at {}", root.display())),
Source::Install { root } => {
self.log(app, "info", format!("Copying ODIAN from the install on {}", root.display()))
}
} }
let verified = odian_packs::current_manifest(source, odian_packs::bundled_public_key()).await?; let verified = odian_packs::current_manifest(source, odian_packs::bundled_public_key()).await?;
let manifest = verified.manifest(); let manifest = verified.manifest();
let version = verified.version().to_string(); let version = verified.version().to_string();
install::check_runnable(manifest)?; install::check_runnable(manifest)?;
if !matches!(source, Source::Site { .. }) {
let here = self.inner.lock().await.state.installed_version.clone();
if let Some(problem) = older_version_problem(&version, here.as_deref(), install_dir) {
return Err(InstallFailure::Failed(problem));
}
}
// An ODIAN drive is carried between PCs: its install must not belong to this PC's account.
// An install onto the PC itself is that PC's own, and keeps the permissions it has.
let portable = self.inner.lock().await.portable;
if portable && crate::settings::installs_on_the_drive(install_dir) {
let _ = std::fs::create_dir_all(install_dir);
if let Err(e) = crate::settings::share_with_every_account(install_dir) {
self.log(app, "warn", format!("This drive's install may only be usable by you ({e})."));
}
}
// The marketplace items the operator chose: for a copy, the popup's answer, used once.
let extras = match self.inner.lock().await.drive_extras.take() {
Some(chosen) if !matches!(source, Source::Site { .. }) => chosen,
_ => self.chosen_extras(install_dir, manifest).await?,
};
// A USB key cannot carry this: Postgres restores 16 GB into it, and unplugging the key // A USB key cannot carry this: Postgres restores 16 GB into it, and unplugging the key
// would take the install with it. The default folder already avoids removable drives. // would take the install with it. The default folder already avoids removable drives.
if crate::settings::is_removable_path(install_dir) { // A drive's own launcher installs onto that drive on purpose: the warning is for a PC.
if crate::settings::is_removable_path(install_dir) && !portable {
self.log( self.log(
app, app,
"warn", "warn",
@@ -636,7 +828,16 @@ impl Launcher {
let _ = app.emit("install-progress", &report); let _ = app.emit("install-progress", &report);
} }
}; };
odian_packs::install(source, install_dir, &verified, InstallOptions::default(), progress, cancel.clone()).await?; // Keeping its database, this PC needs only the tables it lacks from a new snapshot: the
// rest of it would be gigabytes downloaded and never read.
let db_paths = match (&manifest.db, database_restored) {
(Some(db), true) => self.db_files_needed(app, install_dir, db).await,
_ => None,
};
let (tile_sets, src_paths) = market::install_filter(manifest, &extras);
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?;
let base = match crate_stages.load(Ordering::Relaxed) { let base = match crate_stages.load(Ordering::Relaxed) {
0 => 5, 0 => 5,
n => n, n => n,
@@ -727,6 +928,397 @@ impl Launcher {
Ok(version) Ok(version)
} }
/// The operator's marketplace choice for `manifest`, saving it when this is the first time:
/// whatever optional data is already on the disk counts as chosen, so an install from
/// before the marketplace keeps what it has, and a drive seeded from another PC keeps what
/// it was given. An empty folder starts with nothing optional.
async fn chosen_extras(&self, install_dir: &Path, manifest: &odian_packs::Manifest) -> Result<BTreeSet<String>, String> {
let mut inner = self.inner.lock().await;
if let Some(extras) = &inner.state.extras {
return Ok(extras.clone());
}
let extras = market::present_extras(manifest, &install_dir.join("data"));
inner.state.extras = Some(extras.clone());
inner.state.save(install_dir)?;
Ok(extras)
}
/// The manifest the marketplace lists: a drive's when one is being installed from, else the
/// installed version's (from disk), else the DHP's current one, so the optional items can be
/// chosen before the first install.
async fn market_manifest(&self, app: &AppHandle) -> Result<(odian_packs::Manifest, bool, PathBuf), String> {
let (install_dir, installed, site_url) = {
let inner = self.inner.lock().await;
(inner.settings.install_path(), inner.state.installed_version.clone(), inner.settings.site_url.clone())
};
if let Some(version) = installed {
let path = Layout::new(&install_dir, &version).version_dir().join("manifest.json");
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
let manifest =
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not a valid manifest: {e}", path.display()))?;
return Ok((manifest, true, install_dir));
}
if !self.inner.lock().await.online {
return Err("Connect to your DHP to see what it offers.".to_string());
}
let token = self.site_access_token(app).await?;
let source = Source::Site { base_url: site_url, token };
let verified =
odian_packs::current_manifest(&source, odian_packs::bundled_public_key()).await.map_err(|e| e.to_string())?;
Ok((verified.manifest().clone(), false, install_dir))
}
/// What the marketplace shows: every tile set and source file, what this PC has, what is
/// chosen. With `drive`, it describes what that drive can install instead of the DHP, and
/// leaves out what the drive does not carry.
pub async fn market(&self, app: &AppHandle, drive: Option<PathBuf>) -> Result<Market, String> {
let (manifest, installed, install_dir, source_data) = match drive {
Some(picked) => {
let source = drive_source(&picked)?;
let install_dir = self.inner.lock().await.settings.install_path();
if let Some(problem) = self_copy_problem(&source, &install_dir) {
return Err(problem);
}
let verified = odian_packs::current_manifest(&source, odian_packs::bundled_public_key())
.await
.map_err(|e| e.to_string())?;
let here = self.inner.lock().await.state.installed_version.clone();
if let Some(problem) = older_version_problem(verified.version(), here.as_deref(), &install_dir) {
return Err(problem);
}
let installed = here.is_some();
// A drive's store keeps `tiles\` and `src\` at its root; an install keeps them in `data\`.
let data = match &source {
Source::Install { root } => root.join("data"),
Source::Drive { root } => root.clone(),
Source::Site { .. } => install_dir.join("data"),
};
(verified.manifest().clone(), installed, install_dir, Some(data))
}
None => {
let (manifest, installed, install_dir) = self.market_manifest(app).await?;
(manifest, installed, install_dir, None)
}
};
let data = install_dir.join("data");
let chosen = self.inner.lock().await.state.extras.clone();
let extras =
chosen.unwrap_or_else(|| if installed { market::present_extras(&manifest, &data) } else { BTreeSet::new() });
let free_bytes = install_dir.ancestors().find(|dir| dir.exists()).and_then(prune::free_bytes);
let core_bytes = manifest.app.files.iter().map(|f| f.size).sum::<u64>()
+ manifest.db.iter().flat_map(|db| &db.files).map(|f| f.size).sum::<u64>();
Ok(Market {
version: manifest.version.clone(),
installed,
core_bytes,
items: market::items_from(&manifest, &data, source_data.as_deref(), &extras),
free_bytes,
})
}
/// Save the operator's choice of optional items (`selected`, item keys) and nothing more:
/// what an install about to run will bring. `drive` names the source whose items these are.
pub async fn choose_market(&self, app: &AppHandle, selected: Vec<String>, drive: Option<PathBuf>) -> Result<(), String> {
let from_drive = drive.is_some();
let manifest = match drive {
Some(picked) => {
let source = drive_source(&picked)?;
odian_packs::current_manifest(&source, odian_packs::bundled_public_key())
.await
.map_err(|e| e.to_string())?
.manifest()
.clone()
}
None => self.market_manifest(app).await?.0,
};
let extras = market::optional_keys(&manifest, selected);
{
let mut inner = self.inner.lock().await;
if from_drive {
inner.drive_extras = Some(extras);
} else {
inner.state.extras = Some(extras);
let dir = inner.settings.install_path();
inner.state.save(&dir)?;
}
}
self.emit(app).await;
Ok(())
}
/// 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.
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);
{
let mut inner = self.inner.lock().await;
if !matches!(inner.phase, Phase::Idle | Phase::Error) || inner.updating_self {
return Err("The launcher is busy: stop ODIAN or let it finish first.".to_string());
}
if installed && inner.status().update_required {
return Err("Update ODIAN first: the marketplace changes the installed version.".to_string());
}
inner.state.extras = Some(extras.clone());
inner.state.save(&install_dir)?;
}
let data = install_dir.join("data");
let adds = market::to_add(&manifest, &data, &extras);
if !installed || (adds.is_empty() && market::to_remove(&manifest, &data, &extras).is_empty()) {
self.emit(app).await;
return Ok(());
}
if !adds.is_empty() && !self.inner.lock().await.online {
return Err("Your DHP is unreachable: connect to download marketplace items.".to_string());
}
self.start_run(app, None, true).await
}
/// Remove the installed version's optional items the operator no longer chose. Returns
/// whether chosen items still need downloading.
async fn remove_unchosen(&self, app: &AppHandle, install_dir: &Path) -> bool {
let manifest = match self.market_manifest(app).await {
Ok((manifest, _, _)) => manifest,
Err(message) => {
self.log(app, "error", message);
return false;
}
};
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 (m, d, key) = (manifest.clone(), data.clone(), item.key.clone());
match tauri::async_runtime::spawn_blocking(move || market::remove(&m, &d, &key)).await {
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()
}
/// Download the chosen marketplace items of the installed version (tiles and source files
/// only: the app and database are already in place).
async fn install_market_items(
&self,
app: &AppHandle,
source: &Source,
install_dir: &Path,
cancel: &CancellationToken,
) -> Result<String, InstallFailure> {
let (installed, extras) = {
let inner = self.inner.lock().await;
(inner.state.installed_version.clone(), inner.state.extras.clone().unwrap_or_default())
};
let installed = installed.ok_or_else(|| "ODIAN is not installed.".to_string())?;
let verified = odian_packs::version_manifest(source, &installed, odian_packs::bundled_public_key()).await?;
let manifest = verified.manifest();
let titles: Vec<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 options = InstallOptions {
only: vec![Stage::Src, Stage::Tiles],
tile_sets: Some(tile_sets),
src_paths: Some(src_paths),
..InstallOptions::default()
};
let progress = {
let app = app.clone();
move |report: Progress| {
let _ = app.emit("install-progress", &report);
}
};
odian_packs::install(source, install_dir, &verified, options, progress, cancel.clone()).await?;
Ok(installed)
}
/// The files of snapshot `db` this PC's own database needs: the schema dumps and the tables
/// it lacks. Asks the database, starting it for the question when it is stopped. `None`
/// (every file) whenever it cannot be asked: downloading too much is safe, too little is not.
async fn db_files_needed(
&self,
app: &AppHandle,
install_dir: &Path,
db: &odian_packs::manifest::DbSection,
) -> Option<BTreeSet<String>> {
let (installed, password) = {
let inner = self.inner.lock().await;
(inner.state.installed_version.clone(), inner.state.pg_password.clone())
};
let installed = installed?;
let files: Vec<localdb::DbFile> = db
.files
.iter()
.map(|f| localdb::DbFile { section: f.section.clone(), table: f.table.clone(), path: f.path.clone(), size: f.size })
.collect();
let pg = Pg::new(&Layout::new(install_dir, &installed), &password);
let asked = tauri::async_runtime::spawn_blocking(move || -> Result<(BTreeSet<String>, usize, u64, u64), String> {
if !pg.is_initialized() {
return Err("it has not been created yet".to_string());
}
let started_here = !pg.running();
if started_here {
pg.start()?;
}
let missing = pg.missing_tables(&files);
if started_here {
let _ = pg.stop();
}
let missing = missing?;
let needed = localdb::files_needed(&files, &missing);
let needed_bytes = files.iter().filter(|f| needed.contains(&f.path)).map(|f| f.size).sum();
let all_bytes = files.iter().map(|f| f.size).sum();
Ok((needed, missing.len(), needed_bytes, all_bytes))
})
.await
.map_err(|e| e.to_string())
.and_then(|asked| asked);
match asked {
Ok((needed, missing, needed_bytes, all_bytes)) => {
self.log(
app,
"info",
format!(
"This PC's database lacks {missing} table(s) of snapshot {}: downloading {} of its {}.",
db.snapshot,
prune::size_text(needed_bytes),
prune::size_text(all_bytes)
),
);
Some(needed)
}
Err(e) => {
self.log(app, "warn", format!("Could not ask this PC's database which tables it lacks ({e}): downloading the whole snapshot."));
None
}
}
}
/// Replace this launcher with a newer one the download page publishes: fetch it, check it
/// is signed with the ODIAN launcher key (tauri.conf.json `plugins.updater.pubkey`), and
/// run its installer, which restarts the launcher. Runs first at start; `updating_self`
/// keeps anything from launching or installing meanwhile (the window reads "checking").
/// Offline, with no page configured, or with anything wrong, this launcher carries on.
pub async fn update_self(&self, app: &AppHandle) {
use tauri_plugin_updater::UpdaterExt;
// A launcher that ships on an ODIAN drive is part of the drive: installing a newer one
// would put it on this PC instead, beside an install it does not carry.
if self.inner.lock().await.portable {
self.log(app, "info", "This launcher runs from its ODIAN drive: it is updated with the drive.");
return;
}
let mcp_url = self.inner.lock().await.settings.mcp_url.clone();
let Ok(feed) = url::Url::parse(&format!("{}/download/{LAUNCHER_FEED}", mcp_url.trim_end_matches('/'))) else {
return;
};
{
let mut inner = self.inner.lock().await;
// At start the phase is "checking" (the first look at the site), never busier.
if mcp_url.is_empty() || !matches!(inner.phase, Phase::Idle | Phase::Checking | Phase::Error) {
return;
}
inner.updating_self = true;
}
self.emit(app).await;
let result = async {
let updater = app.updater_builder().endpoints(vec![feed])?.timeout(Duration::from_secs(20)).build()?;
let Some(update) = updater.check().await? else { return Ok(()) };
self.log(app, "info", format!("Updating the launcher to {}: it restarts by itself when done.", update.version));
// On Windows this starts the installer and exits the launcher: it does not return.
update.download_and_install(|_, _| {}, || {}).await
}
.await;
match result {
Ok(()) => {}
// Offline or the page unreachable: the normal case away from the office.
Err(tauri_plugin_updater::Error::Reqwest(e)) if e.is_connect() || e.is_timeout() => {}
Err(e) => self.log(app, "warn", format!("Could not update the launcher: {e}")),
}
self.inner.lock().await.updating_self = false;
self.emit(app).await;
}
/// Stop this PC's database when it is still running the version an update has just
/// replaced. Postgres starts a fresh process per connection from the very path it was
/// started from, so once that version's folder goes it answers every new connection with
/// "could not fork new process for connection" while looking perfectly alive. The next
/// launch starts it from the version just installed.
async fn stop_replaced_db(&self, app: &AppHandle, install_dir: &Path, version: &str) {
let password = self.inner.lock().await.state.pg_password.clone();
let pg = Pg::new(&Layout::new(install_dir, version), &password);
let stopped = tauri::async_runtime::spawn_blocking(move || match pg.running() {
true => pg.stop().map(|()| true),
false => Ok(false),
})
.await;
match stopped {
Ok(Ok(true)) => {
self.log(app, "info", "Stopped this PC's database: it was still running the version this update replaced.")
}
Ok(Err(e)) => self.log(
app,
"warn",
format!("This PC's database is still running the version that was replaced and would not stop ({e}). Close ODIAN and start it again before launching."),
),
_ => {}
}
}
/// Remove what the installed version no longer uses: older versions, older database
/// snapshots, their notes (see `prune`). In the background; it never fails anything, and
/// a folder still in use is left for the next time.
pub async fn prune(&self, app: &AppHandle) {
let (install_dir, installed) = {
let inner = self.inner.lock().await;
(inner.settings.install_path(), inner.state.installed_version.clone())
};
let Some(installed) = installed else { return };
// A running database is running SOME version's postgres.exe, and needs that exact path
// for every connection it opens from now on. Whatever is old can wait for a start where
// nothing is running (`stop_replaced_db` makes that the normal case after an update).
let running = {
let password = self.inner.lock().await.state.pg_password.clone();
let pg = Pg::new(&Layout::new(&install_dir, &installed), &password);
tauri::async_runtime::spawn_blocking(move || pg.running()).await.unwrap_or(false)
};
if running {
return;
}
let (dir, version) = (install_dir.clone(), installed.clone());
let Ok(outcome) = tauri::async_runtime::spawn_blocking(move || prune::run(&dir, &prune::plan(&dir, &version))).await else {
return;
};
let mut versions = Vec::new();
let mut snapshots = Vec::new();
for path in &outcome.removed {
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
match path.parent().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned()).as_deref() {
Some("versions") if !name.starts_with('.') => versions.push(name),
Some("db") if !name.starts_with('.') => snapshots.push(name),
_ => {}
}
}
if !versions.is_empty() || !snapshots.is_empty() {
let mut what = Vec::new();
if !versions.is_empty() {
what.push(format!("ODIAN {}", versions.join(", ")));
}
if !snapshots.is_empty() {
what.push(format!("database snapshot(s) {}", snapshots.join(", ")));
}
self.log(
app,
"info",
format!("Removed what ODIAN {installed} no longer uses: {}. {} freed.", what.join("; "), prune::size_text(outcome.freed_bytes)),
);
}
for (path, why) in &outcome.kept {
self.log(app, "warn", format!("Kept {} for now: {why}.", path.display()));
}
}
// ── local stack ─────────────────────────────────────────────────────────── // ── local stack ───────────────────────────────────────────────────────────
pub fn stack_states(&self) -> Vec<StackEvent> { pub fn stack_states(&self) -> Vec<StackEvent> {
@@ -1232,3 +1824,52 @@ impl Launcher {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_times_read_as_utc_dates() {
assert_eq!(utc_text(0), "1970-01-01 00:00:00Z");
assert_eq!(utc_text(1_790_082_309), "2026-09-22 13:05:09Z");
assert_eq!(utc_text(951_782_400), "2000-02-29 00:00:00Z", "a leap day");
}
#[test]
fn the_log_keeps_one_older_file() {
let dir = std::env::temp_dir().join(format!("odian-log-test-{}", uuid::Uuid::new_v4().simple()));
append_log(&dir, "info", "first");
std::fs::write(dir.join("launcher.log"), vec![b'x'; 2_000_001]).unwrap();
append_log(&dir, "warn", "after the roll");
let current = std::fs::read_to_string(dir.join("launcher.log")).unwrap();
assert!(current.ends_with("warn after the roll
") && current.lines().count() == 1, "{current}");
assert_eq!(std::fs::metadata(dir.join("launcher.log.1")).unwrap().len(), 2_000_001);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_drives_install_will_not_be_copied_onto_itself() {
let carried = Source::Install { root: PathBuf::from(r"D:\ODIAN") };
// The install folder still is the drive's own ODIAN, or a folder inside it.
assert!(self_copy_problem(&carried, Path::new(r"D:\ODIAN")).is_some());
assert!(self_copy_problem(&carried, Path::new(r"d:\odian\data")).is_some(), "case and depth");
// Somewhere else on the PC, or elsewhere on the drive: a real copy.
assert!(self_copy_problem(&carried, Path::new(r"C:\ODIAN")).is_none());
assert!(self_copy_problem(&carried, Path::new(r"D:\ODIAN copy")).is_none());
// A drive's pack store installs onto its own drive: that is ODIAN running from an SSD.
let store = Source::Drive { root: PathBuf::from(r"D:\") };
assert!(self_copy_problem(&store, Path::new(r"D:\ODIAN")).is_none());
}
#[test]
fn a_drive_carrying_an_older_odian_will_not_replace_a_newer_install() {
let into = Path::new(r"D:\ODIAN");
assert!(older_version_problem("0.60.0", Some("0.62.1"), into).is_some());
assert!(older_version_problem("0.62.1", Some("0.62.1"), into).is_none(), "the same version reinstalls");
assert!(older_version_problem("0.62.1", Some("0.60.0"), into).is_none(), "newer is an update");
assert!(older_version_problem("0.9.0", Some("0.10.0"), into).is_some(), "numbers, not text");
assert!(older_version_problem("0.60.0", None, into).is_none(), "nothing installed yet");
}
}
+16 -2
View File
@@ -6,6 +6,8 @@ mod fsutil;
mod install; mod install;
mod launcher; mod launcher;
mod localdb; mod localdb;
mod market;
mod prune;
mod settings; mod settings;
mod site; mod site;
mod stack; mod stack;
@@ -20,6 +22,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| { .setup(|app| {
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
@@ -30,15 +33,26 @@ pub fn run() {
} }
app.manage(launcher::Launcher::load(app.handle())?); app.manage(launcher::Launcher::load(app.handle())?);
// First look at the site in the background; the window shows "checking" meanwhile. // First a newer launcher, if the download page has one (it installs and restarts
// this one), then a look at the site; the window shows "checking" meanwhile.
let handle = app.handle().clone(); let handle = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
handle.state::<launcher::Launcher>().refresh(&handle).await; let launcher = handle.state::<launcher::Launcher>();
launcher.update_self(&handle).await;
launcher.refresh(&handle).await;
});
// And remove what the installed version no longer uses, left by earlier updates.
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
handle.state::<launcher::Launcher>().prune(&handle).await;
}); });
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::get_status, commands::get_status,
commands::get_market,
commands::apply_market,
commands::set_market,
commands::refresh, commands::refresh,
commands::sign_in, commands::sign_in,
commands::cancel_sign_in, commands::cancel_sign_in,
+44 -1
View File
@@ -4,7 +4,7 @@
//! snapshot is restored in the order proven on the Data PC's dumps: pre-data, //! snapshot is restored in the order proven on the Data PC's dumps: pre-data,
//! every table's data (in parallel), sequences, post-data. //! every table's data (in parallel), sequences, post-data.
use std::collections::VecDeque; use std::collections::{BTreeSet, VecDeque};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
@@ -420,6 +420,18 @@ const ADDING_MARKER: &str = "adding-tables.txt";
/// copy would make this PC's Engine API skip migrations its schema never had. /// copy would make this PC's Engine API skip migrations its schema never had.
const OWN_DATABASE_ONLY: [&str; 1] = ["public.odian_schema_migrations"]; const OWN_DATABASE_ONLY: [&str; 1] = ["public.odian_schema_migrations"];
/// The files of a snapshot a database lacking only `missing` needs: the schema and sequence
/// dumps (small, and adding tables reads them) and the data of the missing tables. The rest
/// is data the database already holds. A data dump with no plain table name is kept, the
/// safe side of not knowing.
pub fn files_needed(files: &[DbFile], missing: &[String]) -> BTreeSet<String> {
files
.iter()
.filter(|f| f.section != "data" || table_of(f).is_none_or(|table| missing.contains(&table)))
.map(|f| f.path.clone())
.collect()
}
/// The snapshot's data tables an existing database may lack and be given. /// The snapshot's data tables an existing database may lack and be given.
fn candidate_tables(files: &[DbFile]) -> Vec<String> { fn candidate_tables(files: &[DbFile]) -> Vec<String> {
files files
@@ -565,6 +577,37 @@ CREATE FUNCTION public.hospital_count() RETURNS bigint AS $$ SELECT count(*) FRO
("postgis_hospital", "TABLE", "public.postgis_hospital".to_string())); ("postgis_hospital", "TABLE", "public.postgis_hospital".to_string()));
} }
#[test]
fn a_database_keeping_its_data_needs_only_the_schema_and_the_tables_it_lacks() {
let file = |section: &str, table: Option<&str>, path: &str| DbFile {
section: section.to_string(),
table: table.map(str::to_string),
path: path.to_string(),
size: 1,
};
let files = [
file("pre-data", None, "db/S/pre-data.dump"),
file("data", Some("public.odb_note"), "db/S/data/public.odb_note.dump"),
file("data", Some("public.postgis_hospital"), "db/S/data/public.postgis_hospital.dump"),
file("data", None, "db/S/data/Weird Name.dump"),
file("data", Some("public.odian_schema_migrations"), "db/S/data/public.odian_schema_migrations.dump"),
file("sequences", None, "db/S/sequences.dump"),
file("post-data", None, "db/S/post-data.dump"),
];
let needed = files_needed(&files, &["public.postgis_hospital".to_string()]);
assert_eq!(
needed.into_iter().collect::<Vec<_>>(),
[
"db/S/data/Weird Name.dump",
"db/S/data/public.postgis_hospital.dump",
"db/S/post-data.dump",
"db/S/pre-data.dump",
"db/S/sequences.dump",
]
);
assert_eq!(files_needed(&files, &[]).len(), 4, "nothing missing: the schema dumps and the unnamed one");
}
#[test] #[test]
fn a_table_is_referenced_whole_never_as_a_prefix() { fn a_table_is_referenced_whole_never_as_a_prefix() {
assert!(references("CREATE INDEX i ON public.postgis_hospital USING gist (geom);", "public.postgis_hospital")); assert!(references("CREATE INDEX i ON public.postgis_hospital USING gist (geom);", "public.postgis_hospital"));
+427
View File
@@ -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()));
}
}
+344
View File
@@ -0,0 +1,344 @@
//! Removing what the installed version no longer uses.
//!
//! An update installs a version beside the old one and downloads a new database snapshot
//! beside the old one, and nothing ever removed either: every update left gigabytes behind.
//! This removes the versions OLDER than the installed one, the database snapshots older
//! than the one it names (adding tables later reads that one), and the cached notes of
//! those versions. Anything newer stays: it is an update being downloaded, which resumes
//! from what is on disk. Tiles and source files are shared by every version and updated in
//! place, so there is nothing old of theirs to remove.
//!
//! A folder is first renamed aside, then emptied. Either all of it goes or none of it does:
//! a folder something still uses (a process left running from that version) refuses the
//! rename and simply waits for the next time. A half-emptied folder keeps its `.removing-`
//! name, and the next run finishes it. Links are removed, never followed.
use std::path::{Path, PathBuf};
const TRASH_PREFIX: &str = ".removing-";
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Plan {
/// Older versions, older snapshots, and what an earlier run did not finish removing.
pub folders: Vec<PathBuf>,
/// The cached release notes of older versions.
pub files: Vec<PathBuf>,
}
#[derive(Debug, Default)]
pub struct Outcome {
pub removed: Vec<PathBuf>,
/// Folders left in place this time, with the reason.
pub kept: Vec<(PathBuf, String)>,
/// Disk space the removal gave back (0 where it cannot be measured).
pub freed_bytes: u64,
}
/// What removing everything the `installed` version no longer uses would remove.
pub fn plan(install_dir: &Path, installed: &str) -> Plan {
let mut plan = Plan::default();
let Some(current) = parse_version(installed) else { return plan };
let versions = install_dir.join("versions");
for (name, path) in entries(&versions, true) {
if name.starts_with(TRASH_PREFIX) || parse_version(&name).is_some_and(|v| v < current) {
plan.folders.push(path);
}
}
let db = install_dir.join("data").join("db");
let keep = installed_snapshot(&versions.join(installed).join("manifest.json"));
for (name, path) in entries(&db, true) {
let older = keep.as_deref().is_some_and(|keep| is_snapshot_id(&name) && name.as_str() < keep);
if name.starts_with(TRASH_PREFIX) || older {
plan.folders.push(path);
}
}
for (name, path) in entries(&install_dir.join("state").join("notes"), false) {
if name.strip_suffix(".md").and_then(parse_version).is_some_and(|v| v < current) {
plan.files.push(path);
}
}
plan
}
/// Remove what `plan` lists. Never fails as a whole: what cannot go now is reported in
/// `kept` and planned again next time.
pub fn run(install_dir: &Path, plan: &Plan) -> Outcome {
let before = free_bytes(install_dir);
let mut outcome = Outcome::default();
for folder in &plan.folders {
match remove_folder(folder) {
Ok(()) => outcome.removed.push(folder.clone()),
Err(why) => outcome.kept.push((folder.clone(), why)),
}
}
for file in &plan.files {
if std::fs::remove_file(file).is_ok() {
outcome.removed.push(file.clone());
}
}
outcome.freed_bytes = match (before, free_bytes(install_dir)) {
(Some(before), Some(after)) => after.saturating_sub(before),
_ => 0,
};
outcome
}
/// "1.8 GB", "640 MB".
pub fn size_text(bytes: u64) -> String {
if bytes >= 1_000_000_000 {
format!("{:.1} GB", bytes as f64 / 1e9)
} else {
format!("{} MB", bytes / 1_000_000)
}
}
/// `a.b.c` as numbers, so 0.10.0 sorts after 0.9.0. Anything else is not a version, and
/// is never removed.
fn parse_version(name: &str) -> Option<(u64, u64, u64)> {
let mut parts = name.split('.').map(|part| part.parse::<u64>().ok());
let version = (parts.next()??, parts.next()??, parts.next()??);
parts.next().is_none().then_some(version)
}
/// `20260921T125618Z`: the snapshot ids sort by time as text.
fn is_snapshot_id(name: &str) -> bool {
let bytes = name.as_bytes();
bytes.len() == 16
&& bytes[8] == b'T'
&& bytes[15] == b'Z'
&& bytes[..8].iter().chain(&bytes[9..15]).all(u8::is_ascii_digit)
}
/// The snapshot a version's manifest names, if it names one.
fn installed_snapshot(manifest: &Path) -> Option<String> {
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(manifest).ok()?).ok()?;
doc["db"]["snapshot"].as_str().map(str::to_string)
}
/// Folders (or files) directly under `dir`, by name.
fn entries(dir: &Path, folders: bool) -> Vec<(String, PathBuf)> {
let Ok(read) = std::fs::read_dir(dir) else { return Vec::new() };
read
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir() == folders && !kind.is_symlink()))
.map(|entry| (entry.file_name().to_string_lossy().into_owned(), entry.path()))
.collect()
}
/// Remove a folder the safe way (renamed aside, then emptied without following links).
pub(crate) fn remove_folder(folder: &Path) -> 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()
} else {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let aside = folder.with_file_name(format!("{TRASH_PREFIX}{name}-{nanos}"));
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"))
}
fn empty_and_remove(dir: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
let meta = std::fs::symlink_metadata(&path)?;
if is_link(&meta) {
// A junction or symlink: remove the link itself, never what it points at.
if is_dir_link(&meta) {
std::fs::remove_dir(&path)?;
} else {
std::fs::remove_file(&path)?;
}
} else if meta.is_dir() {
empty_and_remove(&path)?;
} else {
if meta.permissions().readonly() {
let mut writable = meta.permissions();
#[allow(clippy::permissions_set_readonly_false)]
writable.set_readonly(false);
std::fs::set_permissions(&path, writable)?;
}
std::fs::remove_file(&path)?;
}
}
std::fs::remove_dir(dir)
}
#[cfg(windows)]
fn is_link(meta: &std::fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(windows)]
fn is_dir_link(meta: &std::fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
meta.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0
}
#[cfg(not(windows))]
fn is_link(meta: &std::fs::Metadata) -> bool {
meta.file_type().is_symlink()
}
#[cfg(not(windows))]
fn is_dir_link(_meta: &std::fs::Metadata) -> bool {
false
}
#[cfg(windows)]
pub(crate) fn free_bytes(path: &Path) -> Option<u64> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
let mut free = 0u64;
// SAFETY: a NUL-terminated path and a valid out pointer; the other outputs are optional.
let ok = unsafe { GetDiskFreeSpaceExW(wide.as_ptr(), &mut free, std::ptr::null_mut(), std::ptr::null_mut()) };
(ok != 0).then_some(free)
}
#[cfg(not(windows))]
pub(crate) fn free_bytes(_path: &Path) -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
fn install() -> PathBuf {
let root = std::env::temp_dir().join(format!("odian-prune-test-{}", uuid::Uuid::new_v4().simple()));
for version in ["0.9.0", "0.10.0", "0.11.0", "0.12.0"] {
let dir = root.join("versions").join(version);
std::fs::create_dir_all(dir.join("runtime")).unwrap();
std::fs::write(dir.join("runtime").join("python.exe"), b"MZ").unwrap();
if version != "0.12.0" {
std::fs::write(dir.join("manifest.json"), b"{}").unwrap(); // 0.12.0: an update half-downloaded
}
std::fs::create_dir_all(root.join("state").join("notes")).unwrap();
std::fs::write(root.join("state").join("notes").join(format!("{version}.md")), b"# notes").unwrap();
}
std::fs::write(
root.join("versions").join("0.11.0").join("manifest.json"),
br#"{"db": {"snapshot": "20260921T125618Z"}}"#,
)
.unwrap();
for snapshot in ["20260915T082529Z", "20260921T125618Z", "20260930T080000Z"] {
let dir = root.join("data").join("db").join(snapshot).join("data");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("public.t.dump"), b"PGDMP").unwrap();
}
std::fs::create_dir_all(root.join("versions").join("tools")).unwrap(); // not a version: never touched
std::fs::create_dir_all(root.join("data").join("tiles").join("dark")).unwrap();
root
}
fn names(paths: &[PathBuf]) -> Vec<String> {
let mut names: Vec<String> =
paths.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
names.sort();
names
}
#[test]
fn only_what_is_older_than_the_installed_version_goes() {
let root = install();
let plan = plan(&root, "0.11.0");
// 0.10.0 sorts before 0.11.0 as numbers; as text it would sort after 0.9.0 only by luck.
assert_eq!(names(&plan.folders), ["0.10.0", "0.9.0", "20260915T082529Z"]);
assert_eq!(names(&plan.files), ["0.10.0.md", "0.9.0.md"]);
let outcome = run(&root, &plan);
assert!(outcome.kept.is_empty(), "{:?}", outcome.kept);
let versions = names(&entries(&root.join("versions"), true).into_iter().map(|(_, p)| p).collect::<Vec<_>>());
assert_eq!(versions, ["0.11.0", "0.12.0", "tools"], "installed, the update in progress, and a stranger stay");
let snapshots = names(&entries(&root.join("data").join("db"), true).into_iter().map(|(_, p)| p).collect::<Vec<_>>());
assert_eq!(snapshots, ["20260921T125618Z", "20260930T080000Z"]);
assert!(root.join("data").join("tiles").join("dark").is_dir());
assert_eq!(super::plan(&root, "0.11.0"), Plan::default(), "a second run has nothing left to do");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn a_version_without_a_snapshot_leaves_every_snapshot_alone() {
let root = install();
std::fs::write(root.join("versions").join("0.11.0").join("manifest.json"), b"{}").unwrap();
assert_eq!(names(&plan(&root, "0.11.0").folders), ["0.10.0", "0.9.0"]);
assert_eq!(plan(&root, "not-a-version"), Plan::default());
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn an_unfinished_removal_is_finished() {
let root = install();
let trash = root.join("versions").join(".removing-0.8.0-1");
std::fs::create_dir_all(trash.join("deep")).unwrap();
std::fs::write(trash.join("deep").join("left.bin"), b"x").unwrap();
let plan = plan(&root, "0.9.0");
assert_eq!(names(&plan.folders), [".removing-0.8.0-1"]);
run(&root, &plan);
assert!(!trash.exists());
std::fs::remove_dir_all(&root).unwrap();
}
#[cfg(windows)]
#[test]
fn a_link_inside_is_removed_without_touching_what_it_points_at() {
let root = install();
let shared = root.join("data").join("tiles");
std::fs::write(shared.join("dark").join("0.png"), b"PNG").unwrap();
junction::create(&shared, root.join("versions").join("0.9.0").join("tiles")).unwrap();
let plan = plan(&root, "0.11.0");
let outcome = run(&root, &plan);
assert!(outcome.kept.is_empty(), "{:?}", outcome.kept);
assert!(!root.join("versions").join("0.9.0").exists());
assert_eq!(std::fs::read(shared.join("dark").join("0.png")).unwrap(), b"PNG", "the link's target survives");
std::fs::remove_dir_all(&root).unwrap();
}
#[cfg(windows)]
#[test]
fn a_folder_in_use_is_kept_whole_for_next_time() {
let root = install();
let busy = root.join("versions").join("0.9.0").join("runtime").join("python.exe");
let holder = std::fs::OpenOptions::new().read(true).open(&busy).unwrap(); // no delete sharing
let plan = plan(&root, "0.11.0");
let outcome = run(&root, &plan);
assert_eq!(names(&outcome.kept.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>()), ["0.9.0"]);
assert!(busy.is_file(), "nothing of it was removed");
drop(holder);
run(&root, &super::plan(&root, "0.11.0"));
assert!(!root.join("versions").join("0.9.0").exists(), "and it goes the next time");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn sizes_read_like_the_launcher_window() {
assert_eq!(size_text(1_840_000_000), "1.8 GB");
assert_eq!(size_text(640_000_000), "640 MB");
}
#[test]
fn names_that_are_not_versions_or_snapshots_are_not_mistaken_for_them() {
assert_eq!(parse_version("0.10.0"), Some((0, 10, 0)));
assert!(parse_version("0.10").is_none() && parse_version("0.10.0.1").is_none() && parse_version("v0.1.0").is_none());
assert!(is_snapshot_id("20260921T125618Z"));
assert!(!is_snapshot_id("20260921T125618") && !is_snapshot_id("2026092aT125618Z"));
}
/// What the launcher would remove from the real install on this PC; removes nothing.
/// `ODIAN_PRUNE_ROOT=C:\ODIAN ODIAN_PRUNE_VERSION=0.60.0 cargo test prune -- --ignored --nocapture`
#[test]
#[ignore = "reads a real install named by ODIAN_PRUNE_ROOT"]
fn show_the_plan_for_a_real_install() {
let root = PathBuf::from(std::env::var("ODIAN_PRUNE_ROOT").unwrap());
let version = std::env::var("ODIAN_PRUNE_VERSION").unwrap();
let plan = plan(&root, &version);
for path in plan.folders.iter().chain(&plan.files) {
println!("would remove {}", path.display());
}
}
}
+197 -3
View File
@@ -34,13 +34,28 @@ struct SiteFile {
site_url: String, site_url: String,
#[serde(default)] #[serde(default)]
mcp_url: String, mcp_url: String,
/// This launcher ships on an ODIAN drive: see `portable_root`.
#[serde(default)]
portable: bool,
} }
/// Where the settings are kept: beside a drive's own launcher, so the site and the install
/// folder travel with the drive; in this PC's config folder for a launcher installed on it.
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> { fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
if let Some(beside) = portable_settings_path() {
return Ok(beside);
}
let dir = app.path().app_config_dir().map_err(|e| format!("no config dir: {e}"))?; let dir = app.path().app_config_dir().map_err(|e| format!("no config dir: {e}"))?;
Ok(dir.join("settings.json")) Ok(dir.join("settings.json"))
} }
fn portable_settings_path() -> Option<PathBuf> {
if !read_site_file().portable {
return None;
}
Some(std::env::current_exe().ok()?.parent()?.join("settings.json"))
}
/// Is this drive a disk built into the PC, rather than a USB key or a card? /// Is this drive a disk built into the PC, rather than a USB key or a card?
/// ///
/// A key plugged into a laptop takes D:, and ODIAN would install itself onto it: the /// A key plugged into a laptop takes D:, and ODIAN would install itself onto it: the
@@ -114,6 +129,108 @@ fn read_site_file() -> SiteFile {
.unwrap_or_default() .unwrap_or_default()
} }
/// The drive a launcher that ships on an ODIAN drive runs from, when it is one. The install
/// lives on THAT drive: the operator picks the folder, but the drive letter is whatever this
/// PC hands the drive, so the folder is re-read against it at every start.
/// None for a launcher installed on a PC, which keeps the folder the operator chose.
pub fn portable_drive() -> Option<PathBuf> {
portable_drive_of(&std::env::current_exe().ok()?, read_site_file().portable)
}
/// `<drive of the exe>\`, or None when this launcher is not a drive's own.
fn portable_drive_of(exe: &Path, portable: bool) -> Option<PathBuf> {
if !portable {
return None;
}
drive_of(exe)
}
/// `C:\` for `C:\a\b`. None for a path with no drive letter (a network share, another OS):
/// an install needs a real disk for Postgres and its 200 GB of tiles.
fn drive_of(path: &Path) -> Option<PathBuf> {
match path.components().next()? {
std::path::Component::Prefix(prefix)
if matches!(prefix.kind(), std::path::Prefix::Disk(_) | std::path::Prefix::VerbatimDisk(_)) =>
{
Some(PathBuf::from(format!("{}\\", prefix.as_os_str().to_string_lossy())))
}
_ => None,
}
}
/// The same folder on `drive`: `D:\ODIAN\x` on `E:\` is `E:\ODIAN\x`. A drive gets a
/// different letter on every PC, and the folder an operator chose has to follow it.
fn on_drive(folder: &Path, drive: &Path) -> PathBuf {
match drive_of(folder) {
Some(_) => drive.join(folder.components().skip(1).collect::<PathBuf>()),
None => drive.join(folder),
}
}
/// Where a portable launcher may install: on its own drive (ODIAN runs from the drive), or
/// on a disk of the PC it is plugged into (the drive hands ODIAN over and leaves). Never a
/// drive's root, and never a network share: Postgres and 200 GB of tiles need a real disk.
pub fn portable_folder_problem(folder: &Path) -> Option<String> {
folder_problem_on(folder, portable_drive()?.as_path())
}
fn folder_problem_on(folder: &Path, drive: &Path) -> Option<String> {
match drive_of(folder) {
Some(disk) if folder == disk => Some(format!(
"{} is a drive's root: install into a folder on it, like {}ODIAN.",
folder.display(),
disk.display()
)),
Some(_) => None,
None => Some(format!(
"The install folder must be a folder on a disk: on the ODIAN drive {} to run ODIAN from it, \
or on this PC's own disk to install onto the PC.",
drive.display()
)),
}
}
/// Is this folder on the drive the launcher runs from? Only then does the install travel,
/// and only then is it worth opening to every account of every PC the drive is plugged into.
pub fn installs_on_the_drive(folder: &Path) -> bool {
portable_drive().is_some_and(|drive| drive_of(folder) == Some(drive))
}
/// How the folder is written down beside a drive's own launcher. A folder on the drive itself
/// is stored without the letter (`ODIAN`, `field kit\ODIAN`): the next PC hands the drive a
/// different one. A folder on a PC's disk is stored whole, and names that PC's disk wherever
/// the drive lands.
fn stored_install_dir(folder: &str, drive: &Path) -> String {
let path = Path::new(folder);
match drive_of(path) {
Some(disk) if disk == drive => path.strip_prefix(&disk).unwrap_or(path).display().to_string(),
_ => folder.to_string(),
}
}
/// Let every Windows account use an ODIAN drive's install: NTFS permissions are per account,
/// so a drive prepared on one PC is otherwise unreadable on the next. The grant is inheritable,
/// so everything installed into it afterwards carries it. Best effort: an install on a PC's own
/// disk keeps the permissions it had, and a drive that refuses the change still installs.
#[cfg(windows)]
pub fn share_with_every_account(dir: &Path) -> Result<(), String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
// BUILTIN\Users by SID: the group's name is localised (Utilisateurs, Benutzer, ...).
let status = std::process::Command::new("icacls")
.arg(dir)
.args(["/grant", "*S-1-5-32-545:(OI)(CI)F", "/Q"])
.creation_flags(CREATE_NO_WINDOW)
.status()
.map_err(|e| format!("cannot run icacls: {e}"))?;
status.success().then_some(()).ok_or_else(|| format!("icacls refused {}", dir.display()))
}
#[cfg(not(windows))]
pub fn share_with_every_account(_dir: &Path) -> Result<(), String> {
Ok(())
}
pub fn normalize_url(url: &str) -> String { pub fn normalize_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_string() url.trim().trim_end_matches('/').to_string()
} }
@@ -121,14 +238,16 @@ pub fn normalize_url(url: &str) -> String {
impl Settings { impl Settings {
pub fn load(app: &AppHandle) -> Result<Settings, String> { pub fn load(app: &AppHandle) -> Result<Settings, String> {
let path = settings_path(app)?; let path = settings_path(app)?;
if let Ok(bytes) = std::fs::read(&path) { let mut settings = match std::fs::read(&path) {
Ok(bytes) => {
let mut settings: Settings = let mut settings: Settings =
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?; serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?;
if settings.install_dir.trim().is_empty() { if settings.install_dir.trim().is_empty() {
settings.install_dir = default_install_dir(); settings.install_dir = default_install_dir();
} }
return Ok(settings); settings
} }
Err(_) => {
let site = read_site_file(); let site = read_site_file();
let settings = Settings { let settings = Settings {
site_url: normalize_url(&site.site_url), site_url: normalize_url(&site.site_url),
@@ -137,14 +256,89 @@ impl Settings {
site_name: site.name, site_name: site.name,
}; };
settings.save(app)?; settings.save(app)?;
settings
}
};
// On an ODIAN drive a folder of the drive is stored without its letter and resolved afresh
// every start: the letter changes from PC to PC, and yesterday's would point at a stranger's
// disk. A folder with a letter names a disk of the PC in front of the operator.
if let Some(drive) = portable_drive() {
let raw = settings.install_dir.trim().to_string();
let folder = match raw.as_str() {
"" => drive.join("ODIAN"),
chosen if drive_of(Path::new(chosen)).is_some() => PathBuf::from(chosen),
chosen => on_drive(Path::new(chosen), &drive),
};
settings.install_dir = folder.display().to_string();
// Written by a launcher that stored the drive's own folder whole: store it the new way
// now, while the letter still is the one it was written with.
if raw != stored_install_dir(&settings.install_dir, &drive) {
settings.save(app)?;
}
}
Ok(settings) Ok(settings)
} }
pub fn save(&self, app: &AppHandle) -> Result<(), String> { pub fn save(&self, app: &AppHandle) -> Result<(), String> {
write_json_atomic(&settings_path(app)?, self) let mut stored = self.clone();
if let Some(drive) = portable_drive() {
stored.install_dir = stored_install_dir(&self.install_dir, &drive);
}
write_json_atomic(&settings_path(app)?, &stored)
} }
pub fn install_path(&self) -> PathBuf { pub fn install_path(&self) -> PathBuf {
PathBuf::from(&self.install_dir) PathBuf::from(&self.install_dir)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_drives_own_launcher_installs_on_that_drive_whatever_its_letter() {
for (exe, want) in [
(r"E:\ODIAN_Launcher\odian-launcher.exe", Some(r"E:\")),
(r"D:\tools\odian\odian-launcher.exe", Some(r"D:\")),
] {
assert_eq!(portable_drive_of(Path::new(exe), true).as_deref(), want.map(Path::new), "{exe}");
}
// A launcher installed on a PC keeps the folder the operator chose.
assert_eq!(portable_drive_of(Path::new(r"C:\Users\x\odian-launcher.exe"), false), None);
assert_eq!(portable_drive_of(Path::new(r"\\server\share\odian-launcher.exe"), true), None, "a share is not a drive");
}
#[test]
fn the_folder_the_operator_chose_follows_the_drive_to_the_next_pc() {
let drive = Path::new(r"E:\");
assert_eq!(on_drive(Path::new(r"D:\ODIAN"), drive), Path::new(r"E:\ODIAN"));
assert_eq!(on_drive(Path::new(r"D:\field kit\ODIAN"), drive), Path::new(r"E:\field kit\ODIAN"));
assert_eq!(on_drive(Path::new(r"E:\ODIAN"), drive), Path::new(r"E:\ODIAN"), "already there");
}
#[test]
fn a_portable_install_folder_is_a_folder_on_the_drive_or_on_this_pcs_disk() {
let drive = Path::new(r"E:\");
// On the drive: ODIAN runs from the drive itself.
assert_eq!(folder_problem_on(Path::new(r"E:\ODIAN"), drive), None);
assert_eq!(folder_problem_on(Path::new(r"E:\field kit\ODIAN"), drive), None);
// On the PC: the drive installs ODIAN onto the PC and leaves.
assert_eq!(folder_problem_on(Path::new(r"C:\ODIAN"), drive), None);
for bad in [r"E:\", r"C:\", r"\\server\share\ODIAN"] {
let problem = folder_problem_on(Path::new(bad), drive).expect(bad);
assert!(!problem.is_empty(), "{bad}");
}
}
#[test]
fn only_the_drives_own_folder_is_stored_without_its_letter() {
let drive = Path::new(r"E:\");
assert_eq!(stored_install_dir(r"E:\ODIAN", drive), "ODIAN");
assert_eq!(stored_install_dir(r"E:\field kit\ODIAN", drive), r"field kit\ODIAN");
// A folder on a PC keeps its disk: it is not the drive's to move.
assert_eq!(stored_install_dir(r"C:\ODIAN", drive), r"C:\ODIAN");
// And it comes back as the same folder on whatever letter the drive gets.
assert_eq!(on_drive(Path::new("ODIAN"), Path::new(r"F:\")), Path::new(r"F:\ODIAN"));
}
}
+11 -5
View File
@@ -553,7 +553,10 @@ fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
/// The archive a dataset's basemap can be baked from, when this install ships it. /// The archive a dataset's basemap can be baked from, when this install ships it.
fn pmtiles_of(config: &StackConfig, dataset: &str) -> Option<PathBuf> { fn pmtiles_of(config: &StackConfig, dataset: &str) -> Option<PathBuf> {
let path = config.layout.data_dir().join("src").join(format!("{dataset}.pmtiles")); let path = config.layout.data_dir().join("src").join(format!("{dataset}.pmtiles"));
path.is_file().then_some(path) // The installer writes an archive chunk by chunk in place and marks it `.ok` once whole:
// a baker reading one mid-download draws garbage.
let complete = config.layout.data_dir().join("src").join(format!("{dataset}.pmtiles.ok"));
(path.is_file() && complete.is_file()).then_some(path)
} }
/// The tile baker, when the version ships one AND some installed dataset has an archive /// The tile baker, when the version ships one AND some installed dataset has an archive
@@ -651,6 +654,8 @@ pub fn installed_db(layout: &Layout) -> Option<odian_packs::manifest::DbSection>
} }
/// The tile sets the installed version's manifest names. /// The tile sets the installed version's manifest names.
/// The version's tile sets this PC has (a marketplace set the operator did not choose has no
/// folder): the client is offered, and the tile server serves, only those.
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> { pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
let path = layout.version_dir().join("manifest.json"); let path = layout.version_dir().join("manifest.json");
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?; let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
@@ -668,6 +673,7 @@ pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(), ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(),
}) })
}) })
.filter(|set| layout.data_dir().join("tiles").join(&set.style).join(&set.dataset).is_dir())
.collect() .collect()
}) })
.unwrap_or_default()) .unwrap_or_default())
@@ -839,6 +845,7 @@ mod tests {
let src = cfg.layout.data_dir().join("src"); let src = cfg.layout.data_dir().join("src");
std::fs::create_dir_all(&src).unwrap(); std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("protomaps_20260423.pmtiles"), b"PMTiles").unwrap(); std::fs::write(src.join("protomaps_20260423.pmtiles"), b"PMTiles").unwrap();
std::fs::write(src.join("protomaps_20260423.pmtiles.ok"), b"digest").unwrap();
let server = parent(&cfg.layout.app_file(TILE_SERVER_APP)); let server = parent(&cfg.layout.app_file(TILE_SERVER_APP));
std::fs::create_dir_all(&server).unwrap(); std::fs::create_dir_all(&server).unwrap();
if with_baker { if with_baker {
@@ -884,14 +891,13 @@ mod tests {
r#"{"tiles":{"block_shift":6,"sets":[{"style":"dark","dataset":"protomaps_20260423"},{"style":"heightmap","dataset":"planet_heightmap"}]}}"#, r#"{"tiles":{"block_shift":6,"sets":[{"style":"dark","dataset":"protomaps_20260423"},{"style":"heightmap","dataset":"planet_heightmap"}]}}"#,
) )
.unwrap(); .unwrap();
// The height map was not chosen in the marketplace: it has no folder, so it is not offered.
std::fs::create_dir_all(layout.data_dir().join("tiles").join("dark").join("protomaps_20260423")).unwrap();
let sets = installed_tile_sets(&layout).unwrap(); let sets = installed_tile_sets(&layout).unwrap();
std::fs::remove_dir_all(&root).unwrap(); std::fs::remove_dir_all(&root).unwrap();
assert_eq!( assert_eq!(
sets, sets,
vec![ vec![TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: String::new() }]
TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: String::new() },
TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: String::new() },
]
); );
} }
} }
+4
View File
@@ -34,6 +34,10 @@ pub struct LauncherState {
/// from before sync: its first sync then starts from the DHP's state at that moment. /// from before sync: its first sync then starts from the DHP's state at that moment.
#[serde(default)] #[serde(default)]
pub sync_origin: Option<SyncOrigin>, pub sync_origin: Option<SyncOrigin>,
/// The marketplace items the operator chose (see `market`). None until the first choice:
/// an install from before the marketplace then counts what it has on disk as chosen.
#[serde(default)]
pub extras: Option<std::collections::BTreeSet<String>>,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
+11 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher", "productName": "ODIAN Launcher",
"version": "0.1.1", "version": "0.1.3",
"identifier": "fr.odian.launcher", "identifier": "fr.odian.launcher",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",
@@ -31,6 +31,7 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis"], "targets": ["nsis"],
"createUpdaterArtifacts": true,
"resources": [ "resources": [
"site.json" "site.json"
], ],
@@ -41,5 +42,14 @@
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdBODcwMzdCRUUxOEVBMTYKUldRVzZoanVld09IZXF0ZEl3dW1vNEpWcmdRZ0Izakl0MHlOOWQxTTd5ZjFwbks1QTVmL2tDWFgK",
"dangerousInsecureTransportProtocol": true,
"windows": {
"installMode": "passive"
}
}
} }
} }
+3 -1
View File
@@ -4,6 +4,7 @@
import ActivityPanel from "./components/ActivityPanel.svelte"; import ActivityPanel from "./components/ActivityPanel.svelte";
import Hero from "./components/Hero.svelte"; import Hero from "./components/Hero.svelte";
import KeyArt from "./components/KeyArt.svelte"; import KeyArt from "./components/KeyArt.svelte";
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
import NewsPanel from "./components/NewsPanel.svelte"; import NewsPanel from "./components/NewsPanel.svelte";
import SettingsDrawer from "./components/SettingsDrawer.svelte"; import SettingsDrawer from "./components/SettingsDrawer.svelte";
import SyncPanel from "./components/SyncPanel.svelte"; import SyncPanel from "./components/SyncPanel.svelte";
@@ -18,7 +19,7 @@
<div class="shell"> <div class="shell">
<KeyArt /> <KeyArt />
<TitleBar onOpenSettings={() => (settingsOpen = true)} /> <TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
<main class="hub"> <main class="hub">
<div class="news-slot"><NewsPanel /></div> <div class="news-slot"><NewsPanel /></div>
@@ -37,6 +38,7 @@
{/if} {/if}
<SettingsDrawer bind:open={settingsOpen} /> <SettingsDrawer bind:open={settingsOpen} />
<MarketplaceDrawer />
</div> </div>
<style> <style>
+1 -1
View File
@@ -98,7 +98,7 @@
{#snippet driveLink(lead: string)} {#snippet driveLink(lead: string)}
<p class="secondary"> <p class="secondary">
{lead} {lead}
<button class="btn-link" disabled={blocked} onclick={() => launcher.installFromDrive()}>Install from a drive…</button> <button class="btn-link" disabled={blocked} onclick={() => launcher.chooseDrive()}>Install from a drive…</button>
</p> </p>
{/snippet} {/snippet}
+7 -1
View File
@@ -18,7 +18,8 @@
| "check" | "check"
| "signout" | "signout"
| "key" | "key"
| "globe"; | "globe"
| "market";
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -92,6 +93,11 @@
<circle cx="8" cy="8" r="5.5" /> <circle cx="8" cy="8" r="5.5" />
<ellipse cx="8" cy="8" rx="2.4" ry="5.5" /> <ellipse cx="8" cy="8" rx="2.4" ry="5.5" />
<path d="M2.5 8h11" /> <path d="M2.5 8h11" />
{:else if name === "market"}
<rect x="2.5" y="2.5" width="4.5" height="4.5" />
<rect x="9" y="2.5" width="4.5" height="4.5" />
<rect x="2.5" y="9" width="4.5" height="4.5" />
<path d="M11.25 9v4.5M9 11.25h4.5" />
{/if} {/if}
</svg> </svg>
+588
View File
@@ -0,0 +1,588 @@
<script lang="ts">
import { untrack } from "svelte";
import Icon from "./Icon.svelte";
import { launcher } from "../lib/launcher.svelte";
import { errorText, formatBytes } from "../lib/format";
import type { Market, MarketItem } from "../lib/types";
let dialog: HTMLDialogElement | undefined = $state();
let market = $state<Market | null>(null);
let loading = $state(false);
let loadError = $state<string | null>(null);
/** The optional items ticked here, by key: the draft the operator applies. */
let chosen = $state<string[]>([]);
let saved = $state(false);
let applyError = $state<string | null>(null);
const s = $derived(launcher.status);
const locked = $derived(launcher.locked);
/** Set while the panel describes a drive to install from, instead of this PC and its DHP. */
const drive = $derived(launcher.marketDrive);
const applying = $derived(launcher.busy === "market");
const busy = $derived(launcher.busy !== null);
const required = $derived(market?.items.filter((item) => item.required) ?? []);
const optional = $derived(market?.items.filter((item) => !item.required) ?? []);
const picked = (item: MarketItem) => chosen.includes(item.key);
const dirty = $derived(optional.some((item) => picked(item) !== item.selected));
const adds = $derived(optional.filter((item) => picked(item) && item.presence !== "installed"));
const removes = $derived(optional.filter((item) => !picked(item) && item.presence !== "absent"));
const installed = $derived((market?.installed ?? false) && drive === null);
const addBytes = $derived(adds.reduce((sum, item) => sum + item.bytes, 0));
const removeBytes = $derived(removes.reduce((sum, item) => sum + item.bytes, 0));
/** Before the first install: everything Install will download with this choice. */
const installBytes = $derived(
(market?.core_bytes ?? 0) +
required.reduce((sum, item) => sum + item.bytes, 0) +
optional.filter(picked).reduce((sum, item) => sum + item.bytes, 0),
);
/** A copy skips what the target already holds, byte for byte: only the rest crosses. */
const copyBytes = $derived(
(market?.core_bytes ?? 0) +
(market?.items
.filter((item) => (item.required || picked(item)) && item.presence !== "installed")
.reduce((sum, item) => sum + item.bytes, 0) ?? 0),
);
const needBytes = $derived(drive !== null ? copyBytes : installed ? addBytes : installBytes);
const tooBig = $derived(market?.free_bytes != null && needBytes > market.free_bytes);
const offlineAdds = $derived(drive === null && installed && adds.length > 0 && !s?.online);
const updateFirst = $derived(drive === null && installed && (s?.update_required ?? false));
/** A stopped download is work to do even when the choice did not change. */
const work = $derived(installed ? adds.length + removes.length > 0 : dirty);
// A copy from a drive starts here, so a disk that cannot hold it stops the button too.
const blocked = $derived(locked || busy || offlineAdds || updateFirst || ((installed || drive !== null) && tooBig));
async function load() {
loading = true;
loadError = null;
try {
const next = await launcher.loadMarket(drive ?? undefined);
market = next;
chosen = next.items.filter((item) => !item.required && item.selected).map((item) => item.key);
} catch (e) {
loadError = errorText(e);
} finally {
loading = false;
}
}
function revert() {
chosen = optional.filter((item) => item.selected).map((item) => item.key);
saved = false;
applyError = null;
}
function toggle(item: MarketItem) {
chosen = picked(item) ? chosen.filter((key) => key !== item.key) : [...chosen, item.key];
saved = false;
applyError = null;
}
async function apply() {
if (blocked) return;
// From a drive: save the choice, then copy. The progress shows in the main panel.
if (drive !== null) {
if (!(await launcher.setMarket([...chosen], drive))) {
applyError = launcher.notice;
return;
}
launcher.marketOpen = false;
void launcher.installFromDrive(drive);
return;
}
if (!work) return;
const startsDownload = installed && work;
const ok = await launcher.applyMarket([...chosen]);
if (!ok) {
applyError = launcher.notice;
return;
}
if (startsDownload) {
launcher.marketOpen = false;
} else {
saved = true;
await load();
}
}
function presenceLabel(item: MarketItem): string {
if (item.presence === "installed") return "Installed";
if (item.presence === "partial") return "Partly downloaded";
return "Not installed";
}
function changeNote(item: MarketItem): string | null {
if (drive !== null) {
if (!picked(item)) return item.presence === "absent" ? null : "Left as it is";
if (item.presence === "installed") return "Already here";
return item.presence === "partial" ? "Copy resumes" : `Copies ${formatBytes(item.bytes)}`;
}
if (!installed) return picked(item) ? "Installed with ODIAN" : null;
if (picked(item) && item.presence === "partial") return "Download resumes";
if (picked(item) && item.presence === "absent") return `Downloads ${formatBytes(item.bytes)}`;
if (!picked(item) && item.presence !== "absent") return `Removed from this PC, frees ${formatBytes(item.bytes)}`;
return null;
}
// Keep the native modal in sync with the store, returning focus to whatever opened it.
const open = $derived(launcher.marketOpen);
let opener: HTMLElement | null = null;
$effect(() => {
const el = dialog;
void drive;
if (!el) return;
if (open && !el.open) {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
saved = false;
applyError = null;
untrack(() => void load());
el.showModal();
} else if (!open) {
if (el.open) el.close();
if (opener?.isConnected) opener.focus();
opener = null;
}
});
// A marketplace download that finishes while the drawer is open changes what is installed.
let lastPhase: string | undefined;
$effect(() => {
const phase = s?.phase;
if (open && lastPhase && lastPhase !== phase && phase === "idle") untrack(() => void load());
lastPhase = phase;
});
$effect(() => {
const el = dialog;
if (!el) return;
const onClick = (e: MouseEvent) => {
if (e.target === el) launcher.marketOpen = false;
};
const onKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
launcher.marketOpen = false;
}
};
el.addEventListener("click", onClick);
el.addEventListener("keydown", onKeydown);
return () => {
el.removeEventListener("click", onClick);
el.removeEventListener("keydown", onKeydown);
};
});
</script>
<dialog bind:this={dialog} class="drawer" aria-labelledby="market-title" onclose={() => (launcher.marketOpen = false)}>
<div class="panel">
<header class="head">
<div class="title">
<h2 id="market-title">{drive === null ? "Marketplace" : "Install from drive"}</h2>
{#if market}<span class="version">ODIAN {market.version}</span>{/if}
</div>
<button type="button" class="icon-btn" aria-label="Close marketplace" onclick={() => (launcher.marketOpen = false)}>
<Icon name="close" />
</button>
</header>
<div class="content">
{#if locked}
<p class="note warn" role="note">
<Icon name="alert" size={14} />
The marketplace is locked while ODIAN is signing in, installing or running.
</p>
{:else if updateFirst}
<p class="note warn" role="note">
<Icon name="alert" size={14} />
Update ODIAN first: the marketplace adds to the installed version.
</p>
{:else if offlineAdds}
<p class="note warn" role="note">
<Icon name="alert" size={14} />
Your DHP is unreachable: connect to download. Removing items works offline.
</p>
{/if}
{#if loading && !market}
<p class="empty">Reading what your DHP offers…</p>
{:else if loadError}
<div class="empty">
<p class="err-text">{loadError}</p>
<button type="button" class="btn" onclick={() => void load()}>
<Icon name="refresh" size={14} /> Try again
</button>
</div>
{:else if market}
{#if drive !== null}
<p class="intro">
Copying from <strong>{drive}</strong> into <strong>{s?.install_dir}</strong>. Only what the drive
carries is listed, and every file is checked as it lands.
</p>
{:else if !installed}
<p class="intro">
ODIAN is not installed yet. Choose what to add: <strong>Install</strong> downloads it with the rest.
</p>
{/if}
<section class="group" aria-labelledby="market-optional">
<h3 id="market-optional" class="eyebrow">Optional</h3>
{#if optional.length === 0}
<p class="text">This version offers nothing optional: everything comes with the install.</p>
{/if}
{#each optional as item (item.key)}
{@const note = changeNote(item)}
<label class="card" class:picked={picked(item)}>
<input
type="checkbox"
class="check"
checked={picked(item)}
disabled={locked || applying}
onchange={() => toggle(item)}
/>
<span class="body">
<span class="line">
<span class="name">{item.title}</span>
<span class="size">{formatBytes(item.bytes)}</span>
</span>
{#if item.description}<span class="desc">{item.description}</span>{/if}
<span class="meta">
<span class="pill {item.presence}">{presenceLabel(item)}</span>
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
</span>
</span>
</label>
{/each}
</section>
<section class="group" aria-labelledby="market-included">
<h3 id="market-included" class="eyebrow">Included with every install</h3>
<ul class="included">
<li>
<span class="name">ODIAN app and database</span>
<span class="size">{formatBytes(market.core_bytes)}</span>
</li>
{#each required as item (item.key)}
<li>
<span class="name" title={item.description}>{item.title}</span>
<span class="size">{formatBytes(item.bytes)}</span>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<footer class="foot">
<span class="summary" role="status">
{#if applyError}
<span class="err">{applyError}</span>
{:else if saved}
<span class="ok"><Icon name="check" size={14} /> Saved: Install brings your choice.</span>
{:else if market}
{#if drive !== null}
Copies {formatBytes(copyBytes)}
{:else if installed}
{#if adds.length || removes.length}
{#if adds.length}Downloads {formatBytes(addBytes)}{/if}{#if adds.length && removes.length}&ensp;·&ensp;{/if}{#if removes.length}Frees {formatBytes(removeBytes)}{/if}
{:else}
Nothing to change
{/if}
{:else}
Install downloads {formatBytes(installBytes)}
{/if}
{#if market.free_bytes != null}
<span class:err={tooBig}>&ensp;·&ensp;{formatBytes(market.free_bytes)} free{tooBig ? ": not enough" : ""}</span>
{/if}
{/if}
</span>
<button type="button" class="btn" disabled={!dirty || applying} onclick={revert}>Revert</button>
<button
type="button"
class="btn btn-accent"
disabled={blocked || (drive === null && !work) || (drive !== null && market === null)}
onclick={apply}
>
{#if applying}Applying…{:else if drive !== null}Install from drive{:else if installed}Apply changes{:else}Save choice{/if}
</button>
</footer>
</div>
</dialog>
<style>
.drawer {
position: fixed;
inset: 0 0 0 auto;
width: min(560px, 100vw);
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: 0;
border: 0;
border-left: 1px solid var(--line-strong);
background: var(--glass-strong);
color: var(--ink);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
opacity: 0;
transform: translateX(24px);
transition:
opacity var(--dur-slow) var(--ease),
transform var(--dur-slow) var(--ease),
overlay var(--dur-slow) allow-discrete,
display var(--dur-slow) allow-discrete;
}
.drawer[open] {
opacity: 1;
transform: none;
}
.drawer::backdrop {
background: rgba(5, 5, 7, 0);
transition:
background-color var(--dur-slow) var(--ease),
overlay var(--dur-slow) allow-discrete,
display var(--dur-slow) allow-discrete;
}
.drawer[open]::backdrop {
background: rgba(5, 5, 7, 0.6);
}
@starting-style {
.drawer[open] {
opacity: 0;
transform: translateX(24px);
}
.drawer[open]::backdrop {
background: rgba(5, 5, 7, 0);
}
}
.panel {
display: flex;
flex-direction: column;
height: 100%;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 0 12px 0 24px;
border-bottom: 1px solid var(--line);
}
.title {
display: flex;
align-items: baseline;
gap: var(--space-3);
}
.head h2 {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
letter-spacing: 0.02em;
}
.version {
color: var(--muted);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.note {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 16px 24px 0;
padding: 8px 12px;
border-radius: var(--radius);
font-size: var(--text-xs);
}
.note.warn {
border: 1px solid rgba(245, 180, 84, 0.3);
background: var(--warn-soft);
color: var(--warn);
}
.intro,
.empty {
margin: 20px 24px 0;
color: var(--muted);
font-size: var(--text-sm);
line-height: 1.55;
}
.intro strong {
color: var(--ink);
font-weight: 500;
}
.empty {
display: grid;
justify-items: start;
gap: var(--space-3);
}
.err-text {
color: var(--danger);
}
.group {
display: grid;
gap: 10px;
padding: 22px 24px 24px;
border-bottom: 1px solid var(--line);
}
.group .eyebrow {
margin-bottom: 4px;
}
.text {
color: var(--muted);
font-size: var(--text-sm);
}
.card {
display: flex;
gap: var(--space-3);
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(9, 9, 11, 0.45);
cursor: pointer;
transition:
border-color var(--dur-fast) var(--ease),
background-color var(--dur-fast) var(--ease);
}
.card:hover {
border-color: var(--line-strong);
background: var(--surface-2);
}
.card.picked {
border-color: var(--accent-line);
background: var(--accent-soft);
}
.card:has(.check:disabled) {
cursor: default;
}
.card:has(.check:focus-visible) {
outline: 1px solid var(--accent);
outline-offset: 2px;
}
.check {
flex: none;
width: 16px;
height: 16px;
margin-top: 2px;
accent-color: var(--accent);
}
.body {
display: grid;
flex: 1;
gap: 6px;
min-width: 0;
}
.line {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.name {
color: var(--ink);
font-size: var(--text-md);
font-weight: 500;
}
.size {
flex: none;
color: var(--ink-2);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.desc {
color: var(--muted);
font-size: var(--text-sm);
line-height: 1.5;
}
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.pill {
padding: 1px 6px;
border: 1px solid var(--line-strong);
border-radius: var(--radius);
color: var(--muted);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pill.installed {
border-color: rgba(134, 239, 172, 0.28);
background: var(--ok-soft);
color: var(--ok);
}
.pill.partial {
border-color: rgba(245, 180, 84, 0.3);
background: var(--warn-soft);
color: var(--warn);
}
.change {
color: var(--accent-hover);
font-size: var(--text-xs);
}
.change.loss {
color: var(--warn);
}
.included {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.included li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}
.included .name {
color: var(--ink-2);
font-size: var(--text-sm);
font-weight: 400;
}
.foot {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 12px 24px;
border-top: 1px solid var(--line);
background: rgba(9, 9, 11, 0.6);
}
.summary {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: var(--text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
.ok {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--ok);
}
.err {
color: var(--danger);
}
</style>
+18 -3
View File
@@ -23,6 +23,9 @@
const current = $derived(launcher.settings); const current = $derived(launcher.settings);
const locked = $derived(launcher.locked); const locked = $derived(launcher.locked);
const saving = $derived(launcher.busy === "settings"); 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 busy = $derived(launcher.busy !== null);
const trimmed = $derived<Settings>({ const trimmed = $derived<Settings>({
@@ -36,10 +39,12 @@
trimmed.mcp_url !== current.mcp_url || trimmed.mcp_url !== current.mcp_url ||
trimmed.install_dir !== current.install_dir), 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 siteError = $derived(urlProblem(trimmed.site_url));
const mcpError = $derived(urlProblem(trimmed.mcp_url)); const mcpError = $derived(urlProblem(trimmed.mcp_url));
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed."); 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 { function urlProblem(value: string): string | null {
if (!value) return "Required."; if (!value) return "Required.";
@@ -134,7 +139,7 @@
function installFromDrive() { function installFromDrive() {
open = false; open = false;
void launcher.installFromDrive(); void launcher.chooseDrive();
} }
function signIn() { function signIn() {
@@ -216,14 +221,24 @@
</button> </button>
</div> </div>
<span id="set-install-dir-help" class={dirty && dirError ? "field-error" : "field-help"}> <span id="set-install-dir-help" class={dirty && dirError ? "field-error" : "field-help"}>
{#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."} {dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."}
{/if}
</span> </span>
</div> </div>
</fieldset> </fieldset>
<section class="group" aria-labelledby="set-drive-title"> <section class="group" aria-labelledby="set-drive-title">
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3> <h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
<p class="text">Install or update from an ODIAN SSD without reaching the DHP. Pick the drive's root folder.</p> <p class="text">
Copy ODIAN from an ODIAN SSD into your install folder, without reaching the DHP. Pick the drive,
then choose what to copy.
</p>
<div> <div>
<button type="button" class="btn" disabled={locked || busy} onclick={installFromDrive}> <button type="button" class="btn" disabled={locked || busy} onclick={installFromDrive}>
<Icon name="drive" size={14} /> Install from drive… <Icon name="drive" size={14} /> Install from drive…
+10 -1
View File
@@ -5,9 +5,10 @@
interface Props { interface Props {
onOpenSettings: () => void; onOpenSettings: () => void;
onOpenMarket: () => void;
} }
let { onOpenSettings }: Props = $props(); let { onOpenSettings, onOpenMarket }: Props = $props();
let maximized = $state(false); let maximized = $state(false);
@@ -81,6 +82,10 @@
<div class="drag" data-tauri-drag-region></div> <div class="drag" data-tauri-drag-region></div>
<button class="icon-btn market" aria-label="Marketplace" title="Marketplace" aria-haspopup="dialog" onclick={onOpenMarket}>
<Icon name="market" />
</button>
<button class="icon-btn settings" aria-label="Settings" title="Settings" aria-haspopup="dialog" onclick={onOpenSettings}> <button class="icon-btn settings" aria-label="Settings" title="Settings" aria-haspopup="dialog" onclick={onOpenSettings}>
<Icon name="settings" /> <Icon name="settings" />
</button> </button>
@@ -223,6 +228,10 @@
align-self: stretch; align-self: stretch;
} }
.market {
margin-right: var(--space-1);
}
.settings { .settings {
margin-right: var(--space-2); margin-right: var(--space-2);
} }
+4
View File
@@ -9,6 +9,7 @@ import type {
Backend, Backend,
InstallProgress, InstallProgress,
LogEvent, LogEvent,
Market,
Settings, Settings,
SignInResult, SignInResult,
StackEvent, StackEvent,
@@ -47,6 +48,9 @@ function createTauriBackend(): Backend {
getStack: () => invoke<StackEvent[]>("get_stack"), getStack: () => invoke<StackEvent[]>("get_stack"),
startInstall: () => invoke<void>("start_install"), startInstall: () => invoke<void>("start_install"),
startInstallFromDrive: (path) => invoke<void>("start_install_from_drive", { path }), startInstallFromDrive: (path) => invoke<void>("start_install_from_drive", { path }),
getMarket: (drive) => invoke<Market>("get_market", drive === undefined ? {} : { drive }),
applyMarket: (selected) => invoke<void>("apply_market", { selected }),
setMarket: (selected, drive) => invoke<void>("set_market", drive === undefined ? { selected } : { selected, drive }),
cancelInstall: () => invoke<void>("cancel_install"), cancelInstall: () => invoke<void>("cancel_install"),
launch: () => invoke<void>("launch"), launch: () => invoke<void>("launch"),
stop: () => invoke<void>("stop"), stop: () => invoke<void>("stop"),
+41 -3
View File
@@ -8,6 +8,7 @@ import type {
InstallProgress, InstallProgress,
LogEvent, LogEvent,
LogLevel, LogLevel,
Market,
Phase, Phase,
Settings, Settings,
StackComponent, StackComponent,
@@ -112,6 +113,9 @@ class Launcher {
stopping = $state(false); stopping = $state(false);
/** The sync review panel. */ /** The sync review panel. */
syncOpen = $state(false); syncOpen = $state(false);
/** The marketplace panel, and the drive it describes (null: this PC and its DHP). */
marketOpen = $state(false);
marketDrive = $state<string | null>(null);
/** Decisions waiting in this PC's database; null until the panel loads them. */ /** Decisions waiting in this PC's database; null until the panel loads them. */
conflicts = $state<SyncConflict[] | null>(null); conflicts = $state<SyncConflict[] | null>(null);
notes = $state<NotesState>({ notes = $state<NotesState>({
@@ -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<Market> {
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() { install() {
return this.run("install", async () => { return this.run("install", async () => {
this.lastOp = { kind: "install" }; this.lastOp = { kind: "install" };
@@ -296,9 +332,11 @@ class Launcher {
}); });
} }
async installFromDrive() { /**
const path = await this.pickFolder(); * Installing from a drive is a copy the operator chooses first: the marketplace opens on what
if (!path) return false; * the drive carries, and starts this once they have picked.
*/
async installFromDrive(path: string) {
return this.run("install", async () => { return this.run("install", async () => {
this.lastOp = { kind: "drive", path }; this.lastOp = { kind: "drive", path };
await backend.startInstallFromDrive(path); await backend.startInstallFromDrive(path);
+100
View File
@@ -18,6 +18,8 @@ import {
type InstallStage, type InstallStage,
type LogEvent, type LogEvent,
type LogLevel, type LogLevel,
type Market,
type MarketItem,
type MockControls, type MockControls,
type MockScenario, type MockScenario,
type Settings, type Settings,
@@ -182,6 +184,25 @@ type Listeners = { [K in Channel]: Set<(payload: Payloads[K]) => void> };
const now = () => Math.floor(Date.now() / 1000); const now = () => Math.floor(Date.now() / 1000);
const busy = "Busy: wait for the current task to finish."; 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<MarketItem, "presence" | "selected">[] = [
{ 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<T extends string>(value: string | null, allowed: readonly T[]): T | null { function pick<T extends string>(value: string | null, allowed: readonly T[]): T | null {
return value !== null && (allowed as readonly string[]).includes(value) ? (value as 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", phase: "idle",
error: null, error: null,
install_dir: DEFAULT_DIR, install_dir: DEFAULT_DIR,
portable: false,
sync: null, sync: null,
live: null, live: null,
}; };
@@ -276,6 +298,9 @@ class MockBackend implements Backend {
private signInCancelled = false; private signInCancelled = false;
/** What waits for a decision in the mock "local database". */ /** What waits for a decision in the mock "local database". */
private conflicts: SyncConflict[]; private conflicts: SyncConflict[];
/** Marketplace: the optional items chosen, and what is on the mock PC. */
private marketChosen = new Set<string>();
private readonly marketPresent = new Map<string, MarketItem["presence"]>();
/** Latest event per component, as `get_stack` reports it. */ /** Latest event per component, as `get_stack` reports it. */
private readonly stackStates = new Map<StackComponent, StackEvent>(); private readonly stackStates = new Map<StackComponent, StackEvent>();
private readonly listeners: Listeners = { private readonly listeners: Listeners = {
@@ -295,6 +320,13 @@ class MockBackend implements Backend {
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : []; this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
this.network = this.status.online; this.network = this.status.online;
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR }; 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; const self = this;
this.mock = { this.mock = {
@@ -476,6 +508,74 @@ class MockBackend implements Backend {
void this.runInstall(kind, "drive", path); void this.runInstall(kind, "drive", path);
} }
async getMarket(drive?: string): Promise<Market> {
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<void> {
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<void> {
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<void> { async cancelInstall(): Promise<void> {
if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started."; 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."; if (this.status.phase !== "installing") throw "Nothing is installing.";
+44
View File
@@ -38,6 +38,11 @@ export interface Status {
error: string | null; error: string | null;
/** e.g. "D:\\ODIAN". */ /** e.g. "D:\\ODIAN". */
install_dir: string; 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. */ /** Operator-data sync counts from the last sync this session; null before one. */
sync: SyncStatus | null; sync: SyncStatus | null;
/** /**
@@ -47,6 +52,36 @@ export interface Status {
live: boolean | null; live: boolean | null;
} }
/** A marketplace item: one tile set or source file of the version. */
export interface MarketItem {
/** Stable across versions: `tiles:<style>/<dataset>` or `src:<path>`. */
key: string;
kind: "tiles" | "src";
title: string;
description: string;
/** What installing it downloads, in full. */
bytes: number;
/** Part of every install: shown, never offered for removal. */
required: boolean;
/** installed: all of it is on this PC; partial: a download that stopped (installing resumes). */
presence: "installed" | "partial" | "absent";
/** Chosen: installed with the version and kept by updates (required items always are). */
selected: boolean;
}
export interface Market {
/** The version the items belong to: the installed one, else the DHP's current one. */
version: string;
/** False before the first install: a choice then waits for Install. */
installed: boolean;
/** The app and database: what every install downloads besides the items. */
core_bytes: number;
/** Required items first. */
items: MarketItem[];
/** Free space where ODIAN is (or will be) installed; null when unknown. */
free_bytes: number | null;
}
export interface SyncStatus { export interface SyncStatus {
/** Local edits the DHP has not taken yet. */ /** Local edits the DHP has not taken yet. */
pending: number; pending: number;
@@ -184,6 +219,15 @@ export interface Backend {
openExternal(url: string): Promise<void>; openExternal(url: string): Promise<void>;
startInstall(): Promise<void>; startInstall(): Promise<void>;
startInstallFromDrive(path: string): Promise<void>; startInstallFromDrive(path: string): Promise<void>;
/** The marketplace: every tile set and source file of the version, or of a drive. */
getMarket(drive?: string): Promise<Market>;
/**
* Choose the optional items (keys) and apply it: unchosen ones leave this PC, chosen missing
* ones download (install-progress follows). Before the first install it only saves the choice.
*/
applyMarket(selected: string[]): Promise<void>;
/** Choose the optional items an install about to run will bring; changes nothing installed. */
setMarket(selected: string[], drive?: string): Promise<void>;
cancelInstall(): Promise<void>; cancelInstall(): Promise<void>;
launch(): Promise<void>; launch(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;