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: String,
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: &str,
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: current_version.to_owned(),
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        let current_version = Version::parse(&self.current_version)?;
160
161        Ok(Updater {
162            app_name: self.app_name,
163            current_version,
164            proxy: self.proxy,
165            installer_args: self.installer_args,
166            current_exe_args: self.current_exe_args,
167            headers: self.headers,
168            timeout: self.timeout,
169            extract_path,
170            github_client,
171            latest_release: None,
172            proper_asset: None,
173        })
174    }
175}
176
177#[derive(Debug, Clone)]
178/// Updater instance capable of checking, downloading and installing updates.
179pub struct Updater {
180    pub app_name: String,
181    pub current_version: Version,
182    pub proxy: Option<Url>,
183    pub github_client: GitHubClient,
184    pub headers: HeaderMap,
185    pub extract_path: PathBuf,
186    pub timeout: Option<Duration>,
187    pub installer_args: Vec<OsString>,
188    pub current_exe_args: Vec<OsString>,
189    pub latest_release: Option<GitHubRelease>,
190    pub proper_asset: Option<GitHubAsset>,
191}
192
193impl Updater {
194    /// Fetch the latest GitHub release and convert it into a simplified structure.
195    pub async fn latest_release(&self) -> Result<GitHubRelease> {
196        self.github_client.get_latest_release().await?.try_into()
197    }
198
199    /// The version of the latest release if it has been previously cached on this instance.
200    pub fn latest_version(&self) -> Option<Version> {
201        self.latest_release
202            .as_ref()
203            .map(|release| release.version.clone())
204    }
205
206    /// The size in bytes of the asset selected for this platform, if already resolved.
207    pub fn asset_size(&self) -> Option<u64> {
208        self.proper_asset.as_ref().map(|asset| asset.size)
209    }
210
211    /// Resolve the proper asset for the current OS/arch.
212    pub async fn proper_asset(&self) -> Result<GitHubAsset> {
213        let release = self.latest_release().await?;
214        release.find_proper_asset()
215    }
216
217    /// Check for a newer version. Returns `Ok(Some(Updater))` configured with the
218    /// selected asset if an update is available, or `Ok(None)` if up-to-date.
219    pub async fn check(&self) -> Result<Option<Updater>> {
220        let latest_release = self.latest_release().await?;
221        if latest_release.version > self.current_version {
222            let asset = latest_release.find_proper_asset()?;
223            Ok(Some(Self {
224                latest_release: Some(latest_release),
225                proper_asset: Some(asset),
226                ..self.clone()
227            }))
228        } else {
229            Ok(None)
230        }
231    }
232
233    /// Check for updates and download/install if available.
234    ///
235    /// This is a convenience method that combines [`Updater::check()`] and [`Updater::download_and_install()`].
236    /// Returns `Ok(true)` if an update was found and installed, `Ok(false)` if no update was needed.
237    pub async fn update<C: FnMut(usize)>(
238        &self,
239        on_chunk: C,
240        // on_download_finish: D,
241    ) -> Result<bool> {
242        if let Some(updater) = self.check().await? {
243            updater.download_and_install(on_chunk).await?;
244            Ok(true)
245        } else {
246            Ok(false)
247        }
248    }
249}
250
251impl Updater {
252    /// Downloads the updater package, verifies it then return it as bytes.
253    ///
254    /// Use [`Updater::install`] to install it
255    pub async fn download<C: FnMut(usize)>(
256        &self,
257        mut on_chunk: C,
258        // on_download_finish: D,
259    ) -> Result<Vec<u8>> {
260        // Fallback to reqwest if octocrab is not available
261        let mut headers = self.headers.clone();
262        if !headers.contains_key(ACCEPT) {
263            headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream"));
264        }
265
266        let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT);
267        if let Some(timeout) = self.timeout {
268            request = request.timeout(timeout);
269        }
270        if let Some(ref proxy) = self.proxy {
271            let proxy = reqwest::Proxy::all(proxy.as_str())?;
272            request = request.proxy(proxy);
273        }
274
275        let download_url = self
276            .proper_asset
277            .clone()
278            .ok_or(Error::AssetNotFound)?
279            .browser_download_url
280            .clone();
281
282        let response = request
283            .build()?
284            .get(download_url)
285            .headers(headers)
286            .send()
287            .await?;
288
289        if !response.status().is_success() {
290            return Err(Error::Network(format!(
291                "Download request failed with status: {}",
292                response.status()
293            )));
294        }
295
296        let mut buffer = Vec::new();
297
298        let mut stream = response.bytes_stream();
299        while let Some(chunk) = stream.next().await {
300            let chunk = chunk?;
301            on_chunk(chunk.len());
302            buffer.extend(chunk);
303        }
304        Ok(buffer)
305    }
306
307    /// Installs the updater package downloaded by [`Updater::download`]
308    pub fn install(&self, bytes: impl AsRef<[u8]>) -> Result<()> {
309        self.install_inner(bytes.as_ref())
310    }
311
312    pub fn relaunch(&self) -> Result<()> {
313        self.relaunch_inner()
314    }
315
316    /// Downloads and installs the updater package
317    pub async fn download_and_install<C: FnMut(usize)>(
318        &self,
319        on_chunk: C,
320        // on_download_finish: D,
321    ) -> Result<()> {
322        let bytes = self.download(on_chunk).await?;
323        self.install(bytes)
324    }
325}