From 4ab90f048eab2918344f97dc8e04413a404e392d Mon Sep 17 00:00:00 2001 From: Amr Bashir Date: Mon, 7 Aug 2023 22:01:21 +0300 Subject: [PATCH] feat(updater): refactor and improvements (#431) Co-authored-by: Lucas Nogueira Co-authored-by: Lucas Nogueira --- .changes/updater-plugin-refactor.md | 6 + .github/workflows/integration-tests.yml | 54 + .github/workflows/test-rust.yml | 5 + Cargo.lock | 164 +- plugins/updater/Cargo.toml | 4 +- plugins/updater/guest-js/index.ts | 34 +- plugins/updater/src/api-iife.js | 2 +- plugins/updater/src/commands.rs | 111 +- plugins/updater/src/config.rs | 2 +- plugins/updater/src/error.rs | 102 +- plugins/updater/src/lib.rs | 89 +- plugins/updater/src/updater.rs | 862 +++++++++ plugins/updater/src/updater/core.rs | 1696 ----------------- plugins/updater/src/updater/extract.rs | 344 ---- plugins/updater/src/updater/mod.rs | 308 --- plugins/updater/src/updater/move_file.rs | 118 -- plugins/updater/tests/app-updater/src/main.rs | 52 +- .../updater/tests/app-updater/tests/update.rs | 2 +- 18 files changed, 1229 insertions(+), 2726 deletions(-) create mode 100644 .changes/updater-plugin-refactor.md create mode 100644 .github/workflows/integration-tests.yml create mode 100644 plugins/updater/src/updater.rs delete mode 100644 plugins/updater/src/updater/core.rs delete mode 100644 plugins/updater/src/updater/extract.rs delete mode 100644 plugins/updater/src/updater/mod.rs delete mode 100644 plugins/updater/src/updater/move_file.rs diff --git a/.changes/updater-plugin-refactor.md b/.changes/updater-plugin-refactor.md new file mode 100644 index 00000000..06da6986 --- /dev/null +++ b/.changes/updater-plugin-refactor.md @@ -0,0 +1,6 @@ +--- +"updater": minor +"updater-js": minor +--- + +The updater plugin is recieving a few changes to improve consistency and ergonomics of the Rust and JS APIs diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 00000000..f616ef6f --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,54 @@ +# Copyright 2019-2023 Tauri Programme within The Commons Conservancy +# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT + +name: integration tests + +on: + push: + branches: + - v1 + - v2 + paths: + - ".github/workflows/integration-tests.yml" + - "plugins/updater/src/**" + pull_request: + branches: + - v1 + - v2 + paths: + - ".github/workflows/integration-tests.yml" + - "plugins/updater/src/**" + +jobs: + run-integration-tests: + runs-on: ${{ matrix.platform }} + + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: install stable + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + - name: install Linux dependencies + if: matrix.platform == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y webkit2gtk-4.1 libayatana-appindicator3-dev libfuse2 + + - uses: Swatinem/rust-cache@v2 + + - name: install Tauri CLI + run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch dev + + - name: run integration tests + run: cargo test --test '*' -- --ignored diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index cf0de8e1..075dcd38 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -193,6 +193,11 @@ jobs: working-directory: examples/api run: mkdir dist + - name: Downgrade crates with MSRV conflict + # The --precise flag can only be used once per invocation. + run: | + cargo update -p time@0.3.24 --precise 0.3.23 + - name: test ${{ matrix.package }} if: matrix.package != 'tauri-plugin-sql' uses: actions-rs/cargo@v1 diff --git a/Cargo.lock b/Cargo.lock index b3e9f4ef..457bbe4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,28 +435,6 @@ dependencies = [ "syn 2.0.28", ] -[[package]] -name = "async-stream" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.28", -] - [[package]] name = "async-task" version = "4.4.0" @@ -690,16 +668,6 @@ dependencies = [ "alloc-stdlib", ] -[[package]] -name = "bstr" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6798148dccfbff0fae41c7574d2fa8f1ef3492fba0face179de5d8d447d67b05" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "bumpalo" version = "3.13.0" @@ -747,6 +715,27 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.16.7" @@ -787,6 +776,9 @@ name = "cc" version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" +dependencies = [ + "jobserver", +] [[package]] name = "cesu8" @@ -2116,19 +2108,6 @@ dependencies = [ "x11-dl", ] -[[package]] -name = "globset" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aca8bbd8e0707c1887a8bbb7e6b40e228f251ff5d62c8220a4a7a53c73aff006" -dependencies = [ - "aho-corasick", - "bstr", - "fnv", - "log", - "regex", -] - [[package]] name = "gobject-sys" version = "0.16.3" @@ -2514,23 +2493,6 @@ dependencies = [ "unicode-normalization", ] -[[package]] -name = "ignore" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbe7873dab538a9a44ad79ede1faf5f30d49f9a5c883ddbab48bce81b64b7492" -dependencies = [ - "globset", - "lazy_static", - "log", - "memchr", - "regex", - "same-file", - "thread_local", - "walkdir", - "winapi-util", -] - [[package]] name = "image" version = "0.24.6" @@ -2808,6 +2770,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" +dependencies = [ + "libc", +] + [[package]] name = "jpeg-decoder" version = "0.3.0" @@ -3681,6 +3652,17 @@ dependencies = [ "windows-targets 0.48.1", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.14" @@ -3700,6 +3682,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest 0.10.7", + "hmac", + "password-hash", + "sha2 0.10.7", ] [[package]] @@ -5848,10 +5833,8 @@ version = "2.0.0-alpha.0" dependencies = [ "base64 0.21.2", "dirs-next", - "flate2", "futures-util", "http", - "ignore", "minisign-verify", "mockito", "percent-encoding", @@ -5865,7 +5848,6 @@ dependencies = [ "thiserror", "time 0.3.24", "tokio", - "tokio-test", "url", "zip", ] @@ -6229,19 +6211,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-test" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53474327ae5e166530d17f2d956afcb4f8a004de581b3cae10f12006bc8163e3" -dependencies = [ - "async-stream", - "bytes 1.4.0", - "futures-core", - "tokio", - "tokio-stream", -] - [[package]] name = "tokio-tungstenite" version = "0.19.0" @@ -7540,9 +7509,48 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" dependencies = [ + "aes 0.8.3", "byteorder", + "bzip2", + "constant_time_eq 0.1.5", "crc32fast", "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time 0.3.24", + "zstd", +] + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.8+zstd.1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5556e6ee25d32df2586c098bbfa278803692a20d0ab9565e049480d52707ec8c" +dependencies = [ + "cc", + "libc", + "pkg-config", ] [[package]] diff --git a/plugins/updater/Cargo.toml b/plugins/updater/Cargo.toml index 7342e8d8..f2492ea2 100644 --- a/plugins/updater/Cargo.toml +++ b/plugins/updater/Cargo.toml @@ -23,16 +23,14 @@ percent-encoding = "2" semver = { version = "1", features = [ "serde" ] } futures-util = "0.3" tempfile = "3" -flate2 = "1" +zip = "0.6" tar = "0.4" -ignore = "0.4" [target."cfg(target_os = \"windows\")".dependencies] zip = { version = "0.6", default-features = false } [dev-dependencies] mockito = "0.31" -tokio-test = "0.4.2" [features] native-tls = [ "reqwest/native-tls" ] diff --git a/plugins/updater/guest-js/index.ts b/plugins/updater/guest-js/index.ts index 3994bdbe..95272c83 100644 --- a/plugins/updater/guest-js/index.ts +++ b/plugins/updater/guest-js/index.ts @@ -4,11 +4,12 @@ import { invoke, Channel } from "@tauri-apps/api/tauri"; +/** Options used to check for updates */ interface CheckOptions { /** * Request headers */ - headers?: Record; + headers?: HeadersInit; /** * Timeout in seconds */ @@ -19,26 +20,34 @@ interface CheckOptions { target?: string; } -interface UpdateResponse { +interface UpdateMetadata { available: boolean; currentVersion: string; - latestVersion: string; + version: string; date?: string; body?: string; } +/** Updater download event */ type DownloadEvent = | { event: "Started"; data: { contentLength?: number } } | { event: "Progress"; data: { chunkLength: number } } | { event: "Finished" }; class Update { - response: UpdateResponse; + currentVersion: string; + version: string; + date?: string; + body?: string; - constructor(response: UpdateResponse) { - this.response = response; + constructor(metadata: UpdateMetadata) { + this.currentVersion = metadata.currentVersion; + this.version = metadata.version; + this.date = metadata.date; + this.body = metadata.body; } + /** Downloads the updater package and installs it */ async downloadAndInstall( onEvent?: (progress: DownloadEvent) => void, ): Promise { @@ -52,11 +61,16 @@ class Update { } } -async function check(options?: CheckOptions): Promise { - return invoke("plugin:updater|check", { ...options }).then( - (response) => new Update(response), +/** Check for updates, resolves to `null` if no updates are available */ +async function check(options?: CheckOptions): Promise { + if (options?.headers) { + options.headers = Array.from(new Headers(options.headers).entries()); + } + + return invoke("plugin:updater|check", { ...options }).then( + (meta) => (meta.available ? new Update(meta) : null), ); } -export type { CheckOptions, UpdateResponse, DownloadEvent }; +export type { CheckOptions, DownloadEvent }; export { check, Update }; diff --git a/plugins/updater/src/api-iife.js b/plugins/updater/src/api-iife.js index 98b55689..4574b7dd 100644 --- a/plugins/updater/src/api-iife.js +++ b/plugins/updater/src/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_UPDATER__=function(e){"use strict";var n=Object.defineProperty,t=(e,n,t)=>{if(!n.has(e))throw TypeError("Cannot "+t)},r=(e,n,r)=>(t(e,n,"read from private field"),r?r.call(e):n.get(e)),i=(e,n,r,i)=>(t(e,n,"write to private field"),i?i.call(e,r):n.set(e,r),r);function a(e,n=!1){let t=window.crypto.getRandomValues(new Uint32Array(1))[0],r=`_${t}`;return Object.defineProperty(window,r,{value:t=>(n&&Reflect.deleteProperty(window,r),e?.(t)),writable:!1,configurable:!0}),t}((e,t)=>{for(var r in t)n(e,r,{get:t[r],enumerable:!0})})({},{Channel:()=>s,PluginListener:()=>l,addPluginListener:()=>c,convertFileSrc:()=>u,invoke:()=>d,transformCallback:()=>a});var o,s=class{constructor(){this.__TAURI_CHANNEL_MARKER__=!0,((e,n,t)=>{if(n.has(e))throw TypeError("Cannot add the same private member more than once");n instanceof WeakSet?n.add(e):n.set(e,t)})(this,o,(()=>{})),this.id=a((e=>{r(this,o).call(this,e)}))}set onmessage(e){i(this,o,e)}get onmessage(){return r(this,o)}toJSON(){return`__CHANNEL__:${this.id}`}};o=new WeakMap;var l=class{constructor(e,n,t){this.plugin=e,this.event=n,this.channelId=t}async unregister(){return d(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}};async function c(e,n,t){let r=new s;return r.onmessage=t,d(`plugin:${e}|register_listener`,{event:n,handler:r}).then((()=>new l(e,n,r.id)))}async function d(e,n={}){return new Promise(((t,r)=>{let i=a((e=>{t(e),Reflect.deleteProperty(window,`_${o}`)}),!0),o=a((e=>{r(e),Reflect.deleteProperty(window,`_${i}`)}),!0);window.__TAURI_IPC__({cmd:e,callback:i,error:o,...n})}))}function u(e,n="asset"){let t=encodeURIComponent(e);return navigator.userAgent.includes("Windows")?`https://${n}.localhost/${t}`:`${n}://localhost/${t}`}class _{constructor(e){this.response=e}async downloadAndInstall(e){const n=new s;return null!=e&&(n.onmessage=e),d("plugin:updater|download_and_install",{onEvent:n})}}return e.Update=_,e.check=async function(e){return d("plugin:updater|check",{...e}).then((e=>new _(e)))},e}({});Object.defineProperty(window.__TAURI__,"updater",{value:__TAURI_UPDATER__})} +if("__TAURI__"in window){var __TAURI_UPDATER__=function(e){"use strict";var n=Object.defineProperty,t=(e,n,t)=>{if(!n.has(e))throw TypeError("Cannot "+t)},r=(e,n,r)=>(t(e,n,"read from private field"),r?r.call(e):n.get(e)),i=(e,n,r,i)=>(t(e,n,"write to private field"),i?i.call(e,r):n.set(e,r),r);function a(e,n=!1){let t=window.crypto.getRandomValues(new Uint32Array(1))[0],r=`_${t}`;return Object.defineProperty(window,r,{value:t=>(n&&Reflect.deleteProperty(window,r),e?.(t)),writable:!1,configurable:!0}),t}((e,t)=>{for(var r in t)n(e,r,{get:t[r],enumerable:!0})})({},{Channel:()=>o,PluginListener:()=>l,addPluginListener:()=>c,convertFileSrc:()=>u,invoke:()=>d,transformCallback:()=>a});var s,o=class{constructor(){this.__TAURI_CHANNEL_MARKER__=!0,((e,n,t)=>{if(n.has(e))throw TypeError("Cannot add the same private member more than once");n instanceof WeakSet?n.add(e):n.set(e,t)})(this,s,(()=>{})),this.id=a((e=>{r(this,s).call(this,e)}))}set onmessage(e){i(this,s,e)}get onmessage(){return r(this,s)}toJSON(){return`__CHANNEL__:${this.id}`}};s=new WeakMap;var l=class{constructor(e,n,t){this.plugin=e,this.event=n,this.channelId=t}async unregister(){return d(`plugin:${this.plugin}|remove_listener`,{event:this.event,channelId:this.channelId})}};async function c(e,n,t){let r=new o;return r.onmessage=t,d(`plugin:${e}|register_listener`,{event:n,handler:r}).then((()=>new l(e,n,r.id)))}async function d(e,n={}){return new Promise(((t,r)=>{let i=a((e=>{t(e),Reflect.deleteProperty(window,`_${s}`)}),!0),s=a((e=>{r(e),Reflect.deleteProperty(window,`_${i}`)}),!0);window.__TAURI_IPC__({cmd:e,callback:i,error:s,...n})}))}function u(e,n="asset"){let t=encodeURIComponent(e);return navigator.userAgent.includes("Windows")?`https://${n}.localhost/${t}`:`${n}://localhost/${t}`}class _{constructor(e){this.currentVersion=e.currentVersion,this.version=e.version,this.date=e.date,this.body=e.body}async downloadAndInstall(e){const n=new o;return null!=e&&(n.onmessage=e),d("plugin:updater|download_and_install",{onEvent:n})}}return e.Update=_,e.check=async function(e){return(null==e?void 0:e.headers)&&(e.headers=Array.from(new Headers(e.headers).entries())),d("plugin:updater|check",{...e}).then((e=>e.available?new _(e):null))},e}({});Object.defineProperty(window.__TAURI__,"updater",{value:__TAURI_UPDATER__})} diff --git a/plugins/updater/src/commands.rs b/plugins/updater/src/commands.rs index 75d78288..cd621dc9 100644 --- a/plugins/updater/src/commands.rs +++ b/plugins/updater/src/commands.rs @@ -4,59 +4,49 @@ use crate::{PendingUpdate, Result, UpdaterExt}; -use http::header; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::Serialize; use tauri::{api::ipc::Channel, AppHandle, Runtime, State}; -use std::{collections::HashMap, time::Duration}; +use std::{ + sync::atomic::{AtomicBool, Ordering}, + time::Duration, +}; -#[derive(Serialize)] +#[derive(Debug, Serialize)] +#[serde(tag = "event", content = "data")] +pub enum DownloadEvent { + #[serde(rename_all = "camelCase")] + Started { + content_length: Option, + }, + #[serde(rename_all = "camelCase")] + Progress { + chunk_length: usize, + }, + Finished, +} + +#[derive(Serialize, Default)] #[serde(rename_all = "camelCase")] pub(crate) struct Metadata { available: bool, current_version: String, - latest_version: String, + version: String, date: Option, body: Option, } -#[derive(Debug, Default)] -pub(crate) struct HeaderMap(header::HeaderMap); - -impl<'de> Deserialize<'de> for HeaderMap { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - let map = HashMap::::deserialize(deserializer)?; - let mut headers = header::HeaderMap::default(); - for (key, value) in map { - if let (Ok(key), Ok(value)) = ( - header::HeaderName::from_bytes(key.as_bytes()), - header::HeaderValue::from_str(&value), - ) { - headers.insert(key, value); - } else { - return Err(serde::de::Error::custom(format!( - "invalid header `{key}` `{value}`" - ))); - } - } - Ok(Self(headers)) - } -} - #[tauri::command] pub(crate) async fn check( app: AppHandle, - pending: State<'_, PendingUpdate>, - headers: Option, + pending: State<'_, PendingUpdate>, + headers: Option>, timeout: Option, target: Option, ) -> Result { - let mut builder = app.updater(); + let mut builder = app.updater_builder(); if let Some(headers) = headers { - for (k, v) in headers.0.iter() { + for (k, v) in headers { builder = builder.header(k, v)?; } } @@ -67,39 +57,46 @@ pub(crate) async fn check( builder = builder.target(target); } - let response = builder.check().await?; - - let metadata = Metadata { - available: response.is_update_available(), - current_version: response.current_version().to_string(), - latest_version: response.latest_version().to_string(), - date: response.date().map(|d| d.to_string()), - body: response.body().cloned(), - }; - - pending.0.lock().await.replace(response); + let updater = builder.build()?; + let update = updater.check().await?; + let mut metadata = Metadata::default(); + if let Some(update) = update { + metadata.available = true; + metadata.current_version = update.current_version.clone(); + metadata.version = update.version.clone(); + metadata.date = update.date.map(|d| d.to_string()); + metadata.body = update.body.clone(); + pending.0.lock().await.replace(update); + } Ok(metadata) } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DownloadProgress { - chunk_length: usize, - content_length: Option, -} - #[tauri::command] pub(crate) async fn download_and_install( _app: AppHandle, - pending: State<'_, PendingUpdate>, + pending: State<'_, PendingUpdate>, on_event: Channel, ) -> Result<()> { if let Some(pending) = &*pending.0.lock().await { + let first_chunk = AtomicBool::new(false); + let on_event_c = on_event.clone(); pending - .download_and_install(move |event| { - on_event.send(&event).unwrap(); - }) + .download_and_install( + move |chunk_length, content_length| { + if first_chunk.swap(false, Ordering::Acquire) { + on_event + .send(&DownloadEvent::Started { content_length }) + .unwrap(); + } + on_event + .send(&DownloadEvent::Progress { chunk_length }) + .unwrap(); + }, + move || { + on_event_c.send(&DownloadEvent::Finished).unwrap(); + }, + ) .await?; } Ok(()) diff --git a/plugins/updater/src/config.rs b/plugins/updater/src/config.rs index 9361ea00..4fa85183 100644 --- a/plugins/updater/src/config.rs +++ b/plugins/updater/src/config.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Deserializer}; use url::Url; /// Updater configuration. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Default)] pub struct Config { #[serde(default)] pub endpoints: Vec, diff --git a/plugins/updater/src/error.rs b/plugins/updater/src/error.rs index 20f2eb42..f12a7e12 100644 --- a/plugins/updater/src/error.rs +++ b/plugins/updater/src/error.rs @@ -2,92 +2,68 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use http::StatusCode; use serde::{Serialize, Serializer}; +use thiserror::Error; /// All errors that can occur while running the updater. -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Error)] #[non_exhaustive] pub enum Error { - /// IO Errors. - #[error("`{0}`")] + /// Endpoints are not sent. + #[error("Updater does not have any endpoints set.")] + EmptyEndpoints, + /// IO errors. + #[error(transparent)] Io(#[from] std::io::Error), - /// Semver Errors. - #[error("Unable to compare version: {0}")] + /// Semver errors. + #[error(transparent)] Semver(#[from] semver::Error), - /// JSON (Serde) Errors. - #[error("JSON error: {0}")] - SerdeJson(#[from] serde_json::Error), - /// Minisign is used for signature validation. - #[error("Verify signature error: {0}")] - Minisign(#[from] minisign_verify::Error), - /// Error with Minisign base64 decoding. - #[error("Signature decoding error: {0}")] - Base64(#[from] base64::DecodeError), - /// UTF8 Errors in signature. - #[error("The signature {0} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string.")] - SignatureUtf8(String), - /// Tauri utils, mainly extract and file move. - #[error("Tauri API error: {0}")] - TauriApi(#[from] tauri::api::Error), - /// Network error. - #[error("Download request failed with status: {0}")] - DownloadFailed(StatusCode), - /// Network error. - #[error("Network error: {0}")] - Network(#[from] reqwest::Error), - /// Failed to serialize header value as string. + /// Serialization errors. #[error(transparent)] - Utf8(#[from] std::string::FromUtf8Error), + Serialization(#[from] serde_json::Error), /// Could not fetch a valid response from the server. #[error("Could not fetch a valid release JSON from the remote")] ReleaseNotFound, - /// Error building updater. - #[error("Unable to prepare the updater: {0}")] - Builder(String), - /// Error building updater. - #[error("Unable to extract the new version: {0}")] - Extract(String), - /// Updater cannot be executed on this Linux package. Currently the updater is enabled only on AppImages. - #[error( - "Cannot run updater on this Linux package. Currently only an AppImage can be updated." - )] - UnsupportedLinuxPackage, - /// Operating system is not supported. - #[error("unsupported OS, expected one of `linux`, `darwin` or `windows`.")] - UnsupportedOs, /// Unsupported app architecture. #[error( "Unsupported application architecture, expected one of `x86`, `x86_64`, `arm` or `aarch64`." )] UnsupportedArch, + /// Operating system is not supported. + #[error("Unsupported OS, expected one of `linux`, `darwin` or `windows`.")] + UnsupportedOs, + /// Failed to determine updater package extract path + #[error("Failed to determine updater package extract path.")] + FailedToDetermineExtractPath, + /// Url parsing errors. + #[error(transparent)] + UrlParse(#[from] url::ParseError), + /// `reqwest` crate errors. + #[error(transparent)] + Reqwest(#[from] reqwest::Error), /// The platform was not found on the updater JSON response. #[error("the platform `{0}` was not found on the response `platforms` object")] TargetNotFound(String), - /// Triggered when there is NO error and the two versions are equals. - /// On client side, it's important to catch this error. - #[error("No updates available")] - UpToDate, - /// The updater responded with an invalid signature type. - #[error("the updater response field `{0}` type is invalid, expected {1} but found {2}")] - InvalidResponseType(&'static str, &'static str, serde_json::Value), - /// HTTP error. + /// Download failed + #[error("`{0}`")] + Network(String), + /// `minisign_verify` errors. #[error(transparent)] - Http(#[from] http::Error), + Minisign(#[from] minisign_verify::Error), + /// `base64` errors. + #[error(transparent)] + Base64(#[from] base64::DecodeError), + /// UTF8 Errors in signature. + #[error("The signature {0} could not be decoded, please check if it is a valid base64 string. The signature must be the contents of the `.sig` file generated by the Tauri bundler, as a string.")] + SignatureUtf8(String), + /// `zip` errors. + #[error(transparent)] + Extract(#[from] zip::result::ZipError), /// Temp dir is not on same mount mount. This prevents our updater to rename the AppImage to a temp file. - #[cfg(target_os = "linux")] #[error("temp directory is not on the same mount point as the AppImage")] TempDirNotOnSameMountPoint, - /// The path StripPrefixError error. - #[error("Path Error: {0}")] - PathPrefix(#[from] std::path::StripPrefixError), - /// Ignore error. - #[error("failed to walkdir: {0}")] - Ignore(#[from] ignore::Error), - /// Zip error. - #[cfg(windows)] #[error(transparent)] - ZipError(#[from] zip::result::ZipError), + Http(#[from] http::Error), } impl Serialize for Error { @@ -98,3 +74,5 @@ impl Serialize for Error { serializer.serialize_str(self.to_string().as_ref()) } } + +pub type Result = std::result::Result; diff --git a/plugins/updater/src/lib.rs b/plugins/updater/src/lib.rs index e9de6643..cb239102 100644 --- a/plugins/updater/src/lib.rs +++ b/plugins/updater/src/lib.rs @@ -14,38 +14,26 @@ )] use tauri::{ + async_runtime::Mutex, plugin::{Builder as PluginBuilder, TauriPlugin}, Manager, Runtime, }; -use tokio::sync::Mutex; - mod commands; mod config; mod error; mod updater; pub use config::Config; -pub use error::Error; +pub use error::{Error, Result}; pub use updater::*; -pub type Result = std::result::Result; - -struct UpdaterState { - target: Option, - config: Config, -} -struct PendingUpdate(Mutex>>); - -#[derive(Default)] -pub struct Builder { - target: Option, - installer_args: Option>, -} +struct PendingUpdate(Mutex>); /// Extension trait to use the updater on [`tauri::App`], [`tauri::AppHandle`] and [`tauri::Window`]. pub trait UpdaterExt { - /// Gets the updater builder to manually check if an update is available. + /// Gets the updater builder to build and updater + /// that can manually check if an update is available. /// /// # Examples /// @@ -55,18 +43,75 @@ pub trait UpdaterExt { /// .setup(|app| { /// let handle = app.handle(); /// tauri::async_runtime::spawn(async move { - /// let response = handle.updater().check().await; + /// let response = handle.updater_builder().build().unwrap().check().await; /// }); /// Ok(()) /// }); /// ``` - fn updater(&self) -> updater::UpdateBuilder; + fn updater_builder(&self) -> UpdaterBuilder; + + /// Gets the updater to manually check if an update is available. + /// + /// # Examples + /// + /// ```no_run + /// use tauri_plugin_updater::UpdaterExt; + /// tauri::Builder::default() + /// .setup(|app| { + /// let handle = app.handle(); + /// tauri::async_runtime::spawn(async move { + /// let response = handle.updater().unwrap().check().await; + /// }); + /// Ok(()) + /// }); + /// ``` + fn updater(&self) -> Result; } impl> UpdaterExt for T { - fn updater(&self) -> updater::UpdateBuilder { - updater::builder(self.app_handle()) + fn updater_builder(&self) -> UpdaterBuilder { + let app = self.app_handle(); + let version = app.package_info().version.clone(); + let updater_config = app.config().tauri.bundle.updater.clone(); + let UpdaterState { config, target } = self.state::().inner(); + + let mut builder = UpdaterBuilder::new(version, config.clone(), updater_config); + + if let Some(target) = target { + builder = builder.target(target); + } + + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + { + let env = app.env(); + if let Some(appimage) = env.appimage { + builder = builder.executable_path(appimage); + } + } + + builder } + + fn updater(&self) -> Result { + self.updater_builder().build() + } +} + +struct UpdaterState { + target: Option, + config: Config, +} + +#[derive(Default)] +pub struct Builder { + target: Option, + installer_args: Option>, } impl Builder { @@ -100,7 +145,7 @@ impl Builder { config.installer_args = installer_args; } app.manage(UpdaterState { target, config }); - app.manage(PendingUpdate::(Default::default())); + app.manage(PendingUpdate(Default::default())); Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/plugins/updater/src/updater.rs b/plugins/updater/src/updater.rs new file mode 100644 index 00000000..385e1e87 --- /dev/null +++ b/plugins/updater/src/updater.rs @@ -0,0 +1,862 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{ + collections::HashMap, + io::{Cursor, Read}, + path::{Path, PathBuf}, + str::FromStr, + time::Duration, +}; + +use base64::Engine; +use futures_util::StreamExt; +use http::HeaderName; +use minisign_verify::{PublicKey, Signature}; +use reqwest::{ + header::{HeaderMap, HeaderValue}, + Client, StatusCode, +}; +use semver::Version; +use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; +use tauri::utils::{config::UpdaterConfig, platform::current_exe}; +use time::OffsetDateTime; +use url::Url; + +use crate::error::{Error, Result}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ReleaseManifestPlatform { + /// Download URL for the platform + pub url: Url, + /// Signature for the platform + pub signature: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(untagged)] +pub enum RemoteReleaseInner { + Dynamic(ReleaseManifestPlatform), + Static { + platforms: HashMap, + }, +} + +/// Information about a release returned by the remote update server. +/// +/// This type can have one of two shapes: Server Format (Dynamic Format) and Static Format. +#[derive(Debug, Clone)] +pub struct RemoteRelease { + /// Version to install. + pub version: Version, + /// Release notes. + pub notes: Option, + /// Release date. + pub pub_date: Option, + /// Release data. + pub data: RemoteReleaseInner, +} + +impl RemoteRelease { + /// The release's download URL for the given target. + pub fn download_url(&self, target: &str) -> Result<&Url> { + match self.data { + RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.url), + RemoteReleaseInner::Static { ref platforms } => platforms + .get(target) + .map_or(Err(Error::TargetNotFound(target.to_string())), |p| { + Ok(&p.url) + }), + } + } + + /// The release's signature for the given target. + pub fn signature(&self, target: &str) -> Result<&String> { + match self.data { + RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.signature), + RemoteReleaseInner::Static { ref platforms } => platforms + .get(target) + .map_or(Err(Error::TargetNotFound(target.to_string())), |platform| { + Ok(&platform.signature) + }), + } + } +} + +pub struct UpdaterBuilder { + current_version: Version, + config: crate::Config, + updater_config: UpdaterConfig, + version_comparator: Option bool + Send + Sync>>, + executable_path: Option, + target: Option, + endpoints: Option>, + headers: HeaderMap, + timeout: Option, + installer_args: Option>, +} + +impl UpdaterBuilder { + pub fn new( + current_version: Version, + config: crate::Config, + updater_config: UpdaterConfig, + ) -> Self { + Self { + current_version, + config, + updater_config, + version_comparator: None, + executable_path: None, + target: None, + endpoints: None, + headers: Default::default(), + timeout: None, + installer_args: None, + } + } + + pub fn version_comparator bool + Send + Sync + 'static>( + mut self, + f: F, + ) -> Self { + self.version_comparator = Some(Box::new(f)); + self + } + + pub fn target(mut self, target: impl Into) -> Self { + self.target.replace(target.into()); + self + } + + pub fn endpoints(mut self, endpoints: Vec) -> Self { + self.endpoints.replace(endpoints); + self + } + + pub fn executable_path>(mut self, p: P) -> Self { + self.executable_path.replace(p.as_ref().into()); + self + } + + pub fn header(mut self, key: K, value: V) -> Result + where + HeaderName: TryFrom, + >::Error: Into, + HeaderValue: TryFrom, + >::Error: Into, + { + let key: std::result::Result = key.try_into().map_err(Into::into); + let value: std::result::Result = + value.try_into().map_err(Into::into); + self.headers.insert(key?, value?); + + Ok(self) + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn installer_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.installer_args + .replace(args.into_iter().map(Into::into).collect()); + self + } + + pub fn build(self) -> Result { + let endpoints = self + .endpoints + .unwrap_or_else(|| self.config.endpoints.into_iter().map(|e| e.0).collect()); + + if endpoints.is_empty() { + return Err(Error::EmptyEndpoints); + }; + + let arch = get_updater_arch().ok_or(Error::UnsupportedArch)?; + let (target, json_target) = if let Some(target) = self.target { + (target.clone(), target) + } else { + let target = get_updater_target().ok_or(Error::UnsupportedOs)?; + (target.to_string(), format!("{target}-{arch}")) + }; + + let executable_path = self.executable_path.clone().unwrap_or(current_exe()?); + + // Get the extract_path from the provided executable_path + let extract_path = if cfg!(target_os = "linux") { + executable_path + } else { + extract_path_from_executable(&executable_path)? + }; + + Ok(Updater { + config: self.updater_config, + current_version: self.current_version, + version_comparator: self.version_comparator, + timeout: self.timeout, + endpoints, + installer_args: self.installer_args.unwrap_or(self.config.installer_args), + arch, + target, + json_target, + headers: self.headers, + extract_path, + }) + } +} + +pub struct Updater { + config: UpdaterConfig, + current_version: Version, + version_comparator: Option bool + Send + Sync>>, + timeout: Option, + endpoints: Vec, + #[allow(dead_code)] + installer_args: Vec, + arch: &'static str, + // The `{{target}}` variable we replace in the endpoint + target: String, + // The value we search if the updater server returns a JSON with the `platforms` object + json_target: String, + headers: HeaderMap, + extract_path: PathBuf, +} + +impl Updater { + pub async fn check(&self) -> Result> { + // we want JSON only + let mut headers = self.headers.clone(); + headers.insert("Accept", HeaderValue::from_str("application/json").unwrap()); + + // Set SSL certs for linux if they aren't available. + #[cfg(target_os = "linux")] + { + if std::env::var_os("SSL_CERT_FILE").is_none() { + std::env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); + } + if std::env::var_os("SSL_CERT_DIR").is_none() { + std::env::set_var("SSL_CERT_DIR", "/etc/ssl/certs"); + } + } + + let mut remote_release: Option = None; + let mut last_error: Option = None; + for url in &self.endpoints { + // replace {{current_version}}, {{target}} and {{arch}} in the provided URL + // this is useful if we need to query example + // https://releases.myapp.com/update/{{target}}/{{arch}}/{{current_version}} + // will be translated into -> + // https://releases.myapp.com/update/darwin/aarch64/1.0.0 + // The main objective is if the update URL is defined via the Cargo.toml + // the URL will be generated dynamically + let url: Url = url + .to_string() + .replace("{{current_version}}", &self.current_version.to_string()) + .replace("{{target}}", &self.target) + .replace("{{arch}}", self.arch) + .parse()?; + + let mut request = Client::new().get(url).headers(headers.clone()); + if let Some(timeout) = self.timeout { + request = request.timeout(timeout); + } + let response = request.send().await; + + if let Ok(res) = response { + if res.status().is_success() { + // no updates found! + if StatusCode::NO_CONTENT == res.status() { + return Ok(None); + }; + + match serde_json::from_value::(res.json().await?) + .map_err(Into::into) + { + Ok(release) => { + last_error = None; + remote_release = Some(release); + // we found a relase, break the loop + break; + } + Err(err) => last_error = Some(err), + } + } + } + } + + // Last error is cleaned on success. + // Shouldn't be triggered if we had a successfull call + if let Some(error) = last_error { + return Err(error); + } + + // Extracted remote metadata + let release = remote_release.ok_or(Error::ReleaseNotFound)?; + + let should_update = match self.version_comparator.as_ref() { + Some(comparator) => comparator(self.current_version.clone(), release.clone()), + None => release.version > self.current_version, + }; + + let update = if should_update { + Some(Update { + current_version: self.current_version.to_string(), + config: self.config.clone(), + target: self.target.clone(), + extract_path: self.extract_path.clone(), + installer_args: self.installer_args.clone(), + version: release.version.to_string(), + date: release.pub_date, + download_url: release.download_url(&self.json_target)?.to_owned(), + body: release.notes.clone(), + signature: release.signature(&self.json_target)?.to_owned(), + timeout: self.timeout, + headers: self.headers.clone(), + }) + } else { + None + }; + + Ok(update) + } +} + +#[derive(Debug, Clone)] +pub struct Update { + config: UpdaterConfig, + /// Update description + pub body: Option, + /// Version used to check for update + pub current_version: String, + /// Version announced + pub version: String, + /// Update publish date + pub date: Option, + /// Target + pub target: String, + /// Extract path + #[allow(unused)] + extract_path: PathBuf, + #[allow(unused)] + installer_args: Vec, + /// Download URL announced + pub download_url: Url, + /// Signature announced + pub signature: String, + /// Request timeout + pub timeout: Option, + /// Request headers + pub headers: HeaderMap, +} + +impl Update { + /// Downloads the updater package, verifies it then return it as bytes. + /// + /// Use [`Update::install`] to install it + pub async fn download), D: FnOnce()>( + &self, + on_chunk: C, + on_download_finish: D, + ) -> Result> { + // set our headers + let mut headers = self.headers.clone(); + headers.insert( + "Accept", + HeaderValue::from_str("application/octet-stream").unwrap(), + ); + headers.insert( + "User-Agent", + HeaderValue::from_str("tauri-updater").unwrap(), + ); + + let mut request = Client::new() + .get(self.download_url.clone()) + .headers(headers); + if let Some(timeout) = self.timeout { + request = request.timeout(timeout); + } + let response = request.send().await?; + + if !response.status().is_success() { + return Err(Error::Network(format!( + "Download request failed with status: {}", + response.status() + ))); + } + + let content_length: Option = response + .headers() + .get("Content-Length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()); + + let mut buffer = Vec::new(); + + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + let bytes = chunk.as_ref().to_vec(); + on_chunk(bytes.len(), content_length); + buffer.extend(bytes); + } + + on_download_finish(); + + let mut update_buffer = Cursor::new(&buffer); + + verify_signature(&mut update_buffer, &self.signature, &self.config.pubkey)?; + + Ok(buffer) + } + + /// Installs the updater package downloaded by [`Update::download`] + pub fn install(&self, bytes: Vec) -> Result<()> { + self.install_inner(bytes) + } + + /// Downloads and installs the updater package + pub async fn download_and_install), D: FnOnce()>( + &self, + on_chunk: C, + on_download_finish: D, + ) -> Result<()> { + let bytes = self.download(on_chunk, on_download_finish).await?; + self.install(bytes) + } + + #[cfg(any(target_os = "android", target_os = "ios"))] + fn install_inner(&self, bytes: Vec) -> Result<()> { + Ok(()) + } + + // Windows + // + // ### Expected structure: + // ├── [AppName]_[version]_x64.msi.zip # ZIP generated by tauri-bundler + // │ └──[AppName]_[version]_x64.msi # Application MSI + // ├── [AppName]_[version]_x64-setup.exe.zip # ZIP generated by tauri-bundler + // │ └──[AppName]_[version]_x64-setup.exe # NSIS installer + // └── ... + // + // ## MSI + // Update server can provide a MSI for Windows. (Generated with tauri-bundler from *Wix*) + // To replace current version of the application. In later version we'll offer + // incremental update to push specific binaries. + // + // ## EXE + // Update server can provide a custom EXE (installer) who can run any task. + #[cfg(windows)] + fn install_inner(&self, bytes: Vec) -> Result<()> { + use std::{ffi::OsStr, fs, process::Command}; + + // FIXME: We need to create a memory buffer with the MSI and then run it. + // (instead of extracting the MSI to a temp path) + // + // The tricky part is the MSI need to be exposed and spawned so the memory allocation + // shouldn't drop but we should be able to pass the reference so we can drop it once the installation + // is done, otherwise we have a huge memory leak. + + let archive = Cursor::new(bytes); + + let tmp_dir = tempfile::Builder::new().tempdir()?.into_path(); + + // extract the buffer to the tmp_dir + // we extract our signed archive into our final directory without any temp file + let mut extractor = zip::ZipArchive::new(archive)?; + + // extract the msi + extractor.extract(&tmp_dir)?; + + let paths = fs::read_dir(&tmp_dir)?; + + let system_root = std::env::var("SYSTEMROOT"); + let powershell_path = system_root.as_ref().map_or_else( + |_| "powershell.exe".to_string(), + |p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"), + ); + + for path in paths { + let found_path = path?.path(); + // we support 2 type of files exe & msi for now + // If it's an `exe` we expect an installer not a runtime. + if found_path.extension() == Some(OsStr::new("exe")) { + // we need to wrap the installer path in quotes for Start-Process + let mut installer_arg = std::ffi::OsString::new(); + installer_arg.push("\""); + installer_arg.push(&found_path); + installer_arg.push("\""); + + // Run the installer + Command::new(powershell_path) + .args(["-NoProfile", "-WindowStyle", "Hidden"]) + .args(["Start-Process"]) + .arg(found_path) + .arg("-ArgumentList") + .arg( + [ + self.config.windows.install_mode.nsis_args(), + self.installer_args + .iter() + .map(AsRef::as_ref) + .collect::>() + .as_slice(), + ] + .concat() + .join(", "), + ) + .spawn() + .expect("installer failed to start"); + + std::process::exit(0); + } else if found_path.extension() == Some(OsStr::new("msi")) { + // we need to wrap the current exe path in quotes for Start-Process + let mut current_exe_arg = std::ffi::OsString::new(); + current_exe_arg.push("\""); + current_exe_arg.push(current_exe()?); + current_exe_arg.push("\""); + + let mut msi_path_arg = std::ffi::OsString::new(); + msi_path_arg.push("\"\"\""); + msi_path_arg.push(&found_path); + msi_path_arg.push("\"\"\""); + + let msiexec_args = self + .config + .windows + .install_mode + .msiexec_args() + .iter() + .map(|p| p.to_string()) + .collect::>(); + + // run the installer and relaunch the application + let powershell_install_res = Command::new(powershell_path) + .args(["-NoProfile", "-WindowStyle", "Hidden"]) + .args([ + "Start-Process", + "-Wait", + "-FilePath", + "$env:SYSTEMROOT\\System32\\msiexec.exe", + "-ArgumentList", + ]) + .arg("/i,") + .arg(msi_path_arg) + .arg(format!(", {}, /promptrestart;", msiexec_args.join(", "))) + .arg("Start-Process") + .arg(current_exe_arg) + .spawn(); + if powershell_install_res.is_err() { + // fallback to running msiexec directly - relaunch won't be available + // we use this here in case powershell fails in an older machine somehow + let msiexec_path = system_root.as_ref().map_or_else( + |_| "msiexec.exe".to_string(), + |p| format!("{p}\\System32\\msiexec.exe"), + ); + let _ = Command::new(msiexec_path) + .arg("/i") + .arg(found_path) + .args(msiexec_args) + .arg("/promptrestart") + .spawn(); + } + + std::process::exit(0); + } + } + + Ok(()) + } + + // Linux (AppImage) + // + // ### Expected structure: + // ├── [AppName]_[version]_amd64.AppImage.tar.gz # GZ generated by tauri-bundler + // │ └──[AppName]_[version]_amd64.AppImage # Application AppImage + // └── ... + // + // We should have an AppImage already installed to be able to copy and install + // the extract_path is the current AppImage path + // tmp_dir is where our new AppImage is found + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ))] + fn install_inner(&self, bytes: Vec) -> Result<()> { + use std::{ + ffi::OsStr, + os::unix::fs::{MetadataExt, PermissionsExt}, + }; + let archive = Cursor::new(bytes); + let extract_path_metadata = self.extract_path.metadata()?; + + let tmp_dir_locations = vec![ + Box::new(|| Some(std::env::temp_dir())) as Box Option>, + Box::new(dirs_next::cache_dir), + Box::new(|| Some(self.extract_path.parent().unwrap().to_path_buf())), + ]; + + for tmp_dir_location in tmp_dir_locations { + if let Some(tmp_dir_location) = tmp_dir_location() { + let tmp_dir = tempfile::Builder::new() + .prefix("tauri_current_app") + .tempdir_in(tmp_dir_location)?; + let tmp_dir_metadata = tmp_dir.path().metadata()?; + + if extract_path_metadata.dev() == tmp_dir_metadata.dev() { + let mut perms = tmp_dir_metadata.permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(tmp_dir.path(), perms)?; + + let tmp_app_image = &tmp_dir.path().join("current_app.AppImage"); + + // create a backup of our current app image + std::fs::rename(&self.extract_path, tmp_app_image)?; + + // extract the buffer to the tmp_dir + // we extract our signed archive into our final directory without any temp file + let mut archive = tar::Archive::new(archive); + for mut entry in archive.entries()?.flatten() { + if let Ok(path) = entry.path() { + if path.extension() == Some(OsStr::new("AppImage")) { + // if something went wrong during the extraction, we should restore previous app + if let Err(err) = entry.unpack(&self.extract_path) { + std::fs::rename(tmp_app_image, &self.extract_path)?; + return Err(err.into()); + } + // early finish we have everything we need here + return Ok(()); + } + } + } + + return Ok(()); + } + } + } + + Err(Error::TempDirNotOnSameMountPoint) + } + + // MacOS + // + // ### Expected structure: + // ├── [AppName]_[version]_x64.app.tar.gz # GZ generated by tauri-bundler + // │ └──[AppName].app # Main application + // │ └── Contents # Application contents... + // │ └── ... + // └── ... + #[cfg(target_os = "macos")] + fn install_inner(&self, bytes: Vec) -> Result<()> { + let archive = Cursor::new(bytes); + let mut extracted_files: Vec = Vec::new(); + + // the first file in the tar.gz will always be + // /Contents + let tmp_dir = tempfile::Builder::new() + .prefix("tauri_current_app") + .tempdir()?; + + // create backup of our current app + std::fs::rename(&self.extract_path, tmp_dir.path())?; + + let mut archive = tar::Archive::new(archive); + for mut entry in archive.entries()?.flatten() { + if let Ok(path) = entry.path() { + // skip the first folder (should be the app name) + let collected_path: PathBuf = path.iter().skip(1).collect(); + let extraction_path = &self.extract_path.join(collected_path); + + // if something went wrong during the extraction, we should restore previous app + if let Err(err) = entry.unpack(extraction_path) { + for file in &extracted_files { + // delete all the files we extracted + if file.is_dir() { + std::fs::remove_dir(file)?; + } else { + std::fs::remove_file(file)?; + } + } + std::fs::rename(tmp_dir.path(), &self.extract_path)?; + return Err(err.into()); + } + + extracted_files.push(extraction_path.to_path_buf()); + } + } + + let _ = std::process::Command::new("touch") + .arg(&self.extract_path) + .status(); + + Ok(()) + } +} + +/// Gets the target string used on the updater. +pub fn target() -> Option { + if let (Some(target), Some(arch)) = (get_updater_target(), get_updater_arch()) { + Some(format!("{target}-{arch}")) + } else { + None + } +} + +pub(crate) fn get_updater_target() -> Option<&'static str> { + if cfg!(target_os = "linux") { + Some("linux") + } else if cfg!(target_os = "macos") { + // TODO shouldn't this be macos instead? + Some("darwin") + } else if cfg!(target_os = "windows") { + Some("windows") + } else { + None + } +} + +pub(crate) fn get_updater_arch() -> Option<&'static str> { + if cfg!(target_arch = "x86") { + Some("i686") + } else if cfg!(target_arch = "x86_64") { + Some("x86_64") + } else if cfg!(target_arch = "arm") { + Some("armv7") + } else if cfg!(target_arch = "aarch64") { + Some("aarch64") + } else { + None + } +} + +pub fn extract_path_from_executable(executable_path: &Path) -> Result { + // Return the path of the current executable by default + // Example C:\Program Files\My App\ + let extract_path = executable_path + .parent() + .map(PathBuf::from) + .ok_or(Error::FailedToDetermineExtractPath)?; + + // MacOS example binary is in /Applications/TestApp.app/Contents/MacOS/myApp + // We need to get /Applications/.app + // TODO(lemarier): Need a better way here + // Maybe we could search for <*.app> to get the right path + #[cfg(target_os = "macos")] + if extract_path + .display() + .to_string() + .contains("Contents/MacOS") + { + return extract_path + .parent() + .map(PathBuf::from) + .ok_or(Error::FailedToDetermineExtractPath)? + .parent() + .map(PathBuf::from) + .ok_or(Error::FailedToDetermineExtractPath); + } + + Ok(extract_path) +} + +impl<'de> Deserialize<'de> for RemoteRelease { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct InnerRemoteRelease { + #[serde(alias = "name", deserialize_with = "parse_version")] + version: Version, + notes: Option, + pub_date: Option, + platforms: Option>, + // dynamic platform response + url: Option, + signature: Option, + } + + let release = InnerRemoteRelease::deserialize(deserializer)?; + + let pub_date = if let Some(date) = release.pub_date { + Some( + OffsetDateTime::parse(&date, &time::format_description::well_known::Rfc3339) + .map_err(|e| DeError::custom(format!("invalid value for `pub_date`: {e}")))?, + ) + } else { + None + }; + + Ok(RemoteRelease { + version: release.version, + notes: release.notes, + pub_date, + data: if let Some(platforms) = release.platforms { + RemoteReleaseInner::Static { platforms } + } else { + RemoteReleaseInner::Dynamic(ReleaseManifestPlatform { + url: release.url.ok_or_else(|| { + DeError::custom("the `url` field was not set on the updater response") + })?, + signature: release.signature.ok_or_else(|| { + DeError::custom("the `signature` field was not set on the updater response") + })?, + }) + }, + }) + } +} + +fn parse_version<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let str = String::deserialize(deserializer)?; + + Version::from_str(str.trim_start_matches('v')).map_err(serde::de::Error::custom) +} + +// Validate signature +// need to be public because its been used +// by our tests in the bundler +// +// NOTE: The buffer position is not reset. +pub fn verify_signature( + archive_reader: &mut R, + release_signature: &str, + pub_key: &str, +) -> Result +where + R: Read, +{ + // we need to convert the pub key + let pub_key_decoded = base64_to_string(pub_key)?; + let public_key = PublicKey::decode(&pub_key_decoded)?; + let signature_base64_decoded = base64_to_string(release_signature)?; + let signature = Signature::decode(&signature_base64_decoded)?; + + // read all bytes until EOF in the buffer + let mut data = Vec::new(); + archive_reader.read_to_end(&mut data)?; + + // Validate signature or bail out + public_key.verify(&data, &signature, true)?; + Ok(true) +} + +fn base64_to_string(base64_string: &str) -> Result { + let decoded_string = &base64::engine::general_purpose::STANDARD.decode(base64_string)?; + let result = std::str::from_utf8(decoded_string) + .map_err(|_| Error::SignatureUtf8(base64_string.into()))? + .to_string(); + Ok(result) +} diff --git a/plugins/updater/src/updater/core.rs b/plugins/updater/src/updater/core.rs deleted file mode 100644 index 986586e2..00000000 --- a/plugins/updater/src/updater/core.rs +++ /dev/null @@ -1,1696 +0,0 @@ -// Copyright 2019-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -#[cfg(desktop)] -use super::{ - extract::{ArchiveFormat, Extract}, - move_file::Move, -}; -use crate::{Error, Result}; -use base64::Engine; -use futures_util::StreamExt; -use http::{ - header::{HeaderName, HeaderValue}, - HeaderMap, Method, StatusCode, -}; -use minisign_verify::{PublicKey, Signature}; -use reqwest::ClientBuilder; -use semver::Version; -use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize}; -use tauri::utils::{platform::current_exe, Env}; -use tauri::{AppHandle, Manager, Runtime}; -use time::OffsetDateTime; -use url::Url; - -#[cfg(desktop)] -use std::io::Seek; -use std::{ - collections::HashMap, - env, - fmt::{self}, - io::{Cursor, Read}, - path::{Path, PathBuf}, - str::{from_utf8, FromStr}, - time::Duration, -}; - -#[cfg(any(target_os = "linux", windows))] -use std::ffi::OsStr; - -#[cfg(all(desktop, not(target_os = "windows")))] -use super::extract::Compression; - -#[cfg(target_os = "windows")] -use std::{ - fs::read_dir, - process::{exit, Command}, -}; - -type ShouldInstall = dyn FnOnce(&Version, &RemoteRelease) -> bool + Send; - -#[derive(Debug, Deserialize, Serialize)] -#[serde(untagged)] -pub enum RemoteReleaseInner { - Dynamic(ReleaseManifestPlatform), - Static { - platforms: HashMap, - }, -} - -/// Information about a release returned by the remote update server. -/// -/// This type can have one of two shapes: Server Format (Dynamic Format) and Static Format. -#[derive(Debug)] -pub struct RemoteRelease { - /// Version to install. - version: Version, - /// Release notes. - notes: Option, - /// Release date. - pub_date: Option, - /// Release data. - data: RemoteReleaseInner, -} - -impl<'de> Deserialize<'de> for RemoteRelease { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - struct InnerRemoteRelease { - #[serde(alias = "name", deserialize_with = "parse_version")] - version: Version, - notes: Option, - pub_date: Option, - platforms: Option>, - // dynamic platform response - url: Option, - signature: Option, - #[cfg(target_os = "windows")] - #[serde(default)] - with_elevated_task: bool, - } - - let release = InnerRemoteRelease::deserialize(deserializer)?; - - let pub_date = if let Some(date) = release.pub_date { - Some( - OffsetDateTime::parse(&date, &time::format_description::well_known::Rfc3339) - .map_err(|e| DeError::custom(format!("invalid value for `pub_date`: {e}")))?, - ) - } else { - None - }; - - Ok(RemoteRelease { - version: release.version, - notes: release.notes, - pub_date, - data: if let Some(platforms) = release.platforms { - RemoteReleaseInner::Static { platforms } - } else { - RemoteReleaseInner::Dynamic(ReleaseManifestPlatform { - url: release.url.ok_or_else(|| { - DeError::custom("the `url` field was not set on the updater response") - })?, - signature: release.signature.ok_or_else(|| { - DeError::custom("the `signature` field was not set on the updater response") - })?, - #[cfg(target_os = "windows")] - with_elevated_task: release.with_elevated_task, - }) - }, - }) - } -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct ReleaseManifestPlatform { - /// Download URL for the platform - pub url: Url, - /// Signature for the platform - pub signature: String, - #[cfg(target_os = "windows")] - #[serde(default)] - /// Optional: Windows only try to use elevated task - pub with_elevated_task: bool, -} - -fn parse_version<'de, D>(deserializer: D) -> std::result::Result -where - D: serde::Deserializer<'de>, -{ - let str = String::deserialize(deserializer)?; - - Version::from_str(str.trim_start_matches('v')).map_err(serde::de::Error::custom) -} - -impl RemoteRelease { - /// The release version. - pub fn version(&self) -> &Version { - &self.version - } - - /// The release notes. - pub fn notes(&self) -> Option<&String> { - self.notes.as_ref() - } - - /// The release date. - pub fn pub_date(&self) -> Option<&OffsetDateTime> { - self.pub_date.as_ref() - } - - /// The release's download URL for the given target. - pub fn download_url(&self, target: &str) -> Result<&Url> { - match self.data { - RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.url), - RemoteReleaseInner::Static { ref platforms } => platforms - .get(target) - .map_or(Err(Error::TargetNotFound(target.to_string())), |p| { - Ok(&p.url) - }), - } - } - - /// The release's signature for the given target. - pub fn signature(&self, target: &str) -> Result<&String> { - match self.data { - RemoteReleaseInner::Dynamic(ref platform) => Ok(&platform.signature), - RemoteReleaseInner::Static { ref platforms } => platforms - .get(target) - .map_or(Err(Error::TargetNotFound(target.to_string())), |platform| { - Ok(&platform.signature) - }), - } - } - - #[cfg(target_os = "windows")] - /// Optional: Windows only try to use elevated task - pub fn with_elevated_task(&self, target: &str) -> Result { - match self.data { - RemoteReleaseInner::Dynamic(ref platform) => Ok(platform.with_elevated_task), - RemoteReleaseInner::Static { ref platforms } => platforms - .get(target) - .map_or(Err(Error::TargetNotFound(target.to_string())), |platform| { - Ok(platform.with_elevated_task) - }), - } - } -} - -pub struct UpdateBuilder { - /// Application handle. - pub app: AppHandle, - /// Current version we are running to compare with announced version - pub current_version: Version, - /// The URLs to checks updates. We suggest at least one fallback on a different domain. - pub urls: Vec, - /// The platform the updater will check and install the update. Default is from `get_updater_target` - pub target: Option, - /// The current executable path. Default is automatically extracted. - pub executable_path: Option, - should_install: Option>, - timeout: Option, - headers: HeaderMap, -} - -impl fmt::Debug for UpdateBuilder { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("UpdateBuilder") - .field("app", &self.app) - .field("current_version", &self.current_version) - .field("urls", &self.urls) - .field("target", &self.target) - .field("executable_path", &self.executable_path) - .field("timeout", &self.timeout) - .field("headers", &self.headers) - .finish() - } -} - -// Create new updater instance and return an Update -impl UpdateBuilder { - pub fn new(app: AppHandle) -> Self { - UpdateBuilder { - app, - urls: Vec::new(), - target: None, - executable_path: None, - // safe to unwrap: CARGO_PKG_VERSION is also a valid semver value - current_version: env!("CARGO_PKG_VERSION").parse().unwrap(), - should_install: None, - timeout: None, - headers: Default::default(), - } - } - - #[allow(dead_code)] - pub fn url(mut self, url: String) -> Self { - self.urls.push( - percent_encoding::percent_decode(url.as_bytes()) - .decode_utf8_lossy() - .to_string(), - ); - self - } - - /// Add multiple URLS at once inside a Vec for future reference - pub fn urls(mut self, urls: &[String]) -> Self { - let mut formatted_vec: Vec = Vec::new(); - for url in urls { - formatted_vec.push( - percent_encoding::percent_decode(url.as_bytes()) - .decode_utf8_lossy() - .to_string(), - ); - } - self.urls = formatted_vec; - self - } - - /// Set the current app version, used to compare against the latest available version. - /// The `cargo_crate_version!` macro can be used to pull the version from your `Cargo.toml` - pub fn current_version(mut self, ver: Version) -> Self { - self.current_version = ver; - self - } - - /// Set the target name. Represents the string that is looked up on the updater API or response JSON. - pub fn target(mut self, target: impl Into) -> Self { - self.target.replace(target.into()); - self - } - - /// Set the executable path - #[allow(dead_code)] - pub fn executable_path>(mut self, executable_path: A) -> Self { - self.executable_path = Some(PathBuf::from(executable_path.as_ref())); - self - } - - pub fn should_install bool + Send + 'static>( - mut self, - f: F, - ) -> Self { - self.should_install.replace(Box::new(f)); - self - } - - pub fn timeout(mut self, timeout: Duration) -> Self { - self.timeout.replace(timeout); - self - } - - /// Add a `Header` to the request. - pub fn header(mut self, key: K, value: V) -> Result - where - HeaderName: TryFrom, - >::Error: Into, - HeaderValue: TryFrom, - >::Error: Into, - { - let key: std::result::Result = key.try_into().map_err(Into::into); - let value: std::result::Result = - value.try_into().map_err(Into::into); - self.headers.insert(key?, value?); - Ok(self) - } - - pub async fn build(mut self) -> Result> { - let mut remote_release: Option = None; - - // make sure we have at least one url - if self.urls.is_empty() { - return Err(Error::Builder( - "Unable to check update, `url` is required.".into(), - )); - }; - - // If no executable path provided, we use current_exe from tauri_utils - let executable_path = self.executable_path.unwrap_or(current_exe()?); - - let arch = get_updater_arch().ok_or(Error::UnsupportedArch)?; - // `target` is the `{{target}}` variable we replace in the endpoint - // `json_target` is the value we search if the updater server returns a JSON with the `platforms` object - let (target, json_target) = if let Some(target) = self.target { - (target.clone(), target) - } else { - let target = get_updater_target().ok_or(Error::UnsupportedOs)?; - (target.to_string(), format!("{target}-{arch}")) - }; - - // Get the extract_path from the provided executable_path - let extract_path = extract_path_from_executable(&self.app.state::(), &executable_path); - - // Set SSL certs for linux if they aren't available. - // We do not require to recheck in the download_and_install as we use - // ENV variables, we can expect them to be set for the second call. - #[cfg(target_os = "linux")] - { - if env::var_os("SSL_CERT_FILE").is_none() { - env::set_var("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"); - } - if env::var_os("SSL_CERT_DIR").is_none() { - env::set_var("SSL_CERT_DIR", "/etc/ssl/certs"); - } - } - - // we want JSON only - let mut headers = self.headers; - headers.insert("Accept", HeaderValue::from_str("application/json").unwrap()); - - // Allow fallback if more than 1 urls is provided - let mut last_error: Option = None; - for url in &self.urls { - // replace {{current_version}}, {{target}} and {{arch}} in the provided URL - // this is useful if we need to query example - // https://releases.myapp.com/update/{{target}}/{{arch}}/{{current_version}} - // will be translated into -> - // https://releases.myapp.com/update/darwin/aarch64/1.0.0 - // The main objective is if the update URL is defined via the Cargo.toml - // the URL will be generated dynamically - let fixed_link = url - .replace("{{current_version}}", &self.current_version.to_string()) - .replace("{{target}}", &target) - .replace("{{arch}}", arch); - - let client = ClientBuilder::new().build()?; - let mut request = client - .request(Method::GET, &fixed_link) - .headers(headers.clone()); - if let Some(timeout) = self.timeout { - request = request.timeout(timeout); - } - - // If we got a success, we stop the loop - // and we set our remote_release variable - if let Ok(res) = request.send().await { - let status = res.status(); - // got status code 2XX - if status.is_success() { - // if we got 204 - if status == StatusCode::NO_CONTENT { - // return with `UpToDate` error - // we should catch on the client - return Err(Error::UpToDate); - }; - let data = res.bytes().await?; - // Convert the remote Result<()>to our local struct - let built_release = serde_json::from_slice(&data).map_err(Into::into); - // make sure all went well and the remote data is compatible - // with what we need locally - match built_release { - Ok(release) => { - last_error = None; - remote_release = Some(release); - break; - } - Err(err) => last_error = Some(err), - } - } // if status code is not 2XX we keep loopin' our urls - } - } - - // Last error is cleaned on success -- shouldn't be triggered if - // we have a successful call - if let Some(error) = last_error { - return Err(error); - } - - // Extracted remote metadata - let final_release = remote_release.ok_or(Error::ReleaseNotFound)?; - - // is the announced version greater than our current one? - let should_update = if let Some(comparator) = self.should_install.take() { - comparator(&self.current_version, &final_release) - } else { - final_release.version() > &self.current_version - }; - - headers.remove("Accept"); - - // create our new updater - Ok(Update { - app: self.app, - target, - extract_path, - should_update, - version: final_release.version().to_string(), - date: final_release.pub_date().cloned(), - current_version: self.current_version, - download_url: final_release.download_url(&json_target)?.to_owned(), - body: final_release.notes().cloned(), - signature: final_release.signature(&json_target)?.to_owned(), - #[cfg(target_os = "windows")] - with_elevated_task: final_release.with_elevated_task(&json_target)?, - timeout: self.timeout, - headers, - }) - } -} - -pub fn builder(app: AppHandle) -> UpdateBuilder { - UpdateBuilder::new(app) -} - -#[derive(Debug)] -pub struct Update { - /// Application handle. - pub app: AppHandle, - /// Update description - pub body: Option, - /// Should we update or not - pub should_update: bool, - /// Version announced - pub version: String, - /// Running version - pub current_version: Version, - /// Update publish date - pub date: Option, - /// Target - #[allow(dead_code)] - target: String, - /// Extract path - extract_path: PathBuf, - /// Download URL announced - download_url: Url, - /// Signature announced - signature: String, - #[cfg(target_os = "windows")] - /// Optional: Windows only try to use elevated task - /// Default to false - with_elevated_task: bool, - /// Request timeout - timeout: Option, - /// Request headers - headers: HeaderMap, -} - -impl Clone for Update { - fn clone(&self) -> Self { - Self { - app: self.app.clone(), - body: self.body.clone(), - should_update: self.should_update, - version: self.version.clone(), - current_version: self.current_version.clone(), - date: self.date, - target: self.target.clone(), - extract_path: self.extract_path.clone(), - download_url: self.download_url.clone(), - signature: self.signature.clone(), - #[cfg(target_os = "windows")] - with_elevated_task: self.with_elevated_task, - timeout: self.timeout, - headers: self.headers.clone(), - } - } -} - -#[derive(Debug, Serialize)] -#[serde(tag = "event", content = "data")] -pub enum DownloadEvent { - #[serde(rename_all = "camelCase")] - Started { - content_length: Option, - }, - #[serde(rename_all = "camelCase")] - Progress { - chunk_length: usize, - }, - Finished, -} - -impl Update { - // Download and install our update - // @todo(lemarier): Split into download and install (two step) but need to be thread safe - pub(crate) async fn download_and_install( - &self, - pub_key: String, - on_event: F, - ) -> Result<()> { - // make sure we can install the update on linux - // We fail here because later we can add more linux support - // actually if we use APPIMAGE, our extract path should already - // be set with our APPIMAGE env variable, we don't need to do - // anything with it yet - #[cfg(target_os = "linux")] - if self.app.state::().appimage.is_none() { - return Err(Error::UnsupportedLinuxPackage); - } - - // set our headers - let mut headers = self.headers.clone(); - headers.insert( - "Accept", - HeaderValue::from_str("application/octet-stream").unwrap(), - ); - headers.insert( - "User-Agent", - HeaderValue::from_str("tauri/updater").unwrap(), - ); - - let client = ClientBuilder::new().build()?; - // Create our request - let mut req = client - .request(Method::GET, self.download_url.clone()) - .headers(headers); - if let Some(timeout) = self.timeout { - req = req.timeout(timeout); - } - - let response = req.send().await?; - - // make sure it's success - if !response.status().is_success() { - return Err(Error::DownloadFailed(response.status())); - } - - let content_length: Option = response - .headers() - .get("Content-Length") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse().ok()); - - on_event(DownloadEvent::Started { content_length }); - - let mut buffer = Vec::new(); - - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk?; - let bytes = chunk.as_ref().to_vec(); - on_event(DownloadEvent::Progress { - chunk_length: bytes.len(), - }); - buffer.extend(bytes); - } - - on_event(DownloadEvent::Finished); - - // create memory buffer from our archive (Seek + Read) - let mut archive_buffer = Cursor::new(buffer); - - // We need an announced signature by the server - // if there is no signature, bail out. - verify_signature(&mut archive_buffer, &self.signature, &pub_key)?; - - // TODO: implement updater in mobile - #[cfg(desktop)] - { - // we copy the files depending of the operating system - // we run the setup, appimage re-install or overwrite the - // macos .app - #[cfg(target_os = "windows")] - copy_files_and_run( - archive_buffer, - &self.extract_path, - self.with_elevated_task, - &self.app.config(), - &self.app.state::().config, - )?; - #[cfg(not(target_os = "windows"))] - copy_files_and_run(archive_buffer, &self.extract_path)?; - } - - // We are done! - Ok(()) - } -} - -// Linux (AppImage) - -// ### Expected structure: -// ├── [AppName]_[version]_amd64.AppImage.tar.gz # GZ generated by tauri-bundler -// │ └──[AppName]_[version]_amd64.AppImage # Application AppImage -// └── ... - -// We should have an AppImage already installed to be able to copy and install -// the extract_path is the current AppImage path -// tmp_dir is where our new AppImage is found -#[cfg(target_os = "linux")] -fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> Result<()> { - use std::os::unix::fs::{MetadataExt, PermissionsExt}; - - let extract_path_metadata = extract_path.metadata()?; - - let tmp_dir_locations = vec![ - Box::new(|| Some(env::temp_dir())) as Box Option>, - Box::new(dirs_next::cache_dir), - Box::new(|| Some(extract_path.parent().unwrap().to_path_buf())), - ]; - - for tmp_dir_location in tmp_dir_locations { - if let Some(tmp_dir_location) = tmp_dir_location() { - let tmp_dir = tempfile::Builder::new() - .prefix("tauri_current_app") - .tempdir_in(tmp_dir_location)?; - let tmp_dir_metadata = tmp_dir.path().metadata()?; - - if extract_path_metadata.dev() == tmp_dir_metadata.dev() { - let mut perms = tmp_dir_metadata.permissions(); - perms.set_mode(0o700); - std::fs::set_permissions(tmp_dir.path(), perms)?; - - let tmp_app_image = &tmp_dir.path().join("current_app.AppImage"); - - // create a backup of our current app image - Move::from_source(extract_path).to_dest(tmp_app_image)?; - - // extract the buffer to the tmp_dir - // we extract our signed archive into our final directory without any temp file - let mut extractor = - Extract::from_cursor(archive_buffer, ArchiveFormat::Tar(Some(Compression::Gz))); - - return extractor - .with_files(|entry| { - let path = entry.path()?; - if path.extension() == Some(OsStr::new("AppImage")) { - // if something went wrong during the extraction, we should restore previous app - if let Err(err) = entry.extract(extract_path) { - Move::from_source(tmp_app_image).to_dest(extract_path)?; - return Err(err); - } - // early finish we have everything we need here - return Ok(true); - } - Ok(false) - }) - .map_err(Into::into); - } - } - } - - Err(Error::TempDirNotOnSameMountPoint) -} - -// Windows -// -// ### Expected structure: -// ├── [AppName]_[version]_x64.msi.zip # ZIP generated by tauri-bundler -// │ └──[AppName]_[version]_x64.msi # Application MSI -// ├── [AppName]_[version]_x64-setup.exe.zip # ZIP generated by tauri-bundler -// │ └──[AppName]_[version]_x64-setup.exe # NSIS installer -// └── ... -// -// ## MSI -// Update server can provide a MSI for Windows. (Generated with tauri-bundler from *Wix*) -// To replace current version of the application. In later version we'll offer -// incremental update to push specific binaries. -// -// ## EXE -// Update server can provide a custom EXE (installer) who can run any task. -#[cfg(target_os = "windows")] -#[allow(clippy::unnecessary_wraps)] -fn copy_files_and_run( - archive_buffer: R, - _extract_path: &Path, - with_elevated_task: bool, - config: &tauri::Config, - updater_config: &crate::Config, -) -> Result<()> { - // FIXME: We need to create a memory buffer with the MSI and then run it. - // (instead of extracting the MSI to a temp path) - // - // The tricky part is the MSI need to be exposed and spawned so the memory allocation - // shouldn't drop but we should be able to pass the reference so we can drop it once the installation - // is done, otherwise we have a huge memory leak. - - let tmp_dir = tempfile::Builder::new().tempdir()?.into_path(); - - // extract the buffer to the tmp_dir - // we extract our signed archive into our final directory without any temp file - let mut extractor = Extract::from_cursor(archive_buffer, ArchiveFormat::Zip); - - // extract the msi - extractor.extract_into(&tmp_dir)?; - - let system_root = std::env::var("SYSTEMROOT"); - let powershell_path = system_root.as_ref().map_or_else( - |_| "powershell.exe".to_string(), - |p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"), - ); - - let paths = read_dir(&tmp_dir)?; - - for path in paths { - let found_path = path?.path(); - // we support 2 type of files exe & msi for now - // If it's an `exe` we expect an installer not a runtime. - if found_path.extension() == Some(OsStr::new("exe")) { - // we need to wrap the installer path in quotes for Start-Process - let mut installer_arg = std::ffi::OsString::new(); - installer_arg.push("\""); - installer_arg.push(&found_path); - installer_arg.push("\""); - - // Run the EXE - Command::new(powershell_path) - .args(["-NoProfile", "-WindowStyle", "Hidden"]) - .args(["Start-Process"]) - .arg(found_path) - .arg("-ArgumentList") - .arg( - [ - config.tauri.bundle.updater.windows.install_mode.nsis_args(), - updater_config - .installer_args - .iter() - .map(AsRef::as_ref) - .collect::>() - .as_slice(), - ] - .concat() - .join(", "), - ) - .spawn() - .expect("installer failed to start"); - - exit(0); - } else if found_path.extension() == Some(OsStr::new("msi")) { - if with_elevated_task { - if let Some(bin_name) = current_exe() - .ok() - .and_then(|pb| pb.file_name().map(|s| s.to_os_string())) - .and_then(|s| s.into_string().ok()) - { - let product_name = bin_name.replace(".exe", ""); - - // Check if there is a task that enables the updater to skip the UAC prompt - let update_task_name = format!("Update {product_name} - Skip UAC"); - if let Ok(output) = Command::new("schtasks") - .arg("/QUERY") - .arg("/TN") - .arg(update_task_name.clone()) - .output() - { - if output.status.success() { - // Rename the MSI to the match file name the Skip UAC task is expecting it to be - let temp_msi = tmp_dir.with_file_name(bin_name).with_extension("msi"); - Move::from_source(&found_path) - .to_dest(&temp_msi) - .expect("Unable to move update MSI"); - let exit_status = Command::new("schtasks") - .arg("/RUN") - .arg("/TN") - .arg(update_task_name) - .status() - .expect("failed to start updater task"); - - if exit_status.success() { - // Successfully launched task that skips the UAC prompt - exit(0); - } - } - // Failed to run update task. Following UAC Path - } - } - } - - // we need to wrap the current exe path in quotes for Start-Process - let mut current_exe_arg = std::ffi::OsString::new(); - current_exe_arg.push("\""); - current_exe_arg.push(current_exe()?); - current_exe_arg.push("\""); - - let mut msi_path_arg = std::ffi::OsString::new(); - msi_path_arg.push("\"\"\""); - msi_path_arg.push(&found_path); - msi_path_arg.push("\"\"\""); - - let mut msiexec_args = config - .tauri - .bundle - .updater - .windows - .install_mode - .clone() - .msiexec_args() - .iter() - .map(|p| p.to_string()) - .collect::>(); - msiexec_args.extend(updater_config.installer_args.clone()); - - // run the installer and relaunch the application - let powershell_install_res = Command::new(powershell_path) - .args(["-NoProfile", "-WindowStyle", "Hidden"]) - .args([ - "Start-Process", - "-Wait", - "-FilePath", - "$env:SYSTEMROOT\\System32\\msiexec.exe", - "-ArgumentList", - ]) - .arg("/i,") - .arg(msi_path_arg) - .arg(format!(", {}, /promptrestart;", msiexec_args.join(", "))) - .arg("Start-Process") - .arg(current_exe_arg) - .spawn(); - if powershell_install_res.is_err() { - // fallback to running msiexec directly - relaunch won't be available - // we use this here in case powershell fails in an older machine somehow - let msiexec_path = system_root.as_ref().map_or_else( - |_| "msiexec.exe".to_string(), - |p| format!("{p}\\System32\\msiexec.exe"), - ); - let _ = Command::new(msiexec_path) - .arg("/i") - .arg(found_path) - .args(msiexec_args) - .arg("/promptrestart") - .spawn(); - } - - exit(0); - } - } - - Ok(()) -} - -// MacOS -// ### Expected structure: -// ├── [AppName]_[version]_x64.app.tar.gz # GZ generated by tauri-bundler -// │ └──[AppName].app # Main application -// │ └── Contents # Application contents... -// │ └── ... -// └── ... -#[cfg(target_os = "macos")] -fn copy_files_and_run(archive_buffer: R, extract_path: &Path) -> Result<()> { - let mut extracted_files: Vec = Vec::new(); - - // extract the buffer to the tmp_dir - // we extract our signed archive into our final directory without any temp file - let mut extractor = - Extract::from_cursor(archive_buffer, ArchiveFormat::Tar(Some(Compression::Gz))); - // the first file in the tar.gz will always be - // /Contents - let tmp_dir = tempfile::Builder::new() - .prefix("tauri_current_app") - .tempdir()?; - - // create backup of our current app - Move::from_source(extract_path).to_dest(tmp_dir.path())?; - - // extract all the files - extractor.with_files(|entry| { - let path = entry.path()?; - // skip the first folder (should be the app name) - let collected_path: PathBuf = path.iter().skip(1).collect(); - let extraction_path = extract_path.join(collected_path); - - // if something went wrong during the extraction, we should restore previous app - if let Err(err) = entry.extract(&extraction_path) { - for file in &extracted_files { - // delete all the files we extracted - if file.is_dir() { - std::fs::remove_dir(file)?; - } else { - std::fs::remove_file(file)?; - } - } - Move::from_source(tmp_dir.path()).to_dest(extract_path)?; - return Err(err); - } - - extracted_files.push(extraction_path); - - Ok(false) - })?; - - let _ = std::process::Command::new("touch") - .arg(extract_path) - .status(); - - Ok(()) -} - -pub(crate) fn get_updater_target() -> Option<&'static str> { - if cfg!(target_os = "linux") { - Some("linux") - } else if cfg!(target_os = "macos") { - Some("darwin") - } else if cfg!(target_os = "windows") { - Some("windows") - } else { - None - } -} - -pub(crate) fn get_updater_arch() -> Option<&'static str> { - if cfg!(target_arch = "x86") { - Some("i686") - } else if cfg!(target_arch = "x86_64") { - Some("x86_64") - } else if cfg!(target_arch = "arm") { - Some("armv7") - } else if cfg!(target_arch = "aarch64") { - Some("aarch64") - } else { - None - } -} - -/// Get the extract_path from the provided executable_path -#[allow(unused_variables)] -pub fn extract_path_from_executable(env: &Env, executable_path: &Path) -> PathBuf { - // Return the path of the current executable by default - // Example C:\Program Files\My App\ - let extract_path = executable_path - .parent() - .map(PathBuf::from) - .expect("Can't determine extract path"); - - // MacOS example binary is in /Applications/TestApp.app/Contents/MacOS/myApp - // We need to get /Applications/.app - // todo(lemarier): Need a better way here - // Maybe we could search for <*.app> to get the right path - #[cfg(target_os = "macos")] - if extract_path - .display() - .to_string() - .contains("Contents/MacOS") - { - return extract_path - .parent() - .map(PathBuf::from) - .expect("Unable to find the extract path") - .parent() - .map(PathBuf::from) - .expect("Unable to find the extract path"); - } - - // We should use APPIMAGE exposed env variable - // This is where our APPIMAGE should sit and should be replaced - #[cfg(target_os = "linux")] - if let Some(app_image_path) = &env.appimage { - return PathBuf::from(app_image_path); - } - - extract_path -} - -// Convert base64 to string and prevent failing -fn base64_to_string(base64_string: &str) -> Result { - let decoded_string = &base64::engine::general_purpose::STANDARD.decode(base64_string)?; - let result = from_utf8(decoded_string) - .map_err(|_| Error::SignatureUtf8(base64_string.into()))? - .to_string(); - Ok(result) -} - -// Validate signature -// need to be public because its been used -// by our tests in the bundler -// -// NOTE: The buffer position is not reset. -pub fn verify_signature( - archive_reader: &mut R, - release_signature: &str, - pub_key: &str, -) -> Result -where - R: Read, -{ - // we need to convert the pub key - let pub_key_decoded = base64_to_string(pub_key)?; - let public_key = PublicKey::decode(&pub_key_decoded)?; - let signature_base64_decoded = base64_to_string(release_signature)?; - let signature = Signature::decode(&signature_base64_decoded)?; - - // read all bytes until EOF in the buffer - let mut data = Vec::new(); - archive_reader.read_to_end(&mut data)?; - - // Validate signature or bail out - public_key.verify(&data, &signature, true)?; - Ok(true) -} - -/*#[cfg(test)] -mod test { - use super::*; - #[cfg(target_os = "macos")] - use std::fs::File; - - macro_rules! block { - ($e:expr) => { - tokio_test::block_on($e) - }; - } - - fn generate_sample_raw_json() -> String { - r#"{ - "version": "v2.0.0", - "notes": "Test version !", - "pub_date": "2020-06-22T19:25:57Z", - "platforms": { - "darwin-aarch64": { - "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJZVGdpKzJmRWZ0SkRvWS9TdFpqTU9xcm1mUmJSSG5OWVlwSklrWkN1SFpWbmh4SDlBcTU3SXpjbm0xMmRjRkphbkpVeGhGcTdrdzlrWGpGVWZQSWdzPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1MDU3CWZpbGU6L1VzZXJzL3J1bm5lci9ydW5uZXJzLzIuMjYzLjAvd29yay90YXVyaS90YXVyaS90YXVyaS9leGFtcGxlcy9jb21tdW5pY2F0aW9uL3NyYy10YXVyaS90YXJnZXQvZGVidWcvYnVuZGxlL29zeC9hcHAuYXBwLnRhci5negp4ZHFlUkJTVnpGUXdDdEhydTE5TGgvRlVPeVhjTnM5RHdmaGx3c0ZPWjZXWnFwVDRNWEFSbUJTZ1ZkU1IwckJGdmlwSzJPd00zZEZFN2hJOFUvL1FDZz09Cg==", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v1.0.0/app.app.tar.gz" - }, - "darwin-x86_64": { - "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJZVGdpKzJmRWZ0SkRvWS9TdFpqTU9xcm1mUmJSSG5OWVlwSklrWkN1SFpWbmh4SDlBcTU3SXpjbm0xMmRjRkphbkpVeGhGcTdrdzlrWGpGVWZQSWdzPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1MDU3CWZpbGU6L1VzZXJzL3J1bm5lci9ydW5uZXJzLzIuMjYzLjAvd29yay90YXVyaS90YXVyaS90YXVyaS9leGFtcGxlcy9jb21tdW5pY2F0aW9uL3NyYy10YXVyaS90YXJnZXQvZGVidWcvYnVuZGxlL29zeC9hcHAuYXBwLnRhci5negp4ZHFlUkJTVnpGUXdDdEhydTE5TGgvRlVPeVhjTnM5RHdmaGx3c0ZPWjZXWnFwVDRNWEFSbUJTZ1ZkU1IwckJGdmlwSzJPd00zZEZFN2hJOFUvL1FDZz09Cg==", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v1.0.0/app.app.tar.gz" - }, - "linux-x86_64": { - "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOWZSM29hTFNmUEdXMHRoOC81WDFFVVFRaXdWOUdXUUdwT0NlMldqdXkyaWVieXpoUmdZeXBJaXRqSm1YVmczNXdRL1Brc0tHb1NOTzhrL1hadFcxdmdnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE3MzQzCWZpbGU6L2hvbWUvcnVubmVyL3dvcmsvdGF1cmkvdGF1cmkvdGF1cmkvZXhhbXBsZXMvY29tbXVuaWNhdGlvbi9zcmMtdGF1cmkvdGFyZ2V0L2RlYnVnL2J1bmRsZS9hcHBpbWFnZS9hcHAuQXBwSW1hZ2UudGFyLmd6CmRUTUM2bWxnbEtTbUhOZGtERUtaZnpUMG5qbVo5TGhtZWE1SFNWMk5OOENaVEZHcnAvVW0zc1A2ajJEbWZUbU0yalRHT0FYYjJNVTVHOHdTQlYwQkF3PT0K", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v1.0.0/app.AppImage.tar.gz" - }, - "windows-x86_64": { - "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJHMWlvTzRUSlQzTHJOMm5waWpic0p0VVI2R0hUNGxhQVMxdzBPRndlbGpXQXJJakpTN0toRURtVzBkcm15R0VaNTJuS1lZRWdzMzZsWlNKUVAzZGdJPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1NTIzCWZpbGU6RDpcYVx0YXVyaVx0YXVyaVx0YXVyaVxleGFtcGxlc1xjb21tdW5pY2F0aW9uXHNyYy10YXVyaVx0YXJnZXRcZGVidWdcYXBwLng2NC5tc2kuemlwCitXa1lQc3A2MCs1KzEwZnVhOGxyZ2dGMlZqbjBaVUplWEltYUdyZ255eUF6eVF1dldWZzFObStaVEQ3QU1RS1lzcjhDVU4wWFovQ1p1QjJXbW1YZUJ3PT0K", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v1.0.0/app.x64.msi.zip" - } - } - }"#.into() - } - - fn generate_sample_platform_json( - version: &str, - public_signature: &str, - download_url: &str, - ) -> String { - format!( - r#" - {{ - "name": "v{version}", - "notes": "This is the latest version! Once updated you shouldn't see this prompt.", - "pub_date": "2020-06-25T14:14:19Z", - "signature": "{public_signature}", - "url": "{download_url}" - }} - "# - ) - } - - fn generate_sample_with_elevated_task_platform_json( - version: &str, - public_signature: &str, - download_url: &str, - with_elevated_task: bool, - ) -> String { - format!( - r#" - {{ - "name": "v{version}", - "notes": "This is the latest version! Once updated you shouldn't see this prompt.", - "pub_date": "2020-06-25T14:14:19Z", - "signature": "{public_signature}", - "url": "{download_url}", - "with_elevated_task": {with_elevated_task} - }} - "# - ) - } - - #[test] - fn simple_http_updater() { - let _m = mockito::mock("GET", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_raw_json()) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .current_version("0.0.0".parse().unwrap()) - .url(mockito::server_url()) - .build()); - - let updater = check_update.expect("Can't check update"); - - assert!(updater.should_update); - } - - #[test] - fn simple_http_updater_raw_json() { - let _m = mockito::mock("GET", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_raw_json()) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .current_version("0.0.0".parse().unwrap()) - .url(mockito::server_url()) - .build()); - - let updater = check_update.expect("Can't check update"); - - assert!(updater.should_update); - } - - #[test] - fn simple_http_updater_raw_json_windows_x86_64() { - let _m = mockito::mock("GET", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_raw_json()) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .current_version("0.0.0".parse().unwrap()) - .target("windows-x86_64") - .url(mockito::server_url()) - .build()); - - let updater = check_update.expect("Can't check update"); - - assert!(updater.should_update); - assert_eq!(updater.version, "2.0.0"); - assert_eq!(updater.signature, "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUldUTE5QWWxkQnlZOVJHMWlvTzRUSlQzTHJOMm5waWpic0p0VVI2R0hUNGxhQVMxdzBPRndlbGpXQXJJakpTN0toRURtVzBkcm15R0VaNTJuS1lZRWdzMzZsWlNKUVAzZGdJPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNTkyOTE1NTIzCWZpbGU6RDpcYVx0YXVyaVx0YXVyaVx0YXVyaVxleGFtcGxlc1xjb21tdW5pY2F0aW9uXHNyYy10YXVyaVx0YXJnZXRcZGVidWdcYXBwLng2NC5tc2kuemlwCitXa1lQc3A2MCs1KzEwZnVhOGxyZ2dGMlZqbjBaVUplWEltYUdyZ255eUF6eVF1dldWZzFObStaVEQ3QU1RS1lzcjhDVU4wWFovQ1p1QjJXbW1YZUJ3PT0K"); - assert_eq!( - updater.download_url.to_string(), - "https://github.com/tauri-apps/updater-test/releases/download/v1.0.0/app.x64.msi.zip" - ); - } - - #[test] - fn simple_http_updater_raw_json_uptodate() { - let _m = mockito::mock("GET", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_raw_json()) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .current_version("10.0.0".parse().unwrap()) - .url(mockito::server_url()) - .build()); - - let updater = check_update.expect("Can't check update"); - - assert!(!updater.should_update); - } - - #[test] - fn simple_http_updater_without_version() { - let _m = mockito::mock("GET", "/darwin-aarch64/1.0.0") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_platform_json( - "2.0.0", - "SampleTauriKey", - "https://tauri.app", - )) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .current_version("1.0.0".parse().unwrap()) - .url(format!( - "{}/darwin-aarch64/{{{{current_version}}}}", - mockito::server_url() - )) - .build()); - - let updater = check_update.expect("Can't check update"); - - assert!(updater.should_update); - } - - #[test] - fn simple_http_updater_percent_decode() { - let _m = mockito::mock("GET", "/darwin-aarch64/1.0.0") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_platform_json( - "2.0.0", - "SampleTauriKey", - "https://tauri.app", - )) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .current_version("1.0.0".parse().unwrap()) - .url( - url::Url::parse(&format!( - "{}/darwin-aarch64/{{{{current_version}}}}", - mockito::server_url() - )) - .unwrap() - .to_string() - ) - .build()); - - let updater = check_update.expect("Can't check update"); - - assert!(updater.should_update); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .current_version("1.0.0".parse().unwrap()) - .urls(&[url::Url::parse(&format!( - "{}/darwin-aarch64/{{{{current_version}}}}", - mockito::server_url() - )) - .unwrap() - .to_string()]) - .build()); - - let updater = check_update.expect("Can't check update"); - - assert!(updater.should_update); - } - - #[test] - fn simple_http_updater_with_elevated_task() { - let _m = mockito::mock("GET", "/windows-x86_64/1.0.0") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_with_elevated_task_platform_json( - "2.0.0", - "SampleTauriKey", - "https://tauri.app", - true, - )) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .current_version("1.0.0".parse().unwrap()) - .url(format!( - "{}/windows-x86_64/{{{{current_version}}}}", - mockito::server_url() - )) - .build()); - - let updater = check_update.expect("Can't check update"); - - assert!(updater.should_update); - } - - #[test] - fn http_updater_uptodate() { - let _m = mockito::mock("GET", "/darwin-aarch64/10.0.0") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_platform_json( - "2.0.0", - "SampleTauriKey", - "https://tauri.app", - )) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .current_version("10.0.0".parse().unwrap()) - .url(format!( - "{}/darwin-aarch64/{{{{current_version}}}}", - mockito::server_url() - )) - .build()); - - let updater = check_update.expect("Can't check update"); - - assert!(!updater.should_update); - } - - #[test] - fn http_updater_fallback_urls() { - let _m = mockito::mock("GET", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_raw_json()) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .url("http://badurl.www.tld/1".into()) - .url(mockito::server_url()) - .current_version("0.0.1".parse().unwrap()) - .build()); - - let updater = check_update.expect("Can't check remote update"); - - assert!(updater.should_update); - } - - #[test] - fn http_updater_fallback_urls_with_array() { - let _m = mockito::mock("GET", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_raw_json()) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .urls(&["http://badurl.www.tld/1".into(), mockito::server_url(),]) - .current_version("0.0.1".parse().unwrap()) - .build()); - - let updater = check_update.expect("Can't check remote update"); - - assert!(updater.should_update); - } - - #[test] - fn http_updater_invalid_remote_data() { - let invalid_signature = r#"{ - "version": "v0.0.3", - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz", - "signature": true - }"#; - let invalid_version = r#"{ - "version": 5, - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz", - "signature": "x" - }"#; - let invalid_name = r#"{ - "name": false, - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz", - "signature": "x" - }"#; - let invalid_date = r#"{ - "version": "1.0.0", - "notes": "Blablaa", - "pub_date": 345645646, - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz", - "signature": "x" - }"#; - let invalid_notes = r#"{ - "version": "v0.0.3", - "notes": ["bla", "bla"], - "pub_date": "2020-02-20T15:41:00Z", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz", - "signature": "x" - }"#; - let invalid_url = r#"{ - "version": "v0.0.3", - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "url": ["https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz", "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz"], - "signature": "x" - }"#; - let invalid_platform_signature = r#"{ - "version": "v0.0.3", - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "platforms": { - "test-target": { - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz", - "signature": { - "test-target": "x" - } - } - } - }"#; - let invalid_platform_url = r#"{ - "version": "v0.0.3", - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "platforms": { - "test-target": { - "url": { - "first": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz" - } - "signature": "x" - } - } - }"#; - - let test_cases = [ - ( - invalid_signature, - Box::new(|e| matches!(e, Error::InvalidResponseType("signature", "string", _))) - as Box bool>, - ), - ( - invalid_version, - Box::new(|e| matches!(e, Error::InvalidResponseType("version", "string", _))) - as Box bool>, - ), - ( - invalid_name, - Box::new(|e| matches!(e, Error::InvalidResponseType("name", "string", _))) - as Box bool>, - ), - ( - invalid_date, - Box::new(|e| matches!(e, Error::InvalidResponseType("pub_date", "string", _))) - as Box bool>, - ), - ( - invalid_notes, - Box::new(|e| matches!(e, Error::InvalidResponseType("notes", "string", _))) - as Box bool>, - ), - ( - invalid_url, - Box::new(|e| matches!(e, Error::InvalidResponseType("url", "string", _))) - as Box bool>, - ), - ( - invalid_platform_signature, - Box::new(|e| matches!(e, Error::InvalidResponseType("signature", "string", _))) - as Box bool>, - ), - ( - invalid_platform_url, - Box::new(|e| matches!(e, Error::InvalidResponseType("url", "string", _))) - as Box bool>, - ), - ]; - - for (response, validator) in test_cases { - let _m = mockito::mock("GET", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(response) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .url(mockito::server_url()) - .current_version("0.0.1".parse().unwrap()) - .target("test-target") - .build()); - if let Err(e) = check_update { - validator(e); - } else { - panic!("unexpected Ok response"); - } - } - } - - #[test] - fn http_updater_missing_remote_data() { - let missing_signature = r#"{ - "version": "v0.0.3", - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz" - }"#; - let missing_version = r#"{ - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz", - "signature": "x" - }"#; - let missing_url = r#"{ - "version": "v0.0.3", - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "signature": "x" - }"#; - let missing_target = r#"{ - "version": "v0.0.3", - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "platforms": { - "unknown-target": { - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz", - "signature": "x" - } - } - }"#; - let missing_platform_signature = r#"{ - "version": "v0.0.3", - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "platforms": { - "test-target": { - "url": "https://github.com/tauri-apps/updater-test/releases/download/v0.0.1/update3.tar.gz" - } - } - }"#; - let missing_platform_url = r#"{ - "version": "v0.0.3", - "notes": "Blablaa", - "pub_date": "2020-02-20T15:41:00Z", - "platforms": { - "test-target": { - "signature": "x" - } - } - }"#; - - fn missing_field_error(field: &str) -> String { - format!("the `{field}` field was not set on the updater response") - } - - let test_cases = [ - (missing_signature, missing_field_error("signature")), - (missing_version, "missing field `version`".to_string()), - (missing_url, missing_field_error("url")), - ( - missing_target, - Error::TargetNotFound("test-target".into()).to_string(), - ), - ( - missing_platform_signature, - "missing field `signature`".to_string(), - ), - (missing_platform_url, "missing field `url`".to_string()), - ]; - - for (response, error) in test_cases { - let _m = mockito::mock("GET", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(response) - .create(); - - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .url(mockito::server_url()) - .current_version("0.0.1".parse().unwrap()) - .target("test-target") - .build()); - if let Err(e) = check_update { - println!("ERROR: {e}, expected: {error}"); - assert!(e.to_string().contains(&error)); - } else { - panic!("unexpected Ok response"); - } - } - } - - // run complete process on mac only for now as we don't have - // server (api) that we can use to test - #[test] - #[cfg(target_os = "macos")] - fn http_updater_complete_process() { - #[cfg(target_os = "macos")] - let archive_file = "archive.macos.tar.gz"; - #[cfg(target_os = "linux")] - let archive_file = "archive.linux.tar.gz"; - #[cfg(target_os = "windows")] - let archive_file = "archive.windows.zip"; - - let good_archive_url = format!("{}/{archive_file}", mockito::server_url()); - - let mut signature_file = File::open(format!( - "./test/updater/fixture/archives/{archive_file}.sig" - )) - .expect("Unable to open signature"); - let mut signature = String::new(); - signature_file - .read_to_string(&mut signature) - .expect("Unable to read signature as string"); - - let mut pubkey_file = File::open("./test/updater/fixture/good_signature/update.key.pub") - .expect("Unable to open pubkey"); - let mut pubkey = String::new(); - pubkey_file - .read_to_string(&mut pubkey) - .expect("Unable to read signature as string"); - - // add sample file - let _m = mockito::mock("GET", format!("/{archive_file}").as_str()) - .with_status(200) - .with_header("content-type", "application/octet-stream") - .with_body_from_file(format!("./test/updater/fixture/archives/{archive_file}")) - .create(); - - // sample mock for update file - let _m = mockito::mock("GET", "/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(generate_sample_platform_json( - "2.0.1", - signature.as_ref(), - good_archive_url.as_ref(), - )) - .create(); - - // Build a tmpdir so we can test our extraction inside - // We dont want to overwrite our current executable or the directory - // Otherwise tests are failing... - let executable_path = current_exe().expect("Can't extract executable path"); - let parent_path = executable_path - .parent() - .expect("Can't find the parent path"); - - let tmp_dir = tempfile::Builder::new() - .prefix("tauri_updater_test") - .tempdir_in(parent_path); - - assert!(tmp_dir.is_ok()); - let tmp_dir_unwrap = tmp_dir.expect("Can't find tmp_dir"); - let tmp_dir_path = tmp_dir_unwrap.path(); - - #[cfg(target_os = "linux")] - let my_executable = &tmp_dir_path.join("updater-example_0.1.0_amd64.AppImage"); - #[cfg(target_os = "macos")] - let my_executable = &tmp_dir_path.join("my_app"); - #[cfg(target_os = "windows")] - let my_executable = &tmp_dir_path.join("my_app.exe"); - - // configure the updater - let app = crate::test::mock_app(); - let check_update = block!(builder(app.handle()) - .url(mockito::server_url()) - // It should represent the executable path, that's why we add my_app.exe in our - // test path -- in production you shouldn't have to provide it - .executable_path(my_executable) - // make sure we force an update - .current_version("1.0.0".parse().unwrap()) - .build()); - - #[cfg(target_os = "linux")] - { - env::set_var("APPIMAGE", my_executable); - } - - // unwrap our results - let updater = check_update.expect("Can't check remote update"); - - // make sure we need to update - assert!(updater.should_update); - // make sure we can read announced version - assert_eq!(updater.version, "2.0.1"); - - // download, install and validate signature - let install_process = block!(updater.download_and_install(pubkey, |_, _| (), || ())); - assert!(install_process.is_ok()); - - // make sure the extraction went well (it should have skipped the main app.app folder) - // as we can't extract in /Applications directly - #[cfg(target_os = "macos")] - let bin_file = tmp_dir_path.join("Contents").join("MacOS").join("app"); - #[cfg(target_os = "linux")] - // linux should extract at same place as the executable path - let bin_file = my_executable; - #[cfg(target_os = "windows")] - let bin_file = tmp_dir_path.join("with").join("long").join("path.json"); - - assert!(bin_file.exists()); - } -} -*/ diff --git a/plugins/updater/src/updater/extract.rs b/plugins/updater/src/updater/extract.rs deleted file mode 100644 index 4d0feca6..00000000 --- a/plugins/updater/src/updater/extract.rs +++ /dev/null @@ -1,344 +0,0 @@ -// Copyright 2019-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use std::{ - borrow::Cow, - fs, - io::{self, Read, Seek}, - path::{self, Path}, -}; - -use crate::{Error, Result}; - -/// The archive reader. -#[derive(Debug)] -pub enum ArchiveReader { - /// A plain reader. - Plain(R), - /// A GZ- compressed reader (decoder). - GzCompressed(Box>), -} - -impl Read for ArchiveReader { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - match self { - Self::Plain(r) => r.read(buf), - Self::GzCompressed(decoder) => decoder.read(buf), - } - } -} - -impl ArchiveReader { - #[allow(dead_code)] - fn get_mut(&mut self) -> &mut R { - match self { - Self::Plain(r) => r, - Self::GzCompressed(decoder) => decoder.get_mut(), - } - } -} - -/// The supported archive formats. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum ArchiveFormat { - /// Tar archive. - Tar(Option), - /// Zip archive. - #[cfg(windows)] - Zip, -} - -impl ArchiveFormat { - fn compression(self) -> Option { - match self { - Self::Tar(c) => c, - #[allow(unreachable_patterns)] - _ => None, - } - } -} - -/// The supported compression types. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum Compression { - /// Gz compression (e.g. `.tar.gz` archives) - Gz, -} - -/// The zip entry. -#[cfg(windows)] -pub struct ZipEntry { - path: std::path::PathBuf, - is_dir: bool, - file_contents: Vec, -} - -/// A read-only view into an entry of an archive. -#[non_exhaustive] -pub enum Entry<'a, R: Read> { - /// An entry of a tar archive. - #[non_exhaustive] - Tar(Box>), - /// An entry of a zip archive. - #[non_exhaustive] - #[cfg(windows)] - Zip(ZipEntry), -} - -impl<'a, R: Read> Entry<'a, R> { - /// The entry path. - pub fn path(&self) -> Result> { - match self { - Self::Tar(e) => e.path().map_err(Into::into), - #[cfg(windows)] - Self::Zip(e) => Ok(Cow::Borrowed(&e.path)), - } - } - - /// Extract this entry into `into_path`. - /// If it's a directory, the target will be created, if it's a file, it'll be extracted at this location. - /// Note: You need to include the complete path, with file name and extension. - pub fn extract(self, into_path: &path::Path) -> Result<()> { - match self { - Self::Tar(mut entry) => { - // determine if it's a file or a directory - if entry.header().entry_type() == tar::EntryType::Directory { - // this is a directory, lets create it - match fs::create_dir_all(into_path) { - Ok(_) => (), - Err(e) => { - if e.kind() != io::ErrorKind::AlreadyExists { - return Err(e.into()); - } - } - } - } else { - let mut out_file = fs::File::create(into_path)?; - io::copy(&mut entry, &mut out_file)?; - - // make sure we set permissions - if let Ok(mode) = entry.header().mode() { - set_perms(into_path, Some(&mut out_file), mode, true)?; - } - } - } - #[cfg(windows)] - Self::Zip(entry) => { - if entry.is_dir { - // this is a directory, lets create it - match fs::create_dir_all(into_path) { - Ok(_) => (), - Err(e) => { - if e.kind() != io::ErrorKind::AlreadyExists { - return Err(e.into()); - } - } - } - } else { - let mut out_file = fs::File::create(into_path)?; - io::copy( - &mut std::io::Cursor::new(entry.file_contents), - &mut out_file, - )?; - } - } - } - - Ok(()) - } -} - -/// The extract manager to retrieve files from archives. -pub struct Extract<'a, R: Read + Seek> { - reader: ArchiveReader, - archive_format: ArchiveFormat, - tar_archive: Option>>, -} - -impl<'a, R: std::fmt::Debug + Read + Seek> std::fmt::Debug for Extract<'a, R> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Extract") - .field("reader", &self.reader) - .field("archive_format", &self.archive_format) - .finish() - } -} - -impl<'a, R: Read + Seek> Extract<'a, R> { - /// Create archive from reader. - pub fn from_cursor(mut reader: R, archive_format: ArchiveFormat) -> Extract<'a, R> { - if reader.rewind().is_err() { - #[cfg(debug_assertions)] - eprintln!("Could not seek to start of the file"); - } - let compression = archive_format.compression(); - Extract { - reader: match compression { - Some(Compression::Gz) => { - ArchiveReader::GzCompressed(Box::new(flate2::read::GzDecoder::new(reader))) - } - _ => ArchiveReader::Plain(reader), - }, - archive_format, - tar_archive: None, - } - } - - /// Reads the archive content. - pub fn with_files< - E: Into, - F: FnMut(Entry<'_, &mut ArchiveReader>) -> std::result::Result, - >( - &'a mut self, - mut f: F, - ) -> Result<()> { - match self.archive_format { - ArchiveFormat::Tar(_) => { - let archive = tar::Archive::new(&mut self.reader); - self.tar_archive.replace(archive); - for entry in self.tar_archive.as_mut().unwrap().entries()? { - let entry = entry?; - if entry.path().is_ok() { - let stop = f(Entry::Tar(Box::new(entry))).map_err(Into::into)?; - if stop { - break; - } - } - } - } - - #[cfg(windows)] - ArchiveFormat::Zip => { - let mut archive = zip::ZipArchive::new(self.reader.get_mut())?; - let file_names = archive - .file_names() - .map(|f| f.to_string()) - .collect::>(); - for path in file_names { - let mut zip_file = archive.by_name(&path)?; - let is_dir = zip_file.is_dir(); - let mut file_contents = Vec::new(); - zip_file.read_to_end(&mut file_contents)?; - let stop = f(Entry::Zip(ZipEntry { - path: path.into(), - is_dir, - file_contents, - })) - .map_err(Into::into)?; - if stop { - break; - } - } - } - } - - Ok(()) - } - - /// Extract an entire source archive into a specified path. If the source is a single compressed - /// file and not an archive, it will be extracted into a file with the same name inside of - /// `into_dir`. - #[allow(dead_code)] - pub fn extract_into(&mut self, into_dir: &path::Path) -> Result<()> { - match self.archive_format { - ArchiveFormat::Tar(_) => { - let mut archive = tar::Archive::new(&mut self.reader); - archive.unpack(into_dir)?; - } - - #[cfg(windows)] - ArchiveFormat::Zip => { - let mut archive = zip::ZipArchive::new(self.reader.get_mut())?; - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - // Decode the file name from raw bytes instead of using file.name() directly. - // file.name() uses String::from_utf8_lossy() which may return messy characters - // such as: τê▒Σ║ñµÿô.app/, that does not work as expected. - // Here we require the file name must be a valid UTF-8. - let file_name = String::from_utf8(file.name_raw().to_vec())?; - let out_path = into_dir.join(file_name); - if file.is_dir() { - fs::create_dir_all(&out_path)?; - } else { - if let Some(out_path_parent) = out_path.parent() { - fs::create_dir_all(out_path_parent)?; - } - let mut out_file = fs::File::create(&out_path)?; - io::copy(&mut file, &mut out_file)?; - } - // Get and Set permissions - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Some(mode) = file.unix_mode() { - fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?; - } - } - } - } - } - Ok(()) - } -} - -fn set_perms( - dst: &Path, - f: Option<&mut std::fs::File>, - mode: u32, - preserve: bool, -) -> io::Result<()> { - _set_perms(dst, f, mode, preserve).map_err(|_| { - io::Error::new( - io::ErrorKind::Other, - format!( - "failed to set permissions to {mode:o} \ - for `{}`", - dst.display() - ), - ) - }) -} - -#[cfg(unix)] -fn _set_perms( - dst: &Path, - f: Option<&mut std::fs::File>, - mode: u32, - preserve: bool, -) -> io::Result<()> { - use std::os::unix::prelude::*; - - let mode = if preserve { mode } else { mode & 0o777 }; - let perm = fs::Permissions::from_mode(mode as _); - match f { - Some(f) => f.set_permissions(perm), - None => fs::set_permissions(dst, perm), - } -} - -#[cfg(windows)] -fn _set_perms( - dst: &Path, - f: Option<&mut std::fs::File>, - mode: u32, - _preserve: bool, -) -> io::Result<()> { - if mode & 0o200 == 0o200 { - return Ok(()); - } - match f { - Some(f) => { - let mut perm = f.metadata()?.permissions(); - perm.set_readonly(true); - f.set_permissions(perm) - } - None => { - let mut perm = fs::metadata(dst)?.permissions(); - perm.set_readonly(true); - fs::set_permissions(dst, perm) - } - } -} diff --git a/plugins/updater/src/updater/mod.rs b/plugins/updater/src/updater/mod.rs deleted file mode 100644 index 913148c9..00000000 --- a/plugins/updater/src/updater/mod.rs +++ /dev/null @@ -1,308 +0,0 @@ -// Copyright 2019-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -//! The Tauri updater. -//! -//! The updater is focused on making Tauri's application updates **as safe and transparent as updates to a website**. -//! -//! For a full guide on setting up the updater, see . -//! -//! Check [`UpdateBuilder`] to see how to trigger and customize the updater at runtime. -//! ``` - -mod core; -mod extract; -mod move_file; - -use std::time::Duration; - -use http::header::{HeaderName, HeaderValue}; -use semver::Version; -use time::OffsetDateTime; - -pub use self::core::{DownloadEvent, RemoteRelease}; - -use tauri::{AppHandle, Manager, Runtime}; - -use crate::{Result, UpdaterState}; - -/// Gets the target string used on the updater. -pub fn target() -> Option { - if let (Some(target), Some(arch)) = (core::get_updater_target(), core::get_updater_arch()) { - Some(format!("{target}-{arch}")) - } else { - None - } -} - -#[derive(Clone, serde::Serialize)] -struct StatusEvent { - status: String, - error: Option, -} - -#[derive(Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct DownloadProgressEvent { - chunk_length: usize, - content_length: Option, -} - -#[derive(Clone, serde::Serialize)] -struct UpdateManifest { - version: String, - date: Option, - body: String, -} - -/// An update check builder. -#[derive(Debug)] -pub struct UpdateBuilder { - inner: core::UpdateBuilder, -} - -impl UpdateBuilder { - /// Sets the current platform's target name for the updater. - /// - /// The target is injected in the endpoint URL by replacing `{{target}}`. - /// Note that this does not affect the `{{arch}}` variable. - /// - /// If the updater response JSON includes the `platforms` field, - /// that object must contain a value for the target key. - /// - /// By default Tauri uses `$OS_NAME` as the replacement for `{{target}}` - /// and `$OS_NAME-$ARCH` as the key in the `platforms` object, - /// where `$OS_NAME` is the current operating system name "linux", "windows" or "darwin") - /// and `$ARCH` is one of the supported architectures ("i686", "x86_64", "armv7" or "aarch64"). - /// - /// See [`Builder::updater_target`](crate::Builder#method.updater_target) for a way to set the target globally. - /// - /// # Examples - /// - /// ## Use a macOS Universal binary target name - /// - /// In this example, we set the updater target only on macOS. - /// On other platforms, we set the default target. - /// Note that `{{target}}` will be replaced with `darwin-universal`, - /// but `{{arch}}` is still the running platform's architecture. - /// - /// ```no_run - /// use tauri_plugin_updater::{target as updater_target, UpdaterExt}; - /// tauri::Builder::default() - /// .setup(|app| { - /// let handle = app.handle(); - /// tauri::async_runtime::spawn(async move { - /// let builder = handle.updater().target(if cfg!(target_os = "macos") { - /// "darwin-universal".to_string() - /// } else { - /// updater_target().unwrap() - /// }); - /// match builder.check().await { - /// Ok(update) => {} - /// Err(error) => {} - /// } - /// }); - /// Ok(()) - /// }); - /// ``` - /// - /// ## Append debug information to the target - /// - /// This allows you to provide updates for both debug and release applications. - /// - /// ```no_run - /// use tauri_plugin_updater::{UpdaterExt, target as updater_target}; - /// tauri::Builder::default() - /// .setup(|app| { - /// let handle = app.handle(); - /// tauri::async_runtime::spawn(async move { - /// let kind = if cfg!(debug_assertions) { "debug" } else { "release" }; - /// let builder = handle.updater().target(format!("{}-{kind}", updater_target().unwrap())); - /// match builder.check().await { - /// Ok(update) => {} - /// Err(error) => {} - /// } - /// }); - /// Ok(()) - /// }); - /// ``` - /// - /// ## Use the platform's target triple - /// - /// ```no_run - /// use tauri_plugin_updater::UpdaterExt; - /// tauri::Builder::default() - /// .setup(|app| { - /// let handle = app.handle(); - /// tauri::async_runtime::spawn(async move { - /// let builder = handle.updater().target(tauri::utils::platform::target_triple().unwrap()); - /// match builder.check().await { - /// Ok(update) => {} - /// Err(error) => {} - /// } - /// }); - /// Ok(()) - /// }); - /// ``` - pub fn target(mut self, target: impl Into) -> Self { - self.inner = self.inner.target(target); - self - } - - /// Sets a closure that is invoked to compare the current version and the latest version returned by the updater server. - /// The first argument is the current version, and the second one is the latest version. - /// - /// The closure must return `true` if the update should be installed. - /// - /// # Examples - /// - /// - Always install the version returned by the server: - /// - /// ```no_run - /// use tauri_plugin_updater::UpdaterExt; - /// tauri::Builder::default() - /// .setup(|app| { - /// app.handle().updater().should_install(|_current, _latest| true); - /// Ok(()) - /// }); - /// ``` - pub fn should_install bool + Send + 'static>( - mut self, - f: F, - ) -> Self { - self.inner = self.inner.should_install(f); - self - } - - /// Sets the timeout for the requests to the updater endpoints. - pub fn timeout(mut self, timeout: Duration) -> Self { - self.inner = self.inner.timeout(timeout); - self - } - - /// Add a `Header` to the request. - pub fn header(mut self, key: K, value: V) -> Result - where - HeaderName: TryFrom, - >::Error: Into, - HeaderValue: TryFrom, - >::Error: Into, - { - self.inner = self.inner.header(key, value)?; - Ok(self) - } - - /// Check if an update is available. - /// - /// # Examples - /// - /// ```no_run - /// use tauri_plugin_updater::{UpdaterExt, DownloadEvent}; - /// tauri::Builder::default() - /// .setup(|app| { - /// let handle = app.handle(); - /// tauri::async_runtime::spawn(async move { - /// match handle.updater().check().await { - /// Ok(update) => { - /// if update.is_update_available() { - /// update.download_and_install(|event| { - /// match event { - /// DownloadEvent::Started { content_length } => println!("started! size: {:?}", content_length), - /// DownloadEvent::Progress { chunk_length } => println!("Downloaded {chunk_length} bytes"), - /// DownloadEvent::Finished => println!("download finished"), - /// } - /// }).await.unwrap(); - /// } - /// } - /// Err(e) => { - /// println!("failed to get update: {}", e); - /// } - /// } - /// }); - /// Ok(()) - /// }); - /// ``` - pub async fn check(self) -> Result> { - self.inner - .build() - .await - .map(|update| UpdateResponse { update }) - } -} - -/// The response of an updater check. -pub struct UpdateResponse { - update: core::Update, -} - -impl Clone for UpdateResponse { - fn clone(&self) -> Self { - Self { - update: self.update.clone(), - } - } -} - -impl UpdateResponse { - /// Whether the updater found a newer release or not. - pub fn is_update_available(&self) -> bool { - self.update.should_update - } - - /// The current version of the application as read by the updater. - pub fn current_version(&self) -> &Version { - &self.update.current_version - } - - /// The latest version of the application found by the updater. - pub fn latest_version(&self) -> &str { - &self.update.version - } - - /// The update date. - pub fn date(&self) -> Option<&OffsetDateTime> { - self.update.date.as_ref() - } - - /// The update description. - pub fn body(&self) -> Option<&String> { - self.update.body.as_ref() - } - - /// Downloads and installs the update. - pub async fn download_and_install(&self, on_event: F) -> Result<()> { - // Launch updater download process - // macOS we display the `Ready to restart dialog` asking to restart - // Windows is closing the current App and launch the downloaded MSI when ready (the process stop here) - // Linux we replace the AppImage by launching a new install, it start a new AppImage instance, so we're closing the previous. (the process stop here) - self.update - .download_and_install( - self.update.app.config().tauri.bundle.updater.pubkey.clone(), - on_event, - ) - .await - } -} - -/// Initializes the [`UpdateBuilder`] using the app configuration. -pub fn builder(handle: AppHandle) -> UpdateBuilder { - let package_info = handle.package_info().clone(); - - // prepare our endpoints - let endpoints = handle - .state::() - .config - .endpoints - .iter() - .map(|e| e.to_string()) - .collect::>(); - - let mut builder = self::core::builder(handle.clone()) - .urls(&endpoints[..]) - .current_version(package_info.version); - if let Some(target) = &handle.state::().target { - builder = builder.target(target); - } - UpdateBuilder { inner: builder } -} diff --git a/plugins/updater/src/updater/move_file.rs b/plugins/updater/src/updater/move_file.rs deleted file mode 100644 index a6ec158e..00000000 --- a/plugins/updater/src/updater/move_file.rs +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2019-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use ignore::WalkBuilder; -use std::{fs, path}; - -use crate::Result; - -/// Moves a file from the given path to the specified destination. -/// -/// `source` and `dest` must be on the same filesystem. -/// If `replace_using_temp` is specified, the destination file will be -/// replaced using the given temporary path. -/// -/// * Errors: -/// * Io - copying / renaming -#[derive(Debug)] -pub struct Move<'a> { - source: &'a path::Path, - temp: Option<&'a path::Path>, -} -impl<'a> Move<'a> { - /// Specify source file - pub fn from_source(source: &'a path::Path) -> Move<'a> { - Self { source, temp: None } - } - - /// If specified and the destination file already exists, the "destination" - /// file will be moved to the given temporary location before the "source" - /// file is moved to the "destination" file. - /// - /// In the event of an `io` error while renaming "source" to "destination", - /// the temporary file will be moved back to "destination". - /// - /// The `temp` dir must be explicitly provided since `rename` operations require - /// files to live on the same filesystem. - #[allow(dead_code)] - pub fn replace_using_temp(&mut self, temp: &'a path::Path) -> &mut Self { - self.temp = Some(temp); - self - } - - /// Move source file to specified destination (replace whole directory) - pub fn to_dest(&self, dest: &path::Path) -> Result<()> { - match self.temp { - None => { - fs::rename(self.source, dest)?; - } - Some(temp) => { - if dest.exists() { - fs::rename(dest, temp)?; - if let Err(e) = fs::rename(self.source, dest) { - fs::rename(temp, dest)?; - return Err(e.into()); - } - } else { - fs::rename(self.source, dest)?; - } - } - }; - Ok(()) - } - - /// Walk in the source and copy all files and create directories if needed by - /// replacing existing elements. (equivalent to a cp -R) - #[allow(dead_code)] - pub fn walk_to_dest(&self, dest: &path::Path) -> Result<()> { - match self.temp { - None => { - // got no temp -- no need to backup - walkdir_and_copy(self.source, dest)?; - } - Some(temp) => { - if dest.exists() { - // we got temp and our dest exist, lets make a backup - // of current files - walkdir_and_copy(dest, temp)?; - - if let Err(e) = walkdir_and_copy(self.source, dest) { - // if we got something wrong we reset the dest with our backup - fs::rename(temp, dest)?; - return Err(e); - } - } else { - // got temp but dest didnt exist - walkdir_and_copy(self.source, dest)?; - } - } - }; - Ok(()) - } -} -// Walk into the source and create directories, and copy files -// Overwriting existing items but keeping untouched the files in the dest -// not provided in the source. -fn walkdir_and_copy(source: &path::Path, dest: &path::Path) -> Result<()> { - let walkdir = WalkBuilder::new(source).hidden(false).build(); - - for entry in walkdir { - // Check if it's a file - - let element = entry?; - let metadata = element.metadata()?; - let destination = dest.join(element.path().strip_prefix(source)?); - - // we make sure it's a directory and destination doesnt exist - if metadata.is_dir() && !&destination.exists() { - fs::create_dir_all(&destination)?; - } - - // we make sure it's a file - if metadata.is_file() { - fs::copy(element.path(), destination)?; - } - } - Ok(()) -} diff --git a/plugins/updater/tests/app-updater/src/main.rs b/plugins/updater/tests/app-updater/src/main.rs index 5b5a868a..b16926cf 100644 --- a/plugins/updater/tests/app-updater/src/main.rs +++ b/plugins/updater/tests/app-updater/src/main.rs @@ -10,38 +10,40 @@ fn main() { #[allow(unused_mut)] let mut context = tauri::generate_context!(); - let mut updater = tauri_plugin_updater::Builder::new(); - if std::env::var("TARGET").unwrap_or_default() == "nsis" { - // /D sets the default installation directory ($INSTDIR), - // overriding InstallDir and InstallDirRegKey. - // It must be the last parameter used in the command line and must not contain any quotes, even if the path contains spaces. - // Only absolute paths are supported. - // NOTE: we only need this because this is an integration test and we don't want to install the app in the programs folder - updater = updater.installer_args(vec![format!( - "/D={}", - tauri::utils::platform::current_exe() - .unwrap() - .parent() - .unwrap() - .display() - )]); - } - tauri::Builder::default() - .plugin(updater.build()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { let handle = app.handle(); tauri::async_runtime::spawn(async move { - match handle.updater().check().await { - Ok(update) => { - if update.is_update_available() { - if let Err(e) = update.download_and_install(|_event| {}).await { - println!("{e}"); - std::process::exit(1); - } + let mut builder = handle.updater_builder(); + if std::env::var("TARGET").unwrap_or_default() == "nsis" { + // /D sets the default installation directory ($INSTDIR), + // overriding InstallDir and InstallDirRegKey. + // It must be the last parameter used in the command line and must not contain any quotes, even if the path contains spaces. + // Only absolute paths are supported. + // NOTE: we only need this because this is an integration test and we don't want to install the app in the programs folder + builder = builder.installer_args(vec![format!( + "/D={}", + tauri::utils::platform::current_exe() + .unwrap() + .parent() + .unwrap() + .display() + )]); + } + let updater = builder.build().unwrap(); + + match updater.check().await { + Ok(Some(update)) => { + if let Err(e) = update.download_and_install(|_, _| {}, || {}).await { + println!("{e}"); + std::process::exit(1); } std::process::exit(0); } + Ok(None) => { + std::process::exit(0); + } Err(e) => { println!("{e}"); std::process::exit(1); diff --git a/plugins/updater/tests/app-updater/tests/update.rs b/plugins/updater/tests/app-updater/tests/update.rs index 6cc2033b..d6be2508 100644 --- a/plugins/updater/tests/app-updater/tests/update.rs +++ b/plugins/updater/tests/app-updater/tests/update.rs @@ -115,7 +115,7 @@ impl Default for BundleTarget { #[cfg(any(target_os = "macos", target_os = "ios"))] return Self::App; #[cfg(target_os = "linux")] - return Self::App; + return Self::AppImage; #[cfg(windows)] return Self::Nsis; }