Implement updating at next exit

This commit is contained in:
Josiah Glosson 2025-07-09 19:18:33 -05:00
parent 7b73aa2908
commit 9b103e063a
9 changed files with 287 additions and 90 deletions

View File

@ -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) {
<div id="teleports"></div>
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
<Suspense @resolve="checkUpdates">
<UpdateModal ref="updateModal" @update-skipped="skipUpdate" />
<UpdateModal
ref="updateModal"
@update-skipped="skipUpdate"
@update-enqueued-for-later="updateEnqueuedForLater"
/>
</Suspense>
<Suspense>
<AppSettingsModal ref="settingsModal" />
@ -510,11 +521,14 @@ function handleAuxClick(e) {
<div class="flex flex-grow"></div>
<NavButton
v-if="!!availableUpdate"
v-tooltip.right="'Update available'"
v-tooltip.right="
enqueuedUpdate === availableUpdate?.version
? 'Update installation queued for next restart'
: 'Update available'
"
:to="forceOpenUpdateModal"
>
<!-- TODO: Also gray if updating on next restart -->
<DownloadIcon v-if="updateSkipped" />
<DownloadIcon v-if="updateSkipped || enqueuedUpdate === availableUpdate?.version" />
<DownloadIcon v-else class="text-brand-green" />
</NavButton>
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">

View File

@ -3,7 +3,7 @@
ref="modal"
:header="formatMessage(messages.header)"
:on-hide="onHide"
:closable="!updateInProgress"
:closable="!updatingImmediately"
>
<div>{{ formatMessage(messages.bodyVersion, { version: update!.version }) }}</div>
<div v-if="updateSize">
@ -17,19 +17,22 @@
<ProgressBar class="mt-4" :progress="downloadProgress" />
<div class="mt-4 flex flex-wrap gap-2">
<ButtonStyled color="green">
<button :disabled="updateInProgress" @click="installUpdateNow">
<button :disabled="updatingImmediately" @click="installUpdateNow">
<RefreshCwIcon />
{{ formatMessage(messages.restartNow) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="updateInProgress">
<button
:disabled="updatingImmediately || downloadInProgress || update!.version == enqueuedUpdate"
@click="updateAtNextExit"
>
<RightArrowIcon />
{{ formatMessage(messages.later) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="updateInProgress" @click="skipUpdate">
<button :disabled="updatingImmediately || downloadInProgress" @click="skipUpdate">
<XIcon />
{{ formatMessage(messages.skip) }}
</button>
@ -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<void>
(e: 'updateEnqueuedForLater', version: string | null): Promise<void>
}>()
const { formatMessage } = useVIntl()
@ -98,15 +103,26 @@ type UpdateData = {
const update = ref<UpdateData>()
const updateSize = ref<number>()
const updateInProgress = ref(false)
const updatingImmediately = ref(false)
const downloadInProgress = ref(false)
const downloadProgress = ref(0)
const enqueuedUpdate = ref<string | null>(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
}
}
})
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()
}
downloadProgress.value = (totalDownloaded / totalSize) * 100
})
}
function skipUpdate() {
hide()
enqueuedUpdate.value = null
emit('updateSkipped', update.value!.version)
removeEnqueuedUpdate()
hide()
}
</script>

View File

@ -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')

View File

@ -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<T> for ErrorTypeA
@ -104,5 +108,6 @@ impl_serialize! {
impl_serialize! {
IO,
Tauri,
TauriUpdater,
Updater,
Http,
}

View File

@ -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::<PendingUpdateData>().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")
}
}

View File

@ -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<R: Runtime>(webview: Webview<R>, rid: ResourceId) -> Result<Option<u64>> {
use tauri_plugin_updater::Update;
let update = webview.resources_table().get::<Update>(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)
}

View File

@ -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<Option<(Arc<Update>, Vec<u8>)>>);
// Reimplementation of Update::download mostly, minus the actual download part
#[tauri::command]
pub async fn get_update_size<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<Option<u64>> {
let update = webview.resources_table().get::<Update>(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<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<()> {
let pending_data = webview.state::<PendingUpdateData>().inner();
let update = webview.resources_table().get::<Update>(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<R: Runtime>(webview: Webview<R>) {
let pending_data = webview.state::<PendingUpdateData>().inner();
pending_data.0.lock().unwrap().take();
}

View File

@ -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() {}

View File

@ -176,7 +176,6 @@ pub enum LoadingBarType {
import_location: PathBuf,
profile_name: String,
},
CheckingForUpdates,
LauncherUpdate {
version: String,
current_version: String,