1use 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
33pub 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
91pub 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 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 pub fn target(mut self, target: impl Into<String>) -> Self {
136 self.target = Some(target.into());
137 self
138 }
139
140 pub fn source(mut self, source: Box<dyn ReleaseSource>) -> Self {
145 self.source = Some(source);
146 self
147 }
148
149 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 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 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 pub fn headers(mut self, headers: HeaderMap) -> Self {
185 self.headers = headers;
186 self
187 }
188
189 pub fn clear_headers(mut self) -> Self {
191 self.headers.clear();
192 self
193 }
194
195 pub fn timeout(mut self, timeout: Duration) -> Self {
197 self.timeout = Some(timeout);
198 self
199 }
200
201 pub fn proxy(mut self, proxy: Url) -> Self {
203 self.proxy = Some(proxy);
204 self
205 }
206
207 pub fn no_proxy(mut self) -> Self {
209 self.no_proxy = true;
210 self
211 }
212
213 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 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 pub fn clear_installer_args(mut self) -> Self {
234 self.installer_args.clear();
235 self
236 }
237
238 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
291pub struct Updater {
296 pub app_name: String,
298 pub current_version: Version,
300 pub config: Config,
302 pub target: String,
304 source: Arc<dyn ReleaseSource>,
305 pub headers: HeaderMap,
307 pub timeout: Option<Duration>,
309 pub proxy: Option<Url>,
311 pub no_proxy: bool,
313 pub extract_path: PathBuf,
315 pub installer_args: Vec<OsString>,
317 pub version_comparator: Option<VersionComparator>,
319 latest_release_version: Mutex<Option<Version>>,
320}
321
322impl Updater {
323 pub fn latest_version(&self) -> Option<Version> {
325 self.latest_release_version.lock().ok()?.clone()
326 }
327
328 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 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 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 pub fn install(&self, bytes: impl AsRef<[u8]>) -> Result<()> {
395 self.install_inner(bytes.as_ref())
396 }
397
398 pub fn relaunch(&self) -> Result<()> {
402 self.relaunch_inner()
403 }
404
405 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 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 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 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}