Skip to main content

corundum_framework_package/
start.rs

1use std::fs;
2use std::path::Path;
3use std::sync::atomic::{AtomicBool, Ordering};
4
5use corundum_framework_platform::dirs::AppDirs;
6use corundum_framework_platform::package;
7use semver::Version;
8use velopack::VelopackApp;
9
10use crate::backend::{self, Located};
11use crate::services::Services;
12
13#[cfg(windows)]
14use crate::cli_path;
15use crate::cli_path::CliSpec;
16
17/// Set by Velopack for the app's first start after an install. Its `run` removes it, so it
18/// is read before.
19const FIRSTRUN: &str = "VELOPACK_FIRSTRUN";
20
21/// Set by Velopack's updater when it starts the app again after an update, applied or
22/// not. Its `run` removes it, so it is read before.
23const RESTART: &str = "VELOPACK_RESTART";
24
25/// Whether [`start`] saw [`RESTART`].
26static RESTARTED: AtomicBool = AtomicBool::new(false);
27
28/// The file, in the app's data directory, holding the version the app last ran with.
29const KEPT_VERSION: &str = "package-version";
30
31/// What a package needs from the app.
32#[derive(Clone, Debug)]
33pub struct PackageSpec {
34    /// The app's directory name, where the last version run is kept: `sapphire-journal`.
35    pub app_name: &'static str,
36    /// Run once per install: in the install hook on Windows, at the first start elsewhere.
37    /// It must be safe to run again.
38    pub on_installed: Option<fn()>,
39    /// Run before uninstalling, on Windows only (no other OS runs anything): 30 seconds,
40    /// no window.
41    pub on_uninstalling: Option<fn()>,
42    /// Run before any update is applied: stop the app's background processes. An error
43    /// stops the update.
44    pub before_apply: Option<fn() -> Result<(), String>>,
45    /// The command line to put on `PATH`, if the app has one.
46    pub cli: Option<CliSpec>,
47    /// The app's background services: the running ones are stopped before an update is
48    /// applied and started again at the next start, and on Windows each is uninstalled
49    /// with the app. An app without services writes [`Services::NONE`].
50    pub services: Services,
51}
52
53/// Which start this is, against the version the app last ran with.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum Start {
56    /// Not in a package (`cargo run`, the scripts, cargo).
57    NotPackaged,
58    /// The first start of this install.
59    FirstStart { version: Version },
60    /// The first start since the version changed.
61    Updated { from: Version, to: Version },
62    /// The version last run.
63    Same { version: Version },
64}
65
66/// Velopack's start, then what this start is. The first thing `main` runs but the app's
67/// logging: started for a Windows hook, it runs the hook and exits, and what goes wrong in
68/// a hook (the command line not put on `PATH`) is only logged, so initialise logging
69/// before calling `start`.
70pub fn start(spec: &PackageSpec) -> Start {
71    let firstrun = std::env::var_os(FIRSTRUN).is_some();
72    RESTARTED.store(std::env::var_os(RESTART).is_some(), Ordering::Relaxed);
73    // The app applies an update when it asks to, after `before_apply`, never behind its back
74    // at a start.
75    let app = VelopackApp::build().set_auto_apply_on_startup(false);
76    // Found from the executable with its links resolved: see `backend::locate`. Outside a
77    // package Velopack searches by itself, and finds nothing either.
78    let located = backend::locate();
79    let app = match &located {
80        Ok(Located {
81            config: Some(config),
82            ..
83        }) => app.set_locator(config.clone()),
84        _ => app,
85    };
86    #[cfg(windows)]
87    let app = windows_hooks(app, spec);
88    let mut app = app;
89    app.run();
90
91    // Whatever this start is, as the first process to start after an update starts the
92    // services it stopped: the GUI, the command line, or one installed by the scripts.
93    #[cfg(feature = "service")]
94    if let Some(dirs) = AppDirs::new(spec.app_name) {
95        crate::services::restart_stopped(
96            spec,
97            dirs.data(),
98            &corundum_framework_service::Environment::detect(),
99            &corundum_framework_service::SystemManager,
100        );
101    }
102
103    if package::current().is_none() {
104        return Start::NotPackaged;
105    }
106    let now = match located {
107        Ok(Located { version, .. }) => version,
108        Err(e) => {
109            tracing::warn!("in a package, but its version could not be read: {e}");
110            return Start::NotPackaged;
111        }
112    };
113    let kept_path = AppDirs::new(spec.app_name).map(|dirs| dirs.data().join(KEPT_VERSION));
114    // Read as bytes: a file that is not UTF-8 is as unreadable as any other garbage, and
115    // makes a first start.
116    let kept = kept_path
117        .as_deref()
118        .and_then(|path| fs::read(path).ok())
119        .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
120    let start = classify(kept.as_deref(), firstrun, &now);
121    match &kept_path {
122        Some(path) => {
123            if let Err(e) = keep(path, &now) {
124                tracing::warn!("could not keep the version in {}: {e}", path.display());
125            }
126        }
127        None => tracing::warn!("no data directory to keep the version in"),
128    }
129    // Windows ran it in the install hook; the other OSes have no hook, so the first start
130    // stands in for it.
131    #[cfg(not(windows))]
132    if matches!(start, Start::FirstStart { .. })
133        && let Some(on_installed) = spec.on_installed
134    {
135        on_installed();
136    }
137    start
138}
139
140/// Whether Velopack's updater started this process, after applying an update or failing
141/// to: `Updater::apply_pending_at_start` then applies nothing, as an update that failed
142/// would be tried again at every start.
143pub(crate) fn restarted() -> bool {
144    RESTARTED.load(Ordering::Relaxed)
145}
146
147/// The app's install and uninstall hooks. Velopack runs a hook and exits when the installer
148/// starts the app for one; the update hooks are left unset, so it exits for those without
149/// running anything.
150#[cfg(windows)]
151fn windows_hooks<'a>(app: VelopackApp<'a>, spec: &PackageSpec) -> VelopackApp<'a> {
152    let on_installed = spec.on_installed;
153    let on_uninstalling = spec.on_uninstalling;
154    let cli = spec.cli;
155    #[cfg(feature = "service")]
156    let services = spec.clone();
157    // A hook has 30 seconds and no window: a failure is logged, never shown or panicked on.
158    app.on_after_install_fast_callback(move |_| {
159        if let Some(cli) = cli
160            && let Err(e) = cli_path::install(&cli)
161        {
162            tracing::warn!("could not put {} on PATH: {e}", cli.command);
163        }
164        if let Some(on_installed) = on_installed {
165            on_installed();
166        }
167    })
168    .on_before_uninstall_fast_callback(move |_| {
169        // First, so the services' programs are no longer running when the files go: each
170        // service's uninstall ends its task (`schtasks /end`) before deleting it.
171        #[cfg(feature = "service")]
172        crate::services::uninstall_all(
173            &services,
174            &corundum_framework_service::Environment::detect(),
175            &corundum_framework_service::SystemManager,
176        );
177        if let Some(on_uninstalling) = on_uninstalling {
178            on_uninstalling();
179        }
180        if let Some(cli) = cli
181            && let Err(e) = cli_path::uninstall(&cli)
182        {
183            tracing::warn!("could not take {} off PATH: {e}", cli.command);
184        }
185    })
186}
187
188/// Keeps `version` as the one the app last ran with.
189fn keep(path: &Path, version: &Version) -> std::io::Result<()> {
190    if let Some(dir) = path.parent() {
191        fs::create_dir_all(dir)?;
192    }
193    fs::write(path, version.to_string())
194}
195
196/// Which start this is: a first one when nothing readable is kept or Velopack says so, an
197/// update when the kept version differs from the package's either way (a downgrade is a
198/// change too), and the same otherwise.
199pub(crate) fn classify(kept: Option<&str>, firstrun: bool, now: &Version) -> Start {
200    match kept.and_then(|kept| Version::parse(kept.trim()).ok()) {
201        Some(from) if !firstrun => {
202            if from == *now {
203                Start::Same {
204                    version: now.clone(),
205                }
206            } else {
207                Start::Updated {
208                    from,
209                    to: now.clone(),
210                }
211            }
212        }
213        _ => Start::FirstStart {
214            version: now.clone(),
215        },
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn v(s: &str) -> Version {
224        Version::parse(s).unwrap()
225    }
226
227    #[test]
228    fn a_start_is_told_from_the_kept_version_the_variable_and_the_package() {
229        let first = |now: &str| Start::FirstStart { version: v(now) };
230        let same = |now: &str| Start::Same { version: v(now) };
231        let updated = |from: &str, to: &str| Start::Updated {
232            from: v(from),
233            to: v(to),
234        };
235        let cases: &[(Option<&str>, bool, &str, Start)] = &[
236            (None, false, "1.0.0", first("1.0.0")),
237            (Some("1.0.0"), false, "1.0.0", same("1.0.0")),
238            (Some("1.0.0"), false, "1.1.0", updated("1.0.0", "1.1.0")),
239            (Some("1.1.0"), false, "1.0.0", updated("1.1.0", "1.0.0")),
240            (Some("1.0.0"), true, "1.0.0", first("1.0.0")),
241            (Some(""), false, "1.0.0", first("1.0.0")),
242            (Some("garbage"), false, "1.0.0", first("1.0.0")),
243            (Some(" 1.0.0\n"), false, "1.0.0", same("1.0.0")),
244        ];
245        for (kept, firstrun, now, want) in cases {
246            assert_eq!(
247                classify(*kept, *firstrun, &v(now)),
248                *want,
249                "kept {kept:?}, firstrun {firstrun}, now {now}"
250            );
251        }
252    }
253}