Skip to main content

gpui_updater_core/source/
appcast.rs

1//! [`UpdateSource`] backed by a Sparkle appcast.
2//!
3//! An appcast is the RSS 2.0 feed Sparkle (macOS) and WinSparkle (Windows)
4//! read, with metadata in the `sparkle:` XML namespace. Reading it here lets one
5//! feed serve both a Sparkle-driven macOS app and this crate's own engine on
6//! other platforms (or on macOS when Sparkle is not embedded).
7
8mod feed;
9mod host;
10#[cfg(test)]
11mod tests;
12
13use semver::Version;
14
15use super::{UpdateSource, dates};
16use crate::error::{Error, Result};
17use crate::http::{self, HttpOptions, Metadata};
18use crate::release::{Asset, Release, parse_version_lenient, web_page_url};
19
20use feed::{Enclosure, Item};
21use host::{HostFacts, OsMatcher};
22
23/// Which appcast field holds the version that is compared and reported.
24///
25/// Sparkle itself orders updates by `sparkle:version` (the bundle's
26/// `CFBundleVersion`, often a build number) and shows
27/// `sparkle:shortVersionString` (`CFBundleShortVersionString`) to users. This
28/// crate orders by semver, so it reads the field that holds one.
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum AppcastVersion {
32    /// Use `sparkle:shortVersionString`, falling back to `sparkle:version`
33    /// when an item has no short version (Sparkle's own display rule).
34    #[default]
35    ShortVersionString,
36    /// Use `sparkle:version` only. Pick this when your `CFBundleVersion` is
37    /// itself a semantic version.
38    BundleVersion,
39}
40
41impl AppcastVersion {
42    /// Resolve an item's version from its short and bundle version strings.
43    ///
44    /// Both values are parsed with [`parse_version_lenient`], so `1.4` reads
45    /// as `1.4.0`. A present but unparsable value is an error; it does not fall
46    /// back to the other field.
47    ///
48    /// # Errors
49    /// Returns [`Error::Parse`] when the selected field is missing or is not a
50    /// version this crate can order.
51    pub fn resolve(
52        self,
53        short_version: Option<&str>,
54        bundle_version: Option<&str>,
55    ) -> Result<Version> {
56        fn present(v: Option<&str>) -> Option<&str> {
57            v.map(str::trim).filter(|s| !s.is_empty())
58        }
59        let raw = match self {
60            Self::ShortVersionString => present(short_version).or(present(bundle_version)),
61            Self::BundleVersion => present(bundle_version),
62        };
63        let raw = raw.ok_or_else(|| Error::Parse("appcast item has no version".into()))?;
64        parse_version_lenient(raw)
65    }
66}
67
68/// Reads releases from a Sparkle appcast.
69///
70/// The source picks the highest-versioned `<item>` that applies to the running
71/// system, then the best `<enclosure>` in it for this platform (see
72/// [`os`](Self::os)). Items are skipped when they:
73///
74/// - are `sparkle:informationalUpdate` items (nothing to install);
75/// - are on a `sparkle:channel` that was not allowed with
76///   [`channel`](Self::channel);
77/// - list `arm64` in `sparkle:hardwareRequirements` and the machine cannot run
78///   arm64 code;
79/// - fall outside `sparkle:minimumSystemVersion` /
80///   `sparkle:maximumSystemVersion` (checked on macOS, or wherever a
81///   [`system_version`](Self::system_version) is set);
82/// - have a version that is not a version this crate can order (see
83///   [`AppcastVersion`]).
84///
85/// The enclosure's `sparkle:edSignature` becomes
86/// [`Release::ed25519_signature`], so a release verifies against the app's
87/// `SUPublicEDKey` once it is set as
88/// [`EngineConfig::ed25519_public_key`](crate::EngineConfig::ed25519_public_key).
89/// An optional `gpui-updater:sha256` enclosure attribute (namespace
90/// `https://github.com/AprilNEA/gpui-updater`) supplies a checksum for the
91/// [`Checksum`](crate::Verification::Checksum) and
92/// [`Strict`](crate::Verification::Strict) policies.
93///
94/// Phased rollouts, critical updates, `sparkle:minimumAutoupdateVersion`, and
95/// delta enclosures are Sparkle features; this source ignores them and always
96/// offers the full archive.
97///
98/// ```no_run
99/// use gpui_updater_core::{AppcastSource, EngineConfig, Verification};
100/// # use semver::Version;
101///
102/// let source = AppcastSource::new("https://dl.example.com/appcast.xml").channel("beta");
103/// let config = EngineConfig::new(Version::new(1, 2, 0))
104///     .ed25519_public_key("pfIShU4dEXqPd5ObYNfDBiQWcXozk7estwzTnF9BamQ=")
105///     .verification(Verification::Signature);
106/// ```
107#[derive(Debug, Clone)]
108pub struct AppcastSource {
109    url: String,
110    os: OsMatcher,
111    channels: Vec<String>,
112    version_field: AppcastVersion,
113    system_version: Option<Version>,
114    /// Extra headers and the proxy for the appcast.
115    http: HttpOptions,
116}
117
118impl AppcastSource {
119    /// Create a source for the appcast at `url`, matching enclosures for the
120    /// platform this binary was built for.
121    pub fn new(url: impl Into<String>) -> Self {
122        Self {
123            url: url.into(),
124            os: OsMatcher::for_current_platform(),
125            channels: Vec::new(),
126            version_field: AppcastVersion::default(),
127            system_version: None,
128            http: HttpOptions::new(),
129        }
130    }
131
132    /// Fetch the appcast with `http`'s extra headers and proxy.
133    #[must_use]
134    pub fn http(mut self, http: HttpOptions) -> Self {
135        self.http = http;
136        self
137    }
138
139    /// Accept only enclosures whose `sparkle:os` equals `os`
140    /// (case-insensitive). An enclosure without `sparkle:os` counts as
141    /// `macos`, as in Sparkle.
142    ///
143    /// By default the source matches the running platform: on macOS `macos`;
144    /// on Windows WinSparkle's `windows-x64` / `windows-arm64` / `windows-x86`,
145    /// then `windows`, then enclosures without `sparkle:os`; on Linux
146    /// `linux-x64` / `linux-arm64`, then `linux`. Writing `sparkle:os` on
147    /// every enclosure of a shared feed avoids any ambiguity.
148    #[must_use]
149    pub fn os(mut self, os: &str) -> Self {
150        self.os = OsMatcher::exact(os);
151        self
152    }
153
154    /// Also offer items on the `sparkle:channel` named `channel` (exact match).
155    /// Items without a channel are always offered. Call repeatedly to allow
156    /// several channels.
157    #[must_use]
158    pub fn channel(mut self, channel: impl Into<String>) -> Self {
159        self.channels.push(channel.into());
160        self
161    }
162
163    /// Choose which field holds the version. Defaults to
164    /// [`AppcastVersion::ShortVersionString`].
165    #[must_use]
166    pub fn version_field(mut self, field: AppcastVersion) -> Self {
167        self.version_field = field;
168        self
169    }
170
171    /// Compare `sparkle:minimumSystemVersion` / `sparkle:maximumSystemVersion`
172    /// against `version` instead of the detected OS version. Without this, the
173    /// check runs on macOS only (other systems' versions are not comparable).
174    #[must_use]
175    pub fn system_version(mut self, version: Version) -> Self {
176        self.system_version = Some(version);
177        self
178    }
179
180    /// The appcast URL this source fetches.
181    pub fn url(&self) -> &str {
182        &self.url
183    }
184
185    /// Pick the newest applicable release among `items`.
186    fn select(&self, items: Vec<Item>, host: &HostFacts) -> Result<Release> {
187        let mut best: Option<Release> = None;
188        for item in items {
189            let Some(candidate) = self.candidate(item, host) else {
190                continue;
191            };
192            if best.as_ref().is_none_or(|b| candidate.version > b.version) {
193                best = Some(candidate);
194            }
195        }
196        best.ok_or(Error::NoMatchingAsset {
197            target_os: std::env::consts::OS,
198            target_arch: std::env::consts::ARCH,
199        })
200    }
201
202    /// The release `item` offers on this host, or `None` if it does not apply.
203    fn candidate(&self, mut item: Item, host: &HostFacts) -> Option<Release> {
204        if item.informational {
205            return None;
206        }
207        if let Some(channel) = &item.channel {
208            if !self.channels.iter().any(|allowed| allowed == channel) {
209                return None;
210            }
211        }
212        if item.hardware_requirements.iter().any(|r| r == "arm64") && !host.supports_arm64 {
213            return None;
214        }
215        if let Some(system) = &host.system_version {
216            let bound =
217                |v: &Option<String>| v.as_deref().and_then(|v| parse_version_lenient(v).ok());
218            if bound(&item.minimum_system_version).is_some_and(|min| *system < min)
219                || bound(&item.maximum_system_version).is_some_and(|max| *system > max)
220            {
221                return None;
222            }
223        }
224
225        let index = item
226            .enclosures
227            .iter()
228            .enumerate()
229            .filter_map(|(i, e)| self.os.rank(e.os.as_deref()).map(|rank| (rank, i)))
230            .min_by_key(|(rank, _)| *rank)?
231            .1;
232        let enclosure: Enclosure = item.enclosures.swap_remove(index);
233
234        let short = item
235            .short_version
236            .as_deref()
237            .or(enclosure.short_version.as_deref());
238        let bundle = item
239            .bundle_version
240            .as_deref()
241            .or(enclosure.bundle_version.as_deref());
242        let version = match self.version_field.resolve(short, bundle) {
243            Ok(version) => version,
244            Err(e) => {
245                tracing::debug!("skipping appcast item: {e}");
246                return None;
247            }
248        };
249
250        let mut release = Release::new(
251            version,
252            Asset::new(asset_name(&enclosure.url), enclosure.url, enclosure.length),
253        );
254        release.notes = item.description.or(item.release_notes_link);
255        release.ed25519_signature = enclosure.ed_signature;
256        release.sha256 = enclosure.sha256;
257        release.published_at = item.pub_date.as_deref().and_then(dates::rfc2822);
258        release.release_url = web_page_url(item.link.as_deref());
259        Some(release)
260    }
261}
262
263impl UpdateSource for AppcastSource {
264    fn fetch_latest(&self) -> Result<Release> {
265        let xml = http::get_string(&self.url, &[], Metadata::Document, &self.http)?;
266        let items = feed::parse(&xml)?;
267        let needs_arch = items.iter().any(|i| !i.hardware_requirements.is_empty());
268        let host = HostFacts {
269            system_version: self
270                .system_version
271                .clone()
272                .or_else(host::current_system_version),
273            supports_arm64: needs_arch && host::supports_arm64(),
274        };
275        self.select(items, &host)
276    }
277}
278
279/// A safe local file name for the download: the URL's last path segment.
280fn asset_name(url: &str) -> String {
281    let path = url.split(['?', '#']).next().unwrap_or(url);
282    let name = path.rsplit('/').next().unwrap_or_default();
283    if name.is_empty() || name == "." || name == ".." || name.contains('\\') {
284        "update".to_string()
285    } else {
286        name.to_string()
287    }
288}