Skip to main content

release_hub/
builder.rs

1//! Builder and runtime updater APIs.
2
3// Copyright (c) 2025 BibCiTeX Contributors
4// SPDX-License-Identifier: MIT OR Apache-2.0
5//
6// This file contains code derived from tauri-plugin-updater
7// Original source: https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/updater
8// Copyright (c) 2015 - Present - The Tauri Programme within The Commons Conservancy.
9// Licensed under MIT OR MIT/Apache-2.0
10
11use crate::{
12    Config, EndpointSource, Error, InstallerKind, ReleaseSource, Result, SourceRequest, TargetInfo,
13    Update, extract_path_from_executable,
14};
15use http::header::ACCEPT;
16use http::{
17    HeaderName,
18    header::{HeaderMap, HeaderValue},
19};
20use reqwest::ClientBuilder;
21use semver::Version;
22use std::{
23    env::current_exe,
24    ffi::OsString,
25    path::{Path, PathBuf},
26    sync::{Arc, Mutex},
27    time::Duration,
28};
29use url::Url;
30
31const UPDATER_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
32
33/// Custom version comparator used to override the default semver `>` update check.
34///
35/// The closure receives the current application version and the fetched remote
36/// release model. Return `true` to treat the release as an update.
37pub type VersionComparator =
38    Arc<dyn Fn(Version, crate::RemoteRelease) -> bool + Send + Sync + 'static>;
39
40#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
41pub(crate) fn windows_installer_args_command_line(args: &[OsString]) -> Option<String> {
42    if args.is_empty() {
43        None
44    } else {
45        Some(
46            args.iter()
47                .map(windows_quote_installer_arg)
48                .collect::<Vec<_>>()
49                .join(" "),
50        )
51    }
52}
53
54#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
55fn windows_quote_installer_arg(arg: &OsString) -> String {
56    let arg = arg.to_string_lossy();
57    if !arg.is_empty() && !arg.contains([' ', '\t', '"']) {
58        return arg.into_owned();
59    }
60
61    let mut quoted = String::from("\"");
62    let mut backslashes = 0usize;
63    for ch in arg.chars() {
64        match ch {
65            '\\' => backslashes += 1,
66            '"' => {
67                quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
68                quoted.push('"');
69                backslashes = 0;
70            }
71            _ => {
72                quoted.push_str(&"\\".repeat(backslashes));
73                quoted.push(ch);
74                backslashes = 0;
75            }
76        }
77    }
78    quoted.push_str(&"\\".repeat(backslashes * 2));
79    quoted.push('"');
80    quoted
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum InstallAction {
85    MacosArchive,
86    WindowsExecutableLaunch,
87    LinuxAppImageReplace,
88    LinuxPackageCommand,
89}
90
91/// Configures and creates an [`Updater`].
92///
93/// This builder is the main integration point for application code. It merges
94/// static [`Config`] values with per-instance overrides such as a custom
95/// [`ReleaseSource`], request headers, proxy settings, and installer arguments.
96pub struct UpdaterBuilder {
97    app_name: String,
98    current_version: Version,
99    config: Config,
100    target: Option<String>,
101    source: Option<Box<dyn ReleaseSource>>,
102    headers: HeaderMap,
103    timeout: Option<Duration>,
104    proxy: Option<Url>,
105    no_proxy: bool,
106    executable_path: Option<PathBuf>,
107    installer_args: Vec<OsString>,
108    version_comparator: Option<VersionComparator>,
109}
110
111impl UpdaterBuilder {
112    /// Creates a new updater builder from application metadata and static configuration.
113    ///
114    /// `current_version` must be a valid semantic version string.
115    pub fn new(app_name: &str, current_version: &str, config: Config) -> Self {
116        Self {
117            app_name: app_name.to_owned(),
118            current_version: Version::parse(current_version).expect("valid semver"),
119            config,
120            target: None,
121            source: None,
122            headers: HeaderMap::new(),
123            timeout: None,
124            proxy: None,
125            no_proxy: false,
126            executable_path: None,
127            installer_args: Vec::new(),
128            version_comparator: None,
129        }
130    }
131
132    /// Overrides the detected target string used when fetching release metadata.
133    ///
134    /// Target strings usually look like `linux-x86_64` or `darwin-aarch64`.
135    pub fn target(mut self, target: impl Into<String>) -> Self {
136        self.target = Some(target.into());
137        self
138    }
139
140    /// Sets a custom release source implementation.
141    ///
142    /// When omitted, the builder falls back to [`EndpointSource`] using
143    /// [`Config::endpoints`].
144    pub fn source(mut self, source: Box<dyn ReleaseSource>) -> Self {
145        self.source = Some(source);
146        self
147    }
148
149    /// Overrides the default version comparison logic.
150    ///
151    /// By default, `release-hub` treats `remote.version > current_version` as
152    /// an update. Provide a comparator here when you need custom channels or
153    /// policies.
154    pub fn version_comparator<F>(mut self, comparator: F) -> Self
155    where
156        F: Fn(Version, crate::RemoteRelease) -> bool + Send + Sync + 'static,
157    {
158        self.version_comparator = Some(Arc::new(comparator));
159        self
160    }
161
162    /// Overrides the executable path used to derive the install target.
163    pub fn executable_path<P: AsRef<Path>>(mut self, p: P) -> Self {
164        self.executable_path.replace(p.as_ref().into());
165        self
166    }
167
168    /// Adds a single HTTP header to release-fetch and download requests.
169    pub fn header<K, V>(mut self, key: K, value: V) -> Result<Self>
170    where
171        HeaderName: TryFrom<K>,
172        <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
173        HeaderValue: TryFrom<V>,
174        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
175    {
176        let key: std::result::Result<HeaderName, http::Error> = key.try_into().map_err(Into::into);
177        let value: std::result::Result<HeaderValue, http::Error> =
178            value.try_into().map_err(Into::into);
179        self.headers.insert(key?, value?);
180        Ok(self)
181    }
182
183    /// Replaces all configured HTTP headers.
184    pub fn headers(mut self, headers: HeaderMap) -> Self {
185        self.headers = headers;
186        self
187    }
188
189    /// Removes all configured HTTP headers.
190    pub fn clear_headers(mut self) -> Self {
191        self.headers.clear();
192        self
193    }
194
195    /// Sets a timeout for release-fetch and download HTTP requests.
196    pub fn timeout(mut self, timeout: Duration) -> Self {
197        self.timeout = Some(timeout);
198        self
199    }
200
201    /// Configures a proxy for release-fetch and download requests.
202    pub fn proxy(mut self, proxy: Url) -> Self {
203        self.proxy = Some(proxy);
204        self
205    }
206
207    /// Disables proxy usage for release-fetch and download requests.
208    pub fn no_proxy(mut self) -> Self {
209        self.no_proxy = true;
210        self
211    }
212
213    /// Appends a single Windows installer argument.
214    pub fn installer_arg<S>(mut self, arg: S) -> Self
215    where
216        S: Into<OsString>,
217    {
218        self.installer_args.push(arg.into());
219        self
220    }
221
222    /// Appends multiple Windows installer arguments.
223    pub fn installer_args<I, S>(mut self, args: I) -> Self
224    where
225        I: IntoIterator<Item = S>,
226        S: Into<OsString>,
227    {
228        self.installer_args.extend(args.into_iter().map(Into::into));
229        self
230    }
231
232    /// Clears builder-provided Windows installer arguments.
233    pub fn clear_installer_args(mut self) -> Self {
234        self.installer_args.clear();
235        self
236    }
237
238    /// Builds an [`Updater`] from the accumulated configuration.
239    ///
240    /// This validates the static config, resolves the effective target and
241    /// install path, and materializes either the custom release source or the
242    /// default endpoint-backed source.
243    pub fn build(self) -> Result<Updater> {
244        self.config.validate()?;
245
246        if self.source.is_none() && self.config.endpoints.is_empty() {
247            return Err(Error::Network("no endpoints configured".into()));
248        }
249
250        let target = match self.target {
251            Some(target) => target,
252            None => TargetInfo::from_system(crate::SystemInfo::current()?).target,
253        };
254        let source = match self.source {
255            Some(source) => Arc::<dyn ReleaseSource>::from(source),
256            None => Arc::new(EndpointSource::new(self.config.endpoints.clone())),
257        };
258
259        let executable_path = self.executable_path.unwrap_or(current_exe()?);
260        let extract_path = if cfg!(target_os = "linux") {
261            executable_path
262        } else {
263            extract_path_from_executable(&executable_path)?
264        };
265        let mut installer_args = self
266            .config
267            .windows
268            .as_ref()
269            .map(|windows| windows.installer_args.clone())
270            .unwrap_or_default();
271        installer_args.extend(self.installer_args);
272
273        Ok(Updater {
274            app_name: self.app_name,
275            current_version: self.current_version,
276            config: self.config,
277            target,
278            source,
279            headers: self.headers,
280            timeout: self.timeout,
281            proxy: self.proxy,
282            no_proxy: self.no_proxy,
283            extract_path,
284            installer_args,
285            version_comparator: self.version_comparator,
286            latest_release_version: Mutex::new(None),
287        })
288    }
289}
290
291/// Updater instance capable of checking, downloading and installing updates.
292///
293/// Instances are cheap to reuse and keep the last successfully observed remote
294/// version for introspection through [`Self::latest_version`].
295pub struct Updater {
296    /// Application name used by platform backends and staging paths.
297    pub app_name: String,
298    /// Current application version.
299    pub current_version: Version,
300    /// Static updater configuration.
301    pub config: Config,
302    /// Selected target string.
303    pub target: String,
304    source: Arc<dyn ReleaseSource>,
305    /// HTTP headers propagated to update downloads.
306    pub headers: HeaderMap,
307    /// Optional download timeout.
308    pub timeout: Option<Duration>,
309    /// Optional proxy configuration.
310    pub proxy: Option<Url>,
311    /// Whether proxy configuration should be ignored.
312    pub no_proxy: bool,
313    /// Derived installation target path.
314    pub extract_path: PathBuf,
315    /// Windows installer arguments propagated from config and builder overrides.
316    pub installer_args: Vec<OsString>,
317    /// Optional custom version comparator.
318    pub version_comparator: Option<VersionComparator>,
319    latest_release_version: Mutex<Option<Version>>,
320}
321
322impl Updater {
323    /// Returns the latest remote version observed by the last successful [`Self::check`] call.
324    pub fn latest_version(&self) -> Option<Version> {
325        self.latest_release_version.lock().ok()?.clone()
326    }
327
328    /// Fetches release metadata and returns an [`Update`] when a newer version is available.
329    ///
330    /// The returned [`Update`] is already narrowed to the current target and
331    /// contains the resolved installer URL, signature, and install strategy.
332    pub async fn check(&self) -> Result<Option<Update>> {
333        let request = SourceRequest::new(self.target.clone());
334        let release = self.source.fetch(&request).await?;
335        let mut headers = release.download_headers.clone();
336        headers.extend(self.headers.clone());
337        if let Ok(mut latest_release_version) = self.latest_release_version.lock() {
338            *latest_release_version = Some(release.version.clone());
339        }
340
341        let has_update = if let Some(comparator) = &self.version_comparator {
342            comparator(self.current_version.clone(), release.clone())
343        } else {
344            release.version > self.current_version
345        };
346        if !has_update {
347            return Ok(None);
348        }
349
350        Ok(Some(Update {
351            current_version: self.current_version.clone(),
352            version: release.version.clone(),
353            date: release.pub_date,
354            body: release.notes.clone(),
355            raw_json: serde_json::to_value(&release)?,
356            download_url: release.download_url(&self.target)?.clone(),
357            signature: release.signature(&self.target)?.clone(),
358            pubkey: self.config.pubkey.clone(),
359            target: self.target.clone(),
360            installer_kind: InstallerKind::from_path(Path::new(
361                release.download_url(&self.target)?.path(),
362            ))?,
363            headers,
364            timeout: self.timeout,
365            proxy: self.proxy.clone(),
366            no_proxy: self.no_proxy,
367            dangerous_accept_invalid_certs: self.config.dangerous_accept_invalid_certs,
368            dangerous_accept_invalid_hostnames: self.config.dangerous_accept_invalid_hostnames,
369            extract_path: self.extract_path.clone(),
370            app_name: self.app_name.clone(),
371            installer_args: self.installer_args.clone(),
372        }))
373    }
374
375    /// Convenience helper that checks for an update and downloads/installs it when present.
376    ///
377    /// Returns `Ok(true)` when an update was found and installed, or `Ok(false)`
378    /// when the current version is already up to date.
379    pub async fn update<C: FnMut(usize)>(&self, on_chunk: C) -> Result<bool> {
380        if let Some(update) = self.check().await? {
381            update.download_and_install(on_chunk).await?;
382            Ok(true)
383        } else {
384            Ok(false)
385        }
386    }
387
388    /// Downloads the updater package for an [`Update`] and returns it as bytes.
389    pub async fn download<C: FnMut(usize)>(&self, update: &Update, on_chunk: C) -> Result<Vec<u8>> {
390        update.download(on_chunk).await
391    }
392
393    /// Installs artifact bytes previously returned by [`Updater::download`].
394    pub fn install(&self, bytes: impl AsRef<[u8]>) -> Result<()> {
395        self.install_inner(bytes.as_ref())
396    }
397
398    /// Relaunches the application using the current platform backend.
399    ///
400    /// Relaunch support is currently implemented on macOS and Windows.
401    pub fn relaunch(&self) -> Result<()> {
402        self.relaunch_inner()
403    }
404
405    /// Convenience helper that downloads and installs a specific [`Update`].
406    pub async fn download_and_install<C: FnMut(usize)>(
407        &self,
408        update: &Update,
409        on_chunk: C,
410    ) -> Result<()> {
411        update.download_and_install(on_chunk).await
412    }
413}
414
415impl Update {
416    fn install_action(&self) -> InstallAction {
417        match self.installer_kind {
418            InstallerKind::AppTarGz | InstallerKind::AppZip => InstallAction::MacosArchive,
419            InstallerKind::Msi | InstallerKind::Nsis => InstallAction::WindowsExecutableLaunch,
420            InstallerKind::AppImage => InstallAction::LinuxAppImageReplace,
421            InstallerKind::Deb | InstallerKind::Rpm => InstallAction::LinuxPackageCommand,
422        }
423    }
424
425    /// Downloads the selected artifact and verifies its detached minisign signature.
426    ///
427    /// The chunk callback receives the total number of bytes currently fetched
428    /// for this download operation.
429    pub async fn download<C>(&self, mut on_chunk: C) -> Result<Vec<u8>>
430    where
431        C: FnMut(usize),
432    {
433        let mut headers = self.headers.clone();
434        if !headers.contains_key(ACCEPT) {
435            headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream"));
436        }
437
438        let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT);
439        if self.dangerous_accept_invalid_certs {
440            request = request.danger_accept_invalid_certs(true);
441        }
442        if self.dangerous_accept_invalid_hostnames {
443            request = request.danger_accept_invalid_hostnames(true);
444        }
445        if let Some(timeout) = self.timeout {
446            request = request.timeout(timeout);
447        }
448        if self.no_proxy {
449            request = request.no_proxy();
450        } else if let Some(ref proxy) = self.proxy {
451            let proxy = reqwest::Proxy::all(proxy.as_str())?;
452            request = request.proxy(proxy);
453        }
454
455        let response = request
456            .build()?
457            .get(self.download_url.clone())
458            .headers(headers)
459            .send()
460            .await?;
461        if !response.status().is_success() {
462            return Err(Error::Network(format!(
463                "Download request failed with status: {}",
464                response.status()
465            )));
466        }
467
468        let bytes = response.bytes().await?;
469        on_chunk(bytes.len());
470        crate::verify_minisign(&bytes, &self.pubkey, &self.signature)?;
471        Ok(bytes.to_vec())
472    }
473
474    /// Installs already-downloaded artifact bytes using the selected platform backend.
475    pub fn install(&self, bytes: &[u8]) -> Result<()> {
476        match self.install_action() {
477            InstallAction::MacosArchive => self.install_macos(bytes),
478            InstallAction::WindowsExecutableLaunch => self.install_windows(bytes),
479            InstallAction::LinuxAppImageReplace | InstallAction::LinuxPackageCommand => {
480                self.install_linux(bytes)
481            }
482        }
483    }
484
485    /// Downloads, verifies, and installs the selected update in one step.
486    pub async fn download_and_install<C>(&self, on_chunk: C) -> Result<()>
487    where
488        C: FnMut(usize),
489    {
490        let bytes = self.download(on_chunk).await?;
491        self.install(&bytes)
492    }
493}
494
495#[cfg(not(target_os = "macos"))]
496impl Update {
497    pub(crate) fn install_macos(&self, _bytes: &[u8]) -> Result<()> {
498        Err(Error::UnsupportedOs)
499    }
500}
501
502#[cfg(not(target_os = "windows"))]
503impl Update {
504    pub(crate) fn install_windows(&self, _bytes: &[u8]) -> Result<()> {
505        Err(Error::UnsupportedOs)
506    }
507}
508
509#[cfg(not(any(target_os = "macos", target_os = "windows")))]
510impl Updater {
511    pub(crate) fn install_inner(&self, _bytes: &[u8]) -> Result<()> {
512        Err(Error::UnsupportedOs)
513    }
514
515    pub(crate) fn relaunch_inner(&self) -> Result<()> {
516        Err(Error::UnsupportedOs)
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use http::HeaderMap;
524    use std::ffi::OsString;
525
526    fn test_update(installer_kind: InstallerKind) -> Update {
527        Update {
528            current_version: Version::parse("1.0.0").unwrap(),
529            version: Version::parse("1.0.1").unwrap(),
530            date: None,
531            body: None,
532            raw_json: serde_json::json!({}),
533            download_url: Url::parse("https://example.com/release-hub.AppImage").unwrap(),
534            signature: String::new(),
535            pubkey: String::new(),
536            target: "linux-x86_64".into(),
537            installer_kind,
538            headers: HeaderMap::new(),
539            timeout: None,
540            proxy: None,
541            no_proxy: false,
542            dangerous_accept_invalid_certs: false,
543            dangerous_accept_invalid_hostnames: false,
544            extract_path: PathBuf::from("/tmp/release-hub"),
545            app_name: "ReleaseHub".into(),
546            installer_args: Vec::new(),
547        }
548    }
549
550    #[test]
551    fn windows_installers_use_launch_route() {
552        assert_eq!(
553            test_update(InstallerKind::Msi).install_action(),
554            InstallAction::WindowsExecutableLaunch
555        );
556        assert_eq!(
557            test_update(InstallerKind::Nsis).install_action(),
558            InstallAction::WindowsExecutableLaunch
559        );
560    }
561
562    #[test]
563    fn windows_installer_args_build_expected_command_line() {
564        let args = vec![
565            OsString::from("/quiet"),
566            OsString::from("C:\\Program Files\\Release Hub"),
567            OsString::from("quote\"here"),
568        ];
569
570        assert_eq!(
571            windows_installer_args_command_line(&args),
572            Some(String::from(
573                "/quiet \"C:\\Program Files\\Release Hub\" \"quote\\\"here\""
574            ))
575        );
576    }
577}