Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/app-frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import HostingPlayHandler from '@/components/ui/hosting/HostingPlayHandler.vue'
import { provideServerPlay } from '@modrinth/ui'

import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import SurveyPopup from '@/components/ui/SurveyPopup.vue'
Expand Down Expand Up @@ -1133,6 +1136,13 @@ const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const hostingPlayHandler = ref()
provideServerPlay({
async play(target) {
if (!hostingPlayHandler.value) throw new Error('Server play handler is not ready.')
await hostingPlayHandler.value.play(target)
},
})
const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()

Expand Down Expand Up @@ -1767,6 +1777,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
} else if (e.event === 'PlayHostingServer') {
await hostingPlayHandler.value?.play({ serverId: e.server_id, worldId: e.world_id })
} else if (e.event === 'InstallSharedInstanceInvite') {
await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
} else if (e.event === 'InstallServer') {
Expand Down Expand Up @@ -2616,6 +2628,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
/>
<HostingPlayHandler ref="hostingPlayHandler" />
<SharedInstanceInviteHandler ref="sharedInstanceInviteHandler" />
<InstallToPlayModal ref="installToPlayModal" :show-external-warnings="false" />
<UpdateToPlayModal ref="updateToPlayModal" :show-external-warnings="false" />
Expand Down
210 changes: 210 additions & 0 deletions apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<template>
<ModrinthAccountRequiredModal ref="accountModal" :request-auth="requestAuth" />
<SharedInstanceInstallModal ref="installModal" />
<ContentDiffModal
ref="updateModal"
:header="formatMessage(messages.update)"
:admonition-header="formatMessage(messages.update)"
:description="formatMessage(messages.updateDescription)"
:diffs="updateDiffs"
:confirm-label="formatMessage(messages.update)"
:confirm-icon="DownloadIcon"
show-external-warnings
@confirm="confirmUpdate"
/>
</template>

<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { DownloadIcon } from '@modrinth/assets'
import { ContentDiffModal, type ContentDiffItem, getHostingServerAddress, defineMessages, injectAuth, injectModrinthClient, injectNotificationManager, type ServerPlayTarget, useVIntl } from '@modrinth/ui'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'

import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import { hostingInstanceMetadata, useHostingInstanceCache } from '@/composables/instances/use-hosting-instance'
import { useInstanceLaunchState } from '@/composables/instances/use-instance-launch-state'
import { toError } from '@/helpers/errors'
import { install_job_list, install_get_shared_instance_preview, install_get_shared_instance_update_preview, install_shared_instance, install_update_shared_instance, installJobInstanceId, wait_for_install_job } from '@/helpers/install'
import { get, list } from '@/helpers/instance'
import { get as getCredentials, type ModrinthAuthFlow } from '@/helpers/mr_auth'
import { get_by_instance_id } from '@/helpers/process'
import { ensureManagedServerWorldExists, start_join_server } from '@/helpers/worlds'
import { instanceKeys } from '@/pages/instance/query-options'
import { injectAppEvents } from '@/providers/app-events'

type LaunchTarget = ServerPlayTarget & { sharedInstanceId: string; name: string; userId: string; icon: string | null }
const auth = injectAuth()
const client = injectModrinthClient()
const appEvents = injectAppEvents()
const queryClient = useQueryClient()
const router = useRouter()
const hostingInstances = useHostingInstanceCache()
const instanceLaunch = useInstanceLaunchState()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const accountModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const installModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const updateModal = ref<InstanceType<typeof ContentDiffModal>>()
const updateDiffs = ref<ContentDiffItem[]>([])
const pendingUpdate = ref<{ target: LaunchTarget; instanceId: string }>()

async function assertAccount(target: LaunchTarget) {
if ((await getCredentials())?.user_id !== target.userId) throw new Error(formatMessage(messages.accountChanged))
}
async function findInstance(target: LaunchTarget) {
const instance = (await list()).find((instance) => instance.shared_instance?.id === target.sharedInstanceId && instance.shared_instance.linked_user_id === target.userId)
if (instance && instance.install_stage !== 'installed') {
const job = (await install_job_list(false)).find((job) => installJobInstanceId(job) === instance.id)
if (job) {
await wait_for_install_job(appEvents, job.job_id)
await assertAccount(target)
return await get(instance.id) ?? undefined
}
}
return instance
}
async function openAndLaunch(instanceId: string, launch: () => Promise<void>) {
await instanceLaunch.run(instanceId, async () => {
await router.push(`/instance/${encodeURIComponent(instanceId)}`)
const processes = await get_by_instance_id(instanceId)
queryClient.setQueryData(instanceKeys.processes(instanceId), Array.isArray(processes) ? processes : [])
if (Array.isArray(processes) && processes.length) return
await launch()
})
}
async function join(target: LaunchTarget, instanceId: string) {
await assertAccount(target)
const [server, legacy] = await Promise.all([
client.archon.servers_v1.get(target.serverId),
client.archon.servers_v0.get(target.serverId),
])
if (!server.worlds.some((world) => world.id === target.worldId && world.is_active && world.content?.shared_instance_id === target.sharedInstanceId)) {
throw new Error(formatMessage(messages.worldChanged))
}
await assertAccount(target)
const instance = await get(instanceId)
if (!instance || instance.quarantined || instance.install_stage !== 'installed') throw new Error(formatMessage(messages.notReady))
const address = getHostingServerAddress(legacy.net, server.subdomain)
if (!address) throw new Error(formatMessage(messages.noAddress))
await assertAccount(target)
await ensureManagedServerWorldExists(instanceId, target.name, address)
hostingInstances.value[instanceId] = hostingInstanceMetadata(server, target.worldId, target.sharedInstanceId, address)
await assertAccount(target)
await start_join_server(instanceId, address)
queryClient.setQueryData(instanceKeys.processes(instanceId), [true])
}
const launchMutation = useMutation({
mutationFn: async ({ target, instanceId }: { target: LaunchTarget; instanceId?: string }) => {
await assertAccount(target)
const existing = await findInstance(target)
if (instanceId && existing?.id !== instanceId) throw new Error(formatMessage(messages.notReady))
if (existing) {
if (existing.quarantined || existing.install_stage !== 'installed') throw new Error(formatMessage(messages.notReady))
await openAndLaunch(existing.id, async () => {
const update = await install_get_shared_instance_update_preview(existing.id)
await assertAccount(target)
if (update?.updateAvailable) {
if (!instanceId) {
showUpdate(target, existing.id, update)
return
}
const job = await install_update_shared_instance(existing.id)
await wait_for_install_job(appEvents, job.job_id)
}
await join(target, existing.id)
})
} else {
await assertAccount(target)
const job = await install_shared_instance(target.sharedInstanceId, target.name, null, target.name, target.icon, target.icon)
const installedId = installJobInstanceId(job)
if (!installedId) throw new Error(formatMessage(messages.notReady))
await queryClient.invalidateQueries({ queryKey: ['instances'] })
await wait_for_install_job(appEvents, job.job_id)
await openAndLaunch(installedId, () => join(target, installedId))
}
},
onError: (error) => handleError(toError(error)),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['instances'] }),
})
function showUpdate(target: LaunchTarget, instanceId: string, preview: NonNullable<Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>>) {
pendingUpdate.value = { target, instanceId }
updateDiffs.value = preview.diffs.map((diff) => ({
type: diff.type, projectName: diff.projectName ?? undefined, fileName: diff.fileName ? encodeURIComponent(diff.fileName) : undefined,
currentVersionName: diff.currentVersionName ?? undefined, newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined, disabled: diff.disabled,
external: diff.type === 'added' && !diff.projectId && !!diff.fileName,
}))
updateModal.value?.show()
}
function confirmUpdate() {
if (!pendingUpdate.value || launchMutation.isPending.value) return
launchMutation.mutate(pendingUpdate.value)
pendingUpdate.value = undefined
}
const prepareMutation = useMutation({
mutationFn: async ({ serverId, worldId }: ServerPlayTarget) => {
if (auth.isReady && !auth.isReady.value) {
await new Promise<void>((resolve) => {
const stop = watch(auth.isReady!, (ready) => { if (ready) { stop(); resolve() } })
})
}
if (!auth.session_token.value && !(await accountModal.value?.show())) return
const credentials = await getCredentials()
if (!credentials) return
const server = queryClient.getQueryData<Archon.Servers.v1.ServerFull>(['servers', 'v1', 'detail', serverId])
?? await client.archon.servers_v1.get(serverId)
const world = server.worlds.find((world) => world.id === worldId && world.is_active)
const sharedInstanceId = world?.content?.shared_instance_id
if (!sharedInstanceId) throw new Error(formatMessage(messages.worldChanged))
const target: LaunchTarget = { serverId, worldId, sharedInstanceId, name: server.name, userId: credentials.user_id, icon: null }
await assertAccount(target)
const existing = await findInstance(target)
if (existing) {
if (existing.quarantined || existing.install_stage !== 'installed') throw new Error(formatMessage(messages.notReady))
await openAndLaunch(existing.id, async () => {
const preview = await install_get_shared_instance_update_preview(existing.id)
await assertAccount(target)
if (preview?.updateAvailable) showUpdate(target, existing.id, preview)
else await join(target, existing.id)
})
} else {
const remote = await client.sharedinstances.instances_v1.get(sharedInstanceId)
target.name = remote.name
target.icon = remote.icon
const preview = await install_get_shared_instance_preview(sharedInstanceId, target.name)
await assertAccount(target)
if (remote.icon) preview.iconUrl = remote.icon
installModal.value?.show(preview, async () => {
if (launchMutation.isPending.value) return
await launchMutation.mutateAsync({ target }).catch(() => {})
})
}
},
onError: (error) => handleError(toError(error)),
})
async function play(target: ServerPlayTarget) {
if (prepareMutation.isPending.value || launchMutation.isPending.value) return
await prepareMutation.mutateAsync(target).catch(() => {})
}
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn('', flow, { showModal: false })
return !!(await getCredentials())
}
watch(() => auth.user.value?.id, () => {
installModal.value?.hide()
updateModal.value?.hide()
pendingUpdate.value = undefined
})
const messages = defineMessages({
update: { id: 'hosting.play.update-to-play', defaultMessage: 'Update to play' },
updateDescription: { id: 'hosting.play.update-description', defaultMessage: 'Update this instance to the server’s latest shared content before joining.' },
accountChanged: { id: 'hosting.play.account-changed', defaultMessage: 'Your Modrinth account changed. Press Play server again to continue.' },
worldChanged: { id: 'hosting.play.world-changed', defaultMessage: 'This world is no longer active or has not been shared. Open the server panel and press Play server again.' },
notReady: { id: 'hosting.play.not-ready', defaultMessage: 'This instance is not available to launch. Check its installation status in your library.' },
noAddress: { id: 'hosting.play.no-address', defaultMessage: 'This server does not have a connection address yet.' },
})
defineExpose({ play })
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ export function useSharedInstanceInviteHandler(
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const { notifySharedInstanceConnectionError, notifySharedInstanceError } =
useSharedInstanceErrors()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const popupNotificationManager = injectPopupNotificationManager()
const queryClient = useQueryClient()
const router = useRouter()
Expand Down Expand Up @@ -83,21 +82,15 @@ export function useSharedInstanceInviteHandler(
}

async function resolveInvite(invite: SharedInstanceInvite) {
const [invitedBy, sharedInstance] = await Promise.all([
const invitedBy =
(!invite.invitedByUsername || !invite.invitedByAvatarUrl) && invite.invitedById
? get_user(invite.invitedById, 'bypass').catch(() => null)
: null,
client.sharedinstances.instances_v1.get(invite.sharedInstanceId).catch(() => {
notifySharedInstanceConnectionError()
return null
}),
])
? await get_user(invite.invitedById, 'bypass').catch(() => null)
: null

return {
...invite,
invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
instanceIconUrl: sharedInstance ? sharedInstance.icon : invite.instanceIconUrl,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Archon } from '@modrinth/api-client'
import { injectAuth, injectModrinthClient } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core'
import { computed, type Ref, watch } from 'vue'

import type { GameInstance } from '@/helpers/types'
import { get_instance_worlds } from '@/helpers/worlds'

type HostingInstanceMetadata = {
sharedInstanceId: string
serverId?: string
worldId?: string
address: string
region?: string
}

export function hostingInstanceMetadata(
server: Archon.Servers.v1.ServerFull,
worldId: string,
sharedInstanceId: string,
address: string,
): HostingInstanceMetadata {
const location = server.location
return {
sharedInstanceId,
serverId: server.id,
worldId,
address,
region: location.status === 'assigned' && location.location_metadata.region_should_be_user_displayed
? location.location_metadata.region
: undefined,
}
}

export function useHostingInstanceCache() {
return useStorage<Record<string, HostingInstanceMetadata>>('hosting-instance-metadata', {})
}

export function useHostingInstance(instance: Ref<GameInstance | undefined>, offline: Ref<boolean>) {
const client = injectModrinthClient()
const auth = injectAuth()
const cache = useHostingInstanceCache()
const saved = computed(() => {
const current = instance.value
const metadata = current && cache.value[current.id]
return metadata?.sharedInstanceId === current?.shared_instance?.id ? metadata : undefined
})
const serverQuery = useQuery({
queryKey: computed(() => ['instances', instance.value?.id, 'hosting', auth.user.value?.id]),
enabled: computed(() => !!instance.value?.shared_instance && !offline.value && !!auth.user.value?.id && auth.user.value.id === instance.value.shared_instance.linked_user_id),
queryFn: async () => {
const current = instance.value!
const sharedId = current.shared_instance!.id
const known = saved.value
const shared = await client.sharedinstances.instances_v1.get(sharedId)
if (!shared.linked_server) return null
return {
instanceId: current.id,
metadata: {
...known,
sharedInstanceId: sharedId,
address: shared.linked_server.domain,
region: shared.linked_server.region,
},
}
},
staleTime: 60_000,
retry: false,
})
watch(serverQuery.data, (result) => {
if (result) cache.value[result.instanceId] = result.metadata
})
const isHostingInstance = computed(() => !!saved.value || !!instance.value?.shared_instance?.server_manager_name)
const worldsQuery = useQuery({
queryKey: computed(() => ['instances', instance.value?.id, 'hosting-worlds']),
enabled: computed(() => isHostingInstance.value && !saved.value && instance.value?.install_stage === 'installed'),
queryFn: () => get_instance_worlds(instance.value!.id),
})
const address = computed(() => {
if (saved.value?.address) return saved.value.address
const world = worldsQuery.data.value?.find((world) =>
world.type === 'server' && world.name === instance.value?.shared_instance?.server_manager_name,
)
return world?.type === 'server' ? world.address : undefined
})
return {
isHostingInstance,
region: computed(() => saved.value?.region),
address,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { reactive } from 'vue'

const startingInstances = reactive(new Set<string>())

export function useInstanceLaunchState() {
return {
isStarting: (instanceId: string) => startingInstances.has(instanceId),
async run(instanceId: string, launch: () => Promise<void>) {
if (startingInstances.has(instanceId)) return
startingInstances.add(instanceId)
try {
await launch()
} finally {
startingInstances.delete(instanceId)
}
},
}
}
Loading
Loading