diff --git a/.github/workflows/theseus-build.yml b/.github/workflows/theseus-build.yml index 90aa645e02..46bb693b8c 100644 --- a/.github/workflows/theseus-build.yml +++ b/.github/workflows/theseus-build.yml @@ -21,6 +21,14 @@ on: type: boolean default: false required: false + environment: + description: Environment + type: choice + options: + - prod + - staging + default: prod + required: false jobs: build: @@ -94,12 +102,14 @@ jobs: shell: bash run: | APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')" + BUILD_ENVIRONMENT="${{ inputs.environment || 'prod' }}" echo "Setting application version to $APP_VERSION" + echo "Using environment $BUILD_ENVIRONMENT" dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version' dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version' dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version' - cp packages/app-lib/.env.prod packages/app-lib/.env + cp "packages/app-lib/.env.${BUILD_ENVIRONMENT}" packages/app-lib/.env - name: Setup Turbo cache uses: rharkor/caching-for-turbo@v1.8 diff --git a/Cargo.lock b/Cargo.lock index 8245cfff0d..15ad9a2ed3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,6 +640,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-minecraft-ping" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b459c14dd8d9ef21e296af3f2a3651ff7dc3536e092fb0b09e528daaa6d89" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "async-process" version = "2.5.0" @@ -2167,6 +2180,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -2195,6 +2218,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -2217,6 +2253,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.106", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -4730,6 +4777,7 @@ dependencies = [ "arc-swap", "argon2", "ariadne", + "async-minecraft-ping", "async-stripe", "async-trait", "base64 0.22.1", @@ -4811,6 +4859,16 @@ dependencies = [ "zxcvbn", ] +[[package]] +name = "labrinth-derive" +version = "0.0.0" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "language-tags" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index d5d9ba9131..a625b83bf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "packages/app-lib", "packages/ariadne", "packages/daedalus", + "packages/labrinth-derive", "packages/modrinth-log", "packages/modrinth-maxmind", "packages/modrinth-util", @@ -32,6 +33,7 @@ arc-swap = "1.7.1" argon2 = { version = "0.5.3", features = ["std"] } ariadne = { path = "packages/ariadne" } async-compression = { version = "0.4.32", default-features = false } +async-minecraft-ping = { version = "0.8.0" } async-recursion = "1.1.1" async-stripe = { version = "0.41.0", default-features = false, features = [ "runtime-tokio-hyper-rustls", @@ -58,6 +60,7 @@ color-eyre = "0.6.5" color-thief = "0.2.2" const_format = "0.2.34" daedalus = { path = "packages/daedalus" } +darling = { version = "0.23" } dashmap = "6.1.0" data-url = "0.3.2" deadpool-redis = { git = "https://github.com/modrinth/deadpool", rev = "db5fb00b036ecc8fe5f18853c559b745ffe47bde", version = "0.22.1" } @@ -121,9 +124,11 @@ paste = "1.0.15" path-util = { path = "packages/path-util" } phf = { version = "0.13.1", features = ["macros"] } png = "0.18.0" +proc-macro2 = { version = "1.0" } prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.38.3" +quote = { version = "1.0" } rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9 rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 redis = "0.32.7" @@ -166,6 +171,7 @@ spdx = "0.12.0" sqlx = { version = "0.8.6", default-features = false } sqlx-tracing = { path = "packages/sqlx-tracing" } strum = "0.27.2" +syn = { version = "2.0" } sysinfo = { version = "0.37.2", default-features = false } tar = "0.4.44" tauri = "2.8.5" diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 601d7172a6..4704d568c0 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -36,14 +36,16 @@ import { NewsArticleCard, NotificationPanel, OverflowMenu, + PopupNotificationPanel, ProgressSpinner, provideModrinthClient, provideNotificationManager, providePageContext, + providePopupNotificationManager, useDebugLogger, useVIntl, } from '@modrinth/ui' -import { renderString } from '@modrinth/utils' +import { formatBytes, renderString } from '@modrinth/utils' import { useQuery } from '@tanstack/vue-query' import { getVersion } from '@tauri-apps/api/app' import { invoke } from '@tauri-apps/api/core' @@ -61,6 +63,7 @@ import AccountsCard from '@/components/ui/AccountsCard.vue' import Breadcrumbs from '@/components/ui/Breadcrumbs.vue' import ErrorModal from '@/components/ui/ErrorModal.vue' import FriendsList from '@/components/ui/friends/FriendsList.vue' +import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue' import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue' import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue' import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue' @@ -68,13 +71,13 @@ import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue' import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' +import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue' +import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue' import NavButton from '@/components/ui/NavButton.vue' 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 UpdateAvailableToast from '@/components/ui/UpdateAvailableToast.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' @@ -101,13 +104,14 @@ import { subscribeToDownloadProgress, } from '@/providers/download-progress.ts' import { useError } from '@/store/error.js' -import { useInstall } from '@/store/install.js' +import { playServerProject, useInstall } from '@/store/install.js' import { useLoading, useTheming } from '@/store/state' import { create_profile_and_install_from_file } from './helpers/pack' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' import { get_available_capes, get_available_skins } from './helpers/skins' import { AppNotificationManager } from './providers/app-notifications' +import { AppPopupNotificationManager } from './providers/app-popup-notifications' const themeStore = useTheming() @@ -115,6 +119,10 @@ const notificationManager = new AppNotificationManager() provideNotificationManager(notificationManager) const { handleError, addNotification } = notificationManager +const popupNotificationManager = new AppPopupNotificationManager() +providePopupNotificationManager(popupNotificationManager) +const { addPopupNotification } = popupNotificationManager + const tauriApiClient = new TauriModrinthClient({ userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`, features: [ @@ -393,8 +401,11 @@ const minecraftAuthErrorModal = ref() const install = useInstall() const modInstallModal = ref() +const addServerToInstanceModal = ref() const installConfirmModal = ref() const incompatibilityWarningModal = ref() +const installToPlayModal = ref() +const updateToPlayModal = ref() const credentials = ref() @@ -473,6 +484,10 @@ onMounted(() => { install.setIncompatibilityWarningModal(incompatibilityWarningModal) install.setInstallConfirmModal(installConfirmModal) install.setModInstallModal(modInstallModal) + install.setAddServerToInstanceModal(addServerToInstanceModal) + install.setInstallToPlayModal(installToPlayModal) + install.setUpdateToPlayModal(updateToPlayModal) + install.setPopupNotificationManager(popupNotificationManager) }) const accounts = ref(null) @@ -490,6 +505,9 @@ async function handleCommand(e) { source: 'CreationModalFileDrop', }) } + } else if (e.event === 'InstallServer') { + await router.push(`/project/${e.id}`) + await playServerProject(e.id).catch(handleError) } else { // Other commands are URL-based (deep linking) urlModal.value.show(e) @@ -508,14 +526,60 @@ const downloadPercent = computed(() => Math.trunc(appUpdateDownload.progress.val const metered = ref(true) const finishedDownloading = ref(false) const restarting = ref(false) -const updateToastDismissed = ref(false) const availableUpdate = ref(null) const updateSize = ref(null) const updatesEnabled = ref(true) + +const updatePopupMessages = defineMessages({ + updateAvailable: { + id: 'app.update-popup.title', + defaultMessage: 'Update available', + }, + downloadComplete: { + id: 'app.update-popup.download-complete', + defaultMessage: 'Download complete', + }, + body: { + id: 'app.update-popup.body', + defaultMessage: + 'Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App.', + }, + meteredBody: { + id: 'app.update-popup.body.metered', + defaultMessage: `Modrinth App v{version} is available now! Since you're on a metered network, we didn't automatically download it.`, + }, + downloadedBody: { + id: 'app.update-popup.body.download-complete', + defaultMessage: `Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App.`, + }, + linuxBody: { + id: 'app.update-popup.body.linux', + defaultMessage: + 'Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!', + }, + reload: { + id: 'app.update-popup.reload', + defaultMessage: 'Reload', + }, + download: { + id: 'app.update-popup.download', + defaultMessage: 'Download ({size})', + }, + changelog: { + id: 'app.update-popup.changelog', + defaultMessage: 'Changelog', + }, +}) + async function checkUpdates() { if (!(await areUpdatesEnabled())) { console.log('Skipping update check as updates are disabled in this build or environment') updatesEnabled.value = false + + if (os.value === 'Linux' && !isDevEnvironment.value) { + checkLinuxUpdates() + setInterval(checkLinuxUpdates, 5 * 60 * 1000) + } return } @@ -535,7 +599,6 @@ async function checkUpdates() { appUpdateDownload.progress.value = 0 finishedDownloading.value = false - updateToastDismissed.value = false console.log(`Update ${update.version} is available.`) @@ -545,6 +608,28 @@ async function checkUpdates() { downloadUpdate(update) } else { console.log(`Metered connection detected, not auto-downloading update.`) + getUpdateSize(update.rid).then((size) => { + updateSize.value = size + addPopupNotification({ + title: formatMessage(updatePopupMessages.updateAvailable), + text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }), + type: 'info', + autoCloseMs: null, + buttons: [ + { + label: formatMessage(updatePopupMessages.download, { + size: formatBytes(updateSize.value ?? 0), + }), + action: () => downloadAvailableUpdate(), + color: 'brand', + }, + { + label: formatMessage(updatePopupMessages.changelog), + action: () => openUrl('https://modrinth.com/news/changelog?filter=app'), + }, + ], + }) + }) } getUpdateSize(update.rid).then((size) => (updateSize.value = size)) @@ -561,8 +646,26 @@ async function checkUpdates() { ) } -async function showUpdateToast() { - updateToastDismissed.value = false +async function checkLinuxUpdates() { + try { + const [response, currentVersion] = await Promise.all([ + fetch('https://launcher-files.modrinth.com/updates.json'), + getVersion(), + ]) + const updates = await response.json() + const latestVersion = updates?.version + + if (latestVersion && latestVersion !== currentVersion) { + addPopupNotification({ + title: formatMessage(updatePopupMessages.updateAvailable), + text: formatMessage(updatePopupMessages.linuxBody, { version: latestVersion }), + type: 'info', + autoCloseMs: null, + }) + } + } catch (e) { + console.error('Failed to check for updates:', e) + } } async function downloadAvailableUpdate() { @@ -588,6 +691,26 @@ async function downloadUpdate(versionToDownload) { unlistenUpdateDownload = null }) console.log('Finished downloading!') + + addPopupNotification({ + title: formatMessage(updatePopupMessages.downloadComplete), + text: formatMessage(updatePopupMessages.downloadedBody, { + version: versionToDownload.version, + }), + type: 'success', + autoCloseMs: null, + buttons: [ + { + label: formatMessage(updatePopupMessages.reload), + action: () => installUpdate(), + color: 'brand', + }, + { + label: formatMessage(updatePopupMessages.changelog), + action: () => openUrl('https://modrinth.com/news/changelog?filter=app'), + }, + ], + }) }) unlistenUpdateDownload = await subscribeToDownloadProgress( appUpdateDownload, @@ -761,25 +884,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload) class="app-grid-layout experimental-styles-within relative" :class="{ 'disable-advanced-rendering': !themeStore.advancedRendering }" > - - - - - -
-
+
- + diff --git a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue index 7186203c12..442f424ec4 100644 --- a/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue +++ b/apps/app-frontend/src/components/ui/QuickInstanceSwitcher.vue @@ -69,7 +69,10 @@ onUnmounted(() => {
-
+
diff --git a/apps/app-frontend/src/components/ui/RunningAppBar.vue b/apps/app-frontend/src/components/ui/RunningAppBar.vue index 53f050362c..d95f1fb298 100644 --- a/apps/app-frontend/src/components/ui/RunningAppBar.vue +++ b/apps/app-frontend/src/components/ui/RunningAppBar.vue @@ -52,10 +52,12 @@

{{ loadingBar.title }}

- -
- {{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}% - {{ loadingBar.message }} +
+ +
+ {{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}% + {{ loadingBar.message }} +
@@ -102,7 +104,12 @@ import { TerminalSquareIcon, UnplugIcon, } from '@modrinth/assets' -import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui' +import { + Button, + ButtonStyled, + Card, + injectNotificationManager, +} from '@modrinth/ui' import { onBeforeUnmount, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' @@ -176,7 +183,7 @@ const goToTerminal = (path) => { const currentLoadingBars = ref([]) const refreshInfo = async () => { - const currentLoadingBarCount = currentLoadingBars.value.length + const previousBars = [...currentLoadingBars.value] currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError)) .map((x) => { if (x.bar_type.type === 'java_download') { @@ -205,7 +212,7 @@ const refreshInfo = async () => { if (currentLoadingBars.value.length === 0) { showCard.value = false - } else if (currentLoadingBarCount < currentLoadingBars.value.length) { + } else if (previousBars.length < currentLoadingBars.value.length) { showCard.value = true } } @@ -346,7 +353,7 @@ onBeforeUnmount(() => { .info-card { position: absolute; top: 3.5rem; - right: 0.5rem; + right: 100%; z-index: 9; width: 20rem; background-color: var(--color-raised-bg); @@ -420,7 +427,7 @@ onBeforeUnmount(() => { display: flex; flex-direction: column; align-items: flex-start; - gap: 0.5rem; + gap: 0.75rem; margin: 0; padding: 0; } diff --git a/apps/app-frontend/src/components/ui/UpdateAvailableToast.vue b/apps/app-frontend/src/components/ui/UpdateAvailableToast.vue deleted file mode 100644 index ccae3ef5a1..0000000000 --- a/apps/app-frontend/src/components/ui/UpdateAvailableToast.vue +++ /dev/null @@ -1,84 +0,0 @@ - - diff --git a/apps/app-frontend/src/components/ui/UpdateToast.vue b/apps/app-frontend/src/components/ui/UpdateToast.vue deleted file mode 100644 index 6a7f104c6b..0000000000 --- a/apps/app-frontend/src/components/ui/UpdateToast.vue +++ /dev/null @@ -1,130 +0,0 @@ - - diff --git a/apps/app-frontend/src/components/ui/install_flow/AddServerToInstanceModal.vue b/apps/app-frontend/src/components/ui/install_flow/AddServerToInstanceModal.vue new file mode 100644 index 0000000000..1ffa2e97a0 --- /dev/null +++ b/apps/app-frontend/src/components/ui/install_flow/AddServerToInstanceModal.vue @@ -0,0 +1,136 @@ + + + + diff --git a/apps/app-frontend/src/components/ui/install_flow/ModInstallModal.vue b/apps/app-frontend/src/components/ui/install_flow/ModInstallModal.vue index 2f5c6a6016..9c7b78e65b 100644 --- a/apps/app-frontend/src/components/ui/install_flow/ModInstallModal.vue +++ b/apps/app-frontend/src/components/ui/install_flow/ModInstallModal.vue @@ -16,6 +16,7 @@ import { useRouter } from 'vue-router' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import { trackEvent } from '@/helpers/analytics' +import { get_project_v3_many } from '@/helpers/cache.js' import { add_project_from_version as installMod, check_installed, @@ -81,6 +82,22 @@ defineExpose({ handleError, ) } + + const linkedProjectIds = profilesVal + .filter((p) => p.linked_data?.project_id) + .map((p) => p.linked_data.project_id) + if (linkedProjectIds.length > 0) { + const linkedProjects = await get_project_v3_many(linkedProjectIds, 'stale_while_revalidate_skip_offline').catch( + () => [], + ) + const serverProjectIds = new Set( + linkedProjects.filter((p) => p?.minecraft_server != null).map((p) => p.id), + ) + for (const profile of profilesVal) { + profile.isServerInstance = serverProjectIds.has(profile.linked_data?.project_id) + } + } + profiles.value = profilesVal installModal.value.show() diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue index 93cf9bc553..019f68d152 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue @@ -424,6 +424,18 @@ const messages = defineMessages({ id: 'instance.settings.tabs.installation.unlink.description', defaultMessage: `This instance is linked to a modpack, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the modpack.`, }, + unlinkServerTitle: { + id: 'instance.settings.tabs.installation.unlink-server.title', + defaultMessage: 'Unlink from server', + }, + unlinkServerDescription: { + id: 'instance.settings.tabs.installation.unlink-server.description', + defaultMessage: `This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server.`, + }, + unlinkServerVanillaDescription: { + id: 'instance.settings.tabs.installation.unlink-server-vanilla.description', + defaultMessage: `This instance is linked to a server, which means you can't change the Minecraft version. Unlinking will permanently disconnect this instance from the server.`, + }, unlinkInstanceButton: { id: 'instance.settings.tabs.installation.unlink.button', defaultMessage: 'Unlink instance', @@ -599,7 +611,7 @@ const messages = defineMessages({ }} - + -