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 c2556c8937..3e6ea39a47 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,10 @@ import { injectModrinthClient, injectNotificationManager, type ModpackSearchResult, - type PendingServerContentInstall, - type PendingServerContentInstallType, - readPendingServerContentInstalls, readStoredServerInstallQueue, - removePendingServerContentInstall, - writePendingServerContentInstallBaseline, + useServerContextRuntime, + useServerPanelSync, + waitForServerContextRuntimeReady, writeStoredServerInstallQueue, } from '@modrinth/ui' import { useQueryClient } from '@tanstack/vue-query' @@ -28,7 +25,6 @@ type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & { installing?: boolean installed?: boolean } -type PendingServerContentInstallInput = Omit export interface ServerModpackSelectionRequest { projectId: string @@ -90,114 +86,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: `/user/${encodeURIComponent(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: `/user/${encodeURIComponent(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,6 +108,7 @@ 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) @@ -246,7 +135,6 @@ export function createServerInstallContent(opts: { initialServerId ? getCachedServer(initialServerId) : null, ) const serverContentProjectIds = ref>(new Set()) - const serverContentInstallKeys = ref>(new Set()) const queuedServerInstalls = ref>>( new Map(), ) @@ -262,6 +150,10 @@ export function createServerInstallContent(opts: { const isInstallingQueuedServerInstalls = ref(false) const queuedInstallProgress = ref({ completed: 0, total: 0 }) const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value) + useServerPanelSync({ + serverId: serverIdQuery, + worldId: effectiveServerWorldId, + }) const serverBackUrl = computed(() => { const sid = serverIdQuery.value if (!sid) return '/hosting/manage' @@ -307,11 +199,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) } @@ -353,14 +241,12 @@ export function createServerInstallContent(opts: { serverContextWorldId.value = null 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) serverContextServerData.value = getCachedServer(sid) try { @@ -476,6 +362,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, @@ -499,9 +392,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 } @@ -514,10 +404,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] }) } @@ -542,20 +428,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..956a621e7e 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,14 @@ import { getTargetInstallPreferences, injectModrinthClient, injectNotificationManager, - readPendingServerContentInstalls, readStoredServerInstallQueue, - removePendingServerContentInstall, requestInstall, stripServerRuntimeInstallFilters, stripServerRuntimeInstallOverrides, + useServerContextRuntime, + useServerPanelSync, useVIntl, - writePendingServerContentInstallBaseline, + waitForServerContextRuntimeReady, writeStoredServerInstallQueue, } from '@modrinth/ui' import { useQuery, useQueryClient } from '@tanstack/vue-query' @@ -35,7 +32,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 +84,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 +104,11 @@ 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) + useServerPanelSync({ + serverId: currentServerId, + worldId: currentWorldId, + }) const { data: serverData, @@ -219,81 +192,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 +217,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(), @@ -428,43 +322,6 @@ export function useServerInstallContent({ ) } - function toResolvePreferences( - preferences?: BrowseInstallPlan['preferences'], - ): Labrinth.Content.v3.ResolutionPreferences { - return { - game_versions: preferences?.gameVersions, - loaders: preferences?.loaders, - } - } - - async function resolveQueuedAddonPlans(plans: BrowseInstallPlan[]) { - const existingProjectIds = getServerInstalledProjectIds() - const resolvedAddons: Array<{ project_id: string; version_id: string }> = [] - - for (const plan of plans) { - const resolved = await client.labrinth.content_v3.resolve({ - project_id: plan.projectId, - version_id: plan.versionId, - content_type: plan.contentType as Labrinth.Content.v3.ContentType, - selected: toResolvePreferences(plan.preferences), - target: toResolvePreferences(getServerInstallTargetPreferences(plan.contentType)), - existing_project_ids: Array.from(existingProjectIds), - }) - const content = [resolved.primary, ...resolved.dependencies] - - for (const item of content) { - if (existingProjectIds.has(item.project_id)) continue - existingProjectIds.add(item.project_id) - resolvedAddons.push({ - project_id: item.project_id, - version_id: item.version_id, - }) - } - } - - return resolvedAddons - } - function getInstallProjectVersions(projectId: string) { return client.labrinth.versions_v2.getProjectVersions(projectId, { include_changelog: false, @@ -498,6 +355,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, @@ -509,18 +373,19 @@ export function useServerInstallContent({ serverId, worldId, install: async (plans) => { - const addons = await resolveQueuedAddonPlans(plans) - if (addons.length > 0) { - await client.archon.content_v1.addAddons(serverId, worldId, addons) - } + await 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 } @@ -533,10 +398,7 @@ export function useServerInstallContent({ total: result.flushedPlans.length, } if (result.flushedPlans.length > 0) { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }), - queryClient.invalidateQueries({ queryKey: ['content', 'list'] }), - ]) + await queryClient.invalidateQueries({ queryKey: ['content', 'list'] }) } return true @@ -559,23 +421,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..56df291622 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -301,13 +301,24 @@ export namespace Archon { environment?: Labrinth.Projects.v3.Environment | null } + export type AddonStatus = + | 'pending' + | 'installed' + | { + failed: { + error: string + } + } + export type Addon = { id: string filename: string filesize: number + btime?: string disabled: boolean kind: AddonKind from_modpack: boolean + status: AddonStatus pack_client_retained: boolean pack_client_depends: boolean has_update: string | null @@ -323,6 +334,10 @@ export namespace Archon { modloader_version: string | null game_version: string | null modpack: ModpackFields | null + installing?: 'loader' | 'modpack' + error?: { + message: string + } addons: Addon[] | null } @@ -989,6 +1004,78 @@ export namespace Archon { world_id: string spec: Archon.Content.v1.Addons } + export type WorldContentPlatform = + | 'forge' + | 'neoforge' + | 'fabric' + | 'quilt' + | 'paper' + | 'purpur' + | 'vanilla' + export type WorldContentPlatformData = { + platform: WorldContentPlatform + game_version: string + platform_version: string | null + } + export type WorldContentModpackSource = + | 'CurseForge' + | { + Modrinth: { + version_id: string + project_id: string + mrpack_sha1: string | null + } + } + | { + LocalMrPackFile: { + path: string + name: string + description: string | null + version_name: string | null + } + } + export type WorldContentModpack = { + spec: WorldContentModpackSource + downloads: number | null + followers: number | null + icon_url: string | null + owner: Archon.Content.v1.ContentOwner | null + title: string | null + description: string | null + version_number: string | null + date_published: string | null + environment: Labrinth.Projects.v3.Environment | null + has_update: string | null + } + export type WorldContentItem = { + parent_directory: string + file_sha1: string | null + filename: string + btime?: string + from_modpack: boolean + version_id: string | null + project_id: string | null + pack_client_retained: boolean + pack_client_depends: boolean + status: Archon.Content.v1.AddonStatus + filesize: number | null + name: string | null + version: Archon.Content.v1.AddonVersion | null + owner: Archon.Content.v1.ContentOwner | null + has_update: string | null + icon_url: string | null + } + export type WorldContentUpdateEvent = { + type: 'world.content.update' + world_id: string + platform_data: WorldContentPlatformData | null + linked_modpack: WorldContentModpack | null + installing?: 'loader' | 'modpack' + error?: { + message: string + } + content: WorldContentItem[] + } export type SyncEvent = | ProtocolResetEvent @@ -1007,6 +1094,7 @@ export namespace Archon { | WorldStartupPatchEvent | WorldContentAddonPatchEvent | WorldContentBaseUpdateEvent + | WorldContentUpdateEvent } } @@ -1111,6 +1199,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 +1343,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 60eb456b61..b857f63d16 100644 --- a/packages/ui/src/components/servers/InstallingBanner.vue +++ b/packages/ui/src/components/servers/InstallingBanner.vue @@ -1,7 +1,8 @@ - - -
-
-
- {{ message }} -
-
-
-