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