Compare commits

...
Author SHA1 Message Date
aecsocket fb8f5eacbd Adjust window occlusion threshold 2026-05-21 22:43:34 +01:00
aecsocketandGitHub 71dee4de40 Add modpacks with external files filter to moderation queue (#6155)
* simplify query

* make it a tristate

* external deps count
2026-05-21 20:21:54 +00:00
SychicandGitHub f74fad0cae fix: typo in version changelog (#6153) 2026-05-21 19:38:11 +00:00
Truman GaoandGitHub 07e81ac036 fix: project embeds (#6152)
* fix: project embeds

* remove: params.id
2026-05-21 17:02:53 +00:00
Calum H.andGitHub 6e7835fb35 feat: implement kryos upload sessions (#6145)
* feat: implement upload sessions

* fix: files not scoped

* feat: hide staging files folder and proper cancel feedback

* fix: lint
2026-05-21 16:49:48 +00:00
Calum H.andGitHub 2f95c4c441 feat: changelog 20th may 2026 (#6148) 2026-05-20 12:51:36 -07:00
aecsocketandGitHub 451b2d0e44 Window occlusion checks on MacOS (#6135)
* wip: window occlusion checks on MacOS

* wip: occlusion works

* occlusion notification

* fix ci

* fix

* wire in hiding into macos occlusion

* remove debug logs

* fix

* clean up
2026-05-20 19:31:12 +00:00
Truman GaoandGitHub 215643c846 fix: server project type page title (#6147)
* fix: server project type page title

* pnpm prepr
2026-05-20 19:01:19 +00:00
Truman GaoandGitHub d8b1415f9c feat: swap date input to use date picker (#6146)
* feat: swap date input to use date picker

* feat: update date picker to analytics branch changes

* feat: polish date picker usage
2026-05-20 17:50:46 +00:00
Calum H.andGitHub 3eeb549d20 fix: intercom bubble positioning properly (#6111)
* feat: fix intercom properly

* fix: positioning size + css transition

* fix: lint

* fix: ts

* fix: nitpick
2026-05-20 17:15:46 +00:00
Calum H.andGitHub c3fe7b4232 feat: content management changes (#6104)
* feat: change modpack updating flow

* fix: pending install state loss

* fix: mods.vue perf problems

* chore: todo doc

* draft: try preload/fix suspense

* fix: lint
2026-05-20 17:07:35 +00:00
Prospector 079a10bba9 changelog 2026-05-20 09:01:26 -07:00
Prospector 3c3d5702ba fix: permissions page being displayed 2026-05-20 08:59:50 -07:00
Prospector a34576a2c3 changelog 2026-05-19 12:51:59 -07:00
ProspectorandGitHub d8e4915a31 fix: 404 error when changing project slug (#6139) 2026-05-19 19:21:23 +00:00
Prospector ed723fa186 changelog 2026-05-19 11:50:49 -07:00
ProspectorandGitHub d6c8d4475b feat: new north american region info (#6091) 2026-05-19 11:49:41 -07:00
ProspectorandGitHub 302b60d89c fix: random 404s (#6138)
* fix: random 404s when navigating from user, org, or collection page to project

* more

* revert server

* clean names

* prepr
2026-05-19 18:11:48 +00:00
aecsocketandGitHub f106dc580f Restrict what tokens can be used for auth init flows (#6137) 2026-05-19 15:45:58 +00:00
aecsocketandGitHub 244c263e40 Implement analytics marker events (#6090)
* Analytics events

* prepare

* change route prefix

* update route return

* Add mod launcher analytics

* more UA strings

* fix ci

* caching on analytics events

* Return parent modpack versions for playtime queries

* sqlx prepare

* fmt

* dummy fixtures
2026-05-19 13:06:04 +00:00
137 changed files with 4344 additions and 960 deletions
Generated
+3
View File
@@ -10565,6 +10565,8 @@ version = "1.0.0-local"
dependencies = [
"async_zip",
"chrono",
"core-foundation 0.10.1",
"core-graphics",
"daedalus",
"dashmap",
"either",
@@ -10572,6 +10574,7 @@ dependencies = [
"hyper 1.7.0",
"hyper-util",
"native-dialog",
"objc2-app-kit",
"paste",
"path-util",
"serde",
+3
View File
@@ -64,6 +64,8 @@ clickhouse = "0.14.0"
color-eyre = "0.6.5"
color-thief = "0.2.2"
const_format = "0.2.34"
core-foundation = "0.10.1"
core-graphics = "0.24.0"
daedalus = { path = "packages/daedalus" }
darling = { version = "0.23" }
dashmap = "6.1.0"
@@ -123,6 +125,7 @@ murmur2 = "0.1.0"
native-dialog = "0.9.2"
notify = { version = "8.2.0", default-features = false }
notify-debouncer-mini = { version = "0.7.0", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false }
p256 = "0.13.2"
parking_lot = "0.12.5"
paste = "1.0.15"
-1
View File
@@ -13,7 +13,6 @@
"test": "vue-tsc --noEmit"
},
"dependencies": {
"@intercom/messenger-js-sdk": "^0.0.14",
"@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*",
+41 -93
View File
@@ -1,5 +1,4 @@
<script setup>
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
import {
AuthFeature,
ModrinthApiError,
@@ -53,6 +52,7 @@ import {
providePopupNotificationManager,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
@@ -88,7 +88,6 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useIntercomPositioning } from '@/composables/intercom-positioning'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
@@ -132,15 +131,36 @@ import { AppPopupNotificationManager } from './providers/app-popup-notifications
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const intercomBubblePositioning = useIntercomPositioning({ route, themeStore })
const {
sidebarToggled,
forceSidebar,
sidebarVisible,
intercomBubblePosition,
updateIntercomBubbleStyles,
clearIntercomBubbleStyles,
} = intercomBubblePositioning
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const credentials = ref()
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const hostingIntercomIdentityKey = computed(() => {
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
const userId = credentials.value?.user_id ?? credentials.value?.user?.id ?? 'anonymous'
return `${userId}:${serverId ?? 'hosting'}`
})
const hostingIntercom = useHostingIntercom({
enabled: computed(() => hostingRouteActive.value && !!credentials.value?.session),
appId: 'ykeritl9',
fetchToken: fetchIntercomToken,
identityKey: hostingIntercomIdentityKey,
horizontalPadding: computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
),
})
const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
@@ -175,7 +195,11 @@ provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
...intercomBubblePositioning.pageContext,
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
},
intercomBubble: hostingIntercom.intercomBubble,
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
@@ -259,8 +283,7 @@ onMounted(async () => {
onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
shutdownHostingIntercom()
clearIntercomBubbleStyles()
unsubscribeSidebarToggle()
await unlistenUpdateDownload?.()
})
@@ -593,8 +616,6 @@ const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const updateToPlayModal = ref()
const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
setupAuthProvider(credentials, async (_redirectPath) => {
@@ -664,10 +685,6 @@ const hasPlus = computed(
const showAd = computed(
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
let intercomBooting = false
let intercomBooted = false
async function fetchIntercomToken() {
const creds = await getCreds()
@@ -676,8 +693,10 @@ async function fetchIntercomToken() {
}
const params = new URLSearchParams()
if (route.path.startsWith('/hosting/manage/') && typeof route.params.id === 'string') {
params.set('server_id', route.params.id)
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
if (route.path.startsWith('/hosting/manage/') && typeof serverId === 'string') {
params.set('server_id', serverId)
}
const query = params.size > 0 ? `?${params.toString()}` : ''
@@ -693,69 +712,6 @@ async function fetchIntercomToken() {
return await response.json()
}
async function bootIntercom() {
if (
intercomBooting ||
intercomBooted ||
!hostingRouteActive.value ||
!credentials.value?.session
) {
return
}
intercomBooting = true
console.debug('[APP][INTERCOM] initializing secure support chat')
try {
const { token } = await fetchIntercomToken()
Intercom({
app_id: 'ykeritl9',
intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24,
alignment: 'right',
horizontal_padding: intercomBubblePosition.value.horizontalPadding,
vertical_padding: intercomBubblePosition.value.verticalPadding,
})
intercomBooted = true
} catch (error) {
console.warn('[APP][INTERCOM] failed to initialize secure support chat', error)
} finally {
intercomBooting = false
}
}
function shutdownHostingIntercom() {
if (!intercomBooted && !intercomBooting) return
shutdownIntercom()
intercomBooting = false
intercomBooted = false
}
watch(
intercomBubblePosition,
(position) => {
updateIntercomBubbleStyles(position)
if (intercomBooted) {
window.Intercom?.('update', {
horizontal_padding: position.horizontalPadding,
vertical_padding: position.verticalPadding,
})
}
},
{ immediate: true },
)
watch(
[hostingRouteActive, credentials],
([active]) => {
if (active) {
void bootIntercom()
} else {
shutdownHostingIntercom()
}
},
{ immediate: true },
)
watch(showAd, () => {
if (!showAd.value) {
hide_ads_window(true)
@@ -1822,14 +1778,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
--os-handle-bg-active: var(--color-scrollbar) !important;
}
.intercom-lightweight-app-launcher,
.intercom-launcher-frame,
iframe[name='intercom-launcher-frame'] {
right: var(--app-support-launcher-right, 20px) !important;
bottom: var(--app-support-launcher-bottom, 20px) !important;
z-index: 9 !important;
}
.mac {
.app-grid-statusbar {
padding-left: 5rem;
@@ -35,7 +35,7 @@ const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const { instance, offline, isMinecraftServer, onUnlinked } = injectInstanceSettings()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const [
fabric_versions,
@@ -113,6 +113,7 @@ provideAppBackup({
})
provideInstallationSettings({
closeSettings: closeModal,
loading: ref(false),
installationInfo: computed(() => {
const rows = [
@@ -45,12 +45,19 @@ const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked')
const instanceRef = computed(() => props.instance)
const queryClient = useQueryClient()
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function hide() {
tabbedModal.value?.hide()
}
provideInstanceSettings({
instance: instanceRef,
offline: props.offline,
isMinecraftServer,
onUnlinked: handleUnlinked,
closeModal: hide,
})
watch(
@@ -113,9 +120,6 @@ const tabs = computed<TabbedModalTab[]>(() => [
},
])
const queryClient = useQueryClient()
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function show(tabIndex?: number) {
if (props.instance.linked_data?.project_id) {
queryClient.prefetchQuery({
@@ -129,7 +133,7 @@ function show(tabIndex?: number) {
}
}
defineExpose({ show })
defineExpose({ show, hide })
</script>
<template>
<TabbedModal
@@ -1,121 +0,0 @@
import { computed, onUnmounted, ref } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
interface ThemeStore {
toggleSidebar: boolean
$subscribe: (callback: () => void) => () => void
}
interface IntercomBubblePosition {
horizontalPadding: number
verticalPadding: number
}
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const INTERCOM_BUBBLE_WIDTH = 72
const INTERCOM_BUBBLE_RIGHT_VAR = '--app-support-launcher-right'
const INTERCOM_BUBBLE_BOTTOM_VAR = '--app-support-launcher-bottom'
export function useIntercomPositioning({
route,
themeStore,
}: {
route: RouteLocationNormalizedLoaded
themeStore: ThemeStore
}) {
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
onUnmounted(unsubscribeSidebarToggle)
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const defaultIntercomBubbleHorizontalPadding = computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
)
const intercomBubbleRequestedHorizontalPadding = ref<number | null>(null)
const intercomBubbleHorizontalPadding = computed(
() =>
intercomBubbleRequestedHorizontalPadding.value ??
defaultIntercomBubbleHorizontalPadding.value,
)
const intercomBubbleVerticalClearance = ref<number | null>(null)
const intercomBubblePosition = computed(() => ({
horizontalPadding: intercomBubbleHorizontalPadding.value,
verticalPadding: intercomBubbleVerticalClearance.value ?? INTERCOM_BUBBLE_DEFAULT_PADDING,
}))
const intercomBubbleHorizontalPaddingRequests = new Map<symbol, number>()
const intercomBubbleClearanceRequests = new Map<symbol, number>()
function requestIntercomBubbleHorizontalPadding(id: symbol, padding: number | null) {
if (padding === null) {
intercomBubbleHorizontalPaddingRequests.delete(id)
} else {
intercomBubbleHorizontalPaddingRequests.set(id, padding)
}
intercomBubbleRequestedHorizontalPadding.value =
intercomBubbleHorizontalPaddingRequests.size > 0
? Math.max(...intercomBubbleHorizontalPaddingRequests.values())
: null
}
function requestIntercomBubbleVerticalClearance(id: symbol, clearance: number | null) {
if (clearance === null) {
intercomBubbleClearanceRequests.delete(id)
} else {
intercomBubbleClearanceRequests.set(id, clearance)
}
intercomBubbleVerticalClearance.value =
intercomBubbleClearanceRequests.size > 0
? Math.max(...intercomBubbleClearanceRequests.values())
: null
}
function updateIntercomBubbleStyles({
horizontalPadding,
verticalPadding,
}: IntercomBubblePosition) {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty(INTERCOM_BUBBLE_RIGHT_VAR, `${horizontalPadding}px`)
document.documentElement.style.setProperty(INTERCOM_BUBBLE_BOTTOM_VAR, `${verticalPadding}px`)
}
function clearIntercomBubbleStyles() {
if (typeof document === 'undefined') return
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_RIGHT_VAR)
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_BOTTOM_VAR)
}
return {
sidebarToggled,
forceSidebar,
sidebarVisible,
intercomBubblePosition,
updateIntercomBubbleStyles,
clearIntercomBubbleStyles,
pageContext: {
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
},
intercomBubble: {
width: ref(INTERCOM_BUBBLE_WIDTH),
horizontalPadding: intercomBubbleHorizontalPadding,
requestHorizontalPadding: requestIntercomBubbleHorizontalPadding,
requestVerticalClearance: requestIntercomBubbleVerticalClearance,
},
},
}
}
@@ -0,0 +1,100 @@
import type {
ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '@modrinth/ui'
import {
get_content_items,
get_linked_modpack_info,
type LinkedModpackInfo,
} from '@/helpers/profile'
import { get_categories } from '@/helpers/tags.js'
import type { CacheBehaviour } from '@/helpers/types'
export type InstanceContentData = {
path: string
contentItems: ContentItem[] | null
modpack: InstanceContentModpackData | null
}
export type InstanceContentModpackData = {
project: ContentModpackCardProject
version: ContentModpackCardVersion
owner: ContentOwner | null
categories: ContentModpackCardCategory[]
hasUpdate: boolean
updateVersionId: string | null
}
export async function loadInstanceContentData(
path: string,
cacheBehaviour?: CacheBehaviour,
onError?: (error: Error) => unknown,
): Promise<InstanceContentData> {
const [contentItems, modpackInfo, allCategories] = await Promise.all([
get_content_items(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
get_linked_modpack_info(path, cacheBehaviour).catch((error) => handleLoadError(error, onError)),
get_categories().catch((error) => handleLoadError(error, onError)),
])
return {
path,
contentItems: (contentItems as ContentItem[] | null | undefined) ?? null,
modpack: normalizeLinkedModpackInfo(
modpackInfo as LinkedModpackInfo | null | undefined,
allCategories as ContentModpackCardCategory[] | null | undefined,
),
}
}
function handleLoadError(error: unknown, onError?: (error: Error) => unknown) {
onError?.(error as Error)
return null
}
function normalizeLinkedModpackInfo(
modpackInfo: LinkedModpackInfo | null | undefined,
allCategories: ContentModpackCardCategory[] | null | undefined,
): InstanceContentModpackData | null {
if (!modpackInfo) return null
return {
project: {
...modpackInfo.project,
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
icon_url: modpackInfo.project.icon_url ?? undefined,
},
version: {
...modpackInfo.version,
date_published: modpackInfo.version.date_published.toString(),
},
owner: modpackInfo.owner
? {
...modpackInfo.owner,
avatar_url: modpackInfo.owner.avatar_url ?? undefined,
}
: null,
categories: resolveLinkedModpackCategories(modpackInfo, allCategories),
hasUpdate: modpackInfo.has_update,
updateVersionId: modpackInfo.update_version_id,
}
}
function resolveLinkedModpackCategories(
modpackInfo: LinkedModpackInfo,
allCategories: ContentModpackCardCategory[] | null | undefined,
) {
if (!allCategories || !modpackInfo.project.categories) return []
const seen = new Set<string>()
return allCategories.filter((category) => {
if (modpackInfo.project.categories.includes(category.name) && !seen.has(category.name)) {
seen.add(category.name)
return true
}
return false
})
}
+44 -10
View File
@@ -218,7 +218,11 @@
:key="instance.path"
>
<template v-if="Component">
<Suspense :key="instance.path">
<Suspense
:key="instance.path"
@pending="subpagePending = true"
@resolve="subpagePending = false"
>
<component
:is="Component"
:instance="instance"
@@ -228,6 +232,7 @@
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
:open-settings="() => settingsModal?.show(1)"
v-bind="contentSubpageProps"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
@@ -295,6 +300,7 @@ import {
ServerPing,
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
@@ -312,6 +318,7 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
@@ -331,6 +338,7 @@ const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -341,12 +349,16 @@ window.addEventListener('online', () => {
})
const instance = ref<GameInstance>()
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
useLoadingBarToken(subpagePending)
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
@@ -361,36 +373,55 @@ const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
function isContentSubpageRoute(routeName = route.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
ping.value = undefined
playersOnline.value = undefined
loadingServerPing.value = false
instance.value = await get(route.params.id as string).catch(handleError)
const nextInstance = await get(route.params.id as string).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
if (!offline.value && instance.value?.linked_data && instance.value.linked_data.project_id) {
const contentPreloadPromise =
nextInstance && isContentSubpageRoute()
? loadInstanceContentData(nextInstance.path, undefined, handleError)
: Promise.resolve(null)
if (!offline.value && nextInstance?.linked_data && nextInstance.linked_data.project_id) {
try {
linkedProjectV3.value = await get_project_v3(
instance.value.linked_data.project_id,
nextLinkedProjectV3 = await get_project_v3(
nextInstance.linked_data.project_id,
'must_revalidate',
)
if (linkedProjectV3.value?.minecraft_server != null) {
isServerInstance.value = true
if (nextLinkedProjectV3?.minecraft_server != null) {
nextIsServerInstance = true
}
} catch (error) {
handleError(error as Error)
}
}
const nextPreloadedContent = await contentPreloadPromise
instance.value = nextInstance ?? undefined
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
fetchDeferredData()
if (instance.value) {
if (nextInstance) {
queryClient.prefetchQuery({
queryKey: ['worlds', instance.value.path],
queryFn: () => refreshWorlds(instance.value!.path),
queryKey: ['worlds', nextInstance.path],
queryFn: () => refreshWorlds(nextInstance.path),
staleTime: 30_000,
})
}
@@ -448,6 +479,9 @@ const renderMode = computed<'scroll' | 'fixed'>(() =>
route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
)
const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const tabs = computed(() => [
{
+389 -107
View File
@@ -13,6 +13,7 @@
:modpack-name="linkedModpackProject?.title"
:modpack-icon-url="linkedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
@update:enabled="handleModpackContentToggle"
@@ -87,12 +88,12 @@ import {
ReadyTransition,
useDebugLogger,
useVIntl,
versionChangesGameVersion,
} from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useDebounceFn } from '@vueuse/core'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
@@ -100,22 +101,20 @@ import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import {
add_project_from_path,
add_project_from_version,
duplicate,
edit,
get,
get_content_items,
get_linked_modpack_content,
get_linked_modpack_info,
list,
remove_project,
toggle_disable_project,
update_managed_modrinth_version,
update_project,
} from '@/helpers/profile'
import { get_categories } from '@/helpers/tags.js'
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInProfile } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
@@ -160,6 +159,7 @@ const props = defineProps<{
instance: GameInstance
isServerInstance?: boolean
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const loading = ref(true)
@@ -212,6 +212,7 @@ const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
// TODO: Extract content operation and updater modal state into composables; this page currently owns file mutations, dependency installs, busy flags, and version selection flow.
const updatingProject = ref<ContentItem | null>(null)
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
const loadingVersions = ref(false)
@@ -219,6 +220,78 @@ const loadingChangelog = ref(false)
const updatingModpack = ref(false)
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isModpackUpdateDowngrade = ref(false)
const activeContentOperationKeys = ref(new Set<string>())
let activeContentOperationCount = 0
let updateRequestId = 0
const activeUpdateRequestId = ref(0)
function fileNameFromPath(path: string) {
return path.split('/').pop() ?? path
}
function getContentOperationKeys(item: ContentItem) {
return [item.id, item.file_path, item.file_name, item.project?.id, item.version?.id].filter(
(key): key is string => !!key,
)
}
function hasContentOperation(item: ContentItem) {
const keys = getContentOperationKeys(item)
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
item.installing = busy
modpackContentModal.value?.updateItem(originalFileName, {
installing: busy,
disabled: busy,
})
if (item.file_name !== originalFileName) {
modpackContentModal.value?.updateItem(item.file_name, {
installing: busy,
disabled: busy,
})
}
}
function beginContentOperation(item: ContentItem) {
if (hasContentOperation(item)) return null
const keys = getContentOperationKeys(item)
activeContentOperationKeys.value = new Set([...activeContentOperationKeys.value, ...keys])
activeContentOperationCount++
isBulkOperating.value = true
setContentItemBusy(item, true)
return { keys, originalFileName: item.file_name }
}
function finishContentOperation(
item: ContentItem,
operation: { keys: string[]; originalFileName: string },
) {
const nextKeys = new Set(activeContentOperationKeys.value)
for (const key of operation.keys) {
nextKeys.delete(key)
}
activeContentOperationKeys.value = nextKeys
activeContentOperationCount = Math.max(0, activeContentOperationCount - 1)
setContentItemBusy(item, false, operation.originalFileName)
if (activeContentOperationCount === 0) {
isBulkOperating.value = false
}
}
function beginUpdateRequest() {
updateRequestId++
activeUpdateRequestId.value = updateRequestId
return updateRequestId
}
function isActiveUpdateRequest(requestId: number) {
return activeUpdateRequestId.value === requestId
}
async function handleBrowseContent() {
if (!props.instance) return
@@ -265,9 +338,21 @@ async function handleUploadFiles() {
}
async function toggleDisableMod(mod: ContentItem) {
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
try {
mod.file_path = await toggle_disable_project(props.instance.path, mod.file_path!)
const newPath = await toggle_disable_project(props.instance.path, mod.file_path)
const newFileName = fileNameFromPath(newPath)
mod.file_path = newPath
mod.file_name = newFileName
mod.enabled = !mod.enabled
modpackContentModal.value?.updateItem(operation.originalFileName, {
file_path: newPath,
file_name: newFileName,
enabled: mod.enabled,
})
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
@@ -279,33 +364,48 @@ async function toggleDisableMod(mod: ContentItem) {
})
} catch (err) {
handleError(err as Error)
} finally {
finishContentOperation(mod, operation)
}
}
const toggleDisableDebounced = useDebounceFn(toggleDisableMod, 20)
const toggleDisableDebounced = toggleDisableMod
async function removeMod(mod: ContentItem) {
await remove_project(props.instance.path, mod.file_path!).catch(handleError)
projects.value = projects.value.filter((x) => mod.file_path !== x.file_path)
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
try {
const removedPath = mod.file_path
await remove_project(props.instance.path, removedPath)
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
} finally {
finishContentOperation(mod, operation)
}
}
async function updateProject(mod: ContentItem) {
try {
const newPath = await update_project(props.instance.path, mod.file_path!)
mod.file_path = newPath
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
if (mod.update_version_id) {
const versionData = await get_version(mod.update_version_id, 'must_revalidate').catch(
handleError,
)
try {
const updateVersionId = mod.update_version_id
await update_project(props.instance.path, mod.file_path)
if (updateVersionId) {
const versionData = await get_version(updateVersionId, 'must_revalidate').catch(handleError)
if (versionData) {
const profile = await get(props.instance.path).catch(handleError)
@@ -316,12 +416,6 @@ async function updateProject(mod: ContentItem) {
}
}
mod.has_update = false
if (mod.version && mod.update_version_id) {
mod.version.id = mod.update_version_id
}
mod.update_version_id = null
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
@@ -331,32 +425,55 @@ async function updateProject(mod: ContentItem) {
})
} catch (err) {
handleError(err as Error)
} finally {
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
}
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
isBulkOperating.value = true
mod.installing = true
if (mod.version) {
mod.version.id = version.id
mod.version.version_number = version.version_number
}
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
const oldPath = mod.file_path
const wasDisabled = mod.enabled === false || oldPath.endsWith('.disabled')
let newPath: string | null = null
let shouldRemoveNewOnError = false
try {
await remove_project(props.instance.path, mod.file_path!)
const newPath = await add_project_from_version(props.instance.path, version.id, 'standalone')
newPath = await add_project_from_version(props.instance.path, version.id, 'update')
shouldRemoveNewOnError = newPath !== oldPath
if (wasDisabled) {
newPath = await toggle_disable_project(props.instance.path, newPath)
}
const profile = await get(props.instance.path).catch(handleError)
if (profile) {
await installVersionDependencies(profile, version, 'update').catch(handleError)
}
mod.file_path = newPath
shouldRemoveNewOnError = false
if (newPath !== oldPath) {
await remove_project(props.instance.path, oldPath)
}
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
if (shouldRemoveNewOnError && newPath && newPath !== oldPath) {
await remove_project(props.instance.path, newPath).catch(() => {})
}
handleError(err as Error)
} finally {
mod.installing = false
isBulkOperating.value = false
await initProjects()
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
}
}
@@ -364,6 +481,8 @@ async function handleUpdate(id: string) {
const item = projects.value.find((p) => p.id === id)
if (!item?.has_update || !item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
debug('handleUpdate triggered', {
fileName: item.file_name,
projectType: item.project_type,
@@ -384,12 +503,47 @@ async function handleUpdate(id: string) {
await nextTick()
contentUpdaterModal.value?.show(item.update_version_id ?? undefined)
const initialVersionId = item.update_version_id ?? undefined
debug('handleUpdate: opening content updater modal', {
type: 'content',
initialVersionId,
item: {
id: item.id,
fileName: item.file_name,
projectType: item.project_type,
projectId: item.project.id,
projectTitle: item.project.title,
currentVersionId: item.version.id,
currentVersionNumber: item.version.version_number,
updateVersionId: item.update_version_id,
},
instance: {
path: props.instance.path,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
linkedData: props.instance.linked_data,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
updatingProjectId: updatingProject.value?.id,
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
id: version.id,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
datePublished: version.date_published,
})),
},
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = (await get_project_versions(item.project.id).catch((e) => {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || updatingProject.value?.id !== item.id) return
loadingVersions.value = false
if (!versions) {
@@ -414,6 +568,25 @@ async function handleUpdate(id: string) {
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleUpdate: resolved content updater preselection', {
type: 'content',
initialVersionId,
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
preselectedVersion: preselectedVersion
? {
id: preselectedVersion.id,
versionNumber: preselectedVersion.version_number,
gameVersions: preselectedVersion.game_versions,
loaders: preselectedVersion.loaders,
datePublished: preselectedVersion.date_published,
}
: null,
versionCount: versions.length,
currentVersionId: item.version.id,
updateVersionId: item.update_version_id,
})
updatingProjectVersions.value = versions
}
@@ -421,6 +594,8 @@ async function handleUpdate(id: string) {
async function handleSwitchVersion(item: ContentItem) {
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
updatingModpack.value = false
updatingProject.value = item
updatingProjectVersions.value = []
@@ -435,6 +610,8 @@ async function handleSwitchVersion(item: ContentItem) {
return handleError(e)
})) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || updatingProject.value?.id !== item.id) return
loadingVersions.value = false
if (!versions) return
@@ -468,9 +645,28 @@ async function handleModpackContent() {
}
}
async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) {
if (!props.instance?.path) return
const contentItems = await get_linked_modpack_content(props.instance.path, cacheBehaviour).catch(
handleError,
)
if (contentItems) {
modpackContentModal.value?.setItems(contentItems)
}
}
async function refreshContentState(cacheBehaviour?: CacheBehaviour) {
await initProjects(cacheBehaviour)
await refreshModpackContentItems(cacheBehaviour)
}
async function handleModpackUpdate() {
if (!props.instance?.linked_data?.project_id) return
const requestId = beginUpdateRequest()
updatingModpack.value = true
updatingProject.value = null
updatingProjectVersions.value = []
@@ -479,14 +675,42 @@ async function handleModpackUpdate() {
await nextTick()
contentUpdaterModal.value?.show(
linkedModpackUpdateVersionId.value ?? props.instance?.linked_data?.version_id ?? undefined,
)
const initialVersionId =
linkedModpackUpdateVersionId.value ?? props.instance?.linked_data?.version_id ?? undefined
debug('handleModpackUpdate: opening modpack updater modal', {
type: 'modpack',
initialVersionId,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
linkedModpackProject: linkedModpackProject.value,
linkedModpackVersion: linkedModpackVersion.value,
linkedModpackHasUpdate: linkedModpackHasUpdate.value,
instance: {
path: props.instance.path,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
linkedData: props.instance.linked_data,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
updatingProjectId: updatingProject.value?.id,
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
id: version.id,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
datePublished: version.date_published,
})),
},
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = (await get_project_versions(props.instance.linked_data.project_id).catch(
handleError,
)) as Labrinth.Versions.v2.Version[] | null
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
loadingVersions.value = false
if (!versions) return
@@ -494,6 +718,25 @@ async function handleModpackUpdate() {
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleModpackUpdate: resolved modpack updater preselection', {
type: 'modpack',
initialVersionId,
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
preselectedVersion: preselectedVersion
? {
id: preselectedVersion.id,
versionNumber: preselectedVersion.version_number,
gameVersions: preselectedVersion.game_versions,
loaders: preselectedVersion.loaders,
datePublished: preselectedVersion.date_published,
}
: null,
versionCount: versions.length,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
currentLinkedVersionId: props.instance.linked_data.version_id,
})
updatingProjectVersions.value = versions
}
@@ -502,10 +745,12 @@ async function fetchAndSpliceVersion(
versionId: string,
cacheBehaviour?: Parameters<typeof get_version>[1],
onError?: (err: unknown) => void,
requestId = activeUpdateRequestId.value,
) {
const fullVersion = (await get_version(versionId, cacheBehaviour).catch(
onError ?? (() => null),
)) as Labrinth.Versions.v2.Version | null
if (!isActiveUpdateRequest(requestId)) return
if (!fullVersion) return
const index = updatingProjectVersions.value.findIndex((v) => v.id === versionId)
if (index !== -1) {
@@ -517,17 +762,26 @@ async function fetchAndSpliceVersion(
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
if (version.changelog != null) return
const requestId = activeUpdateRequestId.value
loadingChangelog.value = true
await fetchAndSpliceVersion(version.id, 'must_revalidate', handleError as (err: unknown) => void)
loadingChangelog.value = false
await fetchAndSpliceVersion(
version.id,
'must_revalidate',
handleError as (err: unknown) => void,
requestId,
)
if (isActiveUpdateRequest(requestId)) {
loadingChangelog.value = false
}
}
async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
if (version.changelog != null) return
await fetchAndSpliceVersion(version.id)
await fetchAndSpliceVersion(version.id, undefined, undefined, activeUpdateRequestId.value)
}
function resetUpdateState() {
activeUpdateRequestId.value = 0
updatingModpack.value = false
updatingProject.value = null
updatingProjectVersions.value = []
@@ -535,13 +789,23 @@ function resetUpdateState() {
loadingChangelog.value = false
}
function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
pendingModpackUpdateVersion.value = selectedVersion
const currentVersionId = props.instance?.linked_data?.version_id
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
: false
const shouldShowWarning =
isModpackUpdateDowngrade.value ||
versionChangesGameVersion(selectedVersion, props.instance.game_version)
if (!shouldShowWarning) {
await handleModpackUpdateConfirm()
return
}
modpackUpdateConfirmModal.value?.show()
}
@@ -551,6 +815,7 @@ async function handleModpackUpdateConfirm() {
const version = pendingModpackUpdateVersion.value
pendingModpackUpdateVersion.value = null
contentUpdaterModal.value?.hide()
isModpackUpdating.value = true
try {
await update_managed_modrinth_version(props.instance.path, version.id)
@@ -574,7 +839,7 @@ async function handleModalUpdate(
pendingModpackUpdateVersion.value = selectedVersion
await handleModpackUpdateConfirm()
} else {
handleModpackUpdateRequest(selectedVersion)
await handleModpackUpdateRequest(selectedVersion)
}
} else if (updatingProject.value) {
const mod = updatingProject.value
@@ -662,51 +927,31 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
async function initProjects(cacheBehaviour?: CacheBehaviour) {
if (!props.instance) return
const [contentItems, modpackInfo, allCategories] = await Promise.all([
get_content_items(props.instance.path, cacheBehaviour).catch(handleError),
get_linked_modpack_info(props.instance.path, cacheBehaviour).catch(handleError),
get_categories().catch(handleError),
])
const contentData = await loadInstanceContentData(
props.instance.path,
cacheBehaviour,
handleError,
)
applyContentData(contentData)
}
if (!contentItems) {
function applyContentData(contentData: InstanceContentData) {
if (contentData.path !== props.instance.path) return false
if (!contentData.contentItems) {
loading.value = false
return
return true
}
projects.value = contentItems
projects.value = contentData.contentItems
if (modpackInfo) {
linkedModpackProject.value = {
...modpackInfo.project,
slug: modpackInfo.project.slug ?? modpackInfo.project.id,
icon_url: modpackInfo.project.icon_url ?? undefined,
}
linkedModpackVersion.value = {
...modpackInfo.version,
date_published: modpackInfo.version.date_published.toString(),
}
linkedModpackOwner.value = modpackInfo.owner
? {
...modpackInfo.owner,
avatar_url: modpackInfo.owner.avatar_url ?? undefined,
}
: null
linkedModpackHasUpdate.value = modpackInfo.has_update
linkedModpackUpdateVersionId.value = modpackInfo.update_version_id
if (allCategories && modpackInfo.project.categories) {
const seen = new Set<string>()
linkedModpackCategories.value = allCategories.filter((cat: { name: string }) => {
if (modpackInfo.project.categories.includes(cat.name) && !seen.has(cat.name)) {
seen.add(cat.name)
return true
}
return false
})
} else {
linkedModpackCategories.value = []
}
if (contentData.modpack) {
linkedModpackProject.value = contentData.modpack.project
linkedModpackVersion.value = contentData.modpack.version
linkedModpackOwner.value = contentData.modpack.owner
linkedModpackCategories.value = contentData.modpack.categories
linkedModpackHasUpdate.value = contentData.modpack.hasUpdate
linkedModpackUpdateVersionId.value = contentData.modpack.updateVersionId
} else {
linkedModpackProject.value = null
linkedModpackVersion.value = null
@@ -717,6 +962,7 @@ async function initProjects(cacheBehaviour?: CacheBehaviour) {
}
loading.value = false
return true
}
provideAppBackup({
@@ -844,10 +1090,22 @@ provideContentManager({
filterPersistKey: props.instance.path,
})
await initProjects()
type UnlistenFn = () => void
const initialContentReady = loadInitialContent()
void initialContentReady.then(restoreModpackContentModalState).catch(handleError)
function loadInitialContent() {
if (props.preloadedContent && applyContentData(props.preloadedContent)) {
return Promise.resolve()
}
return initProjects()
}
async function restoreModpackContentModalState() {
if (!savedModalState) return
// Restore modpack content modal state if returning via back navigation
if (savedModalState) {
const stateToRestore = savedModalState
savedModalState = null
await nextTick()
@@ -860,18 +1118,32 @@ const removeBeforeEach = router.beforeEach(() => {
savedModalState = state ?? null
})
const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop' || !props.instance) return
let isUnmounted = false
let unlistenDragDrop: UnlistenFn | null = null
let unlistenProfiles: UnlistenFn | null = null
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(props.instance.path, file).catch(handleError)
}
await initProjects()
})
onMounted(() => {
void getCurrentWebview()
.onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop' || !props.instance) return
const unlistenProfiles = await profile_listener(
async (event: { event: string; profile_path_id: string }) => {
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(props.instance.path, file).catch(handleError)
}
await initProjects()
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenDragDrop = unlisten
})
.catch(handleError)
void profile_listener(async (event: { event: string; profile_path_id: string }) => {
if (
props.instance &&
event.profile_path_id === props.instance.path &&
@@ -881,8 +1153,17 @@ const unlistenProfiles = await profile_listener(
) {
await initProjects()
}
},
)
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenProfiles = unlisten
})
.catch(handleError)
})
watch(
() => props.instance?.install_stage,
@@ -905,8 +1186,9 @@ watch(
)
onUnmounted(() => {
isUnmounted = true
removeBeforeEach()
unlisten()
unlistenProfiles()
unlistenDragDrop?.()
unlistenProfiles?.()
})
</script>
@@ -8,6 +8,7 @@ export interface InstanceSettingsContext {
offline?: boolean
isMinecraftServer: Ref<boolean>
onUnlinked: () => void
closeModal?: () => void
}
export const [injectInstanceSettings, provideInstanceSettings] =
@@ -17,6 +17,7 @@ import {
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -208,6 +209,7 @@ export function createServerInstallContent(opts: {
const router = useRouter()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const serverIdQuery = computed(() => readQueryString(route.query.sid))
const worldIdQuery = computed(() => readQueryString(route.query.wid))
@@ -471,6 +473,9 @@ export function createServerInstallContent(opts: {
...serverContentInstallKeys.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
if (result.flushedPlans.length > 0) {
await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] })
}
return true
} finally {
@@ -491,6 +496,7 @@ export function createServerInstallContent(opts: {
const plans = new Map(queuedServerInstalls.value)
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(client, plans)
+1
View File
@@ -19,6 +19,7 @@
"types": ["vite/client"],
"paths": {
"@modrinth/api-client": ["../../packages/api-client/src/index.ts"],
"@/*": ["./src/*"]
}
},
+5
View File
@@ -8,6 +8,7 @@ import tauriConf from '../app/tauri.conf.json'
const projectRootDir = resolve(__dirname)
const appLibEnvDir = resolve(projectRootDir, '../../packages/app-lib')
const apiClientSource = resolve(projectRootDir, '../../packages/api-client/src/index.ts')
// Load .env from app-lib manually instead of using Vite's envDir, which would auto-load .env.local and override values
const envFilePath = resolve(appLibEnvDir, '.env')
@@ -37,6 +38,10 @@ export default defineConfig({
},
resolve: {
alias: [
{
find: '@modrinth/api-client',
replacement: apiClientSource,
},
{
find: '@',
replacement: resolve(projectRootDir, 'src'),
+7
View File
@@ -51,6 +51,13 @@ tauri-build = { workspace = true, features = ["codegen"] }
[target.'cfg(target_os = "linux")'.dependencies]
tauri-plugin-updater = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation.workspace = true
core-graphics.workspace = true
objc2-app-kit = { workspace = true, features = [
"NSWindow",
] }
[target.'cfg(windows)'.dependencies]
webview2-com.workspace = true
windows = { workspace = true, features = [
+50 -18
View File
@@ -17,6 +17,8 @@ pub struct AdsState {
}
const AD_LINK: &str = "https://modrinth.com/wrapper/app-ads-cookie";
#[cfg(any(windows, target_os = "macos"))]
pub(super) const OCCLUDED_AREA_THRESHOLD: f64 = 0.5;
#[cfg(not(target_os = "linux"))]
const ADS_USER_AGENT: &str = concat!(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ",
@@ -92,10 +94,12 @@ fn configure_ads_cookie_settings(
}
}
fn set_webview_visible<R: Runtime>(
webview: &tauri::Webview<R>,
_visible: bool,
) {
fn set_webview_visible<R: Runtime>(webview: &tauri::Webview<R>, visible: bool) {
#[cfg(not(any(windows, target_os = "macos")))]
{
_ = visible;
}
webview
.with_webview(
#[allow(unused_variables)]
@@ -103,11 +107,18 @@ fn set_webview_visible<R: Runtime>(
#[cfg(windows)]
{
let controller = wv.controller();
unsafe { controller.SetIsVisible(_visible) }.ok();
unsafe { controller.SetIsVisible(visible) }.ok();
}
},
)
.ok();
#[cfg(target_os = "macos")]
if visible {
webview.show().ok();
} else {
webview.hide().ok();
}
}
fn set_webview_visible_for_window<R: Runtime>(
@@ -129,27 +140,48 @@ fn set_webview_visible_for_window<R: Runtime>(
set_webview_visible(webview, visible && !is_minimized && !is_occluded);
}
#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
fn compute_ads_webview_occlusion<R: Runtime>(
app: &tauri::AppHandle<R>,
) -> Option<bool> {
let main_window = app.get_window("main")?;
let webviews = app.webviews();
let webview = webviews.get("ads-window")?;
let position = webview.position().ok()?;
let size = webview.size().ok()?;
let hwnd = main_window.hwnd().ok()?;
Some(crate::api::ads_occlusion_windows::is_ads_webview_occluded(
hwnd,
position.x,
position.y,
size.width,
size.height,
))
#[cfg(target_os = "macos")]
{
let position = webview.position().ok()?;
let size = webview.size().ok()?;
Some(
crate::api::ads_occlusion_macos::is_ads_webview_occluded(
&main_window,
position.x,
position.y,
size.width,
size.height,
)
.unwrap_or(false),
)
}
#[cfg(windows)]
{
let position = webview.position().ok()?;
let size = webview.size().ok()?;
let hwnd = main_window.hwnd().ok()?;
Some(crate::api::ads_occlusion_windows::is_ads_webview_occluded(
hwnd,
position.x,
position.y,
size.width,
size.height,
))
}
}
#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
async fn sync_ads_occlusion<R: Runtime>(app: &tauri::AppHandle<R>) {
let Some(occluded) = compute_ads_webview_occlusion(app) else {
return;
@@ -281,7 +313,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
});
}
#[cfg(windows)]
#[cfg(any(windows, target_os = "macos"))]
{
let app_handle = app.clone();
+236
View File
@@ -0,0 +1,236 @@
use std::ptr::NonNull;
use core_foundation::array::CFArray;
use core_foundation::base::{CFType, TCFType};
use core_foundation::dictionary::CFDictionary;
use core_foundation::number::CFNumber;
use core_foundation::string::CFString;
use core_graphics::display::CGDisplay;
use core_graphics::geometry::{CGPoint, CGRect, CGSize};
use core_graphics::window::{
CGWindowID, kCGWindowAlpha, kCGWindowBounds, kCGWindowLayer,
kCGWindowListExcludeDesktopElements,
kCGWindowListOptionOnScreenAboveWindow, kCGWindowListOptionOnScreenOnly,
kCGWindowNumber, kCGWindowOwnerName, kCGWindowOwnerPID,
};
use objc2_app_kit::NSWindow;
pub fn is_ads_webview_occluded<R: tauri::Runtime>(
main_window: &tauri::Window<R>,
ad_x: i32,
ad_y: i32,
ad_width: u32,
ad_height: u32,
) -> Option<bool> {
let scale_factor = main_window.scale_factor().ok()?;
let maybe_ns_window = main_window.ns_window().ok()?;
let ns_window = NonNull::new(maybe_ns_window)?.cast::<NSWindow>();
let ns_window = unsafe { ns_window.as_ref() };
let window_number = ns_window.windowNumber();
let main_window_id = if window_number > 0 {
window_number as CGWindowID
} else {
return None;
};
let window_infos = CGDisplay::window_list_info(
kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
None,
)?;
let window_infos = unsafe {
CFArray::<CFDictionary<CFString, CFType>>::wrap_under_get_rule(
window_infos.as_concrete_TypeRef(),
)
};
let main_window_rect = window_infos
.iter()
.find(|window_info| window_id(window_info) == Some(main_window_id))
.and_then(|window_info| window_rect(&window_info))?;
let ad_rect = ad_rect_from_main_window(
&main_window_rect,
ad_x,
ad_y,
ad_width,
ad_height,
scale_factor,
);
if is_empty_rect(&ad_rect) {
return None;
}
let ad_area = rect_area(&ad_rect);
if ad_area == 0.0 {
return None;
}
let app_process_id = std::process::id() as i32;
let windows_above_main = CGDisplay::window_list_info(
kCGWindowListOptionOnScreenAboveWindow
| kCGWindowListExcludeDesktopElements,
Some(main_window_id),
)?;
let windows_above_main = unsafe {
CFArray::<CFDictionary<CFString, CFType>>::wrap_under_get_rule(
windows_above_main.as_concrete_TypeRef(),
)
};
let mut occluded_area = 0.0;
for window_info in windows_above_main.iter() {
if window_id(&window_info) == Some(main_window_id) {
continue;
}
if window_process_id(&window_info) == Some(app_process_id) {
continue;
}
let owner_name = window_owner_name(&window_info);
if owner_name.as_deref().is_some_and(is_system_window_owner) {
continue;
}
let layer = window_layer(&window_info);
if layer != Some(0) {
continue;
}
let alpha = window_alpha(&window_info);
if alpha.is_some_and(|alpha| alpha <= 0.0) {
continue;
}
let Some(rect) = window_rect(&window_info) else {
continue;
};
let Some(intersection) = intersect_rects(&ad_rect, &rect) else {
continue;
};
occluded_area += rect_area(&intersection);
let occluded_ratio = occluded_area / ad_area;
if occluded_ratio >= super::ads::OCCLUDED_AREA_THRESHOLD {
return Some(true);
}
}
Some(false)
}
fn ad_rect_from_main_window(
main_window_rect: &CGRect,
x: i32,
y: i32,
width: u32,
height: u32,
scale_factor: f64,
) -> CGRect {
let scale_factor = if scale_factor > 0.0 {
scale_factor
} else {
1.0
};
CGRect::new(
&CGPoint::new(
main_window_rect.origin.x + x as f64 / scale_factor,
main_window_rect.origin.y + y as f64 / scale_factor,
),
&CGSize::new(width as f64 / scale_factor, height as f64 / scale_factor),
)
}
fn window_id(
window_info: &CFDictionary<CFString, CFType>,
) -> Option<CGWindowID> {
number_value(window_info, unsafe { kCGWindowNumber })
.and_then(|value| u32::try_from(value).ok())
}
fn window_process_id(
window_info: &CFDictionary<CFString, CFType>,
) -> Option<i32> {
number_value(window_info, unsafe { kCGWindowOwnerPID })
.and_then(|value| i32::try_from(value).ok())
}
fn window_layer(window_info: &CFDictionary<CFString, CFType>) -> Option<i32> {
number_value(window_info, unsafe { kCGWindowLayer })
.and_then(|value| i32::try_from(value).ok())
}
fn window_alpha(window_info: &CFDictionary<CFString, CFType>) -> Option<f64> {
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowAlpha) };
window_info.find(&key)?.downcast::<CFNumber>()?.to_f64()
}
fn window_owner_name(
window_info: &CFDictionary<CFString, CFType>,
) -> Option<String> {
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowOwnerName) };
Some(window_info.find(&key)?.downcast::<CFString>()?.to_string())
}
fn is_system_window_owner(owner_name: &str) -> bool {
matches!(owner_name, "WindowManager")
}
fn number_value(
window_info: &CFDictionary<CFString, CFType>,
key: core_foundation::string::CFStringRef,
) -> Option<i64> {
let key = unsafe { CFString::wrap_under_get_rule(key) };
window_info.find(&key)?.downcast::<CFNumber>()?.to_i64()
}
fn window_rect(window_info: &CFDictionary<CFString, CFType>) -> Option<CGRect> {
let key = unsafe { CFString::wrap_under_get_rule(kCGWindowBounds) };
let bounds = window_info.find(&key)?.downcast::<CFDictionary>()?;
let rect = CGRect::from_dict_representation(&bounds)?;
if is_empty_rect(&rect) {
None
} else {
Some(rect)
}
}
fn is_empty_rect(rect: &CGRect) -> bool {
rect.size.width <= 0.0 || rect.size.height <= 0.0
}
fn rect_area(rect: &CGRect) -> f64 {
if is_empty_rect(rect) {
return 0.0;
}
rect.size.width * rect.size.height
}
fn intersect_rects(a: &CGRect, b: &CGRect) -> Option<CGRect> {
let left = a.origin.x.max(b.origin.x);
let top = a.origin.y.max(b.origin.y);
let right = (a.origin.x + a.size.width).min(b.origin.x + b.size.width);
let bottom = (a.origin.y + a.size.height).min(b.origin.y + b.size.height);
let rect = CGRect::new(
&CGPoint::new(left, top),
&CGSize::new(right - left, bottom - top),
);
if is_empty_rect(&rect) {
None
} else {
Some(rect)
}
}
+1 -3
View File
@@ -8,8 +8,6 @@ use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsIconic, IsWindowVisible,
};
const OCCLUDED_AREA_THRESHOLD: f64 = 1.0;
pub fn is_ads_webview_occluded(
main_hwnd: HWND,
x: i32,
@@ -63,7 +61,7 @@ pub fn is_ads_webview_occluded(
occluded_area.saturating_add(rect_area(&intersection));
if (occluded_area as f64 / ad_area as f64)
>= OCCLUDED_AREA_THRESHOLD
>= super::ads::OCCLUDED_AREA_THRESHOLD
{
return true;
}
+2
View File
@@ -18,6 +18,8 @@ pub mod tags;
pub mod utils;
pub mod ads;
#[cfg(target_os = "macos")]
mod ads_occlusion_macos;
#[cfg(windows)]
mod ads_occlusion_windows;
pub mod cache;
+12 -1
View File
@@ -1,10 +1,15 @@
import { GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
import serverSidedVue from '@vitejs/plugin-vue'
import fs from 'fs/promises'
import { defineNuxtConfig } from 'nuxt/config'
import { fileURLToPath } from 'url'
import svgLoader from 'vite-svg-loader'
import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const API_CLIENT_SOURCE = fileURLToPath(
new URL('../../packages/api-client/src/index.ts', import.meta.url),
)
const preloadedFonts = [
'inter/Inter-Regular.woff2',
@@ -24,6 +29,9 @@ const STAGING_MODRINTH_URL = 'https://staging.modrinth.com'
export default defineNuxtConfig({
srcDir: 'src/',
alias: {
'@modrinth/api-client': API_CLIENT_SOURCE,
},
app: {
head: {
htmlAttrs: {
@@ -82,6 +90,9 @@ export default defineNuxtConfig({
},
cacheDir: '../../node_modules/.vite/apps/knossos',
resolve: {
alias: {
'@modrinth/api-client': API_CLIENT_SOURCE,
},
dedupe: ['vue'],
},
plugins: [
-1
View File
@@ -38,7 +38,6 @@
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@intercom/messenger-js-sdk": "^0.0.14",
"@ltd/j-toml": "^1.38.0",
"@modrinth/api-client": "workspace:*",
"@modrinth/assets": "workspace:*",
@@ -46,7 +46,7 @@
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changlog </span>
<span class="font-semibold text-contrast"> Version changelog </span>
<div class="w-full">
<MarkdownEditor
@@ -131,10 +131,9 @@
class="h-10"
/>
<StyledInput
<DatePicker
v-else-if="field.type === 'date'"
v-model="formData[field.name]"
type="date"
wrapper-class="w-full"
/>
@@ -230,6 +229,7 @@ import {
Admonition,
Checkbox,
Combobox,
DatePicker,
defineMessages,
financialMessages,
formFieldLabels,
@@ -95,11 +95,11 @@
<span class="text-red">*</span>
</span>
</label>
<StyledInput
<DatePicker
v-model="formData.dateOfBirth"
type="date"
:max="maxDate"
:max-date="maxDate"
autocomplete="bday"
placeholder="Select date of birth"
wrapper-class="w-full"
/>
</div>
@@ -213,6 +213,7 @@
import {
Chips,
Combobox,
DatePicker,
defineMessages,
formFieldLabels,
formFieldPlaceholders,
@@ -233,7 +233,7 @@ const visibleNags = computed<Nag[]>(() => {
link: {
path: 'moderation',
title: messages.visitModerationPage,
shouldShow: () => props.routeName !== 'type-id-moderation',
shouldShow: () => props.routeName !== 'type-project-moderation',
},
})
}
@@ -739,8 +739,8 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
} else {
// Fallback: use project ID (will trigger middleware redirect)
navigateTo({
name: 'type-id',
params: { type: 'project', id: next.projectId },
name: 'type-project',
params: { type: 'project', project: next.projectId },
state: { showChecklist: true },
})
}
@@ -1067,8 +1067,8 @@ async function skipToNextProject() {
} else {
// Fallback: use project ID
navigateTo({
name: 'type-id',
params: { type: 'project', id },
name: 'type-project',
params: { type: 'project', project: id },
state: { showChecklist: true },
})
}
@@ -2127,8 +2127,8 @@ async function endChecklist(status?: string) {
} else {
// Fallback: use project ID
navigateTo({
name: 'type-id',
params: { type: 'project', id },
name: 'type-project',
params: { type: 'project', project: id },
state: { showChecklist: true },
})
}
+30 -1
View File
@@ -761,9 +761,13 @@ import {
commonMessages,
commonProjectTypeCategoryMessages,
commonSettingsMessages,
createHostingIntercomIdentityKey,
defineMessages,
injectModrinthClient,
injectPageContext,
OverflowMenu,
providePageContext,
useHostingIntercom,
useVIntl,
} from '@modrinth/ui'
import TeleportOverflowMenu from '@modrinth/ui/src/components/base/TeleportOverflowMenu.vue'
@@ -808,6 +812,25 @@ const router = useNativeRouter()
const signInRouteObj = computed(() => getSignInRouteObj(route))
const link = config.public.siteUrl + route.path.replace(/\/+$/, '')
const client = injectModrinthClient()
const pageContext = injectPageContext()
const hostingIntercomActive = computed(() => route.path.startsWith('/hosting') && !!auth.value.user)
const hostingIntercomServerId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : rawId
})
const hostingIntercom = useHostingIntercom({
enabled: hostingIntercomActive,
appId: computed(() => config.public.intercomAppId),
fetchToken: fetchIntercomToken,
identityKey: computed(() =>
createHostingIntercomIdentityKey(auth.value.user, hostingIntercomServerId.value),
),
})
providePageContext({
...pageContext,
intercomBubble: hostingIntercom.intercomBubble,
})
const { data: payoutBalance } = useQuery({
queryKey: ['payout', 'balance'],
@@ -836,6 +859,12 @@ const showTinMismatchBanner = computed(() => {
const basePopoutId = useId()
async function fetchIntercomToken() {
return $fetch('/api/intercom/messenger-jwt', {
query: hostingIntercomServerId.value ? { server_id: hostingIntercomServerId.value } : {},
})
}
const navMenuMessages = defineMessages({
home: {
id: 'layout.nav.home',
@@ -1148,7 +1177,7 @@ const isDiscovering = computed(
)
const isDiscoveringSubpage = computed(
() => route.name && route.name.startsWith('type-id') && !route.query.sid,
() => route.name && route.name.startsWith('type-project') && !route.query.sid,
)
const isRussia = computed(() => country.value === 'ru')
+1 -1
View File
@@ -1536,7 +1536,7 @@
"message": "Where are Modrinth Hosting servers located? Can I choose a region?"
},
"hosting-marketing.faq.location.answer": {
"message": "We have servers available in North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support."
"message": "We have servers available across North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support."
},
"hosting-marketing.faq.versions-loaders": {
"message": "What Minecraft versions and loaders can be used?"
@@ -20,8 +20,13 @@ const PROJECT_TYPES = [
export default defineNuxtRouteMiddleware(async (to) => {
// Only run this middleware on the server - it relies on server-only runtime config
if (import.meta.client) return
const routeProjectParam = to.params.project
const projectId = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
const routeType = Array.isArray(to.params.type) ? to.params.type[0] : to.params.type
// Only handle project routes
if (!to.params.id || !PROJECT_TYPES.includes(to.params.type as string)) {
if (!projectId || !routeType || !PROJECT_TYPES.includes(routeType)) {
return
}
@@ -29,7 +34,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const tags = useGeneratedState()
const projectId = to.params.id as string
try {
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
@@ -485,7 +485,7 @@
v-if="!isServerProject"
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
(auth.user && currentMember) || route.name === 'type-project-version-version'
? `standard`
: `brand`
"
@@ -507,7 +507,7 @@
v-else
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
(auth.user && currentMember) || route.name === 'type-project-version-version'
? `standard`
: `brand`
"
@@ -529,7 +529,7 @@
size="large"
circular
:color="
route.name === 'type-id-version-version' || (auth.user && currentMember)
route.name === 'type-project-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
@@ -547,7 +547,7 @@
size="large"
circular
:color="
route.name === 'type-id-version-version' || (auth.user && currentMember)
route.name === 'type-project-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
@@ -1136,7 +1136,7 @@ import { useModerationQueue } from '~/services/moderation-queue.ts'
import { getReportPath, reportProject } from '~/utils/report-helpers.ts'
definePageMeta({
key: (route) => `${route.params.id}`,
key: (route) => `${route.params.project}`,
})
const data = useNuxtApp()
@@ -1151,6 +1151,9 @@ const { addNotification } = notifications
const auth = await useAuth()
const user = await useUser()
// Route param for initial lookup (middleware caches by both slug and ID)
const routeProjectId = ref(useRouteId('project'))
const { createProjectDownloadUrl } = useCdnDownloadContext()
const downloadReason = ref('standalone')
@@ -1637,7 +1640,7 @@ const collections = computed(() =>
)
if (
!route.params.id ||
!routeProjectId.value ||
!(
tags.value.projectTypes.find((x) => x.id === route.params.type) ||
route.params.type === 'project'
@@ -1650,9 +1653,6 @@ if (
})
}
// Route param for initial lookup (middleware caches by both slug and ID)
const routeProjectId = computed(() => route.params.id)
// Use DI client for TanStack Query
const client = injectModrinthClient()
const queryClient = useQueryClient()
@@ -1870,7 +1870,7 @@ const { data: organizationRaw } = useQuery({
// Return null when the project no longer belongs to an organization.
const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null))
const isSettings = computed(() => route.name.startsWith('type-id-settings'))
const isSettings = computed(() => route.name.startsWith('type-project-settings'))
// Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages
const versionsRaw = computed(() => {
@@ -1911,27 +1911,6 @@ function loadDependencies() {
// This allows showing/hiding UI elements without loading full version data
const hasVersions = computed(() => (project.value?.versions?.length ?? 0) > 0)
async function updateProjectRoute() {
if (
project.value &&
route.params.id !== project.value.slug &&
!flags.value.disablePrettyProjectUrlRedirects
) {
await navigateTo(
{
name: route.name,
params: {
...route.params,
id: project.value.slug,
},
query: route.query,
hash: route.hash,
},
{ replace: true },
)
}
}
async function invalidateProject() {
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', routeProjectId.value] })
@@ -1947,12 +1926,27 @@ async function invalidateProject() {
const patchProjectMutation = useMutation({
mutationFn: async ({ projectId, data }) => {
await client.labrinth.projects_v2.edit(projectId, data)
if (data.slug !== undefined && data.slug !== route.params.project) {
routeProjectId.value = data.slug
await navigateTo(
{
name: route.name,
params: {
type: route.params.type,
project: data.slug,
},
query: route.query,
hash: route.hash,
},
{ replace: true },
)
}
return data
},
onMutate: async ({ projectId, data }) => {
// Cancel outgoing refetches for both slug-based and ID-based cache keys
// The query may be keyed by slug (routeProjectId) but we also have the actual UUID (projectId)
// The query may be keyed by slug (routeProjectId.value) but we also have the actual UUID (projectId)
await queryClient.cancelQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
if (routeProjectId.value !== projectId) {
await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] })
@@ -2332,9 +2326,8 @@ watch(
const projectTypeDisplay = computed(() => {
if (!project.value) return ''
return formatProjectType(
data.$getProjectTypeForDisplay(project.value.project_type, project.value.loaders),
)
const projectType = isServerProject.value ? 'minecraft_java_server' : project.value.project_type
return formatProjectType(data.$getProjectTypeForDisplay(projectType, project.value.loaders))
})
const following = computed(() => {
@@ -2372,7 +2365,7 @@ useHead({
],
})
if (!route.name.startsWith('type-id-settings')) {
if (!route.name.startsWith('type-project-settings')) {
useSeoMeta({
title: () => title.value,
description: () => description.value,
@@ -2460,7 +2453,6 @@ async function patchProject(resData, quiet = false) {
{ projectId: project.value.id, data: resData },
{
onSuccess: async () => {
await updateProjectRoute()
if (!quiet) {
addNotification({
title: formatMessage(messages.projectUpdated),
@@ -360,7 +360,6 @@ const formatDateTimeShort = useFormatDateTime({
minute: 'numeric',
})
const route = useRoute()
const vintl = useVIntl()
const { formatMessage } = vintl
@@ -373,13 +372,15 @@ const messages = defineMessages({
},
})
const chargeId = useRouteId('charge')
const {
data: user,
error: userError,
suspense: userSuspense,
} = useQuery({
queryKey: ['user', route.params.id],
queryFn: () => labrinth.users_v2.get(route.params.id),
queryKey: ['user', chargeId],
queryFn: () => labrinth.users_v2.get(chargeId),
})
onServerPrefetch(userSuspense)
@@ -529,8 +529,8 @@ const returnLink = computed(() => {
return null
})
const collectionId = computed(() => route.params.id)
const isFollowingCollection = computed(() => collectionId.value === 'following')
const collectionId = useRouteId('collection')
const isFollowingCollection = computed(() => collectionId === 'following')
// Static collection for "following" page
const followingCollection = computed(() =>
@@ -555,15 +555,15 @@ const {
error: collectionError,
isPending: collectionIsPending,
} = useQuery({
queryKey: computed(() => ['collection', collectionId.value]),
queryFn: () => api.labrinth.collections.get(collectionId.value),
enabled: computed(() => !!collectionId.value && !isFollowingCollection.value),
queryKey: computed(() => ['collection', collectionId]),
queryFn: () => api.labrinth.collections.get(collectionId),
enabled: computed(() => !!collectionId && !isFollowingCollection.value),
})
watch(
collectionError,
(error) => {
if (error && collectionId.value && !isFollowingCollection.value) {
if (error && collectionId && !isFollowingCollection.value) {
const status = error.statusCode ?? error.status ?? 404
showError({
fatal: true,
@@ -1,7 +1,7 @@
<template>
<ReportView
:auth="auth"
:report-id="route.params.id"
:report-id="reportId"
:breadcrumbs-stack="[
{ href: '/dashboard/reports', label: formatMessage(messages.activeReportsTitle) },
]"
@@ -25,10 +25,10 @@ const messages = defineMessages({
},
})
const route = useNativeRoute()
const auth = await useAuth()
const reportId = useRouteId('report')
useHead({
title: () => `${formatMessage(messages.reportTitle, { id: route.params.id })} - Modrinth`,
title: () => `${formatMessage(messages.reportTitle, { id: reportId })} - Modrinth`,
})
</script>
+1 -1
View File
@@ -868,7 +868,7 @@ const messages = defineMessages({
faqLocationAnswer: {
id: 'hosting-marketing.faq.location.answer',
defaultMessage:
"We have servers available in North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support.",
"We have servers available across North America, Europe, and Southeast Asia at the moment that you can choose upon purchase. More regions to come in the future! If you'd like to switch your region, please contact support.",
},
faqIncreaseStorage: {
id: 'hosting-marketing.faq.increase-storage',
@@ -9,8 +9,6 @@
:site-url="config.public.siteUrl as string"
:products="products"
:auth-user="authUser"
:fetch-intercom-token="fetchIntercomToken"
:intercom-app-id="config.public.intercomAppId as string"
:navigate-to-billing="() => router.push('/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks="
@@ -77,12 +75,6 @@ const authUser = auth.value?.user
}
: undefined
async function fetchIntercomToken(): Promise<{ token: string }> {
return $fetch('/api/intercom/messenger-jwt', {
query: { server_id: serverId },
})
}
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
return {
userId: auth.value?.user?.id ?? null,
+7 -2
View File
@@ -102,7 +102,12 @@
<tr>
<td>Revenue earned on</td>
<td>
<StyledInput id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
<DatePicker
id="revenue-date-picker"
v-model="rawSelectedDate"
show-today
position="above"
/>
<noscript
>(JavaScript must be enabled for the date picker to function, example date:
2024-07-15)
@@ -162,7 +167,7 @@
</template>
<script lang="ts" setup>
import { injectModrinthClient, StyledInput, useFormatDateTime, useFormatMoney } from '@modrinth/ui'
import { DatePicker, injectModrinthClient, useFormatDateTime, useFormatMoney } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
+4 -4
View File
@@ -529,10 +529,10 @@ async function moderateAllInFilter() {
}
navigateTo({
name: 'type-id',
name: 'type-project',
params: {
type: 'project',
id: targetProject.project.slug,
project: targetProject.project.slug,
},
state: {
showChecklist: true,
@@ -566,10 +566,10 @@ async function startFromProject(projectId: string) {
}
navigateTo({
name: 'type-id',
name: 'type-project',
params: {
type: 'project',
id: targetProject.project.slug,
project: targetProject.project.slug,
},
state: {
showChecklist: true,
@@ -7,8 +7,7 @@ import ModerationReportCard from '~/components/ui/moderation/ModerationReportCar
import { enrichReportBatch } from '~/helpers/moderation.ts'
const client = injectModrinthClient()
const { params } = useRoute()
const reportId = params.id as string
const reportId = String(useRouteId('report'))
const { data: report } = useQuery({
queryKey: computed(() => ['report', reportId]),
@@ -9,13 +9,12 @@ import MaliciousSummaryModal, {
} from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
const route = useRoute()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const projectId = computed(() => route.params.id as string)
const projectId = String(useRouteId('project'))
useHead({ title: () => `Tech review - ${projectId.value} - Modrinth` })
useHead({ title: () => `Tech review - ${projectId} - Modrinth` })
const CACHE_TTL = 24 * 60 * 60 * 1000
const CACHE_KEY_PREFIX = 'tech_review_source_'
@@ -151,7 +150,7 @@ const {
} = useQuery({
queryKey: ['tech-review-project-report', projectId],
queryFn: async () => {
return await client.labrinth.tech_review_internal.getProjectReport(projectId.value)
return await client.labrinth.tech_review_internal.getProjectReport(projectId)
},
retry: false,
})
@@ -163,7 +162,7 @@ const {
} = useQuery({
queryKey: ['project', projectId],
queryFn: async () => {
return await client.labrinth.projects_v3.get(projectId.value)
return await client.labrinth.projects_v3.get(projectId)
},
retry: false,
})
@@ -171,14 +170,14 @@ const {
const { data: organizationData, isLoading: isLoadingOrg } = useQuery({
queryKey: ['project-organization', projectId],
queryFn: async () => {
return await client.labrinth.projects_v3.getOrganization(projectId.value)
return await client.labrinth.projects_v3.getOrganization(projectId)
},
})
const { data: membersData, isLoading: isLoadingMembers } = useQuery({
queryKey: ['project-members', projectId],
queryFn: async () => {
return await client.labrinth.projects_v3.getMembers(projectId.value)
return await client.labrinth.projects_v3.getMembers(projectId)
},
enabled: computed(() => !organizationData.value && !isLoadingOrg.value),
})
@@ -265,7 +264,7 @@ function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
}
function refetch() {
queryClient.invalidateQueries({ queryKey: ['tech-review-project-report', projectId.value] })
queryClient.invalidateQueries({ queryKey: ['tech-review-project-report', projectId] })
}
</script>
@@ -348,7 +348,7 @@ const tags = useGeneratedState()
const config = useRuntimeConfig()
const modal_creation = useTemplateRef('modal_creation')
const orgId = useRouteId()
const orgId = useRouteId('organization')
if (route.path.includes('settings')) {
useSeoMeta({
@@ -85,7 +85,7 @@ const showPreviewImage = (files) => {
}
}
const orgId = useRouteId()
const orgId = useRouteId('orgId')
const save = async () => {
// Save field changes via useSavable
+13 -13
View File
@@ -16,10 +16,10 @@
"
:width="'550px'"
>
<div class="flex flex-col gap-4">
<div class="flex w-full flex-col">
<div class="flex flex-col gap-6">
<div class="flex w-full flex-col gap-2.5">
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
<span class="font-semibold">{{ formatMessage(createModalMessages.nameLabel) }}</span>
</label>
<StyledInput
id="pat-name"
@@ -29,20 +29,20 @@
/>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col gap-2.5">
<label for="pat-scopes">
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
<span class="font-semibold">{{ formatMessage(commonMessages.scopesLabel) }}</span>
</label>
<div
id="pat-scopes"
class="scope-items mt-2 grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
class="scope-items grid grid-cols-1 gap-x-6 gap-y-4 min-[600px]:grid-cols-2"
>
<div
v-for="category in scopeCategories"
:key="category.name"
class="flex flex-col gap-2"
class="flex flex-col gap-1.5"
>
<h4 class="m-0 border-b border-divider pb-1 text-base font-bold text-contrast">
<h4 class="m-0 border-b border-divider text-base font-medium text-primary">
{{ category.name }}
</h4>
<div class="flex flex-col gap-2">
@@ -58,15 +58,14 @@
</div>
</div>
<div class="flex w-full flex-col">
<div class="flex w-full flex-col gap-2.5">
<label for="pat-expires">
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
<span class="font-semibold">{{ formatMessage(createModalMessages.expiresLabel) }}</span>
</label>
<StyledInput id="pat-expires" v-model="expires" type="date" />
<p></p>
<DatePicker id="pat-expires" v-model="expires" show-today wrapper-class="w-full" />
</div>
<div class="ml-auto flex gap-2">
<div class="ml-auto mt-4 flex gap-2">
<ButtonStyled type="outlined">
<button @click="$refs.patModal.hide()">
<XIcon />
@@ -207,6 +206,7 @@ import {
commonSettingsMessages,
ConfirmModal,
CopyCode,
DatePicker,
defineMessages,
injectModrinthClient,
injectNotificationManager,
@@ -685,13 +685,15 @@ const messages = defineMessages({
const client = injectModrinthClient()
const userId = useRouteId('user')
const {
data: user,
error: userError,
suspense: userSuspense,
} = useQuery({
queryKey: computed(() => ['user', route.params.id]),
queryFn: () => client.labrinth.users_v2.get(route.params.id),
queryKey: computed(() => ['user', userId]),
queryFn: () => client.labrinth.users_v2.get(userId),
})
watch(
@@ -710,9 +712,9 @@ watch(
)
const { data: projects, suspense: projectsSuspense } = useQuery({
queryKey: computed(() => ['user', route.params.id, 'projects']),
queryKey: computed(() => ['user', userId, 'projects']),
queryFn: async () => {
const projects = await client.labrinth.users_v2.getProjects(route.params.id)
const projects = await client.labrinth.users_v2.getProjects(userId)
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
project.project_type = data.$getProjectTypeForUrl(
@@ -726,13 +728,13 @@ const { data: projects, suspense: projectsSuspense } = useQuery({
})
const { data: organizations, suspense: orgsSuspense } = useQuery({
queryKey: computed(() => ['user', route.params.id, 'organizations']),
queryFn: () => client.labrinth.users_v2.getOrganizations(route.params.id),
queryKey: computed(() => ['user', userId, 'organizations']),
queryFn: () => client.labrinth.users_v2.getOrganizations(userId),
})
const { data: collections, suspense: collectionsSuspense } = useQuery({
queryKey: computed(() => ['user', route.params.id, 'collections']),
queryFn: () => client.labrinth.users_v2.getCollections(route.params.id),
queryKey: computed(() => ['user', userId, 'collections']),
queryFn: () => client.labrinth.users_v2.getCollections(userId),
})
onServerPrefetch(async () => {
@@ -865,7 +867,7 @@ const isAdminViewing = computed(() => isAdmin(auth.value.user))
async function toggleAffiliate(id) {
await client.labrinth.users_v2.patch(id, { badges: user.value.badges ^ (1 << 7) })
queryClient.invalidateQueries({ queryKey: ['user', route.params.id] })
queryClient.invalidateQueries({ queryKey: ['user', userId] })
}
const navLinks = computed(() => [
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n external_dependencies_count as \"external_dependencies_count!\"\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued,\n (\n SELECT COUNT(*)\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE v.mod_id = m.id\n AND d.dependency_file_name IS NOT NULL\n ) external_dependencies_count\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n WHERE\n ($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "external_dependencies_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8",
"Bool"
]
},
"nullable": [
false,
null
]
},
"hash": "119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tUPDATE analytics_events\n\t\t\tSET meta = $2, starts = $3, ends = $4\n\t\t\tWHERE id = $1\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Jsonb",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "2e6ffe58fef1368fdd1377534b27ec367b211113464c6c79a3c9e226ae3fdb79"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO analytics_events (id, meta, starts, ends)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Jsonb",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "67dbb27773b9d5dc77ee7ea9ce8b0509d3a6b4cc6c4b194901d1690203020977"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM analytics_events WHERE id=$1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "94cec16e1be48761fe78a87fcead205bb294479eaae95fd1e9cb00b0975ca297"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tDELETE FROM analytics_events\n\t\t\tWHERE id = $1\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "a53a7681f3e8ab918d225d16690b20d5856c1747c651a52e7d94d8a2ee23dc08"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT id, meta AS \"meta: Json<AnalyticsEventMeta>\", starts, ends\n\t\t\tFROM analytics_events\n\t\t\tORDER BY starts DESC\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "meta: Json<AnalyticsEventMeta>",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "starts",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "ends",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "c7817c07dc91b562dfabc3b8c4bade55b8c920df7d98adde36c9e5d1cf5801f1"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, mod_id\n FROM versions\n WHERE mod_id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "mod_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
false
]
},
"hash": "e425e8fce0571388d90cbe861223e47d858deca66530ffdba4d0eac3aeac1433"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "ec1f08768071d55613b0b69b3eac43e1e5d0a532171b5de7b9086ae7376f1482"
}
+7 -1
View File
@@ -1,5 +1,11 @@
- Use `ApiError` as the error type for API routes
- Prefer `ApiError::Internal` and `ApiError::Request` over `ApiError::InvalidInput`
- The return type of an HTTP route should not be `HttpResponse` if possible; always prefer more specific types
- Use `web::Json<T>` for JSON-encoded response
- Use `()` for no content
- Prefer `ApiError` variants:
- `ApiError::Request` instead of `ApiError::InvalidInput`
- `ApiError::Auth` instead of `ApiError::CustomAuthentication`
- `ApiError::Internal` for database errors, 3rd party service errors, anything else internal
- Use `eyre!` to construct a value for `Internal` and `Request` variants
- Error messages (both for errors and exceptions) must be formatted as per the Rust API guidelines:
- lowercase message
@@ -0,0 +1,19 @@
-- Dummy ClickHouse download rows for exercising v3 analytics download-source normalization.
-- Run this against the analytics ClickHouse database, for example:
-- curl -u default:default 'http://localhost:8123/?database=staging_ariadne' --data-binary @apps/labrinth/fixtures/analytics-changes-clickhouse.sql
--
-- Project 910000000000003 = 4AP3jpvKl
INSERT INTO downloads FORMAT JSONEachRow
{"recorded":"2026-05-13 00:05:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"modrinth/kyros/1.0.0 (support@modrinth.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:10:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"modrinth/theseus/0.8.6 (support@modrinth.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:15:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Gradle/8.8","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:20:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"MultiMC/5.0","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:25:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PrismLauncher/6.1","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:30:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PolyMC/7.0","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:35:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) FeatherLauncher/2.6.12-c Chrome/144.0.7559.236 Electron/40.9.1 Safari/537.36 (hello@feathermc.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:40:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"FeatherMC/Feather Client Rust Launcher/1.0.0 (hello@feathermc.com)","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:45:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.134 Feather/9b6bb39d Safari/537.36","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:50:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"PandoraLauncher/1.2.3","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:55:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"Mozilla/5.0 AppleWebKit/605.1.15","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
{"recorded":"2026-05-13 00:59:00.0000","domain":"cdn.modrinth.com","site_path":"/data/analytics-fixture.jar","user_id":0,"project_id":910000000000003,"version_id":0,"ip":"::1","country":"US","user_agent":"curl/8.7.1","headers":[],"reason":"primary","game_version":"1.20.1","loader":"fabric"}
@@ -0,0 +1,97 @@
-- Dummy analytics data for exercising v3 analytics events and project download analytics.
-- IDs are listed as integers, followed by their equivalent base62 representation.
-- User 103587649610509 = 1XZwx9qL
INSERT INTO users (
id, username, email, role, badges, balance, email_verified
)
VALUES (
103587649610509, 'Analytics Admin', 'analytics-admin@modrinth.com',
'admin', 0, 0, TRUE
)
ON CONFLICT (id) DO UPDATE SET
role = EXCLUDED.role;
INSERT INTO sessions (
id, session, user_id, expires, refresh_expires, ip, user_agent
)
VALUES (
103587649610510, 'mra_analytics_admin', 103587649610509,
'2030-01-01T00:00:00Z', '2030-01-01T00:00:00Z',
'127.0.0.1', 'analytics fixture'
)
ON CONFLICT (session) DO UPDATE SET
user_id = EXCLUDED.user_id,
expires = EXCLUDED.expires,
refresh_expires = EXCLUDED.refresh_expires;
-- Project 910000000000003 = 4AP3jpvKl
-- Team 910000000000001 = 4AP3jpvKj
-- Thread 910000000000004 = 4AP3jpvKm
INSERT INTO teams (id)
VALUES (910000000000001)
ON CONFLICT (id) DO NOTHING;
INSERT INTO team_members (
id, team_id, user_id, role, permissions, accepted, payouts_split, ordering,
organization_permissions, is_owner
)
VALUES (
910000000000002, 910000000000001, 103587649610509, 'Owner',
1023, TRUE, 100, 0, NULL, TRUE
)
ON CONFLICT (id) DO UPDATE SET
permissions = EXCLUDED.permissions,
accepted = EXCLUDED.accepted,
is_owner = EXCLUDED.is_owner;
INSERT INTO mods (
id, team_id, name, summary, downloads, slug, description, follows,
license, status, requested_status, monetization_status,
side_types_migration_review_status, components
)
VALUES (
910000000000003, 910000000000001, 'Analytics Fixture Project',
'Project used by analytics fixture data.', 0, 'analytics-fixture-project',
'', 0, 'LicenseRef-All-Rights-Reserved', 'approved', 'approved',
'monetized', 'reviewed', '{}'::jsonb
)
ON CONFLICT (id) DO UPDATE SET
team_id = EXCLUDED.team_id,
status = EXCLUDED.status,
requested_status = EXCLUDED.requested_status,
monetization_status = EXCLUDED.monetization_status;
INSERT INTO threads (id, thread_type, mod_id)
VALUES (910000000000004, 'project', 910000000000003)
ON CONFLICT (id) DO NOTHING;
-- Analytics events used to test /v3/analytics-event and Redis caching.
-- Event 910000000000101 = 4AP3jpvMR
-- Event 910000000000102 = 4AP3jpvMS
INSERT INTO analytics_events (id, meta, starts, ends)
VALUES
(
910000000000101,
'{
"title": "Downloads launch",
"announcement_url": "https://modrinth.com/news/downloads-launch",
"for_metric_kind": ["downloads"]
}'::jsonb,
'2026-05-13T00:00:00Z',
'2026-05-14T00:00:00Z'
),
(
910000000000102,
'{
"title": "Revenue promo",
"announcement_url": "https://modrinth.com/news/revenue-promo",
"for_metric_kind": ["revenue"]
}'::jsonb,
'2026-05-14T00:00:00Z',
'2026-05-15T00:00:00Z'
)
ON CONFLICT (id) DO UPDATE SET
meta = EXCLUDED.meta,
starts = EXCLUDED.starts,
ends = EXCLUDED.ends;
@@ -0,0 +1,6 @@
create table analytics_events (
id bigint primary key,
meta jsonb not null,
starts timestamptz not null,
ends timestamptz not null
);
@@ -0,0 +1,140 @@
use chrono::{DateTime, Utc};
use futures::{StreamExt, TryStreamExt};
use sqlx::types::Json;
use crate::{
database::{
models::{DBAnalyticsEventId, DatabaseError},
redis::RedisPool,
},
models::v3::analytics_event::AnalyticsEventMeta,
};
use serde::{Deserialize, Serialize};
const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events";
const ANALYTICS_EVENTS_ALL_KEY: &str = "all";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBAnalyticsEvent {
pub id: DBAnalyticsEventId,
pub meta: AnalyticsEventMeta,
pub starts: DateTime<Utc>,
pub ends: DateTime<Utc>,
}
impl DBAnalyticsEvent {
pub async fn insert(
&self,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<(), DatabaseError> {
sqlx::query!(
"
INSERT INTO analytics_events (id, meta, starts, ends)
VALUES ($1, $2, $3, $4)
",
self.id as DBAnalyticsEventId,
sqlx::types::Json(&self.meta) as Json<&AnalyticsEventMeta>,
self.starts,
self.ends,
)
.execute(exec)
.await?;
Ok(())
}
pub async fn update(
&self,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
"
UPDATE analytics_events
SET meta = $2, starts = $3, ends = $4
WHERE id = $1
",
self.id as DBAnalyticsEventId,
sqlx::types::Json(&self.meta) as Json<&AnalyticsEventMeta>,
self.starts,
self.ends,
)
.execute(exec)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn remove(
id: DBAnalyticsEventId,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
"
DELETE FROM analytics_events
WHERE id = $1
",
id as DBAnalyticsEventId,
)
.execute(exec)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn get_all(
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
redis: &RedisPool,
) -> Result<Vec<DBAnalyticsEvent>, DatabaseError> {
let mut redis = redis.connect().await?;
if let Some(events) = redis
.get_deserialized_from_json(
ANALYTICS_EVENTS_NAMESPACE,
ANALYTICS_EVENTS_ALL_KEY,
)
.await?
{
return Ok(events);
}
let events = sqlx::query!(
r#"
SELECT id, meta AS "meta: Json<AnalyticsEventMeta>", starts, ends
FROM analytics_events
ORDER BY starts DESC
"#
)
.fetch(exec)
.map(|record| {
let record = record?;
Ok::<_, DatabaseError>(DBAnalyticsEvent {
id: DBAnalyticsEventId(record.id),
meta: record.meta.0,
starts: record.starts,
ends: record.ends,
})
})
.try_collect::<Vec<_>>()
.await?;
redis
.set_serialized_to_json(
ANALYTICS_EVENTS_NAMESPACE,
ANALYTICS_EVENTS_ALL_KEY,
&events,
None,
)
.await?;
Ok(events)
}
pub async fn clear_cache(redis: &RedisPool) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
redis
.delete(ANALYTICS_EVENTS_NAMESPACE, ANALYTICS_EVENTS_ALL_KEY)
.await?;
Ok(())
}
}
+10 -6
View File
@@ -1,12 +1,12 @@
use super::DatabaseError;
use crate::database::PgTransaction;
use crate::models::ids::{
AffiliateCodeId, ChargeId, CollectionId, FileId, ImageId, NotificationId,
OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
UserSubscriptionId, VersionId,
AffiliateCodeId, AnalyticsEventId, ChargeId, CollectionId, FileId, ImageId,
NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId,
OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId,
ProductId, ProductPriceId, ProjectId, ReportId, SessionId,
SharedInstanceId, SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId,
ThreadMessageId, UserSubscriptionId, VersionId,
};
use ariadne::ids::base62_impl::to_base62;
use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range};
@@ -269,6 +269,10 @@ db_id_interface!(
AffiliateCodeId,
generator: generate_affiliate_code_id @ "affiliate_codes",
);
db_id_interface!(
AnalyticsEventId,
generator: generate_analytics_event_id @ "analytics_events",
);
id_type!(CategoryId as i32);
id_type!(GameId as i32);
+2
View File
@@ -1,6 +1,7 @@
use thiserror::Error;
pub mod affiliate_code_item;
pub mod analytics_event_item;
pub mod categories;
pub mod charge_item;
pub mod collection_item;
@@ -44,6 +45,7 @@ pub mod users_subscriptions_credits;
pub mod version_item;
pub use affiliate_code_item::DBAffiliateCode;
pub use analytics_event_item::DBAnalyticsEvent;
pub use collection_item::DBCollection;
pub use ids::*;
pub use image_item::DBImage;
@@ -0,0 +1,55 @@
use std::collections::HashSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::models::ids::AnalyticsEventId;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AnalyticsEvent {
pub id: AnalyticsEventId,
#[serde(flatten)]
pub meta: AnalyticsEventMeta,
pub starts: DateTime<Utc>,
pub ends: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AnalyticsEventMeta {
#[serde(default)]
pub title: String,
#[serde(default)]
pub announcement_url: Option<String>,
#[serde(default)]
pub for_metric_kind: HashSet<MetricKind>,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum MetricKind {
Views,
Downloads,
Playtime,
Revenue,
}
impl From<crate::database::models::DBAnalyticsEvent> for AnalyticsEvent {
fn from(data: crate::database::models::DBAnalyticsEvent) -> Self {
Self {
id: data.id.into(),
meta: data.meta,
starts: data.starts,
ends: data.ends,
}
}
}
+1
View File
@@ -26,3 +26,4 @@ base62_id!(ThreadMessageId);
base62_id!(UserSubscriptionId);
base62_id!(VersionId);
base62_id!(AffiliateCodeId);
base62_id!(AnalyticsEventId);
+1
View File
@@ -1,5 +1,6 @@
pub mod affiliate_code;
pub mod analytics;
pub mod analytics_event;
pub mod billing;
pub mod collections;
pub mod ids;
+12 -1
View File
@@ -1117,7 +1117,14 @@ pub async fn init(
}
let user_id = if let Some(token) = info.token {
let (_, user) = get_user_record_from_bearer_token(
// Linking a new auth provider changes how the account can be accessed,
// so only first-party session tokens may authorize this flow. OAuth and
// PAT tokens can be delegated or stored outside an interactive login.
if !token.starts_with("mra_") {
return Err(AuthenticationError::InvalidCredentials);
}
let (scopes, user) = get_user_record_from_bearer_token(
&req,
Some(&token),
&**client,
@@ -1128,6 +1135,10 @@ pub async fn init(
.await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
if !scopes.contains(Scopes::USER_AUTH_WRITE) {
return Err(AuthenticationError::InvalidCredentials);
}
Some(user.id)
} else {
None
@@ -53,6 +53,9 @@ pub struct ProjectsRequestOptions {
/// How many projects to skip.
#[serde(default)]
pub offset: u32,
/// Whether to filter by modpacks that have external dependencies.
#[serde(default)]
pub has_external_dependencies: Option<bool>,
}
fn default_count() -> u16 {
@@ -68,6 +71,8 @@ pub struct FetchedProject {
pub project: Project,
/// Who owns the project.
pub ownership: Ownership,
/// How many external file dependencies the project has.
pub external_dependencies_count: i64,
}
/// Fetched information on who owns a project.
@@ -190,13 +195,22 @@ pub async fn get_projects_internal(
use futures::stream::TryStreamExt;
let project_ids = sqlx::query!(
"
SELECT id
let project_rows = sqlx::query!(
r#"
SELECT
id,
external_dependencies_count as "external_dependencies_count!"
FROM (
SELECT DISTINCT ON (m.id)
m.id,
m.queued
m.queued,
(
SELECT COUNT(*)
FROM versions v
INNER JOIN dependencies d ON d.dependent_id = v.id
WHERE v.mod_id = m.id
AND d.dependency_file_name IS NOT NULL
) external_dependencies_count
FROM mods m
/* -- Temporarily, don't exclude projects in tech rev q
@@ -212,20 +226,36 @@ pub async fn get_projects_internal(
GROUP BY m.id
) t
WHERE
($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)
ORDER BY queued ASC
OFFSET $3
LIMIT $2
",
"#,
ProjectStatus::Processing.as_str(),
request_opts.count as i64,
request_opts.offset as i64
request_opts.offset as i64,
request_opts.has_external_dependencies,
)
.fetch(&**pool)
.map_ok(|m| database::models::DBProjectId(m.id))
.try_collect::<Vec<database::models::DBProjectId>>()
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("failed to fetch projects awaiting review")?;
let project_ids = project_rows
.iter()
.map(|m| database::models::DBProjectId(m.id))
.collect::<Vec<_>>();
let project_metadata = project_rows
.into_iter()
.map(|m| {
(
database::models::DBProjectId(m.id),
m.external_dependencies_count,
)
})
.collect::<HashMap<_, _>>();
let projects =
database::DBProject::get_many_ids(&project_ids, &**pool, &redis)
.await
@@ -240,7 +270,16 @@ pub async fn get_projects_internal(
let map_project =
|(project, ownership): (Project, Ownership)| -> FetchedProject {
FetchedProject { ownership, project }
let external_dependencies_count = project_metadata
.get(&database::models::DBProjectId(project.id.0 as i64))
.copied()
.unwrap_or_default();
FetchedProject {
ownership,
project,
external_dependencies_count,
}
};
let projects = projects
@@ -61,6 +61,7 @@ pub async fn get_projects(
web::Query(internal::moderation::ProjectsRequestOptions {
count: count.count,
offset: 0,
has_external_dependencies: None,
}),
session_queue,
)
@@ -0,0 +1,201 @@
use actix_web::{HttpRequest, delete, get, patch, post, web};
use chrono::{DateTime, Utc};
use eyre::eyre;
use serde::{Deserialize, Serialize};
use crate::{
auth::get_user_from_headers,
database::{
PgPool,
models::{
DBAnalyticsEvent, DBAnalyticsEventId, generate_analytics_event_id,
},
redis::RedisPool,
},
models::{
ids::AnalyticsEventId,
pats::Scopes,
v3::analytics_event::{AnalyticsEvent, AnalyticsEventMeta},
},
queue::session::AuthQueue,
routes::ApiError,
util::error::Context,
};
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
cfg.service(analytics_events_get)
.service(analytics_event_create)
.service(analytics_event_edit)
.service(analytics_event_delete);
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AnalyticsEventUpsert {
#[serde(flatten)]
pub meta: AnalyticsEventMeta,
pub starts: DateTime<Utc>,
pub ends: DateTime<Utc>,
}
/// Fetches all analytics events.
#[utoipa::path(responses((status = OK, body = Vec<AnalyticsEvent>)))]
#[get("")]
pub async fn analytics_events_get(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
) -> Result<web::Json<Vec<AnalyticsEvent>>, ApiError> {
let events = DBAnalyticsEvent::get_all(&**pool, &redis)
.await
.wrap_internal_err("failed to fetch analytics events")?
.into_iter()
.map(AnalyticsEvent::from)
.collect();
Ok(web::Json(events))
}
/// Creates an analytics event.
#[utoipa::path(responses((status = OK, body = AnalyticsEvent)))]
#[post("")]
pub async fn analytics_event_create(
req: HttpRequest,
event: web::Json<AnalyticsEventUpsert>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::Auth(eyre!(
"you do not have permission to manage analytics events"
)));
}
let mut transaction = pool
.begin()
.await
.wrap_internal_err("failed to begin transaction")?;
let id = generate_analytics_event_id(&mut transaction)
.await
.wrap_internal_err("failed to generate analytics event ID")?;
let event = DBAnalyticsEvent {
id,
meta: event.meta.clone(),
starts: event.starts,
ends: event.ends,
};
event
.insert(&mut transaction)
.await
.wrap_internal_err("failed to insert analytics event")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit transaction")?;
DBAnalyticsEvent::clear_cache(&redis)
.await
.wrap_internal_err("failed to clear analytics event cache")?;
Ok(web::Json(event.into()))
}
/// Edits an analytics event.
#[utoipa::path(responses((status = OK, body = AnalyticsEvent)))]
#[patch("/{id}")]
pub async fn analytics_event_edit(
req: HttpRequest,
id: web::Path<(AnalyticsEventId,)>,
event: web::Json<AnalyticsEventUpsert>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<AnalyticsEvent>, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::Auth(eyre!(
"you do not have permission to manage analytics events"
)));
}
let event = DBAnalyticsEvent {
id: DBAnalyticsEventId::from(id.into_inner().0),
meta: event.meta.clone(),
starts: event.starts,
ends: event.ends,
};
let updated = event
.update(&**pool)
.await
.wrap_internal_err("failed to update analytics event")?;
if !updated {
return Err(ApiError::NotFound);
}
DBAnalyticsEvent::clear_cache(&redis)
.await
.wrap_internal_err("failed to clear analytics event cache")?;
Ok(web::Json(event.into()))
}
/// Deletes an analytics event.
#[utoipa::path(responses((status = NO_CONTENT)))]
#[delete("/{id}")]
pub async fn analytics_event_delete(
req: HttpRequest,
id: web::Path<(AnalyticsEventId,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<(), ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::empty(),
)
.await?
.1;
if !user.role.is_admin() {
return Err(ApiError::Auth(eyre!(
"you do not have permission to manage analytics events"
)));
}
let deleted = DBAnalyticsEvent::remove(
DBAnalyticsEventId::from(id.into_inner().0),
&**pool,
)
.await
.wrap_internal_err("failed to delete analytics event")?;
if !deleted {
return Err(ApiError::NotFound);
}
DBAnalyticsEvent::clear_cache(&redis)
.await
.wrap_internal_err("failed to clear analytics event cache")?;
Ok(())
}
+247 -28
View File
@@ -9,15 +9,16 @@
mod old;
use std::num::NonZeroU64;
use std::{num::NonZeroU64, sync::LazyLock};
use crate::database::PgPool;
use actix_web::{HttpRequest, post, web};
use chrono::{DateTime, TimeDelta, Utc};
use eyre::eyre;
use futures::StreamExt;
use regex::Regex;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use crate::{
auth::{AuthenticationError, get_user_from_headers},
@@ -167,8 +168,8 @@ pub enum ProjectDownloadsField {
VersionId,
/// Referrer domain which linked to this project.
Domain,
/// Modrinth site path which was visited, e.g. `/mod/foo`.
SitePath,
/// Normalized user agent used to download this project.
UserAgent,
/// Whether these downloads were monetized or not.
Monetized,
/// What country these downloads came from.
@@ -325,9 +326,9 @@ pub struct ProjectDownloads {
/// [`ProjectDownloadsField::Domain`].
#[serde(skip_serializing_if = "Option::is_none")]
domain: Option<String>,
/// [`ProjectDownloadsField::SitePath`].
/// [`ProjectDownloadsField::UserAgent`].
#[serde(skip_serializing_if = "Option::is_none")]
site_path: Option<String>,
user_agent: Option<DownloadSource>,
/// [`ProjectDownloadsField::VersionId`].
#[serde(skip_serializing_if = "Option::is_none")]
version_id: Option<VersionId>,
@@ -350,6 +351,56 @@ pub struct ProjectDownloads {
downloads: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, utoipa::ToSchema)]
pub enum DownloadSource {
Website,
ModrinthApp,
ModrinthHosting,
ModrinthMaven,
Other,
Named(String),
}
impl Serialize for DownloadSource {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Self::Named(name) => serializer.serialize_str(name),
Self::Website => serializer.serialize_str("website"),
Self::ModrinthApp => serializer.serialize_str("modrinth_app"),
Self::ModrinthHosting => {
serializer.serialize_str("modrinth_hosting")
}
Self::ModrinthMaven => serializer.serialize_str("modrinth_maven"),
Self::Other => serializer.serialize_str("other"),
}
}
}
impl<'de> Deserialize<'de> for DownloadSource {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let source = String::deserialize(deserializer)?;
Ok(match source.as_str() {
"website" => Self::Website,
"modrinth_app" => Self::ModrinthApp,
"modrinth_hosting" => Self::ModrinthHosting,
"modrinth_maven" => Self::ModrinthMaven,
"other" => Self::Other,
_ if !source.is_empty() => Self::Named(source),
_ => {
return Err(D::Error::custom(
"download source cannot be empty",
));
}
})
}
}
/// [`ReturnMetrics::project_playtime`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectPlaytime {
@@ -476,7 +527,7 @@ mod query {
pub bucket: u64,
pub project_id: DBProjectId,
pub domain: String,
pub site_path: String,
pub user_agent: String,
pub version_id: DBVersionId,
pub monetized: i8,
pub country: String,
@@ -489,7 +540,7 @@ mod query {
pub const DOWNLOADS: &str = {
const USE_PROJECT_ID: &str = "{use_project_id: Bool}";
const USE_DOMAIN: &str = "{use_domain: Bool}";
const USE_SITE_PATH: &str = "{use_site_path: Bool}";
const USE_USER_AGENT: &str = "{use_user_agent: Bool}";
const USE_VERSION_ID: &str = "{use_version_id: Bool}";
const USE_MONETIZED: &str = "{use_monetized: Bool}";
const USE_COUNTRY: &str = "{use_country: Bool}";
@@ -502,7 +553,7 @@ mod query {
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
if({USE_DOMAIN}, domain, '') AS domain,
if({USE_SITE_PATH}, site_path, '') AS site_path,
if({USE_USER_AGENT}, user_agent, '') AS user_agent,
if({USE_VERSION_ID}, version_id, 0) AS version_id,
if({USE_MONETIZED}, CAST(user_id != 0 AS Int8), -1) AS monetized,
if({USE_COUNTRY}, country, '') AS country,
@@ -517,7 +568,7 @@ mod query {
-- not the possibly-zero one,
-- by using `downloads.project_id` instead of `project_id`
AND downloads.project_id IN {PROJECT_IDS}
GROUP BY bucket, project_id, domain, site_path, version_id, monetized, country, reason, game_version, loader"
GROUP BY bucket, project_id, domain, user_agent, version_id, monetized, country, reason, game_version, loader"
)
};
@@ -538,23 +589,48 @@ mod query {
const USE_LOADER: &str = "{use_loader: Bool}";
const USE_GAME_VERSION: &str = "{use_game_version: Bool}";
const USE_COUNTRY: &str = "{use_country: Bool}";
const PARENT_VERSION_IDS: &str = "{parent_version_ids: Array(UInt64)}";
const PARENT_VERSION_PROJECT_IDS: &str =
"{parent_version_project_ids: Array(UInt64)}";
formatcp!(
"SELECT
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
if({USE_PROJECT_ID}, project_id, 0) AS project_id,
if({USE_VERSION_ID}, version_id, 0) AS version_id,
if({USE_LOADER}, loader, '') AS loader,
if({USE_GAME_VERSION}, game_version, '') AS game_version,
if({USE_COUNTRY}, country, '') AS country,
bucket,
if({USE_PROJECT_ID}, source_project_id, 0) AS project_id,
version_id,
loader,
game_version,
country,
SUM(seconds) AS seconds
FROM playtime
WHERE
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
-- make sure that the REAL project id is included,
-- not the possibly-zero one,
-- by using `playtime.project_id` instead of `project_id`
AND playtime.project_id IN {PROJECT_IDS}
FROM (
SELECT
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
project_id AS source_project_id,
if({USE_VERSION_ID}, version_id, 0) AS version_id,
if({USE_LOADER}, loader, '') AS loader,
if({USE_GAME_VERSION}, game_version, '') AS game_version,
if({USE_COUNTRY}, country, '') AS country,
seconds
FROM playtime
WHERE
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
AND playtime.project_id IN {PROJECT_IDS}
UNION ALL
SELECT
widthBucket(toUnixTimestamp(recorded), {TIME_RANGE_START}, {TIME_RANGE_END}, {TIME_SLICES}) AS bucket,
transform(parent, {PARENT_VERSION_IDS}, {PARENT_VERSION_PROJECT_IDS}) AS source_project_id,
if({USE_VERSION_ID}, version_id, 0) AS version_id,
if({USE_LOADER}, loader, '') AS loader,
if({USE_GAME_VERSION}, game_version, '') AS game_version,
if({USE_COUNTRY}, country, '') AS country,
seconds
FROM playtime
WHERE
recorded BETWEEN {TIME_RANGE_START} AND {TIME_RANGE_END}
AND parent IN {PARENT_VERSION_IDS}
)
GROUP BY bucket, project_id, version_id, loader, game_version, country"
)
};
@@ -672,6 +748,27 @@ pub async fn fetch_analytics(
let project_ids =
filter_allowed_project_ids(&project_ids, &user, &pool, &redis).await?;
let project_id_values =
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
let parent_versions = sqlx::query!(
"
SELECT id, mod_id
FROM versions
WHERE mod_id = ANY($1)
",
&project_id_values,
)
.fetch_all(&**pool)
.await?;
let parent_version_ids = parent_versions
.iter()
.map(|version| DBVersionId(version.id))
.collect::<Vec<_>>();
let parent_version_project_ids = parent_versions
.iter()
.map(|version| DBProjectId(version.mod_id))
.collect::<Vec<_>>();
let affiliate_code_ids =
DBAffiliateCode::get_by_affiliate(user.id.into(), &**pool)
.await?
@@ -684,6 +781,8 @@ pub async fn fetch_analytics(
req: &req,
time_slices: &mut time_slices,
project_ids: &project_ids,
parent_version_ids: &parent_version_ids,
parent_version_project_ids: &parent_version_project_ids,
affiliate_code_ids: &affiliate_code_ids,
};
@@ -737,7 +836,7 @@ pub async fn fetch_analytics(
&[
("use_project_id", uses(F::ProjectId)),
("use_domain", uses(F::Domain)),
("use_site_path", uses(F::SitePath)),
("use_user_agent", uses(F::UserAgent)),
("use_version_id", uses(F::VersionId)),
("use_monetized", uses(F::Monetized)),
("use_country", uses(F::Country)),
@@ -756,7 +855,11 @@ pub async fn fetch_analytics(
source_project: row.project_id.into(),
metrics: ProjectMetrics::Downloads(ProjectDownloads {
domain: none_if_empty(row.domain),
site_path: none_if_empty(row.site_path),
user_agent: if uses(F::UserAgent) {
normalize_download_source(&row.user_agent)
} else {
None
},
version_id: none_if_zero_version_id(row.version_id),
monetized: match row.monetized {
0 => Some(false),
@@ -840,9 +943,6 @@ pub async fn fetch_analytics(
return Err(AuthenticationError::InvalidCredentials.into());
}
let project_id_values =
project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
let mut rows = sqlx::query!(
"SELECT
WIDTH_BUCKET(
@@ -1016,6 +1116,76 @@ fn none_if_zero_version_id(v: DBVersionId) -> Option<VersionId> {
if v.0 == 0 { None } else { Some(v.into()) }
}
#[derive(Debug, Clone, Copy)]
enum DownloadSourcePattern {
Named(&'static str),
Website,
ModrinthApp,
ModrinthHosting,
ModrinthMaven,
}
impl DownloadSourcePattern {
fn into_source(self) -> DownloadSource {
match self {
Self::Named(name) => DownloadSource::Named(name.into()),
Self::Website => DownloadSource::Website,
Self::ModrinthApp => DownloadSource::ModrinthApp,
Self::ModrinthHosting => DownloadSource::ModrinthHosting,
Self::ModrinthMaven => DownloadSource::ModrinthMaven,
}
}
}
static DOWNLOAD_SOURCE_PATTERNS: LazyLock<Vec<(Regex, DownloadSourcePattern)>> =
LazyLock::new(|| {
use DownloadSourcePattern as P;
[
(r"^modrinth/kyros/", P::ModrinthHosting),
(r"^modrinth/theseus/", P::ModrinthApp),
(r"^(Gradle/|Apache-Maven/)", P::ModrinthMaven),
(r"^MultiMC/", P::Named("MultiMC")),
(r"^PrismLauncher/", P::Named("Prism Launcher")),
(r"^PolyMC/", P::Named("PolyMC")),
(r"^FCL/", P::Named("FCL")),
(r"^PCL2/", P::Named("PCL2")),
(r"^HMCL/", P::Named("HMCL")),
(r"^Lunar Client Launcher", P::Named("Lunar Client")),
(r"^PojavLauncher", P::Named("PojavLauncher")),
(r"^ATLauncher/", P::Named("ATLauncher")),
(r"FeatherLauncher/", P::Named("Feather Client")),
(
r"^FeatherMC/Feather Client Rust Launcher/",
P::Named("Feather Client"),
),
(r"Feather/[0-9A-Za-z]+", P::Named("Feather Client")),
(r"^PandoraLauncher/", P::Named("Pandora Launcher")),
(r"^unsup", P::Named("unsup")),
(r"nothub/mrpack-install", P::Named("mrpack-install")),
(r"^(packwiz-installer|packwiz/)", P::Named("Packwiz")),
(
r"^(Mozilla/|Chrome/|Chromium/|Firefox/|Safari/|AppleWebKit/|Edg/|OPR/)",
P::Website,
),
]
.into_iter()
.map(|(pattern, source)| {
(
Regex::new(pattern)
.expect("download source regex should be valid"),
source,
)
})
.collect()
});
fn normalize_download_source(user_agent: &str) -> Option<DownloadSource> {
DOWNLOAD_SOURCE_PATTERNS.iter().find_map(|(regex, source)| {
regex.is_match(user_agent).then(|| source.into_source())
})
}
fn condense_country(country: String, count: u64) -> String {
// Every country under '50' (view or downloads) should be condensed into 'XX'
if count < 50 {
@@ -1030,6 +1200,8 @@ struct QueryClickhouseContext<'a> {
req: &'a GetRequest,
time_slices: &'a mut [TimeSlice],
project_ids: &'a [DBProjectId],
parent_version_ids: &'a [DBVersionId],
parent_version_project_ids: &'a [DBProjectId],
affiliate_code_ids: &'a [DBAffiliateCodeId],
}
@@ -1051,6 +1223,8 @@ where
.param("time_range_end", cx.req.time_range.end.timestamp())
.param("time_slices", cx.time_slices.len())
.param("project_ids", cx.project_ids)
.param("parent_version_ids", cx.parent_version_ids)
.param("parent_version_project_ids", cx.parent_version_project_ids)
.param("affiliate_code_ids", cx.affiliate_code_ids);
for (param_name, used) in use_columns {
query = query.param(param_name, used)
@@ -1170,6 +1344,51 @@ mod tests {
use super::*;
#[test]
fn normalizes_download_sources() {
let cases = [
("MultiMC/5.0", Some(DownloadSource::Named("MultiMC".into()))),
(
"PrismLauncher/6.1",
Some(DownloadSource::Named("Prism Launcher".into())),
),
(
"modrinth/theseus/0.8.6 (support@modrinth.com)",
Some(DownloadSource::ModrinthApp),
),
(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
Some(DownloadSource::Website),
),
("curl/8.7.1", None),
];
for (user_agent, source) in cases {
assert_eq!(normalize_download_source(user_agent), source);
}
}
#[test]
fn download_source_serializes_as_raw_string() {
assert_eq!(
serde_json::to_value(DownloadSource::Named("MultiMC".into()))
.unwrap(),
json!("MultiMC")
);
assert_eq!(
serde_json::to_value(DownloadSource::Website).unwrap(),
json!("website")
);
assert_eq!(
serde_json::to_value(DownloadSource::ModrinthApp).unwrap(),
json!("modrinth_app")
);
assert_eq!(
serde_json::to_value(DownloadSource::Other).unwrap(),
json!("other")
);
}
#[test]
fn response_format() {
let test_project_1 = ProjectId(123);
+6
View File
@@ -3,6 +3,7 @@ use crate::util::cors::default_cors;
use actix_web::{HttpResponse, web};
use serde_json::json;
pub mod analytics_event;
pub mod analytics_get;
pub mod collections;
pub mod friends;
@@ -59,6 +60,11 @@ pub fn utoipa_config(
.wrap(default_cors())
.configure(analytics_get::config),
);
cfg.service(
utoipa_actix_web::scope("/v3/analytics-event")
.wrap(default_cors())
.configure(analytics_event::config),
);
cfg.service(
utoipa_actix_web::scope("/v3/payout")
.wrap(default_cors())
+2
View File
@@ -11,6 +11,7 @@ import { ISO3166Module } from './iso3166'
import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
@@ -71,6 +72,7 @@ export const MODULE_REGISTRY = {
kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
@@ -14,6 +14,7 @@ export class KyrosContentV1Module extends AbstractModule {
* @param files - Files to upload as addons
* @param options - Optional progress callback
* @returns UploadHandle with promise, onProgress, and cancel
* @deprecated Use `kyros.upload_sessions_v1` so cancellation can remove staged addon files before finalize.
*/
public uploadAddonFile(
worldId: string,
@@ -94,6 +94,7 @@ export class KyrosFilesV0Module extends AbstractModule {
* @param file - File to upload
* @param options - Optional progress callback and feature overrides
* @returns UploadHandle with promise, onProgress, and cancel
* @deprecated Use `kyros.upload_sessions_v1` for bulk uploads so cancellation can remove staged files before finalize.
*/
public uploadFile(
path: string,
@@ -1,4 +1,32 @@
export namespace Kyros {
export namespace UploadSessions {
export namespace v1 {
export type Scope = 'content' | 'files'
export type UploadSessionStatus =
| 'active'
| 'uploading'
| 'finalizing'
| 'cancelled'
| 'finalized'
| 'expired'
export interface UploadSessionResponse {
upload_id: string
status: UploadSessionStatus
created_at: number
updated_at: number
last_upload_at: number | null
expires_at: number
entry_count: number
uploaded_byte_count: number
}
export interface GetUploadSessionResponse {
session: UploadSessionResponse | null
}
}
}
export namespace Files {
export namespace v0 {
export interface DirectoryItem {
@@ -0,0 +1,104 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export type UploadSessionFile = {
file: File | Blob
filename: string
}
export class KyrosUploadSessionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_upload_sessions_v1'
}
public async create(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async get(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
): Promise<Kyros.UploadSessions.v1.GetUploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.GetUploadSessionResponse>(
`/worlds/${worldId}/files/upload-session`,
{
api: '',
version: 'v1',
method: 'GET',
useNodeAuth: true,
},
)
}
public uploadFiles(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
files: UploadSessionFile[],
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<Kyros.UploadSessions.v1.UploadSessionResponse> {
const formData = new FormData()
for (const { file, filename } of files) {
formData.append('file', file, filename)
}
return this.client.upload<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/files`,
{
api: '',
version: 'v1',
formData,
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
},
)
}
public async finalize(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}/finalize`,
{
api: '',
version: 'v1',
method: 'POST',
useNodeAuth: true,
},
)
}
public async cancel(
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
): Promise<Kyros.UploadSessions.v1.UploadSessionResponse> {
return this.client.request<Kyros.UploadSessions.v1.UploadSessionResponse>(
`/worlds/${worldId}/files/upload-session/${uploadId}`,
{
api: '',
version: 'v1',
method: 'DELETE',
useNodeAuth: true,
},
)
}
}
+18 -12
View File
@@ -875,6 +875,7 @@ impl CachedEntry {
.fetch_all(pool)
.await?;
let now = Utc::now().timestamp();
for row in query {
let parsed_data = if let Some(data) = row.data.clone() {
Some(Self::deserialize_cache_value(type_, data, &row.id)?)
@@ -882,7 +883,7 @@ impl CachedEntry {
None
};
if row.expires <= Utc::now().timestamp() {
if row.expires <= now {
if cache_behaviour == CacheBehaviour::MustRevalidate {
continue;
} else {
@@ -890,6 +891,19 @@ impl CachedEntry {
}
}
let row_id = row.id.clone();
let row_alias = row.alias.clone();
let remove_matching_key = |x: &&str| {
x != &&*row_id
&& !row_alias.as_ref().is_some_and(|y| {
if type_.case_sensitive_alias().unwrap_or(true) {
x == y
} else {
y.to_lowercase() == x.to_lowercase()
}
})
};
if let Some(data) = parsed_data {
if data.get_type() != type_ {
return Err(crate::ErrorKind::OtherError(format!(
@@ -901,17 +915,7 @@ impl CachedEntry {
.as_error());
}
remaining_keys.retain(|x| {
x != &&*row.id
&& !row.alias.as_ref().is_some_and(|y| {
if type_.case_sensitive_alias().unwrap_or(true)
{
x == y
} else {
y.to_lowercase() == x.to_lowercase()
}
})
});
remaining_keys.retain(remove_matching_key);
return_vals.push(Self {
id: row.id,
@@ -920,6 +924,8 @@ impl CachedEntry {
data: Some(data),
expires: row.expires,
});
} else {
remaining_keys.retain(remove_matching_key);
}
}
}
+35 -58
View File
@@ -3,13 +3,12 @@
//! ## Data Flow
//!
//! 1. Frontend calls `get_content_items(profile_path)`
//! 2. Backend fetches all installed files via `Profile::get_projects()`
//! 3. If profile is linked to a modpack:
//! 2. If profile is linked to a modpack:
//! - Fetch modpack file hashes from cache (populated during installation)
//! - Fallback: re-download .mrpack if cache miss (cleared/expired)
//! - Filter out files that belong to the modpack
//! 4. For remaining files, fetch project/version/owner metadata in parallel
//! 5. Return sorted `ContentItem` list
//! - Filter out files that belong to the modpack before update lookup
//! 3. For remaining files, fetch project/version/owner metadata in parallel
//! 4. Return sorted `ContentItem` list
//!
//! ## Caching
//!
@@ -226,12 +225,8 @@ pub async fn get_linked_modpack_info(
};
// Check for updates
let (has_update, update_version_id, update_version) = check_modpack_update(
profile,
&linked_data.version_id,
&version,
all_versions,
);
let (has_update, update_version_id, update_version) =
check_modpack_update(&linked_data.version_id, &version, all_versions);
Ok(Some(LinkedModpackInfo {
project,
@@ -243,10 +238,9 @@ pub async fn get_linked_modpack_info(
}))
}
/// Check if a newer compatible version exists for the linked modpack.
/// Check if a newer version exists for the linked modpack.
/// Returns (has_update, update_version_id, update_version).
fn check_modpack_update(
profile: &Profile,
installed_version_id: &str,
installed_version: &Version,
all_versions: Option<Vec<Version>>,
@@ -255,44 +249,19 @@ fn check_modpack_update(
return (false, None, None);
};
// Get the loader as a string for comparison
let loader_str = profile.loader.as_str().to_lowercase();
let game_version = &profile.game_version;
// Filter to compatible versions
let mut compatible_versions: Vec<&Version> = versions
let mut newer_versions: Vec<&Version> = versions
.iter()
.filter(|v| {
// Must support the profile's game version
let supports_game = v.game_versions.contains(game_version);
// Must support the profile's loader
// The v2 API replaces "mrpack" with actual loaders from mrpack_loaders,
// but if mrpack_loaders is missing, loaders may be just ["mrpack"].
// In that case we can't filter by loader, so accept the version.
let real_loaders: Vec<_> = v
.loaders
.iter()
.filter(|l| l.to_lowercase() != "mrpack")
.collect();
let supports_loader = real_loaders.is_empty()
|| real_loaders.iter().any(|l| l.to_lowercase() == loader_str);
supports_game && supports_loader
v.id != installed_version_id
&& v.date_published > installed_version.date_published
})
.collect();
// Sort by date_published descending (newest first)
compatible_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
newer_versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
// Find the newest compatible version
if let Some(newest) = compatible_versions.first() {
// Check if the newest version is different and newer than installed
if newest.id != installed_version_id
&& newest.date_published > installed_version.date_published
{
return (true, Some(newest.id.clone()), Some((*newest).clone()));
}
if let Some(newest) = newer_versions.first() {
return (true, Some(newest.id.clone()), Some((*newest).clone()));
}
(false, None, None)
@@ -306,10 +275,6 @@ pub async fn get_content_items(
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Vec<ContentItem>> {
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
let modpack_ids = if let Some(ref linked_data) = profile.linked_data {
if linked_data.version_id.is_empty() {
None
@@ -350,23 +315,35 @@ pub async fn get_content_items(
None
};
let user_files: Vec<(String, ProfileFile)> = all_files
.into_iter()
.filter(|(_, file)| {
modpack_ids
.as_ref()
.is_none_or(|ids| !ids.is_modpack_file(file))
})
.collect();
let user_files: Vec<(String, ProfileFile)> = if let Some(ids) = &modpack_ids
{
let filtered_files = profile
.get_projects_excluding_modpack_files(
&ids.hashes,
&ids.project_ids,
cache_behaviour,
pool,
fetch_semaphore,
)
.await?;
filtered_files.into_iter().collect()
} else {
let all_files = profile
.get_projects(cache_behaviour, pool, fetch_semaphore)
.await?;
all_files.into_iter().collect()
};
profile_files_to_content_items(
let content_items = profile_files_to_content_items(
&profile.path,
&user_files,
cache_behaviour,
pool,
fetch_semaphore,
)
.await
.await?;
Ok(content_items)
}
/// Pre-fetched metadata for projects, versions, teams, and organizations.

Some files were not shown because too many files have changed in this diff Show More