From a538b99c18fcba44d25ae0bfa9214e3d735f9b7e Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Mon, 29 Sep 2025 09:28:31 -0600 Subject: [PATCH] Reworked app update flow (#3960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make theseus capable of logging messages from the `log` crate * Move update checking entirely into JS and open a modal if an update is available * Fix formatjs on Windows and run formatjs * Add in the buttons and body * Fix lint * Show update size in modal * Fix update not being rechecked if the update modal was directly dismissed * Slight UI tweaks * Fix lint * Implement skipping the update * Implement the Update Now button * Implement updating at next exit * Turn download progress into an error bar on failure * Restore 5 minute update check instead of 30 seconds * Fix PendingUpdateData being seen as a unit struct * Fix lint * Make CI also lint updater code * feat: create AppearingProgressBar component * feat: polish update available modal * feat: add error handling * Open changelog with tauri-plugin-opener * Run intl:extract * Update completion toasts (#3978) * Use single LAUNCHER_USER_AGENT constant for all user agents * Fix build on Mac * Request the update size with HEAD instead of GET * UI tweaks * lint * Fix lint * fix: hide modal header & add "Hide update reminder" button w/ tooltip * Run intl:extract * fix: lint issues * fix: merge issues * notifications.js no longer exists * Add metered network checking * Add a timeout to macOS is_network_metered * Fix tauri.conf.json * vibe debugging * Set a dispatch queue * Have a popup that asks you if you'd like to disable automatic file downloads if you're on a metered network * Move UpdateModal to modal package * Fix lint * Add a toggle for automatic downloads * Fix type Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com> Signed-off-by: Josiah Glosson * Redo updating UI and experience * lint * fix unlistener issue * remove unneeded translation keys * Fix expose issue * temp disable cranelift, tweak some messages * change version back * Clean up App.vue * move toast to top right * update reload icon * Fixed the bug!!!!!!!!!!!! * improve messages * intl:extract * Add liquid glass icon file * not you! * use dependency injection * lint on apple icon * Fix imports, move download size to button * change update check back to 5 mins * lint + move to providers * intl:extract --------- Signed-off-by: Cal H. Signed-off-by: Josiah Glosson Co-authored-by: Calum Co-authored-by: Prospector Co-authored-by: Cal H. Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: Alejandro González <7822554+AlexTMjugador@users.noreply.github.com> --- Cargo.lock | 20 ++ Cargo.toml | 4 + apps/app-frontend/src/App.vue | 336 +++++++++++++++++- .../src/components/ui/NavButton.vue | 20 +- .../src/components/ui/ProgressBar.vue | 13 +- .../src/components/ui/RunningAppBar.vue | 8 +- .../src/components/ui/SplashScreen.vue | 5 +- .../src/components/ui/UpdateToast.vue | 131 +++++++ .../components/ui/modal/AppSettingsModal.vue | 24 +- .../src/components/ui/modal/ModalWrapper.vue | 13 +- apps/app-frontend/src/helpers/settings.ts | 4 + apps/app-frontend/src/helpers/utils.js | 27 +- .../app-frontend/src/locales/en-US/index.json | 45 +++ .../src/providers/download-progress.ts | 36 ++ apps/app/build.rs | 1 + .../icons/apple.icon/Assets/Modrinth logo.svg | 19 + apps/app/icons/apple.icon/icon.json | 73 ++++ apps/app/package.json | 4 +- apps/app/src/api/mod.rs | 11 +- apps/app/src/api/utils.rs | 20 +- apps/app/src/main.rs | 141 ++++---- apps/app/src/updater_impl.rs | 121 +++++++ apps/app/src/updater_impl_noop.rs | 26 ++ apps/frontend/package.json | 2 +- .../ui/servers/PlatformMrpackModal.vue | 119 +------ apps/labrinth/Cargo.toml | 19 +- ...cdcf73da199ea6ac05ee3ee798ece80d877cf.json | 2 +- ...8dab4d0b9ded6200f827b9de7ac32e5318d5.json} | 24 +- ...9474cc5c0d36cc0698c4cc0852da81fed158.json} | 6 +- packages/app-lib/Cargo.toml | 9 +- .../20250815164653_updater-settings.sql | 6 + packages/app-lib/src/api/mod.rs | 2 +- packages/app-lib/src/error.rs | 7 + packages/app-lib/src/event/mod.rs | 1 - packages/app-lib/src/lib.rs | 6 + packages/app-lib/src/logger.rs | 15 +- packages/app-lib/src/state/friends.rs | 7 +- packages/app-lib/src/state/settings.rs | 22 +- packages/app-lib/src/util/fetch.rs | 8 +- packages/app-lib/src/util/network.rs | 76 ++++ packages/assets/generated-icons.ts | 2 + packages/assets/icons/refresh-cw.svg | 1 + .../components/base/AppearingProgressBar.vue | 140 ++++++++ .../ui/src/components/base/JoinedButtons.vue | 121 +++++++ .../src/components/base/ProgressSpinner.vue | 58 +++ packages/ui/src/components/index.ts | 4 + packages/ui/src/components/modal/NewModal.vue | 5 +- packages/ui/src/locales/en-US/index.json | 3 + packages/ui/src/utils/common-messages.ts | 4 + 49 files changed, 1487 insertions(+), 284 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/UpdateToast.vue create mode 100644 apps/app-frontend/src/providers/download-progress.ts create mode 100644 apps/app/icons/apple.icon/Assets/Modrinth logo.svg create mode 100644 apps/app/icons/apple.icon/icon.json create mode 100644 apps/app/src/updater_impl.rs create mode 100644 apps/app/src/updater_impl_noop.rs rename packages/app-lib/.sqlx/{query-5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca.json => query-7dc83d7ffa3d583fc5ffaf13811a8dab4d0b9ded6200f827b9de7ac32e5318d5.json} (87%) rename packages/app-lib/.sqlx/{query-3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c.json => query-eb95fac3043d0ffd10caef69cc469474cc5c0d36cc0698c4cc0852da81fed158.json} (80%) create mode 100644 packages/app-lib/migrations/20250815164653_updater-settings.sql create mode 100644 packages/assets/icons/refresh-cw.svg create mode 100644 packages/ui/src/components/base/AppearingProgressBar.vue create mode 100644 packages/ui/src/components/base/JoinedButtons.vue create mode 100644 packages/ui/src/components/base/ProgressSpinner.vue diff --git a/Cargo.lock b/Cargo.lock index 68f3dcbd6..e93f8143d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1388,6 +1388,21 @@ dependencies = [ "stacker", ] +[[package]] +name = "cidre" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe72b7127090407349fdb577f063dc6508e5cce89d9bd21dc77c1c84f5d4703" +dependencies = [ + "cidre-macros", +] + +[[package]] +name = "cidre-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82bc2f84c0baaa09299da3a03864491549685912c1e338a54211e00589dc1e4c" + [[package]] name = "cityhash-rs" version = "1.0.1" @@ -9026,6 +9041,7 @@ dependencies = [ "bytes", "chardetng", "chrono", + "cidre", "daedalus", "dashmap", "data-url", @@ -9075,7 +9091,10 @@ dependencies = [ "url", "uuid 1.17.0", "whoami", + "windows", + "windows-core", "winreg 0.55.0", + "zbus", "zip", ] @@ -9354,6 +9373,7 @@ dependencies = [ "futures-io", "futures-sink", "pin-project-lite", + "slab", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 2c80cd5e3..2d4a5d5a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ bytes = "1.10.1" censor = "0.3.0" chardetng = "0.1.17" chrono = "0.4.41" +cidre = { version = "0.11.2", default-features = false, features = ["macos_15_0"] } clap = "4.5.43" clickhouse = "0.13.3" color-thief = "0.2.2" @@ -173,9 +174,12 @@ uuid = "1.17.0" validator = "0.20.0" webp = { version = "0.3.0", default-features = false } whoami = "1.6.0" +windows = "0.61.3" +windows-core = "0.61.2" winreg = "0.55.0" woothee = "0.13.0" yaserde = "0.12.0" +zbus = "5.9.0" zip = { version = "4.3.0", default-features = false, features = [ "bzip2", "deflate", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index a2d28ff1f..359a26af4 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -14,6 +14,7 @@ import { NewspaperIcon, NotepadTextIcon, PlusIcon, + RefreshCwIcon, RestoreIcon, RightArrowIcon, SettingsIcon, @@ -24,9 +25,11 @@ import { Avatar, Button, ButtonStyled, + commonMessages, NewsArticleCard, NotificationPanel, OverflowMenu, + ProgressSpinner, provideNotificationManager, } from '@modrinth/ui' import { renderString } from '@modrinth/utils' @@ -35,8 +38,8 @@ import { invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' import { openUrl } from '@tauri-apps/plugin-opener' import { type } from '@tauri-apps/plugin-os' -import { check } from '@tauri-apps/plugin-updater' import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' +import { defineMessages, useVIntl } from '@vintl/vintl' import { $fetch } from 'ofetch' import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue' import { RouterView, useRoute, useRouter } from 'vue-router' @@ -58,6 +61,7 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import RunningAppBar from '@/components/ui/RunningAppBar.vue' import SplashScreen from '@/components/ui/SplashScreen.vue' +import UpdateToast from '@/components/ui/UpdateToast.vue' import URLConfirmModal from '@/components/ui/URLConfirmModal.vue' import { useCheckDisableMouseover } from '@/composables/macCssFix.js' import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js' @@ -67,9 +71,20 @@ import { command_listener, warning_listener } from '@/helpers/events.js' import { useFetch } from '@/helpers/fetch.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js' import { list } from '@/helpers/profile.js' -import { get } from '@/helpers/settings.ts' +import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' -import { getOS, isDev, restartApp } from '@/helpers/utils.js' +import { + areUpdatesEnabled, + enqueueUpdateForInstallation, + getOS, + getUpdateSize, + isDev, + isNetworkMetered, +} from '@/helpers/utils.js' +import { + provideAppUpdateDownloadProgress, + subscribeToDownloadProgress, +} from '@/providers/download-progress.ts' import { useError } from '@/store/error.js' import { useInstall } from '@/store/install.js' import { useLoading, useTheming } from '@/store/state' @@ -114,11 +129,39 @@ onMounted(async () => { document.querySelector('body').addEventListener('click', handleClick) document.querySelector('body').addEventListener('auxclick', handleAuxClick) + + checkUpdates() }) -onUnmounted(() => { +onUnmounted(async () => { document.querySelector('body').removeEventListener('click', handleClick) document.querySelector('body').removeEventListener('auxclick', handleAuxClick) + + await unlistenUpdateDownload?.() +}) + +const { formatMessage } = useVIntl() +const messages = defineMessages({ + updateInstalledToastTitle: { + id: 'app.update.complete-toast.title', + defaultMessage: 'Version {version} was successfully installed!', + }, + updateInstalledToastText: { + id: 'app.update.complete-toast.text', + defaultMessage: 'Click here to view the changelog.', + }, + reloadToUpdate: { + id: 'app.update.reload-to-update', + defaultMessage: 'Reload to install update', + }, + downloadUpdate: { + id: 'app.update.download-update', + defaultMessage: 'Download update', + }, + downloadingUpdate: { + id: 'app.update.downloading-update', + defaultMessage: 'Downloading update ({percent}%)', + }, }) async function setupApp() { @@ -134,7 +177,8 @@ async function setupApp() { toggle_sidebar, developer_mode, feature_flags, - } = await get() + pending_update_toast_for_version, + } = await getSettings() if (default_page === 'Library') { await router.push('/library') @@ -221,7 +265,6 @@ async function setupApp() { }) get_opening_command().then(handleCommand) - checkUpdates() fetchCredentials() try { @@ -232,6 +275,22 @@ async function setupApp() { console.warn('Failed to generate skin previews in app setup.', error) } + if (pending_update_toast_for_version !== null) { + const settings = await getSettings() + settings.pending_update_toast_for_version = null + await setSettings(settings) + + const version = await getVersion() + if (pending_update_toast_for_version === version) { + notifications.addNotification({ + type: 'success', + title: formatMessage(messages.updateInstalledToastTitle, { version }), + text: formatMessage(messages.updateInstalledToastText), + clickAction: () => openUrl('https://modrinth.com/news/changelog?filter=app'), + }) + } + } + if (osType === 'windows') { await processPendingSurveys() } else { @@ -377,19 +436,113 @@ async function handleCommand(e) { } } -const updateAvailable = ref(false) -async function checkUpdates() { - const update = await check() - updateAvailable.value = !!update +const appUpdateDownload = { + progress: ref(0), + version: ref(), +} +let unlistenUpdateDownload +const downloadProgress = computed(() => appUpdateDownload.progress.value) +const downloadPercent = computed(() => Math.trunc(appUpdateDownload.progress.value * 100)) + +const metered = ref(true) +const finishedDownloading = ref(false) +const restarting = ref(false) +const updateToastDismissed = ref(false) +const availableUpdate = ref(null) +const updateSize = ref(null) +async function checkUpdates() { + if (!(await areUpdatesEnabled())) { + console.log('Skipping update check as updates are disabled in this build') + return + } + + async function performCheck() { + const update = await invoke('plugin:updater|check') + const isExistingUpdate = update.version === availableUpdate.value?.version + + if (!update) { + console.log('No update available') + return + } + + if (isExistingUpdate) { + console.log('Update is already known') + return + } + + appUpdateDownload.progress.value = 0 + finishedDownloading.value = false + updateToastDismissed.value = false + + console.log(`Update ${update.version} is available.`) + + metered.value = await isNetworkMetered() + if (!metered.value) { + console.log('Starting download of update') + downloadUpdate(update) + } else { + console.log(`Metered connection detected, not auto-downloading update.`) + } + + getUpdateSize(update.rid).then((size) => (updateSize.value = size)) + + availableUpdate.value = update + } + + await performCheck() setTimeout( () => { checkUpdates() }, - 5 * 1000 * 60, + 5 /* min */ * 60 /* sec */ * 1000 /* ms */, ) } +async function showUpdateToast() { + updateToastDismissed.value = false +} + +async function downloadAvailableUpdate() { + return downloadUpdate(availableUpdate.value) +} + +async function downloadUpdate(versionToDownload) { + if (!versionToDownload) { + handleError(`Failed to download update: no version available`) + } + + if (appUpdateDownload.progress.value !== 0) { + console.error(`Update ${versionToDownload.version} already downloading`) + return + } + + console.log(`Downloading update ${versionToDownload.version}`) + + try { + enqueueUpdateForInstallation(versionToDownload.rid).then(() => { + finishedDownloading.value = true + unlistenUpdateDownload?.().then(() => { + unlistenUpdateDownload = null + }) + console.log('Finished downloading!') + }) + unlistenUpdateDownload = await subscribeToDownloadProgress( + appUpdateDownload, + versionToDownload.version, + ) + } catch (e) { + handleError(e) + } +} + +async function installUpdate() { + restarting.value = true + setTimeout(async () => { + await handleClose() + }, 250) +} + function handleClick(e) { let target = e.target while (target != null) { @@ -534,12 +687,47 @@ async function processPendingSurveys() { console.info('No user survey to show') } } + +provideAppUpdateDownloadProgress(appUpdateDownload)