fix: use localstorage for sync state during install (#6057)

* fix: use localstorage for sync state during install

* fix: lint
This commit is contained in:
Calum H.
2026-05-09 22:02:42 +01:00
committed by GitHub
parent 07f9e3aedc
commit c7602602e5
9 changed files with 1262 additions and 757 deletions

View File

@@ -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() {

View 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,
}
}

View File

@@ -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."
}, },

View File

@@ -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(
() => () =>

View File

@@ -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,
}) })
} }

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"
}, },