Skip to main content

gpui_updater_core/install/
mod.rs

1//! Replacing the installed application with a freshly downloaded artifact.
2#![doc = include_str!("../../docs/install.md")]
3
4use std::path::{Path, PathBuf};
5
6use crate::error::Result;
7
8#[cfg(target_os = "linux")]
9mod linux;
10#[cfg(target_os = "macos")]
11pub(crate) mod macos;
12#[cfg(target_os = "windows")]
13mod windows;
14
15mod ownership;
16pub use ownership::{Ownership, OwnershipHints, PackageManager, ownership};
17
18/// Outcome of installing an update.
19#[derive(Debug, Clone)]
20pub struct Installed {
21    /// Path to relaunch into the new version, fed to `App::set_restart_path` by
22    /// the GPUI integration. This is what GPUI's platform `restart` consumes, so
23    /// it is platform-shaped: on macOS the `.app` bundle (relaunched via `open`,
24    /// which needs the bundle, not the inner Mach-O — that would open in
25    /// Terminal); on Linux the executable itself; on Windows the executable —
26    /// or, for a staged MSI, the generated apply script that installs the
27    /// package and relaunches the app (GPUI's Windows `restart` spawns the path
28    /// only after this process exits, which is the ordering msiexec needs).
29    /// `None` means the running location was updated in place with no new path
30    /// to restart into.
31    pub restart_path: Option<PathBuf>,
32}
33
34/// How much of the Windows Installer UI an `.msi` update shows. The package
35/// installs after the app exits.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37#[non_exhaustive]
38pub enum MsiUi {
39    /// A progress bar and no questions (`/passive`).
40    #[default]
41    Passive,
42    /// The basic UI: progress, a Cancel button, and error dialogs (`/qb`).
43    Basic,
44    /// No UI at all (`/quiet`). A per-machine package cannot ask for
45    /// elevation then, so use it for per-user packages or an elevated app.
46    Quiet,
47}
48
49impl MsiUi {
50    /// The msiexec option for this UI level.
51    #[cfg_attr(
52        not(any(target_os = "windows", test)),
53        expect(dead_code, reason = "msiexec runs on Windows only")
54    )]
55    pub(crate) fn flag(self) -> &'static str {
56        match self {
57            Self::Passive => "/passive",
58            Self::Basic => "/qb",
59            Self::Quiet => "/quiet",
60        }
61    }
62}
63
64/// How [`install_with`] installs an update.
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66#[non_exhaustive]
67pub struct InstallOptions {
68    /// The Windows Installer UI for an `.msi` update.
69    pub msi_ui: MsiUi,
70}
71
72impl InstallOptions {
73    /// The defaults: [`MsiUi::Passive`].
74    #[must_use]
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Set the Windows Installer UI for an `.msi` update.
80    #[must_use]
81    pub fn msi_ui(mut self, ui: MsiUi) -> Self {
82        self.msi_ui = ui;
83        self
84    }
85}
86
87/// Install a downloaded update artifact over `install_root`.
88///
89/// - `artifact`: the downloaded file (`.dmg` on macOS, `.tar.gz` or
90///   `.AppImage` on Linux, `.exe` or `.msi` on Windows). On macOS and Linux
91///   it may also be a directory holding an already-unpacked payload (the
92///   `.app` bundle or the executable), which is what
93///   [`UpdateEngine::download`](crate::UpdateEngine::download) returns after
94///   rebuilding an update from a delta.
95/// - `install_root`: what to replace — on macOS the `.app` bundle directory;
96///   on Linux the executable file, the `AppImage` file, or a directory that
97///   holds the whole app (replaced as a whole from the `.tar.gz`). Use
98///   [`current_install_root`] to derive it, or set
99///   [`EngineConfig::install_root`](crate::EngineConfig::install_root) to an
100///   app directory.
101///
102/// # Errors
103/// Returns [`Error::Install`](crate::Error::Install) on any failure during
104/// mounting, copying, or swapping, and
105/// [`Error::UnsupportedPlatform`](crate::Error::UnsupportedPlatform) on
106/// platforms without an install strategy.
107pub fn install(artifact: &Path, install_root: &Path) -> Result<Installed> {
108    install_with(artifact, install_root, &InstallOptions::new())
109}
110
111/// [`install`] with `options`, such as the Windows Installer UI for an
112/// `.msi`.
113///
114/// # Errors
115/// As [`install`].
116pub fn install_with(
117    artifact: &Path,
118    install_root: &Path,
119    options: &InstallOptions,
120) -> Result<Installed> {
121    #[cfg(target_os = "macos")]
122    {
123        let _ = options;
124        macos::install(artifact, install_root)
125    }
126    #[cfg(target_os = "linux")]
127    {
128        let _ = options;
129        linux::install(artifact, install_root)
130    }
131    #[cfg(target_os = "windows")]
132    {
133        windows::install(artifact, install_root, options)
134    }
135    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
136    {
137        let _ = (artifact, install_root, options);
138        Err(crate::Error::UnsupportedPlatform(std::env::consts::OS))
139    }
140}
141
142/// Remove what an earlier install left next to `install_root` because the
143/// old version was still running: on Windows, the previous executable
144/// (`<app>.old.exe`) that a bare-`.exe` install renames aside. Call it at
145/// startup. It does nothing when there is nothing to remove, and it ignores
146/// failures, because the next call tries again.
147pub fn remove_leftovers(install_root: &Path) {
148    #[cfg(target_os = "windows")]
149    windows::remove_leftovers(install_root);
150    #[cfg(not(target_os = "windows"))]
151    let _ = install_root;
152}
153
154/// Best-effort path of the currently installed application to replace.
155///
156/// On macOS this walks up from the running executable to the enclosing `.app`
157/// bundle. On Linux it is the `AppImage` file (`$APPIMAGE`) when the app runs
158/// from inside that `AppImage`'s mount (`$APPDIR`), and otherwise the running
159/// executable. On Windows it is the running executable.
160///
161/// # Errors
162/// Returns [`Error::Io`](crate::Error::Io) if the current executable path
163/// cannot be determined.
164pub fn current_install_root() -> Result<PathBuf> {
165    let exe = std::env::current_exe()?;
166    #[cfg(target_os = "macos")]
167    {
168        Ok(macos::bundle_root(&exe).unwrap_or(exe))
169    }
170    #[cfg(target_os = "linux")]
171    {
172        Ok(linux::appimage_root(&exe).unwrap_or(exe))
173    }
174    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
175    {
176        Ok(exe)
177    }
178}
179
180/// Check, before anything is downloaded, that an update can be installed over
181/// `install_root`. `artifact_name` is the release asset's file name, which
182/// decides how it installs.
183///
184/// Installing replaces `install_root` from inside its parent directory, so
185/// that directory must be writable — except for a Windows `.msi`, which
186/// msiexec installs with its own elevation. On macOS an app that runs from
187/// an App Translocation path is rejected too: macOS runs a quarantined app
188/// from a randomized read-only copy until the user moves it.
189///
190/// # Errors
191/// [`Error::Location`](crate::Error::Location) for an App Translocation path
192/// or a read-only volume (such as a mounted disk image), and
193/// [`Error::Permission`](crate::Error::Permission) when the user may not
194/// write to the parent directory. Any other failure to probe the directory
195/// is left for the install to report.
196pub fn check_location(install_root: &Path, artifact_name: &str) -> Result<()> {
197    if cfg!(target_os = "macos")
198        && install_root
199            .components()
200            .any(|c| c.as_os_str() == "AppTranslocation")
201    {
202        return Err(crate::Error::Location(
203            "the app runs from a read-only App Translocation copy; move it to /Applications and open it again"
204                .into(),
205        ));
206    }
207    let installer_elevates =
208        cfg!(target_os = "windows") && artifact_name.to_ascii_lowercase().ends_with(".msi");
209    if installer_elevates {
210        return Ok(());
211    }
212    match install_root.parent() {
213        Some(dir) if !dir.as_os_str().is_empty() => probe_write(dir),
214        _ => Ok(()),
215    }
216}
217
218/// Create and remove a file in `dir` to learn whether the user may write
219/// there.
220fn probe_write(dir: &Path) -> Result<()> {
221    use std::io::ErrorKind;
222    use std::time::{SystemTime, UNIX_EPOCH};
223
224    let nanos = SystemTime::now()
225        .duration_since(UNIX_EPOCH)
226        .map_or(0, |d| d.subsec_nanos());
227    let probe = dir.join(format!(
228        ".gpui-updater-probe-{}-{nanos}",
229        std::process::id()
230    ));
231    match std::fs::OpenOptions::new()
232        .write(true)
233        .create_new(true)
234        .open(&probe)
235    {
236        Ok(file) => {
237            drop(file);
238            let _ = std::fs::remove_file(&probe);
239            Ok(())
240        }
241        Err(e) if e.kind() == ErrorKind::ReadOnlyFilesystem => {
242            Err(crate::Error::Location(format!(
243                "{} is on a read-only volume, such as a mounted disk image; move the app to a writable folder",
244                dir.display()
245            )))
246        }
247        Err(e) if e.kind() == ErrorKind::PermissionDenied => Err(crate::Error::Permission(
248            format!("no permission to write to {}", dir.display()),
249        )),
250        Err(e) => {
251            tracing::debug!(dir = %dir.display(), error = %e, "could not probe the install directory");
252            Ok(())
253        }
254    }
255}
256
257#[cfg(test)]
258mod location_tests {
259    use super::*;
260    #[cfg(unix)]
261    use crate::error::{Error, ErrorKind};
262
263    #[test]
264    fn accepts_a_writable_folder_and_leaves_nothing_behind() {
265        let dir = tempfile::tempdir().unwrap();
266        check_location(&dir.path().join("App.app"), "App.dmg").unwrap();
267        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
268    }
269
270    #[test]
271    fn leaves_a_missing_folder_for_the_install_to_report() {
272        let dir = tempfile::tempdir().unwrap();
273        check_location(&dir.path().join("gone/App.app"), "App.dmg").unwrap();
274    }
275
276    #[cfg(target_os = "macos")]
277    #[test]
278    fn rejects_app_translocation() {
279        let root = Path::new("/private/var/folders/x/T/AppTranslocation/1A2B/d/App.app");
280        let err = check_location(root, "App.dmg").unwrap_err();
281        assert!(matches!(err, Error::Location(_)), "{err:?}");
282        assert_eq!(err.kind(), ErrorKind::Location);
283    }
284
285    #[cfg(unix)]
286    #[test]
287    fn rejects_a_folder_the_user_may_not_write() {
288        use std::os::unix::fs::PermissionsExt;
289
290        let dir = tempfile::tempdir().unwrap();
291        let locked = dir.path().join("locked");
292        std::fs::create_dir(&locked).unwrap();
293        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
294        // A privileged user may write anyway, so the check passes there.
295        let privileged = std::fs::write(locked.join("probe"), b"").is_ok();
296        let result = check_location(&locked.join("App.app"), "App.dmg");
297        std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
298        if privileged {
299            return;
300        }
301        let err = result.unwrap_err();
302        assert!(matches!(err, Error::Permission(_)), "{err:?}");
303        assert_eq!(err.kind(), ErrorKind::Permission);
304    }
305}
306
307#[cfg(test)]
308mod options_tests {
309    use super::*;
310
311    #[test]
312    fn msi_ui_levels_map_to_msiexec_options() {
313        assert_eq!(InstallOptions::new().msi_ui, MsiUi::Passive);
314        assert_eq!(
315            InstallOptions::new().msi_ui(MsiUi::Basic).msi_ui,
316            MsiUi::Basic
317        );
318        assert_eq!(MsiUi::Passive.flag(), "/passive");
319        assert_eq!(MsiUi::Basic.flag(), "/qb");
320        assert_eq!(MsiUi::Quiet.flag(), "/quiet");
321    }
322}