diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 5faea7c84a0..2add6f3bddc 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -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' @@ -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() @@ -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') { @@ -2616,6 +2628,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) @create-anyway="handleContentInstallModpackDuplicateCreateAnyway" @go-to-instance="handleContentInstallModpackDuplicateGoToInstance" /> + diff --git a/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue b/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue new file mode 100644 index 00000000000..17498a9531f --- /dev/null +++ b/apps/app-frontend/src/components/ui/hosting/HostingPlayHandler.vue @@ -0,0 +1,210 @@ + + + diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts index 00bd70b9c04..173ba7dfd73 100644 --- a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts +++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts @@ -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() @@ -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, } } diff --git a/apps/app-frontend/src/composables/instances/use-hosting-instance.ts b/apps/app-frontend/src/composables/instances/use-hosting-instance.ts new file mode 100644 index 00000000000..516b06c3c0d --- /dev/null +++ b/apps/app-frontend/src/composables/instances/use-hosting-instance.ts @@ -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>('hosting-instance-metadata', {}) +} + +export function useHostingInstance(instance: Ref, offline: Ref) { + 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, + } +} diff --git a/apps/app-frontend/src/composables/instances/use-instance-launch-state.ts b/apps/app-frontend/src/composables/instances/use-instance-launch-state.ts new file mode 100644 index 00000000000..8a920347bc5 --- /dev/null +++ b/apps/app-frontend/src/composables/instances/use-instance-launch-state.ts @@ -0,0 +1,18 @@ +import { reactive } from 'vue' + +const startingInstances = reactive(new Set()) + +export function useInstanceLaunchState() { + return { + isStarting: (instanceId: string) => startingInstances.has(instanceId), + async run(instanceId: string, launch: () => Promise) { + if (startingInstances.has(instanceId)) return + startingInstances.add(instanceId) + try { + await launch() + } finally { + startingInstances.delete(instanceId) + } + }, + } +} diff --git a/apps/app-frontend/src/generated/app-events/CommandPayload.ts b/apps/app-frontend/src/generated/app-events/CommandPayload.ts index 1183c97876c..d844877140a 100644 --- a/apps/app-frontend/src/generated/app-events/CommandPayload.ts +++ b/apps/app-frontend/src/generated/app-events/CommandPayload.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CommandPayload = { "event": "InstallMod", id: string, } | { "event": "InstallVersion", id: string, } | { "event": "InstallModpack", id: string, } | { "event": "InstallServer", id: string, } | { "event": "LaunchInstance", id: string, server: string | null, singleplayer_world: string | null, } | { "event": "InstallSharedInstanceInvite", invite_id: string, } | { "event": "RunMRPack", path: string, }; +export type CommandPayload = { "event": "InstallMod", id: string, } | { "event": "InstallVersion", id: string, } | { "event": "InstallModpack", id: string, } | { "event": "InstallServer", id: string, } | { "event": "LaunchInstance", id: string, server: string | null, singleplayer_world: string | null, } | { "event": "InstallSharedInstanceInvite", invite_id: string, } | { "event": "RunMRPack", path: string, } | { "event": "PlayHostingServer", server_id: string, world_id: string, }; diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts index e763358993f..63f113e920c 100644 --- a/apps/app-frontend/src/generated/app-events/postcard/index.d.ts +++ b/apps/app-frontend/src/generated/app-events/postcard/index.d.ts @@ -40,7 +40,7 @@ export type LoadingPayload = { event: LoadingBarType, loader_uuid: string, fract export type WarningPayload = { message: string } export type InstanceBulkUpdateProgressPayload = { instanceId: string, stage: InstanceBulkUpdateProgressStage, current: u64, total: u64 } export type InstanceBulkUpdateProgressStage = { tag: "resolving_versions" } | { tag: "downloading" } | { tag: "finishing" } -export type CommandPayload = { tag: "InstallMod", value: { id: string } } | { tag: "InstallVersion", value: { id: string } } | { tag: "InstallModpack", value: { id: string } } | { tag: "InstallServer", value: { id: string } } | { tag: "LaunchInstance", value: { id: string, server: string | undefined, singleplayer_world: string | undefined } } | { tag: "InstallSharedInstanceInvite", value: { invite_id: string } } | { tag: "RunMRPack", value: { path: string } } +export type CommandPayload = { tag: "InstallMod", value: { id: string } } | { tag: "InstallVersion", value: { id: string } } | { tag: "InstallModpack", value: { id: string } } | { tag: "InstallServer", value: { id: string } } | { tag: "LaunchInstance", value: { id: string, server: string | undefined, singleplayer_world: string | undefined } } | { tag: "InstallSharedInstanceInvite", value: { invite_id: string } } | { tag: "RunMRPack", value: { path: string } } | { tag: "PlayHostingServer", value: { server_id: string, world_id: string } } export type ProcessPayload = { instance_id: string, uuid: string, event: ProcessPayloadType, message: string } export type ProcessPayloadType = { tag: "launched" } | { tag: "finished" } export type InstancePayload = { instance_id: string, event: InstancePayloadType } diff --git a/apps/app-frontend/src/generated/app-events/postcard/index.js b/apps/app-frontend/src/generated/app-events/postcard/index.js index 2407d7da9b5..d43f4a09165 100644 --- a/apps/app-frontend/src/generated/app-events/postcard/index.js +++ b/apps/app-frontend/src/generated/app-events/postcard/index.js @@ -300,6 +300,14 @@ function deserialize_COMMAND_PAYLOAD(d) { path: d.deserialize_string() } }; + case 7: + return { + tag: "PlayHostingServer", + value: { + server_id: d.deserialize_string(), + world_id: d.deserialize_string() + } + }; default: throw "variant not implemented" } diff --git a/apps/app-frontend/src/helpers/shared-instance-errors.ts b/apps/app-frontend/src/helpers/shared-instance-errors.ts index b0ce5d0f74d..6538a1b1597 100644 --- a/apps/app-frontend/src/helpers/shared-instance-errors.ts +++ b/apps/app-frontend/src/helpers/shared-instance-errors.ts @@ -1,16 +1,17 @@ +import { ModrinthApiError } from '@modrinth/api-client' import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui' -import { - getErrorMessage, - isSharedInstancesApiError, - type SharedInstanceUnavailableReason, -} from '@/helpers/install' +import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install' export const sharedInstanceErrorMessages = defineMessages({ unavailableTitle: { id: 'instance.shared-instance.unavailable.title', defaultMessage: 'Shared instance no longer available', }, + notFoundTitle: { + id: 'instance.shared-instance.unavailable.not-found-title', + defaultMessage: 'Shared instance unavailable', + }, lockedTitle: { id: 'instance.shared-instance.unavailable.locked-title', defaultMessage: 'Instance locked', @@ -21,9 +22,9 @@ export const sharedInstanceErrorMessages = defineMessages({ "Your local instance is still available, but it is no longer linked and won't receive updates.", }, deletedText: { - id: 'instance.shared-instance.unavailable.deleted-text', + id: 'instance.shared-instance.unavailable.not-found-text', defaultMessage: - 'The primary instance was deleted. This instance is still available, but it is no longer linked and will no longer receive updates.', + 'We couldn’t find this shared instance. Your installed content is still on this device.', }, accessRevokedText: { id: 'instance.shared-instance.unavailable.access-revoked-text', @@ -65,6 +66,7 @@ export function sharedInstanceUnavailableTextMessage( export function sharedInstanceUnavailableTitleMessage( reason: SharedInstanceUnavailableReason | null, ) { + if (reason === 'deleted') return sharedInstanceErrorMessages.notFoundTitle return reason === 'quarantined' ? sharedInstanceErrorMessages.lockedTitle : sharedInstanceErrorMessages.unavailableTitle @@ -103,15 +105,13 @@ export function useSharedInstanceErrors() { } function notifySharedInstanceError(error: unknown) { - if (isSharedInstancesApiError(error)) { - notifySharedInstanceConnectionError() - return - } + const message = getErrorMessage(error) + const status = error instanceof ModrinthApiError ? error.statusCode : undefined addNotification({ type: 'error', title: formatMessage(sharedInstanceErrorMessages.errorTitle), - text: getErrorMessage(error), + text: status && !message.includes(`HTTP ${status}`) ? `HTTP ${status}: ${message}` : message, }) } diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue index c088dfb550d..d410b4fd08d 100644 --- a/apps/app-frontend/src/pages/Browse.vue +++ b/apps/app-frontend/src/pages/Browse.vue @@ -909,17 +909,18 @@ function getCardActions( const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id) const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(projectResult.project_id) const isInstallingSelection = isInstallingQueuedServerInstalls.value + const showAsInstalling = isInstalling || (isInstallingSelection && isQueuedRoot) const validatingInstall = isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection const installLabel = showAsInstalled ? commonMessages.installedLabel : isQueued - ? isInstalling || isInstallingSelection + ? showAsInstalling ? validatingInstall ? commonMessages.validatingLabel : messages.installingToServer : commonMessages.selectedLabel - : isInstalling || isInstallingSelection + : showAsInstalling ? validatingInstall ? commonMessages.validatingLabel : messages.installingToServer @@ -928,16 +929,11 @@ function getCardActions( { key: 'install', label: formatMessage(installLabel), - icon: - isInstalling || isInstallingSelection - ? SpinnerIcon - : isQueued || showAsInstalled - ? CheckIcon - : PlusIcon, - iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined, + icon: showAsInstalling ? SpinnerIcon : isQueued || showAsInstalled ? CheckIcon : PlusIcon, + iconClass: showAsInstalling ? 'animate-spin' : undefined, disabled: showAsInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot), - color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand', + color: isQueued && !showAsInstalling ? 'green' : 'brand', type: 'outlined', onClick: async () => { if (isQueuedRoot) { diff --git a/apps/app-frontend/src/pages/hosting/manage/Index.vue b/apps/app-frontend/src/pages/hosting/manage/Index.vue index e182d1386fc..60f6a84bf23 100644 --- a/apps/app-frontend/src/pages/hosting/manage/Index.vue +++ b/apps/app-frontend/src/pages/hosting/manage/Index.vue @@ -1,11 +1,11 @@