diff --git a/examples/api/src/views/Updater.svelte b/examples/api/src/views/Updater.svelte index 9536a7a2..2fc57360 100644 --- a/examples/api/src/views/Updater.svelte +++ b/examples/api/src/views/Updater.svelte @@ -1,60 +1,44 @@
{#if !isChecking && !newUpdate} - + {:else if !isInstalling && newUpdate} {:else} diff --git a/package.json b/package.json index 5a0bfce4..4a3df949 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "license": "MIT or APACHE-2.0", "type": "module", "scripts": { - "build": "pnpm run -r --parallel --filter !plugins-workspace --filter !\"./plugins/*/examples/**\" build", + "build": "pnpm run -r --parallel --filter !plugins-workspace --filter !\"./plugins/*/examples/**\" --filter !\"./examples/*\" build", "lint": "eslint .", "format": "prettier --write .", "format-check": "prettier --check ." diff --git a/plugins/updater/Cargo.lock b/plugins/updater/Cargo.lock index 554978ea..59f6c938 100644 --- a/plugins/updater/Cargo.lock +++ b/plugins/updater/Cargo.lock @@ -2919,10 +2919,10 @@ dependencies = [ "serde", "serde_json", "tauri", - "tauri-runtime", "tempfile", "thiserror", "time", + "tokio", "tokio-test", "url", ] diff --git a/plugins/updater/Cargo.toml b/plugins/updater/Cargo.toml index 82c052dc..f35713e1 100644 --- a/plugins/updater/Cargo.toml +++ b/plugins/updater/Cargo.toml @@ -16,7 +16,7 @@ serde = "1" serde_json = "1" thiserror = "1" -tauri-runtime = "0.13.0-alpha.4" +tokio = "1" reqwest = { version = "0.11", default-features = false, features = [ "json", "stream" ] } url = "2" http = "0.2" diff --git a/plugins/updater/guest-js/index.ts b/plugins/updater/guest-js/index.ts index e69de29b..0123c9ce 100644 --- a/plugins/updater/guest-js/index.ts +++ b/plugins/updater/guest-js/index.ts @@ -0,0 +1,70 @@ +import { invoke, transformCallback } from '@tauri-apps/api/tauri' + +interface CheckOptions { + /** + * Request headers + */ + headers?: Record + /** + * Timeout in seconds + */ + timeout?: number + /** + * Target identifier for the running application. This is sent to the backend. + */ + target?: string +} + +interface UpdateResponse { + available: boolean + currentVersion: string + latestVersion: string + date?: string + body?: string +} + +// TODO: use channel from @tauri-apps/api on v2 +class Channel { + id: number + onmessage: (response: T) => void = () => { + // do nothing + } + + constructor() { + this.id = transformCallback((response: T) => { + this.onmessage(response) + }) + } + + toJSON(): string { + return `__CHANNEL__:${this.id}` + } +} + +type DownloadEvent = + { event: 'Started', data: { contentLength?: number } } | + { event: 'Progress', data: { chunkLength: number } } | + { event: 'Finished' } + +class Update { + response: UpdateResponse + + private constructor(response: UpdateResponse) { + this.response = response + } + + async downloadAndInstall(onEvent?: (progress: DownloadEvent) => void): Promise { + const channel = new Channel() + if (onEvent != null) { + channel.onmessage = onEvent + } + return invoke('plugin:updater|download_and_install', { onEvent: channel }) + } +} + +async function check(options?: CheckOptions): Promise { + return invoke('plugin:updater|check', { ...options }) +} + +export type { CheckOptions, UpdateResponse, DownloadEvent } +export { check, Update } diff --git a/plugins/updater/src/commands.rs b/plugins/updater/src/commands.rs index efa7aca0..8a3d6c13 100644 --- a/plugins/updater/src/commands.rs +++ b/plugins/updater/src/commands.rs @@ -1,6 +1,102 @@ -use tauri::{AppHandle, Runtime}; +use crate::{PendingUpdate, Result, UpdaterExt}; + +use http::header; +use serde::{Deserialize, Deserializer, Serialize}; +use tauri::{api::ipc::Channel, AppHandle, Runtime, State}; + +use std::{collections::HashMap, time::Duration}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Metadata { + available: bool, + current_version: String, + latest_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, + timeout: Option, + target: Option, +) -> Result { + let mut builder = app.updater(); + if let Some(headers) = headers { + for (k, v) in headers.0.iter() { + builder = builder.header(k, v)?; + } + } + if let Some(timeout) = timeout { + builder = builder.timeout(Duration::from_secs(timeout)); + } + if let Some(target) = target { + 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); + + Ok(metadata) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DownloadProgress { + chunk_length: usize, + content_length: Option, +} #[tauri::command] -pub fn version(app: AppHandle) -> String { - app.package_info().version.to_string() +pub(crate) async fn download_and_install( + _app: AppHandle, + pending: State<'_, PendingUpdate>, + on_event: Channel, +) -> Result<()> { + if let Some(pending) = pending.0.lock().await.take() { + pending + .download_and_install(move |event| { + let _ = on_event.send(&event); + }) + .await?; + } + Ok(()) } diff --git a/plugins/updater/src/error.rs b/plugins/updater/src/error.rs index 9e9243bf..509c9504 100644 --- a/plugins/updater/src/error.rs +++ b/plugins/updater/src/error.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT use http::StatusCode; +use serde::{Serialize, Serializer}; /// All errors that can occur while running the updater. #[derive(Debug, thiserror::Error)] @@ -78,3 +79,12 @@ pub enum Error { #[error("temp directory is not on the same mount point as the AppImage")] TempDirNotOnSameMountPoint, } + +impl Serialize for Error { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + serializer.serialize_str(self.to_string().as_ref()) + } +} diff --git a/plugins/updater/src/lib.rs b/plugins/updater/src/lib.rs index 9fc7c9c6..ec55cb5b 100644 --- a/plugins/updater/src/lib.rs +++ b/plugins/updater/src/lib.rs @@ -3,6 +3,8 @@ use tauri::{ Manager, Runtime, }; +use tokio::sync::Mutex; + mod commands; mod error; mod updater; @@ -15,6 +17,8 @@ struct UpdaterState { target: Option, } +struct PendingUpdate(Mutex>>); + #[derive(Default)] pub struct Builder { target: Option, @@ -61,9 +65,13 @@ impl Builder { PluginBuilder::::new("updater") .setup(move |app, _api| { app.manage(UpdaterState { target }); + app.manage(PendingUpdate::(Default::default())); Ok(()) }) - .invoke_handler(tauri::generate_handler![commands::version,]) + .invoke_handler(tauri::generate_handler![ + commands::check, + commands::download_and_install + ]) .build() } } diff --git a/plugins/updater/src/updater/core.rs b/plugins/updater/src/updater/core.rs index 2d84a174..73ead697 100644 --- a/plugins/updater/src/updater/core.rs +++ b/plugins/updater/src/updater/core.rs @@ -507,14 +507,27 @@ impl Clone for Update { } } +#[derive(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), D: FnOnce()>( + pub(crate) async fn download_and_install( &self, pub_key: String, - on_chunk: C, - on_download_finish: D, + on_event: F, ) -> Result<()> { // make sure we can install the update on linux // We fail here because later we can add more linux support @@ -559,17 +572,21 @@ impl Update { .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_chunk(bytes.len(), content_length); + on_event(DownloadEvent::Progress { + chunk_length: bytes.len(), + }); buffer.extend(bytes); } - on_download_finish(); + on_event(DownloadEvent::Finished); // create memory buffer from our archive (Seek + Read) let mut archive_buffer = Cursor::new(buffer); @@ -1652,4 +1669,4 @@ mod test { assert!(bin_file.exists()); } } -*/ \ No newline at end of file +*/ diff --git a/plugins/updater/src/updater/mod.rs b/plugins/updater/src/updater/mod.rs index d2440190..7111cec3 100644 --- a/plugins/updater/src/updater/mod.rs +++ b/plugins/updater/src/updater/mod.rs @@ -8,51 +8,7 @@ //! //! For a full guide on setting up the updater, see . //! -//! Check [`UpdateBuilder`] to see how to manually trigger and customize the updater at runtime. -//! -//! ## Events -//! -//! To listen to the updater events, for example to check for error messages, you need to use [`RunEvent::Updater`](crate::RunEvent) in [`App::run`](crate::App#method.run). -//! -//! ```no_run -//! let app = tauri::Builder::default() -//! // on an actual app, remove the string argument -//! .build(tauri::generate_context!("tests/app-updater/tauri.conf.json")) -//! .expect("error while building tauri application"); -//! app.run(|_app_handle, event| match event { -//! tauri::RunEvent::Updater(updater_event) => { -//! match updater_event { -//! tauri::UpdaterEvent::UpdateAvailable { body, date, version } => { -//! println!("update available {} {:?} {}", body, date, version); -//! } -//! // Emitted when the download is about to be started. -//! tauri::UpdaterEvent::Pending => { -//! println!("update is pending!"); -//! } -//! tauri::UpdaterEvent::DownloadProgress { chunk_length, content_length } => { -//! println!("downloaded {} of {:?}", chunk_length, content_length); -//! } -//! // Emitted when the download has finished and the update is about to be installed. -//! tauri::UpdaterEvent::Downloaded => { -//! println!("update has been downloaded!"); -//! } -//! // Emitted when the update was installed. You can then ask to restart the app. -//! tauri::UpdaterEvent::Updated => { -//! println!("app has been updated"); -//! } -//! // Emitted when the app already has the latest version installed and an update is not needed. -//! tauri::UpdaterEvent::AlreadyUpToDate => { -//! println!("app is already up to date"); -//! } -//! // Emitted when there is an error with the updater. We suggest to listen to this event even if the default dialog is enabled. -//! tauri::UpdaterEvent::Error(error) => { -//! println!("failed to update: {}", error); -//! } -//! _ => (), -//! } -//! } -//! _ => {} -//! }); +//! Check [`UpdateBuilder`] to see how to trigger and customize the updater at runtime. //! ``` mod core; @@ -63,37 +19,12 @@ use http::header::{HeaderName, HeaderValue}; use semver::Version; use time::OffsetDateTime; -pub use self::core::RemoteRelease; +pub use self::core::{DownloadEvent, RemoteRelease}; -use tauri::{AppHandle, EventLoopMessage, Manager, Runtime, UpdaterEvent}; -use tauri_runtime::EventLoopProxy; +use tauri::{AppHandle, Manager, Runtime}; use crate::Result; -/// Check for new updates -pub const EVENT_CHECK_UPDATE: &str = "tauri://update"; -/// New update available -pub const EVENT_UPDATE_AVAILABLE: &str = "tauri://update-available"; -/// Used to initialize an update *should run check-update first (once you received the update available event)* -pub const EVENT_INSTALL_UPDATE: &str = "tauri://update-install"; -/// Send updater status or error even if dialog is enabled, you should -/// always listen for this event. It'll send you the install progress -/// and any error triggered during update check and install -pub const EVENT_STATUS_UPDATE: &str = "tauri://update-status"; -/// The name of the event that is emitted on download progress. -pub const EVENT_DOWNLOAD_PROGRESS: &str = "tauri://update-download-progress"; -/// this is the status emitted when the download start -pub const EVENT_STATUS_PENDING: &str = "PENDING"; -/// When you got this status, something went wrong -/// you can find the error message inside the `error` field. -pub const EVENT_STATUS_ERROR: &str = "ERROR"; -/// The update has been downloaded. -pub const EVENT_STATUS_DOWNLOADED: &str = "DOWNLOADED"; -/// When you receive this status, you should ask the user to restart -pub const EVENT_STATUS_SUCCESS: &str = "DONE"; -/// When you receive this status, this is because the application is running last version -pub const EVENT_STATUS_UPTODATE: &str = "UPTODATE"; - /// 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()) { @@ -127,16 +58,9 @@ struct UpdateManifest { #[derive(Debug)] pub struct UpdateBuilder { inner: core::UpdateBuilder, - events: bool, } impl UpdateBuilder { - /// Do not use the event system to emit information or listen to install the update. - pub fn skip_events(mut self) -> Self { - self.events = false; - self - } - /// Sets the current platform's target name for the updater. /// /// The target is injected in the endpoint URL by replacing `{{target}}`. @@ -272,7 +196,7 @@ impl UpdateBuilder { /// # Examples /// /// ```no_run - /// use tauri_plugin_updater::UpdaterExt; + /// use tauri_plugin_updater::{UpdaterExt, DownloadEvent}; /// tauri::Builder::default() /// .setup(|app| { /// let handle = app.handle(); @@ -280,7 +204,13 @@ impl UpdateBuilder { /// match handle.updater().check().await { /// Ok(update) => { /// if update.is_update_available() { - /// update.download_and_install().await.unwrap(); + /// 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) => { @@ -292,53 +222,10 @@ impl UpdateBuilder { /// }); /// ``` pub async fn check(self) -> Result> { - let handle = self.inner.app.clone(); - let events = self.events; - // check updates - match self.inner.build().await { - Ok(update) => { - if events { - // send notification if we need to update - if update.should_update { - let body = update.body.clone().unwrap_or_else(|| String::from("")); - - // Emit `tauri://update-available` - let _ = handle.emit_all( - EVENT_UPDATE_AVAILABLE, - UpdateManifest { - body: body.clone(), - date: update.date.map(|d| d.to_string()), - version: update.version.clone(), - }, - ); - let _ = handle.create_proxy().send_event(EventLoopMessage::Updater( - UpdaterEvent::UpdateAvailable { - body, - date: update.date, - version: update.version.clone(), - }, - )); - - // Listen for `tauri://update-install` - let update_ = update.clone(); - handle.once_global(EVENT_INSTALL_UPDATE, move |_msg| { - tauri::async_runtime::spawn(async move { - let _ = download_and_install(update_).await; - }); - }); - } else { - send_status_update(&handle, UpdaterEvent::AlreadyUpToDate); - } - } - Ok(UpdateResponse { update }) - } - Err(e) => { - if self.events { - send_status_update(&handle, UpdaterEvent::Error(e.to_string())); - } - Err(e) - } - } + self.inner + .build() + .await + .map(|update| UpdateResponse { update }) } } @@ -382,45 +269,20 @@ impl UpdateResponse { } /// Downloads and installs the update. - pub async fn download_and_install(self) -> Result<()> { - download_and_install(self.update).await + 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.updater.pubkey.clone(), + on_event, + ) + .await } } -pub(crate) async fn download_and_install(update: core::Update) -> Result<()> { - // Start installation - // emit {"status": "PENDING"} - send_status_update(&update.app, UpdaterEvent::Pending); - - let handle = update.app.clone(); - let handle_ = handle.clone(); - - // 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) - let update_result = update - .download_and_install( - update.app.config().tauri.updater.pubkey.clone(), - move |chunk_length, content_length| { - send_download_progress_event(&handle, chunk_length, content_length); - }, - move || { - send_status_update(&handle_, UpdaterEvent::Downloaded); - }, - ) - .await; - - if let Err(err) = &update_result { - // emit {"status": "ERROR", "error": "The error message"} - send_status_update(&update.app, UpdaterEvent::Error(err.to_string())); - } else { - // emit {"status": "DONE"} - send_status_update(&update.app, UpdaterEvent::Updated); - } - update_result -} - /// Initializes the [`UpdateBuilder`] using the app configuration. pub fn builder(handle: AppHandle) -> UpdateBuilder { let updater_config = &handle.config().tauri.updater; @@ -441,61 +303,5 @@ pub fn builder(handle: AppHandle) -> UpdateBuilder { if let Some(target) = &handle.state::().target { builder = builder.target(target); } - UpdateBuilder { - inner: builder, - events: true, - } + UpdateBuilder { inner: builder } } - -// Send a status update via `tauri://update-download-progress` event. -fn send_download_progress_event( - handle: &AppHandle, - chunk_length: usize, - content_length: Option, -) { - let _ = handle.emit_all( - EVENT_DOWNLOAD_PROGRESS, - DownloadProgressEvent { - chunk_length, - content_length, - }, - ); - let _ = handle.create_proxy().send_event(EventLoopMessage::Updater( - UpdaterEvent::DownloadProgress { - chunk_length, - content_length, - }, - )); -} - -// Send a status update via `tauri://update-status` event. -fn send_status_update(handle: &AppHandle, message: UpdaterEvent) { - let _ = handle.emit_all( - EVENT_STATUS_UPDATE, - if let UpdaterEvent::Error(error) = &message { - StatusEvent { - error: Some(error.clone()), - status: status_message(&message).into(), - } - } else { - StatusEvent { - error: None, - status: status_message(&message).into(), - } - }, - ); - let _ = handle - .create_proxy() - .send_event(EventLoopMessage::Updater(message)); -} - -pub(crate) fn status_message(message: &UpdaterEvent) -> &'static str { - match message { - UpdaterEvent::Pending => EVENT_STATUS_PENDING, - UpdaterEvent::Downloaded => EVENT_STATUS_DOWNLOADED, - UpdaterEvent::Updated => EVENT_STATUS_SUCCESS, - UpdaterEvent::AlreadyUpToDate => EVENT_STATUS_UPTODATE, - UpdaterEvent::Error(_) => EVENT_STATUS_ERROR, - _ => unreachable!(), - } - } \ No newline at end of file