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}