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)