Skip to main content

guise/update/
updater.rs

1//! [`UpdateConfig`] — the gpui-free engine — and [`Updater`], the app-level
2//! object the components and the poller are built from.
3//!
4//! The split is not decoration: the check and the install are blocking work that
5//! runs on gpui's background executor, which requires everything it captures to
6//! be `Send`. [`Updater`] carries `Rc` callbacks (notifications, a pre-restart
7//! hook) and so can never cross that boundary; [`UpdateConfig`] is the plain-data
8//! half that can, and it is what the background task actually gets a copy of.
9
10use std::rc::Rc;
11use std::time::Duration;
12
13use gpui::{App, SharedString};
14
15use super::{InstallKind, Relaunch, Release, UpdateCheck, UpdateSource, UpdateStage};
16
17/// How often to re-check while running (a conservative hourly cadence).
18pub const POLL: Duration = Duration::from_secs(60 * 60);
19
20/// Everything the blocking check and install need, and nothing that can't cross
21/// onto a background thread. Build one through [`Updater`] unless you are driving
22/// the mechanics yourself.
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct UpdateConfig {
25  /// Display name, used in the prompt's copy ("Acme 1.2.3 is available").
26  pub(crate) app: String,
27  /// Filesystem-safe name, used for staging paths under `$TMPDIR`.
28  pub(crate) slug: String,
29  /// The running version, which every check compares against.
30  pub(crate) version: String,
31  /// Where releases are published.
32  pub(crate) source: UpdateSource,
33  /// Sent as `User-Agent` — GitHub's API rejects requests without one.
34  pub(crate) user_agent: String,
35  /// The macOS `codesign` requirement an update must satisfy.
36  pub(crate) requirement: Option<String>,
37  /// Refuse to install when the release publishes no SHA-256 to check the
38  /// download against.
39  pub(crate) require_checksum: bool,
40}
41
42impl UpdateConfig {
43  /// A config for `app` at `version`, checking `source`.
44  pub fn new(app: impl Into<String>, version: impl Into<String>, source: UpdateSource) -> Self {
45    let app = app.into();
46    let slug = slug(&app);
47    let user_agent = format!("{slug}-updater");
48    UpdateConfig {
49      app,
50      slug,
51      version: version.into(),
52      source,
53      user_agent,
54      requirement: None,
55      require_checksum: false,
56    }
57  }
58
59  /// The `codesign` requirement a macOS update must satisfy before it is
60  /// installed — **without** this, macOS installs refuse to run in place and
61  /// the prompt falls back to opening the download page.
62  ///
63  /// Pass the requirement text only; the `-R=` that `codesign` needs is added
64  /// here. Pin your team, not a certificate: team IDs survive certificate
65  /// renewals, so
66  ///
67  /// ```ignore
68  /// .codesign_requirement("anchor apple generic and certificate leaf[subject.OU] = XJDC46F35X")
69  /// ```
70  ///
71  /// keeps working when the signing cert rolls. The practical consequence is
72  /// that an ad-hoc signed build — what CI produces when the signing secrets
73  /// are absent — cannot self-update, and shouldn't be able to.
74  pub fn codesign_requirement(mut self, requirement: impl Into<String>) -> Self {
75    self.requirement = Some(requirement.into());
76    self
77  }
78
79  /// Refuse to install an update whose release publishes no SHA-256.
80  ///
81  /// A published digest is always checked when it exists. This turns a
82  /// *missing* one from a silent pass into a refusal, which is the setting
83  /// you want on Linux: the AppImage path has no signature to fall back on,
84  /// so without a digest the only thing vouching for the file that is about
85  /// to be renamed over the running binary is the feed that named it.
86  ///
87  /// Publish `<asset>.sha256` beside each artifact, or a `SHA256SUMS`
88  /// listing, and turn this on.
89  pub fn require_checksum(mut self, require: bool) -> Self {
90    self.require_checksum = require;
91    self
92  }
93
94  /// Whether a missing checksum blocks an install.
95  pub fn requires_checksum(&self) -> bool {
96    self.require_checksum
97  }
98
99  /// Check a downloaded file against the digest the release published for it.
100  ///
101  /// Absent a published digest this is a pass unless
102  /// [`require_checksum`](Self::require_checksum) is set — a release that
103  /// never shipped checksums shouldn't become uninstallable the day this
104  /// lands.
105  pub(crate) fn verify_checksum(
106    &self,
107    release: &Release,
108    asset: &super::ReleaseAsset,
109    file: &std::path::Path,
110  ) -> Result<(), String> {
111    let Some(published) = release.checksum_for(asset) else {
112      if self.require_checksum {
113        return Err(format!(
114          "this release publishes no SHA-256 for {} — refusing to install it",
115          asset.name
116        ));
117      }
118      return Ok(());
119    };
120    let body = super::fetch::bytes(&published.url, &self.user_agent)
121      .map_err(|e| format!("could not fetch the published checksum: {e}"))?;
122    let body = String::from_utf8_lossy(&body);
123    let expected = super::checksum::find(&body, &asset.name).ok_or_else(|| {
124      format!(
125        "{} does not record a SHA-256 for {}",
126        published.name, asset.name
127      )
128    })?;
129    let actual = super::checksum::of_file(file)?;
130    if !super::checksum::matches(&expected, &actual) {
131      return Err(format!(
132        "{} does not match its published SHA-256 — refusing to install it",
133        asset.name
134      ));
135    }
136    Ok(())
137  }
138
139  /// Override the `User-Agent` sent with every request.
140  pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
141    self.user_agent = user_agent.into();
142    self
143  }
144
145  /// Override the filesystem-safe name used for staging paths.
146  pub fn slug(mut self, slug: impl Into<String>) -> Self {
147    self.slug = slug.into();
148    self
149  }
150
151  /// The app's display name.
152  pub fn app(&self) -> &str {
153    &self.app
154  }
155
156  /// The running version.
157  pub fn version(&self) -> &str {
158    &self.version
159  }
160
161  /// Where releases are published.
162  pub fn source(&self) -> &UpdateSource {
163    &self.source
164  }
165
166  /// How this copy was installed — see [`super::detect`].
167  pub fn install_kind(&self) -> InstallKind {
168    super::detect()
169  }
170
171  /// Fetch the latest release and classify it. **Blocking** (it spawns `curl`)
172  /// — run it on gpui's background executor, or let [`super::start`] and
173  /// [`super::check_now`] do that for you.
174  pub fn check(&self) -> Result<UpdateCheck, String> {
175    super::release::check(
176      &self.source,
177      &self.user_agent,
178      &self.version,
179      &self.install_kind(),
180    )
181  }
182
183  /// Download the release and rewrite this install in place, returning how to
184  /// relaunch. **Blocking** — run it off the UI thread. `on_stage` is called
185  /// from the calling thread as the install progresses, including once per
186  /// download sample.
187  pub fn install(
188    &self,
189    release: &Release,
190    kind: &InstallKind,
191    on_stage: &dyn Fn(UpdateStage),
192  ) -> Result<Relaunch, String> {
193    match kind {
194      InstallKind::MacApp(app) => super::mac::install(self, release, app, on_stage),
195      InstallKind::AppImage(path) => super::appimage::install(self, release, path, on_stage),
196      InstallKind::Unknown => Err("this install can't be updated in place".to_string()),
197    }
198  }
199
200  /// Whether this release can be installed in place — the question the prompt's
201  /// action button asks before it promises anything.
202  ///
203  /// All three halves matter. The install has to be one we can rewrite; the
204  /// release has to have published the asset to do it with (a release still
205  /// uploading its artifacts hasn't); and a macOS install needs a
206  /// [`codesign_requirement`](Self::codesign_requirement) to verify the payload
207  /// against. Any of them missing and the honest button is "Open Download".
208  pub fn can_install(&self, release: &Release, kind: &InstallKind) -> bool {
209    if !kind.is_in_place() || release.asset_for(kind).is_none() {
210      return false;
211    }
212    !matches!(kind, InstallKind::MacApp(_)) || self.requirement.is_some()
213  }
214}
215
216/// The app-level updater: an [`UpdateConfig`] plus the parts only the UI side
217/// needs — the poll cadence, the update window's title, and hooks for posting a
218/// notification and for saving state before the app restarts.
219///
220/// ```ignore
221/// let updater = Updater::github("Acme", env!("CARGO_PKG_VERSION"), "acme/acme")
222///     .codesign_requirement("anchor apple generic and certificate leaf[subject.OU] = XJDC46F35X")
223///     .before_restart(|cx| save_session(cx));
224/// guise::update::start(updater, cx);
225/// ```
226#[derive(Clone)]
227pub struct Updater {
228  config: UpdateConfig,
229  poll: Duration,
230  title: SharedString,
231  notify: Option<NotifyHook>,
232  before_restart: Option<RestartHook>,
233}
234
235/// The app's notification hook, called with `(title, body)`.
236type NotifyHook = Rc<dyn Fn(&str, &str)>;
237
238/// The app's hook for the moment before the restart.
239type RestartHook = Rc<dyn Fn(&mut App)>;
240
241impl Updater {
242  /// An updater for `app` at `version`, checking `source`.
243  pub fn new(app: impl Into<String>, version: impl Into<String>, source: UpdateSource) -> Self {
244    Updater::from_config(UpdateConfig::new(app, version, source))
245  }
246
247  /// An updater reading a GitHub repo's `releases/latest`, given `owner/repo`.
248  pub fn github(
249    app: impl Into<String>,
250    version: impl Into<String>,
251    repo: impl Into<String>,
252  ) -> Self {
253    Updater::new(app, version, UpdateSource::github(repo))
254  }
255
256  /// Wrap an existing [`UpdateConfig`].
257  pub fn from_config(config: UpdateConfig) -> Self {
258    Updater {
259      config,
260      poll: POLL,
261      title: "Software Update".into(),
262      notify: None,
263      before_restart: None,
264    }
265  }
266
267  /// See [`UpdateConfig::require_checksum`] — recommended for Linux installs,
268  /// which have no signature to fall back on.
269  pub fn require_checksum(mut self, require: bool) -> Self {
270    self.config = self.config.require_checksum(require);
271    self
272  }
273
274  /// See [`UpdateConfig::codesign_requirement`] — required for macOS installs.
275  pub fn codesign_requirement(mut self, requirement: impl Into<String>) -> Self {
276    self.config = self.config.codesign_requirement(requirement);
277    self
278  }
279
280  /// Override the `User-Agent` sent with every request.
281  pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
282    self.config = self.config.user_agent(user_agent);
283    self
284  }
285
286  /// Override the filesystem-safe name used for staging paths.
287  pub fn slug(mut self, slug: impl Into<String>) -> Self {
288    self.config = self.config.slug(slug);
289    self
290  }
291
292  /// How often [`super::start`] re-checks while the app runs (default one hour).
293  pub fn poll_every(mut self, every: Duration) -> Self {
294    self.poll = every;
295    self
296  }
297
298  /// Title for the windows [`super::check_now`] opens (default
299  /// "Software Update", the platform convention).
300  pub fn window_title(mut self, title: impl Into<SharedString>) -> Self {
301    self.title = title.into();
302    self
303  }
304
305  /// Called with `(title, body)` when the install starts, finishes without a
306  /// window to restart into, or fails. guise never posts an OS notification
307  /// itself — that needs an app's own bundle identity and permission state.
308  pub fn on_notify(mut self, notify: impl Fn(&str, &str) + 'static) -> Self {
309    self.notify = Some(Rc::new(notify));
310    self
311  }
312
313  /// Called immediately before the app restarts into the new version — the
314  /// place to persist a session, since the restart never goes through the
315  /// normal quit path where an app would usually save.
316  pub fn before_restart(mut self, hook: impl Fn(&mut App) + 'static) -> Self {
317    self.before_restart = Some(Rc::new(hook));
318    self
319  }
320
321  /// The `Send` half, for the background check and install.
322  pub fn config(&self) -> &UpdateConfig {
323    &self.config
324  }
325
326  /// The app's display name.
327  pub fn app(&self) -> &str {
328    self.config.app()
329  }
330
331  /// The running version.
332  pub fn version(&self) -> &str {
333    self.config.version()
334  }
335
336  /// The re-check cadence.
337  pub fn poll(&self) -> Duration {
338    self.poll
339  }
340
341  /// The update window's title.
342  pub fn title(&self) -> &SharedString {
343    &self.title
344  }
345
346  /// Post a notification through the app's hook, if it installed one.
347  pub(crate) fn notify(&self, title: &str, body: &str) {
348    if let Some(notify) = &self.notify {
349      notify(title, body);
350    }
351  }
352
353  /// Run the pre-restart hook, if the app installed one.
354  pub(crate) fn run_before_restart(&self, cx: &mut App) {
355    if let Some(hook) = &self.before_restart {
356      hook(cx);
357    }
358  }
359}
360
361/// A filesystem-safe name derived from the app's display name: lowercase, with
362/// every run of anything else collapsed to a single `-`. Staging paths are built
363/// from this, so a name like "My App 2.0" must not arrive with spaces in it.
364fn slug(app: &str) -> String {
365  let mut out = String::with_capacity(app.len());
366  for ch in app.chars() {
367    if ch.is_ascii_alphanumeric() {
368      out.push(ch.to_ascii_lowercase());
369    } else if !out.ends_with('-') {
370      out.push('-');
371    }
372  }
373  let trimmed = out.trim_matches('-');
374  if trimmed.is_empty() {
375    "app".to_string()
376  } else {
377    trimmed.to_string()
378  }
379}
380
381#[cfg(test)]
382mod tests {
383  use super::*;
384  use crate::update::ReleaseAsset;
385  use std::path::PathBuf;
386
387  fn release(names: &[&str]) -> Release {
388    Release {
389      version: "9.9.9".to_string(),
390      url: "https://acme.dev/releases/9.9.9".to_string(),
391      assets: names
392        .iter()
393        .map(|name| ReleaseAsset {
394          name: name.to_string(),
395          url: format!("https://d/{name}"),
396          size: 1,
397        })
398        .collect(),
399    }
400  }
401
402  fn config() -> UpdateConfig {
403    UpdateConfig::new("Acme", "1.0.0", UpdateSource::github("acme/acme"))
404  }
405
406  #[test]
407  fn slugs_are_path_safe() {
408    assert_eq!(slug("Acme"), "acme");
409    assert_eq!(slug("My App 2.0"), "my-app-2-0");
410    assert_eq!(slug("  Spaced  "), "spaced");
411    assert_eq!(slug("../../etc"), "etc");
412    assert_eq!(slug("🚀"), "app");
413    assert_eq!(slug(""), "app");
414  }
415
416  #[test]
417  fn the_user_agent_defaults_to_the_slug() {
418    assert_eq!(config().user_agent, "acme-updater");
419    assert_eq!(
420      UpdateConfig::new("My App", "1", UpdateSource::github("a/b")).user_agent,
421      "my-app-updater"
422    );
423  }
424
425  /// A macOS install with no requirement configured has nothing to verify the
426  /// payload against, so it must not be offered as an in-place install.
427  #[test]
428  fn macos_needs_a_codesign_requirement_to_be_installable() {
429    let mac = InstallKind::MacApp(PathBuf::from("/Applications/Acme.app"));
430    let dmg = release(&["Acme.dmg"]);
431    assert!(!config().can_install(&dmg, &mac));
432    assert!(config()
433      .codesign_requirement("anchor apple generic")
434      .can_install(&dmg, &mac));
435  }
436
437  /// The AppImage path verifies by architecture match and size, not codesign,
438  /// so it needs no requirement.
439  #[test]
440  fn appimage_is_installable_without_a_requirement() {
441    let image = InstallKind::AppImage(PathBuf::from("/opt/Acme.AppImage"));
442    let asset = format!("Acme-9.9.9-{}.AppImage", std::env::consts::ARCH);
443    assert!(config().can_install(&release(&[&asset]), &image));
444  }
445
446  #[test]
447  fn a_release_without_our_asset_is_not_installable() {
448    let mac = InstallKind::MacApp(PathBuf::from("/Applications/Acme.app"));
449    let config = config().codesign_requirement("anchor apple generic");
450    assert!(!config.can_install(&release(&["Acme.AppImage"]), &mac));
451    assert!(!config.can_install(&release(&[]), &mac));
452  }
453
454  #[test]
455  fn unknown_installs_are_never_installable_in_place() {
456    let config = config().codesign_requirement("anchor apple generic");
457    let every_asset = release(&["Acme.dmg", "Acme.AppImage"]);
458    assert!(!config.can_install(&every_asset, &InstallKind::Unknown));
459    assert!(config
460      .install(&every_asset, &InstallKind::Unknown, &|_| {})
461      .is_err());
462  }
463
464  /// The Linux install path has no signature to fall back on, so a release
465  /// that publishes no digest must be refusable outright.
466  #[test]
467  fn require_checksum_refuses_a_release_with_no_digest() {
468    let release = crate::update::Release {
469      version: "9.9.9".to_string(),
470      url: String::new(),
471      assets: vec![crate::update::ReleaseAsset {
472        name: "Acme.AppImage".to_string(),
473        url: "https://d/a".to_string(),
474        size: 1,
475      }],
476    };
477    let asset = release.assets[0].clone();
478    let path = std::path::Path::new("/nonexistent/Acme.AppImage");
479
480    // Off by default: a project that never shipped checksums keeps working.
481    let lenient = config();
482    assert!(lenient.verify_checksum(&release, &asset, path).is_ok());
483
484    let strict = config().require_checksum(true);
485    assert!(strict.requires_checksum());
486    let err = strict
487      .verify_checksum(&release, &asset, path)
488      .expect_err("a missing digest must block the install");
489    assert!(err.contains("publishes no SHA-256"), "{err}");
490  }
491}