Compare commits

...

50 Commits

Author SHA1 Message Date
Josiah Glosson
2774cdca76 Merge branch 'main' into app-updater-rework
# Conflicts:
#	packages/app-lib/src/state/friends.rs
#	packages/app-lib/src/util/fetch.rs
2025-07-22 15:39:52 -05:00
Alejandro González
d4516d3527 feat(app): configurable Modrinth endpoints through .env files (#4015) 2025-07-21 22:55:57 +00:00
Josiah Glosson
87de47fe5e Use rust-lld linker on MSVC Windows (#4042)
The latest version of MSVC fails when linking labrinth, making now a perfect opportunity to switch over to the rust-lld linker instead.
2025-07-21 22:35:05 +00:00
Josiah Glosson
3b6a9dde0f Run intl:extract 2025-07-21 10:56:08 -05:00
Josiah Glosson
a1e0c134a0 Merge branch 'main' into app-updater-rework 2025-07-21 10:53:00 -05:00
Emma Alexia
7d76fe1b6a Add more info about last attempts to admin billing dashboard (#4029) 2025-07-21 08:35:36 +00:00
Prospector
ae25a15abd Update changelog 2025-07-19 15:17:39 -07:00
Prospector
0f755b94ce Revert "Author Validation Improvements (#3970)" (#4024)
This reverts commit 44267619b6.
2025-07-19 22:04:47 +00:00
Emma Alexia
bcf46d440b Count failed payments as "open" charges (#4013)
This allows people to cancel failed payments, currently it fails with error "There is no open charge for this subscription"
2025-07-19 14:33:37 +00:00
Josiah Glosson
526561f2de Add --color to intl:extract verification (#4023) 2025-07-19 12:42:17 +00:00
Emma Alexia
a8caa1afc3 Clarify that Modrinth Servers are for Java Edition (#4021) 2025-07-18 18:37:06 +00:00
Emma Alexia
98e9a8473d Fix NeoForge instance importing from MultiMC/Prism (#4016)
Fixes DEV-170
2025-07-18 13:00:11 +00:00
coolbot
936395484e fix: status alerts and version buttons no longer cause a failed to generate error. (#4017)
* add empty message to actions with no message, fixing broken message generation.

* fix typo in 2.2 / description message.
2025-07-18 05:32:31 +00:00
Emma Alexia
0c3e23db96 Improve errors when email is already in use (#4014)
Fixes #1485

Also fixes an issue where email_verified was being set to true regardless of whether the oauth provider provides an email (thus indicating that a null email is verified)
2025-07-18 01:59:48 +00:00
IMB11
0310cc52d0 fix: hide modal header & add "Hide update reminder" button w/ tooltip 2025-07-17 11:37:17 +01:00
Gwenaël DENIEL
013ba4d86d Update Browse.vue (#4000)
Updated functions refreshSearch and clearSearch to reset the currentPage.value to 1

Signed-off-by: Gwenaël DENIEL <monsieur.potatoes93@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-17 07:58:24 +00:00
coolbot
93813c448c Add buttons for tec team, as well as other requested actions (#4012)
* add tec rev related buttons, identity verification button, and fix edge case appearance of links stage.

* lint fix
2025-07-17 07:49:11 +00:00
coolbot
c20b869e62 fix text in license and links stages (#4010)
* fix text in license and links stages, change a license option to conditional

* remove unused project definition

* Switch markdown to use <br />

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-17 03:05:00 +00:00
Alejandro González
56c556821b refactor(app-frontend): followup to PR #3999 (#4008) 2025-07-17 00:07:18 +00:00
Josiah Glosson
071e2b58b3 Fix lint 2025-07-16 19:04:24 -05:00
IMB11
44267619b6 Author Validation Improvements (#3970)
* feat: set up typed nag (validators) system

* feat: start on frontend impl

* fix: shouldShow issues

* feat: continue work

* feat: re add submitting/re-submit nags

* feat: start work implementing validation checks using new nag system

* fix: links page + add more validations

* feat: tags validations

* fix: lint issues

* fix: lint

* fix: issues

* feat: start on i18nifying nags

* feat: impl intl

* fix: minecraft title clause update

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-16 22:28:42 +00:00
Prospector
30e93e0880 lint 2025-07-16 15:25:38 -07:00
Prospector
2c90f1c142 UI tweaks 2025-07-16 15:08:21 -07:00
Josiah Glosson
83bd4dde45 Request the update size with HEAD instead of GET 2025-07-15 20:36:21 -05:00
Josiah Glosson
221c26d613 Merge branch 'main' into app-updater-rework 2025-07-15 20:31:11 -05:00
Josiah Glosson
7cc39cb54d Fix build on Mac 2025-07-15 20:28:34 -05:00
Josiah Glosson
80e0f84b62 Use single LAUNCHER_USER_AGENT constant for all user agents 2025-07-15 20:25:37 -05:00
Josiah Glosson
1e934312a4 Update completion toasts (#3978) 2025-07-12 21:05:17 +01:00
Josiah Glosson
6fa0ee487d Run intl:extract 2025-07-11 16:22:20 -05:00
Josiah Glosson
62e2e5ea6f Open changelog with tauri-plugin-opener 2025-07-11 09:30:05 -05:00
Calum
69a461dffc feat: add error handling 2025-07-11 14:06:44 +01:00
Calum
35baa1af5e feat: polish update available modal 2025-07-10 20:00:39 +01:00
Calum
59ab09a275 feat: create AppearingProgressBar component 2025-07-10 17:50:24 +01:00
Josiah Glosson
286ab6d4a0 Make CI also lint updater code 2025-07-10 08:35:41 -05:00
Josiah Glosson
f9a4042f13 Fix lint 2025-07-10 08:28:15 -05:00
Josiah Glosson
3b74e021b5 Fix PendingUpdateData being seen as a unit struct 2025-07-09 23:07:07 -05:00
Josiah Glosson
8922c7ab03 Restore 5 minute update check instead of 30 seconds 2025-07-09 20:42:23 -05:00
Josiah Glosson
5495b01bc5 Turn download progress into an error bar on failure 2025-07-09 20:41:11 -05:00
Josiah Glosson
9b103e063a Implement updating at next exit 2025-07-09 20:41:11 -05:00
Josiah Glosson
7b73aa2908 Implement the Update Now button 2025-07-09 20:41:11 -05:00
Josiah Glosson
ae75292fd0 Implement skipping the update 2025-07-09 20:41:11 -05:00
Josiah Glosson
9a43d49b3b Fix lint 2025-07-09 20:41:11 -05:00
Josiah Glosson
3d4d0afa59 Slight UI tweaks 2025-07-09 20:41:11 -05:00
Josiah Glosson
9aab6c3e08 Fix update not being rechecked if the update modal was directly dismissed 2025-07-09 20:41:10 -05:00
Josiah Glosson
52d6bf3907 Show update size in modal 2025-07-09 20:41:10 -05:00
Josiah Glosson
523800ea39 Fix lint 2025-07-09 20:41:10 -05:00
Josiah Glosson
35aea3cab2 Add in the buttons and body 2025-07-09 20:41:10 -05:00
Josiah Glosson
36cb3f1686 Fix formatjs on Windows and run formatjs 2025-07-09 20:41:10 -05:00
Josiah Glosson
e59dd086fc Move update checking entirely into JS and open a modal if an update is available 2025-07-09 20:41:10 -05:00
Josiah Glosson
607c42cf01 Make theseus capable of logging messages from the log crate 2025-07-09 20:41:10 -05:00
70 changed files with 1393 additions and 352 deletions

View File

@@ -2,5 +2,8 @@
[target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
[build]
rustflags = ["--cfg", "tokio_unstable"]

View File

@@ -75,7 +75,7 @@ jobs:
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: ⚙️ Set application version
- name: ⚙️ Set application version and environment
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
@@ -84,6 +84,8 @@ jobs:
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
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8

View File

@@ -74,10 +74,14 @@ jobs:
cp .env.local .env
sqlx database setup
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
- name: 🔍 Lint and test
run: pnpm run ci
- name: 🔍 Verify intl:extract has been run
run: |
pnpm intl:extract
git diff --exit-code */*/src/locales/en-US/index.json
git diff --exit-code --color */*/src/locales/en-US/index.json

1
Cargo.lock generated
View File

@@ -8930,6 +8930,7 @@ dependencies = [
"data-url",
"dirs",
"discord-rich-presence",
"dotenvy",
"dunce",
"either",
"encoding_rs",

View File

@@ -9,7 +9,7 @@
"tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit"
},
"dependencies": {

View File

@@ -1,5 +1,14 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
import {
computed,
onMounted,
onUnmounted,
ref,
watch,
useTemplateRef,
provide,
nextTick,
} from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import {
ArrowBigUpDashIcon,
@@ -33,7 +42,7 @@ import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings.ts'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -42,7 +51,7 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { type } from '@tauri-apps/plugin-os'
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
import { areUpdatesEnabled, getOS, isDev } from '@/helpers/utils.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { getVersion } from '@tauri-apps/api/app'
@@ -59,7 +68,6 @@ import { get_opening_command, initialize_state } from '@/helpers/state'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js'
import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
@@ -69,8 +77,11 @@ import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import UpdateModal from '@/components/ui/UpdateModal.vue'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { createTooltip, destroyTooltip } from 'floating-vue'
const themeStore = useTheming()
@@ -109,6 +120,18 @@ onUnmounted(() => {
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
})
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.',
},
})
async function setupApp() {
stateInitialized.value = true
const {
@@ -122,7 +145,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')
@@ -209,7 +233,6 @@ async function setupApp() {
})
get_opening_command().then(handleCommand)
checkUpdates()
fetchCredentials()
try {
@@ -219,6 +242,22 @@ async function setupApp() {
} catch (error) {
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'),
})
}
}
}
const stateFailed = ref(false)
@@ -346,19 +385,95 @@ async function handleCommand(e) {
}
}
const updateAvailable = ref(false)
const availableUpdate = ref(null)
const updateSkipped = ref(false)
const enqueuedUpdate = ref(null)
const updateModal = useTemplateRef('updateModal')
async function checkUpdates() {
const update = await check()
updateAvailable.value = !!update
if (!(await areUpdatesEnabled())) {
console.log('Skipping update check as updates are disabled in this build')
return
}
async function performCheck() {
if (updateModal.value.isOpen) {
console.log('Skipping update check because the update modal is already open')
return
}
const update = await invoke('plugin:updater|check')
if (!update) {
return
}
console.log(`Update ${update.version} is available.`)
if (update.version === availableUpdate.value?.version) {
console.log(
'Skipping update modal because the new version is the same as the dismissed update',
)
return
}
availableUpdate.value = update
const settings = await getSettings()
if (settings.skipped_update === update.version) {
updateSkipped.value = true
console.log('Skipping update modal because the user chose to skip this update')
return
}
updateSkipped.value = false
updateModal.value.show(update)
}
await performCheck()
setTimeout(
() => {
checkUpdates()
},
5 * 1000 * 60,
5 * 60 * 1000,
)
}
async function skipUpdate(version) {
enqueuedUpdate.value = null
updateSkipped.value = true
const 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
const settings = await getSettings()
settings.skipped_update = null
await setSettings(settings)
}
updateModal.value.show(availableUpdate.value)
}
const updateButton = useTemplateRef('updateButton')
async function showUpdateButtonTooltip() {
await nextTick()
const tooltip = createTooltip(updateButton.value.$el, {
placement: 'right',
content: 'Click here to view the update again.',
})
tooltip.show()
setTimeout(() => {
tooltip.hide()
destroyTooltip(updateButton.value.$el)
}, 3500)
}
function handleClick(e) {
let target = e.target
while (target != null) {
@@ -399,6 +514,14 @@ function handleAuxClick(e) {
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<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"
@update-enqueued-for-later="updateEnqueuedForLater"
@modal-hidden="showUpdateButtonTooltip"
/>
</Suspense>
<Suspense>
<AppSettingsModal ref="settingsModal" />
</Suspense>
@@ -449,8 +572,18 @@ function handleAuxClick(e) {
<PlusIcon />
</NavButton>
<div class="flex flex-grow"></div>
<NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
<DownloadIcon />
<NavButton
v-if="!!availableUpdate"
ref="updateButton"
v-tooltip.right="
enqueuedUpdate === availableUpdate?.version
? 'Update installation queued for next restart'
: 'Update available'
"
:to="forceOpenUpdateModal"
>
<DownloadIcon v-if="updateSkipped || enqueuedUpdate === availableUpdate?.version" />
<DownloadIcon v-else class="text-brand-green" />
</NavButton>
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
<SettingsIcon />

View File

@@ -1,6 +1,12 @@
<template>
<div class="progress-bar">
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
<div
class="progress-bar__fill"
:style="{
width: `${progress}%`,
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
}"
></div>
</div>
</template>
@@ -13,6 +19,10 @@ defineProps({
return value >= 0 && value <= 100
},
},
error: {
type: Boolean,
default: false,
},
})
</script>
@@ -27,7 +37,6 @@ defineProps({
.progress-bar__fill {
height: 100%;
background-color: var(--color-brand);
transition: width 0.3s;
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<ModalWrapper ref="modal" hide-header :closable="false" :on-hide="onHide">
<div class="flex flex-col gap-4">
<div class="w-[500px]">
<div class="font-extrabold text-contrast text-xl">
{{ formatMessage(messages.header) }} Modrinth App v{{ update!.version }}
</div>
<template v-if="!downloadInProgress && !downloadError">
<div class="mb-1 leading-tight">{{ formatMessage(messages.bodyVersion) }}</div>
<div class="text-sm text-secondary mb-2">
{{ formatMessage(messages.downloadSize, { size: formatBytes(updateSize) }) }}
</div>
</template>
<AppearingProgressBar
v-if="!downloadError"
:max-value="shouldShowProgress ? updateSize || 0 : 0"
:current-value="shouldShowProgress ? downloadedBytes : 0"
color="green"
class="w-full mb-4 mt-2"
/>
<div v-if="downloadError" class="leading-tight">
<div class="text-red font-medium mb-4">
{{ formatMessage(messages.downloadError) }}
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button @click="installUpdateNow">
<DownloadIcon />
{{ formatMessage(messages.tryAgain) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyError">
<ClipboardCopyIcon />
{{
copiedError
? formatMessage(messages.copiedError)
: formatMessage(messages.copyError)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<a href="https://support.modrinth.com"><ChatIcon /> Get support</a>
</ButtonStyled>
</div>
</div>
<div v-if="!downloadError" class="flex flex-wrap gap-2 w-full">
<JoinedButtons
:actions="installActions"
:disabled="updatingImmediately || downloadInProgress"
color="brand"
/>
<div>
<ButtonStyled>
<button @click="() => openUrl('https://modrinth.com/news/changelog?filter=app')">
<ExternalIcon /> {{ formatMessage(messages.changelog) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { useTemplateRef, ref, computed } from 'vue'
import { AppearingProgressBar, ButtonStyled, JoinedButtons } from '@modrinth/ui'
import type { JoinedButtonAction } from '@modrinth/ui'
import { ExternalIcon, DownloadIcon, RedoIcon, ClipboardCopyIcon, XIcon } from '@modrinth/assets'
import { enqueueUpdateForInstallation, getUpdateSize } from '@/helpers/utils'
import { formatBytes } from '@modrinth/utils'
import { handleError } from '@/store/notifications'
import { loading_listener } from '@/helpers/events'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ChatIcon } from '@/assets/icons'
const emit = defineEmits<{
(e: 'updateEnqueuedForLater', version: string | null): Promise<void>
(e: 'modalHidden'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.update.modal-header',
defaultMessage: 'Update available - ',
},
copiedError: {
id: 'app.update.copied-error',
defaultMessage: 'Copied to clipboard!',
},
bodyVersion: {
id: 'app.update.modal-body-version',
defaultMessage:
'We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements.',
},
downloadSize: {
id: 'app.update.download-size',
defaultMessage: 'The update is {size}.',
},
changelog: {
id: 'app.update.changelog',
defaultMessage: 'View changelog',
},
restartNow: {
id: 'app.update.restart',
defaultMessage: 'Update now',
},
later: {
id: 'app.update.later',
defaultMessage: 'Update on exit',
},
downloadError: {
id: 'app.update.download-error',
defaultMessage:
'An error occurred while downloading the update. Please try again later. Contact support if the issue persists.',
},
copyError: {
id: 'app.update.copy-error',
defaultMessage: 'Copy error',
},
tryAgain: {
id: 'app.update.try-again',
defaultMessage: 'Try again',
},
hide: {
id: 'app.update.hide',
defaultMessage: 'Hide update reminder',
},
})
type UpdateData = {
rid: number
currentVersion: string
version: string
date?: string
body?: string
rawJson: Record<string, unknown>
}
const update = ref<UpdateData>()
const updateSize = ref<number>()
const updatingImmediately = ref(false)
const downloadInProgress = ref(false)
const downloadProgress = ref(0)
const copiedError = ref(false)
const downloadError = ref<Error | null>(null)
const enqueuedUpdate = ref<string | null>(null)
const installActions = computed<JoinedButtonAction[]>(() => [
{
id: 'install-now',
label: formatMessage(messages.restartNow),
icon: DownloadIcon,
action: installUpdateNow,
color: 'green',
},
{
id: 'install-later',
label: formatMessage(messages.later),
icon: RedoIcon,
action: updateAtNextExit,
},
{
id: 'hide',
label: formatMessage(messages.hide),
action: () => {
hide()
emit('modalHidden')
},
icon: XIcon,
},
])
const downloadedBytes = computed(() => {
return updateSize.value ? Math.round((downloadProgress.value / 100) * updateSize.value) : 0
})
const shouldShowProgress = computed(() => {
return downloadInProgress.value || updatingImmediately.value
})
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(new MouseEvent('click'))
isOpen.value = true
}
function onHide() {
isOpen.value = false
}
function hide() {
modal.value!.hide()
}
defineExpose({ show, hide, isOpen })
async function copyError() {
if (downloadError.value) {
copiedError.value = true
const errorData = {
message: downloadError.value.message,
stack: downloadError.value.stack,
name: downloadError.value.name,
timestamp: new Date().toISOString(),
updateVersion: update.value?.version,
}
setTimeout(() => {
copiedError.value = false
}, 3000)
try {
await navigator.clipboard.writeText(JSON.stringify(errorData, null, 2))
} catch (e) {
console.error('Failed to copy error to clipboard:', e)
}
}
}
// TODO: Migrate to common events.ts helper when events/listeners are refactored
interface LoadingListenerEvent {
event: {
type: 'launcher_update'
version: string
}
fraction?: number
}
loading_listener((event: LoadingListenerEvent) => {
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() {
downloadError.value = null
downloadProgress.value = 0
const versionToDownload = update.value!.version
downloadInProgress.value = true
try {
await enqueueUpdateForInstallation(update.value!.rid)
} catch (e) {
downloadInProgress.value = false
downloadError.value = e instanceof Error ? e : new Error(String(e))
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()
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -11,6 +11,10 @@ const props = defineProps({
type: String,
default: null,
},
hideHeader: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: true,
@@ -48,7 +52,14 @@ function onModalHide() {
</script>
<template>
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
<Modal
ref="modal"
:header="header"
:noblur="!themeStore.advancedRendering"
:closable="closable"
:hide-header="hideHeader"
@hide="onModalHide"
>
<template #title>
<slot name="title" />
</template>

View File

@@ -62,6 +62,9 @@ export type AppSettings = {
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
skipped_update: string | null
pending_update_toast_for_version: string | null
}
// Get full settings object

View File

@@ -67,9 +67,8 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
const armWidth = 2
const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let index = 1; index <= imageData.length; index++) {
//every fourth value in RGBA is the alpha channel
if (index % 4 == 0 && imageData[index - 1] !== 0) {
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC')
return
}

View File

@@ -5,6 +5,22 @@ export async function isDev() {
return await invoke('is_dev')
}
export async function areUpdatesEnabled() {
return await invoke('are_updates_enabled')
}
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')
@@ -37,13 +53,6 @@ export async function restartApp() {
return await invoke('restart_app')
}
/**
* @deprecated This method is no longer needed, and just returns its parameter
*/
export function sanitizePotentialFileUrl(url) {
return url
}
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':

View File

@@ -20,6 +20,45 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.update.changelog": {
"message": "View changelog"
},
"app.update.complete-toast.text": {
"message": "Click here to view the changelog."
},
"app.update.complete-toast.title": {
"message": "Version {version} was successfully installed!"
},
"app.update.copied-error": {
"message": "Copied to clipboard!"
},
"app.update.copy-error": {
"message": "Copy error"
},
"app.update.download-error": {
"message": "An error occurred while downloading the update. Please try again later. Contact support if the issue persists."
},
"app.update.download-size": {
"message": "The update is {size}."
},
"app.update.hide": {
"message": "Hide update reminder"
},
"app.update.later": {
"message": "Update on exit"
},
"app.update.modal-body-version": {
"message": "We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements."
},
"app.update.modal-header": {
"message": "Update available - "
},
"app.update.restart": {
"message": "Update now"
},
"app.update.try-again": {
"message": "Try again"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},

View File

@@ -220,7 +220,7 @@ async function refreshSearch() {
}
}
results.value = rawResults.result
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
currentPage.value = 1
const persistentParams: LocationQuery = {}
@@ -266,6 +266,7 @@ async function onSearchChangeToTop() {
function clearSearch() {
query.value = ''
currentPage.value = 1
}
watch(

View File

@@ -5,8 +5,8 @@
"build": "tauri build",
"dev": "tauri dev",
"test": "cargo nextest run --all-targets --no-fail-fast",
"lint": "cargo fmt --check && cargo clippy --all-targets",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
"lint": "cargo fmt --check && cargo clippy --all-targets && cargo clippy --all-targets --features updater",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo clippy --all-targets --features updater --fix --allow-dirty && cargo fmt"
},
"devDependencies": {
"@tauri-apps/cli": "2.5.0"

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

@@ -14,6 +14,11 @@ mod error;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(feature = "updater")]
mod updater_impl;
#[cfg(not(feature = "updater"))]
mod updater_impl_noop;
// Should be called in launcher initialization
#[tracing::instrument(skip_all)]
#[tauri::command]
@@ -21,75 +26,9 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
tracing::info!("Initializing app event state...");
theseus::EventState::init(app.clone()).await?;
#[cfg(feature = "updater")]
'updater: {
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
State::init().await?;
break 'updater;
}
tracing::info!("Initializing app state...");
State::init().await?;
use tauri_plugin_updater::UpdaterExt;
let updater = app.updater_builder().build()?;
let update_fut = updater.check();
tracing::info!("Initializing app state...");
State::init().await?;
let check_bar = theseus::init_loading(
theseus::LoadingBarType::CheckingForUpdates,
1.0,
"Checking for updates...",
)
.await?;
tracing::info!("Checking for updates...");
let update = update_fut.await;
drop(check_bar);
if let Some(update) = update.ok().flatten() {
tracing::info!("Update found: {:?}", update.download_url);
let loader_bar_id = theseus::init_loading(
theseus::LoadingBarType::LauncherUpdate {
version: update.version.clone(),
current_version: update.current_version.clone(),
},
1.0,
"Updating Modrinth App...",
)
.await?;
// 100 MiB
const DEFAULT_CONTENT_LENGTH: u64 = 1024 * 1024 * 100;
update
.download_and_install(
|chunk_length, content_length| {
let _ = theseus::emit_loading(
&loader_bar_id,
(chunk_length as f64)
/ (content_length
.unwrap_or(DEFAULT_CONTENT_LENGTH)
as f64),
None,
);
},
|| {},
)
.await?;
app.restart();
}
}
#[cfg(not(feature = "updater"))]
{
State::init().await?;
}
tracing::info!("Finished checking for updates!");
let state = State::get().await?;
app.asset_protocol_scope()
.allow_directory(state.directories.caches_dir(), true)?;
@@ -125,6 +64,17 @@ fn is_dev() -> bool {
cfg!(debug_assertions)
}
#[tauri::command]
fn are_updates_enabled() -> bool {
cfg!(feature = "updater")
}
#[cfg(feature = "updater")]
pub use updater_impl::*;
#[cfg(not(feature = "updater"))]
pub use updater_impl_noop::*;
// Toggles decorations
#[tauri::command]
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
@@ -166,7 +116,17 @@ fn main() {
#[cfg(feature = "updater")]
{
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
use tauri_plugin_http::reqwest::header::{HeaderValue, USER_AGENT};
use theseus::LAUNCHER_USER_AGENT;
builder = builder.plugin(
tauri_plugin_updater::Builder::new()
.header(
USER_AGENT,
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
)
.unwrap()
.build(),
);
}
builder = builder
@@ -261,9 +221,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,
@@ -275,8 +240,41 @@ fn main() {
match app {
Ok(app) => {
app.run(|app, event| {
#[cfg(not(target_os = "macos"))]
#[cfg(not(any(feature = "updater", target_os = "macos")))]
drop((app, event));
#[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() {
fn set_changelog_toast(version: Option<String>) {
let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move {
let mut settings = settings::get().await?;
settings.pending_update_toast_for_version = version;
settings::set(settings).await?;
Ok(())
});
if let Err(e) = toast_result {
tracing::warn!("Failed to set pending_update_toast: {e}")
}
}
set_changelog_toast(Some(update.version.clone()));
if let Err(e) = update.install(data) {
tracing::error!("Error while updating: {e}");
set_changelog_toast(None);
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();
}
}
}
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = event {
tracing::info!("Handling webview open {urls:?}");
@@ -304,6 +302,8 @@ fn main() {
});
}
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
@@ -332,7 +332,6 @@ fn main() {
.show()
.unwrap();
tracing::error!("Error while running tauri application: {:?}", e);
panic!("{1}: {:?}", e, "error while running tauri application")
}
}

View File

@@ -0,0 +1,121 @@
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::{
LAUNCHER_USER_AGENT, LoadingBarType, emit_loading, init_loading,
};
use tokio::time::Instant;
#[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(LAUNCHER_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()?
.head(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

@@ -10,7 +10,7 @@
"postinstall": "nuxi prepare",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "nuxi build"
},
"devDependencies": {

View File

@@ -1,36 +1,7 @@
<template>
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isLoading" class="w-full">
<div class="mb-2 flex justify-between text-sm">
<Transition name="phrase-fade" mode="out-in">
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
currentPhrase
}}</span>
</Transition>
<div class="flex flex-col items-end">
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
>
</div>
</div>
<div class="h-2 w-full rounded-full bg-divider">
<div
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
:style="{ width: `${uploadProgress}%` }"
></div>
</div>
</div>
</Transition>
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
<Transition
enter-active-class="transition-all duration-300 ease-out"
@@ -144,7 +115,7 @@
</template>
<script setup lang="ts">
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { BackupWarning, ButtonStyled, NewModal, AppearingProgressBar } from "@modrinth/ui";
import {
UploadIcon,
RightArrowIcon,
@@ -187,50 +158,9 @@ const hardReset = ref(false);
const isLoading = ref(false);
const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null);
const uploadProgress = ref(0);
const uploadedBytes = ref(0);
const totalBytes = ref(0);
const uploadPhrases = [
"Removing Herobrine...",
"Feeding parrots...",
"Teaching villagers new trades...",
"Convincing creepers to be friendly...",
"Polishing diamonds...",
"Training wolves to fetch...",
"Building pixel art...",
"Explaining redstone to beginners...",
"Collecting all the cats...",
"Negotiating with endermen...",
"Planting suspicious stew ingredients...",
"Calibrating TNT blast radius...",
"Teaching chickens to fly...",
"Sorting inventory alphabetically...",
"Convincing iron golems to smile...",
];
const currentPhrase = ref("Uploading...");
let phraseInterval: NodeJS.Timeout | null = null;
const usedPhrases = ref(new Set<number>());
const getNextPhrase = () => {
if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
usedPhrases.value.clear();
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex);
}
}
const availableIndices = uploadPhrases
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index));
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
usedPhrases.value.add(randomIndex);
return uploadPhrases[randomIndex];
};
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
@@ -259,31 +189,17 @@ const handleReinstall = async () => {
}
isLoading.value = true;
uploadProgress.value = 0;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = mrpackFile.value.size;
currentPhrase.value = getNextPhrase();
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase();
}, 4500);
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value,
hardReset.value,
);
onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress;
onProgress(({ loaded, total }) => {
uploadedBytes.value = loaded;
totalBytes.value = total;
if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval);
phraseInterval = null;
currentPhrase.value = "Installing modpack...";
}
});
try {
@@ -316,10 +232,6 @@ const handleReinstall = async () => {
}
} finally {
isLoading.value = false;
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
}
};
const onShow = () => {
@@ -328,15 +240,8 @@ const onShow = () => {
loadingServerCheck.value = false;
isLoading.value = false;
mrpackFile.value = null;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = 0;
currentPhrase.value = "Uploading...";
usedPhrases.value.clear();
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
};
const show = () => mrpackModal.value?.show();
@@ -349,14 +254,4 @@ defineExpose({ show, hide });
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
.phrase-fade-enter-active,
.phrase-fade-leave-active {
transition: opacity 0.3s ease;
}
.phrase-fade-enter-from,
.phrase-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -150,9 +150,26 @@
</template>
</span>
<span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary"
>({{ formatRelativeTime(charge.last_attempt) }})
</span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }}

View File

@@ -45,8 +45,9 @@
<h2
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]"
>
Modrinth Servers is the easiest way to host your own Minecraft server. Seamlessly install
and play your favorite mods and modpacks, all within the Modrinth platform.
Modrinth Servers is the easiest way to host your own Minecraft: Java Edition server.
Seamlessly install and play your favorite mods and modpacks, all within the Modrinth
platform.
</h2>
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
<div
@@ -459,7 +460,7 @@
</p>
</details>
<details pyro-hash="players" class="group" :open="$route.hash === '#players'">
<details pyro-hash="performance" class="group" :open="$route.hash === '#performance'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -480,7 +481,7 @@
</p>
</details>
<details pyro-hash="players" class="group" :open="$route.hash === '#prices'">
<details pyro-hash="prices" class="group" :open="$route.hash === '#prices'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -491,6 +492,24 @@
All prices are listed in United States Dollars (USD).
</p>
</details>
<details pyro-hash="versions" class="group" :open="$route.hash === '#versions'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
</span>
What Minecraft versions and loaders can be used?
</summary>
<p class="m-0 ml-6 leading-[160%]">
Modrinth Servers can run any version of Minecraft: Java Edition going all the way
back to version 1.2.5, including snapshot versions.
</p>
<p class="m-0 ml-6 mt-3 leading-[160%]">
We also support a wide range of mod and plugin loaders, including Fabric, Quilt,
Forge, and NeoForge for mods, as well as Paper and Purpur for plugins. Availability
depends on whether the mod or plugin loader supports the selected Minecraft version.
</p>
</details>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
"describe": {
"columns": [
{
@@ -102,5 +102,5 @@
true
]
},
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
}

View File

@@ -43,7 +43,9 @@ pub enum AuthenticationError {
InvalidAuthMethod,
#[error("GitHub Token from incorrect Client ID")]
InvalidClientId,
#[error("User email/account is already registered on Modrinth")]
#[error(
"User email is already registered on Modrinth. Try 'Forgot password' to access your account."
)]
DuplicateUser,
#[error("Invalid state sent, you probably need to get a new websocket")]
SocketError,

View File

@@ -197,7 +197,7 @@ impl DBCharge {
) -> Result<Option<DBCharge>, DatabaseError> {
let user_subscription_id = user_subscription_id.0;
let res = select_charges_with_predicate!(
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
user_subscription_id
)
.fetch_optional(exec)

View File

@@ -223,8 +223,8 @@ impl TempUser {
stripe_customer_id: None,
totp_secret: None,
username,
email: self.email,
email_verified: true,
email: self.email.clone(),
email_verified: self.email.is_some(),
avatar_url,
raw_avatar_url,
bio: self.bio,
@@ -1419,15 +1419,15 @@ pub async fn create_account_with_password(
.hash_password(new_account.password.as_bytes(), &salt)?
.to_string();
if crate::database::models::DBUser::get_by_email(
if !crate::database::models::DBUser::get_by_case_insensitive_email(
&new_account.email,
&**pool,
)
.await?
.is_some()
.is_empty()
{
return Err(ApiError::InvalidInput(
"Email is already registered on Modrinth!".to_string(),
"Email is already registered on Modrinth! Try 'Forgot password' to access your account.".to_string(),
));
}
@@ -2220,6 +2220,18 @@ pub async fn set_email(
.await?
.1;
if !crate::database::models::DBUser::get_by_case_insensitive_email(
&email.email,
&**pool,
)
.await?
.is_empty()
{
return Err(ApiError::InvalidInput(
"Email is already registered on Modrinth! Try 'Forgot password' in incognito to access and delete your other account.".to_string(),
));
}
let mut transaction = pool.begin().await?;
sqlx::query!(

View File

@@ -15,6 +15,7 @@
"app:intl:extract": "pnpm run --filter=@modrinth/app-frontend intl:extract",
"blog:fix": "turbo run fix --filter=@modrinth/blog",
"pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
"moderation:fix": "turbo run fix --filter=@modrinth/moderation",
"build": "turbo run build --continue",
"lint": "turbo run lint --continue",
"test": "turbo run test --continue",

View File

@@ -1,2 +1,10 @@
# SQLite database file location
DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
MODRINTH_URL=http://localhost:3000/
MODRINTH_API_URL=http://127.0.0.1:8000/v2/
MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -0,0 +1,10 @@
MODRINTH_URL=https://modrinth.com/
MODRINTH_API_URL=https://api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
MODRINTH_SOCKET_URL=wss://api.modrinth.com/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -0,0 +1,10 @@
MODRINTH_URL=https://staging.modrinth.com/
MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
MODRINTH_SOCKET_URL=wss://staging-api.modrinth.com/
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
# in your system and run `cargo sqlx database setup` to generate an empty database that
# can be used for developing the app DB schema
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -142,6 +142,16 @@
"name": "toggle_sidebar",
"ordinal": 27,
"type_info": "Integer"
},
{
"name": "skipped_update",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "pending_update_toast_for_version",
"ordinal": 29,
"type_info": "Text"
}
],
"parameters": {
@@ -175,8 +185,10 @@
true,
false,
null,
false
false,
true,
true
]
},
"hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca"
"hash": "30fecc13c6ea4da1e99e35d307aa8e7c4e7f15ea99527e7974619ef8ed946abe"
}

View File

@@ -41,7 +41,7 @@
{
"name": "display_claims!: serde_json::Value",
"ordinal": 7,
"type_info": "Null"
"type_info": "Text"
}
],
"parameters": {

View File

@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28\n ",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 28
"Right": 30
},
"nullable": []
},
"hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c"
"hash": "cd7c9f394e4f0ec8fd0043352f7382ac2570aaa096cd2ebb4de990f4d42cc5c9"
}

View File

@@ -82,6 +82,7 @@ ariadne.workspace = true
winreg.workspace = true
[build-dependencies]
dotenvy.workspace = true
dunce.workspace = true
[features]

View File

@@ -4,12 +4,31 @@ use std::process::{Command, exit};
use std::{env, fs};
fn main() {
println!("cargo::rerun-if-changed=.env");
println!("cargo::rerun-if-changed=java/gradle");
println!("cargo::rerun-if-changed=java/src");
println!("cargo::rerun-if-changed=java/build.gradle.kts");
println!("cargo::rerun-if-changed=java/settings.gradle.kts");
println!("cargo::rerun-if-changed=java/gradle.properties");
set_env();
build_java_jars();
}
fn set_env() {
for (var_name, var_value) in
dotenvy::dotenv_iter().into_iter().flatten().flatten()
{
if var_name == "DATABASE_URL" {
// The sqlx database URL is a build-time detail that should not be exposed to the crate
continue;
}
println!("cargo::rustc-env={var_name}={var_value}");
}
}
fn build_java_jars() {
let out_dir =
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
.unwrap();
@@ -37,6 +56,7 @@ fn main() {
.current_dir(dunce::canonicalize("java").unwrap())
.status()
.expect("Failed to wait on Gradle build");
if !exit_status.success() {
println!("cargo::error=Gradle build failed with {exit_status}");
exit(exit_status.code().unwrap_or(1));

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN skipped_update TEXT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE settings
ADD COLUMN pending_update_toast_for_version TEXT NULL;

View File

@@ -1,7 +1,7 @@
use crate::state::ModrinthCredentials;
#[tracing::instrument]
pub fn authenticate_begin_flow() -> String {
pub fn authenticate_begin_flow() -> &'static str {
crate::state::get_login_url()
}

View File

@@ -284,6 +284,12 @@ async fn import_mmc_unmanaged(
component.version.clone().unwrap_or_default(),
));
}
if component.uid.starts_with("net.neoforged") {
return Some((
PackDependency::NeoForge,
component.version.clone().unwrap_or_default(),
));
}
if component.uid.starts_with("org.quiltmc.quilt-loader") {
return Some((
PackDependency::QuiltLoader,

View File

@@ -1,13 +0,0 @@
//! Configuration structs
// pub const MODRINTH_URL: &str = "https://staging.modrinth.com/";
// pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/";
// pub const MODRINTH_API_URL_V3: &str = "https://staging-api.modrinth.com/v3/";
pub const MODRINTH_URL: &str = "https://modrinth.com/";
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/";
pub const MODRINTH_API_URL_V3: &str = "https://api.modrinth.com/v3/";
pub const MODRINTH_SOCKET_URL: &str = "wss://api.modrinth.com/";
pub const META_URL: &str = "https://launcher-meta.modrinth.com/";

View File

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

View File

@@ -11,7 +11,6 @@ and launching Modrinth mod packs
mod util;
mod api;
mod config;
mod error;
mod event;
mod launcher;
@@ -26,3 +25,9 @@ pub use event::{
};
pub use logger::start_logger;
pub use state::State;
pub const LAUNCHER_USER_AGENT: &str = concat!(
"modrinth/theseus/",
env!("CARGO_PKG_VERSION"),
" (support@modrinth.com)"
);

View File

@@ -25,12 +25,11 @@ pub fn start_logger() -> Option<()> {
.unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
});
let subscriber = tracing_subscriber::registry()
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(filter)
.with(tracing_error::ErrorLayer::default());
tracing::subscriber::set_global_default(subscriber)
.expect("setting default subscriber failed");
.with(tracing_error::ErrorLayer::default())
.init();
Some(())
}
@@ -76,7 +75,7 @@ pub fn start_logger() -> Option<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("theseus=info"));
let subscriber = tracing_subscriber::registry()
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_writer(file)
@@ -84,10 +83,8 @@ pub fn start_logger() -> Option<()> {
.with_timer(ChronoLocal::rfc_3339()),
)
.with(filter)
.with(tracing_error::ErrorLayer::default());
tracing::subscriber::set_global_default(subscriber)
.expect("Setting default subscriber failed");
.with(tracing_error::ErrorLayer::default())
.init();
Some(())
}

View File

@@ -1,4 +1,3 @@
use crate::config::{META_URL, MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::state::ProjectType;
use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async};
use chrono::{DateTime, Utc};
@@ -8,6 +7,7 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::collections::HashMap;
use std::env;
use std::fmt::Display;
use std::hash::Hash;
use std::path::{Path, PathBuf};
@@ -945,7 +945,7 @@ impl CachedEntry {
CacheValueType::Project => {
fetch_original_values!(
Project,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"projects",
CacheValue::Project
)
@@ -953,7 +953,7 @@ impl CachedEntry {
CacheValueType::Version => {
fetch_original_values!(
Version,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"versions",
CacheValue::Version
)
@@ -961,7 +961,7 @@ impl CachedEntry {
CacheValueType::User => {
fetch_original_values!(
User,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"users",
CacheValue::User
)
@@ -969,7 +969,7 @@ impl CachedEntry {
CacheValueType::Team => {
let mut teams = fetch_many_batched::<Vec<TeamMember>>(
Method::GET,
MODRINTH_API_URL_V3,
env!("MODRINTH_API_URL_V3"),
"teams?ids=",
&keys,
fetch_semaphore,
@@ -1008,7 +1008,7 @@ impl CachedEntry {
CacheValueType::Organization => {
let mut orgs = fetch_many_batched::<Organization>(
Method::GET,
MODRINTH_API_URL_V3,
env!("MODRINTH_API_URL_V3"),
"organizations?ids=",
&keys,
fetch_semaphore,
@@ -1063,7 +1063,7 @@ impl CachedEntry {
CacheValueType::File => {
let mut versions = fetch_json::<HashMap<String, Version>>(
Method::POST,
&format!("{MODRINTH_API_URL}version_files"),
concat!(env!("MODRINTH_API_URL"), "version_files"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1119,7 +1119,11 @@ impl CachedEntry {
.map(|x| {
(
x.key().to_string(),
format!("{META_URL}{}/v0/manifest.json", x.key()),
format!(
"{}{}/v0/manifest.json",
env!("MODRINTH_LAUNCHER_META_URL"),
x.key()
),
)
})
.collect::<Vec<_>>();
@@ -1154,7 +1158,7 @@ impl CachedEntry {
CacheValueType::MinecraftManifest => {
fetch_original_value!(
MinecraftManifest,
META_URL,
env!("MODRINTH_LAUNCHER_META_URL"),
format!(
"minecraft/v{}/manifest.json",
daedalus::minecraft::CURRENT_FORMAT_VERSION
@@ -1165,7 +1169,7 @@ impl CachedEntry {
CacheValueType::Categories => {
fetch_original_value!(
Categories,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/category",
CacheValue::Categories
)
@@ -1173,7 +1177,7 @@ impl CachedEntry {
CacheValueType::ReportTypes => {
fetch_original_value!(
ReportTypes,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/report_type",
CacheValue::ReportTypes
)
@@ -1181,7 +1185,7 @@ impl CachedEntry {
CacheValueType::Loaders => {
fetch_original_value!(
Loaders,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/loader",
CacheValue::Loaders
)
@@ -1189,7 +1193,7 @@ impl CachedEntry {
CacheValueType::GameVersions => {
fetch_original_value!(
GameVersions,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/game_version",
CacheValue::GameVersions
)
@@ -1197,7 +1201,7 @@ impl CachedEntry {
CacheValueType::DonationPlatforms => {
fetch_original_value!(
DonationPlatforms,
MODRINTH_API_URL,
env!("MODRINTH_API_URL"),
"tag/donation_platform",
CacheValue::DonationPlatforms
)
@@ -1297,14 +1301,12 @@ impl CachedEntry {
}
});
let version_update_url =
format!("{MODRINTH_API_URL}version_files/update");
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loaders_key, game_version), hashes)| {
fetch_json::<HashMap<String, Version>>(
Method::POST,
&version_update_url,
concat!(env!("MODRINTH_API_URL"), "version_files/update"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1368,7 +1370,11 @@ impl CachedEntry {
.map(|x| {
(
x.key().to_string(),
format!("{MODRINTH_API_URL}search{}", x.key()),
format!(
"{}search{}",
env!("MODRINTH_API_URL"),
x.key()
),
)
})
.collect::<Vec<_>>();

View File

@@ -1,4 +1,4 @@
use crate::config::{MODRINTH_API_URL_V3, MODRINTH_SOCKET_URL};
use crate::LAUNCHER_USER_AGENT;
use crate::data::ModrinthCredentials;
use crate::event::FriendPayload;
use crate::event::emit::emit_friend;
@@ -77,18 +77,15 @@ impl FriendsSocket {
if let Some(credentials) = credentials {
let mut request = format!(
"{MODRINTH_SOCKET_URL}_internal/launcher_socket?code={}",
"{}_internal/launcher_socket?code={}",
env!("MODRINTH_SOCKET_URL"),
credentials.session
)
.into_client_request()?;
let user_agent = format!(
"modrinth/theseus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
);
request.headers_mut().insert(
"User-Agent",
HeaderValue::from_str(&user_agent).unwrap(),
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
);
let res = connect_async(request).await;
@@ -303,7 +300,7 @@ impl FriendsSocket {
) -> crate::Result<Vec<UserFriend>> {
fetch_json(
Method::GET,
&format!("{MODRINTH_API_URL_V3}friends"),
concat!(env!("MODRINTH_API_URL_V3"), "friends"),
None,
None,
semaphore,
@@ -328,7 +325,7 @@ impl FriendsSocket {
) -> crate::Result<()> {
fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
None,
None,
None,
@@ -349,7 +346,7 @@ impl FriendsSocket {
) -> crate::Result<()> {
fetch_advanced(
Method::DELETE,
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
None,
None,
None,

View File

@@ -1,4 +1,3 @@
use crate::config::{MODRINTH_API_URL, MODRINTH_URL};
use crate::state::{CacheBehaviour, CachedEntry};
use crate::util::fetch::{FetchSemaphore, fetch_advanced};
use chrono::{DateTime, Duration, TimeZone, Utc};
@@ -31,7 +30,7 @@ impl ModrinthCredentials {
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}session/refresh"),
concat!(env!("MODRINTH_API_URL"), "session/refresh"),
None,
None,
Some(("Authorization", &*creds.session)),
@@ -190,8 +189,8 @@ impl ModrinthCredentials {
}
}
pub fn get_login_url() -> String {
format!("{MODRINTH_URL}auth/sign-in?launcher=true")
pub const fn get_login_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-in?launcher=true")
}
pub async fn finish_login_flow(
@@ -216,7 +215,7 @@ async fn fetch_info(
) -> crate::Result<crate::state::cache::User> {
let result = fetch_advanced(
Method::GET,
&format!("{MODRINTH_API_URL}user"),
concat!(env!("MODRINTH_API_URL"), "user"),
None,
None,
Some(("Authorization", token)),

View File

@@ -38,6 +38,9 @@ pub struct Settings {
pub developer_mode: bool,
pub feature_flags: HashMap<FeatureFlag, bool>,
pub skipped_update: Option<String>,
pub pending_update_toast_for_version: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)]
@@ -63,7 +66,8 @@ impl Settings {
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,
hook_pre_launch, hook_wrapper, hook_post_exit,
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,
skipped_update, pending_update_toast_for_version
FROM settings
"
)
@@ -117,6 +121,9 @@ impl Settings {
.as_ref()
.and_then(|x| serde_json::from_str(x).ok())
.unwrap_or_default(),
skipped_update: res.skipped_update,
pending_update_toast_for_version: res
.pending_update_toast_for_version,
})
}
@@ -170,7 +177,10 @@ impl Settings {
toggle_sidebar = $26,
feature_flags = $27,
hide_nametag_skins_page = $28
hide_nametag_skins_page = $28,
skipped_update = $29,
pending_update_toast_for_version = $30
",
max_concurrent_writes,
max_concurrent_downloads,
@@ -199,7 +209,9 @@ impl Settings {
self.migrated,
self.toggle_sidebar,
feature_flags,
self.hide_nametag_skins_page
self.hide_nametag_skins_page,
self.skipped_update,
self.pending_update_toast_for_version,
)
.execute(exec)
.await?;

View File

@@ -1,6 +1,6 @@
//! Functions for fetching information from the Internet
use super::io::{self, IOError};
use crate::config::{MODRINTH_API_URL, MODRINTH_API_URL_V3};
use crate::LAUNCHER_USER_AGENT;
use crate::event::LoadingBarId;
use crate::event::emit::emit_loading;
use bytes::Bytes;
@@ -20,11 +20,8 @@ pub struct FetchSemaphore(pub Semaphore);
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut headers = reqwest::header::HeaderMap::new();
let header = reqwest::header::HeaderValue::from_str(&format!(
"modrinth/theseus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
))
.unwrap();
let header =
reqwest::header::HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap();
headers.insert(reqwest::header::USER_AGENT, header);
reqwest::Client::builder()
.tcp_keepalive(Some(time::Duration::from_secs(10)))
@@ -84,8 +81,8 @@ pub async fn fetch_advanced(
.as_ref()
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
&& (url.starts_with("https://cdn.modrinth.com")
|| url.starts_with(MODRINTH_API_URL)
|| url.starts_with(MODRINTH_API_URL_V3))
|| url.starts_with(env!("MODRINTH_API_URL"))
|| url.starts_with(env!("MODRINTH_API_URL_V3")))
{
crate::state::ModrinthCredentials::get_active(exec).await?
} else {

View File

@@ -132,6 +132,7 @@ import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
import _RadioButtonIcon from './icons/radio-button.svg?component'
import _ReceiptTextIcon from './icons/receipt-text.svg?component'
import _RedoIcon from './icons/redo.svg?component'
import _RefreshCwIcon from './icons/refresh-cw.svg?component'
import _ReplyIcon from './icons/reply.svg?component'
import _ReportIcon from './icons/report.svg?component'
import _RestoreIcon from './icons/restore.svg?component'
@@ -323,6 +324,7 @@ export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
export const RadioButtonIcon = _RadioButtonIcon
export const ReceiptTextIcon = _ReceiptTextIcon
export const RedoIcon = _RedoIcon
export const RefreshCwIcon = _RefreshCwIcon
export const ReplyIcon = _ReplyIcon
export const ReportIcon = _ReportIcon
export const RestoreIcon = _RestoreIcon

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-cw-icon lucide-refresh-cw"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@@ -1 +0,0 @@
**License id:** %PROJECT_LICENSE_ID% \

View File

@@ -1 +1,2 @@
**License id:** %PROJECT_LICENSE_ID% \
**License Link:** %PROJECT_LICENSE_URL%

View File

@@ -1 +1 @@
> **{PLATFORM}:** {URL}
> **{PLATFORM}:** {URL}<br />

View File

@@ -1 +1,2 @@
<u>**Donation Links:**</u>
<br />
<u>**Donation Links:**</u><br />

View File

@@ -1,5 +1,5 @@
## No English Description
Per section 2.2 of %RULES% a project's Summary and Description must be in English, unless meant exclusively for non-English use, such as translations.
Per section 2.2 of %RULES% a project's [Summary](%PROJECT_SETTINGS_LINK%) and %PROJECT_DESCRIPTION_FLINK% must be in English, unless meant exclusively for non-English use, such as translations.
You may include your non-English Description if you would like but we ask that you also add an English translation of the Description to your Description page, if you would like to use an online translator to do this, we recommend [DeepL](https://www.deepl.com/translator).
You may include your non-English Description if you would like but we ask that you also add an English translation of the Description to your project page, if you would like to use an online translator to do this, we recommend [DeepL](https://www.deepl.com/translator).

View File

@@ -0,0 +1,6 @@
## Identity Verification
**Welcome to Modrinth!** We're happy to see you here, we just want to make sure you're you.
Since this project already exists on the internet we ask that you provide evidence you are the rightful owner of this content.
For instance, by submitting a screenshot accessing the settings of the project's existing pages such as %PLATFORM%.

View File

@@ -0,0 +1,5 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project before resubmission so that it can be reviewed by our Moderation Team.
We also ask that you provide the source for any included binary files, as well as detailed build instructions allowing us to verify that the compiled code you are distributing matches the provided source.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -0,0 +1,4 @@
## Source Code Requested
To ensure the safety of all Modrinth users, we ask that you provide the source code for this project and the process you used to obfuscate it before resubmission so that it can be reviewed by our Moderation Team.
We understand that you may not want to publish the source code for this project, so you are welcome to share it privately to the [Modrinth Content Moderation Team](https://github.com/ModrinthModeration) on GitHub.

View File

@@ -20,14 +20,7 @@ const licensesNotRequiringSource: string[] = [
const licenseStage: Stage = {
title: 'Is this license and link valid?',
text: async (project) => {
let text = ''
text += (await import('../messages/checklist-text/license/id.md?raw')).default
if (project.license.url)
text += (await import('../messages/checklist-text/license/link.md?raw')).default
return text
},
text: async () => (await import('../messages/checklist-text/licensing.md?raw')).default,
id: 'license',
icon: BookTextIcon,
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
@@ -55,33 +48,24 @@ const licenseStage: Stage = {
},
],
},
// {
// id: 'license_no_source',
// type: 'conditional-button',
// label: 'No Source',
// fallbackWeight: 602,
// suggestedStatus: 'rejected',
// severity: 'medium',
// fallbackMessage: async () => (await import('../messages/license/no_source.md?raw')).default,
// messageVariants: [
// {
// conditions: {
// requiredActions: ['reupload_unclear_fork'],
// },
// weight: 602,
// message: async () => (await import('../messages/license/no_source-fork.md?raw')).default,
// },
// ],
// },
{
id: 'license_no_source',
type: 'button',
type: 'conditional-button',
label: 'No Source',
weight: 602,
suggestedStatus: 'rejected',
severity: 'medium',
shouldShow: (project) => !licensesNotRequiringSource.includes(project.license.id),
message: async () => (await import('../messages/license/no_source.md?raw')).default,
messageVariants: [
{
conditions: {
excludedActions: ['license_no_source-fork'],
},
weight: 602,
message: async () => (await import('../messages/license/no_source.md?raw')).default,
},
],
fallbackWeight: 602,
fallbackMessage: async () => '',
enablesActions: [
{
id: 'license_no_source-fork',

View File

@@ -61,7 +61,8 @@ const links: Stage = {
{
id: 'links_unaccessible_options',
type: 'multi-select-chips',
label: 'Warn of unaccessible link?',
label: 'Warn of inaccessible link?',
shouldShow: (project) => Boolean(project.source_url || project.discord_url),
options: [
{
label: 'Source',

View File

@@ -20,6 +20,7 @@ const reupload: Stage = {
'reupload_unclear_fork',
'reupload_insufficient_fork',
'reupload_request_proof',
'reupload_identity_verification',
],
relevantExtraInput: [
{
@@ -44,11 +45,12 @@ const reupload: Stage = {
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/fork.md?raw')).default,
// disablesActions: [
// 'reupload_reupload',
// 'reupload_insufficient_fork',
// 'reupload_request_proof',
// ],
disablesActions: [
'reupload_reupload',
'reupload_insufficient_fork',
'reupload_request_proof',
'reupload_identity_verification',
],
} as ButtonAction,
{
id: 'reupload_insufficient_fork',
@@ -58,7 +60,12 @@ const reupload: Stage = {
suggestedStatus: 'rejected',
severity: 'high',
message: async () => (await import('../messages/reupload/insufficient_fork.md?raw')).default,
// disablesActions: ['reupload_unclear_fork', 'reupload_reupload', 'reupload_request_proof'],
disablesActions: [
'reupload_unclear_fork',
'reupload_reupload',
'reupload_request_proof',
'reupload_identity_verification',
],
} as ButtonAction,
{
id: 'reupload_request_proof',
@@ -69,7 +76,34 @@ const reupload: Stage = {
severity: 'high',
message: async () =>
(await import('../messages/reupload/proof_of_permissions.md?raw')).default,
// disablesActions: ['reupload_reupload', 'reupload_unclear_fork', 'reupload_insufficient_fork'],
disablesActions: [
'reupload_reupload',
'reupload_unclear_fork',
'reupload_insufficient_fork',
'reupload_identity_verification',
],
},
{
id: 'reupload_identity_verification',
type: 'button',
label: 'Verify Identity',
weight: 1100,
suggestedStatus: 'rejected',
severity: 'high',
message: async () =>
(await import('../messages/reupload/identity_verification.md?raw')).default,
relevantExtraInput: [
{
label: 'Where else can the project be found?',
variable: 'PLATFORM',
required: true,
},
],
disablesActions: [
'reupload_reupload',
'reupload_insufficient_fork',
'reupload_request_proof',
],
},
],
}

View File

@@ -1,5 +1,5 @@
import type { Stage } from '../../types/stage'
import type { ButtonAction } from '../../types/actions'
import type { ButtonAction, DropdownAction, DropdownActionOption } from '../../types/actions'
import { TriangleAlertIcon } from '@modrinth/assets'
const statusAlerts: Stage = {
@@ -39,6 +39,41 @@ const statusAlerts: Stage = {
message: async () =>
(await import('../messages/status-alerts/account_issues.md?raw')).default,
} as ButtonAction,
{
id: 'status_tec_source_request',
type: 'button',
label: `Request Source`,
suggestedStatus: 'rejected',
severity: 'critical',
disablesActions: ['status_corrections_applied', 'status_private_use'],
shouldShow: (project) =>
project.project_type === 'mod' ||
project.project_type === 'shader' ||
project.project_type.toString() === 'plugin',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'status_tec_source_request_options',
type: 'dropdown',
label: 'Why are you requesting source?',
options: [
{
label: 'Obfuscated',
weight: 999999,
message: async () =>
(await import('../messages/status-alerts/tec/source_request-obfs.md?raw')).default,
} as DropdownActionOption,
{
label: 'Binaries',
weight: 999000,
message: async () =>
(await import('../messages/status-alerts/tec/source_request-bins.md?raw')).default,
} as DropdownActionOption,
],
} as DropdownAction,
],
} as ButtonAction,
{
id: 'status_automod_confusion',
type: 'button',

View File

@@ -25,6 +25,8 @@ const versions: Stage = {
label: 'Incorrect Project Type',
suggestedStatus: 'rejected',
severity: 'medium',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'versions_incorrect_project_type_options',
@@ -62,6 +64,8 @@ const versions: Stage = {
label: 'Alternate Versions',
suggestedStatus: 'flagged',
severity: 'medium',
weight: -999999,
message: async () => '',
enablesActions: [
{
id: 'versions_incorrect_project_type_options',
@@ -110,7 +114,7 @@ const versions: Stage = {
weight: 1002,
suggestedStatus: 'rejected',
severity: 'high',
shouldShow: (project) => project.project_type === `modpack`,
shouldShow: (project) => project.project_type === 'modpack',
message: async () =>
(await import('../messages/versions/alternate_versions-zip.md?raw')).default,
} as DropdownActionOption,

View File

@@ -0,0 +1,140 @@
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isVisible" class="w-full">
<div class="mb-2 flex justify-between text-sm">
<Transition name="phrase-fade" mode="out-in">
<span :key="currentPhrase" class="text-md font-semibold">{{ currentPhrase }}</span>
</Transition>
<div class="flex flex-col items-end">
<span class="text-secondary">{{ Math.round(progress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(currentValue) }} / {{ formatBytes(maxValue) }}</span
>
</div>
</div>
<div class="h-2 w-full rounded-full bg-divider">
<div
class="h-2 animate-pulse bg-brand rounded-full transition-all duration-300 ease-out"
:style="{ width: `${progress}%` }"
></div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { formatBytes } from '@modrinth/utils'
import { computed, onUnmounted, ref, watch } from 'vue'
interface Props {
maxValue: number
currentValue: number
tips?: string[]
}
const props = withDefaults(defineProps<Props>(), {
tips: () => [
'Removing Herobrine...',
'Feeding parrots...',
'Teaching villagers new trades...',
'Convincing creepers to be friendly...',
'Polishing diamonds...',
'Training wolves to fetch...',
'Building pixel art...',
'Explaining redstone to beginners...',
'Collecting all the cats...',
'Negotiating with endermen...',
'Planting suspicious stew ingredients...',
'Calibrating TNT blast radius...',
'Teaching chickens to fly...',
'Sorting inventory alphabetically...',
'Convincing iron golems to smile...',
],
})
const currentPhrase = ref('')
const usedPhrases = ref(new Set<number>())
let phraseInterval: NodeJS.Timeout | null = null
const progress = computed(() => {
if (props.maxValue === 0) return 0
return Math.min((props.currentValue / props.maxValue) * 100, 100)
})
const isVisible = computed(() => props.maxValue > 0 && props.currentValue >= 0)
function getNextPhrase() {
if (usedPhrases.value.size >= props.tips.length) {
const currentPhraseIndex = props.tips.indexOf(currentPhrase.value)
usedPhrases.value.clear()
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex)
}
}
const availableIndices = props.tips
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index))
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
usedPhrases.value.add(randomIndex)
return props.tips[randomIndex]
}
function startPhraseRotation() {
if (phraseInterval) {
clearInterval(phraseInterval)
}
currentPhrase.value = getNextPhrase()
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase()
}, 4500)
}
function stopPhraseRotation() {
if (phraseInterval) {
clearInterval(phraseInterval)
phraseInterval = null
}
}
watch(isVisible, (newVisible) => {
if (newVisible) {
startPhraseRotation()
} else {
stopPhraseRotation()
usedPhrases.value.clear()
}
})
watch(progress, (newProgress) => {
if (newProgress >= 100) {
stopPhraseRotation()
currentPhrase.value = 'Installing modpack...'
}
})
onUnmounted(() => {
stopPhraseRotation()
})
</script>
<style scoped>
.phrase-fade-enter-active,
.phrase-fade-leave-active {
transition: opacity 0.3s ease;
}
.phrase-fade-enter-from,
.phrase-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="joined-buttons">
<ButtonStyled :color="color">
<button :disabled="disabled" @click="handlePrimaryAction">
<component :is="primaryAction.icon" v-if="primaryAction.icon" aria-hidden="true" />
{{ primaryAction.label }}
</button>
</ButtonStyled>
<ButtonStyled v-if="dropdownActions.length > 0" :color="color">
<OverflowMenu class="btn-dropdown-animation" :options="dropdownOptions" :disabled="disabled">
<DropdownIcon />
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
<component :is="action.icon" v-if="action.icon" aria-hidden="true" />
{{ action.label }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ButtonStyled, OverflowMenu } from '../index'
import { DropdownIcon } from '@modrinth/assets'
import type { Component } from 'vue'
// TODO: This should be moved to a shared types file.
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
export interface JoinedButtonAction {
id: string
label: string
icon?: Component
action: () => void
color?: Colors
hoverFilled?: boolean
}
interface Props {
actions: JoinedButtonAction[]
color?: Colors
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
color: 'standard',
disabled: false,
})
const primaryAction = computed(() => props.actions[0])
const dropdownActions = computed(() => props.actions.slice(1))
const colorMap: Record<
Colors,
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
| 'highlight'
| 'primary'
| 'danger'
| 'secondary'
| undefined
> = {
standard: 'secondary',
brand: 'primary',
red: 'red',
orange: 'orange',
green: 'green',
blue: 'blue',
purple: 'purple',
}
const dropdownOptions = computed(() =>
dropdownActions.value.map((action) => ({
id: action.id,
color: action.color ? colorMap[action.color] : undefined,
action: action.action,
hoverFilled: action.hoverFilled ?? true,
})),
)
function handlePrimaryAction() {
if (primaryAction.value && !props.disabled) {
primaryAction.value.action()
}
}
</script>
<style scoped>
.joined-buttons {
display: flex;
align-items: center;
}
.joined-buttons > :deep(.btn) {
border-radius: 0;
}
.joined-buttons > :deep(.btn:first-child) {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.joined-buttons > :deep(.btn:last-child) {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
margin-left: -1px;
}
.joined-buttons > :deep(.btn:not(:last-child)) {
border-right: none;
}
.btn-dropdown-animation {
padding: 0.5rem !important;
}
</style>

View File

@@ -9,7 +9,11 @@
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
<div
class="vue-notification-template vue-notification"
:class="{ [item.type]: true }"
@click="item.clickAction"
>
<div class="notification-title" v-html="item.title"></div>
<div class="notification-content" v-html="item.text"></div>
</div>
@@ -31,11 +35,14 @@ const notifications = ref([])
defineExpose({
addNotification: (notification) => {
notification.clickAction = notification.clickAction ?? (() => {})
const existingNotif = notifications.value.find(
(x) =>
x.text === notification.text &&
x.title === notification.title &&
x.type === notification.type,
x.type === notification.type &&
x.clickAction === notification.clickAction,
)
if (existingNotif) {
setNotificationTimer(existingNotif)

View File

@@ -1,6 +1,7 @@
// Base content
export { default as Accordion } from './base/Accordion.vue'
export { default as Admonition } from './base/Admonition.vue'
export { default as AppearingProgressBar } from './base/AppearingProgressBar.vue'
export { default as AutoLink } from './base/AutoLink.vue'
export { default as Avatar } from './base/Avatar.vue'
export { default as Badge } from './base/Badge.vue'
@@ -21,6 +22,8 @@ export { default as FileInput } from './base/FileInput.vue'
export { default as FilterBar } from './base/FilterBar.vue'
export type { FilterBarOption } from './base/FilterBar.vue'
export { default as HeadingLink } from './base/HeadingLink.vue'
export { default as JoinedButtons } from './base/JoinedButtons.vue'
export type { JoinedButtonAction } from './base/JoinedButtons.vue'
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
export { default as ManySelect } from './base/ManySelect.vue'
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'

View File

@@ -22,6 +22,7 @@
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
<div
data-tauri-drag-region
v-if="!hideHeader"
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-divider max-w-full"
>
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
@@ -60,6 +61,7 @@ const props = withDefaults(
closeOnClickOutside?: boolean
warnOnClose?: boolean
header?: string
hideHeader?: boolean
onHide?: () => void
onShow?: () => void
}>(),
@@ -71,6 +73,7 @@ const props = withDefaults(
closeOnEsc: true,
warnOnClose: false,
header: undefined,
hideHeader: false,
onHide: () => {},
onShow: () => {},
},
@@ -134,7 +137,7 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
}
function handleKeyDown(event: KeyboardEvent) {
if (props.closeOnEsc && event.key === 'Escape') {
if (props.closeOnEsc && event.key === 'Escape' && props.closable) {
hide()
mouseX.value = window.innerWidth / 2
mouseY.value = window.innerHeight / 2

View File

@@ -10,6 +10,13 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2025-07-19T15:20:00-07:00`,
product: 'web',
body: `### Improvements
- Removed Tumblr icon from footer as we no longer use it.
- Reverted changes to publishing checklist since they need more work.`,
},
{
date: `2025-07-16T12:40:00-07:00`,
product: 'web',