You've already forked AstralRinth
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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(() => [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,7 +12,7 @@ use dashmap::DashMap;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::convert::TryFrom;
|
||||
use std::convert::TryInto;
|
||||
use std::path::Path;
|
||||
@@ -939,41 +939,144 @@ impl Profile {
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<DashMap<String, ProfileFile>> {
|
||||
self.get_projects_inner(
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_projects_excluding_modpack_files(
|
||||
&self,
|
||||
excluded_hashes: &HashSet<String>,
|
||||
excluded_project_ids: &HashSet<String>,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<DashMap<String, ProfileFile>> {
|
||||
self.get_projects_inner(
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
Some(excluded_hashes),
|
||||
Some(excluded_project_ids),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_projects_inner(
|
||||
&self,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
excluded_hashes: Option<&HashSet<String>>,
|
||||
excluded_project_ids: Option<&HashSet<String>>,
|
||||
) -> crate::Result<DashMap<String, ProfileFile>> {
|
||||
let (keys, file_hashes) =
|
||||
self.scan_and_hash(pool, fetch_semaphore).await?;
|
||||
|
||||
let file_updates = file_hashes
|
||||
.iter()
|
||||
.map(|x| Self::get_cache_key(x, self))
|
||||
let excluded_hashes = excluded_hashes.filter(|ids| !ids.is_empty());
|
||||
let excluded_project_ids =
|
||||
excluded_project_ids.filter(|ids| !ids.is_empty());
|
||||
|
||||
let file_hashes = file_hashes
|
||||
.into_iter()
|
||||
.filter(|hash| {
|
||||
excluded_hashes
|
||||
.is_none_or(|excluded| !excluded.contains(&hash.hash))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let file_hashes_ref =
|
||||
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
|
||||
let file_updates_ref =
|
||||
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
|
||||
let (file_info, file_updates) = tokio::try_join!(
|
||||
CachedEntry::get_file_many(
|
||||
&file_hashes_ref,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
),
|
||||
CachedEntry::get_file_update_many(
|
||||
&file_updates_ref,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
)?;
|
||||
let (file_hashes, file_info_by_hash, file_updates) =
|
||||
if let Some(excluded_project_ids) = excluded_project_ids {
|
||||
let file_hashes_ref =
|
||||
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
|
||||
let file_info = CachedEntry::get_file_many(
|
||||
&file_hashes_ref,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut keys_by_path: std::collections::HashMap<
|
||||
String,
|
||||
InitialScanFile,
|
||||
> = keys.into_iter().map(|k| (k.path.clone(), k)).collect();
|
||||
let file_info_by_hash: HashMap<String, CachedFile> = file_info
|
||||
.into_iter()
|
||||
.map(|f| (f.hash.clone(), f))
|
||||
.collect();
|
||||
|
||||
let file_info_by_hash: std::collections::HashMap<String, CachedFile> =
|
||||
file_info.into_iter().map(|f| (f.hash.clone(), f)).collect();
|
||||
let file_hashes = file_hashes
|
||||
.into_iter()
|
||||
.filter(|hash| {
|
||||
file_info_by_hash.get(&hash.hash).is_none_or(|file| {
|
||||
!excluded_project_ids.contains(&file.project_id)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let file_updates = file_hashes
|
||||
.iter()
|
||||
.filter(|x| file_info_by_hash.contains_key(&x.hash))
|
||||
.map(|x| Self::get_cache_key(x, self))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let file_updates_ref =
|
||||
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
|
||||
let file_updates = CachedEntry::get_file_update_many(
|
||||
&file_updates_ref,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
(file_hashes, file_info_by_hash, file_updates)
|
||||
} else {
|
||||
let file_updates = file_hashes
|
||||
.iter()
|
||||
.map(|x| Self::get_cache_key(x, self))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let file_hashes_ref =
|
||||
file_hashes.iter().map(|x| &*x.hash).collect::<Vec<_>>();
|
||||
let file_updates_ref =
|
||||
file_updates.iter().map(|x| &**x).collect::<Vec<_>>();
|
||||
let (file_info, file_updates) = tokio::try_join!(
|
||||
CachedEntry::get_file_many(
|
||||
&file_hashes_ref,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
),
|
||||
CachedEntry::get_file_update_many(
|
||||
&file_updates_ref,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
)?;
|
||||
|
||||
let file_info_by_hash: HashMap<String, CachedFile> = file_info
|
||||
.into_iter()
|
||||
.map(|f| (f.hash.clone(), f))
|
||||
.collect();
|
||||
|
||||
(file_hashes, file_info_by_hash, file_updates)
|
||||
};
|
||||
|
||||
let mut keys_by_path: HashMap<String, InitialScanFile> =
|
||||
keys.into_iter().map(|k| (k.path.clone(), k)).collect();
|
||||
|
||||
let mut updates_by_hash: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for update in file_updates {
|
||||
updates_by_hash
|
||||
.entry(update.hash)
|
||||
.or_default()
|
||||
.push(update.update_version_id);
|
||||
}
|
||||
|
||||
let files = DashMap::new();
|
||||
|
||||
@@ -989,11 +1092,8 @@ impl Profile {
|
||||
);
|
||||
|
||||
let update_version_id = if let Some(metadata) = &file {
|
||||
let update_ids: Vec<String> = file_updates
|
||||
.iter()
|
||||
.filter(|x| x.hash == hash.hash)
|
||||
.map(|x| x.update_version_id.clone())
|
||||
.collect();
|
||||
let update_ids =
|
||||
updates_by_hash.remove(&hash.hash).unwrap_or_default();
|
||||
|
||||
if !update_ids.contains(&metadata.version_id) {
|
||||
update_ids.into_iter().next()
|
||||
|
||||
+126
-24
@@ -32,7 +32,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-16">
|
||||
<div class="flex-1 overflow-y-auto px-4" :class="isModpack ? 'pb-4' : 'pb-16'">
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center h-full gap-2">
|
||||
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||
<span class="text-sm text-secondary">{{
|
||||
@@ -76,11 +76,11 @@
|
||||
class="rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
|
||||
:class="[
|
||||
getBadgeClasses(version),
|
||||
isVersionCompatible(version) ? 'px-2.5 py-0.5' : 'p-1',
|
||||
shouldShowIncompatibleBadge(version) ? 'p-1' : 'px-2.5 py-0.5',
|
||||
]"
|
||||
>
|
||||
<CircleAlertIcon
|
||||
v-if="!isVersionCompatible(version)"
|
||||
v-if="shouldShowIncompatibleBadge(version)"
|
||||
v-tooltip="formatMessage(messages.incompatibleBadge)"
|
||||
class="size-4"
|
||||
/>
|
||||
@@ -99,6 +99,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isModpack"
|
||||
class="absolute bottom-0 left-0 right-0 pointer-events-none flex flex-col items-center justify-end bg-gradient-to-b from-transparent to-bg-raised to-70% pb-3 h-24"
|
||||
>
|
||||
<div class="pointer-events-auto">
|
||||
@@ -197,13 +198,16 @@
|
||||
<div
|
||||
class="w-full flex flex-row items-center gap-4 p-4 border-solid border-x-0 border-b-0 border-t border-surface-4"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2 max-w-[55%] flex-1 text-orange mr-auto">
|
||||
<div
|
||||
v-if="showUpdateWarning"
|
||||
class="flex flex-row items-center gap-2 max-w-[55%] flex-1 text-orange mr-auto"
|
||||
>
|
||||
<TriangleAlertIcon class="size-6 shrink-0" />
|
||||
<span>{{
|
||||
formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 shrink-0">
|
||||
<div class="flex flex-row gap-2 shrink-0 ml-auto">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="handleCancel">
|
||||
<XIcon />
|
||||
@@ -233,6 +237,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<ConfirmModal
|
||||
ref="incompatibleUpdateModal"
|
||||
:title="formatMessage(messages.incompatibleUpdateHeader)"
|
||||
:description="
|
||||
formatMessage(messages.incompatibleUpdateDescription, {
|
||||
version: pendingIncompatibleUpdate?.version.version_number ?? '...',
|
||||
})
|
||||
"
|
||||
:proceed-icon="DownloadIcon"
|
||||
:proceed-label="formatMessage(messages.updateAnywayButton)"
|
||||
:danger="false"
|
||||
:markdown="false"
|
||||
@proceed="confirmIncompatibleUpdate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -255,11 +274,16 @@ import { computed, ref, watch } from 'vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import ConfirmModal from '#ui/components/modal/ConfirmModal.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import VersionChannelIndicator from '#ui/components/version/VersionChannelIndicator.vue'
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import {
|
||||
versionChangesGameVersion,
|
||||
versionMatchesCompatibilityTarget,
|
||||
} from '#ui/utils/version-compatibility'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('ContentUpdaterModal')
|
||||
@@ -338,6 +362,19 @@ const messages = defineMessages({
|
||||
id: 'instances.updater-modal.loading-changelog',
|
||||
defaultMessage: 'Loading changelog...',
|
||||
},
|
||||
incompatibleUpdateHeader: {
|
||||
id: 'instances.updater-modal.incompatible-update.header',
|
||||
defaultMessage: 'Update to incompatible version?',
|
||||
},
|
||||
incompatibleUpdateDescription: {
|
||||
id: 'instances.updater-modal.incompatible-update.description',
|
||||
defaultMessage:
|
||||
'{version} is not marked as compatible with this installation. It may fail to launch or behave unexpectedly.',
|
||||
},
|
||||
updateAnywayButton: {
|
||||
id: 'instances.updater-modal.incompatible-update.proceed',
|
||||
defaultMessage: 'Update anyway',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -378,10 +415,15 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const incompatibleUpdateModal = ref<InstanceType<typeof ConfirmModal>>()
|
||||
const searchQuery = ref('')
|
||||
const hideIncompatibleState = ref(true)
|
||||
const switchMode = ref(false)
|
||||
const selectedVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const pendingIncompatibleUpdate = ref<{
|
||||
version: Labrinth.Versions.v2.Version
|
||||
event: MouseEvent
|
||||
} | null>(null)
|
||||
// Store the initial version ID to select when versions become available
|
||||
const pendingInitialVersionId = ref<string | undefined>(undefined)
|
||||
|
||||
@@ -422,15 +464,13 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const NON_MOD_PROJECT_TYPES = new Set(['shader', 'shaderpack', 'resourcepack', 'datapack'])
|
||||
|
||||
function isVersionCompatible(version: Labrinth.Versions.v2.Version): boolean {
|
||||
const hasGameVersion = version.game_versions.includes(props.currentGameVersion)
|
||||
const skipLoaderCheck = props.projectType != null && NON_MOD_PROJECT_TYPES.has(props.projectType)
|
||||
const hasLoader =
|
||||
skipLoaderCheck ||
|
||||
version.loaders.some((loader) => loader.toLowerCase() === props.currentLoader.toLowerCase())
|
||||
const compatible = hasGameVersion && hasLoader
|
||||
const compatible = versionMatchesCompatibilityTarget(version, {
|
||||
gameVersion: props.currentGameVersion,
|
||||
loader: props.currentLoader,
|
||||
projectType: props.projectType,
|
||||
})
|
||||
|
||||
if (!compatible) {
|
||||
debug('isVersionCompatible: INCOMPATIBLE', {
|
||||
versionId: version.id,
|
||||
@@ -440,15 +480,13 @@ function isVersionCompatible(version: Labrinth.Versions.v2.Version): boolean {
|
||||
currentLoader: props.currentLoader,
|
||||
currentGameVersion: props.currentGameVersion,
|
||||
projectType: props.projectType,
|
||||
hasGameVersion,
|
||||
hasLoader,
|
||||
skipLoaderCheck,
|
||||
})
|
||||
}
|
||||
return compatible
|
||||
}
|
||||
|
||||
const currentVersion = computed(() => props.versions.find((v) => v.id === props.currentVersionId))
|
||||
const showUpdateWarning = computed(() => !isModpack.value)
|
||||
|
||||
const isDowngrade = computed(() => {
|
||||
if (!selectedVersion.value || !currentVersion.value) return false
|
||||
@@ -468,8 +506,13 @@ const filteredVersions = computed(() => {
|
||||
}
|
||||
|
||||
const beforeFilterCount = versions.length
|
||||
if (hideIncompatibleState.value) {
|
||||
versions = versions.filter(isVersionCompatible)
|
||||
if (!isModpack.value && hideIncompatibleState.value) {
|
||||
versions = versions.filter(
|
||||
(version) =>
|
||||
version.id === props.currentVersionId ||
|
||||
version.id === selectedVersion.value?.id ||
|
||||
isVersionCompatible(version),
|
||||
)
|
||||
}
|
||||
|
||||
debug('filteredVersions computed', {
|
||||
@@ -478,18 +521,23 @@ const filteredVersions = computed(() => {
|
||||
afterCompatibilityFilter: versions.length,
|
||||
hiddenByCompatibility: beforeFilterCount - versions.length,
|
||||
hideIncompatible: hideIncompatibleState.value,
|
||||
filteringCompatibility: !isModpack.value && hideIncompatibleState.value,
|
||||
})
|
||||
|
||||
return versions
|
||||
})
|
||||
|
||||
function shouldShowBadge(version: Labrinth.Versions.v2.Version): boolean {
|
||||
return version.id === props.currentVersionId || !isVersionCompatible(version)
|
||||
return version.id === props.currentVersionId || shouldShowIncompatibleBadge(version)
|
||||
}
|
||||
|
||||
function shouldShowIncompatibleBadge(version: Labrinth.Versions.v2.Version): boolean {
|
||||
return version.id !== props.currentVersionId && !isModpack.value && !isVersionCompatible(version)
|
||||
}
|
||||
|
||||
function getBadgeLabel(version: Labrinth.Versions.v2.Version): string {
|
||||
if (version.id === props.currentVersionId) return formatMessage(messages.currentBadge)
|
||||
if (!isVersionCompatible(version)) return formatMessage(messages.incompatibleBadge)
|
||||
if (shouldShowIncompatibleBadge(version)) return formatMessage(messages.incompatibleBadge)
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -499,8 +547,7 @@ function getBadgeClasses(version: Labrinth.Versions.v2.Version): string {
|
||||
return 'bg-surface-4 border-surface-5 text-primary'
|
||||
}
|
||||
|
||||
// Incompatible badge (takes precedence over version type)
|
||||
if (!isVersionCompatible(version)) {
|
||||
if (shouldShowIncompatibleBadge(version)) {
|
||||
return 'bg-highlight-orange border-brand-orange text-brand-orange'
|
||||
}
|
||||
|
||||
@@ -568,7 +615,61 @@ function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
|
||||
function handleUpdate(event: MouseEvent) {
|
||||
if (selectedVersion.value) {
|
||||
emit('update', selectedVersion.value, event)
|
||||
const changesGameVersion = versionChangesGameVersion(
|
||||
selectedVersion.value,
|
||||
props.currentGameVersion,
|
||||
)
|
||||
const shouldShowParentWarning =
|
||||
isModpack.value && !event.shiftKey && (changesGameVersion || isDowngrade.value)
|
||||
if (
|
||||
isModpack.value &&
|
||||
!event.shiftKey &&
|
||||
!isVersionCompatible(selectedVersion.value) &&
|
||||
!changesGameVersion
|
||||
) {
|
||||
pendingIncompatibleUpdate.value = {
|
||||
version: selectedVersion.value,
|
||||
event,
|
||||
}
|
||||
incompatibleUpdateModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
emitUpdate(selectedVersion.value, event, {
|
||||
hide: !shouldShowParentWarning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function confirmIncompatibleUpdate() {
|
||||
const pendingUpdate = pendingIncompatibleUpdate.value
|
||||
pendingIncompatibleUpdate.value = null
|
||||
|
||||
if (pendingUpdate) {
|
||||
const current = currentVersion.value
|
||||
const isPendingDowngrade = current
|
||||
? new Date(pendingUpdate.version.date_published) < new Date(current.date_published)
|
||||
: false
|
||||
const changesGameVersion = versionChangesGameVersion(
|
||||
pendingUpdate.version,
|
||||
props.currentGameVersion,
|
||||
)
|
||||
const shouldShowParentWarning =
|
||||
isModpack.value && !pendingUpdate.event.shiftKey && (changesGameVersion || isPendingDowngrade)
|
||||
|
||||
emitUpdate(pendingUpdate.version, pendingUpdate.event, {
|
||||
hide: !shouldShowParentWarning,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function emitUpdate(
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
event: MouseEvent,
|
||||
options: { hide?: boolean } = {},
|
||||
) {
|
||||
emit('update', version, event)
|
||||
if (options.hide ?? true) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
@@ -580,7 +681,8 @@ function handleCancel() {
|
||||
|
||||
function show(initialVersionId?: string, options?: { switchMode?: boolean }) {
|
||||
searchQuery.value = ''
|
||||
hideIncompatibleState.value = true
|
||||
hideIncompatibleState.value = !isModpack.value
|
||||
pendingIncompatibleUpdate.value = null
|
||||
switchMode.value = options?.switchMode ?? false
|
||||
|
||||
debug('show() called', {
|
||||
|
||||
+17
-2
@@ -36,6 +36,7 @@ interface Props {
|
||||
modpackName?: string
|
||||
modpackIconUrl?: string
|
||||
enableToggle?: boolean
|
||||
busy?: boolean
|
||||
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
|
||||
switchVersion?: (item: ContentItem) => void
|
||||
}
|
||||
@@ -44,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
modpackName: undefined,
|
||||
modpackIconUrl: undefined,
|
||||
enableToggle: false,
|
||||
busy: false,
|
||||
getOverflowOptions: undefined,
|
||||
switchVersion: undefined,
|
||||
})
|
||||
@@ -247,12 +249,13 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
}
|
||||
: undefined,
|
||||
...(props.enableToggle ? { enabled: item.enabled } : {}),
|
||||
installing: item.installing === true,
|
||||
isClientOnly:
|
||||
isClientOnlyEnvironment(item.environment) ||
|
||||
!!item.pack_client_retained ||
|
||||
!!item.pack_client_depends,
|
||||
clientWarning: getClientWarningType(item),
|
||||
disabled: disabledIds.value.has(item.file_name),
|
||||
disabled: props.busy || disabledIds.value.has(item.file_name) || item.installing === true,
|
||||
overflowOptions: [
|
||||
...(props.switchVersion
|
||||
? [
|
||||
@@ -283,17 +286,20 @@ function getTypeIcon(type: string) {
|
||||
}
|
||||
|
||||
function handleEnabledChange(fileName: string, value: boolean) {
|
||||
if (props.busy) return
|
||||
const item = items.value.find((i) => i.file_name === fileName)
|
||||
if (!item) return
|
||||
emit('update:enabled', item, value)
|
||||
}
|
||||
|
||||
function bulkEnable() {
|
||||
if (props.busy) return
|
||||
emit('bulk:enable', [...selectedItems.value])
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
function bulkDisable() {
|
||||
if (props.busy) return
|
||||
emit('bulk:disable', [...selectedItems.value])
|
||||
selectedIds.value = []
|
||||
}
|
||||
@@ -361,7 +367,15 @@ function updateItem(fileName: string, updates: Partial<ContentItem> & { disabled
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show, showLoading, hide, getState, restore, updateItem })
|
||||
function setItems(contentItems: ContentItem[]) {
|
||||
const contentFileNames = new Set(contentItems.map((item) => item.file_name))
|
||||
items.value = contentItems
|
||||
selectedIds.value = selectedIds.value.filter((id) => contentFileNames.has(id))
|
||||
disabledIds.value = new Set([...disabledIds.value].filter((id) => contentFileNames.has(id)))
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -544,6 +558,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
|
||||
<ContentSelectionBar
|
||||
v-if="props.enableToggle"
|
||||
:selected-items="selectedItems"
|
||||
:is-bulk-operating="props.busy"
|
||||
style="--left-bar-width: 0px; --right-bar-width: 0px"
|
||||
@clear="selectedIds = []"
|
||||
@enable="bulkEnable"
|
||||
|
||||
@@ -26,6 +26,7 @@ import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
import { versionChangesGameVersion } from '#ui/utils/version-compatibility'
|
||||
|
||||
import ConfirmModpackUpdateModal from '../content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
||||
import ConfirmReinstallModal from '../content-tab/components/modals/ConfirmReinstallModal.vue'
|
||||
@@ -120,22 +121,31 @@ const isLocalFile = computed(() => {
|
||||
|
||||
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event?: MouseEvent) {
|
||||
pendingUpdateVersion.value = version
|
||||
|
||||
const currentVersionId = ctx.updaterModalProps.value.currentVersionId
|
||||
const currentVersion = form.updatingProjectVersions.value.find((v) => v.id === currentVersionId)
|
||||
isUpdateDowngrade.value = currentVersion
|
||||
? new Date(version.date_published) < new Date(currentVersion.date_published)
|
||||
: false
|
||||
if (event?.shiftKey) {
|
||||
const shouldShowWarning =
|
||||
isUpdateDowngrade.value ||
|
||||
versionChangesGameVersion(version, ctx.updaterModalProps.value.currentGameVersion)
|
||||
|
||||
if (event?.shiftKey || !shouldShowWarning) {
|
||||
handleModpackUpdateConfirm()
|
||||
} else {
|
||||
modpackUpdateModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
modpackUpdateModal.value?.show()
|
||||
}
|
||||
|
||||
function handleModpackUpdateConfirm() {
|
||||
if (pendingUpdateVersion.value) {
|
||||
const version = pendingUpdateVersion.value
|
||||
if (version) {
|
||||
contentUpdaterModal.value?.hide()
|
||||
form.cancelEditing()
|
||||
form.handleUpdaterConfirm(pendingUpdateVersion.value)
|
||||
ctx.closeSettings?.()
|
||||
form.handleUpdaterConfirm(version)
|
||||
pendingUpdateVersion.value = null
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -64,6 +64,7 @@ export interface InstallationSettingsContext {
|
||||
reinstalling?: Ref<boolean>
|
||||
|
||||
afterSave?: () => Promise<void>
|
||||
closeSettings?: () => void
|
||||
|
||||
lockPlatform?: boolean
|
||||
hideLoaderVersion?: boolean
|
||||
|
||||
@@ -354,6 +354,7 @@ function toApiLoader(loader: string): Archon.Content.v1.Modloader {
|
||||
}
|
||||
|
||||
provideInstallationSettings({
|
||||
closeSettings: serverSettings.closeModal,
|
||||
onGameVersionHover: handleGameVersionHover,
|
||||
loading: computed(() => !server.value || addonsQuery.isLoading.value),
|
||||
installationInfo: computed(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
readPendingServerContentInstalls,
|
||||
removePendingServerContentInstall,
|
||||
} from '#ui/utils/server-content-installing'
|
||||
import { versionChangesGameVersion } from '#ui/utils/version-compatibility'
|
||||
|
||||
import {
|
||||
flushStoredServerAddonInstallQueue,
|
||||
@@ -160,6 +162,44 @@ const projectQuery = useQuery({
|
||||
enabled: computed(() => !!modpackProjectId.value),
|
||||
})
|
||||
|
||||
function getVersionTime(version: Labrinth.Versions.v2.Version) {
|
||||
return new Date(version.date_published).getTime()
|
||||
}
|
||||
|
||||
function sortVersionsByPublishedDate(versions: Labrinth.Versions.v2.Version[]) {
|
||||
return [...versions].sort((a, b) => getVersionTime(b) - getVersionTime(a))
|
||||
}
|
||||
|
||||
const currentModpackVersionId = computed(() => {
|
||||
const spec = contentQuery.data.value?.modpack?.spec
|
||||
return spec?.platform === 'modrinth' ? spec.version_id : null
|
||||
})
|
||||
|
||||
const newestModpackUpdateVersion = computed(() => {
|
||||
const currentVersionId = currentModpackVersionId.value
|
||||
if (!currentVersionId) return null
|
||||
|
||||
const versions = sortVersionsByPublishedDate(modpackVersionsQuery.data.value ?? [])
|
||||
const currentVersion = versions.find((version) => version.id === currentVersionId)
|
||||
const installedPublishedAt = contentQuery.data.value?.modpack?.date_published
|
||||
const storedCurrentTime = installedPublishedAt
|
||||
? new Date(installedPublishedAt).getTime()
|
||||
: Number.NaN
|
||||
const currentVersionTime = Number.isNaN(storedCurrentTime)
|
||||
? currentVersion
|
||||
? getVersionTime(currentVersion)
|
||||
: Number.NaN
|
||||
: storedCurrentTime
|
||||
|
||||
return (
|
||||
versions.find((version) => {
|
||||
if (version.id === currentVersionId) return false
|
||||
if (Number.isNaN(currentVersionTime)) return true
|
||||
return getVersionTime(version) > currentVersionTime
|
||||
}) ?? null
|
||||
)
|
||||
})
|
||||
|
||||
const modpack = computed<ContentModpackData | null>(() => {
|
||||
const mp = contentQuery.data.value?.modpack
|
||||
if (!mp) return null
|
||||
@@ -207,7 +247,7 @@ const modpack = computed<ContentModpackData | null>(() => {
|
||||
project_type: 'modpack',
|
||||
header: 'categories',
|
||||
})) as ContentModpackCardCategory[],
|
||||
hasUpdate: !!mp.has_update,
|
||||
hasUpdate: !!mp.has_update || !!newestModpackUpdateVersion.value,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -237,6 +277,14 @@ const lastStableContentKeys = ref<Set<string>>(new Set())
|
||||
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
|
||||
const contentInstallAddedKeys = ref<Set<string>>(new Set())
|
||||
const isFlushingStoredServerInstalls = ref(false)
|
||||
const { pause: pausePendingInstallPoll, resume: resumePendingInstallPoll } = useIntervalFn(
|
||||
() => {
|
||||
if (pendingServerContentInstalls.value.length === 0 || contentQuery.isFetching.value) return
|
||||
void contentQuery.refetch()
|
||||
},
|
||||
5000,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
function syncPendingServerContentInstalls() {
|
||||
pendingServerContentInstalls.value = readPendingServerContentInstalls(serverId, worldId.value)
|
||||
@@ -262,6 +310,27 @@ function getAddonInstallKeys(addons: Archon.Content.v1.Addon[]) {
|
||||
return keys
|
||||
}
|
||||
|
||||
function addonMatchesPendingInstall(
|
||||
addon: Archon.Content.v1.Addon,
|
||||
pendingInstall: PendingServerContentInstall,
|
||||
) {
|
||||
return (
|
||||
addon.project_id === pendingInstall.projectId ||
|
||||
addon.version?.id === pendingInstall.versionId ||
|
||||
(!!pendingInstall.fileName && addon.filename === pendingInstall.fileName)
|
||||
)
|
||||
}
|
||||
|
||||
function removeResolvedPendingServerContentInstalls(addons: Archon.Content.v1.Addon[]) {
|
||||
if (addons.length === 0 || pendingServerContentInstalls.value.length === 0) return
|
||||
|
||||
for (const pendingInstall of pendingServerContentInstalls.value) {
|
||||
if (addons.some((addon) => addonMatchesPendingInstall(addon, pendingInstall))) {
|
||||
removePendingServerContentInstall(serverId, worldId.value, pendingInstall.projectId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncContentInstallKeys(
|
||||
addons: Archon.Content.v1.Addon[] = contentQuery.data.value?.addons ?? [],
|
||||
) {
|
||||
@@ -373,17 +442,32 @@ const rawContentItems = computed<ContentItem[]>(() => {
|
||||
const pendingInstallByProjectId = new Map(
|
||||
pendingServerContentInstalls.value.map((item) => [item.projectId, item]),
|
||||
)
|
||||
const pendingInstallByVersionId = new Map(
|
||||
pendingServerContentInstalls.value.map((item) => [item.versionId, item]),
|
||||
)
|
||||
const pendingInstallByFileName = new Map<string, PendingServerContentInstall>()
|
||||
for (const item of pendingServerContentInstalls.value) {
|
||||
if (item.fileName) {
|
||||
pendingInstallByFileName.set(item.fileName, item)
|
||||
}
|
||||
}
|
||||
const installingContentKeys = new Set([...pendingProjectIds, ...contentInstallAddedKeys.value])
|
||||
const installedProjectIds = new Set(
|
||||
addons.map((addon) => addon.project_id).filter((id): id is string => !!id),
|
||||
const resolvedPendingProjectIds = new Set(
|
||||
pendingServerContentInstalls.value
|
||||
.filter((item) => addons.some((addon) => addonMatchesPendingInstall(addon, item)))
|
||||
.map((item) => item.projectId),
|
||||
)
|
||||
const pendingItems = pendingServerContentInstalls.value
|
||||
.filter((item) => !installedProjectIds.has(item.projectId))
|
||||
.filter((item) => !resolvedPendingProjectIds.has(item.projectId))
|
||||
.map(pendingInstallToContentItem)
|
||||
const addonItems = addons.map((addon) => {
|
||||
const contentItem = addonToContentItem(addon)
|
||||
const installing = installingContentKeys.has(getAddonInstallKey(addon))
|
||||
const pendingItem = addon.project_id ? pendingInstallByProjectId.get(addon.project_id) : null
|
||||
const pendingItem =
|
||||
(addon.project_id ? pendingInstallByProjectId.get(addon.project_id) : null) ??
|
||||
(addon.version?.id ? pendingInstallByVersionId.get(addon.version.id) : null) ??
|
||||
pendingInstallByFileName.get(addon.filename) ??
|
||||
null
|
||||
const installing = !!pendingItem || installingContentKeys.has(getAddonInstallKey(addon))
|
||||
|
||||
if (!installing || !pendingItem) {
|
||||
return {
|
||||
@@ -478,6 +562,26 @@ watch(
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => contentQuery.data.value?.addons, pendingServerContentInstalls],
|
||||
([addons]) => {
|
||||
removeResolvedPendingServerContentInstalls(addons ?? [])
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => pendingServerContentInstalls.value.length > 0,
|
||||
(hasPendingInstalls) => {
|
||||
if (hasPendingInstalls) {
|
||||
resumePendingInstallPoll()
|
||||
} else {
|
||||
pausePendingInstallPoll()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
worldId,
|
||||
() => {
|
||||
@@ -498,6 +602,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
pausePendingInstallPoll()
|
||||
window.removeEventListener(
|
||||
pendingServerContentInstallsEvent,
|
||||
handlePendingServerContentInstallsChanged,
|
||||
@@ -659,9 +764,7 @@ const updatingProjectVersions = computed(() => {
|
||||
? modpackVersionsQuery.data.value
|
||||
: projectVersionsQuery.data.value
|
||||
if (!source) return []
|
||||
return [...source].sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
return sortVersionsByPublishedDate(source)
|
||||
})
|
||||
|
||||
const loadingVersions = computed(() =>
|
||||
@@ -674,8 +777,12 @@ const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
|
||||
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const isModpackUpdateDowngrade = ref(false)
|
||||
|
||||
const currentGameVersion = computed(() => contentQuery.data.value?.game_version ?? '')
|
||||
const currentLoader = computed(() => contentQuery.data.value?.modloader ?? '')
|
||||
const currentGameVersion = computed(
|
||||
() => contentQuery.data.value?.game_version ?? server.value?.mc_version ?? '',
|
||||
)
|
||||
const currentLoader = computed(
|
||||
() => contentQuery.data.value?.modloader ?? server.value?.loader ?? '',
|
||||
)
|
||||
|
||||
function handleBrowseContent() {
|
||||
const contentType = type.value
|
||||
@@ -929,7 +1036,9 @@ async function handleModpackUpdate() {
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(mp.has_update ?? undefined)
|
||||
contentUpdaterModal.value?.show(
|
||||
newestModpackUpdateVersion.value?.id ?? mp.has_update ?? undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function spliceVersionInCache(fullVersion: Labrinth.Versions.v2.Version) {
|
||||
@@ -973,17 +1082,21 @@ function resetUpdateState() {
|
||||
|
||||
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?: MouseEvent) {
|
||||
if (updatingModpack.value) {
|
||||
if (event?.shiftKey) {
|
||||
pendingModpackUpdateVersion.value = selectedVersion
|
||||
pendingModpackUpdateVersion.value = selectedVersion
|
||||
|
||||
const mpSpec = contentQuery.data.value?.modpack?.spec
|
||||
const currentVersionId = mpSpec?.platform === 'modrinth' ? mpSpec.version_id : undefined
|
||||
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, currentGameVersion.value)
|
||||
|
||||
if (event?.shiftKey || !shouldShowWarning) {
|
||||
handleModpackUpdateConfirm()
|
||||
} else {
|
||||
const mpSpec = contentQuery.data.value?.modpack?.spec
|
||||
const currentVersionId = mpSpec?.platform === 'modrinth' ? mpSpec.version_id : undefined
|
||||
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
|
||||
isModpackUpdateDowngrade.value = currentVersion
|
||||
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
|
||||
: false
|
||||
pendingModpackUpdateVersion.value = selectedVersion
|
||||
modpackUpdateModal.value?.show()
|
||||
}
|
||||
return
|
||||
@@ -1048,6 +1161,7 @@ async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
|
||||
function handleModpackUpdateConfirm() {
|
||||
if (pendingModpackUpdateVersion.value) {
|
||||
contentUpdaterModal.value?.hide()
|
||||
performUpdate(pendingModpackUpdateVersion.value)
|
||||
pendingModpackUpdateVersion.value = null
|
||||
}
|
||||
|
||||
@@ -1658,6 +1658,15 @@
|
||||
"instances.updater-modal.hide-incompatible": {
|
||||
"defaultMessage": "Hide incompatible"
|
||||
},
|
||||
"instances.updater-modal.incompatible-update.description": {
|
||||
"defaultMessage": "{version} is not marked as compatible with this installation. It may fail to launch or behave unexpectedly."
|
||||
},
|
||||
"instances.updater-modal.incompatible-update.header": {
|
||||
"defaultMessage": "Update to incompatible version?"
|
||||
},
|
||||
"instances.updater-modal.incompatible-update.proceed": {
|
||||
"defaultMessage": "Update anyway"
|
||||
},
|
||||
"instances.updater-modal.loading-changelog": {
|
||||
"defaultMessage": "Loading changelog..."
|
||||
},
|
||||
|
||||
@@ -11,4 +11,5 @@ export * from './server-content-installing'
|
||||
export * from './server-search'
|
||||
export * from './tag-messages'
|
||||
export * from './truncate'
|
||||
export * from './version-compatibility'
|
||||
export * from './vue-children'
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const NON_MOD_PROJECT_TYPES = new Set(['shader', 'shaderpack', 'resourcepack', 'datapack'])
|
||||
|
||||
const LOADER_ALIAS_GROUPS = [
|
||||
['paper', 'purpur', 'spigot', 'bukkit'],
|
||||
['neoforge', 'neo'],
|
||||
]
|
||||
|
||||
type VersionCompatibilityData = {
|
||||
game_versions: string[]
|
||||
loaders: string[]
|
||||
}
|
||||
|
||||
export function normalizeLoaderAlias(loader: string) {
|
||||
return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '')
|
||||
}
|
||||
|
||||
export function getCompatibleLoaderAliases(loader: string) {
|
||||
const normalizedLoader = normalizeLoaderAlias(loader)
|
||||
const aliases = new Set([normalizedLoader])
|
||||
const aliasGroup = LOADER_ALIAS_GROUPS.find((group) => group.includes(normalizedLoader))
|
||||
|
||||
if (aliasGroup) {
|
||||
for (const alias of aliasGroup) {
|
||||
aliases.add(alias)
|
||||
}
|
||||
}
|
||||
|
||||
return aliases
|
||||
}
|
||||
|
||||
export function versionChangesGameVersion(
|
||||
version: VersionCompatibilityData,
|
||||
currentGameVersion: string,
|
||||
) {
|
||||
return !!currentGameVersion && !version.game_versions.includes(currentGameVersion)
|
||||
}
|
||||
|
||||
export function versionMatchesCompatibilityTarget(
|
||||
version: VersionCompatibilityData,
|
||||
target: {
|
||||
gameVersion: string
|
||||
loader: string
|
||||
projectType?: string
|
||||
},
|
||||
) {
|
||||
if (!target.gameVersion || !version.game_versions.includes(target.gameVersion)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (target.projectType && NON_MOD_PROJECT_TYPES.has(target.projectType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const normalizedVersionLoaders = version.loaders.map(normalizeLoaderAlias)
|
||||
|
||||
if (
|
||||
target.projectType === 'modpack' &&
|
||||
(normalizedVersionLoaders.length === 0 ||
|
||||
normalizedVersionLoaders.every((loader) => loader === 'mrpack'))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!target.loader) {
|
||||
return false
|
||||
}
|
||||
|
||||
const loaderAliases = getCompatibleLoaderAliases(target.loader)
|
||||
return normalizedVersionLoaders.some((loader) => loaderAliases.has(loader))
|
||||
}
|
||||
Reference in New Issue
Block a user