diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 3bec57114..416f1b879 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -348,6 +348,7 @@ async function handleCommand(e) { const availableUpdate = ref(null) const updateSkipped = ref(false) +const enqueuedUpdate = ref(null) const updateModal = useTemplateRef('updateModal') async function checkUpdates() { if (!(await areUpdatesEnabled())) { @@ -399,12 +400,18 @@ async function checkUpdates() { } async function skipUpdate(version) { + enqueuedUpdate.value = null + updateSkipped.value = true let settings = await getSettings() settings.skipped_update = version await setSettings(settings) } +async function updateEnqueuedForLater(version) { + enqueuedUpdate.value = version +} + async function forceOpenUpdateModal() { if (updateSkipped.value) { updateSkipped.value = false @@ -456,7 +463,11 @@ function handleAuxClick(e) {
- + @@ -510,11 +521,14 @@ function handleAuxClick(e) {
- - + diff --git a/apps/app-frontend/src/components/ui/UpdateModal.vue b/apps/app-frontend/src/components/ui/UpdateModal.vue index 806be7dbd..3a710637c 100644 --- a/apps/app-frontend/src/components/ui/UpdateModal.vue +++ b/apps/app-frontend/src/components/ui/UpdateModal.vue @@ -3,7 +3,7 @@ ref="modal" :header="formatMessage(messages.header)" :on-hide="onHide" - :closable="!updateInProgress" + :closable="!updatingImmediately" >
{{ formatMessage(messages.bodyVersion, { version: update!.version }) }}
@@ -17,19 +17,22 @@
- - - @@ -44,14 +47,16 @@ import { defineMessages, useVIntl } from '@vintl/vintl' import { useTemplateRef, ref } from 'vue' import { ButtonStyled } from '@modrinth/ui' import { RefreshCwIcon, XIcon, RightArrowIcon } from '@modrinth/assets' -import { getUpdateSize } from '@/helpers/utils' +import { enqueueUpdateForInstallation, getUpdateSize, removeEnqueuedUpdate } from '@/helpers/utils' import { formatBytes } from '@modrinth/utils' import { handleError } from '@/store/notifications' import ProgressBar from '@/components/ui/ProgressBar.vue' -import { Update } from '@tauri-apps/plugin-updater' +import { loading_listener } from '@/helpers/events' +import { getCurrentWindow } from '@tauri-apps/api/window' const emit = defineEmits<{ - (e: 'updateSkipped', version: string): void + (e: 'updateSkipped', version: string): Promise + (e: 'updateEnqueuedForLater', version: string | null): Promise }>() const { formatMessage } = useVIntl() @@ -98,15 +103,26 @@ type UpdateData = { const update = ref() const updateSize = ref() -const updateInProgress = ref(false) + +const updatingImmediately = ref(false) +const downloadInProgress = ref(false) const downloadProgress = ref(0) +const enqueuedUpdate = ref(null) + const modal = useTemplateRef('modal') const isOpen = ref(false) async function show(newUpdate: UpdateData) { + const oldVersion = update.value?.version + update.value = newUpdate updateSize.value = await getUpdateSize(newUpdate.rid).catch(handleError) + + if (oldVersion !== update.value?.version) { + downloadProgress.value = 0 + } + modal.value!.show() isOpen.value = true } @@ -121,25 +137,62 @@ function hide() { defineExpose({ show, hide, isOpen }) -function installUpdateNow() { - updateInProgress.value = true - let totalSize = 0 - let totalDownloaded = 0 - new Update(update.value!).downloadAndInstall((event) => { - if (event.event === 'Started') { - totalSize = event.data.contentLength! - } else if (event.event === 'Progress') { - totalDownloaded += event.data.chunkLength - } else if (event.event === 'Finished') { - totalDownloaded = totalSize +loading_listener((event) => { + if (event.event.type === 'launcher_update') { + if (event.event.version === update.value!.version) { + downloadProgress.value = (event.fraction ?? 1.0) * 100 } - downloadProgress.value = (totalDownloaded / totalSize) * 100 - }) + } +}) + +function installUpdateNow() { + updatingImmediately.value = true + + if (enqueuedUpdate.value !== update.value!.version) { + downloadUpdate() + } else if (!downloadInProgress.value) { + // Update already downloaded. Simply close the app + getCurrentWindow().close() + } +} + +function updateAtNextExit() { + enqueuedUpdate.value = update.value!.version + emit('updateEnqueuedForLater', update.value!.version) + + downloadUpdate() + hide() +} + +async function downloadUpdate() { + const versionToDownload = update.value!.version + + downloadInProgress.value = true + try { + await enqueueUpdateForInstallation(update.value!.rid) + } catch (e) { + downloadInProgress.value = false + + handleError(e) + + enqueuedUpdate.value = null + updatingImmediately.value = false + await emit('updateEnqueuedForLater', null) + return + } + downloadInProgress.value = false + + if (updatingImmediately.value && update.value!.version === versionToDownload) { + await getCurrentWindow().close() + } } function skipUpdate() { - hide() + enqueuedUpdate.value = null emit('updateSkipped', update.value!.version) + + removeEnqueuedUpdate() + hide() } diff --git a/apps/app-frontend/src/helpers/utils.js b/apps/app-frontend/src/helpers/utils.js index 1cda120de..4e46e792f 100644 --- a/apps/app-frontend/src/helpers/utils.js +++ b/apps/app-frontend/src/helpers/utils.js @@ -13,6 +13,14 @@ export async function getUpdateSize(updateRid) { return await invoke('get_update_size', { rid: updateRid }) } +export async function enqueueUpdateForInstallation(updateRid) { + return await invoke('enqueue_update_for_installation', { rid: updateRid }) +} + +export async function removeEnqueuedUpdate() { + return await invoke('remove_enqueued_update') +} + // One of 'Windows', 'Linux', 'MacOS' export async function getOS() { return await invoke('plugin:utils|get_os') diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 294e784f6..55acfe843 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -45,8 +45,12 @@ pub enum TheseusSerializableError { Tauri(#[from] tauri::Error), #[cfg(feature = "updater")] - #[error("Tauri updater error: {0}")] - TauriUpdater(#[from] tauri_plugin_updater::Error), + #[error("Updater error: {0}")] + Updater(#[from] tauri_plugin_updater::Error), + + #[cfg(feature = "updater")] + #[error("HTTP error: {0}")] + Http(#[from] tauri_plugin_http::reqwest::Error), } // Generic implementation of From for ErrorTypeA @@ -104,5 +108,6 @@ impl_serialize! { impl_serialize! { IO, Tauri, - TauriUpdater, + Updater, + Http, } diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index da83ced08..5d901fe9d 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -15,7 +15,9 @@ mod error; mod macos; #[cfg(feature = "updater")] -mod update_size_checker; +mod updater_impl; +#[cfg(not(feature = "updater"))] +mod updater_impl_noop; // Should be called in launcher initialization #[tracing::instrument(skip_all)] @@ -68,13 +70,10 @@ fn are_updates_enabled() -> bool { } #[cfg(feature = "updater")] -pub use update_size_checker::get_update_size; +pub use updater_impl::*; #[cfg(not(feature = "updater"))] -#[tauri::command] -fn get_update_size() -> theseus::Result<()> { - Err(theseus::ErrorKind::OtherError("Updates are disabled in this build.".to_string()).into()) -} +pub use updater_impl_noop::*; // Toggles decorations #[tauri::command] @@ -212,11 +211,14 @@ fn main() { .plugin(api::ads::init()) .plugin(api::friends::init()) .plugin(api::worlds::init()) + .manage(PendingUpdateData::default()) .invoke_handler(tauri::generate_handler![ initialize_state, is_dev, are_updates_enabled, get_update_size, + enqueue_update_for_installation, + remove_enqueued_update, toggle_decorations, show_window, restart_app, @@ -228,8 +230,9 @@ fn main() { match app { Ok(app) => { app.run(|app, event| { - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", feature = "updater")))] drop((app, event)); + #[cfg(target_os = "macos")] if let tauri::RunEvent::Opened { urls } = event { tracing::info!("Handling webview open {urls:?}"); @@ -254,9 +257,29 @@ fn main() { }); } } + + #[cfg(feature = "updater")] + if matches!(event, tauri::RunEvent::Exit) { + let update_data = app.state::().inner(); + if let Some((update, data)) = &*update_data.0.lock().unwrap() { + if let Err(e) = update.install(data) { + tracing::error!("Error while updating: {e}"); + + DialogBuilder::message() + .set_level(MessageLevel::Error) + .set_title("Update error") + .set_text(format!("Failed to install update due to an error:\n{e}")) + .alert() + .show() + .unwrap(); + } + } + } }); } Err(e) => { + tracing::error!("Error while running tauri application: {:?}", e); + #[cfg(target_os = "windows")] { // tauri doesn't expose runtime errors, so matching a string representation seems like the only solution @@ -285,7 +308,6 @@ fn main() { .show() .unwrap(); - tracing::error!("Error while running tauri application: {:?}", e); panic!("{1}: {:?}", e, "error while running tauri application") } } diff --git a/apps/app/src/update_size_checker.rs b/apps/app/src/update_size_checker.rs deleted file mode 100644 index b62ebec8c..000000000 --- a/apps/app/src/update_size_checker.rs +++ /dev/null @@ -1,52 +0,0 @@ -use tauri::{Manager, ResourceId, Runtime, Webview}; -use tauri::http::header::ACCEPT; -use tauri::http::HeaderValue; -use tauri_plugin_http::reqwest; -use tauri_plugin_http::reqwest::ClientBuilder; -use tauri_plugin_updater::Error; -use tauri_plugin_updater::Result; - -const UPDATER_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); - -// Reimplementation of Update::download mostly, minus the actual download part -#[tauri::command] -pub async fn get_update_size(webview: Webview, rid: ResourceId) -> Result> { - use tauri_plugin_updater::Update; - - let update = webview.resources_table().get::(rid)?; - - let mut headers = update.headers.clone(); - if !headers.contains_key(ACCEPT) { - headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream")); - } - - let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT); - if let Some(timeout) = update.timeout { - request = request.timeout(timeout); - } - if let Some(ref proxy) = update.proxy { - let proxy = reqwest::Proxy::all(proxy.as_str())?; - request = request.proxy(proxy); - } - let response = request - .build()? - .get(update.download_url.clone()) - .headers(headers) - .send() - .await?; - - if !response.status().is_success() { - return Err(Error::Network(format!( - "Download request failed with status: {}", - response.status() - )).into()); - } - - let content_length = response - .headers() - .get("Content-Length") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse().ok()); - - Ok(content_length) -} diff --git a/apps/app/src/updater_impl.rs b/apps/app/src/updater_impl.rs new file mode 100644 index 000000000..2f38b5e89 --- /dev/null +++ b/apps/app/src/updater_impl.rs @@ -0,0 +1,122 @@ +use crate::api::Result; +use std::sync::{Arc, Mutex}; +use tauri::http::HeaderValue; +use tauri::http::header::ACCEPT; +use tauri::{Manager, ResourceId, Runtime, Webview}; +use tauri_plugin_http::reqwest; +use tauri_plugin_http::reqwest::ClientBuilder; +use tauri_plugin_updater::Error; +use tauri_plugin_updater::Update; +use theseus::{LoadingBarType, emit_loading, init_loading}; +use tokio::time::Instant; + +const UPDATER_USER_AGENT: &str = + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); + +#[derive(Default)] +pub struct PendingUpdateData(pub Mutex, Vec)>>); + +// Reimplementation of Update::download mostly, minus the actual download part +#[tauri::command] +pub async fn get_update_size( + webview: Webview, + rid: ResourceId, +) -> Result> { + let update = webview.resources_table().get::(rid)?; + + let mut headers = update.headers.clone(); + if !headers.contains_key(ACCEPT) { + headers.insert( + ACCEPT, + HeaderValue::from_static("application/octet-stream"), + ); + } + + let mut request = ClientBuilder::new().user_agent(UPDATER_USER_AGENT); + if let Some(timeout) = update.timeout { + request = request.timeout(timeout); + } + if let Some(ref proxy) = update.proxy { + let proxy = reqwest::Proxy::all(proxy.as_str())?; + request = request.proxy(proxy); + } + let response = request + .build()? + .get(update.download_url.clone()) + .headers(headers) + .send() + .await?; + + if !response.status().is_success() { + return Err(Error::Network(format!( + "Download request failed with status: {}", + response.status() + )) + .into()); + } + + let content_length = response + .headers() + .get("Content-Length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()); + + Ok(content_length) +} + +#[tauri::command] +pub async fn enqueue_update_for_installation( + webview: Webview, + rid: ResourceId, +) -> Result<()> { + let pending_data = webview.state::().inner(); + + let update = webview.resources_table().get::(rid)?; + + let progress = init_loading( + LoadingBarType::LauncherUpdate { + version: update.version.clone(), + current_version: update.current_version.clone(), + }, + 1.0, + "Downloading update...", + ) + .await?; + + let download_start = Instant::now(); + let update_data = update + .download( + |chunk_size, total_size| { + let Some(total_size) = total_size else { + return; + }; + if let Err(e) = emit_loading( + &progress, + chunk_size as f64 / total_size as f64, + None, + ) { + tracing::error!( + "Failed to update download progress bar: {e}" + ); + } + }, + || {}, + ) + .await?; + let download_duration = download_start.elapsed(); + tracing::info!("Downloaded update in {download_duration:?}"); + + pending_data + .0 + .lock() + .unwrap() + .replace((update, update_data)); + + Ok(()) +} + +#[tauri::command] +pub fn remove_enqueued_update(webview: Webview) { + let pending_data = webview.state::().inner(); + pending_data.0.lock().unwrap().take(); +} diff --git a/apps/app/src/updater_impl_noop.rs b/apps/app/src/updater_impl_noop.rs new file mode 100644 index 000000000..b8871f65b --- /dev/null +++ b/apps/app/src/updater_impl_noop.rs @@ -0,0 +1,26 @@ +use crate::api::Result; +use theseus::ErrorKind; + +#[derive(Default)] +pub struct PendingUpdateData; + +#[tauri::command] +pub fn get_update_size() -> Result<()> { + updates_are_disabled() +} + +#[tauri::command] +pub fn enqueue_update_for_installation() -> Result<()> { + updates_are_disabled() +} + +fn updates_are_disabled() -> Result<()> { + let error: theseus::Error = ErrorKind::OtherError( + "Updates are disabled in this build.".to_string(), + ) + .into(); + Err(error.into()) +} + +#[tauri::command] +pub fn remove_enqueued_update() {} diff --git a/packages/app-lib/src/event/mod.rs b/packages/app-lib/src/event/mod.rs index 92b7b53fa..92e71416a 100644 --- a/packages/app-lib/src/event/mod.rs +++ b/packages/app-lib/src/event/mod.rs @@ -176,7 +176,6 @@ pub enum LoadingBarType { import_location: PathBuf, profile_name: String, }, - CheckingForUpdates, LauncherUpdate { version: String, current_version: String,