update
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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() }]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user