release_hub/
builder.rs

1// Copyright (c) 2025 BibCiTeX Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3//
4// This file contains code derived from tauri-plugin-updater
5// Original source: https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/updater
6// Copyright (c) 2015 - Present - The Tauri Programme within The Commons Conservancy.
7// Licensed under MIT OR MIT/Apache-2.0
8
9use crate::{
10    Error, GitHubAsset, GitHubClient, GitHubRelease, Result, extract_path_from_executable,
11};
12use futures_util::StreamExt;
13use http::{HeaderName, header::ACCEPT};
14use reqwest::{
15    ClientBuilder,
16    header::{HeaderMap, HeaderValue},
17};
18use semver::Version;
19use std::{
20    env::current_exe,
21    ffi::OsString,
22    path::{Path, PathBuf},
23    time::Duration,
24};
25use url::Url;
26
27const UPDATER_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
28
29// Builder and core updater logic.
30//
31// This module exposes the `UpdaterBuilder` used to configure the updater
32// and the `Updater` type that performs release checks, downloads and
33// installation on supported platforms.
34
35/// Configures and creates an [`Updater`].
36pub struct UpdaterBuilder {
37    app_name: String,
38    github_owner: String,
39    github_repo: String,
40    current_version: Version,
41    executable_path: Option<PathBuf>,
42    headers: HeaderMap,
43    timeout: Option<Duration>,
44    proxy: Option<Url>,
45    installer_args: Vec<OsString>,
46    current_exe_args: Vec<OsString>,
47}
48
49impl UpdaterBuilder {
50    /// Create a new builder.
51    ///
52    /// - `app_name`: Display name used in temp file prefixes and logs.
53    /// - `current_version`: Your app's current semantic version.
54    /// - `github_owner`/`github_repo`: Repository to query releases from.
55    pub fn new(
56        app_name: &str,
57        current_version: Version,
58        github_owner: &str,
59        github_repo: &str,
60    ) -> Self {
61        Self {
62            installer_args: Vec::new(),
63            current_exe_args: Vec::new(),
64            app_name: app_name.to_owned(),
65            current_version,
66            executable_path: None,
67            github_owner: github_owner.to_owned(),
68            github_repo: github_repo.to_owned(),
69            headers: HeaderMap::new(),
70            timeout: None,
71            proxy: None,
72        }
73    }
74
75    /// Override the executable path used to derive install/extract target.
76    pub fn executable_path<P: AsRef<Path>>(mut self, p: P) -> Self {
77        self.executable_path.replace(p.as_ref().into());
78        self
79    }
80
81    /// Add a single HTTP header applied to the download request.
82    pub fn header<K, V>(mut self, key: K, value: V) -> Result<Self>
83    where
84        HeaderName: TryFrom<K>,
85        <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
86        HeaderValue: TryFrom<V>,
87        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
88    {
89        let key: std::result::Result<HeaderName, http::Error> = key.try_into().map_err(Into::into);
90        let value: std::result::Result<HeaderValue, http::Error> =
91            value.try_into().map_err(Into::into);
92        self.headers.insert(key?, value?);
93
94        Ok(self)
95    }
96
97    /// Replace all headers with the provided map.
98    pub fn headers(mut self, headers: HeaderMap) -> Self {
99        self.headers = headers;
100        self
101    }
102
103    /// Remove all configured headers.
104    pub fn clear_headers(mut self) -> Self {
105        self.headers.clear();
106        self
107    }
108
109    /// Set a request timeout for downloads.
110    pub fn timeout(mut self, timeout: Duration) -> Self {
111        self.timeout = Some(timeout);
112        self
113    }
114
115    /// Route network requests through the given proxy.
116    pub fn proxy(mut self, proxy: Url) -> Self {
117        self.proxy.replace(proxy);
118        self
119    }
120
121    /// Append a single argument to the platform installer invocation (if used).
122    pub fn installer_arg<S>(mut self, arg: S) -> Self
123    where
124        S: Into<OsString>,
125    {
126        self.installer_args.push(arg.into());
127        self
128    }
129
130    /// Append multiple installer arguments.
131    pub fn installer_args<I, S>(mut self, args: I) -> Self
132    where
133        I: IntoIterator<Item = S>,
134        S: Into<OsString>,
135    {
136        self.installer_args.extend(args.into_iter().map(Into::into));
137        self
138    }
139
140    /// Clear all installer arguments.
141    pub fn clear_installer_args(mut self) -> Self {
142        self.installer_args.clear();
143        self
144    }
145
146    /// Finalize configuration and create an [`Updater`].
147    pub fn build(self) -> Result<Updater> {
148        let executable_path = self.executable_path.clone().unwrap_or(current_exe()?);
149
150        // Get the extract_path from the provided executable_path
151        let extract_path = if cfg!(target_os = "linux") {
152            executable_path
153        } else {
154            extract_path_from_executable(&executable_path)?
155        };
156
157        let github_client = GitHubClient::new(&self.github_owner, &self.github_repo);
158
159        Ok(Updater {
160            app_name: self.app_name,
161            current_version: self.current_version,
162            proxy: self.proxy,
163            installer_args: self.installer_args,
164            current_exe_args: self.current_exe_args,
165            headers: self.headers,
166            timeout: self.timeout,
167            extract_path,
168            github_client,
169            latest_release: None,
170            proper_asset: None,
171        })
172    }
173}
174
175#[derive(Debug, Clone)]
176/// Updater instance capable of checking, downloading and installing updates.
177pub struct Updater {
178    pub app_name: String,
179    pub current_version: Version,
180    pub proxy: Option<Url>,
181    pub github_client: GitHubClient,
182    pub headers: HeaderMap,
183    pub extract_path: PathBuf,
184    pub timeout: Option<Duration>,
185    pub installer_args: Vec<OsString>,
186    pub current_exe_args: Vec<OsString>,
187    pub latest_release: Option<GitHubRelease>,
188    pub proper_asset: Option<GitHubAsset>,
189}
190
191impl Updater {
192    /// Fetch the latest GitHub release and convert it into a simplified structure.
193    pub async fn latest_release(&self) -> Result<GitHubRelease> {
194        self.github_client.get_latest_release().await?.try_into()
195    }
196
197    /// The version of the latest release if it has been previously cached on this instance.
198    pub fn latest_version(&self) -> Option<Version> {
199        self.latest_release
200            .as_ref()
201            .map(|release| release.version.clone())
202    }
203
204    /// The size in bytes of the asset selected for this platform, if already resolved.
205    pub fn asset_size(&self) -> Option<u64> {
206        self.proper_asset.as_ref().map(|asset| asset.size)
207    }
208
209    /// Resolve the proper asset for the current OS/arch.
210    pub async fn proper_asset(&self) -> Result<GitHubAsset> {
211        let release = self.latest_release().await?;
212        release.find_proper_asset()
213    }
214
215    /// Check for a newer version. Returns `Ok(Some(Updater))` configured with the
216    /// selected asset if an update is available, or `Ok(None)` if up-to-date.
217    pub async fn check(&self) -> Result<Option<Updater>> {
218        let latest_release = self.latest_release().await?;
219        if latest_release.version > self.current_version {
220            let asset = latest_release.find_proper_asset()?;
221            Ok(Some(Self {
222                latest_release: Some(latest_release),
223                proper_asset: Some(asset),
224                ..self.clone()
225            }))
226        } else {
227            Ok(None)
228        }
229    }
230
231    /// Check for updates and download/install if available.
232    ///
233    /// This is a convenience method that combines [`Updater::check()`] and [`Updater::download_and_install()`].
234    /// Returns `Ok(true)` if an update was found and installed, `Ok(false)` if no update was needed.
235    pub async fn update<C: FnMut(usize)>(
236        &self,
237        on_chunk: C,
238        // on_download_finish: D,
239    ) -> Result<bool> {
240        if let Some(updater) = self.check().await? {
241            updater.download_and_install(on_chunk).await?;
242            Ok(true)
243        } else {
244            Ok(false)
245        }
246    }
247}
248
249impl Updater {
250    /// Downloads the updater package, verifies it then return it as bytes.
251    ///
252    /// Use [`Updater::install`] to install it
253    pub async fn download<C: FnMut(usize)>(
254        &self,
255        mut on_chunk: C,
256        // on_download_finish: D,
257    ) -> Result<Vec<u8>> {
258        // Fallback to reqwest if octocrab is not available
259        let mut headers = self.headers.clone();
260        if !headers.contains_key(ACCEPT) {
261            headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream"));
262        }
263
264        let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT);
265        if let Some(timeout) = self.timeout {
266            request = request.timeout(timeout);
267        }
268        if let Some(ref proxy) = self.proxy {
269            let proxy = reqwest::Proxy::all(proxy.as_str())?;
270            request = request.proxy(proxy);
271        }
272
273        let download_url = self
274            .proper_asset
275            .clone()
276            .ok_or(Error::AssetNotFound)?
277            .browser_download_url
278            .clone();
279
280        let response = request
281            .build()?
282            .get(download_url)
283            .headers(headers)
284            .send()
285            .await?;
286
287        if !response.status().is_success() {
288            return Err(Error::Network(format!(
289                "Download request failed with status: {}",
290                response.status()
291            )));
292        }
293
294        let mut buffer = Vec::new();
295
296        let mut stream = response.bytes_stream();
297        while let Some(chunk) = stream.next().await {
298            let chunk = chunk?;
299            on_chunk(chunk.len());
300            buffer.extend(chunk);
301        }
302        Ok(buffer)
303    }
304
305    /// Installs the updater package downloaded by [`Updater::download`]
306    pub fn install(&self, bytes: impl AsRef<[u8]>) -> Result<()> {
307        self.install_inner(bytes.as_ref())
308    }
309
310    pub fn relaunch(&self) -> Result<()> {
311        self.relaunch_inner()
312    }
313
314    /// Downloads and installs the updater package
315    pub async fn download_and_install<C: FnMut(usize)>(
316        &self,
317        on_chunk: C,
318        // on_download_finish: D,
319    ) -> Result<()> {
320        let bytes = self.download(on_chunk).await?;
321        self.install(bytes)
322    }
323}