Skip to main content

gpui_updater_core/
engine.rs

1//! The blocking update orchestrator that ties the pieces together.
2
3mod artifact;
4mod config;
5#[cfg(all(test, feature = "delta", any(target_os = "macos", target_os = "linux")))]
6mod delta_tests;
7#[cfg(feature = "delta")]
8mod hops;
9mod payload;
10#[cfg(test)]
11mod tests;
12
13use std::path::{Path, PathBuf};
14
15use semver::Version;
16
17pub use artifact::Artifact;
18use config::Integrity;
19pub use config::{
20    BundleVersion, CodeSigning, DEFAULT_MAX_DELTA_HOPS, DEFAULT_MAX_DOWNLOAD_SIZE, EngineConfig,
21    Verification, VersionBinding,
22};
23
24use crate::cancel::CancelToken;
25use crate::error::Result;
26use crate::http::{self, Fetch};
27use crate::install::{self, Installed, Ownership, OwnershipHints};
28use crate::release::Release;
29use crate::source::UpdateSource;
30use crate::staging;
31
32/// Blocking update orchestrator: check → download → verify → install.
33///
34/// All methods block; the GPUI integration runs them on a background executor.
35pub struct UpdateEngine<S: UpdateSource> {
36    source: S,
37    config: EngineConfig,
38}
39
40impl<S: UpdateSource> UpdateEngine<S> {
41    /// Build an engine from a source and configuration.
42    pub fn new(source: S, config: EngineConfig) -> Self {
43        Self { source, config }
44    }
45
46    /// The configured current version.
47    pub fn current_version(&self) -> &Version {
48        &self.config.current_version
49    }
50
51    /// Fetch the latest release, returning it only if it is newer than the
52    /// current version (otherwise `Ok(None)`).
53    ///
54    /// # Errors
55    /// Propagates source/network/parse errors. A newer release is rejected
56    /// instead of returned when the [`Verification`] policy cannot be met
57    /// ([`Error::VerificationRequired`](crate::Error::VerificationRequired)),
58    /// or when its artifact or signature would not be fetched over HTTPS
59    /// ([`Error::InsecureUrl`](crate::Error::InsecureUrl)).
60    pub fn check(&self) -> Result<Option<Release>> {
61        let latest = self.source.fetch_latest()?;
62        if latest.version > self.config.current_version {
63            self.config.precheck(&latest)?;
64            ensure_fetchable(&latest)?;
65            Ok(Some(latest))
66        } else {
67            Ok(None)
68        }
69    }
70
71    /// Download the update and verify it according to the configured
72    /// policies. Returns the [`Artifact`] to hand to [`install`](Self::install).
73    ///
74    /// Files are fetched over HTTPS only, into a fresh directory that only the
75    /// current user can access (or [`EngineConfig::download_dir`], where an
76    /// earlier download of the same artifact that still verifies is reused
77    /// instead of fetched again). A download
78    /// larger than the size the release publishes, or than
79    /// [`EngineConfig::max_download_size`], is cut off. The file is then
80    /// checked against its SHA-256 and minisign signature (see
81    /// [`Verification`] and [`VersionBinding`]), and on macOS the app inside
82    /// must declare the release version and carry the expected code signature
83    /// (see [`BundleVersion`] and [`CodeSigning`]).
84    ///
85    /// With the `delta` feature (macOS and Linux), published deltas are tried
86    /// first: the cheapest chain from the current version, if it is smaller
87    /// than the full artifact. Each delta is downloaded, verified under the
88    /// same policies, and applied to the files it expects, which are checked
89    /// against the hashes recorded in the delta. The result is a directory
90    /// holding the rebuilt bundle or executable. If anything in that path
91    /// fails — no usable delta, a modified installation, a bad checksum or
92    /// signature, a network error — the full `release.asset` is downloaded
93    /// instead, so deltas can never make an update fail.
94    ///
95    /// `progress` is called as `(downloaded_bytes, total_bytes)`. After a failed
96    /// delta attempt it restarts from zero for the full download.
97    ///
98    /// To stop a download from another thread, use
99    /// [`download_cancellable`](Self::download_cancellable).
100    ///
101    /// # Errors
102    /// Returns an error on download failure, if verification fails, if the
103    /// policy requires a check the release cannot satisfy
104    /// ([`Error::VerificationRequired`](crate::Error::VerificationRequired)),
105    /// or, before anything is downloaded, if the update could not be
106    /// installed where the app is ([`check_location`](Self::check_location)).
107    pub fn download(
108        &self,
109        release: &Release,
110        progress: impl FnMut(u64, Option<u64>),
111    ) -> Result<Artifact> {
112        self.download_cancellable(release, &CancelToken::new(), progress)
113    }
114
115    /// [`download`](Self::download), stopping with
116    /// [`Error::Cancelled`](crate::Error::Cancelled) once `cancel` is
117    /// cancelled. See [`CancelToken`] for when a cancel takes effect. A
118    /// cancelled download removes its partial file; a delta attempt that is
119    /// cancelled does not fall back to the full download.
120    ///
121    /// # Errors
122    /// As [`download`](Self::download), plus
123    /// [`Error::Cancelled`](crate::Error::Cancelled).
124    pub fn download_cancellable(
125        &self,
126        release: &Release,
127        cancel: &CancelToken,
128        mut progress: impl FnMut(u64, Option<u64>),
129    ) -> Result<Artifact> {
130        cancel.check()?;
131        self.check_location(release)?;
132        let dir = self.download_dir()?;
133        // The name comes from release metadata; keep the file inside `dir`.
134        let artifact = dir.join(staging::safe_file_name(&release.asset.name, "update"));
135        if self.config.download_dir.is_some() {
136            if let Some(len) = self.reusable(&artifact, release) {
137                tracing::info!(path = %artifact.display(), "reusing the verified earlier download");
138                progress(len, Some(len));
139                return Ok(Artifact::verified(artifact));
140            }
141        }
142        #[cfg(feature = "delta")]
143        if let Some(rebuilt) = self.try_deltas(release, &dir, cancel, &mut progress) {
144            match self.check_payload(&rebuilt, release) {
145                Ok(()) => return Ok(Artifact::verified(rebuilt)),
146                Err(e) => {
147                    tracing::warn!(error = %e, "rebuilt update rejected; falling back to the full download");
148                    let _ = std::fs::remove_dir_all(&rebuilt);
149                }
150            }
151        }
152        // A delta attempt that was cancelled must not turn into a full download.
153        cancel.check()?;
154        let fetch = Fetch::new(&release.asset.url)
155            .expect_len(release.asset.size)
156            .max_len(self.config.max_download_size)
157            .cancel(cancel)
158            .http(&self.config.http);
159        http::download(&fetch, &artifact, &mut progress)?;
160        cancel.check()?;
161        self.config
162            .verify_file(&artifact, Integrity::of_release(release), &release.version)?;
163        self.check_payload(&artifact, release)?;
164        Ok(Artifact::verified(artifact))
165    }
166
167    /// The size of `path`, if an earlier download left this release's
168    /// artifact there: a regular file of the published size that passes a
169    /// published checksum or signature and the payload check. A file that
170    /// passes only because nothing was published to check it against is not
171    /// reused.
172    fn reusable(&self, path: &Path, release: &Release) -> Option<u64> {
173        let meta = std::fs::symlink_metadata(path).ok()?;
174        let size = release.asset.size;
175        if !meta.is_file() || (size > 0 && meta.len() != size) {
176            return None;
177        }
178        match self
179            .config
180            .verify_file(path, Integrity::of_release(release), &release.version)
181        {
182            Ok(true) => {}
183            Ok(false) => return None,
184            Err(e) => {
185                tracing::debug!(error = %e, "not reusing the earlier download");
186                return None;
187            }
188        }
189        self.check_payload(path, release).ok()?;
190        Some(meta.len())
191    }
192
193    /// Check the app inside a verified artifact before it is offered for
194    /// install (macOS).
195    fn check_payload(&self, artifact: &Path, release: &Release) -> Result<()> {
196        if self.config.verification.skips_all() {
197            return Ok(());
198        }
199        let installed = self.install_root()?;
200        payload::check(
201            artifact,
202            &payload::Expectations {
203                version: &release.version,
204                installed: &installed,
205                bundle_version: self.config.bundle_version,
206                code_signing: &self.config.code_signing,
207            },
208        )
209    }
210
211    fn download_dir(&self) -> Result<PathBuf> {
212        match &self.config.download_dir {
213            Some(dir) => {
214                staging::ensure_private_dir(dir)?;
215                Ok(dir.clone())
216            }
217            None => Ok(staging::private_temp_dir("gpui-updater")?),
218        }
219    }
220
221    /// Check that `release` could be installed where the app is, without
222    /// downloading anything: see [`install::check_location`].
223    /// [`download`](Self::download) runs this first; call it yourself to tell
224    /// the user before offering the download.
225    ///
226    /// # Errors
227    /// [`Error::Location`](crate::Error::Location) when the app runs from a
228    /// read-only place, such as a disk image or an App Translocation copy, and
229    /// [`Error::Permission`](crate::Error::Permission) when the user may not
230    /// write next to it.
231    pub fn check_location(&self, release: &Release) -> Result<()> {
232        install::check_location(&self.install_root()?, &release.asset.name)
233    }
234
235    fn install_root(&self) -> Result<PathBuf> {
236        match &self.config.install_root {
237            Some(root) => Ok(root.clone()),
238            None => install::current_install_root(),
239        }
240    }
241
242    /// Install a downloaded artifact over the configured install root.
243    ///
244    /// Only an [`Artifact`] returned by [`download`](Self::download) is
245    /// accepted, so nothing reaches the installer without passing the
246    /// engine's checks.
247    ///
248    /// # Errors
249    /// Propagates install failures.
250    pub fn install(&self, artifact: &Artifact) -> Result<Installed> {
251        install::install_with(
252            artifact.path(),
253            &self.install_root()?,
254            &self.config.install_options,
255        )
256    }
257
258    /// Remove what an earlier install left beside the install root because
259    /// the old version was still running, such as the previous `.exe` on
260    /// Windows: see [`install::remove_leftovers`]. Call it at startup.
261    pub fn remove_leftovers(&self) {
262        if let Ok(root) = self.install_root() {
263            install::remove_leftovers(&root);
264        }
265    }
266
267    /// Who keeps the installed app up to date: the app itself, a package
268    /// manager, or the packager. See [`install::ownership`]. When it is not
269    /// [`Ownership::SelfUpdate`], tell the user how to update instead of
270    /// offering to. When the install root is unknown, the app is taken to
271    /// update itself.
272    #[must_use]
273    pub fn ownership(&self, hints: &OwnershipHints) -> Ownership {
274        self.install_root().map_or(Ownership::SelfUpdate, |root| {
275            install::ownership(&root, hints)
276        })
277    }
278
279    /// Run the full pipeline. Returns `Ok(None)` when already up to date.
280    ///
281    /// # Errors
282    /// Propagates any check/download/verify/install error.
283    pub fn update_now(&self, progress: impl FnMut(u64, Option<u64>)) -> Result<Option<Installed>> {
284        let Some(release) = self.check()? else {
285            return Ok(None);
286        };
287        let artifact = self.download(&release, progress)?;
288        Ok(Some(self.install(&artifact)?))
289    }
290}
291
292/// Fail when the release's artifact or detached signature would not be
293/// fetched over HTTPS, so such a release is never offered as available.
294fn ensure_fetchable(release: &Release) -> Result<()> {
295    http::ensure_secure(&release.asset.url)?;
296    match release
297        .signature_url
298        .as_deref()
299        .map(str::trim)
300        .filter(|url| !url.is_empty())
301    {
302        Some(url) => http::ensure_secure(url),
303        None => Ok(()),
304    }
305}