corundum-framework-package 0.6.0

A desktop app as a Velopack package: its start and install hooks, told apart from an update
Documentation
use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};

use corundum_framework_platform::dirs::AppDirs;
use corundum_framework_platform::package;
use semver::Version;
use velopack::VelopackApp;

use crate::backend::{self, Located};
use crate::services::Services;

#[cfg(windows)]
use crate::cli_path;
use crate::cli_path::CliSpec;

/// Set by Velopack for the app's first start after an install. Its `run` removes it, so it
/// is read before.
const FIRSTRUN: &str = "VELOPACK_FIRSTRUN";

/// Set by Velopack's updater when it starts the app again after an update, applied or
/// not. Its `run` removes it, so it is read before.
const RESTART: &str = "VELOPACK_RESTART";

/// Whether [`start`] saw [`RESTART`].
static RESTARTED: AtomicBool = AtomicBool::new(false);

/// The file, in the app's data directory, holding the version the app last ran with.
const KEPT_VERSION: &str = "package-version";

/// What a package needs from the app.
#[derive(Clone, Debug)]
pub struct PackageSpec {
    /// The app's directory name, where the last version run is kept: `sapphire-journal`.
    pub app_name: &'static str,
    /// Run once per install: in the install hook on Windows, at the first start elsewhere.
    /// It must be safe to run again.
    pub on_installed: Option<fn()>,
    /// Run before uninstalling, on Windows only (no other OS runs anything): 30 seconds,
    /// no window.
    pub on_uninstalling: Option<fn()>,
    /// Run before any update is applied: stop the app's background processes. An error
    /// stops the update.
    pub before_apply: Option<fn() -> Result<(), String>>,
    /// The command line to put on `PATH`, if the app has one.
    pub cli: Option<CliSpec>,
    /// The app's background services: the running ones are stopped before an update is
    /// applied and started again at the next start, and on Windows each is uninstalled
    /// with the app. An app without services writes [`Services::NONE`].
    pub services: Services,
}

/// Which start this is, against the version the app last ran with.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Start {
    /// Not in a package (`cargo run`, the scripts, cargo).
    NotPackaged,
    /// The first start of this install.
    FirstStart { version: Version },
    /// The first start since the version changed.
    Updated { from: Version, to: Version },
    /// The version last run.
    Same { version: Version },
}

/// Velopack's start, then what this start is. The first thing `main` runs but the app's
/// logging: started for a Windows hook, it runs the hook and exits, and what goes wrong in
/// a hook (the command line not put on `PATH`) is only logged, so initialise logging
/// before calling `start`.
pub fn start(spec: &PackageSpec) -> Start {
    let firstrun = std::env::var_os(FIRSTRUN).is_some();
    RESTARTED.store(std::env::var_os(RESTART).is_some(), Ordering::Relaxed);
    // The app applies an update when it asks to, after `before_apply`, never behind its back
    // at a start.
    let app = VelopackApp::build().set_auto_apply_on_startup(false);
    // Found from the executable with its links resolved: see `backend::locate`. Outside a
    // package Velopack searches by itself, and finds nothing either.
    let located = backend::locate();
    let app = match &located {
        Ok(Located {
            config: Some(config),
            ..
        }) => app.set_locator(config.clone()),
        _ => app,
    };
    #[cfg(windows)]
    let app = windows_hooks(app, spec);
    let mut app = app;
    app.run();

    // Whatever this start is, as the first process to start after an update starts the
    // services it stopped: the GUI, the command line, or one installed by the scripts.
    #[cfg(feature = "service")]
    if let Some(dirs) = AppDirs::new(spec.app_name) {
        crate::services::restart_stopped(
            spec,
            dirs.data(),
            &corundum_framework_service::Environment::detect(),
            &corundum_framework_service::SystemManager,
        );
    }

    if package::current().is_none() {
        return Start::NotPackaged;
    }
    let now = match located {
        Ok(Located { version, .. }) => version,
        Err(e) => {
            tracing::warn!("in a package, but its version could not be read: {e}");
            return Start::NotPackaged;
        }
    };
    let kept_path = AppDirs::new(spec.app_name).map(|dirs| dirs.data().join(KEPT_VERSION));
    // Read as bytes: a file that is not UTF-8 is as unreadable as any other garbage, and
    // makes a first start.
    let kept = kept_path
        .as_deref()
        .and_then(|path| fs::read(path).ok())
        .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
    let start = classify(kept.as_deref(), firstrun, &now);
    match &kept_path {
        Some(path) => {
            if let Err(e) = keep(path, &now) {
                tracing::warn!("could not keep the version in {}: {e}", path.display());
            }
        }
        None => tracing::warn!("no data directory to keep the version in"),
    }
    // Windows ran it in the install hook; the other OSes have no hook, so the first start
    // stands in for it.
    #[cfg(not(windows))]
    if matches!(start, Start::FirstStart { .. })
        && let Some(on_installed) = spec.on_installed
    {
        on_installed();
    }
    start
}

