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
+1
View File
@@ -24,6 +24,7 @@ tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tokio = { version = "1", features = ["net", "io-util", "time", "sync", "macros", "rt-multi-thread", "fs"] }
tokio-util = "0.7"
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_opener::OpenerExt;
use crate::launcher::{Launcher, Status};
use crate::launcher::{Launcher, Market, Status};
use crate::settings::Settings;
use crate::stack::StackEvent;
use crate::sync::Conflict;
@@ -21,6 +21,31 @@ pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
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]
pub async fn refresh(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Status> {
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.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use std::sync::atomic::{AtomicU32, Ordering};
@@ -13,6 +14,8 @@ use tokio_util::sync::CancellationToken;
use crate::install;
use crate::localdb::{self, Pg};
use crate::market;
use crate::prune;
use crate::settings::{normalize_url, Settings};
use crate::site::{self, SessionRefresh, TokenCheck};
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
@@ -54,6 +57,9 @@ pub struct Status {
pub phase: Phase,
pub error: Option<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.
pub sync: Option<SyncStatus>,
/// 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>,
}
/// 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)]
struct LogEvent {
level: &'static str,
@@ -104,6 +123,16 @@ struct Inner {
/// during a blip still knows where live mode will forward.
engine_url: Option<String>,
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 {
@@ -118,13 +147,18 @@ impl Inner {
operator: self.state.operator.clone(),
// 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()),
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,
available: self.available.clone(),
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(),
install_dir: self.settings.install_dir.clone(),
portable: self.portable,
sync: self.sync.clone(),
live: self.live,
}
@@ -141,6 +175,98 @@ pub struct Launcher {
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 {
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}"))?;
Ok(Launcher {
http,
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None }),
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_states: std::sync::Mutex::new(Vec::new()),
sign_in_cancel: Notify::new(),
@@ -175,7 +301,13 @@ impl Launcher {
}
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>) {
@@ -383,6 +515,11 @@ impl Launcher {
if next.install_dir.is_empty() {
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)?;
if next.install_dir != inner.settings.install_dir {
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.
/// 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> {
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;
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
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 {
Some(root) => {
if !root.join("packs").join("versions").join("CURRENT").is_file() {
return Err(format!("{} is not an ODIAN drive: it has no packs\\versions\\CURRENT.", root.display()));
let source = drive_source(root)?;
if let Some(problem) = self_copy_problem(&source, &inner.settings.install_path()) {
return Err(problem);
}
}
None => {
@@ -448,7 +595,7 @@ impl Launcher {
}
let install_dir = {
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());
}
inner.phase = Phase::Installing;
@@ -463,7 +610,7 @@ impl Launcher {
let handle = app.clone();
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(())
}
@@ -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.
let hold = CancellationToken::new();
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 result = loop {
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 {
Ok(token) => Source::Site { base_url: self.inner.lock().await.settings.site_url.clone(), token },
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.
// Everything already verified is skipped on the second pass.
Err(InstallFailure::Unauthorized) if drive.is_none() && !renewed => {
@@ -566,12 +724,18 @@ impl Launcher {
*slot = None;
}
match result {
Ok(_) if market => {
self.log(app, "info", "The marketplace items are installed.");
self.finish(app, None).await;
}
Ok(version) => {
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;
if drive.is_none() {
self.refresh(app).await;
}
self.prune(app).await;
}
Err(InstallFailure::Cancelled) => {
self.log(app, "warn", "Install cancelled. Everything already verified was kept; installing again resumes from there.");
@@ -595,18 +759,46 @@ impl Launcher {
source: &Source,
install_dir: &Path,
cancel: &CancellationToken,
market: bool,
) -> Result<String, InstallFailure> {
if market {
return self.install_market_items(app, source, install_dir, cancel).await;
}
match source {
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::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 manifest = verified.manifest();
let version = verified.version().to_string();
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
// 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(
app,
"warn",
@@ -636,7 +828,16 @@ impl Launcher {
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) {
0 => 5,
n => n,
@@ -727,6 +928,397 @@ impl Launcher {
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 ───────────────────────────────────────────────────────────
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 launcher;
mod localdb;
mod market;
mod prune;
mod settings;
mod site;
mod stack;
@@ -20,6 +22,7 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -30,15 +33,26 @@ pub fn run() {
}
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();
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(())
})
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::get_market,
commands::apply_market,
commands::set_market,
commands::refresh,
commands::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,
//! 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::process::{Command, Stdio};
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.
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.
fn candidate_tables(files: &[DbFile]) -> Vec<String> {
files
@@ -565,6 +577,37 @@ CREATE FUNCTION public.hospital_count() RETURNS bigint AS $$ SELECT count(*) FRO
("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]
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"));
+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());
}
}
}
+209 -15
View File
@@ -34,13 +34,28 @@ struct SiteFile {
site_url: String,
#[serde(default)]
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> {
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}"))?;
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?
///
/// 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()
}
/// 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 {
url.trim().trim_end_matches('/').to_string()
}
@@ -121,30 +238,107 @@ pub fn normalize_url(url: &str) -> String {
impl Settings {
pub fn load(app: &AppHandle) -> Result<Settings, String> {
let path = settings_path(app)?;
if let Ok(bytes) = std::fs::read(&path) {
let mut settings: Settings =
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?;
if settings.install_dir.trim().is_empty() {
settings.install_dir = default_install_dir();
let mut settings = match std::fs::read(&path) {
Ok(bytes) => {
let mut settings: Settings =
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?;
if settings.install_dir.trim().is_empty() {
settings.install_dir = default_install_dir();
}
settings
}
Err(_) => {
let site = read_site_file();
let settings = Settings {
site_url: normalize_url(&site.site_url),
mcp_url: normalize_url(&site.mcp_url),
install_dir: default_install_dir(),
site_name: site.name,
};
settings.save(app)?;
settings
}
return Ok(settings);
}
let site = read_site_file();
let settings = Settings {
site_url: normalize_url(&site.site_url),
mcp_url: normalize_url(&site.mcp_url),
install_dir: default_install_dir(),
site_name: site.name,
};
settings.save(app)?;
// 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)
}
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 {
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.
fn pmtiles_of(config: &StackConfig, dataset: &str) -> Option<PathBuf> {
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
@@ -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 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> {
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()))?;
@@ -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(),
})
})
.filter(|set| layout.data_dir().join("tiles").join(&set.style).join(&set.dataset).is_dir())
.collect()
})
.unwrap_or_default())
@@ -839,6 +845,7 @@ mod tests {
let src = cfg.layout.data_dir().join("src");
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.ok"), b"digest").unwrap();
let server = parent(&cfg.layout.app_file(TILE_SERVER_APP));
std::fs::create_dir_all(&server).unwrap();
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"}]}}"#,
)
.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();
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(
sets,
vec![
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() },
]
vec![TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".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.
#[serde(default)]
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)]
+11 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "ODIAN Launcher",
"version": "0.1.1",
"version": "0.1.3",
"identifier": "fr.odian.launcher",
"build": {
"frontendDist": "../dist",
@@ -31,6 +31,7 @@
"bundle": {
"active": true,
"targets": ["nsis"],
"createUpdaterArtifacts": true,
"resources": [
"site.json"
],
@@ -41,5 +42,14 @@
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdBODcwMzdCRUUxOEVBMTYKUldRVzZoanVld09IZXF0ZEl3dW1vNEpWcmdRZ0Izakl0MHlOOWQxTTd5ZjFwbks1QTVmL2tDWFgK",
"dangerousInsecureTransportProtocol": true,
"windows": {
"installMode": "passive"
}
}
}
}