fix: use localstorage for sync state during install (#6057)
* fix: use localstorage for sync state during install * fix: lint
This commit is contained in:
@@ -5,14 +5,17 @@ import {
|
|||||||
type BrowseSelectedProject,
|
type BrowseSelectedProject,
|
||||||
createContext,
|
createContext,
|
||||||
type CreationFlowContextValue,
|
type CreationFlowContextValue,
|
||||||
flushInstallQueue,
|
flushStoredServerAddonInstallQueue,
|
||||||
|
getStoredServerAddonInstallQueue,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
type PendingServerContentInstall,
|
type PendingServerContentInstall,
|
||||||
type PendingServerContentInstallType,
|
type PendingServerContentInstallType,
|
||||||
readPendingServerContentInstalls,
|
readPendingServerContentInstalls,
|
||||||
|
readStoredServerInstallQueue,
|
||||||
removePendingServerContentInstall,
|
removePendingServerContentInstall,
|
||||||
writePendingServerContentInstallBaseline,
|
writePendingServerContentInstallBaseline,
|
||||||
|
writeStoredServerInstallQueue,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
|
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -89,37 +92,6 @@ function readQueryString(value: unknown): string | null {
|
|||||||
return typeof value === 'string' && value.length > 0 ? value : null
|
return typeof value === 'string' && value.length > 0 ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQueueStorageKey(serverId: string | null, worldId: string | null) {
|
|
||||||
if (!serverId || !worldId) return null
|
|
||||||
return `server-install-queue:${serverId}:${worldId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStoredQueue(serverId: string | null, worldId: string | null) {
|
|
||||||
const key = getQueueStorageKey(serverId, worldId)
|
|
||||||
if (!key) return new Map<string, BrowseInstallPlan<InstallableSearchResult>>()
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
if (!raw) return new Map<string, BrowseInstallPlan<InstallableSearchResult>>()
|
|
||||||
return new Map<string, BrowseInstallPlan<InstallableSearchResult>>(JSON.parse(raw))
|
|
||||||
} catch {
|
|
||||||
return new Map<string, BrowseInstallPlan<InstallableSearchResult>>()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeStoredQueue(
|
|
||||||
serverId: string | null,
|
|
||||||
worldId: string | null,
|
|
||||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
|
||||||
) {
|
|
||||||
const key = getQueueStorageKey(serverId, worldId)
|
|
||||||
if (!key) return
|
|
||||||
if (plans.size === 0) {
|
|
||||||
localStorage.removeItem(key)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
localStorage.setItem(key, JSON.stringify(Array.from(plans.entries())))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
|
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
|
||||||
if (project.organization) {
|
if (project.organization) {
|
||||||
const ownerId = project.organization_id ?? project.organization
|
const ownerId = project.organization_id ?? project.organization
|
||||||
@@ -235,7 +207,7 @@ export function createServerInstallContent(opts: {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const { addNotification, handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
|
|
||||||
const serverIdQuery = computed(() => readQueryString(route.query.sid))
|
const serverIdQuery = computed(() => readQueryString(route.query.sid))
|
||||||
const worldIdQuery = computed(() => readQueryString(route.query.wid))
|
const worldIdQuery = computed(() => readQueryString(route.query.wid))
|
||||||
@@ -340,7 +312,7 @@ export function createServerInstallContent(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedWorldId) {
|
if (resolvedWorldId) {
|
||||||
queuedServerInstalls.value = readStoredQueue(sid, resolvedWorldId)
|
queuedServerInstalls.value = readStoredServerInstallQueue(sid, resolvedWorldId)
|
||||||
await refreshServerInstalledContent(sid, resolvedWorldId)
|
await refreshServerInstalledContent(sid, resolvedWorldId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,7 +330,7 @@ export function createServerInstallContent(opts: {
|
|||||||
if (sid !== prevSid) {
|
if (sid !== prevSid) {
|
||||||
serverContentProjectIds.value = new Set()
|
serverContentProjectIds.value = new Set()
|
||||||
serverContentInstallKeys.value = new Set()
|
serverContentInstallKeys.value = new Set()
|
||||||
queuedServerInstalls.value = readStoredQueue(sid, wid)
|
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||||
try {
|
try {
|
||||||
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -367,7 +339,7 @@ export function createServerInstallContent(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (wid !== prevWid) {
|
if (wid !== prevWid) {
|
||||||
queuedServerInstalls.value = readStoredQueue(sid, wid)
|
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wid && (sid !== prevSid || wid !== prevWid)) {
|
if (wid && (sid !== prevSid || wid !== prevWid)) {
|
||||||
@@ -432,11 +404,21 @@ export function createServerInstallContent(opts: {
|
|||||||
setQueuedServerInstallPlans(nextPlans)
|
setQueuedServerInstallPlans(nextPlans)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStoredServerInstallPlans(
|
||||||
|
serverId: string,
|
||||||
|
worldId: string,
|
||||||
|
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||||
|
) {
|
||||||
|
if (serverId === serverIdQuery.value && worldId === effectiveServerWorldId.value) {
|
||||||
|
queuedServerInstalls.value = plans
|
||||||
|
}
|
||||||
|
writeStoredServerInstallQueue(serverId, worldId, plans)
|
||||||
|
}
|
||||||
|
|
||||||
async function flushQueuedServerInstalls(
|
async function flushQueuedServerInstalls(
|
||||||
serverId: string | null = serverIdQuery.value,
|
serverId: string | null = serverIdQuery.value,
|
||||||
worldId: string | null = effectiveServerWorldId.value,
|
worldId: string | null = effectiveServerWorldId.value,
|
||||||
) {
|
) {
|
||||||
if (queuedServerInstalls.value.size === 0) return true
|
|
||||||
if (isInstallingQueuedServerInstalls.value) return false
|
if (isInstallingQueuedServerInstalls.value) return false
|
||||||
|
|
||||||
if (!serverId || !worldId) {
|
if (!serverId || !worldId) {
|
||||||
@@ -444,50 +426,53 @@ export function createServerInstallContent(opts: {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const installedProjectIds = new Set<string>()
|
const queuedPlans = getStoredServerAddonInstallQueue<InstallableSearchResult>(serverId, worldId)
|
||||||
|
if (queuedPlans.size === 0) return true
|
||||||
|
|
||||||
isInstallingQueuedServerInstalls.value = true
|
isInstallingQueuedServerInstalls.value = true
|
||||||
queuedInstallProgress.value = {
|
queuedInstallProgress.value = {
|
||||||
completed: 0,
|
completed: 0,
|
||||||
total: queuedServerInstalls.value.size,
|
total: queuedPlans.size,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await flushInstallQueue({
|
const result = await flushStoredServerAddonInstallQueue({
|
||||||
queue: {
|
serverId,
|
||||||
get: () => queuedServerInstalls.value,
|
worldId,
|
||||||
set: (plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>) => {
|
install: (plans) =>
|
||||||
queuedServerInstalls.value = plans
|
client.archon.content_v1.addAddons(
|
||||||
writeStoredQueue(serverId, worldId, plans)
|
serverId,
|
||||||
},
|
worldId,
|
||||||
},
|
plans.map((plan) => ({
|
||||||
install: async (plan) => {
|
project_id: plan.projectId,
|
||||||
await client.archon.content_v1.addAddon(serverId, worldId, {
|
version_id: plan.versionId,
|
||||||
project_id: plan.projectId,
|
})),
|
||||||
version_id: plan.versionId,
|
),
|
||||||
})
|
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
|
||||||
installedProjectIds.add(plan.projectId)
|
|
||||||
},
|
|
||||||
onError: (error, plan) => {
|
|
||||||
removePendingServerContentInstall(serverId, worldId, plan.projectId)
|
|
||||||
handleError(error as Error)
|
|
||||||
},
|
|
||||||
onProgress: (completed, total) => {
|
|
||||||
queuedInstallProgress.value = { completed, total }
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (installedProjectIds.size > 0) {
|
if (!result.ok) {
|
||||||
serverContentProjectIds.value = new Set([
|
for (const plan of result.attemptedPlans) {
|
||||||
...serverContentProjectIds.value,
|
removePendingServerContentInstall(serverId, worldId, plan.projectId)
|
||||||
...installedProjectIds,
|
}
|
||||||
])
|
handleError(result.error as Error)
|
||||||
serverContentInstallKeys.value = new Set([
|
return false
|
||||||
...serverContentInstallKeys.value,
|
|
||||||
...installedProjectIds,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.ok
|
queuedInstallProgress.value = {
|
||||||
|
completed: result.flushedPlans.length,
|
||||||
|
total: result.flushedPlans.length,
|
||||||
|
}
|
||||||
|
serverContentProjectIds.value = new Set([
|
||||||
|
...serverContentProjectIds.value,
|
||||||
|
...result.flushedPlans.map((plan) => plan.projectId),
|
||||||
|
])
|
||||||
|
serverContentInstallKeys.value = new Set([
|
||||||
|
...serverContentInstallKeys.value,
|
||||||
|
...result.flushedPlans.map((plan) => plan.projectId),
|
||||||
|
])
|
||||||
|
|
||||||
|
return true
|
||||||
} finally {
|
} finally {
|
||||||
isInstallingQueuedServerInstalls.value = false
|
isInstallingQueuedServerInstalls.value = false
|
||||||
queuedInstallProgress.value = { completed: 0, total: 0 }
|
queuedInstallProgress.value = { completed: 0, total: 0 }
|
||||||
@@ -522,17 +507,7 @@ export function createServerInstallContent(opts: {
|
|||||||
.catch((err) => handleError(err as Error))
|
.catch((err) => handleError(err as Error))
|
||||||
}
|
}
|
||||||
await router.push(backUrl)
|
await router.push(backUrl)
|
||||||
|
void flushQueuedServerInstalls(sid, wid)
|
||||||
const ok = await flushQueuedServerInstalls(sid, wid)
|
|
||||||
if (!ok) {
|
|
||||||
queuedServerInstalls.value = new Map()
|
|
||||||
writeStoredQueue(sid, wid, new Map())
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Some projects failed to install',
|
|
||||||
text: 'Failed projects were not added. You can try installing them again.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -545,7 +520,7 @@ export function createServerInstallContent(opts: {
|
|||||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||||
) {
|
) {
|
||||||
queuedServerInstalls.value = plans
|
queuedServerInstalls.value = plans
|
||||||
writeStoredQueue(serverIdQuery.value, effectiveServerWorldId.value, plans)
|
writeStoredServerInstallQueue(serverIdQuery.value, effectiveServerWorldId.value, plans)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onServerFlowBack() {
|
function onServerFlowBack() {
|
||||||
|
|||||||
746
apps/frontend/src/composables/use-server-install-content.ts
Normal file
746
apps/frontend/src/composables/use-server-install-content.ts
Normal file
@@ -0,0 +1,746 @@
|
|||||||
|
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||||
|
import type {
|
||||||
|
BrowseInstallContentType,
|
||||||
|
BrowseInstallPlan,
|
||||||
|
BrowseSearchState,
|
||||||
|
CreationFlowContextValue,
|
||||||
|
FilterValue,
|
||||||
|
PendingServerContentInstall,
|
||||||
|
PendingServerContentInstallType,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import {
|
||||||
|
addPendingServerContentInstalls,
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
flushStoredServerAddonInstallQueue,
|
||||||
|
getStoredServerAddonInstallQueue,
|
||||||
|
getTargetInstallPreferences,
|
||||||
|
injectModrinthClient,
|
||||||
|
injectNotificationManager,
|
||||||
|
readPendingServerContentInstalls,
|
||||||
|
readStoredServerInstallQueue,
|
||||||
|
removePendingServerContentInstall,
|
||||||
|
requestInstall,
|
||||||
|
useVIntl,
|
||||||
|
writePendingServerContentInstallBaseline,
|
||||||
|
writeStoredServerInstallQueue,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { navigateTo, useRoute } from '#app'
|
||||||
|
import { queryAsString } from '~/utils/router'
|
||||||
|
|
||||||
|
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
|
||||||
|
type ServerInstallBrowseSearchState = Pick<
|
||||||
|
BrowseSearchState,
|
||||||
|
'currentFilters' | 'overriddenProvidedFilterTypes'
|
||||||
|
>
|
||||||
|
type ServerInstallProjectType = {
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
type ServerInstallDebug = (...args: unknown[]) => void
|
||||||
|
|
||||||
|
export interface ServerInstallModalHandle {
|
||||||
|
show: () => void | Promise<void>
|
||||||
|
hide: () => void
|
||||||
|
ctx?: CreationFlowContextValue | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerInstallSearchResult extends Labrinth.Search.v2.ResultSearchProject {
|
||||||
|
installed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseServerInstallContentOptions {
|
||||||
|
projectType: ComputedRef<ServerInstallProjectType | undefined>
|
||||||
|
onboardingModalRef: Ref<ServerInstallModalHandle | null>
|
||||||
|
debug?: ServerInstallDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unsupportedContentType: {
|
||||||
|
id: 'discover.install.error.unsupported-content-type',
|
||||||
|
defaultMessage: 'This content type cannot be installed to a server from browse.',
|
||||||
|
},
|
||||||
|
noServerWorld: {
|
||||||
|
id: 'discover.install.error.no-server-world',
|
||||||
|
defaultMessage: 'No server world is available for install.',
|
||||||
|
},
|
||||||
|
backToSetup: {
|
||||||
|
id: 'discover.install.back-to-setup',
|
||||||
|
defaultMessage: 'Back to setup',
|
||||||
|
},
|
||||||
|
cancelReset: {
|
||||||
|
id: 'discover.install.cancel-reset',
|
||||||
|
defaultMessage: 'Cancel reset',
|
||||||
|
},
|
||||||
|
backToServer: {
|
||||||
|
id: 'discover.install.back-to-server',
|
||||||
|
defaultMessage: 'Back to server',
|
||||||
|
},
|
||||||
|
resetModpackHeading: {
|
||||||
|
id: 'discover.install.heading.reset-modpack',
|
||||||
|
defaultMessage: 'Selecting modpack to install after reset',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
|
||||||
|
if (project.organization) {
|
||||||
|
const ownerId = project.organization_id ?? project.organization
|
||||||
|
return {
|
||||||
|
id: ownerId,
|
||||||
|
name: project.organization,
|
||||||
|
type: 'organization' as const,
|
||||||
|
link: `/organization/${ownerId}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project.author) return null
|
||||||
|
|
||||||
|
const ownerId = project.author_id ?? project.author
|
||||||
|
return {
|
||||||
|
id: ownerId,
|
||||||
|
name: project.author,
|
||||||
|
type: 'user' as const,
|
||||||
|
link: `/user/${ownerId}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueuedAddonInstallPlans(
|
||||||
|
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||||
|
) {
|
||||||
|
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServerInstallContent({
|
||||||
|
projectType,
|
||||||
|
onboardingModalRef,
|
||||||
|
debug = () => {},
|
||||||
|
}: UseServerInstallContentOptions) {
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const route = useRoute()
|
||||||
|
const { handleError } = injectNotificationManager()
|
||||||
|
let browseSearchState: ServerInstallBrowseSearchState | null = null
|
||||||
|
|
||||||
|
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
|
||||||
|
const fromContext = computed(() => queryAsString(route.query.from) || null)
|
||||||
|
const currentWorldId = computed(() => queryAsString(route.query.wid) || null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: serverData,
|
||||||
|
isLoading: serverDataLoading,
|
||||||
|
error: serverDataError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: computed(() => ['servers', 'detail', currentServerId.value] as const),
|
||||||
|
queryFn: () => {
|
||||||
|
debug('serverData queryFn firing for:', currentServerId.value)
|
||||||
|
return client.archon.servers_v0.get(currentServerId.value!)
|
||||||
|
},
|
||||||
|
enabled: computed(() => {
|
||||||
|
const enabled = !!currentServerId.value
|
||||||
|
debug('serverData enabled:', enabled)
|
||||||
|
return enabled
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(serverData, (val) =>
|
||||||
|
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
|
||||||
|
)
|
||||||
|
watch(serverDataLoading, (val) => debug('serverData loading:', val))
|
||||||
|
watch(serverDataError, (val) => {
|
||||||
|
if (val) debug('serverData error:', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverIcon = computed(() => {
|
||||||
|
if (!currentServerId.value || !import.meta.client) return null
|
||||||
|
return localStorage.getItem(`server-icon-${currentServerId.value}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverHideInstalled = ref(false)
|
||||||
|
const hideSelectedServerInstalls = ref(false)
|
||||||
|
const installingProjectIds = ref<Set<string>>(new Set())
|
||||||
|
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
|
||||||
|
const hiddenInstalledProjectIds = ref<Set<string>>(new Set())
|
||||||
|
const hiddenInstalledProjectIdsInitialized = ref(false)
|
||||||
|
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<ServerInstallSearchResult>>>(
|
||||||
|
readStoredServerInstallQueue(currentServerId.value, currentWorldId.value),
|
||||||
|
)
|
||||||
|
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
|
||||||
|
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
|
||||||
|
const selectedServerInstallProjects = computed(() =>
|
||||||
|
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
|
||||||
|
id: plan.projectId,
|
||||||
|
name: plan.project.title ?? formatMessage(commonMessages.projectLabel),
|
||||||
|
iconUrl: plan.project.icon_url ?? null,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const isInstallingQueuedServerInstalls = ref(false)
|
||||||
|
const queuedInstallProgress = ref({ completed: 0, total: 0 })
|
||||||
|
const serverInstallQueue = {
|
||||||
|
get: () => queuedServerInstalls.value,
|
||||||
|
set: (plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>) => {
|
||||||
|
queuedServerInstalls.value = plans
|
||||||
|
writeStoredServerInstallQueue(currentServerId.value, currentWorldId.value, plans)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
|
||||||
|
const { data: serverContentData, error: serverContentError } = useQuery({
|
||||||
|
queryKey: contentQueryKey,
|
||||||
|
queryFn: () =>
|
||||||
|
client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!),
|
||||||
|
enabled: computed(() => !!currentServerId.value && !!currentWorldId.value),
|
||||||
|
})
|
||||||
|
|
||||||
|
function setBrowseSearchState(state: ServerInstallBrowseSearchState) {
|
||||||
|
browseSearchState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStoredServerInstallPlans(
|
||||||
|
serverId: string,
|
||||||
|
worldId: string,
|
||||||
|
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||||
|
) {
|
||||||
|
if (serverId === currentServerId.value && worldId === currentWorldId.value) {
|
||||||
|
queuedServerInstalls.value = plans
|
||||||
|
}
|
||||||
|
writeStoredServerInstallQueue(serverId, worldId, plans)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getQueuedInstallOwner(project: ServerInstallSearchResult) {
|
||||||
|
const fallback = getQueuedInstallOwnerFallback(project)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (project.organization) {
|
||||||
|
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
|
||||||
|
if (organization) {
|
||||||
|
return {
|
||||||
|
id: organization.id,
|
||||||
|
name: organization.name,
|
||||||
|
type: 'organization' as const,
|
||||||
|
avatar_url: organization.icon_url ?? undefined,
|
||||||
|
link: `/organization/${organization.slug}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
|
||||||
|
const owner =
|
||||||
|
members.find((member) => member.user.id === project.author_id)?.user ??
|
||||||
|
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
|
||||||
|
members[0]?.user
|
||||||
|
|
||||||
|
if (owner) {
|
||||||
|
return {
|
||||||
|
id: owner.id,
|
||||||
|
name: owner.username,
|
||||||
|
type: 'user' as const,
|
||||||
|
avatar_url: owner.avatar_url,
|
||||||
|
link: `/user/${owner.username}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueuedInstallPlaceholder(
|
||||||
|
plan: BrowseInstallPlan<ServerInstallSearchResult>,
|
||||||
|
owner: PendingServerContentInstallInput['owner'],
|
||||||
|
): PendingServerContentInstallInput {
|
||||||
|
return {
|
||||||
|
projectId: plan.projectId,
|
||||||
|
versionId: plan.versionId,
|
||||||
|
contentType: plan.contentType as PendingServerContentInstallType,
|
||||||
|
title: plan.project.title ?? formatMessage(commonMessages.projectLabel),
|
||||||
|
versionName: plan.versionName ?? null,
|
||||||
|
versionNumber: plan.versionNumber ?? null,
|
||||||
|
fileName: plan.fileName ?? null,
|
||||||
|
owner,
|
||||||
|
slug: plan.project.slug ?? plan.projectId,
|
||||||
|
iconUrl: plan.project.icon_url ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueuedInstallPlaceholderFallbacks(
|
||||||
|
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||||
|
) {
|
||||||
|
return getQueuedAddonInstallPlans(plans).map((plan) =>
|
||||||
|
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getQueuedInstallPlaceholders(
|
||||||
|
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||||
|
) {
|
||||||
|
return Promise.all(
|
||||||
|
getQueuedAddonInstallPlans(plans).map(async (plan) =>
|
||||||
|
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProjectInstalling(projectId: string, installing: boolean) {
|
||||||
|
const next = new Set(installingProjectIds.value)
|
||||||
|
if (installing) {
|
||||||
|
next.add(projectId)
|
||||||
|
} else {
|
||||||
|
next.delete(projectId)
|
||||||
|
}
|
||||||
|
installingProjectIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function markProjectInstalled(projectId: string) {
|
||||||
|
optimisticallyInstalledProjectIds.value = new Set([
|
||||||
|
...optimisticallyInstalledProjectIds.value,
|
||||||
|
projectId,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerInstalledProjectIds(data = serverContentData.value) {
|
||||||
|
return new Set(
|
||||||
|
(data?.addons ?? [])
|
||||||
|
.map((addon) => addon.project_id)
|
||||||
|
.filter((projectId): projectId is string => !!projectId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerInstalledContentKeys(data = serverContentData.value) {
|
||||||
|
return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHiddenInstalledProjectIds() {
|
||||||
|
hiddenInstalledProjectIds.value = new Set([
|
||||||
|
...getServerInstalledProjectIds(),
|
||||||
|
...optimisticallyInstalledProjectIds.value,
|
||||||
|
])
|
||||||
|
hiddenInstalledProjectIdsInitialized.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverFilters = computed<FilterValue[]>(() => {
|
||||||
|
debug(
|
||||||
|
'serverFilters recomputing, serverData:',
|
||||||
|
!!serverData.value,
|
||||||
|
'projectType:',
|
||||||
|
projectType.value?.id,
|
||||||
|
)
|
||||||
|
const filters: FilterValue[] = []
|
||||||
|
if (serverData.value && projectType.value?.id !== 'modpack') {
|
||||||
|
const gameVersion = serverData.value.mc_version
|
||||||
|
if (gameVersion) {
|
||||||
|
filters.push({ type: 'game_version', option: gameVersion })
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = serverData.value.loader?.toLowerCase()
|
||||||
|
|
||||||
|
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
|
||||||
|
if (platform && modLoaders.includes(platform)) {
|
||||||
|
filters.push({ type: 'mod_loader', option: platform })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginLoaders = ['paper', 'purpur']
|
||||||
|
if (platform && pluginLoaders.includes(platform)) {
|
||||||
|
filters.push({ type: 'plugin_loader', option: platform })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectType.value?.id === 'mod') {
|
||||||
|
filters.push({ type: 'environment', option: 'server' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
|
||||||
|
for (const x of hiddenInstalledProjectIds.value) {
|
||||||
|
filters.push({
|
||||||
|
type: 'project_id',
|
||||||
|
option: `project_id:${x}`,
|
||||||
|
negative: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) {
|
||||||
|
for (const id of queuedServerInstallProjectIds.value) {
|
||||||
|
filters.push({
|
||||||
|
type: 'project_id',
|
||||||
|
option: `project_id:${id}`,
|
||||||
|
negative: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentServerId.value && projectType.value?.id === 'modpack') {
|
||||||
|
filters.push(
|
||||||
|
{ type: 'environment', option: 'client' },
|
||||||
|
{ type: 'environment', option: 'server' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
debug('serverFilters result:', filters)
|
||||||
|
return filters
|
||||||
|
})
|
||||||
|
|
||||||
|
function getCurrentServerInstallType(): BrowseInstallContentType {
|
||||||
|
const type = projectType.value?.id
|
||||||
|
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
throw new Error(formatMessage(messages.unsupportedContentType))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
|
||||||
|
return getTargetInstallPreferences(
|
||||||
|
{
|
||||||
|
gameVersion: serverData.value?.mc_version,
|
||||||
|
loader: serverData.value?.loader,
|
||||||
|
},
|
||||||
|
contentType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstallProjectVersions(projectId: string) {
|
||||||
|
return client.labrinth.versions_v2.getProjectVersions(projectId, {
|
||||||
|
include_changelog: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueuedServerInstalls() {
|
||||||
|
serverInstallQueue.set(new Map())
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeQueuedServerInstall(projectId: string) {
|
||||||
|
const nextPlans = new Map(queuedServerInstalls.value)
|
||||||
|
nextPlans.delete(projectId)
|
||||||
|
serverInstallQueue.set(nextPlans)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushQueuedServerInstalls(
|
||||||
|
serverId: string | null = currentServerId.value,
|
||||||
|
worldId: string | null = currentWorldId.value,
|
||||||
|
) {
|
||||||
|
if (isInstallingQueuedServerInstalls.value) return false
|
||||||
|
|
||||||
|
if (!serverId || !worldId) {
|
||||||
|
handleError(new Error(formatMessage(messages.noServerWorld)))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedPlans = getStoredServerAddonInstallQueue<ServerInstallSearchResult>(
|
||||||
|
serverId,
|
||||||
|
worldId,
|
||||||
|
)
|
||||||
|
if (queuedPlans.size === 0) return true
|
||||||
|
|
||||||
|
isInstallingQueuedServerInstalls.value = true
|
||||||
|
queuedInstallProgress.value = {
|
||||||
|
completed: 0,
|
||||||
|
total: queuedPlans.size,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await flushStoredServerAddonInstallQueue({
|
||||||
|
serverId,
|
||||||
|
worldId,
|
||||||
|
install: (plans) =>
|
||||||
|
client.archon.content_v1.addAddons(
|
||||||
|
serverId,
|
||||||
|
worldId,
|
||||||
|
plans.map((plan) => ({
|
||||||
|
project_id: plan.projectId,
|
||||||
|
version_id: plan.versionId,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
for (const plan of result.attemptedPlans) {
|
||||||
|
removePendingServerContentInstall(serverId, worldId, plan.projectId)
|
||||||
|
}
|
||||||
|
handleError(result.error as Error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plan of result.flushedPlans) {
|
||||||
|
markProjectInstalled(plan.projectId)
|
||||||
|
}
|
||||||
|
queuedInstallProgress.value = {
|
||||||
|
completed: result.flushedPlans.length,
|
||||||
|
total: result.flushedPlans.length,
|
||||||
|
}
|
||||||
|
if (result.flushedPlans.length > 0) {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} finally {
|
||||||
|
isInstallingQueuedServerInstalls.value = false
|
||||||
|
queuedInstallProgress.value = { completed: 0, total: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discardQueuedServerInstallsAndBack() {
|
||||||
|
clearQueuedServerInstalls()
|
||||||
|
await navigateTo(serverBackUrl.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installQueuedServerInstallsAndBack() {
|
||||||
|
const sid = currentServerId.value
|
||||||
|
const wid = currentWorldId.value
|
||||||
|
const backUrl = serverBackUrl.value
|
||||||
|
const plans = new Map(queuedServerInstalls.value)
|
||||||
|
|
||||||
|
if (sid && wid) {
|
||||||
|
writeStoredServerInstallQueue(sid, wid, plans)
|
||||||
|
writePendingServerContentInstallBaseline(sid, wid, [
|
||||||
|
...getServerInstalledContentKeys(),
|
||||||
|
...optimisticallyInstalledProjectIds.value,
|
||||||
|
])
|
||||||
|
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
|
||||||
|
void getQueuedInstallPlaceholders(plans)
|
||||||
|
.then((items) => {
|
||||||
|
const pendingProjectIds = new Set(
|
||||||
|
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
|
||||||
|
)
|
||||||
|
addPendingServerContentInstalls(
|
||||||
|
sid,
|
||||||
|
wid,
|
||||||
|
items.filter((item) => pendingProjectIds.has(item.projectId)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch((err) => handleError(err as Error))
|
||||||
|
}
|
||||||
|
await navigateTo(backUrl)
|
||||||
|
void flushQueuedServerInstalls(sid, wid)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serverInstall(project: ServerInstallSearchResult) {
|
||||||
|
if (!serverData.value || !currentServerId.value || !currentWorldId.value) {
|
||||||
|
handleError(new Error('No server to install to.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!browseSearchState) {
|
||||||
|
handleError(new Error('Search state is not ready.'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = getCurrentServerInstallType()
|
||||||
|
const isModpack = contentType === 'modpack'
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) {
|
||||||
|
removeQueuedServerInstall(project.project_id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) {
|
||||||
|
setProjectInstalling(project.project_id, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestInstall({
|
||||||
|
project,
|
||||||
|
contentType,
|
||||||
|
mode: isModpack ? 'immediate' : 'queue',
|
||||||
|
selectedFilters: isModpack ? [] : browseSearchState.currentFilters.value,
|
||||||
|
providedFilters: isModpack ? [] : serverFilters.value,
|
||||||
|
overriddenProvidedFilterTypes: isModpack
|
||||||
|
? []
|
||||||
|
: browseSearchState.overriddenProvidedFilterTypes.value,
|
||||||
|
targetPreferences: getServerInstallTargetPreferences(contentType),
|
||||||
|
getProjectVersions: getInstallProjectVersions,
|
||||||
|
queue: serverInstallQueue,
|
||||||
|
install: async (plan) => {
|
||||||
|
const modalInstance = onboardingModalRef.value
|
||||||
|
if (!modalInstance) {
|
||||||
|
setProjectInstalling(plan.projectId, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onboardingInstallingProject.value = plan.project
|
||||||
|
modalInstance.show()
|
||||||
|
await nextTick()
|
||||||
|
const ctx = modalInstance.ctx
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
ctx.setupType.value = 'modpack'
|
||||||
|
ctx.modpackSelection.value = {
|
||||||
|
projectId: plan.projectId,
|
||||||
|
versionId: plan.versionId,
|
||||||
|
name: plan.project.title,
|
||||||
|
iconUrl: plan.project.icon_url ?? undefined,
|
||||||
|
}
|
||||||
|
ctx.modal.value?.setStage('final-config')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
if (isModpack) {
|
||||||
|
setProjectInstalling(project.project_id, false)
|
||||||
|
}
|
||||||
|
handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`))
|
||||||
|
} finally {
|
||||||
|
if (!isModpack) {
|
||||||
|
setProjectInstalling(project.project_id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onboardingInstallingProject = ref<ServerInstallSearchResult | null>(null)
|
||||||
|
|
||||||
|
function onOnboardingHide() {
|
||||||
|
if (onboardingInstallingProject.value) {
|
||||||
|
setProjectInstalling(onboardingInstallingProject.value.project_id, false)
|
||||||
|
onboardingInstallingProject.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOnboardingBack() {
|
||||||
|
onboardingModalRef.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onModpackFlowCreate(config: CreationFlowContextValue) {
|
||||||
|
if (!currentServerId.value || !currentWorldId.value || !config.modpackSelection.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.archon.content_v1.installContent(currentServerId.value, currentWorldId.value, {
|
||||||
|
content_variant: 'modpack',
|
||||||
|
spec: {
|
||||||
|
platform: 'modrinth',
|
||||||
|
project_id: config.modpackSelection.value.projectId,
|
||||||
|
version_id: config.modpackSelection.value.versionId,
|
||||||
|
},
|
||||||
|
soft_override: false,
|
||||||
|
properties: config.buildProperties(),
|
||||||
|
} satisfies Archon.Content.v1.InstallWorldContent)
|
||||||
|
|
||||||
|
if (fromContext.value === 'onboarding') {
|
||||||
|
await client.archon.servers_v1.endIntro(currentServerId.value)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
|
||||||
|
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
|
||||||
|
} else {
|
||||||
|
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
handleError(new Error(`Error installing modpack: ${e}`))
|
||||||
|
config.loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverBackUrl = computed(() => {
|
||||||
|
if (!serverData.value) return ''
|
||||||
|
const id = serverData.value.server_id
|
||||||
|
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
|
||||||
|
if (fromContext.value === 'reset-server')
|
||||||
|
return `/hosting/manage/${id}?openSettings=installation`
|
||||||
|
return `/hosting/manage/${id}/content`
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverBackLabel = computed(() => {
|
||||||
|
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
|
||||||
|
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
|
||||||
|
return formatMessage(messages.backToServer)
|
||||||
|
})
|
||||||
|
|
||||||
|
const serverBrowseHeading = computed(() =>
|
||||||
|
fromContext.value === 'reset-server'
|
||||||
|
? formatMessage(messages.resetModpackHeading)
|
||||||
|
: formatMessage(commonMessages.installingContentLabel),
|
||||||
|
)
|
||||||
|
|
||||||
|
const installContext = computed(() => {
|
||||||
|
if (!serverData.value) return null
|
||||||
|
return {
|
||||||
|
name: serverData.value.name,
|
||||||
|
loader: serverData.value.loader ?? '',
|
||||||
|
gameVersion: serverData.value.mc_version ?? '',
|
||||||
|
serverId: currentServerId.value,
|
||||||
|
upstream: serverData.value.upstream,
|
||||||
|
iconSrc: serverIcon.value,
|
||||||
|
isMedal: serverData.value.is_medal,
|
||||||
|
backUrl: serverBackUrl.value,
|
||||||
|
backLabel: serverBackLabel.value,
|
||||||
|
heading: serverBrowseHeading.value,
|
||||||
|
queuedCount: queuedServerInstallCount.value,
|
||||||
|
selectedProjects: selectedServerInstallProjects.value,
|
||||||
|
isInstallingSelected: isInstallingQueuedServerInstalls.value,
|
||||||
|
installProgress: queuedInstallProgress.value,
|
||||||
|
clearQueued: clearQueuedServerInstalls,
|
||||||
|
clearSelected: clearQueuedServerInstalls,
|
||||||
|
onBack: flushQueuedServerInstalls,
|
||||||
|
discardSelectedAndBack: discardQueuedServerInstallsAndBack,
|
||||||
|
installSelected: installQueuedServerInstallsAndBack,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(serverContentError, (error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to load server content:', error)
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
serverContentData,
|
||||||
|
(data) => {
|
||||||
|
if (!data) return
|
||||||
|
if (!hiddenInstalledProjectIdsInitialized.value) {
|
||||||
|
syncHiddenInstalledProjectIds()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (route.query.shi && projectType.value?.id !== 'modpack') {
|
||||||
|
serverHideInstalled.value = route.query.shi === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(serverHideInstalled, (hideInstalled) => {
|
||||||
|
if (hideInstalled) {
|
||||||
|
syncHiddenInstalledProjectIds()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([currentServerId, currentWorldId], ([serverId, worldId], [prevServerId, prevWorldId]) => {
|
||||||
|
if (serverId !== prevServerId || worldId !== prevWorldId) {
|
||||||
|
queuedServerInstalls.value = readStoredServerInstallQueue(serverId, worldId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(queuedServerInstallCount, (count) => {
|
||||||
|
if (count === 0) {
|
||||||
|
hideSelectedServerInstalls.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentServerId,
|
||||||
|
fromContext,
|
||||||
|
currentWorldId,
|
||||||
|
serverData,
|
||||||
|
serverContentData,
|
||||||
|
serverFilters,
|
||||||
|
serverHideInstalled,
|
||||||
|
hideSelectedServerInstalls,
|
||||||
|
installingProjectIds,
|
||||||
|
optimisticallyInstalledProjectIds,
|
||||||
|
queuedServerInstallProjectIds,
|
||||||
|
queuedServerInstallCount,
|
||||||
|
isInstallingQueuedServerInstalls,
|
||||||
|
installContext,
|
||||||
|
setBrowseSearchState,
|
||||||
|
syncHiddenInstalledProjectIds,
|
||||||
|
serverInstall,
|
||||||
|
onOnboardingHide,
|
||||||
|
onOnboardingBack,
|
||||||
|
onModpackFlowCreate,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1241,12 +1241,6 @@
|
|||||||
"discover.install.error.no-server-world": {
|
"discover.install.error.no-server-world": {
|
||||||
"message": "No server world is available for install."
|
"message": "No server world is available for install."
|
||||||
},
|
},
|
||||||
"discover.install.error.some-projects-failed.description": {
|
|
||||||
"message": "Failed projects were not added. You can try installing them again."
|
|
||||||
},
|
|
||||||
"discover.install.error.some-projects-failed.title": {
|
|
||||||
"message": "Some projects failed to install"
|
|
||||||
},
|
|
||||||
"discover.install.error.unsupported-content-type": {
|
"discover.install.error.unsupported-content-type": {
|
||||||
"message": "This content type cannot be installed to a server from browse."
|
"message": "This content type cannot be installed to a server from browse."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import {
|
import {
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@@ -11,47 +11,37 @@ import {
|
|||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import type {
|
import type { CardAction } from '@modrinth/ui'
|
||||||
BrowseInstallContentType,
|
|
||||||
BrowseInstallPlan,
|
|
||||||
CardAction,
|
|
||||||
CreationFlowContextValue,
|
|
||||||
PendingServerContentInstall,
|
|
||||||
PendingServerContentInstallType,
|
|
||||||
} from '@modrinth/ui'
|
|
||||||
import {
|
import {
|
||||||
addPendingServerContentInstalls,
|
|
||||||
BrowseInstallHeader,
|
BrowseInstallHeader,
|
||||||
BrowsePageLayout,
|
BrowsePageLayout,
|
||||||
BrowseSidebar,
|
BrowseSidebar,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
CreationFlowModal,
|
CreationFlowModal,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
flushInstallQueue,
|
|
||||||
getTargetInstallPreferences,
|
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
|
||||||
PROJECT_DEP_MARKER_QUERY,
|
PROJECT_DEP_MARKER_QUERY,
|
||||||
provideBrowseManager,
|
provideBrowseManager,
|
||||||
readPendingServerContentInstalls,
|
|
||||||
removePendingServerContentInstall,
|
|
||||||
requestInstall,
|
|
||||||
SelectedProjectsFloatingBar,
|
SelectedProjectsFloatingBar,
|
||||||
useBrowseSearch,
|
useBrowseSearch,
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
useStickyObserver,
|
useStickyObserver,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
writePendingServerContentInstallBaseline,
|
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { cycleValue } from '@modrinth/utils'
|
import { cycleValue } from '@modrinth/utils'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import { useTimeoutFn } from '@vueuse/core'
|
import { useTimeoutFn } from '@vueuse/core'
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
||||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||||
import { projectQueryOptions } from '~/composables/queries/project'
|
import { projectQueryOptions } from '~/composables/queries/project'
|
||||||
import { versionQueryOptions } from '~/composables/queries/version'
|
import { versionQueryOptions } from '~/composables/queries/version'
|
||||||
|
import type {
|
||||||
|
ServerInstallModalHandle,
|
||||||
|
ServerInstallSearchResult,
|
||||||
|
} from '~/composables/use-server-install-content'
|
||||||
|
import { useServerInstallContent } from '~/composables/use-server-install-content'
|
||||||
import { withLabrinthCanaryHeader } from '~/helpers/canary.ts'
|
import { withLabrinthCanaryHeader } from '~/helpers/canary.ts'
|
||||||
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
|
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
|
||||||
|
|
||||||
@@ -75,8 +65,6 @@ const tags = useGeneratedState()
|
|||||||
const flags = useFeatureFlags()
|
const flags = useFeatureFlags()
|
||||||
const auth = await useAuth()
|
const auth = await useAuth()
|
||||||
|
|
||||||
const { addNotification, handleError } = injectNotificationManager()
|
|
||||||
|
|
||||||
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
|
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
|
||||||
const HOVER_DURATION_TO_PREFETCH_MS = 500
|
const HOVER_DURATION_TO_PREFETCH_MS = 500
|
||||||
|
|
||||||
@@ -161,524 +149,33 @@ function cycleSearchDisplayMode() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
|
const onboardingModalRef = ref<ServerInstallModalHandle | null>(null)
|
||||||
const fromContext = computed(() => queryAsString(route.query.from) || null)
|
|
||||||
const currentWorldId = computed(() => queryAsString(route.query.wid) || null)
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: serverData,
|
currentServerId,
|
||||||
isLoading: serverDataLoading,
|
fromContext,
|
||||||
error: serverDataError,
|
serverData,
|
||||||
} = useQuery({
|
|
||||||
queryKey: computed(() => ['servers', 'detail', currentServerId.value] as const),
|
|
||||||
queryFn: () => {
|
|
||||||
debug('serverData queryFn firing for:', currentServerId.value)
|
|
||||||
return client.archon.servers_v0.get(currentServerId.value!)
|
|
||||||
},
|
|
||||||
enabled: computed(() => {
|
|
||||||
const enabled = !!currentServerId.value
|
|
||||||
debug('serverData enabled:', enabled)
|
|
||||||
return enabled
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(serverData, (val) =>
|
|
||||||
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
|
|
||||||
)
|
|
||||||
watch(serverDataLoading, (val) => debug('serverData loading:', val))
|
|
||||||
watch(serverDataError, (val) => {
|
|
||||||
if (val) debug('serverData error:', val)
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverIcon = computed(() => {
|
|
||||||
if (!currentServerId.value || !import.meta.client) return null
|
|
||||||
return localStorage.getItem(`server-icon-${currentServerId.value}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverHideInstalled = ref(false)
|
|
||||||
const hideSelectedServerInstalls = ref(false)
|
|
||||||
const installingProjectIds = ref<Set<string>>(new Set())
|
|
||||||
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
|
|
||||||
const hiddenInstalledProjectIds = ref<Set<string>>(new Set())
|
|
||||||
const hiddenInstalledProjectIdsInitialized = ref(false)
|
|
||||||
|
|
||||||
interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject {
|
|
||||||
installed?: boolean
|
|
||||||
}
|
|
||||||
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
|
|
||||||
|
|
||||||
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(new Map())
|
|
||||||
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
|
|
||||||
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
|
|
||||||
const selectedServerInstallProjects = computed(() =>
|
|
||||||
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
|
|
||||||
id: plan.projectId,
|
|
||||||
name: plan.project.title ?? formatMessage(commonMessages.projectLabel),
|
|
||||||
iconUrl: plan.project.icon_url ?? null,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const isInstallingQueuedServerInstalls = ref(false)
|
|
||||||
const queuedInstallProgress = ref({ completed: 0, total: 0 })
|
|
||||||
const serverInstallQueue = {
|
|
||||||
get: () => queuedServerInstalls.value,
|
|
||||||
set: (plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>) => {
|
|
||||||
queuedServerInstalls.value = plans
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
|
|
||||||
if (project.organization) {
|
|
||||||
const ownerId = project.organization_id ?? project.organization
|
|
||||||
return {
|
|
||||||
id: ownerId,
|
|
||||||
name: project.organization,
|
|
||||||
type: 'organization' as const,
|
|
||||||
link: `/organization/${ownerId}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project.author) return null
|
|
||||||
|
|
||||||
const ownerId = project.author_id ?? project.author
|
|
||||||
return {
|
|
||||||
id: ownerId,
|
|
||||||
name: project.author,
|
|
||||||
type: 'user' as const,
|
|
||||||
link: `/user/${ownerId}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getQueuedInstallOwner(project: InstallableSearchResult) {
|
|
||||||
const fallback = getQueuedInstallOwnerFallback(project)
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (project.organization) {
|
|
||||||
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
|
|
||||||
if (organization) {
|
|
||||||
return {
|
|
||||||
id: organization.id,
|
|
||||||
name: organization.name,
|
|
||||||
type: 'organization' as const,
|
|
||||||
avatar_url: organization.icon_url ?? undefined,
|
|
||||||
link: `/organization/${organization.slug}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
|
|
||||||
const owner =
|
|
||||||
members.find((member) => member.user.id === project.author_id)?.user ??
|
|
||||||
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
|
|
||||||
members[0]?.user
|
|
||||||
|
|
||||||
if (owner) {
|
|
||||||
return {
|
|
||||||
id: owner.id,
|
|
||||||
name: owner.username,
|
|
||||||
type: 'user' as const,
|
|
||||||
avatar_url: owner.avatar_url,
|
|
||||||
link: `/user/${owner.username}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQueuedAddonInstallPlans(
|
|
||||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
|
||||||
) {
|
|
||||||
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQueuedInstallPlaceholder(
|
|
||||||
plan: BrowseInstallPlan<InstallableSearchResult>,
|
|
||||||
owner: PendingServerContentInstallInput['owner'],
|
|
||||||
): PendingServerContentInstallInput {
|
|
||||||
return {
|
|
||||||
projectId: plan.projectId,
|
|
||||||
versionId: plan.versionId,
|
|
||||||
contentType: plan.contentType as PendingServerContentInstallType,
|
|
||||||
title: plan.project.title ?? formatMessage(commonMessages.projectLabel),
|
|
||||||
versionName: plan.versionName ?? null,
|
|
||||||
versionNumber: plan.versionNumber ?? null,
|
|
||||||
fileName: plan.fileName ?? null,
|
|
||||||
owner,
|
|
||||||
slug: plan.project.slug ?? plan.projectId,
|
|
||||||
iconUrl: plan.project.icon_url ?? null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQueuedInstallPlaceholderFallbacks(
|
|
||||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
|
||||||
) {
|
|
||||||
return getQueuedAddonInstallPlans(plans).map((plan) =>
|
|
||||||
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getQueuedInstallPlaceholders(
|
|
||||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
|
||||||
) {
|
|
||||||
return Promise.all(
|
|
||||||
getQueuedAddonInstallPlans(plans).map(async (plan) =>
|
|
||||||
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setProjectInstalling(projectId: string, installing: boolean) {
|
|
||||||
const next = new Set(installingProjectIds.value)
|
|
||||||
if (installing) {
|
|
||||||
next.add(projectId)
|
|
||||||
} else {
|
|
||||||
next.delete(projectId)
|
|
||||||
}
|
|
||||||
installingProjectIds.value = next
|
|
||||||
}
|
|
||||||
|
|
||||||
function markProjectInstalled(projectId: string) {
|
|
||||||
optimisticallyInstalledProjectIds.value = new Set([
|
|
||||||
...optimisticallyInstalledProjectIds.value,
|
|
||||||
projectId,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerInstalledProjectIds(data = serverContentData.value) {
|
|
||||||
return new Set(
|
|
||||||
(data?.addons ?? [])
|
|
||||||
.map((addon) => addon.project_id)
|
|
||||||
.filter((projectId): projectId is string => !!projectId),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerInstalledContentKeys(data = serverContentData.value) {
|
|
||||||
return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename))
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncHiddenInstalledProjectIds() {
|
|
||||||
hiddenInstalledProjectIds.value = new Set([
|
|
||||||
...getServerInstalledProjectIds(),
|
|
||||||
...optimisticallyInstalledProjectIds.value,
|
|
||||||
])
|
|
||||||
hiddenInstalledProjectIdsInitialized.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
|
|
||||||
const { data: serverContentData, error: serverContentError } = useQuery({
|
|
||||||
queryKey: contentQueryKey,
|
|
||||||
queryFn: () => client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!),
|
|
||||||
enabled: computed(() => !!currentServerId.value && !!currentWorldId.value),
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(serverContentError, (error) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('Failed to load server content:', error)
|
|
||||||
handleError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
serverContentData,
|
serverContentData,
|
||||||
(data) => {
|
serverFilters,
|
||||||
if (!data) return
|
serverHideInstalled,
|
||||||
if (!hiddenInstalledProjectIdsInitialized.value) {
|
hideSelectedServerInstalls,
|
||||||
syncHiddenInstalledProjectIds()
|
installingProjectIds,
|
||||||
}
|
optimisticallyInstalledProjectIds,
|
||||||
},
|
queuedServerInstallProjectIds,
|
||||||
{ immediate: true },
|
queuedServerInstallCount,
|
||||||
)
|
isInstallingQueuedServerInstalls,
|
||||||
|
installContext,
|
||||||
const installContentMutation = useMutation({
|
setBrowseSearchState,
|
||||||
mutationFn: ({
|
syncHiddenInstalledProjectIds,
|
||||||
serverId,
|
serverInstall,
|
||||||
worldId,
|
onOnboardingHide,
|
||||||
projectId,
|
onOnboardingBack,
|
||||||
versionId,
|
onModpackFlowCreate,
|
||||||
}: {
|
} = useServerInstallContent({
|
||||||
serverId: string
|
projectType,
|
||||||
worldId: string
|
onboardingModalRef,
|
||||||
projectId: string
|
debug,
|
||||||
versionId: string
|
|
||||||
}) =>
|
|
||||||
client.archon.content_v1.addAddon(serverId, worldId, {
|
|
||||||
project_id: projectId,
|
|
||||||
version_id: versionId,
|
|
||||||
}),
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', variables.serverId] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (route.query.shi && projectType.value?.id !== 'modpack') {
|
|
||||||
serverHideInstalled.value = route.query.shi === 'true'
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(serverHideInstalled, (hideInstalled) => {
|
|
||||||
if (hideInstalled) {
|
|
||||||
syncHiddenInstalledProjectIds()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverFilters = computed(() => {
|
|
||||||
debug(
|
|
||||||
'serverFilters recomputing, serverData:',
|
|
||||||
!!serverData.value,
|
|
||||||
'projectType:',
|
|
||||||
projectType.value?.id,
|
|
||||||
)
|
|
||||||
const filters = []
|
|
||||||
if (serverData.value && projectType.value?.id !== 'modpack') {
|
|
||||||
const gameVersion = serverData.value.mc_version
|
|
||||||
if (gameVersion) {
|
|
||||||
filters.push({ type: 'game_version', option: gameVersion })
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform = serverData.value.loader?.toLowerCase()
|
|
||||||
|
|
||||||
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
|
|
||||||
if (platform && modLoaders.includes(platform)) {
|
|
||||||
filters.push({ type: 'mod_loader', option: platform })
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginLoaders = ['paper', 'purpur']
|
|
||||||
if (platform && pluginLoaders.includes(platform)) {
|
|
||||||
filters.push({ type: 'plugin_loader', option: platform })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectType.value?.id === 'mod') {
|
|
||||||
filters.push({ type: 'environment', option: 'server' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
|
|
||||||
for (const x of hiddenInstalledProjectIds.value) {
|
|
||||||
filters.push({
|
|
||||||
type: 'project_id',
|
|
||||||
option: `project_id:${x}`,
|
|
||||||
negative: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) {
|
|
||||||
for (const id of queuedServerInstallProjectIds.value) {
|
|
||||||
filters.push({
|
|
||||||
type: 'project_id',
|
|
||||||
option: `project_id:${id}`,
|
|
||||||
negative: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentServerId.value && projectType.value?.id === 'modpack') {
|
|
||||||
filters.push(
|
|
||||||
{ type: 'environment', option: 'client' },
|
|
||||||
{ type: 'environment', option: 'server' },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
debug('serverFilters result:', filters)
|
|
||||||
return filters
|
|
||||||
})
|
|
||||||
|
|
||||||
function getCurrentServerInstallType(): BrowseInstallContentType {
|
|
||||||
const type = projectType.value?.id
|
|
||||||
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
|
|
||||||
return type
|
|
||||||
}
|
|
||||||
throw new Error(formatMessage(messages.unsupportedContentType))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
|
|
||||||
return getTargetInstallPreferences(
|
|
||||||
{
|
|
||||||
gameVersion: serverData.value?.mc_version,
|
|
||||||
loader: serverData.value?.loader,
|
|
||||||
},
|
|
||||||
contentType,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInstallProjectVersions(projectId: string) {
|
|
||||||
return client.labrinth.versions_v2.getProjectVersions(projectId, {
|
|
||||||
include_changelog: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearQueuedServerInstalls() {
|
|
||||||
queuedServerInstalls.value = new Map()
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeQueuedServerInstall(projectId: string) {
|
|
||||||
const nextPlans = new Map(queuedServerInstalls.value)
|
|
||||||
nextPlans.delete(projectId)
|
|
||||||
queuedServerInstalls.value = nextPlans
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([currentServerId, currentWorldId], ([serverId, worldId], [prevServerId, prevWorldId]) => {
|
|
||||||
if (serverId !== prevServerId || worldId !== prevWorldId) {
|
|
||||||
clearQueuedServerInstalls()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function flushQueuedServerInstalls(
|
|
||||||
serverId: string | null = currentServerId.value,
|
|
||||||
worldId: string | null = currentWorldId.value,
|
|
||||||
) {
|
|
||||||
if (queuedServerInstalls.value.size === 0) return true
|
|
||||||
if (isInstallingQueuedServerInstalls.value) return false
|
|
||||||
|
|
||||||
if (!serverId || !worldId) {
|
|
||||||
handleError(new Error(formatMessage(messages.noServerWorld)))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
isInstallingQueuedServerInstalls.value = true
|
|
||||||
queuedInstallProgress.value = {
|
|
||||||
completed: 0,
|
|
||||||
total: queuedServerInstalls.value.size,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await flushInstallQueue({
|
|
||||||
queue: serverInstallQueue,
|
|
||||||
install: async (plan) => {
|
|
||||||
await installContentMutation.mutateAsync({
|
|
||||||
serverId,
|
|
||||||
worldId,
|
|
||||||
projectId: plan.projectId,
|
|
||||||
versionId: plan.versionId,
|
|
||||||
})
|
|
||||||
markProjectInstalled(plan.projectId)
|
|
||||||
},
|
|
||||||
onError: (error, plan) => {
|
|
||||||
removePendingServerContentInstall(serverId, worldId, plan.projectId)
|
|
||||||
handleError(error as Error)
|
|
||||||
},
|
|
||||||
onProgress: (completed, total) => {
|
|
||||||
queuedInstallProgress.value = { completed, total }
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.ok
|
|
||||||
} finally {
|
|
||||||
isInstallingQueuedServerInstalls.value = false
|
|
||||||
queuedInstallProgress.value = { completed: 0, total: 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discardQueuedServerInstallsAndBack() {
|
|
||||||
clearQueuedServerInstalls()
|
|
||||||
await navigateTo(serverBackUrl.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installQueuedServerInstallsAndBack() {
|
|
||||||
const sid = currentServerId.value
|
|
||||||
const wid = currentWorldId.value
|
|
||||||
const backUrl = serverBackUrl.value
|
|
||||||
const plans = new Map(queuedServerInstalls.value)
|
|
||||||
|
|
||||||
if (sid && wid) {
|
|
||||||
writePendingServerContentInstallBaseline(sid, wid, [
|
|
||||||
...getServerInstalledContentKeys(),
|
|
||||||
...optimisticallyInstalledProjectIds.value,
|
|
||||||
])
|
|
||||||
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
|
|
||||||
void getQueuedInstallPlaceholders(plans)
|
|
||||||
.then((items) => {
|
|
||||||
const pendingProjectIds = new Set(
|
|
||||||
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
|
|
||||||
)
|
|
||||||
addPendingServerContentInstalls(
|
|
||||||
sid,
|
|
||||||
wid,
|
|
||||||
items.filter((item) => pendingProjectIds.has(item.projectId)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.catch((err) => handleError(err as Error))
|
|
||||||
}
|
|
||||||
await navigateTo(backUrl)
|
|
||||||
|
|
||||||
const ok = await flushQueuedServerInstalls(sid, wid)
|
|
||||||
if (!ok) {
|
|
||||||
queuedServerInstalls.value = new Map()
|
|
||||||
addNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: formatMessage(messages.someProjectsFailedTitle),
|
|
||||||
text: formatMessage(messages.someProjectsFailedText),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function serverInstall(project: InstallableSearchResult) {
|
|
||||||
if (!serverData.value || !currentServerId.value || !currentWorldId.value) {
|
|
||||||
handleError(new Error('No server to install to.'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const contentType = getCurrentServerInstallType()
|
|
||||||
const isModpack = contentType === 'modpack'
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) {
|
|
||||||
removeQueuedServerInstall(project.project_id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) {
|
|
||||||
setProjectInstalling(project.project_id, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
await requestInstall({
|
|
||||||
project,
|
|
||||||
contentType,
|
|
||||||
mode: isModpack ? 'immediate' : 'queue',
|
|
||||||
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
|
|
||||||
providedFilters: isModpack ? [] : serverFilters.value,
|
|
||||||
overriddenProvidedFilterTypes: isModpack
|
|
||||||
? []
|
|
||||||
: searchState.overriddenProvidedFilterTypes.value,
|
|
||||||
targetPreferences: getServerInstallTargetPreferences(contentType),
|
|
||||||
getProjectVersions: getInstallProjectVersions,
|
|
||||||
queue: serverInstallQueue,
|
|
||||||
install: async (plan) => {
|
|
||||||
const modalInstance = onboardingModalRef.value
|
|
||||||
if (!modalInstance) {
|
|
||||||
setProjectInstalling(plan.projectId, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onboardingInstallingProject.value = plan.project
|
|
||||||
modalInstance.show()
|
|
||||||
await nextTick()
|
|
||||||
const ctx = modalInstance.ctx
|
|
||||||
ctx.setupType.value = 'modpack'
|
|
||||||
ctx.modpackSelection.value = {
|
|
||||||
projectId: plan.projectId,
|
|
||||||
versionId: plan.versionId,
|
|
||||||
name: plan.project.title,
|
|
||||||
iconUrl: plan.project.icon_url ?? undefined,
|
|
||||||
}
|
|
||||||
ctx.modal.value?.setStage('final-config')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
if (isModpack) {
|
|
||||||
setProjectInstalling(project.project_id, false)
|
|
||||||
}
|
|
||||||
handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`))
|
|
||||||
} finally {
|
|
||||||
if (!isModpack) {
|
|
||||||
setProjectInstalling(project.project_id, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
|
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||||
const content = project.minecraft_java_server?.content
|
const content = project.minecraft_java_server?.content
|
||||||
if (content?.kind === 'modpack') {
|
if (content?.kind === 'modpack') {
|
||||||
@@ -750,7 +247,7 @@ function getCardActions(
|
|||||||
): CardAction[] {
|
): CardAction[] {
|
||||||
if (currentProjectType === 'server') return []
|
if (currentProjectType === 'server') return []
|
||||||
|
|
||||||
const projectResult = result as InstallableSearchResult
|
const projectResult = result as ServerInstallSearchResult
|
||||||
|
|
||||||
if (flags.value.showDiscoverProjectButtons) {
|
if (flags.value.showDiscoverProjectButtons) {
|
||||||
return [
|
return [
|
||||||
@@ -834,126 +331,7 @@ function getCardActions(
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const onboardingModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
|
|
||||||
const onboardingInstallingProject = ref<InstallableSearchResult | null>(null)
|
|
||||||
|
|
||||||
function onOnboardingHide() {
|
|
||||||
if (onboardingInstallingProject.value) {
|
|
||||||
setProjectInstalling(onboardingInstallingProject.value.project_id, false)
|
|
||||||
onboardingInstallingProject.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onOnboardingBack() {
|
|
||||||
onboardingModalRef.value?.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onModpackFlowCreate(config: CreationFlowContextValue) {
|
|
||||||
if (!currentServerId.value || !config.modpackSelection.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.archon.content_v1.installContent(currentServerId.value, currentWorldId.value!, {
|
|
||||||
content_variant: 'modpack',
|
|
||||||
spec: {
|
|
||||||
platform: 'modrinth',
|
|
||||||
project_id: config.modpackSelection.value.projectId,
|
|
||||||
version_id: config.modpackSelection.value.versionId,
|
|
||||||
},
|
|
||||||
soft_override: false,
|
|
||||||
properties: config.buildProperties(),
|
|
||||||
} satisfies Archon.Content.v1.InstallWorldContent)
|
|
||||||
|
|
||||||
if (fromContext.value === 'onboarding') {
|
|
||||||
await client.archon.servers_v1.endIntro(currentServerId.value)
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
|
|
||||||
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
|
|
||||||
} else {
|
|
||||||
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
handleError(new Error(`Error installing modpack: ${e}`))
|
|
||||||
config.loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverBackUrl = computed(() => {
|
|
||||||
if (!serverData.value) return ''
|
|
||||||
const id = serverData.value.server_id
|
|
||||||
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
|
|
||||||
if (fromContext.value === 'reset-server') return `/hosting/manage/${id}?openSettings=installation`
|
|
||||||
return `/hosting/manage/${id}/content`
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverBackLabel = computed(() => {
|
|
||||||
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
|
|
||||||
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
|
|
||||||
return formatMessage(messages.backToServer)
|
|
||||||
})
|
|
||||||
|
|
||||||
const serverBrowseHeading = computed(() =>
|
|
||||||
fromContext.value === 'reset-server'
|
|
||||||
? formatMessage(messages.resetModpackHeading)
|
|
||||||
: formatMessage(commonMessages.installingContentLabel),
|
|
||||||
)
|
|
||||||
|
|
||||||
const installContext = computed(() => {
|
|
||||||
if (!serverData.value) return null
|
|
||||||
return {
|
|
||||||
name: serverData.value.name,
|
|
||||||
loader: serverData.value.loader ?? '',
|
|
||||||
gameVersion: serverData.value.mc_version ?? '',
|
|
||||||
serverId: currentServerId.value,
|
|
||||||
upstream: serverData.value.upstream,
|
|
||||||
iconSrc: serverIcon.value,
|
|
||||||
isMedal: serverData.value.is_medal,
|
|
||||||
backUrl: serverBackUrl.value,
|
|
||||||
backLabel: serverBackLabel.value,
|
|
||||||
heading: serverBrowseHeading.value,
|
|
||||||
queuedCount: queuedServerInstallCount.value,
|
|
||||||
selectedProjects: selectedServerInstallProjects.value,
|
|
||||||
isInstallingSelected: isInstallingQueuedServerInstalls.value,
|
|
||||||
installProgress: queuedInstallProgress.value,
|
|
||||||
clearQueued: clearQueuedServerInstalls,
|
|
||||||
clearSelected: clearQueuedServerInstalls,
|
|
||||||
onBack: flushQueuedServerInstalls,
|
|
||||||
discardSelectedAndBack: discardQueuedServerInstallsAndBack,
|
|
||||||
installSelected: installQueuedServerInstallsAndBack,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unsupportedContentType: {
|
|
||||||
id: 'discover.install.error.unsupported-content-type',
|
|
||||||
defaultMessage: 'This content type cannot be installed to a server from browse.',
|
|
||||||
},
|
|
||||||
noServerWorld: {
|
|
||||||
id: 'discover.install.error.no-server-world',
|
|
||||||
defaultMessage: 'No server world is available for install.',
|
|
||||||
},
|
|
||||||
someProjectsFailedTitle: {
|
|
||||||
id: 'discover.install.error.some-projects-failed.title',
|
|
||||||
defaultMessage: 'Some projects failed to install',
|
|
||||||
},
|
|
||||||
someProjectsFailedText: {
|
|
||||||
id: 'discover.install.error.some-projects-failed.description',
|
|
||||||
defaultMessage: 'Failed projects were not added. You can try installing them again.',
|
|
||||||
},
|
|
||||||
backToSetup: {
|
|
||||||
id: 'discover.install.back-to-setup',
|
|
||||||
defaultMessage: 'Back to setup',
|
|
||||||
},
|
|
||||||
cancelReset: {
|
|
||||||
id: 'discover.install.cancel-reset',
|
|
||||||
defaultMessage: 'Cancel reset',
|
|
||||||
},
|
|
||||||
backToServer: {
|
|
||||||
id: 'discover.install.back-to-server',
|
|
||||||
defaultMessage: 'Back to server',
|
|
||||||
},
|
|
||||||
resetModpackHeading: {
|
|
||||||
id: 'discover.install.heading.reset-modpack',
|
|
||||||
defaultMessage: 'Selecting modpack to install after reset',
|
|
||||||
},
|
|
||||||
gameVersionProvidedByServer: {
|
gameVersionProvidedByServer: {
|
||||||
id: 'search.filter.locked.server-game-version.title',
|
id: 'search.filter.locked.server-game-version.title',
|
||||||
defaultMessage: 'Game version is provided by the server',
|
defaultMessage: 'Game version is provided by the server',
|
||||||
@@ -1009,12 +387,7 @@ const searchState = useBrowseSearch({
|
|||||||
maxResultsOptions: currentMaxResultsOptions,
|
maxResultsOptions: currentMaxResultsOptions,
|
||||||
displayMode: resultsDisplayMode,
|
displayMode: resultsDisplayMode,
|
||||||
})
|
})
|
||||||
|
setBrowseSearchState(searchState)
|
||||||
watch(queuedServerInstallCount, (count) => {
|
|
||||||
if (count === 0) {
|
|
||||||
hideSelectedServerInstalls.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class ArchonContentV1Module extends AbstractModule {
|
|||||||
api: 'archon',
|
api: 'archon',
|
||||||
version: 1,
|
version: 1,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { addons } satisfies Archon.Content.v1.AddAddonsRequest,
|
body: addons satisfies Archon.Content.v1.AddAddonsRequest,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,7 @@ export namespace Archon {
|
|||||||
kind?: AddonKind
|
kind?: AddonKind
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AddAddonsRequest = {
|
export type AddAddonsRequest = AddAddonRequest[]
|
||||||
addons: AddAddonRequest[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RemoveAddonRequest = {
|
export type RemoveAddonRequest = {
|
||||||
kind: AddonKind
|
kind: AddonKind
|
||||||
|
|||||||
@@ -71,6 +71,207 @@ export interface BrowseInstallQueue<TProject extends BrowseInstallProject = Brow
|
|||||||
set: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
|
set: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serverInstallQueueStoragePrefix = 'server-install-queue'
|
||||||
|
const serverInstallQueueLockStoragePrefix = 'server-install-queue-lock'
|
||||||
|
const serverInstallQueueLockTtl = 15 * 60 * 1000
|
||||||
|
const serverInstallQueueLockRefreshInterval = 30 * 1000
|
||||||
|
const activeInstallQueueFlushes = new Map<string, Promise<unknown>>()
|
||||||
|
|
||||||
|
export function getStoredServerInstallQueueKey(serverId: string | null, worldId: string | null) {
|
||||||
|
if (!serverId || !worldId) return null
|
||||||
|
return `${serverInstallQueueStoragePrefix}:${serverId}:${worldId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredServerInstallQueue<
|
||||||
|
TProject extends BrowseInstallProject = BrowseInstallProject,
|
||||||
|
>(serverId: string | null, worldId: string | null) {
|
||||||
|
const key = getStoredServerInstallQueueKey(serverId, worldId)
|
||||||
|
if (!key || typeof localStorage === 'undefined') {
|
||||||
|
return new Map<string, BrowseInstallPlan<TProject>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (!raw) return new Map<string, BrowseInstallPlan<TProject>>()
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return new Map<string, BrowseInstallPlan<TProject>>()
|
||||||
|
|
||||||
|
return new Map<string, BrowseInstallPlan<TProject>>(
|
||||||
|
parsed.filter(isStoredServerInstallQueueEntry),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return new Map<string, BrowseInstallPlan<TProject>>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredServerInstallQueue<
|
||||||
|
TProject extends BrowseInstallProject = BrowseInstallProject,
|
||||||
|
>(
|
||||||
|
serverId: string | null,
|
||||||
|
worldId: string | null,
|
||||||
|
plans: Map<string, BrowseInstallPlan<TProject>>,
|
||||||
|
) {
|
||||||
|
const key = getStoredServerInstallQueueKey(serverId, worldId)
|
||||||
|
if (!key || typeof localStorage === 'undefined') return
|
||||||
|
|
||||||
|
if (plans.size === 0) {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(key, JSON.stringify(Array.from(plans.entries())))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerInstallQueueLockName(lockKey: string) {
|
||||||
|
return `${serverInstallQueueStoragePrefix}:flush:${lockKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredServerInstallQueueLockKey(lockName: string) {
|
||||||
|
return `${serverInstallQueueLockStoragePrefix}:${lockName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStoredServerInstallQueueLock(value: unknown): value is StoredServerInstallQueueLock {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
return typeof record.token === 'string' && typeof record.expiresAt === 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredServerInstallQueueLock(key: string) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return isStoredServerInstallQueueLock(parsed) ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServerInstallQueueLockToken() {
|
||||||
|
return `${Date.now()}:${Math.random().toString(36).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAcquireStoredServerInstallQueueLock(
|
||||||
|
lockName: string,
|
||||||
|
): AcquiredStoredServerInstallQueueLock | null {
|
||||||
|
const key = getStoredServerInstallQueueLockKey(lockName)
|
||||||
|
const existingLock = readStoredServerInstallQueueLock(key)
|
||||||
|
if (existingLock && existingLock.expiresAt > Date.now()) return null
|
||||||
|
|
||||||
|
const token = createServerInstallQueueLockToken()
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + serverInstallQueueLockTtl,
|
||||||
|
} satisfies StoredServerInstallQueueLock),
|
||||||
|
)
|
||||||
|
|
||||||
|
const storedLock = readStoredServerInstallQueueLock(key)
|
||||||
|
return storedLock?.token === token ? { key, token } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStoredServerInstallQueueLock(lock: AcquiredStoredServerInstallQueueLock) {
|
||||||
|
const storedLock = readStoredServerInstallQueueLock(lock.key)
|
||||||
|
if (storedLock?.token !== lock.token) return false
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
lock.key,
|
||||||
|
JSON.stringify({
|
||||||
|
token: lock.token,
|
||||||
|
expiresAt: Date.now() + serverInstallQueueLockTtl,
|
||||||
|
} satisfies StoredServerInstallQueueLock),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseStoredServerInstallQueueLock(lock: AcquiredStoredServerInstallQueueLock) {
|
||||||
|
const storedLock = readStoredServerInstallQueueLock(lock.key)
|
||||||
|
if (storedLock?.token === lock.token) {
|
||||||
|
localStorage.removeItem(lock.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStoredServerInstallQueueLock<T>(
|
||||||
|
lockName: string,
|
||||||
|
callback: () => T | Promise<T>,
|
||||||
|
) {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return await callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
let lock = tryAcquireStoredServerInstallQueueLock(lockName)
|
||||||
|
while (!lock) {
|
||||||
|
await wait(100)
|
||||||
|
lock = tryAcquireStoredServerInstallQueueLock(lockName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const acquiredLock = lock
|
||||||
|
const refreshInterval = setInterval(
|
||||||
|
() => refreshStoredServerInstallQueueLock(acquiredLock),
|
||||||
|
serverInstallQueueLockRefreshInterval,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await callback()
|
||||||
|
} finally {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
releaseStoredServerInstallQueueLock(acquiredLock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithServerInstallQueueLock<T>(lockName: string, callback: () => T | Promise<T>) {
|
||||||
|
const locks =
|
||||||
|
typeof navigator === 'undefined' ? undefined : (navigator as NavigatorWithLocks).locks
|
||||||
|
|
||||||
|
if (locks) {
|
||||||
|
return await locks.request(lockName, { mode: 'exclusive' }, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withStoredServerInstallQueueLock(lockName, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withServerInstallQueueLock<T>(
|
||||||
|
lockKey: string | null | undefined,
|
||||||
|
callback: () => T | Promise<T>,
|
||||||
|
) {
|
||||||
|
if (!lockKey) return await callback()
|
||||||
|
|
||||||
|
const lockName = getServerInstallQueueLockName(lockKey)
|
||||||
|
for (;;) {
|
||||||
|
const activeFlush = activeInstallQueueFlushes.get(lockName)
|
||||||
|
if (!activeFlush) break
|
||||||
|
await activeFlush.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = runWithServerInstallQueueLock(lockName, callback)
|
||||||
|
activeInstallQueueFlushes.set(lockName, flush)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await flush
|
||||||
|
} finally {
|
||||||
|
if (activeInstallQueueFlushes.get(lockName) === flush) {
|
||||||
|
activeInstallQueueFlushes.delete(lockName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withStoredServerInstallQueueFlushLock<T>(
|
||||||
|
serverId: string | null,
|
||||||
|
worldId: string | null,
|
||||||
|
callback: () => T | Promise<T>,
|
||||||
|
) {
|
||||||
|
return await withServerInstallQueueLock(
|
||||||
|
getStoredServerInstallQueueKey(serverId, worldId),
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter inputs for deriving selected install preferences.
|
* Filter inputs for deriving selected install preferences.
|
||||||
*
|
*
|
||||||
@@ -118,6 +319,7 @@ export interface RequestInstallOptions<
|
|||||||
export interface FlushInstallQueueOptions<TProject extends BrowseInstallProject> {
|
export interface FlushInstallQueueOptions<TProject extends BrowseInstallProject> {
|
||||||
queue: BrowseInstallQueue<TProject>
|
queue: BrowseInstallQueue<TProject>
|
||||||
install: (plan: BrowseInstallPlan<TProject>) => void | Promise<void>
|
install: (plan: BrowseInstallPlan<TProject>) => void | Promise<void>
|
||||||
|
lockKey?: string | null
|
||||||
onError?: (error: unknown, plan: BrowseInstallPlan<TProject>) => void
|
onError?: (error: unknown, plan: BrowseInstallPlan<TProject>) => void
|
||||||
onProgress?: (
|
onProgress?: (
|
||||||
completed: number,
|
completed: number,
|
||||||
@@ -126,6 +328,13 @@ export interface FlushInstallQueueOptions<TProject extends BrowseInstallProject>
|
|||||||
) => void | Promise<void>
|
) => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FlushStoredServerAddonInstallQueueOptions<TProject extends BrowseInstallProject> {
|
||||||
|
serverId: string
|
||||||
|
worldId: string
|
||||||
|
install: (plans: BrowseInstallPlan<TProject>[]) => void | Promise<void>
|
||||||
|
onQueueChange?: (plans: Map<string, BrowseInstallPlan<TProject>>) => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of a queue flush. Failed plans are also written back to the queue.
|
* Result of a queue flush. Failed plans are also written back to the queue.
|
||||||
*/
|
*/
|
||||||
@@ -135,11 +344,38 @@ export interface FlushInstallQueueResult<TProject extends BrowseInstallProject>
|
|||||||
failedPlans: Map<string, BrowseInstallPlan<TProject>>
|
failedPlans: Map<string, BrowseInstallPlan<TProject>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FlushStoredServerAddonInstallQueueResult<TProject extends BrowseInstallProject> {
|
||||||
|
ok: boolean
|
||||||
|
flushedPlans: BrowseInstallPlan<TProject>[]
|
||||||
|
attemptedPlans: BrowseInstallPlan<TProject>[]
|
||||||
|
error?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
interface InstallCandidate {
|
interface InstallCandidate {
|
||||||
preferences: BrowseInstallPreferences
|
preferences: BrowseInstallPreferences
|
||||||
source: BrowseInstallPlanSource
|
source: BrowseInstallPlanSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StoredServerInstallQueueLock {
|
||||||
|
token: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcquiredStoredServerInstallQueueLock {
|
||||||
|
key: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavigatorWithLocks = {
|
||||||
|
locks?: {
|
||||||
|
request: <T>(
|
||||||
|
name: string,
|
||||||
|
options: { mode: 'exclusive' },
|
||||||
|
callback: () => T | Promise<T>,
|
||||||
|
) => Promise<T>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a project/content type to the browse filter keys that represent its loader.
|
* Maps a project/content type to the browse filter keys that represent its loader.
|
||||||
*/
|
*/
|
||||||
@@ -367,6 +603,81 @@ export async function requestInstall<TProject extends BrowseInstallProject>(
|
|||||||
* Successful plans are removed; failed plans remain in the queue for retry or user action.
|
* Successful plans are removed; failed plans remain in the queue for retry or user action.
|
||||||
*/
|
*/
|
||||||
export async function flushInstallQueue<TProject extends BrowseInstallProject>({
|
export async function flushInstallQueue<TProject extends BrowseInstallProject>({
|
||||||
|
queue,
|
||||||
|
install,
|
||||||
|
lockKey,
|
||||||
|
onError,
|
||||||
|
onProgress,
|
||||||
|
}: FlushInstallQueueOptions<TProject>): Promise<FlushInstallQueueResult<TProject>> {
|
||||||
|
return await withServerInstallQueueLock(lockKey, () =>
|
||||||
|
flushInstallQueueUnlocked({ queue, install, onError, onProgress }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredServerAddonInstallQueue<
|
||||||
|
TProject extends BrowseInstallProject = BrowseInstallProject,
|
||||||
|
>(serverId: string, worldId: string) {
|
||||||
|
const storedPlans = readStoredServerInstallQueue<TProject>(serverId, worldId)
|
||||||
|
const addonPlans = new Map(
|
||||||
|
Array.from(storedPlans).filter(([, plan]) => plan.contentType !== 'modpack'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (addonPlans.size !== storedPlans.size) {
|
||||||
|
writeStoredServerInstallQueue(serverId, worldId, addonPlans)
|
||||||
|
}
|
||||||
|
|
||||||
|
return addonPlans
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushStoredServerAddonInstallQueue<TProject extends BrowseInstallProject>({
|
||||||
|
serverId,
|
||||||
|
worldId,
|
||||||
|
install,
|
||||||
|
onQueueChange,
|
||||||
|
}: FlushStoredServerAddonInstallQueueOptions<TProject>): Promise<
|
||||||
|
FlushStoredServerAddonInstallQueueResult<TProject>
|
||||||
|
> {
|
||||||
|
let attemptedPlans: BrowseInstallPlan<TProject>[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const flushedPlans = await withStoredServerInstallQueueFlushLock(
|
||||||
|
serverId,
|
||||||
|
worldId,
|
||||||
|
async () => {
|
||||||
|
const plans = Array.from(
|
||||||
|
getStoredServerAddonInstallQueue<TProject>(serverId, worldId).values(),
|
||||||
|
)
|
||||||
|
attemptedPlans = plans
|
||||||
|
if (plans.length === 0) return []
|
||||||
|
|
||||||
|
await install(plans)
|
||||||
|
|
||||||
|
const remainingPlans = getStoredServerAddonInstallQueue<TProject>(serverId, worldId)
|
||||||
|
for (const plan of plans) {
|
||||||
|
remainingPlans.delete(plan.projectId)
|
||||||
|
}
|
||||||
|
writeStoredServerInstallQueue(serverId, worldId, remainingPlans)
|
||||||
|
onQueueChange?.(remainingPlans)
|
||||||
|
return plans
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
flushedPlans,
|
||||||
|
attemptedPlans,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
flushedPlans: [],
|
||||||
|
attemptedPlans,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushInstallQueueUnlocked<TProject extends BrowseInstallProject>({
|
||||||
queue,
|
queue,
|
||||||
install,
|
install,
|
||||||
onError,
|
onError,
|
||||||
@@ -381,6 +692,10 @@ export async function flushInstallQueue<TProject extends BrowseInstallProject>({
|
|||||||
try {
|
try {
|
||||||
await install(plan)
|
await install(plan)
|
||||||
successfulPlans.push(plan)
|
successfulPlans.push(plan)
|
||||||
|
|
||||||
|
const remainingPlans = new Map(queue.get())
|
||||||
|
remainingPlans.delete(plan.projectId)
|
||||||
|
queue.set(remainingPlans)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failedPlans.set(plan.projectId, plan)
|
failedPlans.set(plan.projectId, plan)
|
||||||
onError?.(error, plan)
|
onError?.(error, plan)
|
||||||
@@ -389,9 +704,6 @@ export async function flushInstallQueue<TProject extends BrowseInstallProject>({
|
|||||||
await onProgress?.(completed, queuedPlans.length, plan)
|
await onProgress?.(completed, queuedPlans.length, plan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.set(failedPlans)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: failedPlans.size === 0,
|
ok: failedPlans.size === 0,
|
||||||
successfulPlans,
|
successfulPlans,
|
||||||
@@ -523,6 +835,53 @@ function uniqueDefined(values: readonly (string | null | undefined)[] = []) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStoredServerInstallQueueEntry(
|
||||||
|
value: unknown,
|
||||||
|
): value is [string, BrowseInstallPlan<BrowseInstallProject>] {
|
||||||
|
if (!Array.isArray(value) || value.length !== 2) return false
|
||||||
|
const [key, plan] = value
|
||||||
|
return typeof key === 'string' && isStoredBrowseInstallPlan(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStoredBrowseInstallPlan(
|
||||||
|
value: unknown,
|
||||||
|
): value is BrowseInstallPlan<BrowseInstallProject> {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
isStoredBrowseInstallProject(record.project) &&
|
||||||
|
typeof record.projectId === 'string' &&
|
||||||
|
typeof record.versionId === 'string' &&
|
||||||
|
isStoredBrowseInstallContentType(record.contentType) &&
|
||||||
|
isStoredBrowseInstallPreferences(record.preferences) &&
|
||||||
|
(record.source === 'filtered' || record.source === 'target')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStoredBrowseInstallProject(value: unknown): value is BrowseInstallProject {
|
||||||
|
return (
|
||||||
|
!!value &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
typeof (value as Record<string, unknown>).project_id === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStoredBrowseInstallContentType(value: unknown): value is BrowseInstallContentType {
|
||||||
|
return value === 'modpack' || value === 'mod' || value === 'plugin' || value === 'datapack'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStoredBrowseInstallPreferences(value: unknown): value is BrowseInstallPreferences {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
return isOptionalStringArray(record.gameVersions) && isOptionalStringArray(record.loaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptionalStringArray(value: unknown) {
|
||||||
|
return (
|
||||||
|
value === undefined || (Array.isArray(value) && value.every((item) => typeof item === 'string'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function createNoCompatibleVersionError(
|
function createNoCompatibleVersionError(
|
||||||
contentType: BrowseInstallContentType,
|
contentType: BrowseInstallContentType,
|
||||||
preferences: BrowseInstallPreferences,
|
preferences: BrowseInstallPreferences,
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ import {
|
|||||||
pendingServerContentInstallsEvent,
|
pendingServerContentInstallsEvent,
|
||||||
readPendingServerContentInstallBaseline,
|
readPendingServerContentInstallBaseline,
|
||||||
readPendingServerContentInstalls,
|
readPendingServerContentInstalls,
|
||||||
|
removePendingServerContentInstall,
|
||||||
} from '#ui/utils/server-content-installing'
|
} from '#ui/utils/server-content-installing'
|
||||||
|
|
||||||
|
import {
|
||||||
|
flushStoredServerAddonInstallQueue,
|
||||||
|
getStoredServerAddonInstallQueue,
|
||||||
|
} from '../../../shared/browse-tab/composables/install-logic'
|
||||||
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
||||||
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
|
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
|
||||||
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
|
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
|
||||||
@@ -97,6 +102,10 @@ const messages = defineMessages({
|
|||||||
id: 'hosting.content.failed-to-bulk-update',
|
id: 'hosting.content.failed-to-bulk-update',
|
||||||
defaultMessage: 'Failed to update content',
|
defaultMessage: 'Failed to update content',
|
||||||
},
|
},
|
||||||
|
failedToInstallContent: {
|
||||||
|
id: 'hosting.content.failed-to-install',
|
||||||
|
defaultMessage: 'Failed to install content',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
@@ -227,6 +236,7 @@ const pendingServerContentInstalls = ref<PendingServerContentInstall[]>([])
|
|||||||
const lastStableContentKeys = ref<Set<string>>(new Set())
|
const lastStableContentKeys = ref<Set<string>>(new Set())
|
||||||
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
|
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
|
||||||
const contentInstallAddedKeys = ref<Set<string>>(new Set())
|
const contentInstallAddedKeys = ref<Set<string>>(new Set())
|
||||||
|
const isFlushingStoredServerInstalls = ref(false)
|
||||||
|
|
||||||
function syncPendingServerContentInstalls() {
|
function syncPendingServerContentInstalls() {
|
||||||
pendingServerContentInstalls.value = readPendingServerContentInstalls(serverId, worldId.value)
|
pendingServerContentInstalls.value = readPendingServerContentInstalls(serverId, worldId.value)
|
||||||
@@ -237,6 +247,7 @@ function handlePendingServerContentInstallsChanged(event: Event) {
|
|||||||
.detail
|
.detail
|
||||||
if (detail?.serverId !== serverId || detail?.worldId !== worldId.value) return
|
if (detail?.serverId !== serverId || detail?.worldId !== worldId.value) return
|
||||||
syncPendingServerContentInstalls()
|
syncPendingServerContentInstalls()
|
||||||
|
void flushStoredServerInstalls()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAddonInstallKey(addon: Archon.Content.v1.Addon) {
|
function getAddonInstallKey(addon: Archon.Content.v1.Addon) {
|
||||||
@@ -277,6 +288,50 @@ function syncContentInstallKeys(
|
|||||||
contentInstallAddedKeys.value = new Set()
|
contentInstallAddedKeys.value = new Set()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flushStoredServerInstalls() {
|
||||||
|
const wid = worldId.value
|
||||||
|
if (!wid || isFlushingStoredServerInstalls.value) return
|
||||||
|
|
||||||
|
const queuedPlans = getStoredServerAddonInstallQueue(serverId, wid)
|
||||||
|
if (queuedPlans.size === 0) return
|
||||||
|
|
||||||
|
isFlushingStoredServerInstalls.value = true
|
||||||
|
try {
|
||||||
|
const result = await flushStoredServerAddonInstallQueue({
|
||||||
|
serverId,
|
||||||
|
worldId: wid,
|
||||||
|
install: (plans) =>
|
||||||
|
client.archon.content_v1.addAddons(
|
||||||
|
serverId,
|
||||||
|
wid,
|
||||||
|
plans.map((plan) => ({
|
||||||
|
project_id: plan.projectId,
|
||||||
|
version_id: plan.versionId,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
for (const plan of result.attemptedPlans) {
|
||||||
|
removePendingServerContentInstall(serverId, wid, plan.projectId)
|
||||||
|
}
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: formatMessage(messages.failedToInstallContent),
|
||||||
|
text: result.error instanceof Error ? result.error.message : undefined,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.flushedPlans.length > 0) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isFlushingStoredServerInstalls.value = false
|
||||||
|
syncPendingServerContentInstalls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pendingInstallToContentItem(item: PendingServerContentInstall): ContentItem {
|
function pendingInstallToContentItem(item: PendingServerContentInstall): ContentItem {
|
||||||
return {
|
return {
|
||||||
project: {
|
project: {
|
||||||
@@ -428,12 +483,14 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
syncPendingServerContentInstalls()
|
syncPendingServerContentInstalls()
|
||||||
syncContentInstallKeys()
|
syncContentInstallKeys()
|
||||||
|
void flushStoredServerInstalls()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncPendingServerContentInstalls()
|
syncPendingServerContentInstalls()
|
||||||
|
void flushStoredServerInstalls()
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
pendingServerContentInstallsEvent,
|
pendingServerContentInstallsEvent,
|
||||||
handlePendingServerContentInstallsChanged,
|
handlePendingServerContentInstallsChanged,
|
||||||
|
|||||||
@@ -1316,6 +1316,9 @@
|
|||||||
"hosting.content.failed-to-bulk-update": {
|
"hosting.content.failed-to-bulk-update": {
|
||||||
"defaultMessage": "Failed to update content"
|
"defaultMessage": "Failed to update content"
|
||||||
},
|
},
|
||||||
|
"hosting.content.failed-to-install": {
|
||||||
|
"defaultMessage": "Failed to install content"
|
||||||
|
},
|
||||||
"hosting.content.failed-to-load-modpack-content": {
|
"hosting.content.failed-to-load-modpack-content": {
|
||||||
"defaultMessage": "Failed to load modpack content"
|
"defaultMessage": "Failed to load modpack content"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user