/// Whether Velopack's updater started this process, after applying an update or failing
/// to: `Updater::apply_pending_at_start` then applies nothing, as an update that failed
/// would be tried again at every start.
pub(crate) fn restarted() -> bool {
    RESTARTED.load(Ordering::Relaxed)
}

/// The app's install and uninstall hooks. Velopack runs a hook and exits when the installer
/// starts the app for one; the update hooks are left unset, so it exits for those without
/// running anything.
#[cfg(windows)]
fn windows_hooks<'a>(app: VelopackApp<'a>, spec: &PackageSpec) -> VelopackApp<'a> {
    let on_installed = spec.on_installed;
    let on_uninstalling = spec.on_uninstalling;
    let cli = spec.cli;
    #[cfg(feature = "service")]
    let services = spec.clone();
    // A hook has 30 seconds and no window: a failure is logged, never shown or panicked on.
    app.on_after_install_fast_callback(move |_| {
        if let Some(cli) = cli
            && let Err(e) = cli_path::install(&cli)
        {
            tracing::warn!("could not put {} on PATH: {e}", cli.command);
        }
        if let Some(on_installed) = on_installed {
            on_installed();
        }
    })
    .on_before_uninstall_fast_callback(move |_| {
        // First, so the services' programs are no longer running when the files go: each
        // service's uninstall ends its task (`schtasks /end`) before deleting it.
        #[cfg(feature = "service")]
        crate::services::uninstall_all(
            &services,
            &corundum_framework_service::Environment::detect(),
            &corundum_framework_service::SystemManager,
        );
        if let Some(on_uninstalling) = on_uninstalling {
            on_uninstalling();
        }
        if let Some(cli) = cli
            && let Err(e) = cli_path::uninstall(&cli)
        {
            tracing::warn!("could not take {} off PATH: {e}", cli.command);
        }
    })
}

/// Keeps `version` as the one the app last ran with.
fn keep(path: &Path, version: &Version) -> std::io::Result<()> {
    if let Some(dir) = path.parent() {
        fs::create_dir_all(dir)?;
    }
    fs::write(path, version.to_string())
}

/// Which start this is: a first one when nothing readable is kept or Velopack says so, an
/// update when the kept version differs from the package's either way (a downgrade is a
/// change too), and the same otherwise.
pub(crate) fn classify(kept: Option<&str>, firstrun: bool, now: &Version) -> Start {
    match kept.and_then(|kept| Version::parse(kept.trim()).ok()) {
        Some(from) if !firstrun => {
            if from == *now {
                Start::Same {
                    version: now.clone(),
                }
            } else {
                Start::Updated {
                    from,
                    to: now.clone(),
                }
            }
        }
        _ => Start::FirstStart {
            version: now.clone(),
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn v(s: &str) -> Version {
        Version::parse(s).unwrap()
    }

    #[test]
    fn a_start_is_told_from_the_kept_version_the_variable_and_the_package() {
        let first = |now: &str| Start::FirstStart { version: v(now) };
        let same = |now: &str| Start::Same { version: v(now) };
        let updated = |from: &str, to: &str| Start::Updated {
            from: v(from),
            to: v(to),
        };
        let cases: &[(Option<&str>, bool, &str, Start)] = &[
            (None, false, "1.0.0", first("1.0.0")),
            (Some("1.0.0"), false, "1.0.0", same("1.0.0")),
            (Some("1.0.0"), false, "1.1.0", updated("1.0.0", "1.1.0")),
            (Some("1.1.0"), false, "1.0.0", updated("1.1.0", "1.0.0")),
            (Some("1.0.0"), true, "1.0.0", first("1.0.0")),
            (Some(""), false, "1.0.0", first("1.0.0")),
            (Some("garbage"), false, "1.0.0", first("1.0.0")),
            (Some(" 1.0.0\n"), false, "1.0.0", same("1.0.0")),
        ];
        for (kept, firstrun, now, want) in cases {
            assert_eq!(
                classify(*kept, *firstrun, &v(now)),
                *want,
                "kept {kept:?}, firstrun {firstrun}, now {now}"
            );
        }
    }
}