diff --git a/apps/app-frontend/src/providers/setup/server-install-content.ts b/apps/app-frontend/src/providers/setup/server-install-content.ts index 9875173dad..f3ad767f2c 100644 --- a/apps/app-frontend/src/providers/setup/server-install-content.ts +++ b/apps/app-frontend/src/providers/setup/server-install-content.ts @@ -1,6 +1,5 @@ -import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client' +import type { Archon, Labrinth } from '@modrinth/api-client' import { - addPendingServerContentInstalls, type BrowseInstallPlan, type BrowseSelectedProject, createContext, @@ -10,12 +9,9 @@ import { injectModrinthClient, injectNotificationManager, type ModpackSearchResult, - type PendingServerContentInstall, - type PendingServerContentInstallType, - readPendingServerContentInstalls, readStoredServerInstallQueue, - removePendingServerContentInstall, - writePendingServerContentInstallBaseline, + useServerContextRuntime, + waitForServerContextRuntimeReady, writeStoredServerInstallQueue, } from '@modrinth/ui' import { useQueryClient } from '@tanstack/vue-query' @@ -28,7 +24,6 @@ type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & { installing?: boolean installed?: boolean } -type PendingServerContentInstallInput = Omit export interface ServerModpackSelectionRequest { projectId: string @@ -90,114 +85,6 @@ function readQueryString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null } -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: `https://modrinth.com/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: `https://modrinth.com/user/${ownerId}`, - } -} - -async function getQueuedInstallOwner( - client: AbstractModrinthClient, - 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: `https://modrinth.com/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: `https://modrinth.com/user/${owner.username}`, - } - } - } catch { - return fallback - } - - return fallback -} - -function getQueuedAddonInstallPlans( - plans: Map>, -) { - return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack') -} - -function getQueuedInstallPlaceholder( - plan: BrowseInstallPlan, - owner: PendingServerContentInstallInput['owner'], -): PendingServerContentInstallInput { - const project = plan.project as InstallableSearchResult & { slug?: string | null } - return { - projectId: plan.projectId, - versionId: plan.versionId, - contentType: plan.contentType as PendingServerContentInstallType, - title: project.name ?? 'Project', - versionName: plan.versionName ?? null, - versionNumber: plan.versionNumber ?? null, - fileName: plan.fileName ?? null, - owner, - slug: project.slug ?? plan.projectId, - iconUrl: project.icon_url ?? null, - } -} - -function getQueuedInstallPlaceholderFallbacks( - plans: Map>, -) { - return getQueuedAddonInstallPlans(plans).map((plan) => - getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)), - ) -} - -async function getQueuedInstallPlaceholders( - client: AbstractModrinthClient, - plans: Map>, -) { - return Promise.all( - getQueuedAddonInstallPlans(plans).map(async (plan) => - getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)), - ), - ) -} - export function createServerInstallContent(opts: { serverSetupModalRef: Ref }) { @@ -220,11 +107,11 @@ export function createServerInstallContent(opts: { const isFromWorlds = computed(() => browseFrom.value === 'worlds') const isServerContext = computed(() => !!serverIdQuery.value) const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value) + useServerContextRuntime(serverIdQuery) const serverContextWorldId = ref(worldIdQuery.value) const serverContextServerData = ref(null) const serverContentProjectIds = ref>(new Set()) - const serverContentInstallKeys = ref>(new Set()) const queuedServerInstalls = ref>>( new Map(), ) @@ -282,11 +169,7 @@ export function createServerInstallContent(opts: { .map((addon) => addon.project_id) .filter((projectId): projectId is string => !!projectId), ) - const keys = new Set( - (content.addons ?? []).map((addon) => addon.project_id ?? addon.filename), - ) serverContentProjectIds.value = ids - serverContentInstallKeys.value = keys } catch (err) { handleError(err as Error) } @@ -321,14 +204,12 @@ export function createServerInstallContent(opts: { if (!sid) { serverContextServerData.value = null serverContentProjectIds.value = new Set() - serverContentInstallKeys.value = new Set() setQueuedServerInstallPlans(new Map()) return } if (sid !== prevSid) { serverContentProjectIds.value = new Set() - serverContentInstallKeys.value = new Set() queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid) try { serverContextServerData.value = await client.archon.servers_v0.get(sid) @@ -440,6 +321,13 @@ export function createServerInstallContent(opts: { const queuedPlans = getStoredServerAddonInstallQueue(serverId, worldId) if (queuedPlans.size === 0) return true + try { + await waitForServerContextRuntimeReady(client, serverId) + } catch (error) { + handleError(error as Error) + return false + } + isInstallingQueuedServerInstalls.value = true queuedInstallProgress.value = { completed: 0, @@ -463,9 +351,6 @@ export function createServerInstallContent(opts: { }) if (!result.ok) { - for (const plan of result.attemptedPlans) { - removePendingServerContentInstall(serverId, worldId, plan.projectId) - } handleError(result.error as Error) return false } @@ -478,10 +363,6 @@ export function createServerInstallContent(opts: { ...serverContentProjectIds.value, ...result.flushedPlans.map((plan) => plan.projectId), ]) - serverContentInstallKeys.value = new Set([ - ...serverContentInstallKeys.value, - ...result.flushedPlans.map((plan) => plan.projectId), - ]) if (result.flushedPlans.length > 0) { await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }) } @@ -506,20 +387,6 @@ export function createServerInstallContent(opts: { if (sid && wid) { writeStoredServerInstallQueue(sid, wid, plans) - writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value) - addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans)) - void getQueuedInstallPlaceholders(client, 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 router.push(backUrl) void flushQueuedServerInstalls(sid, wid) diff --git a/apps/frontend/src/composables/use-server-install-content.ts b/apps/frontend/src/composables/use-server-install-content.ts index 53ab528596..1445e7a6be 100644 --- a/apps/frontend/src/composables/use-server-install-content.ts +++ b/apps/frontend/src/composables/use-server-install-content.ts @@ -6,11 +6,8 @@ import type { CreationFlowContextValue, EnvironmentSearchOverride, FilterValue, - PendingServerContentInstall, - PendingServerContentInstallType, } from '@modrinth/ui' import { - addPendingServerContentInstalls, commonMessages, defineMessages, flushStoredServerAddonInstallQueue, @@ -18,14 +15,13 @@ import { getTargetInstallPreferences, injectModrinthClient, injectNotificationManager, - readPendingServerContentInstalls, readStoredServerInstallQueue, - removePendingServerContentInstall, requestInstall, stripServerRuntimeInstallFilters, stripServerRuntimeInstallOverrides, + useServerContextRuntime, useVIntl, - writePendingServerContentInstallBaseline, + waitForServerContextRuntimeReady, writeStoredServerInstallQueue, } from '@modrinth/ui' import { useQuery, useQueryClient } from '@tanstack/vue-query' @@ -35,7 +31,6 @@ import { computed, nextTick, ref, watch } from 'vue' import { navigateTo, useRoute } from '#app' import { queryAsString } from '~/utils/router' -type PendingServerContentInstallInput = Omit type ServerInstallBrowseSearchState = Pick< BrowseSearchState, 'currentFilters' | 'overriddenProvidedFilterTypes' @@ -88,34 +83,6 @@ const messages = defineMessages({ }, }) -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>, -) { - return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack') -} - export function useServerInstallContent({ projectType, onboardingModalRef, @@ -136,6 +103,7 @@ export function useServerInstallContent({ const currentServerId = computed(() => queryAsString(route.query.sid) || null) const fromContext = computed(() => queryAsString(route.query.from) || null) const currentWorldId = computed(() => queryAsString(route.query.wid) || null) + useServerContextRuntime(currentServerId) const { data: serverData, @@ -219,81 +187,6 @@ export function useServerInstallContent({ 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, - owner: PendingServerContentInstallInput['owner'], - ): PendingServerContentInstallInput { - return { - projectId: plan.projectId, - versionId: plan.versionId, - contentType: plan.contentType as PendingServerContentInstallType, - title: getInstallProjectName(plan.project), - 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>, - ) { - return getQueuedAddonInstallPlans(plans).map((plan) => - getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)), - ) - } - - async function getQueuedInstallPlaceholders( - plans: Map>, - ) { - 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) { @@ -319,10 +212,6 @@ export function useServerInstallContent({ ) } - function getServerInstalledContentKeys(data = serverContentData.value) { - return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename)) - } - function syncHiddenInstalledProjectIds() { hiddenInstalledProjectIds.value = new Set([ ...getServerInstalledProjectIds(), @@ -498,6 +387,13 @@ export function useServerInstallContent({ ) if (queuedPlans.size === 0) return true + try { + await waitForServerContextRuntimeReady(client, serverId) + } catch (error) { + handleError(error as Error) + return false + } + isInstallingQueuedServerInstalls.value = true queuedInstallProgress.value = { completed: 0, @@ -518,9 +414,6 @@ export function useServerInstallContent({ }) if (!result.ok) { - for (const plan of result.attemptedPlans) { - removePendingServerContentInstall(serverId, worldId, plan.projectId) - } handleError(result.error as Error) return false } @@ -559,23 +452,6 @@ export function useServerInstallContent({ 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) diff --git a/packages/api-client/src/core/abstract-websocket.ts b/packages/api-client/src/core/abstract-websocket.ts index 8d3e54ee29..784bf50a40 100644 --- a/packages/api-client/src/core/abstract-websocket.ts +++ b/packages/api-client/src/core/abstract-websocket.ts @@ -9,6 +9,7 @@ export type WebSocketEventHandler< export interface WebSocketConnection { serverId: string socket: WebSocket + authenticated: boolean reconnectAttempts: number reconnectTimer?: ReturnType isReconnecting: boolean @@ -31,6 +32,7 @@ export abstract class AbstractWebSocketClient { protected readonly MAX_RECONNECT_ATTEMPTS = 10 protected readonly RECONNECT_BASE_DELAY = 1000 protected readonly RECONNECT_MAX_DELAY = 30000 + protected readonly AUTHENTICATION_TIMEOUT = 30000 constructor( protected client: { @@ -58,6 +60,7 @@ export abstract class AbstractWebSocketClient { } if (status && !status.connected && !options?.force) { + await this.waitForAuthentication(serverId) return } @@ -69,6 +72,28 @@ export abstract class AbstractWebSocketClient { await this.connect(serverId, auth) } + protected async waitForAuthentication(serverId: string): Promise { + await new Promise((resolve, reject) => { + let unsubscribe = () => {} + const timeout = setTimeout(() => { + unsubscribe() + reject(new Error(`WebSocket authentication timed out for server ${serverId}`)) + }, this.AUTHENTICATION_TIMEOUT) + + unsubscribe = this.on(serverId, 'auth-ok', () => { + clearTimeout(timeout) + unsubscribe() + resolve() + }) + + if (this.getStatus(serverId)?.connected) { + clearTimeout(timeout) + unsubscribe() + resolve() + } + }) + } + on( serverId: string, eventType: E, @@ -88,7 +113,7 @@ export abstract class AbstractWebSocketClient { if (!connection) return null return { - connected: connection.socket.readyState === WebSocket.OPEN, + connected: connection.socket.readyState === WebSocket.OPEN && connection.authenticated, reconnecting: connection.isReconnecting, reconnectAttempts: connection.reconnectAttempts, } diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index 7add0d5a84..ae573b34fd 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -1111,6 +1111,53 @@ export namespace Archon { version_id: string } + export type InstallProgressFileKey = { + type: 'file' + install_type: 'install' | 'update' + project_id: string + version_id: string + parent_directory: string + source_filename: string | null + target_filename?: string | null + } + + export type InstallProgressModrinthModpackKey = { + type: 'modrinth_modpack' + project_id: string + version_id: string + } + + export type InstallProgressLocalModpackKey = { + type: 'local_modpack' + filename: string + } + + export type InstallProgressPlatformKey = { + type: 'platform' + platform: 'forge' | 'neoforge' | 'fabric' | 'quilt' | 'paper' | 'purpur' | 'vanilla' + platform_version: string + game_version: string + } + + export type InstallProgressKey = + | InstallProgressFileKey + | InstallProgressModrinthModpackKey + | InstallProgressLocalModpackKey + | InstallProgressPlatformKey + + export type InstallProgressItem = { + world_id: string + key: InstallProgressKey + id: string + progress: number | null + error: string | null + } + + export type WSInstallProgressEvent = { + event: 'install-progress' + items: InstallProgressItem[] + } + export type FilesystemOpKind = 'unarchive' export type FilesystemOpState = @@ -1208,6 +1255,7 @@ export namespace Archon { | WSInstallationResultEvent | WSUptimeEvent | WSNewModEvent + | WSInstallProgressEvent | WSFilesystemOpsEvent export type WSEventType = WSEvent['event'] diff --git a/packages/api-client/src/platform/websocket-generic.ts b/packages/api-client/src/platform/websocket-generic.ts index 19aa098f45..e66bc05095 100644 --- a/packages/api-client/src/platform/websocket-generic.ts +++ b/packages/api-client/src/platform/websocket-generic.ts @@ -19,12 +19,14 @@ export class GenericWebSocketClient extends AbstractWebSocketClient { } return new Promise((resolve, reject) => { + let settled = false try { const ws = new WebSocket(getNodeWebSocketUrl(auth.url)) const connection: WebSocketConnection = { serverId, socket: ws, + authenticated: false, reconnectAttempts: 0, reconnectTimer: undefined, isReconnecting: false, @@ -37,18 +39,26 @@ export class GenericWebSocketClient extends AbstractWebSocketClient { connection.reconnectAttempts = 0 connection.isReconnecting = false - - resolve() } ws.onmessage = (messageEvent) => { try { const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent + if (data.event === 'auth-ok') { + connection.authenticated = true + } else if (data.event === 'auth-incorrect') { + connection.authenticated = false + } const eventKey = `${serverId}:${data.event}` as keyof WSEventMap // eslint-disable-next-line @typescript-eslint/no-explicit-any this.emitter.emit(eventKey, data as any) + if (data.event === 'auth-ok' && !settled) { + settled = true + resolve() + } + if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') { this.handleAuthExpiring(serverId).catch(console.error) } @@ -58,11 +68,20 @@ export class GenericWebSocketClient extends AbstractWebSocketClient { } ws.onclose = (event) => { + connection.authenticated = false console.debug(`[WebSocket] Closed for server ${serverId}:`, { code: event.code, reason: event.reason, wasClean: event.wasClean, }) + if (!settled) { + settled = true + reject( + new Error( + `WebSocket closed before authentication for server ${serverId} (code: ${event.code})`, + ), + ) + } if (event.code !== NORMAL_CLOSURE) { this.scheduleReconnect(serverId, auth) } @@ -77,13 +96,17 @@ export class GenericWebSocketClient extends AbstractWebSocketClient { readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState], type: (event as Event).type, }) - reject( - new Error( - `WebSocket connection failed for server ${serverId} (readyState: ${readyState})`, - ), - ) + if (!settled) { + settled = true + reject( + new Error( + `WebSocket connection failed for server ${serverId} (readyState: ${readyState})`, + ), + ) + } } } catch (error) { + settled = true reject(error) } }) diff --git a/packages/ui/src/components/servers/InstallingBanner.vue b/packages/ui/src/components/servers/InstallingBanner.vue index cf8a901019..95635da75b 100644 --- a/packages/ui/src/components/servers/InstallingBanner.vue +++ b/packages/ui/src/components/servers/InstallingBanner.vue @@ -1,7 +1,8 @@ - - -
-
-
- {{ message }} -
-
-
-