mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
fix: ws connection duplication + disconnecting during browse
This commit is contained in:
@@ -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<PendingServerContentInstall, 'createdAt'>
|
||||
|
||||
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<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
|
||||
}
|
||||
|
||||
function getQueuedInstallPlaceholder(
|
||||
plan: BrowseInstallPlan<InstallableSearchResult>,
|
||||
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<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return getQueuedAddonInstallPlans(plans).map((plan) =>
|
||||
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
|
||||
)
|
||||
}
|
||||
|
||||
async function getQueuedInstallPlaceholders(
|
||||
client: AbstractModrinthClient,
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return Promise.all(
|
||||
getQueuedAddonInstallPlans(plans).map(async (plan) =>
|
||||
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function createServerInstallContent(opts: {
|
||||
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
|
||||
}) {
|
||||
@@ -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<string | null>(worldIdQuery.value)
|
||||
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
|
||||
const serverContentProjectIds = ref<Set<string>>(new Set())
|
||||
const serverContentInstallKeys = ref<Set<string>>(new Set())
|
||||
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
|
||||
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<InstallableSearchResult>(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)
|
||||
|
||||
@@ -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<PendingServerContentInstall, 'createdAt'>
|
||||
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<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||
) {
|
||||
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<ServerInstallSearchResult>,
|
||||
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<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||
) {
|
||||
return getQueuedAddonInstallPlans(plans).map((plan) =>
|
||||
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
|
||||
)
|
||||
}
|
||||
|
||||
async function getQueuedInstallPlaceholders(
|
||||
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||
) {
|
||||
return Promise.all(
|
||||
getQueuedAddonInstallPlans(plans).map(async (plan) =>
|
||||
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function setProjectInstalling(projectId: string, installing: boolean) {
|
||||
const next = new Set(installingProjectIds.value)
|
||||
if (installing) {
|
||||
@@ -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)
|
||||
|
||||
@@ -9,6 +9,7 @@ export type WebSocketEventHandler<
|
||||
export interface WebSocketConnection {
|
||||
serverId: string
|
||||
socket: WebSocket
|
||||
authenticated: boolean
|
||||
reconnectAttempts: number
|
||||
reconnectTimer?: ReturnType<typeof setTimeout>
|
||||
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<void> {
|
||||
await new Promise<void>((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<E extends Archon.Websocket.v0.WSEventType>(
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<T> = Ref<T> | ComputedRef<T>
|
||||
type RuntimeUnsubscriber = () => void
|
||||
|
||||
type RuntimeReadyWaiter = {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
type ServerContextRuntime = {
|
||||
client: AbstractModrinthClient
|
||||
serverId: string
|
||||
leases: number
|
||||
socketLeases: number
|
||||
syncLeases: number
|
||||
releaseTimer: ReturnType<typeof setTimeout> | null
|
||||
socketReleaseTimer: ReturnType<typeof setTimeout> | null
|
||||
syncReleaseTimer: ReturnType<typeof setTimeout> | null
|
||||
connectPromise: Promise<void> | null
|
||||
socketUnsubscribers: RuntimeUnsubscriber[]
|
||||
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
|
||||
isSocketAuthenticated: Ref<boolean>
|
||||
isSocketAuthIncorrect: Ref<boolean>
|
||||
hasAuthoritativeInstallProgress: Ref<boolean>
|
||||
readyWaiters: Set<RuntimeReadyWaiter>
|
||||
destroyed: boolean
|
||||
}
|
||||
|
||||
export type ServerContextRuntimeLease = {
|
||||
serverId: string
|
||||
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
|
||||
isSocketAuthenticated: Ref<boolean>
|
||||
isSocketAuthIncorrect: Ref<boolean>
|
||||
hasAuthoritativeInstallProgress: Ref<boolean>
|
||||
waitUntilReady: () => Promise<void>
|
||||
release: () => void
|
||||
}
|
||||
|
||||
type RetainServerContextRuntimeOptions = {
|
||||
connect?: boolean
|
||||
socket?: boolean
|
||||
sync?: boolean
|
||||
}
|
||||
|
||||
const runtimeReleaseDelay = 1000
|
||||
const authoritativeReadinessTimeout = 30000
|
||||
const runtimesByClient = new WeakMap<AbstractModrinthClient, Map<string, ServerContextRuntime>>()
|
||||
|
||||
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<void>((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<string | null>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<T> = Ref<T> | ComputedRef<T>
|
||||
type SocketUnsubscriber = () => void
|
||||
|
||||
type ConnectSocketOptions = {
|
||||
force?: boolean
|
||||
extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[]
|
||||
}
|
||||
|
||||
@@ -113,6 +116,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
|
||||
const cpuData = ref<number[]>([])
|
||||
const ramData = ref<number[]>([])
|
||||
let serverContextRuntimeLease: ServerContextRuntimeLease | null = null
|
||||
|
||||
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T> = Ref<T> | ComputedRef<T>
|
||||
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<typeof setTimeout> | 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) {
|
||||
|
||||
@@ -53,9 +53,6 @@ export interface BrowseInstallPlan<TProject extends BrowseInstallProject = Brows
|
||||
project: TProject
|
||||
projectId: string
|
||||
versionId: string
|
||||
versionName?: string
|
||||
versionNumber?: string
|
||||
fileName?: string
|
||||
contentType: BrowseInstallContentType
|
||||
preferences: BrowseInstallPreferences
|
||||
source: BrowseInstallPlanSource
|
||||
@@ -567,15 +564,10 @@ export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
|
||||
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,
|
||||
|
||||
@@ -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<string, Archon.Websocket.v0.InstallProgressItem>()
|
||||
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<FileInstallProgressItem[]>(() =>
|
||||
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<Set<string>>(new Set())
|
||||
const settlingFileInstallProgressItems = ref<Map<string, FileInstallProgressItem>>(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<Set<string>>(new Set())
|
||||
const isContentInstallActive = computed(
|
||||
() =>
|
||||
fileInstallProgressItems.value.some(isFileInstallActive) ||
|
||||
settlingFileInstallProgressItems.value.size > 0,
|
||||
)
|
||||
|
||||
function sortedUnique(values: Array<string | null | undefined>) {
|
||||
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<string, FileInstallProgressItem>()
|
||||
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<PendingServerContentInstall[]>([])
|
||||
const isContentInstallActive = computed(
|
||||
() => hasActiveFileInstallProgress.value || pendingServerContentInstalls.value.length > 0,
|
||||
)
|
||||
const lastStableContentKeys = ref<Set<string>>(new Set())
|
||||
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
|
||||
const contentInstallAddedKeys = ref<Set<string>>(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<string>()
|
||||
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<ContentItem[]>(() => {
|
||||
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<string, PendingServerContentInstall>()
|
||||
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<ContentItem[]>([])
|
||||
@@ -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!, {
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { getNodeWebSocketUrl, ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import { ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CopyIcon,
|
||||
@@ -1150,7 +1150,7 @@ const nodeUnavailableDetails = computed(() => [
|
||||
label: 'Error message',
|
||||
value: nodeAccessible.value
|
||||
? (serverError.value?.message ?? 'Unknown')
|
||||
: 'Unable to reach node. Ping test failed.',
|
||||
: 'Unable to establish the node WebSocket connection.',
|
||||
type: 'block' as const,
|
||||
},
|
||||
])
|
||||
@@ -1268,48 +1268,6 @@ function safeStringify(obj: unknown, indent = ' '): string {
|
||||
)
|
||||
}
|
||||
|
||||
async function testNodeReachability(): Promise<boolean> {
|
||||
const nodeInstance = serverData.value?.node?.instance
|
||||
if (!nodeInstance) return false
|
||||
|
||||
try {
|
||||
const auth = await client.archon.servers_v0.getWebSocketAuth(props.serverId)
|
||||
const authUrl = getNodeWebSocketUrl(auth.url)
|
||||
const protocol = authUrl.toLowerCase().startsWith('ws://') ? 'ws' : 'wss'
|
||||
const wsUrl = getNodeWebSocketUrl(`${nodeInstance}/pingtest`).replace(
|
||||
/^wss?:\/\//i,
|
||||
`${protocol}://`,
|
||||
)
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const timeout = setTimeout(() => {
|
||||
socket.close()
|
||||
resolve(false)
|
||||
}, 5000)
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.send(performance.now().toString())
|
||||
}
|
||||
|
||||
socket.onmessage = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.close()
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping node ${nodeInstance}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function initializeServer() {
|
||||
if (serverData.value?.status === 'suspended') {
|
||||
isLoading.value = false
|
||||
@@ -1321,19 +1279,6 @@ function initializeServer() {
|
||||
return
|
||||
}
|
||||
|
||||
testNodeReachability()
|
||||
.then((result) => {
|
||||
nodeAccessible.value = result
|
||||
if (!nodeAccessible.value) {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error testing node reachability:', err)
|
||||
nodeAccessible.value = false
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
if (serverError.value) {
|
||||
isLoading.value = false
|
||||
} else {
|
||||
@@ -1345,6 +1290,7 @@ function initializeServer() {
|
||||
],
|
||||
})
|
||||
.then((connected) => {
|
||||
nodeAccessible.value = connected
|
||||
if (connected && cachedWsState?.consoleLines?.length) {
|
||||
modrinthServersConsole.clear()
|
||||
modrinthServersConsole.addLines(cachedWsState.consoleLines)
|
||||
|
||||
@@ -8,7 +8,6 @@ export * from './loaders'
|
||||
export * from './notices'
|
||||
export * from './savable'
|
||||
export * from './search'
|
||||
export * from './server-content-installing'
|
||||
export * from './server-search'
|
||||
export * from './tag-messages'
|
||||
export * from './truncate'
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import type {
|
||||
ContentCardProject,
|
||||
ContentCardVersion,
|
||||
ContentOwner,
|
||||
} from '../layouts/shared/content-tab/types'
|
||||
|
||||
export type PendingServerContentInstallType = 'mod' | 'plugin' | 'datapack'
|
||||
type PendingServerContentOwner = Omit<ContentOwner, 'link'> & { link?: string }
|
||||
|
||||
export interface PendingServerContentInstall {
|
||||
projectId: string
|
||||
versionId: string
|
||||
contentType: PendingServerContentInstallType
|
||||
title: ContentCardProject['title']
|
||||
versionName?: ContentCardVersion['version_number'] | null
|
||||
versionNumber?: ContentCardVersion['version_number'] | null
|
||||
fileName?: ContentCardVersion['file_name'] | null
|
||||
owner?: PendingServerContentOwner | null
|
||||
slug?: ContentCardProject['slug'] | null
|
||||
iconUrl?: ContentCardProject['icon_url'] | null
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface PendingServerContentInstallBaseline {
|
||||
contentKeys: string[]
|
||||
projectIds?: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export const pendingServerContentInstallsEvent = 'modrinth:pending-server-content-installs'
|
||||
|
||||
const stalePendingInstallAge = 30 * 60 * 1000
|
||||
|
||||
function getPendingServerContentInstallsKey(serverId: string | null, worldId: string | null) {
|
||||
if (!serverId || !worldId) return null
|
||||
return `server-content-installing:${serverId}:${worldId}`
|
||||
}
|
||||
|
||||
function getPendingServerContentInstallBaselineKey(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
) {
|
||||
if (!serverId || !worldId) return null
|
||||
return `server-content-installing-baseline:${serverId}:${worldId}`
|
||||
}
|
||||
|
||||
function isPendingServerContentInstall(value: unknown): value is PendingServerContentInstall {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return (
|
||||
typeof record.projectId === 'string' &&
|
||||
typeof record.versionId === 'string' &&
|
||||
(record.contentType === 'mod' ||
|
||||
record.contentType === 'plugin' ||
|
||||
record.contentType === 'datapack') &&
|
||||
typeof record.title === 'string' &&
|
||||
typeof record.createdAt === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isPendingServerContentInstallBaseline(
|
||||
value: unknown,
|
||||
): value is PendingServerContentInstallBaseline {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
const contentKeys = record.contentKeys ?? record.projectIds
|
||||
return (
|
||||
Array.isArray(contentKeys) &&
|
||||
contentKeys.every((contentKey) => typeof contentKey === 'string') &&
|
||||
typeof record.createdAt === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function filterFreshPendingServerContentInstalls(items: PendingServerContentInstall[]) {
|
||||
const cutoff = Date.now() - stalePendingInstallAge
|
||||
return items.filter((item) => item.createdAt >= cutoff)
|
||||
}
|
||||
|
||||
function isFreshPendingServerContentInstallBaseline(item: PendingServerContentInstallBaseline) {
|
||||
return item.createdAt >= Date.now() - stalePendingInstallAge
|
||||
}
|
||||
|
||||
function emitPendingServerContentInstallsChanged(serverId: string | null, worldId: string | null) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(pendingServerContentInstallsEvent, {
|
||||
detail: { serverId, worldId },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function readPendingServerContentInstalls(serverId: string | null, worldId: string | null) {
|
||||
const key = getPendingServerContentInstallsKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return []
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
const freshItems = filterFreshPendingServerContentInstalls(
|
||||
parsed.filter(isPendingServerContentInstall),
|
||||
)
|
||||
if (freshItems.length !== parsed.length) {
|
||||
writePendingServerContentInstalls(serverId, worldId, freshItems)
|
||||
}
|
||||
return freshItems
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function writePendingServerContentInstalls(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
items: PendingServerContentInstall[],
|
||||
) {
|
||||
const key = getPendingServerContentInstallsKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return
|
||||
|
||||
const freshItems = filterFreshPendingServerContentInstalls(items)
|
||||
if (freshItems.length === 0) {
|
||||
localStorage.removeItem(key)
|
||||
const baselineKey = getPendingServerContentInstallBaselineKey(serverId, worldId)
|
||||
if (baselineKey) {
|
||||
localStorage.removeItem(baselineKey)
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem(key, JSON.stringify(freshItems))
|
||||
}
|
||||
emitPendingServerContentInstallsChanged(serverId, worldId)
|
||||
}
|
||||
|
||||
export function readPendingServerContentInstallBaseline(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
) {
|
||||
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return null
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!isPendingServerContentInstallBaseline(parsed)) return null
|
||||
if (!isFreshPendingServerContentInstallBaseline(parsed)) {
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
return new Set(parsed.contentKeys ?? parsed.projectIds)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writePendingServerContentInstallBaseline(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
contentKeys: Iterable<string>,
|
||||
) {
|
||||
const key = getPendingServerContentInstallBaselineKey(serverId, worldId)
|
||||
if (!key || typeof localStorage === 'undefined') return
|
||||
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
contentKeys: Array.from(new Set(contentKeys)),
|
||||
createdAt: Date.now(),
|
||||
} satisfies PendingServerContentInstallBaseline),
|
||||
)
|
||||
emitPendingServerContentInstallsChanged(serverId, worldId)
|
||||
}
|
||||
|
||||
export function addPendingServerContentInstalls(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
items: Omit<PendingServerContentInstall, 'createdAt'>[],
|
||||
) {
|
||||
if (items.length === 0) return
|
||||
|
||||
const now = Date.now()
|
||||
const next = new Map(
|
||||
readPendingServerContentInstalls(serverId, worldId).map((item) => [item.projectId, item]),
|
||||
)
|
||||
for (const item of items) {
|
||||
next.set(item.projectId, { ...item, createdAt: now })
|
||||
}
|
||||
writePendingServerContentInstalls(serverId, worldId, Array.from(next.values()))
|
||||
}
|
||||
|
||||
export function removePendingServerContentInstall(
|
||||
serverId: string | null,
|
||||
worldId: string | null,
|
||||
projectId: string,
|
||||
) {
|
||||
writePendingServerContentInstalls(
|
||||
serverId,
|
||||
worldId,
|
||||
readPendingServerContentInstalls(serverId, worldId).filter(
|
||||
(item) => item.projectId !== projectId,
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user