From 30b60d36b60d6d05b0812b2150a89e7d68b25edb Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Fri, 31 Jul 2026 09:48:55 +0100 Subject: [PATCH] fix: ws connection duplication + disconnecting during browse --- .../providers/setup/server-install-content.ts | 155 +--- .../composables/use-server-install-content.ts | 144 +--- .../api-client/src/core/abstract-websocket.ts | 27 +- .../api-client/src/modules/archon/types.ts | 7 +- .../src/platform/websocket-generic.ts | 37 +- packages/ui/src/composables/index.ts | 1 + .../src/composables/server-context-runtime.ts | 350 +++++++++ .../composables/server-manage-core-runtime.ts | 79 +- .../ui/src/composables/server-panel-sync.ts | 22 +- .../browse-tab/composables/install-logic.ts | 8 - .../wrapped/hosting/manage/content.vue | 698 +++++++++--------- .../layouts/wrapped/hosting/manage/root.vue | 60 +- packages/ui/src/utils/index.ts | 1 - .../ui/src/utils/server-content-installing.ts | 203 ----- 14 files changed, 830 insertions(+), 962 deletions(-) create mode 100644 packages/ui/src/composables/server-context-runtime.ts delete mode 100644 packages/ui/src/utils/server-content-installing.ts 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 9875173da..f3ad767f2 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 53ab52859..1445e7a6b 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 8d3e54ee2..784bf50a4 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 962adbdb1..ae573b34f 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -1113,9 +1113,12 @@ export namespace Archon { export type InstallProgressFileKey = { type: 'file' - parent_directory: string - filename: string install_type: 'install' | 'update' + project_id: string + version_id: string + parent_directory: string + source_filename: string | null + target_filename?: string | null } export type InstallProgressModrinthModpackKey = { diff --git a/packages/api-client/src/platform/websocket-generic.ts b/packages/api-client/src/platform/websocket-generic.ts index 19aa098f4..e66bc0509 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/composables/index.ts b/packages/ui/src/composables/index.ts index 1c4540bbd..b12d7cb40 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -14,6 +14,7 @@ export * from './scroll-indicator' export * from './server-backup' export * from './server-backups-queue' export * from './server-console' +export * from './server-context-runtime' export * from './server-manage-core-runtime' export * from './server-permissions' export { applyEarsMod, removeEarsMod } from './skin-rendering/use-ears-mod-features' diff --git a/packages/ui/src/composables/server-context-runtime.ts b/packages/ui/src/composables/server-context-runtime.ts new file mode 100644 index 000000000..7c5616051 --- /dev/null +++ b/packages/ui/src/composables/server-context-runtime.ts @@ -0,0 +1,350 @@ +import type { AbstractModrinthClient, Archon } from '@modrinth/api-client' +import type { ComputedRef, Ref } from 'vue' +import { onUnmounted, ref, watch } from 'vue' + +import { injectModrinthClient } from '../providers' + +type ReadableRef = Ref | ComputedRef +type RuntimeUnsubscriber = () => void + +type RuntimeReadyWaiter = { + resolve: () => void + reject: (error: Error) => void + timeout: ReturnType +} + +type ServerContextRuntime = { + client: AbstractModrinthClient + serverId: string + leases: number + socketLeases: number + syncLeases: number + releaseTimer: ReturnType | null + socketReleaseTimer: ReturnType | null + syncReleaseTimer: ReturnType | null + connectPromise: Promise | null + socketUnsubscribers: RuntimeUnsubscriber[] + installProgressItems: Ref + isSocketAuthenticated: Ref + isSocketAuthIncorrect: Ref + hasAuthoritativeInstallProgress: Ref + readyWaiters: Set + destroyed: boolean +} + +export type ServerContextRuntimeLease = { + serverId: string + installProgressItems: Ref + isSocketAuthenticated: Ref + isSocketAuthIncorrect: Ref + hasAuthoritativeInstallProgress: Ref + waitUntilReady: () => Promise + release: () => void +} + +type RetainServerContextRuntimeOptions = { + connect?: boolean + socket?: boolean + sync?: boolean +} + +const runtimeReleaseDelay = 1000 +const authoritativeReadinessTimeout = 30000 +const runtimesByClient = new WeakMap>() + +function getClientRuntimes(client: AbstractModrinthClient) { + let runtimes = runtimesByClient.get(client) + if (!runtimes) { + runtimes = new Map() + runtimesByClient.set(client, runtimes) + } + return runtimes +} + +function isRuntimeReady(runtime: ServerContextRuntime) { + return runtime.isSocketAuthenticated.value && runtime.hasAuthoritativeInstallProgress.value +} + +function resolveReadyWaiters(runtime: ServerContextRuntime) { + if (!isRuntimeReady(runtime)) return + + for (const waiter of runtime.readyWaiters) { + clearTimeout(waiter.timeout) + waiter.resolve() + } + runtime.readyWaiters.clear() +} + +function createServerContextRuntime( + client: AbstractModrinthClient, + serverId: string, +): ServerContextRuntime { + const runtime: ServerContextRuntime = { + client, + serverId, + leases: 0, + socketLeases: 0, + syncLeases: 0, + releaseTimer: null, + socketReleaseTimer: null, + syncReleaseTimer: null, + connectPromise: null, + socketUnsubscribers: [], + installProgressItems: ref([]), + isSocketAuthenticated: ref(false), + isSocketAuthIncorrect: ref(false), + hasAuthoritativeInstallProgress: ref(false), + readyWaiters: new Set(), + destroyed: false, + } + + return runtime +} + +function attachRuntimeSocketListeners(runtime: ServerContextRuntime) { + if (runtime.socketUnsubscribers.length > 0) return + + runtime.socketUnsubscribers = [ + runtime.client.archon.sockets.on(runtime.serverId, 'auth-ok', () => { + runtime.isSocketAuthenticated.value = true + runtime.isSocketAuthIncorrect.value = false + runtime.hasAuthoritativeInstallProgress.value = false + }), + runtime.client.archon.sockets.on(runtime.serverId, 'auth-incorrect', () => { + runtime.isSocketAuthenticated.value = false + runtime.isSocketAuthIncorrect.value = true + runtime.hasAuthoritativeInstallProgress.value = false + }), + runtime.client.archon.sockets.on(runtime.serverId, 'install-progress', (event) => { + runtime.installProgressItems.value = event.items + runtime.hasAuthoritativeInstallProgress.value = true + resolveReadyWaiters(runtime) + }), + ] +} + +function disconnectRuntimeSocket(runtime: ServerContextRuntime) { + for (const unsubscribe of runtime.socketUnsubscribers) unsubscribe() + runtime.socketUnsubscribers = [] + runtime.client.archon.sockets.disconnect(runtime.serverId) + runtime.connectPromise = null + runtime.isSocketAuthenticated.value = false + runtime.isSocketAuthIncorrect.value = false + runtime.hasAuthoritativeInstallProgress.value = false + for (const waiter of runtime.readyWaiters) { + clearTimeout(waiter.timeout) + waiter.reject(new Error(`Node socket for server ${runtime.serverId} was released`)) + } + runtime.readyWaiters.clear() +} + +function disconnectRuntimeSync(runtime: ServerContextRuntime) { + runtime.client.archon.sync.disconnect(runtime.serverId) +} + +async function ensureRuntimeConnections( + runtime: ServerContextRuntime, + options: RetainServerContextRuntimeOptions = {}, +) { + if (runtime.destroyed) { + throw new Error(`Server context runtime for ${runtime.serverId} has been released`) + } + + const shouldConnectSocket = options.socket !== false + const shouldConnectSync = options.sync !== false + const socketStatus = runtime.client.archon.sockets.getStatus(runtime.serverId) + if (shouldConnectSocket && !socketStatus?.connected) { + attachRuntimeSocketListeners(runtime) + runtime.isSocketAuthenticated.value = false + runtime.hasAuthoritativeInstallProgress.value = false + } + + if (shouldConnectSync) { + void runtime.client.archon.sync + .safeConnectServer(runtime.serverId, { intent: 'all' }) + .catch((error) => { + console.warn( + `[server-context-runtime] Failed to connect sync stream for ${runtime.serverId}:`, + error, + ) + }) + } + + if (shouldConnectSocket && !runtime.connectPromise) { + const connectPromise = runtime.client.archon.sockets + .safeConnect(runtime.serverId) + .then(() => { + runtime.isSocketAuthenticated.value = true + }) + .finally(() => { + if (runtime.connectPromise === connectPromise) { + runtime.connectPromise = null + } + }) + runtime.connectPromise = connectPromise + } + + if (runtime.connectPromise) await runtime.connectPromise +} + +async function waitUntilRuntimeReady(runtime: ServerContextRuntime) { + await ensureRuntimeConnections(runtime) + if (isRuntimeReady(runtime)) return + + await new Promise((resolve, reject) => { + const waiter: RuntimeReadyWaiter = { + resolve, + reject, + timeout: setTimeout(() => { + runtime.readyWaiters.delete(waiter) + reject( + new Error( + `Timed out waiting for authoritative install progress for server ${runtime.serverId}`, + ), + ) + }, authoritativeReadinessTimeout), + } + runtime.readyWaiters.add(waiter) + resolveReadyWaiters(runtime) + }) +} + +function destroyRuntime(runtime: ServerContextRuntime) { + if (runtime.destroyed || runtime.leases > 0) return + runtime.destroyed = true + + if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer) + if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer) + disconnectRuntimeSocket(runtime) + disconnectRuntimeSync(runtime) + + getClientRuntimes(runtime.client).delete(runtime.serverId) +} + +export function retainServerContextRuntime( + client: AbstractModrinthClient, + serverId: string, + options: RetainServerContextRuntimeOptions = {}, +): ServerContextRuntimeLease { + const runtimes = getClientRuntimes(client) + let runtime = runtimes.get(serverId) + if (!runtime) { + runtime = createServerContextRuntime(client, serverId) + runtimes.set(serverId, runtime) + } + + if (runtime.releaseTimer) { + clearTimeout(runtime.releaseTimer) + runtime.releaseTimer = null + } + const retainSocket = options.socket !== false + const retainSync = options.sync !== false + if (retainSocket) { + if (runtime.socketReleaseTimer) { + clearTimeout(runtime.socketReleaseTimer) + runtime.socketReleaseTimer = null + } + attachRuntimeSocketListeners(runtime) + runtime.socketLeases += 1 + } + if (retainSync) { + if (runtime.syncReleaseTimer) { + clearTimeout(runtime.syncReleaseTimer) + runtime.syncReleaseTimer = null + } + runtime.syncLeases += 1 + } + runtime.leases += 1 + if (options.connect !== false) { + void ensureRuntimeConnections(runtime, options).catch((error) => { + if (runtime && runtime.leases > 0) { + console.warn( + `[server-context-runtime] Failed to connect node socket for ${serverId}:`, + error, + ) + } + }) + } + + let released = false + return { + serverId, + installProgressItems: runtime.installProgressItems, + isSocketAuthenticated: runtime.isSocketAuthenticated, + isSocketAuthIncorrect: runtime.isSocketAuthIncorrect, + hasAuthoritativeInstallProgress: runtime.hasAuthoritativeInstallProgress, + waitUntilReady: () => waitUntilRuntimeReady(runtime), + release: () => { + if (released) return + released = true + runtime.leases = Math.max(0, runtime.leases - 1) + if (retainSocket) { + runtime.socketLeases = Math.max(0, runtime.socketLeases - 1) + } + if (retainSync) { + runtime.syncLeases = Math.max(0, runtime.syncLeases - 1) + } + + if (runtime.leases === 0) { + if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer) + if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer) + runtime.socketReleaseTimer = null + runtime.syncReleaseTimer = null + runtime.releaseTimer = setTimeout(() => { + runtime.releaseTimer = null + destroyRuntime(runtime) + }, runtimeReleaseDelay) + return + } + + if (retainSocket && runtime.socketLeases === 0) { + runtime.socketReleaseTimer = setTimeout(() => { + runtime.socketReleaseTimer = null + if (runtime.socketLeases === 0) disconnectRuntimeSocket(runtime) + }, runtimeReleaseDelay) + } + if (retainSync && runtime.syncLeases === 0) { + runtime.syncReleaseTimer = setTimeout(() => { + runtime.syncReleaseTimer = null + if (runtime.syncLeases === 0) disconnectRuntimeSync(runtime) + }, runtimeReleaseDelay) + } + }, + } +} + +export function useServerContextRuntime(serverId: ReadableRef) { + const client = injectModrinthClient() + let lease: ServerContextRuntimeLease | null = null + + const stop = watch( + () => serverId.value, + (nextServerId) => { + lease?.release() + lease = null + + if (typeof window !== 'undefined' && nextServerId) { + lease = retainServerContextRuntime(client, nextServerId) + } + }, + { immediate: true }, + ) + + onUnmounted(() => { + stop() + lease?.release() + lease = null + }) +} + +export async function waitForServerContextRuntimeReady( + client: AbstractModrinthClient, + serverId: string, +) { + const lease = retainServerContextRuntime(client, serverId) + try { + await lease.waitUntilReady() + } finally { + lease.release() + } +} diff --git a/packages/ui/src/composables/server-manage-core-runtime.ts b/packages/ui/src/composables/server-manage-core-runtime.ts index 599a5261c..a8294158d 100644 --- a/packages/ui/src/composables/server-manage-core-runtime.ts +++ b/packages/ui/src/composables/server-manage-core-runtime.ts @@ -5,20 +5,23 @@ import { type UploadState, } from '@modrinth/api-client' import type { ComputedRef, Ref } from 'vue' -import { computed, ref } from 'vue' +import { computed, ref, watch } from 'vue' import type { FileOperation } from '../layouts/shared/files-tab/types' import { injectModrinthClient, provideModrinthServerContext } from '../providers' import type { BusyReason, CancelUploadHandler, ServerStats } from '../providers/server-context' import { defineMessage } from './i18n' import { useModrinthServersConsole } from './server-console' +import { + retainServerContextRuntime, + type ServerContextRuntimeLease, +} from './server-context-runtime' import { useServerInstallationTracker } from './server-installation-tracker' type ReadableRef = Ref | ComputedRef type SocketUnsubscriber = () => void type ConnectSocketOptions = { - force?: boolean extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[] } @@ -113,6 +116,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp const socketUnsubscribers = ref([]) const cpuData = ref([]) const ramData = ref([]) + let serverContextRuntimeLease: ServerContextRuntimeLease | null = null let uptimeIntervalId: ReturnType | null = null let staleStatsTimeoutId: ReturnType | null = null @@ -270,25 +274,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp startUptimeTicker() } - const handleInstallProgressEvent = (data: Archon.Websocket.v0.WSInstallProgressEvent) => { - if (!shouldProcessEvent()) return - handleInstallProgress(data.items) - } - - const handleAuthIncorrect = () => { - if (!shouldProcessEvent()) return - isWsAuthIncorrect.value = true - if (options.setDisconnectedOnAuthIncorrect) { - isConnected.value = false - } - } - - const handleAuthOk = () => { - if (!shouldProcessEvent()) return - isWsAuthIncorrect.value = false - isConnected.value = true - } - const clearSocketListeners = () => { for (const unsub of socketUnsubscribers.value) unsub() socketUnsubscribers.value = [] @@ -298,10 +283,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp if (!targetServerId && !connectedSocketServerId.value) return clearSocketListeners() - - if (targetServerId) { - client.archon.sockets.disconnect(targetServerId) - } + serverContextRuntimeLease?.release() + serverContextRuntimeLease = null stopUptimeTicker() clearStaleStatsTimers() @@ -328,6 +311,12 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp disconnectSocket(connectedSocketServerId.value ?? undefined) try { + const runtimeLease = retainServerContextRuntime(client, targetServerId, { + connect: false, + }) + serverContextRuntimeLease = runtimeLease + connectedSocketServerId.value = targetServerId + const baseSubscriptions: SocketUnsubscriber[] = [ client.archon.sockets.on(targetServerId, 'log', handleLog), client.archon.sockets.on(targetServerId, 'log4j', handleLog4j), @@ -335,27 +324,45 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp client.archon.sockets.on(targetServerId, 'state', handleState), client.archon.sockets.on(targetServerId, 'power-state', handlePowerState), client.archon.sockets.on(targetServerId, 'uptime', handleUptime), - client.archon.sockets.on(targetServerId, 'install-progress', handleInstallProgressEvent), - client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect), - client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk), + watch( + runtimeLease.installProgressItems, + (items) => { + if (shouldProcessEvent()) handleInstallProgress(items) + }, + { immediate: true }, + ), + watch( + runtimeLease.isSocketAuthenticated, + (authenticated) => { + if (!shouldProcessEvent()) return + if (authenticated || options.setDisconnectedOnAuthIncorrect) { + isConnected.value = authenticated + } + }, + { immediate: true }, + ), + watch( + runtimeLease.isSocketAuthIncorrect, + (authIncorrect) => { + if (shouldProcessEvent()) isWsAuthIncorrect.value = authIncorrect + }, + { immediate: true }, + ), ] const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? [] socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions] - const safeConnectOptions = connectOptions.force ? { force: true } : undefined - await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions) - connectedSocketServerId.value = targetServerId - isConnected.value = true - isWsAuthIncorrect.value = false - modrinthServersConsole.clear() modrinthServersConsole.beginInitialLogHydration() + await runtimeLease.waitUntilReady() + isConnected.value = true + isWsAuthIncorrect.value = false + return true } catch (error) { console.error('[hosting/manage] Failed to connect server socket:', error) - clearSocketListeners() - isConnected.value = false + disconnectSocket(targetServerId) return false } } diff --git a/packages/ui/src/composables/server-panel-sync.ts b/packages/ui/src/composables/server-panel-sync.ts index fa9d7f9ef..9ec5e0778 100644 --- a/packages/ui/src/composables/server-panel-sync.ts +++ b/packages/ui/src/composables/server-panel-sync.ts @@ -5,6 +5,11 @@ import { onMounted, onUnmounted, watch } from 'vue' import { injectModrinthClient } from '#ui/providers' +import { + retainServerContextRuntime, + type ServerContextRuntimeLease, +} from './server-context-runtime' + type ReadableRef = Ref | ComputedRef type SyncUnsubscriber = () => void @@ -20,6 +25,7 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) { const queryClient = useQueryClient() let activeServerId: string | null = null + let runtimeLease: ServerContextRuntimeLease | null = null let unsubscribers: SyncUnsubscriber[] = [] let mounted = false let actionLogInvalidateTimer: ReturnType | null = null @@ -43,12 +49,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) { unsubscribers = [ client.archon.sync.onAny(targetServerId, (event) => handleSyncEvent(targetServerId, event)), ] - - void client.archon.sync.safeConnectServer(targetServerId, { intent: 'all' }).catch((error) => { - console.warn( - `[server-panel-sync] Failed to connect sync stream for ${targetServerId}:`, - error, - ) + runtimeLease = retainServerContextRuntime(client, targetServerId, { + socket: false, + sync: true, }) } @@ -61,10 +64,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) { for (const unsubscribe of unsubscribers) unsubscribe() unsubscribers = [] - if (activeServerId) { - client.archon.sync.disconnect(activeServerId) - activeServerId = null - } + runtimeLease?.release() + runtimeLease = null + activeServerId = null } function handleSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent) { diff --git a/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts b/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts index fe39fb2e2..359a8c8d8 100644 --- a/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts +++ b/packages/ui/src/layouts/shared/browse-tab/composables/install-logic.ts @@ -53,9 +53,6 @@ export interface BrowseInstallPlan( const version = getLatestMatchingInstallVersion(versions, candidate.preferences) if (version) { - const fileName = - version.files.find((file) => file.primary)?.filename ?? version.files[0]?.filename return { project: options.project, projectId, versionId: version.id, - versionName: version.name, - versionNumber: version.version_number, - fileName, contentType: options.contentType, preferences: candidate.preferences, source: candidate.source, diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue index fd4c3c156..e41283966 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue @@ -2,14 +2,14 @@ import { type Archon, type Labrinth, ModrinthApiError } from '@modrinth/api-client' import { ClipboardCopyIcon } from '@modrinth/assets' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' -import { useIntervalFn } from '@vueuse/core' -import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' +import { computed, nextTick, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import ReadyTransition from '#ui/components/base/ReadyTransition.vue' import UnknownFileWarningModal from '#ui/components/modal/UnknownFileWarningModal.vue' import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload' import { defineMessages, useVIntl } from '#ui/composables/i18n' +import { waitForServerContextRuntimeReady } from '#ui/composables/server-context-runtime' import { useServerPermissions } from '#ui/composables/server-permissions' import { injectModrinthClient, @@ -18,13 +18,6 @@ import { injectServerSettingsModal, } from '#ui/providers' import { commonMessages } from '#ui/utils/common-messages' -import { - type PendingServerContentInstall, - pendingServerContentInstallsEvent, - readPendingServerContentInstallBaseline, - readPendingServerContentInstalls, - removePendingServerContentInstall, -} from '#ui/utils/server-content-installing' import { versionChangesGameVersion } from '#ui/utils/version-compatibility' import type { BrowseInstallPlan } from '../../../shared/browse-tab/composables/install-logic' @@ -193,16 +186,6 @@ const setupActionBusyMessage = computed(() => { const currentWorldInstallProgressItems = computed(() => installProgressItems.value.filter((item) => item.world_id === worldId.value), ) -const hasActiveFileInstallProgress = computed(() => - currentWorldInstallProgressItems.value.some( - (item) => - item.key.type === 'file' && - item.error == null && - item.progress != null && - item.progress < 100, - ), -) - const contentActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0) const contentActionBusyMessage = computed(() => { if (!canSetup.value) return permissionDeniedMessage.value @@ -342,44 +325,217 @@ const addonLookup = computed(() => { }) function normalizeInstallFilename(filename: string) { - return filename.endsWith('.disabled') ? filename.slice(0, -'.disabled'.length) : filename + const normalized = filename.endsWith('.disabled') + ? filename.slice(0, -'.disabled'.length) + : filename + return normalized.toLowerCase() } -const fileInstallProgressByFilename = computed(() => { - const progressByFilename = new Map() - for (const item of currentWorldInstallProgressItems.value) { - if (item.key.type === 'file') { - progressByFilename.set(normalizeInstallFilename(item.key.filename), item) +type FileInstallProgressItem = Archon.Websocket.v0.InstallProgressItem & { + key: Archon.Websocket.v0.InstallProgressFileKey +} +type ServerContentItem = ContentItem & { + installIdentityFilenames?: string[] +} + +const fileInstallProgressItems = computed(() => + currentWorldInstallProgressItems.value.filter( + (item): item is FileInstallProgressItem => item.key.type === 'file', + ), +) + +function getFileInstallTargetFilename(key: Archon.Websocket.v0.InstallProgressFileKey) { + return key.target_filename ?? key.source_filename ?? key.project_id +} + +function getFileInstallFilenames(key: Archon.Websocket.v0.InstallProgressFileKey) { + return [key.source_filename, key.target_filename] + .filter((filename): filename is string => !!filename) + .map(normalizeInstallFilename) +} + +function isFileInstallActive(item: FileInstallProgressItem) { + return item.error == null && item.progress !== 100 +} + +const completedInstallIds = ref>(new Set()) +const settlingFileInstallProgressItems = ref>(new Map()) +const displayedFileInstallProgressItems = computed(() => { + const displayed = new Map(settlingFileInstallProgressItems.value) + for (const item of fileInstallProgressItems.value) { + if (isFileInstallActive(item)) { + displayed.set(item.id, item) } } - return progressByFilename + return Array.from(displayed.values()) }) -const completedInstallFilenames = ref>(new Set()) +const isContentInstallActive = computed( + () => + fileInstallProgressItems.value.some(isFileInstallActive) || + settlingFileInstallProgressItems.value.size > 0, +) + +function sortedUnique(values: Array) { + return Array.from(new Set(values.filter((value): value is string => !!value))).sort() +} + +const installProgressProjectIds = computed(() => + sortedUnique(displayedFileInstallProgressItems.value.map((item) => item.key.project_id)), +) +const installProgressVersionIds = computed(() => + sortedUnique(displayedFileInstallProgressItems.value.map((item) => item.key.version_id)), +) + +const installProgressProjectsQuery = useQuery({ + queryKey: computed( + () => ['labrinth', 'projects', 'v3', 'multiple', installProgressProjectIds.value] as const, + ), + queryFn: () => client.labrinth.projects_v3.getMultiple(installProgressProjectIds.value), + enabled: computed(() => installProgressProjectIds.value.length > 0), + placeholderData: (previousData) => previousData, + staleTime: 5 * 60 * 1000, +}) + +const installProgressVersionsQuery = useQuery({ + queryKey: computed( + () => ['labrinth', 'versions', 'v2', 'multiple', installProgressVersionIds.value] as const, + ), + queryFn: () => client.labrinth.versions_v2.getVersions(installProgressVersionIds.value), + enabled: computed(() => installProgressVersionIds.value.length > 0), + placeholderData: (previousData) => previousData, + staleTime: 5 * 60 * 1000, +}) + +const installProgressTeamIds = computed(() => + sortedUnique( + (installProgressProjectsQuery.data.value ?? []) + .filter( + (project) => installProgressProjectIds.value.includes(project.id) && !project.organization, + ) + .map((project) => project.team_id), + ), +) +const installProgressOrganizationIds = computed(() => + sortedUnique( + (installProgressProjectsQuery.data.value ?? []) + .filter((project) => installProgressProjectIds.value.includes(project.id)) + .map((project) => project.organization), + ), +) + +const installProgressTeamsQuery = useQuery({ + queryKey: computed( + () => ['labrinth', 'teams', 'v3', 'multiple', installProgressTeamIds.value] as const, + ), + queryFn: async () => { + const teamIds = installProgressTeamIds.value + const teams = await client.labrinth.teams_v3.getMultiple(teamIds) + return teamIds.map((teamId, index) => ({ + teamId, + members: teams[index] ?? [], + })) + }, + enabled: computed(() => installProgressTeamIds.value.length > 0), + placeholderData: (previousData) => previousData, + staleTime: 5 * 60 * 1000, +}) + +const installProgressOrganizationsQuery = useQuery({ + queryKey: computed( + () => + [ + 'labrinth', + 'organizations', + 'v3', + 'multiple', + installProgressOrganizationIds.value, + ] as const, + ), + queryFn: () => client.labrinth.organizations_v3.getMultiple(installProgressOrganizationIds.value), + enabled: computed(() => installProgressOrganizationIds.value.length > 0), + placeholderData: (previousData) => previousData, + staleTime: 5 * 60 * 1000, +}) + +const installProgressProjectsById = computed( + () => + new Map( + (installProgressProjectsQuery.data.value ?? []).map((project) => [project.id, project]), + ), +) +const installProgressVersionsById = computed( + () => + new Map( + (installProgressVersionsQuery.data.value ?? []).map((version) => [version.id, version]), + ), +) +const installProgressTeamsById = computed(() => { + const teams = installProgressTeamsQuery.data.value ?? [] + return new Map(teams.map((team) => [team.teamId, team.members])) +}) +const installProgressOrganizationsById = computed( + () => + new Map( + (installProgressOrganizationsQuery.data.value ?? []).map((organization) => [ + organization.id, + organization, + ]), + ), +) + +async function settleCompletedFileInstalls(items: FileInstallProgressItem[]) { + try { + await contentQuery.refetch() + } catch { + return + } finally { + const activeIds = new Set( + fileInstallProgressItems.value.filter(isFileInstallActive).map((item) => item.id), + ) + const nextSettlingItems = new Map(settlingFileInstallProgressItems.value) + for (const item of items) { + if (!activeIds.has(item.id)) nextSettlingItems.delete(item.id) + } + settlingFileInstallProgressItems.value = nextSettlingItems + } +} watch( - fileInstallProgressByFilename, - (progressByFilename, previousProgressByFilename) => { - const completed = new Set(completedInstallFilenames.value) - let shouldRefreshContent = false - if (previousProgressByFilename) { - for (const filename of previousProgressByFilename.keys()) { - if (!progressByFilename.has(filename)) { - if (!completed.has(filename)) shouldRefreshContent = true - completed.add(filename) + fileInstallProgressItems, + (progressItems, previousProgressItems) => { + const completed = new Set(completedInstallIds.value) + const settlingItems = new Map(settlingFileInstallProgressItems.value) + const itemsToSettle = new Map() + const progressIds = new Set(progressItems.map((item) => item.id)) + + if (previousProgressItems) { + for (const item of previousProgressItems) { + if (!progressIds.has(item.id) && isFileInstallActive(item)) { + settlingItems.set(item.id, item) + itemsToSettle.set(item.id, item) + completed.add(item.id) } } } - for (const [filename, item] of progressByFilename) { - if (item.error != null || item.progress === 100) { - if (item.error == null && !completed.has(filename)) shouldRefreshContent = true - completed.add(filename) - } else { - completed.delete(filename) + + for (const item of progressItems) { + if (isFileInstallActive(item)) { + completed.delete(item.id) + settlingItems.delete(item.id) + } else if (item.error != null) { + completed.add(item.id) + settlingItems.delete(item.id) + } else if (!completed.has(item.id)) { + completed.add(item.id) + settlingItems.set(item.id, item) + itemsToSettle.set(item.id, item) } } - completedInstallFilenames.value = completed - if (shouldRefreshContent && !contentQuery.isFetching.value) { - void contentQuery.refetch() + + completedInstallIds.value = completed + settlingFileInstallProgressItems.value = settlingItems + if (itemsToSettle.size > 0) { + void settleCompletedFileInstalls(Array.from(itemsToSettle.values())) } }, { immediate: true }, @@ -390,17 +546,58 @@ function getContentItemInstallFilename(item: ContentItem) { return normalizeInstallFilename(filename) } -function getContentItemInstallProgress(item: ContentItem) { - return fileInstallProgressByFilename.value.get(getContentItemInstallFilename(item)) +function getContentItemInstallProgress(item: ContentItem): FileInstallProgressItem | undefined { + const projectId = item.project?.id + const versionId = item.version?.id + const filename = getContentItemInstallFilename(item) + + return fileInstallProgressItems.value.find((progressItem) => { + const key = progressItem.key + if (key.project_id === projectId) return true + if (key.version_id === versionId) return true + return getFileInstallFilenames(key).includes(filename) + }) +} + +function getInstallProgressOwner(project: Labrinth.Projects.v3.Project | undefined) { + if (!project) return undefined + + if (project.organization) { + const organization = installProgressOrganizationsById.value.get(project.organization) + if (!organization) return undefined + return { + id: organization.id, + name: organization.name, + type: 'organization' as const, + avatar_url: organization.icon_url ?? undefined, + link: `/organization/${organization.slug}`, + } + } + + const members = installProgressTeamsById.value.get(project.team_id) + const owner = + members?.find((member) => member.is_owner) ?? + members?.find((member) => member.role.toLowerCase() === 'owner') + if (!owner) return undefined + + return { + id: owner.user.id, + name: owner.user.username, + type: 'user' as const, + avatar_url: owner.user.avatar_url, + link: `/user/${owner.user.username}`, + } } function fileInstallProgressToContentItem( item: Archon.Websocket.v0.InstallProgressItem, key: Archon.Websocket.v0.InstallProgressFileKey, -): ContentItem { - const filename = key.filename +): ServerContentItem { + const filename = getFileInstallTargetFilename(key) const extensionIndex = filename.lastIndexOf('.') - const title = extensionIndex > 0 ? filename.slice(0, extensionIndex) : filename + const fallbackTitle = extensionIndex > 0 ? filename.slice(0, extensionIndex) : filename + const project = installProgressProjectsById.value.get(key.project_id) + const version = installProgressVersionsById.value.get(key.version_id) const projectType = key.parent_directory === 'plugins' ? 'plugin' @@ -411,76 +608,48 @@ function fileInstallProgressToContentItem( id: `installing:${item.id}`, file_name: filename, project: { - id: item.id, - slug: filename, - title, + id: key.project_id, + slug: project?.slug ?? key.project_id, + title: project?.name ?? fallbackTitle ?? key.project_id, + icon_url: project?.icon_url, }, version: { - id: item.id, - version_number: formatMessage(commonMessages.installingLabel), + id: key.version_id, + version_number: version?.name || version?.version_number || key.version_id, file_name: filename, }, + owner: getInstallProgressOwner(project), enabled: true, project_type: projectType, has_update: false, update_version_id: null, installing: true, installProgress: item.progress, + installIdentityFilenames: getFileInstallFilenames(key), + } +} + +function decorateContentItemWithInstallProgress( + contentItem: ContentItem, + installProgress: FileInstallProgressItem, +): ServerContentItem { + const progressItem = fileInstallProgressToContentItem(installProgress, installProgress.key) + const hasHydratedProject = installProgressProjectsById.value.has(installProgress.key.project_id) + const hasHydratedVersion = installProgressVersionsById.value.has(installProgress.key.version_id) + const installing = isFileInstallActive(installProgress) + + return { + ...contentItem, + project: hasHydratedProject ? progressItem.project : contentItem.project, + version: hasHydratedVersion ? progressItem.version : contentItem.version, + owner: progressItem.owner ?? contentItem.owner, + installing, + installProgress: installing ? installProgress.progress : undefined, + installIdentityFilenames: progressItem.installIdentityFilenames, } } -const pendingServerContentInstalls = ref([]) -const isContentInstallActive = computed( - () => hasActiveFileInstallProgress.value || pendingServerContentInstalls.value.length > 0, -) -const lastStableContentKeys = ref>(new Set()) -const contentInstallBaselineKeys = ref | null>(null) -const contentInstallAddedKeys = ref>(new Set()) const isFlushingStoredServerInstalls = ref(false) -const { pause: pausePendingInstallPoll, resume: resumePendingInstallPoll } = useIntervalFn( - () => { - if (pendingServerContentInstalls.value.length === 0 || contentQuery.isFetching.value) return - void contentQuery.refetch() - }, - 5000, - { immediate: false }, -) - -function syncPendingServerContentInstalls() { - const pendingInstalls = readPendingServerContentInstalls(serverId, worldId.value) - pendingServerContentInstalls.value = pendingInstalls - - const completed = new Set(completedInstallFilenames.value) - let changed = false - for (const item of pendingInstalls) { - if (item.fileName) { - changed = completed.delete(normalizeInstallFilename(item.fileName)) || changed - } - } - if (changed) { - completedInstallFilenames.value = completed - } -} - -function handlePendingServerContentInstallsChanged(event: Event) { - const detail = (event as CustomEvent<{ serverId?: string | null; worldId?: string | null }>) - .detail - if (detail?.serverId !== serverId || detail?.worldId !== worldId.value) return - syncPendingServerContentInstalls() - void flushStoredServerInstalls() -} - -function getAddonInstallKey(addon: Archon.Content.v1.Addon) { - return addon.project_id ?? addon.filename -} - -function getAddonInstallKeys(addons: Archon.Content.v1.Addon[]) { - const keys = new Set() - for (const addon of addons) { - keys.add(getAddonInstallKey(addon)) - } - return keys -} function getInstalledProjectIds() { return new Set( @@ -533,53 +702,6 @@ async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) { return resolvedAddons } -function addonMatchesPendingInstall( - addon: Archon.Content.v1.Addon, - pendingInstall: PendingServerContentInstall, -) { - return ( - addon.project_id === pendingInstall.projectId || - addon.version?.id === pendingInstall.versionId || - (!!pendingInstall.fileName && addon.filename === pendingInstall.fileName) - ) -} - -function removeResolvedPendingServerContentInstalls(addons: Archon.Content.v1.Addon[]) { - if (addons.length === 0 || pendingServerContentInstalls.value.length === 0) return - - for (const pendingInstall of pendingServerContentInstalls.value) { - if (addons.some((addon) => addonMatchesPendingInstall(addon, pendingInstall))) { - removePendingServerContentInstall(serverId, worldId.value, pendingInstall.projectId) - } - } -} - -function syncContentInstallKeys( - addons: Archon.Content.v1.Addon[] = contentQuery.data.value?.addons ?? [], -) { - const currentKeys = getAddonInstallKeys(addons) - if (isContentInstallActive.value) { - if (!contentInstallBaselineKeys.value) { - contentInstallBaselineKeys.value = - readPendingServerContentInstallBaseline(serverId, worldId.value) ?? - new Set(lastStableContentKeys.value) - } - - const nextAddedKeys = new Set(contentInstallAddedKeys.value) - for (const key of currentKeys) { - if (!contentInstallBaselineKeys.value.has(key)) { - nextAddedKeys.add(key) - } - } - contentInstallAddedKeys.value = nextAddedKeys - return - } - - lastStableContentKeys.value = currentKeys - contentInstallBaselineKeys.value = null - contentInstallAddedKeys.value = new Set() -} - async function flushStoredServerInstalls() { const wid = worldId.value if (!wid || isFlushingStoredServerInstalls.value) return @@ -587,6 +709,17 @@ async function flushStoredServerInstalls() { const queuedPlans = getStoredServerAddonInstallQueue(serverId, wid) if (queuedPlans.size === 0) return + try { + await waitForServerContextRuntimeReady(client, serverId) + } catch (error) { + addNotification({ + type: 'error', + title: formatMessage(messages.failedToInstallContent), + text: error instanceof Error ? error.message : undefined, + }) + return + } + isFlushingStoredServerInstalls.value = true try { const result = await flushStoredServerAddonInstallQueue({ @@ -601,9 +734,6 @@ async function flushStoredServerInstalls() { }) if (!result.ok) { - for (const plan of result.attemptedPlans) { - removePendingServerContentInstall(serverId, wid, plan.projectId) - } addNotification({ type: 'error', title: formatMessage(messages.failedToInstallContent), @@ -617,146 +747,39 @@ async function flushStoredServerInstalls() { } } finally { isFlushingStoredServerInstalls.value = false - syncPendingServerContentInstalls() - } -} - -function pendingInstallToContentItem(item: PendingServerContentInstall): ContentItem { - return { - project: { - id: item.projectId, - slug: item.slug ?? item.projectId, - title: item.title, - icon_url: item.iconUrl ?? undefined, - }, - version: { - id: item.versionId, - version_number: - item.versionName ?? item.versionNumber ?? formatMessage(commonMessages.installingLabel), - file_name: item.fileName ?? formatMessage(commonMessages.installingLabel), - }, - owner: item.owner - ? { - id: item.owner.id, - name: item.owner.name, - type: item.owner.type, - avatar_url: getContentOwnerAvatarUrl(item.owner), - link: item.owner.link, - } - : undefined, - id: `installing:${item.projectId}`, - enabled: true, - file_name: `installing:${item.projectId}`, - project_type: item.contentType, - has_update: false, - update_version_id: null, - installing: true, } } const rawContentItems = computed(() => { const addons = contentQuery.data.value?.addons ?? [] - const pendingProjectIds = new Set( - pendingServerContentInstalls.value.map((item) => item.projectId), - ) - const pendingInstallByProjectId = new Map( - pendingServerContentInstalls.value.map((item) => [item.projectId, item]), - ) - const pendingInstallByVersionId = new Map( - pendingServerContentInstalls.value.map((item) => [item.versionId, item]), - ) - const pendingInstallByFileName = new Map() - for (const item of pendingServerContentInstalls.value) { - if (item.fileName) { - pendingInstallByFileName.set(item.fileName, item) - } - } - const installingContentKeys = new Set([...pendingProjectIds, ...contentInstallAddedKeys.value]) - const resolvedPendingProjectIds = new Set( - pendingServerContentInstalls.value - .filter((item) => addons.some((addon) => addonMatchesPendingInstall(addon, item))) - .map((item) => item.projectId), - ) - const pendingItems = pendingServerContentInstalls.value - .filter((item) => !resolvedPendingProjectIds.has(item.projectId)) - .map(pendingInstallToContentItem) const addonItems = addons.map((addon) => { const contentItem = addonToContentItem(addon) - const pendingItem = - (addon.project_id ? pendingInstallByProjectId.get(addon.project_id) : null) ?? - (addon.version?.id ? pendingInstallByVersionId.get(addon.version.id) : null) ?? - pendingInstallByFileName.get(addon.filename) ?? - null const installProgress = getContentItemInstallProgress(contentItem) - const installFilename = getContentItemInstallFilename(contentItem) - const installing = - installProgress != null - ? installProgress.error == null && installProgress.progress !== 100 - : !completedInstallFilenames.value.has(installFilename) && - (!!pendingItem || installingContentKeys.has(getAddonInstallKey(addon))) - - if (!installing || !pendingItem) { - return { - ...contentItem, - installing, - installProgress: installing ? installProgress?.progress : undefined, - } - } - - const pendingContentItem = pendingInstallToContentItem(pendingItem) - return { - ...contentItem, - project: { - ...contentItem.project, - slug: pendingContentItem.project.slug, - title: pendingContentItem.project.title, - icon_url: contentItem.project.icon_url ?? pendingContentItem.project.icon_url, - }, - version: { - id: pendingContentItem.version?.id ?? contentItem.version?.id ?? contentItem.file_name, - version_number: - pendingContentItem.version?.version_number ?? - contentItem.version?.version_number ?? - formatMessage(commonMessages.installingLabel), - file_name: - pendingContentItem.version?.file_name ?? - contentItem.version?.file_name ?? - contentItem.file_name, - }, - owner: pendingContentItem.owner ?? contentItem.owner, - installing, - installProgress: installProgress?.progress, - } + return installProgress + ? decorateContentItemWithInstallProgress(contentItem, installProgress) + : contentItem }) - const pendingDisplayItems = pendingItems.map((item) => { - const installProgress = getContentItemInstallProgress(item) - const installing = - installProgress != null - ? installProgress.error == null && installProgress.progress !== 100 - : !completedInstallFilenames.value.has(getContentItemInstallFilename(item)) - return { - ...item, - installing, - installProgress: installing ? installProgress?.progress : undefined, - } - }) - const displayedInstallFilenames = new Set( - [...addonItems, ...pendingDisplayItems].map(getContentItemInstallFilename), - ) - const progressOnlyItems = currentWorldInstallProgressItems.value.flatMap((item) => { + const displayedInstallFilenames = new Set(addonItems.map(getContentItemInstallFilename)) + const progressOnlyItems = displayedFileInstallProgressItems.value.flatMap((item) => { + if (item.error != null) return [] + const matchingAddon = addons.find((addon) => { + if (addon.project_id === item.key.project_id) return true + if (addon.version?.id === item.key.version_id) return true + return getFileInstallFilenames(item.key).includes(normalizeInstallFilename(addon.filename)) + }) if ( - item.key.type !== 'file' || - item.error != null || - item.progress === 100 || - displayedInstallFilenames.has(normalizeInstallFilename(item.key.filename)) + matchingAddon || + displayedInstallFilenames.has( + normalizeInstallFilename(getFileInstallTargetFilename(item.key)), + ) ) { return [] } return [fileInstallProgressToContentItem(item, item.key)] }) - return [...addonItems, ...pendingDisplayItems, ...progressOnlyItems] + return [...addonItems, ...progressOnlyItems] }) const displayedContentItems = ref([]) @@ -765,48 +788,51 @@ const contentReadyPending = computed( () => contentQuery.isLoading.value && contentQuery.data.value === undefined && - pendingServerContentInstalls.value.length === 0 && displayedContentItems.value.length === 0, ) -function getContentItemDisplayKey(item: ContentItem) { - return item.project?.id ?? item.file_name ?? item.id -} - function getContentItemId(item: ContentItem) { return item.file_name ?? item.id } -function mergeFragileContentItems(items: ContentItem[]) { - const nextItems = new Map(items.map((item) => [getContentItemDisplayKey(item), item])) - const mergedItems = displayedContentItems.value.flatMap((item) => { - let nextKey = getContentItemDisplayKey(item) - let nextItem = nextItems.get(nextKey) - if (!nextItem && item.installing) { - const installFilename = getContentItemInstallFilename(item) - const matchingEntry = Array.from(nextItems.entries()).find( - ([, candidate]) => getContentItemInstallFilename(candidate) === installFilename, - ) - if (matchingEntry) { - nextKey = matchingEntry[0] - nextItem = matchingEntry[1] - } - } - if (!nextItem) { - if ( - item.installing && - completedInstallFilenames.value.has(getContentItemInstallFilename(item)) - ) { - return [] - } - return [item] - } +function getContentItemIdentityFilenames(item: ContentItem) { + const identityFilenames = (item as ServerContentItem).installIdentityFilenames ?? [] + return new Set([ + ...identityFilenames, + normalizeInstallFilename(item.version?.file_name || item.file_name), + ]) +} - nextItems.delete(nextKey) - return [nextItem] +function findMatchingContentItemIndex(item: ContentItem, candidates: ContentItem[]) { + const projectId = item.project?.id + if (projectId) { + const projectIndex = candidates.findIndex((candidate) => candidate.project?.id === projectId) + if (projectIndex !== -1) return projectIndex + } + + const versionId = item.version?.id + if (versionId) { + const versionIndex = candidates.findIndex((candidate) => candidate.version?.id === versionId) + if (versionIndex !== -1) return versionIndex + } + + const filenames = getContentItemIdentityFilenames(item) + return candidates.findIndex((candidate) => + Array.from(getContentItemIdentityFilenames(candidate)).some((filename) => + filenames.has(filename), + ), + ) +} + +function mergeFragileContentItems(items: ContentItem[]) { + const remainingItems = [...items] + const mergedItems = displayedContentItems.value.flatMap((item) => { + const matchingIndex = findMatchingContentItemIndex(item, remainingItems) + if (matchingIndex === -1) return [item] + return remainingItems.splice(matchingIndex, 1) }) - return [...mergedItems, ...nextItems.values()] + return [...mergedItems, ...remainingItems] } watch( @@ -829,62 +855,16 @@ watch( { deep: true, immediate: true }, ) -watch( - [isContentInstallActive, () => contentQuery.data.value?.addons], - ([, addons]) => { - syncContentInstallKeys(addons ?? []) - }, - { deep: true, immediate: true }, -) - -watch( - [() => contentQuery.data.value?.addons, pendingServerContentInstalls], - ([addons]) => { - removeResolvedPendingServerContentInstalls(addons ?? []) - }, - { deep: true, immediate: true }, -) - -watch( - () => pendingServerContentInstalls.value.length > 0, - (hasPendingInstalls) => { - if (hasPendingInstalls) { - resumePendingInstallPoll() - } else { - pausePendingInstallPoll() - } - }, - { immediate: true }, -) - watch( worldId, () => { - completedInstallFilenames.value = new Set() - syncPendingServerContentInstalls() - syncContentInstallKeys() + completedInstallIds.value = new Set() + settlingFileInstallProgressItems.value = new Map() void flushStoredServerInstalls() }, { immediate: true }, ) -onMounted(() => { - syncPendingServerContentInstalls() - void flushStoredServerInstalls() - window.addEventListener( - pendingServerContentInstallsEvent, - handlePendingServerContentInstallsChanged, - ) -}) - -onUnmounted(() => { - pausePendingInstallPoll() - window.removeEventListener( - pendingServerContentInstallsEvent, - handlePendingServerContentInstallsChanged, - ) -}) - const deleteMutation = useMutation({ mutationFn: ({ addon }: { addon: Archon.Content.v1.Addon }) => client.archon.content_v1.deleteAddon(serverId, worldId.value!, { diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue index 7a90fa36b..5a09934ab 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue @@ -303,7 +303,7 @@