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