tauri_plugin_updater/
updater.rs

1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5use std::{
6    collections::HashMap,
7    ffi::OsString,
8    io::Cursor,
9    path::{Path, PathBuf},
10    str::FromStr,
11    sync::Arc,
12    time::Duration,
13};
14
15#[cfg(not(target_os = "macos"))]
16use std::ffi::OsStr;
17
18use base64::Engine;
19use futures_util::StreamExt;
20use http::{header::ACCEPT, HeaderName};
21use minisign_verify::{PublicKey, Signature};
22use percent_encoding::{AsciiSet, CONTROLS};
23use reqwest::{
24    header::{HeaderMap, HeaderValue},
25    ClientBuilder, StatusCode,
26};
27use semver::Version;
28use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize};
29use tauri::{utils::platform::current_exe, AppHandle, Resource, Runtime};
30use time::OffsetDateTime;
31use url::Url;
32
33use crate::{
34    error::{Error, Result},
35    Config,
36};
37
38const UPDATER_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
39
40#[derive(Debug, Deserialize, Serialize, Clone)]
41pub struct ReleaseManifestPlatform {
42    /// Download URL for the platform
43    pub url: Url,
44    /// Signature for the platform
45    pub signature: String,
46}
47
48#[derive(Debug, Deserialize, Serialize, Clone)]
49#[serde(untagged)]
50pub enum RemoteReleaseInner {
51    Dynamic(ReleaseManifestPlatform),
52    Static {
53        platforms: HashMap<String, ReleaseManifestPlatform>,
54    },
55}
56
57/// Information about a release returned by the remote update server.
58///
59/// This type can have one of two shapes: Server Format (Dynamic Format) and Static Format.
60#[derive(Debug, Clone)]
61pub struct RemoteRelease {
62    /// Version to install.
63    pub version: Version,
64    /// Release notes.
65    pub notes: Option<String>,
66    /// Release date.
67    pub pub_date: Option<OffsetDateTime>,
68    /// Release data.
69    pub data: RemoteReleaseInner,
70}
71
72impl RemoteRelease {
73    /// The release's download URL for the given target.
74    pub fn download_url(&self, target: &str) -> Result<&Url> {
75        match self.data {
76            RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.url),
77            RemoteReleaseInner::Static { ref platforms } => platforms
78                .get(target)
79                .map_or(Err(Error::TargetNotFound(target.to_string())), |p| {
80                    Ok(&p.url)
81                }),
82        }
83    }
84
85    /// The release's signature for the given target.
86    pub fn signature(&self, target: &str) -> Result<&String> {
87        match self.data {
88            RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.signature),
89            RemoteReleaseInner::Static { ref platforms } => platforms
90                .get(target)
91                .map_or(Err(Error::TargetNotFound(target.to_string())), |platform| {
92                    Ok(&platform.signature)
93                }),
94        }
95    }
96}
97
98pub type OnBeforeExit = Arc<dyn Fn() + Send + Sync + 'static>;
99pub type OnBeforeRequest = Arc<dyn Fn(ClientBuilder) -> ClientBuilder + Send + Sync + 'static>;
100pub type VersionComparator = Arc<dyn Fn(Version, RemoteRelease) -> bool + Send + Sync>;
101type MainThreadClosure = Box<dyn FnOnce() + Send + Sync + 'static>;
102type RunOnMainThread =
103    Box<dyn Fn(MainThreadClosure) -> std::result::Result<(), tauri::Error> + Send + Sync + 'static>;
104
105pub struct UpdaterBuilder {
106    #[allow(dead_code)]
107    run_on_main_thread: RunOnMainThread,
108    app_name: String,
109    current_version: Version,
110    config: Config,
111    pub(crate) version_comparator: Option<VersionComparator>,
112    executable_path: Option<PathBuf>,
113    target: Option<String>,
114    endpoints: Option<Vec<Url>>,
115    headers: HeaderMap,
116    timeout: Option<Duration>,
117    proxy: Option<Url>,
118    installer_args: Vec<OsString>,
119    current_exe_args: Vec<OsString>,
120    on_before_exit: Option<OnBeforeExit>,
121    configure_client: Option<OnBeforeRequest>,
122}
123
124impl UpdaterBuilder {
125    pub(crate) fn new<R: Runtime>(app: &AppHandle<R>, config: crate::Config) -> Self {
126        let app_ = app.clone();
127        let run_on_main_thread = move |f| app_.run_on_main_thread(f);
128        Self {
129            run_on_main_thread: Box::new(run_on_main_thread),
130            installer_args: config
131                .windows
132                .as_ref()
133                .map(|w| w.installer_args.clone())
134                .unwrap_or_default(),
135            current_exe_args: Vec::new(),
136            app_name: app.package_info().name.clone(),
137            current_version: app.package_info().version.clone(),
138            config,
139            version_comparator: None,
140            executable_path: None,
141            target: None,
142            endpoints: None,
143            headers: Default::default(),
144            timeout: None,
145            proxy: None,
146            on_before_exit: None,
147            configure_client: None,
148        }
149    }
150
151    pub fn version_comparator<F: Fn(Version, RemoteRelease) -> bool + Send + Sync + 'static>(
152        mut self,
153        f: F,
154    ) -> Self {
155        self.version_comparator = Some(Arc::new(f));
156        self
157    }
158
159    pub fn target(mut self, target: impl Into<String>) -> Self {
160        self.target.replace(target.into());
161        self
162    }
163
164    pub fn endpoints(mut self, endpoints: Vec<Url>) -> Result<Self> {
165        crate::config::validate_endpoints(
166            &endpoints,
167            self.config.dangerous_insecure_transport_protocol,
168        )?;
169
170        self.endpoints.replace(endpoints);
171        Ok(self)
172    }
173
174    pub fn executable_path<P: AsRef<Path>>(mut self, p: P) -> Self {
175        self.executable_path.replace(p.as_ref().into());
176        self
177    }
178
179    pub fn header<K, V>(mut self, key: K, value: V) -> Result<Self>
180    where
181        HeaderName: TryFrom<K>,
182        <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
183        HeaderValue: TryFrom<V>,
184        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
185    {
186        let key: std::result::Result<HeaderName, http::Error> = key.try_into().map_err(Into::into);
187        let value: std::result::Result<HeaderValue, http::Error> =
188            value.try_into().map_err(Into::into);
189        self.headers.insert(key?, value?);
190
191        Ok(self)
192    }
193
194    pub fn headers(mut self, headers: HeaderMap) -> Self {
195        self.headers = headers;
196        self
197    }
198
199    pub fn clear_headers(mut self) -> Self {
200        self.headers.clear();
201        self
202    }
203
204    pub fn timeout(mut self, timeout: Duration) -> Self {
205        self.timeout = Some(timeout);
206        self
207    }
208
209    pub fn proxy(mut self, proxy: Url) -> Self {
210        self.proxy.replace(proxy);
211        self
212    }
213
214    pub fn pubkey<S: Into<String>>(mut self, pubkey: S) -> Self {
215        self.config.pubkey = pubkey.into();
216        self
217    }
218
219    pub fn installer_arg<S>(mut self, arg: S) -> Self
220    where
221        S: Into<OsString>,
222    {
223        self.installer_args.push(arg.into());
224        self
225    }
226
227    pub fn installer_args<I, S>(mut self, args: I) -> Self
228    where
229        I: IntoIterator<Item = S>,
230        S: Into<OsString>,
231    {
232        self.installer_args.extend(args.into_iter().map(Into::into));
233        self
234    }
235
236    pub fn clear_installer_args(mut self) -> Self {
237        self.installer_args.clear();
238        self
239    }
240
241    pub fn on_before_exit<F: Fn() + Send + Sync + 'static>(mut self, f: F) -> Self {
242        self.on_before_exit.replace(Arc::new(f));
243        self
244    }
245
246    /// Allows you to modify the `reqwest` client builder before the HTTP request is sent.
247    ///
248    /// Note that `reqwest` crate may be updated in minor releases of tauri-plugin-updater.
249    /// Therefore it's recommended to pin the plugin to at least a minor version when you're using `configure_client`.
250    ///
251    pub fn configure_client<F: Fn(ClientBuilder) -> ClientBuilder + Send + Sync + 'static>(
252        mut self,
253        f: F,
254    ) -> Self {
255        self.configure_client.replace(Arc::new(f));
256        self
257    }
258
259    pub fn build(self) -> Result<Updater> {
260        let endpoints = self
261            .endpoints
262            .unwrap_or_else(|| self.config.endpoints.clone());
263
264        if endpoints.is_empty() {
265            return Err(Error::EmptyEndpoints);
266        };
267
268        let arch = get_updater_arch().ok_or(Error::UnsupportedArch)?;
269        let (target, json_target) = if let Some(target) = self.target {
270            (target.clone(), target)
271        } else {
272            let target = get_updater_target().ok_or(Error::UnsupportedOs)?;
273            (target.to_string(), format!("{target}-{arch}"))
274        };
275
276        let executable_path = self.executable_path.clone().unwrap_or(current_exe()?);
277
278        // Get the extract_path from the provided executable_path
279        let extract_path = if cfg!(target_os = "linux") {
280            executable_path
281        } else {
282            extract_path_from_executable(&executable_path)?
283        };
284
285        Ok(Updater {
286            run_on_main_thread: Arc::new(self.run_on_main_thread),
287            config: self.config,
288            app_name: self.app_name,
289            current_version: self.current_version,
290            version_comparator: self.version_comparator,
291            timeout: self.timeout,
292            proxy: self.proxy,
293            endpoints,
294            installer_args: self.installer_args,
295            current_exe_args: self.current_exe_args,
296            arch,
297            target,
298            json_target,
299            headers: self.headers,
300            extract_path,
301            on_before_exit: self.on_before_exit,
302            configure_client: self.configure_client,
303        })
304    }
305}
306
307impl UpdaterBuilder {
308    pub(crate) fn current_exe_args<I, S>(mut self, args: I) -> Self
309    where
310        I: IntoIterator<Item = S>,
311        S: Into<OsString>,
312    {
313        self.current_exe_args
314            .extend(args.into_iter().map(Into::into));
315        self
316    }
317}
318
319pub struct Updater {
320    #[allow(dead_code)]
321    run_on_main_thread: Arc<RunOnMainThread>,
322    config: Config,
323    app_name: String,
324    current_version: Version,
325    version_comparator: Option<VersionComparator>,
326    timeout: Option<Duration>,
327    proxy: Option<Url>,
328    endpoints: Vec<Url>,
329    arch: &'static str,
330    // The `{{target}}` variable we replace in the endpoint
331    target: String,
332    // The value we search if the updater server returns a JSON with the `platforms` object
333    json_target: String,
334    headers: HeaderMap,
335    extract_path: PathBuf,
336    on_before_exit: Option<OnBeforeExit>,
337    configure_client: Option<OnBeforeRequest>,
338    #[allow(unused)]
339    installer_args: Vec<OsString>,
340    #[allow(unused)]
341    current_exe_args: Vec<OsString>,
342}
343
344impl Updater {
345    pub async fn check(&self) -> Result<Option<Update>> {
346        // we want JSON only
347        let mut headers = self.headers.clone();
348        if !headers.contains_key(ACCEPT) {
349            headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
350        }
351
352        // Set SSL certs for linux if they aren't available.
353        #[cfg(target_os = "linux")]
354        {
355            if std::env::var_os("SSL_CERT_FILE").is_none() {
356                std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt");
357            }
358            if std::env::var_os("SSL_CERT_DIR").is_none() {
359                std::env::set_var("SSL_CERT_DIR", "/etc/ssl/certs");
360            }
361        }
362
363        let mut remote_release: Option<RemoteRelease> = None;
364        let mut raw_json: Option<serde_json::Value> = None;
365        let mut last_error: Option<Error> = None;
366        for url in &self.endpoints {
367            // replace {{current_version}}, {{target}} and {{arch}} in the provided URL
368            // this is useful if we need to query example
369            // https://releases.myapp.com/update/{{target}}/{{arch}}/{{current_version}}
370            // will be translated into ->
371            // https://releases.myapp.com/update/darwin/aarch64/1.0.0
372            // The main objective is if the update URL is defined via the Cargo.toml
373            // the URL will be generated dynamically
374            let version = self.current_version.to_string();
375            let version = version.as_bytes();
376            const CONTROLS_ADD: &AsciiSet = &CONTROLS.add(b'+');
377            let encoded_version = percent_encoding::percent_encode(version, CONTROLS_ADD);
378            let encoded_version = encoded_version.to_string();
379
380            let url: Url = url
381                .to_string()
382                // url::Url automatically url-encodes the path components
383                .replace("%7B%7Bcurrent_version%7D%7D", &encoded_version)
384                .replace("%7B%7Btarget%7D%7D", &self.target)
385                .replace("%7B%7Barch%7D%7D", self.arch)
386                // but not query parameters
387                .replace("{{current_version}}", &encoded_version)
388                .replace("{{target}}", &self.target)
389                .replace("{{arch}}", self.arch)
390                .parse()?;
391
392            log::debug!("checking for updates {url}");
393
394            let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT);
395            if let Some(timeout) = self.timeout {
396                request = request.timeout(timeout);
397            }
398            if let Some(ref proxy) = self.proxy {
399                log::debug!("using proxy {proxy}");
400                let proxy = reqwest::Proxy::all(proxy.as_str())?;
401                request = request.proxy(proxy);
402            }
403
404            if let Some(ref configure_client) = self.configure_client {
405                request = configure_client(request);
406            }
407
408            let response = request
409                .build()?
410                .get(url)
411                .headers(headers.clone())
412                .send()
413                .await;
414
415            match response {
416                Ok(res) => {
417                    if res.status().is_success() {
418                        // no updates found!
419                        if StatusCode::NO_CONTENT == res.status() {
420                            log::debug!("update endpoint returned 204 No Content");
421                            return Ok(None);
422                        };
423
424                        let update_response: serde_json::Value = res.json().await?;
425                        log::debug!("update response: {update_response:?}");
426                        raw_json = Some(update_response.clone());
427                        match serde_json::from_value::<RemoteRelease>(update_response)
428                            .map_err(Into::into)
429                        {
430                            Ok(release) => {
431                                log::debug!("parsed release response {release:?}");
432                                last_error = None;
433                                remote_release = Some(release);
434                                // we found a release, break the loop
435                                break;
436                            }
437                            Err(err) => {
438                                log::error!("failed to deserialize update response: {err}");
439                                last_error = Some(err)
440                            }
441                        }
442                    } else {
443                        log::error!(
444                            "update endpoint did not respond with a successful status code"
445                        );
446                    }
447                }
448                Err(err) => {
449                    log::error!("failed to check for updates: {err}");
450                    last_error = Some(err.into())
451                }
452            }
453        }
454
455        // Last error is cleaned on success.
456        // Shouldn't be triggered if we had a successfull call
457        if let Some(error) = last_error {
458            return Err(error);
459        }
460
461        // Extracted remote metadata
462        let release = remote_release.ok_or(Error::ReleaseNotFound)?;
463
464        let should_update = match self.version_comparator.as_ref() {
465            Some(comparator) => comparator(self.current_version.clone(), release.clone()),
466            None => release.version > self.current_version,
467        };
468
469        let update = if should_update {
470            Some(Update {
471                run_on_main_thread: self.run_on_main_thread.clone(),
472                config: self.config.clone(),
473                on_before_exit: self.on_before_exit.clone(),
474                app_name: self.app_name.clone(),
475                current_version: self.current_version.to_string(),
476                target: self.target.clone(),
477                extract_path: self.extract_path.clone(),
478                version: release.version.to_string(),
479                date: release.pub_date,
480                download_url: release.download_url(&self.json_target)?.to_owned(),
481                signature: release.signature(&self.json_target)?.to_owned(),
482                body: release.notes,
483                raw_json: raw_json.unwrap(),
484                timeout: None,
485                proxy: self.proxy.clone(),
486                headers: self.headers.clone(),
487                installer_args: self.installer_args.clone(),
488                current_exe_args: self.current_exe_args.clone(),
489                configure_client: self.configure_client.clone(),
490            })
491        } else {
492            None
493        };
494
495        Ok(update)
496    }
497}
498
499#[derive(Clone)]
500pub struct Update {
501    #[allow(dead_code)]
502    run_on_main_thread: Arc<RunOnMainThread>,
503    config: Config,
504    #[allow(unused)]
505    on_before_exit: Option<OnBeforeExit>,
506    /// Update description
507    pub body: Option<String>,
508    /// Version used to check for update
509    pub current_version: String,
510    /// Version announced
511    pub version: String,
512    /// Update publish date
513    pub date: Option<OffsetDateTime>,
514    /// Target
515    pub target: String,
516    /// Download URL announced
517    pub download_url: Url,
518    /// Signature announced
519    pub signature: String,
520    /// The raw version of server's JSON response. Useful if the response contains additional fields that the updater doesn't handle.
521    pub raw_json: serde_json::Value,
522    /// Request timeout
523    pub timeout: Option<Duration>,
524    /// Request proxy
525    pub proxy: Option<Url>,
526    /// Request headers
527    pub headers: HeaderMap,
528    /// Extract path
529    #[allow(unused)]
530    extract_path: PathBuf,
531    /// App name, used for creating named tempfiles on Windows
532    #[allow(unused)]
533    app_name: String,
534    #[allow(unused)]
535    installer_args: Vec<OsString>,
536    #[allow(unused)]
537    current_exe_args: Vec<OsString>,
538    configure_client: Option<OnBeforeRequest>,
539}
540
541impl Resource for Update {}
542
543impl Update {
544    /// Downloads the updater package, verifies it then return it as bytes.
545    ///
546    /// Use [`Update::install`] to install it
547    pub async fn download<C: FnMut(usize, Option<u64>), D: FnOnce()>(
548        &self,
549        mut on_chunk: C,
550        on_download_finish: D,
551    ) -> Result<Vec<u8>> {
552        // set our headers
553        let mut headers = self.headers.clone();
554        if !headers.contains_key(ACCEPT) {
555            headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream"));
556        }
557
558        let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT);
559        if let Some(timeout) = self.timeout {
560            request = request.timeout(timeout);
561        }
562        if let Some(ref proxy) = self.proxy {
563            let proxy = reqwest::Proxy::all(proxy.as_str())?;
564            request = request.proxy(proxy);
565        }
566        if let Some(ref configure_client) = self.configure_client {
567            request = configure_client(request);
568        }
569        let response = request
570            .build()?
571            .get(self.download_url.clone())
572            .headers(headers)
573            .send()
574            .await?;
575
576        if !response.status().is_success() {
577            return Err(Error::Network(format!(
578                "Download request failed with status: {}",
579                response.status()
580            )));
581        }
582
583        let content_length: Option<u64> = response
584            .headers()
585            .get("Content-Length")
586            .and_then(|value| value.to_str().ok())
587            .and_then(|value| value.parse().ok());
588
589        let mut buffer = Vec::new();
590
591        let mut stream = response.bytes_stream();
592        while let Some(chunk) = stream.next().await {
593            let chunk = chunk?;
594            on_chunk(chunk.len(), content_length);
595            buffer.extend(chunk);
596        }
597        on_download_finish();
598
599        verify_signature(&buffer, &self.signature, &self.config.pubkey)?;
600
601        Ok(buffer)
602    }
603
604    /// Installs the updater package downloaded by [`Update::download`]
605    pub fn install(&self, bytes: impl AsRef<[u8]>) -> Result<()> {
606        self.install_inner(bytes.as_ref())
607    }
608
609    /// Downloads and installs the updater package
610    pub async fn download_and_install<C: FnMut(usize, Option<u64>), D: FnOnce()>(
611        &self,
612        on_chunk: C,
613        on_download_finish: D,
614    ) -> Result<()> {
615        let bytes = self.download(on_chunk, on_download_finish).await?;
616        self.install(bytes)
617    }
618
619    #[cfg(mobile)]
620    fn install_inner(&self, _bytes: &[u8]) -> Result<()> {
621        Ok(())
622    }
623}
624
625#[cfg(windows)]
626enum WindowsUpdaterType {
627    Nsis {
628        path: PathBuf,
629        #[allow(unused)]
630        temp: Option<tempfile::TempPath>,
631    },
632    Msi {
633        path: PathBuf,
634        #[allow(unused)]
635        temp: Option<tempfile::TempPath>,
636    },
637}
638
639#[cfg(windows)]
640impl WindowsUpdaterType {
641    fn nsis(path: PathBuf, temp: Option<tempfile::TempPath>) -> Self {
642        Self::Nsis { path, temp }
643    }
644
645    fn msi(path: PathBuf, temp: Option<tempfile::TempPath>) -> Self {
646        Self::Msi {
647            path: path.wrap_in_quotes(),
648            temp,
649        }
650    }
651}
652
653#[cfg(windows)]
654impl Config {
655    fn install_mode(&self) -> crate::config::WindowsUpdateInstallMode {
656        self.windows
657            .as_ref()
658            .map(|w| w.install_mode.clone())
659            .unwrap_or_default()
660    }
661}
662
663/// Windows
664#[cfg(windows)]
665impl Update {
666    /// ### Expected structure:
667    /// ├── [AppName]_[version]_x64.msi              # Application MSI
668    /// ├── [AppName]_[version]_x64-setup.exe        # NSIS installer
669    /// ├── [AppName]_[version]_x64.msi.zip          # ZIP generated by tauri-bundler
670    /// │   └──[AppName]_[version]_x64.msi           # Application MSI
671    /// ├── [AppName]_[version]_x64-setup.exe.zip          # ZIP generated by tauri-bundler
672    /// │   └──[AppName]_[version]_x64-setup.exe           # NSIS installer
673    /// └── ...
674    fn install_inner(&self, bytes: &[u8]) -> Result<()> {
675        use std::iter::once;
676        use windows_sys::{
677            w,
678            Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOW},
679        };
680
681        let updater_type = self.extract(bytes)?;
682
683        let install_mode = self.config.install_mode();
684        let current_args = &self.current_exe_args()[1..];
685        let msi_args;
686        let nsis_args;
687
688        let installer_args: Vec<&OsStr> = match &updater_type {
689            WindowsUpdaterType::Nsis { .. } => {
690                nsis_args = current_args
691                    .iter()
692                    .map(escape_nsis_current_exe_arg)
693                    .collect::<Vec<_>>();
694
695                install_mode
696                    .nsis_args()
697                    .iter()
698                    .map(OsStr::new)
699                    .chain(once(OsStr::new("/UPDATE")))
700                    .chain(once(OsStr::new("/ARGS")))
701                    .chain(nsis_args.iter().map(OsStr::new))
702                    .chain(self.installer_args())
703                    .collect()
704            }
705            WindowsUpdaterType::Msi { path, .. } => {
706                let escaped_args = current_args
707                    .iter()
708                    .map(escape_msi_property_arg)
709                    .collect::<Vec<_>>()
710                    .join(" ");
711                msi_args = OsString::from(format!("LAUNCHAPPARGS=\"{escaped_args}\""));
712
713                [OsStr::new("/i"), path.as_os_str()]
714                    .into_iter()
715                    .chain(install_mode.msiexec_args().iter().map(OsStr::new))
716                    .chain(once(OsStr::new("/promptrestart")))
717                    .chain(self.installer_args())
718                    .chain(once(OsStr::new("AUTOLAUNCHAPP=True")))
719                    .chain(once(msi_args.as_os_str()))
720                    .collect()
721            }
722        };
723
724        if let Some(on_before_exit) = self.on_before_exit.as_ref() {
725            log::debug!("running on_before_exit hook");
726            on_before_exit();
727        }
728
729        let file = match &updater_type {
730            WindowsUpdaterType::Nsis { path, .. } => path.as_os_str().to_os_string(),
731            WindowsUpdaterType::Msi { .. } => std::env::var("SYSTEMROOT").as_ref().map_or_else(
732                |_| OsString::from("msiexec.exe"),
733                |p| OsString::from(format!("{p}\\System32\\msiexec.exe")),
734            ),
735        };
736        let file = encode_wide(file);
737
738        let parameters = installer_args.join(OsStr::new(" "));
739        let parameters = encode_wide(parameters);
740
741        unsafe {
742            ShellExecuteW(
743                std::ptr::null_mut(),
744                w!("open"),
745                file.as_ptr(),
746                parameters.as_ptr(),
747                std::ptr::null(),
748                SW_SHOW,
749            )
750        };
751
752        std::process::exit(0);
753    }
754
755    fn installer_args(&self) -> Vec<&OsStr> {
756        self.installer_args
757            .iter()
758            .map(OsStr::new)
759            .collect::<Vec<_>>()
760    }
761
762    fn current_exe_args(&self) -> Vec<&OsStr> {
763        self.current_exe_args
764            .iter()
765            .map(OsStr::new)
766            .collect::<Vec<_>>()
767    }
768
769    fn extract(&self, bytes: &[u8]) -> Result<WindowsUpdaterType> {
770        #[cfg(feature = "zip")]
771        if infer::archive::is_zip(bytes) {
772            return self.extract_zip(bytes);
773        }
774
775        self.extract_exe(bytes)
776    }
777
778    fn make_temp_dir(&self) -> Result<PathBuf> {
779        Ok(tempfile::Builder::new()
780            .prefix(&format!("{}-{}-updater-", self.app_name, self.version))
781            .tempdir()?
782            .into_path())
783    }
784
785    #[cfg(feature = "zip")]
786    fn extract_zip(&self, bytes: &[u8]) -> Result<WindowsUpdaterType> {
787        let temp_dir = self.make_temp_dir()?;
788
789        let archive = Cursor::new(bytes);
790        let mut extractor = zip::ZipArchive::new(archive)?;
791        extractor.extract(&temp_dir)?;
792
793        let paths = std::fs::read_dir(&temp_dir)?;
794        for path in paths {
795            let path = path?.path();
796            let ext = path.extension();
797            if ext == Some(OsStr::new("exe")) {
798                return Ok(WindowsUpdaterType::nsis(path, None));
799            } else if ext == Some(OsStr::new("msi")) {
800                return Ok(WindowsUpdaterType::msi(path, None));
801            }
802        }
803
804        Err(crate::Error::BinaryNotFoundInArchive)
805    }
806
807    fn extract_exe(&self, bytes: &[u8]) -> Result<WindowsUpdaterType> {
808        if infer::app::is_exe(bytes) {
809            let (path, temp) = self.write_to_temp(bytes, ".exe")?;
810            Ok(WindowsUpdaterType::nsis(path, temp))
811        } else if infer::archive::is_msi(bytes) {
812            let (path, temp) = self.write_to_temp(bytes, ".msi")?;
813            Ok(WindowsUpdaterType::msi(path, temp))
814        } else {
815            Err(crate::Error::InvalidUpdaterFormat)
816        }
817    }
818
819    fn write_to_temp(
820        &self,
821        bytes: &[u8],
822        ext: &str,
823    ) -> Result<(PathBuf, Option<tempfile::TempPath>)> {
824        use std::io::Write;
825
826        let temp_dir = self.make_temp_dir()?;
827        let mut temp_file = tempfile::Builder::new()
828            .prefix(&format!("{}-{}-installer", self.app_name, self.version))
829            .suffix(ext)
830            .rand_bytes(0)
831            .tempfile_in(temp_dir)?;
832        temp_file.write_all(bytes)?;
833
834        let temp = temp_file.into_temp_path();
835        Ok((temp.to_path_buf(), Some(temp)))
836    }
837}
838
839/// Linux (AppImage and Deb)
840#[cfg(any(
841    target_os = "linux",
842    target_os = "dragonfly",
843    target_os = "freebsd",
844    target_os = "netbsd",
845    target_os = "openbsd"
846))]
847impl Update {
848    /// ### Expected structure:
849    /// ├── [AppName]_[version]_amd64.AppImage.tar.gz    # GZ generated by tauri-bundler
850    /// │   └──[AppName]_[version]_amd64.AppImage        # Application AppImage
851    /// ├── [AppName]_[version]_amd64.deb                # Debian package
852    /// └── ...
853    ///
854    fn install_inner(&self, bytes: &[u8]) -> Result<()> {
855        if self.is_deb_package() {
856            self.install_deb(bytes)
857        } else {
858            // Handle AppImage or other formats
859            self.install_appimage(bytes)
860        }
861    }
862
863    fn install_appimage(&self, bytes: &[u8]) -> Result<()> {
864        use std::os::unix::fs::{MetadataExt, PermissionsExt};
865        let extract_path_metadata = self.extract_path.metadata()?;
866
867        let tmp_dir_locations = vec![
868            Box::new(|| Some(std::env::temp_dir())) as Box<dyn FnOnce() -> Option<PathBuf>>,
869            Box::new(dirs::cache_dir),
870            Box::new(|| Some(self.extract_path.parent().unwrap().to_path_buf())),
871        ];
872
873        for tmp_dir_location in tmp_dir_locations {
874            if let Some(tmp_dir_location) = tmp_dir_location() {
875                let tmp_dir = tempfile::Builder::new()
876                    .prefix("tauri_current_app")
877                    .tempdir_in(tmp_dir_location)?;
878                let tmp_dir_metadata = tmp_dir.path().metadata()?;
879
880                if extract_path_metadata.dev() == tmp_dir_metadata.dev() {
881                    let mut perms = tmp_dir_metadata.permissions();
882                    perms.set_mode(0o700);
883                    std::fs::set_permissions(tmp_dir.path(), perms)?;
884
885                    let tmp_app_image = &tmp_dir.path().join("current_app.AppImage");
886
887                    let permissions = std::fs::metadata(&self.extract_path)?.permissions();
888
889                    // create a backup of our current app image
890                    std::fs::rename(&self.extract_path, tmp_app_image)?;
891
892                    #[cfg(feature = "zip")]
893                    if infer::archive::is_gz(bytes) {
894                        log::debug!("extracting AppImage");
895                        // extract the buffer to the tmp_dir
896                        // we extract our signed archive into our final directory without any temp file
897                        let archive = Cursor::new(bytes);
898                        let decoder = flate2::read::GzDecoder::new(archive);
899                        let mut archive = tar::Archive::new(decoder);
900                        for mut entry in archive.entries()?.flatten() {
901                            if let Ok(path) = entry.path() {
902                                if path.extension() == Some(OsStr::new("AppImage")) {
903                                    // if something went wrong during the extraction, we should restore previous app
904                                    if let Err(err) = entry.unpack(&self.extract_path) {
905                                        std::fs::rename(tmp_app_image, &self.extract_path)?;
906                                        return Err(err.into());
907                                    }
908                                    // early finish we have everything we need here
909                                    return Ok(());
910                                }
911                            }
912                        }
913                        // if we have not returned early we should restore the backup
914                        std::fs::rename(tmp_app_image, &self.extract_path)?;
915                        return Err(Error::BinaryNotFoundInArchive);
916                    }
917
918                    log::debug!("rewriting AppImage");
919                    return match std::fs::write(&self.extract_path, bytes)
920                        .and_then(|_| std::fs::set_permissions(&self.extract_path, permissions))
921                    {
922                        Err(err) => {
923                            // if something went wrong during the extraction, we should restore previous app
924                            std::fs::rename(tmp_app_image, &self.extract_path)?;
925                            Err(err.into())
926                        }
927                        Ok(_) => Ok(()),
928                    };
929                }
930            }
931        }
932
933        Err(Error::TempDirNotOnSameMountPoint)
934    }
935
936    fn is_deb_package(&self) -> bool {
937        // First check if we're in a typical Debian installation path
938        let in_system_path = self
939            .extract_path
940            .to_str()
941            .map(|p| p.starts_with("/usr"))
942            .unwrap_or(false);
943
944        if !in_system_path {
945            return false;
946        }
947
948        // Then verify it's actually a Debian-based system by checking for dpkg
949        let dpkg_exists = std::path::Path::new("/var/lib/dpkg").exists();
950        let apt_exists = std::path::Path::new("/etc/apt").exists();
951
952        // Additional check for the package in dpkg database
953        let package_in_dpkg = if let Ok(output) = std::process::Command::new("dpkg")
954            .args(["-S", &self.extract_path.to_string_lossy()])
955            .output()
956        {
957            output.status.success()
958        } else {
959            false
960        };
961
962        // Consider it a deb package only if:
963        // 1. We're in a system path AND
964        // 2. We have Debian package management tools AND
965        // 3. The binary is tracked by dpkg
966        dpkg_exists && apt_exists && package_in_dpkg
967    }
968
969    fn install_deb(&self, bytes: &[u8]) -> Result<()> {
970        // First verify the bytes are actually a .deb package
971        if !infer::archive::is_deb(bytes) {
972            log::warn!("update is not a valid deb package");
973            return Err(Error::InvalidUpdaterFormat);
974        }
975
976        // Try different temp directories
977        let tmp_dir_locations = vec![
978            Box::new(|| Some(std::env::temp_dir())) as Box<dyn FnOnce() -> Option<PathBuf>>,
979            Box::new(dirs::cache_dir),
980            Box::new(|| Some(self.extract_path.parent().unwrap().to_path_buf())),
981        ];
982
983        // Try writing to multiple temp locations until one succeeds
984        for tmp_dir_location in tmp_dir_locations {
985            if let Some(path) = tmp_dir_location() {
986                if let Ok(tmp_dir) = tempfile::Builder::new()
987                    .prefix("tauri_deb_update")
988                    .tempdir_in(path)
989                {
990                    let deb_path = tmp_dir.path().join("package.deb");
991
992                    // Try writing the .deb file
993                    if std::fs::write(&deb_path, bytes).is_ok() {
994                        // If write succeeds, proceed with installation
995                        return self.try_install_with_privileges(&deb_path);
996                    }
997                    // If write fails, continue to next temp location
998                }
999            }
1000        }
1001
1002        // If we get here, all temp locations failed
1003        Err(Error::TempDirNotFound)
1004    }
1005
1006    fn try_install_with_privileges(&self, deb_path: &Path) -> Result<()> {
1007        // 1. First try using pkexec (graphical sudo prompt)
1008        if let Ok(status) = std::process::Command::new("pkexec")
1009            .arg("dpkg")
1010            .arg("-i")
1011            .arg(deb_path)
1012            .status()
1013        {
1014            if status.success() {
1015                log::debug!("installed deb with pkexec");
1016                return Ok(());
1017            }
1018        }
1019
1020        // 2. Try zenity or kdialog for a graphical sudo experience
1021        if let Ok(password) = self.get_password_graphically() {
1022            if self.install_with_sudo(deb_path, &password)? {
1023                log::debug!("installed deb with GUI sudo");
1024                return Ok(());
1025            }
1026        }
1027
1028        // 3. Final fallback: terminal sudo
1029        let status = std::process::Command::new("sudo")
1030            .arg("dpkg")
1031            .arg("-i")
1032            .arg(deb_path)
1033            .status()?;
1034
1035        if status.success() {
1036            log::debug!("installed deb with sudo");
1037            Ok(())
1038        } else {
1039            Err(Error::DebInstallFailed)
1040        }
1041    }
1042
1043    fn get_password_graphically(&self) -> Result<String> {
1044        // Try zenity first
1045        let zenity_result = std::process::Command::new("zenity")
1046            .args([
1047                "--password",
1048                "--title=Authentication Required",
1049                "--text=Enter your password to install the update:",
1050            ])
1051            .output();
1052
1053        if let Ok(output) = zenity_result {
1054            if output.status.success() {
1055                return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
1056            }
1057        }
1058
1059        // Fall back to kdialog if zenity fails or isn't available
1060        let kdialog_result = std::process::Command::new("kdialog")
1061            .args(["--password", "Enter your password to install the update:"])
1062            .output();
1063
1064        if let Ok(output) = kdialog_result {
1065            if output.status.success() {
1066                return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
1067            }
1068        }
1069
1070        Err(Error::AuthenticationFailed)
1071    }
1072
1073    fn install_with_sudo(&self, deb_path: &Path, password: &str) -> Result<bool> {
1074        use std::io::Write;
1075        use std::process::{Command, Stdio};
1076
1077        let mut child = Command::new("sudo")
1078            .arg("-S") // read password from stdin
1079            .arg("dpkg")
1080            .arg("-i")
1081            .arg(deb_path)
1082            .stdin(Stdio::piped())
1083            .stdout(Stdio::piped())
1084            .stderr(Stdio::piped())
1085            .spawn()?;
1086
1087        if let Some(mut stdin) = child.stdin.take() {
1088            // Write password to stdin
1089            writeln!(stdin, "{}", password)?;
1090        }
1091
1092        let status = child.wait()?;
1093        Ok(status.success())
1094    }
1095}
1096
1097/// MacOS
1098#[cfg(target_os = "macos")]
1099impl Update {
1100    /// ### Expected structure:
1101    /// ├── [AppName]_[version]_x64.app.tar.gz       # GZ generated by tauri-bundler
1102    /// │   └──[AppName].app                         # Main application
1103    /// │      └── Contents                          # Application contents...
1104    /// │          └── ...
1105    /// └── ...
1106    fn install_inner(&self, bytes: &[u8]) -> Result<()> {
1107        use flate2::read::GzDecoder;
1108
1109        let cursor = Cursor::new(bytes);
1110        let mut extracted_files: Vec<PathBuf> = Vec::new();
1111
1112        // Create temp directories for backup and extraction
1113        let tmp_backup_dir = tempfile::Builder::new()
1114            .prefix("tauri_current_app")
1115            .tempdir()?;
1116
1117        let tmp_extract_dir = tempfile::Builder::new()
1118            .prefix("tauri_updated_app")
1119            .tempdir()?;
1120
1121        let decoder = GzDecoder::new(cursor);
1122        let mut archive = tar::Archive::new(decoder);
1123
1124        // Extract files to temporary directory
1125        for entry in archive.entries()? {
1126            let mut entry = entry?;
1127            let collected_path: PathBuf = entry.path()?.iter().skip(1).collect();
1128            let extraction_path = tmp_extract_dir.path().join(&collected_path);
1129
1130            // Ensure parent directories exist
1131            if let Some(parent) = extraction_path.parent() {
1132                std::fs::create_dir_all(parent)?;
1133            }
1134
1135            if let Err(err) = entry.unpack(&extraction_path) {
1136                // Cleanup on error
1137                std::fs::remove_dir_all(tmp_extract_dir.path()).ok();
1138                return Err(err.into());
1139            }
1140            extracted_files.push(extraction_path);
1141        }
1142
1143        // Try to move the current app to backup
1144        let move_result = std::fs::rename(
1145            &self.extract_path,
1146            tmp_backup_dir.path().join("current_app"),
1147        );
1148        let need_authorization = if let Err(err) = move_result {
1149            if err.kind() == std::io::ErrorKind::PermissionDenied {
1150                true
1151            } else {
1152                std::fs::remove_dir_all(tmp_extract_dir.path()).ok();
1153                return Err(err.into());
1154            }
1155        } else {
1156            false
1157        };
1158
1159        if need_authorization {
1160            log::debug!("app installation needs admin privileges");
1161            // Use AppleScript to perform moves with admin privileges
1162            let apple_script = format!(
1163                "do shell script \"rm -rf '{src}' && mv -f '{new}' '{src}'\" with administrator privileges",
1164                src = self.extract_path.display(),
1165                new = tmp_extract_dir.path().display()
1166            );
1167
1168            let (tx, rx) = std::sync::mpsc::channel();
1169            let res = (self.run_on_main_thread)(Box::new(move || {
1170                let mut script =
1171                    osakit::Script::new_from_source(osakit::Language::AppleScript, &apple_script);
1172                script.compile().expect("invalid AppleScript");
1173                let r = script.execute();
1174                tx.send(r).unwrap();
1175            }));
1176            let result = rx.recv().unwrap();
1177
1178            if res.is_err() || result.is_err() {
1179                std::fs::remove_dir_all(tmp_extract_dir.path()).ok();
1180                return Err(Error::Io(std::io::Error::new(
1181                    std::io::ErrorKind::PermissionDenied,
1182                    "Failed to move the new app into place",
1183                )));
1184            }
1185        } else {
1186            // Remove existing directory if it exists
1187            if self.extract_path.exists() {
1188                std::fs::remove_dir_all(&self.extract_path)?;
1189            }
1190            // Move the new app to the target path
1191            std::fs::rename(tmp_extract_dir.path(), &self.extract_path)?;
1192        }
1193
1194        let _ = std::process::Command::new("touch")
1195            .arg(&self.extract_path)
1196            .status();
1197
1198        Ok(())
1199    }
1200}
1201
1202/// Gets the target string used on the updater.
1203pub fn target() -> Option<String> {
1204    if let (Some(target), Some(arch)) = (get_updater_target(), get_updater_arch()) {
1205        Some(format!("{target}-{arch}"))
1206    } else {
1207        None
1208    }
1209}
1210
1211pub(crate) fn get_updater_target() -> Option<&'static str> {
1212    if cfg!(target_os = "linux") {
1213        Some("linux")
1214    } else if cfg!(target_os = "macos") {
1215        // TODO shouldn't this be macos instead?
1216        Some("darwin")
1217    } else if cfg!(target_os = "windows") {
1218        Some("windows")
1219    } else {
1220        None
1221    }
1222}
1223
1224pub(crate) fn get_updater_arch() -> Option<&'static str> {
1225    if cfg!(target_arch = "x86") {
1226        Some("i686")
1227    } else if cfg!(target_arch = "x86_64") {
1228        Some("x86_64")
1229    } else if cfg!(target_arch = "arm") {
1230        Some("armv7")
1231    } else if cfg!(target_arch = "aarch64") {
1232        Some("aarch64")
1233    } else if cfg!(target_arch = "riscv64") {
1234        Some("riscv64")
1235    } else {
1236        None
1237    }
1238}
1239
1240pub fn extract_path_from_executable(executable_path: &Path) -> Result<PathBuf> {
1241    // Return the path of the current executable by default
1242    // Example C:\Program Files\My App\
1243    let extract_path = executable_path
1244        .parent()
1245        .map(PathBuf::from)
1246        .ok_or(Error::FailedToDetermineExtractPath)?;
1247
1248    // MacOS example binary is in /Applications/TestApp.app/Contents/MacOS/myApp
1249    // We need to get /Applications/<app>.app
1250    // TODO(lemarier): Need a better way here
1251    // Maybe we could search for <*.app> to get the right path
1252    #[cfg(target_os = "macos")]
1253    if extract_path
1254        .display()
1255        .to_string()
1256        .contains("Contents/MacOS")
1257    {
1258        return extract_path
1259            .parent()
1260            .map(PathBuf::from)
1261            .ok_or(Error::FailedToDetermineExtractPath)?
1262            .parent()
1263            .map(PathBuf::from)
1264            .ok_or(Error::FailedToDetermineExtractPath);
1265    }
1266
1267    Ok(extract_path)
1268}
1269
1270impl<'de> Deserialize<'de> for RemoteRelease {
1271    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1272    where
1273        D: Deserializer<'de>,
1274    {
1275        #[derive(Deserialize)]
1276        struct InnerRemoteRelease {
1277            #[serde(alias = "name", deserialize_with = "parse_version")]
1278            version: Version,
1279            notes: Option<String>,
1280            pub_date: Option<String>,
1281            platforms: Option<HashMap<String, ReleaseManifestPlatform>>,
1282            // dynamic platform response
1283            url: Option<Url>,
1284            signature: Option<String>,
1285        }
1286
1287        let release = InnerRemoteRelease::deserialize(deserializer)?;
1288
1289        let pub_date = if let Some(date) = release.pub_date {
1290            Some(
1291                OffsetDateTime::parse(&date, &time::format_description::well_known::Rfc3339)
1292                    .map_err(|e| DeError::custom(format!("invalid value for `pub_date`: {e}")))?,
1293            )
1294        } else {
1295            None
1296        };
1297
1298        Ok(RemoteRelease {
1299            version: release.version,
1300            notes: release.notes,
1301            pub_date,
1302            data: if let Some(platforms) = release.platforms {
1303                RemoteReleaseInner::Static { platforms }
1304            } else {
1305                RemoteReleaseInner::Dynamic(ReleaseManifestPlatform {
1306                    url: release.url.ok_or_else(|| {
1307                        DeError::custom("the `url` field was not set on the updater response")
1308                    })?,
1309                    signature: release.signature.ok_or_else(|| {
1310                        DeError::custom("the `signature` field was not set on the updater response")
1311                    })?,
1312                })
1313            },
1314        })
1315    }
1316}
1317
1318fn parse_version<'de, D>(deserializer: D) -> std::result::Result<Version, D::Error>
1319where
1320    D: serde::Deserializer<'de>,
1321{
1322    let str = String::deserialize(deserializer)?;
1323
1324    Version::from_str(str.trim_start_matches('v')).map_err(serde::de::Error::custom)
1325}
1326
1327// Validate signature
1328fn verify_signature(data: &[u8], release_signature: &str, pub_key: &str) -> Result<bool> {
1329    // we need to convert the pub key
1330    let pub_key_decoded = base64_to_string(pub_key)?;
1331    let public_key = PublicKey::decode(&pub_key_decoded)?;
1332    let signature_base64_decoded = base64_to_string(release_signature)?;
1333    let signature = Signature::decode(&signature_base64_decoded)?;
1334
1335    // Validate signature or bail out
1336    public_key.verify(data, &signature, true)?;
1337    Ok(true)
1338}
1339
1340fn base64_to_string(base64_string: &str) -> Result<String> {
1341    let decoded_string = &base64::engine::general_purpose::STANDARD.decode(base64_string)?;
1342    let result = std::str::from_utf8(decoded_string)
1343        .map_err(|_| Error::SignatureUtf8(base64_string.into()))?
1344        .to_string();
1345    Ok(result)
1346}
1347
1348#[cfg(windows)]
1349fn encode_wide(string: impl AsRef<OsStr>) -> Vec<u16> {
1350    use std::os::windows::ffi::OsStrExt;
1351
1352    string
1353        .as_ref()
1354        .encode_wide()
1355        .chain(std::iter::once(0))
1356        .collect()
1357}
1358
1359#[cfg(windows)]
1360trait PathExt {
1361    fn wrap_in_quotes(&self) -> Self;
1362}
1363
1364#[cfg(windows)]
1365impl PathExt for PathBuf {
1366    fn wrap_in_quotes(&self) -> Self {
1367        let mut msi_path = OsString::from("\"");
1368        msi_path.push(self.as_os_str());
1369        msi_path.push("\"");
1370        PathBuf::from(msi_path)
1371    }
1372}
1373
1374// adapted from https://github.com/rust-lang/rust/blob/1c047506f94cd2d05228eb992b0a6bbed1942349/library/std/src/sys/args/windows.rs#L174
1375#[cfg(windows)]
1376fn escape_nsis_current_exe_arg(arg: &&OsStr) -> String {
1377    let arg = arg.to_string_lossy();
1378    let mut cmd: Vec<char> = Vec::new();
1379
1380    // compared to std we additionally escape `/` so that nsis won't interpret them as a beginning of an nsis argument.
1381    let quote = arg.chars().any(|c| c == ' ' || c == '\t' || c == '/') || arg.is_empty();
1382    let escape = true;
1383    if quote {
1384        cmd.push('"');
1385    }
1386    let mut backslashes: usize = 0;
1387    for x in arg.chars() {
1388        if escape {
1389            if x == '\\' {
1390                backslashes += 1;
1391            } else {
1392                if x == '"' {
1393                    // Add n+1 backslashes to total 2n+1 before internal '"'.
1394                    cmd.extend((0..=backslashes).map(|_| '\\'));
1395                }
1396                backslashes = 0;
1397            }
1398        }
1399        cmd.push(x);
1400    }
1401    if quote {
1402        // Add n backslashes to total 2n before ending '"'.
1403        cmd.extend((0..backslashes).map(|_| '\\'));
1404        cmd.push('"');
1405    }
1406    cmd.into_iter().collect()
1407}
1408
1409#[cfg(windows)]
1410fn escape_msi_property_arg(arg: impl AsRef<OsStr>) -> String {
1411    let mut arg = arg.as_ref().to_string_lossy().to_string();
1412
1413    // Otherwise this argument will get lost in ShellExecute
1414    if arg.is_empty() {
1415        return "\"\"\"\"".to_string();
1416    } else if !arg.contains(' ') && !arg.contains('"') {
1417        return arg;
1418    }
1419
1420    if arg.contains('"') {
1421        arg = arg.replace('"', r#""""""#);
1422    }
1423
1424    if arg.starts_with('-') {
1425        if let Some((a1, a2)) = arg.split_once('=') {
1426            format!("{a1}=\"\"{a2}\"\"")
1427        } else {
1428            format!("\"\"{arg}\"\"")
1429        }
1430    } else {
1431        format!("\"\"{arg}\"\"")
1432    }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437
1438    #[test]
1439    #[cfg(windows)]
1440    fn it_wraps_correctly() {
1441        use super::PathExt;
1442        use std::path::PathBuf;
1443
1444        assert_eq!(
1445            PathBuf::from("C:\\Users\\Some User\\AppData\\tauri-example.exe").wrap_in_quotes(),
1446            PathBuf::from("\"C:\\Users\\Some User\\AppData\\tauri-example.exe\"")
1447        )
1448    }
1449
1450    #[test]
1451    #[cfg(windows)]
1452    fn it_escapes_correctly_for_msi() {
1453        use crate::updater::escape_msi_property_arg;
1454
1455        // Explanation for quotes:
1456        // The output of escape_msi_property_args() will be used in `LAUNCHAPPARGS=\"{HERE}\"`. This is the first quote level.
1457        // To escape a quotation mark we use a second quotation mark, so "" is interpreted as " later.
1458        // This means that the escaped strings can't ever have a single quotation mark!
1459        // Now there are 3 major things to look out for to not break the msiexec call:
1460        //   1) Wrap spaces in quotation marks, otherwise it will be interpreted as the end of the msiexec argument.
1461        //   2) Escape escaping quotation marks, otherwise they will either end the msiexec argument or be ignored.
1462        //   3) Escape emtpy args in quotation marks, otherwise the argument will get lost.
1463        let cases = [
1464            "something",
1465            "--flag",
1466            "--empty=",
1467            "--arg=value",
1468            "some space",                     // This simulates `./my-app "some string"`.
1469            "--arg value", // -> This simulates `./my-app "--arg value"`. Same as above but it triggers the startsWith(`-`) logic.
1470            "--arg=unwrapped space", // `./my-app --arg="unwrapped space"`
1471            "--arg=\"wrapped\"", // `./my-app --args=""wrapped""`
1472            "--arg=\"wrapped space\"", // `./my-app --args=""wrapped space""`
1473            "--arg=midword\"wrapped space\"", // `./my-app --args=midword""wrapped""`
1474            "",            // `./my-app '""'`
1475        ];
1476        let cases_escaped = [
1477            "something",
1478            "--flag",
1479            "--empty=",
1480            "--arg=value",
1481            "\"\"some space\"\"",
1482            "\"\"--arg value\"\"",
1483            "--arg=\"\"unwrapped space\"\"",
1484            r#"--arg=""""""wrapped"""""""#,
1485            r#"--arg=""""""wrapped space"""""""#,
1486            r#"--arg=""midword""""wrapped space"""""""#,
1487            "\"\"\"\"",
1488        ];
1489
1490        // Just to be sure we didn't mess that up
1491        assert_eq!(cases.len(), cases_escaped.len());
1492
1493        for (orig, escaped) in cases.iter().zip(cases_escaped) {
1494            assert_eq!(escape_msi_property_arg(orig), escaped);
1495        }
1496    }
1497
1498    #[test]
1499    #[cfg(windows)]
1500    fn it_escapes_correctly_for_nsis() {
1501        use crate::updater::escape_nsis_current_exe_arg;
1502        use std::ffi::OsStr;
1503
1504        let cases = [
1505            "something",
1506            "--flag",
1507            "--empty=",
1508            "--arg=value",
1509            "some space",                     // This simulates `./my-app "some string"`.
1510            "--arg value", // -> This simulates `./my-app "--arg value"`. Same as above but it triggers the startsWith(`-`) logic.
1511            "--arg=unwrapped space", // `./my-app --arg="unwrapped space"`
1512            "--arg=\"wrapped\"", // `./my-app --args=""wrapped""`
1513            "--arg=\"wrapped space\"", // `./my-app --args=""wrapped space""`
1514            "--arg=midword\"wrapped space\"", // `./my-app --args=midword""wrapped""`
1515            "",            // `./my-app '""'`
1516        ];
1517        // Note: These may not be the results we actually want (monitor this!).
1518        // We only make sure the implementation doesn't unintentionally change.
1519        let cases_escaped = [
1520            "something",
1521            "--flag",
1522            "--empty=",
1523            "--arg=value",
1524            "\"some space\"",
1525            "\"--arg value\"",
1526            "\"--arg=unwrapped space\"",
1527            "--arg=\\\"wrapped\\\"",
1528            "\"--arg=\\\"wrapped space\\\"\"",
1529            "\"--arg=midword\\\"wrapped space\\\"\"",
1530            "\"\"",
1531        ];
1532
1533        // Just to be sure we didn't mess that up
1534        assert_eq!(cases.len(), cases_escaped.len());
1535
1536        for (orig, escaped) in cases.iter().zip(cases_escaped) {
1537            assert_eq!(escape_nsis_current_exe_arg(&OsStr::new(orig)), escaped);
1538        }
1539    }
1540}