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}