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}