refactor: use DI for instance page + subpages (#6987)

* refactor: use DI for instance page + subpages

* fix: qa

* refactor: layout.vue bring up a layer

* refactor: rename folders

* import order

* refactor: move settings into instance page

* fix: lint

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-08-04 16:52:00 +00:00
committed by GitHub
co-authored by tdgao
parent 99377f8436
commit fe0c97190a
42 changed files with 1009 additions and 1142 deletions
+64 -47
View File
@@ -32,7 +32,7 @@ import {
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
@@ -41,15 +41,9 @@ import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
import {
get_project,
get_project_v3,
get_search_results_v3,
get_version_many,
} from '@/helpers/cache.js'
import { get_project, get_search_results_v3, get_version_many } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events.js'
import {
get as getInstance,
get_installed_project_ids as getInstalledProjectIds,
list as listInstances,
} from '@/helpers/instance'
@@ -57,6 +51,11 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds'
import {
instanceDetailQueryOptions,
instanceKeys,
instanceLinkedProjectQueryOptions,
} from '@/pages/instance/query-options'
import {
type BreadcrumbDefinition,
useBreadcrumb,
@@ -154,30 +153,29 @@ const {
markServerProjectInstalled,
} = serverInstallContent
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
link?: {
type: string
project_id: string
version_id: string
}
}
const initialInstanceId = String(route.query.i ?? '')
const instance: Ref<Instance | null> = ref(
queryClient.getQueryData<Instance>(['instances', 'summary', initialInstanceId]) ?? null,
const initialInstanceId = computed(() => String(route.query.i ?? ''))
const instanceQuery = useQuery(
computed(() => ({
...instanceDetailQueryOptions(initialInstanceId.value),
enabled: !!initialInstanceId.value,
})),
)
const instance = computed(() => instanceQuery.data.value ?? null)
const linkedInstanceProjectId = computed(() => instance.value?.link?.project_id ?? '')
const linkedInstanceProjectQuery = useQuery(
computed(() => ({
...instanceLinkedProjectQueryOptions(linkedInstanceProjectId.value),
enabled: !!linkedInstanceProjectId.value,
})),
)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(route.query.ai === 'true')
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
const isServerInstance = computed(
() => linkedInstanceProjectQuery.data.value?.minecraft_server != null,
)
const instanceBreadcrumb = route.query.i
? useBreadcrumb({
@@ -291,7 +289,13 @@ await initInstanceContext()
async function refreshInstalledProjectIds() {
if (!route.query.i) {
const instances = await listInstances().catch(handleError)
const instances = await queryClient
.fetchQuery({
queryKey: [...instanceKeys.all, 'installed-project-ids'],
queryFn: listInstances,
staleTime: 0,
})
.catch(handleError)
if (!instances) return
const ids = instances
@@ -303,7 +307,14 @@ async function refreshInstalledProjectIds() {
}
if (route.query.from === 'worlds') {
const worlds = await get_instance_worlds(route.query.i as string).catch(handleError)
const targetInstanceId = route.query.i as string
const worlds = await queryClient
.fetchQuery({
queryKey: instanceKeys.installedProjectIds(targetInstanceId, 'worlds'),
queryFn: () => get_instance_worlds(targetInstanceId),
staleTime: 0,
})
.catch(handleError)
if (!worlds) return
const serverProjectIds = worlds
@@ -314,7 +325,14 @@ async function refreshInstalledProjectIds() {
return
}
const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError)
const targetInstanceId = route.query.i as string
const ids = await queryClient
.fetchQuery({
queryKey: instanceKeys.installedProjectIds(targetInstanceId, 'content'),
queryFn: () => getInstalledProjectIds(targetInstanceId),
staleTime: 0,
})
.catch(handleError)
if (!ids) return
debugLog('installedProjectIds loaded', { count: ids.length })
@@ -329,11 +347,13 @@ async function initInstanceContext() {
queryWid: route.query.wid,
queryFrom: route.query.from,
})
await initServerContext()
await refreshInstalledProjectIds()
await Promise.all([
initServerContext(),
refreshInstalledProjectIds(),
route.query.i ? instanceQuery.suspense().catch(handleError) : Promise.resolve(),
])
if (route.query.i) {
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
debugLog('instance loaded', {
name: instance.value?.name,
loader: instance.value?.loader,
@@ -341,15 +361,7 @@ async function initInstanceContext() {
})
if (instance.value?.link?.project_id) {
debugLog('checking linked project for server status', instance.value.link.project_id)
const projectV3 = await get_project_v3(
instance.value.link.project_id,
'must_revalidate',
).catch(handleError)
if (projectV3?.minecraft_server != null) {
debugLog('instance is a server instance')
isServerInstance.value = true
}
await linkedInstanceProjectQuery.suspense().catch(handleError)
}
}
}
@@ -577,16 +589,12 @@ const messages = defineMessages({
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
function resetInstanceContext() {
if (!instance.value) return
debugLog('instance context removed, resetting')
instance.value = null
installedProjectIds.value = null
instanceHideInstalled.value = false
newlyInstalled.value = []
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
isServerInstance.value = false
browseBreadcrumb.reset()
void refreshInstalledProjectIds()
}
@@ -611,9 +619,18 @@ watch(
watch(
() => route.query.i,
(instanceId) => {
if (!instanceId && route.path.startsWith('/browse')) {
async (nextInstanceId, previousInstanceId) => {
if (!route.path.startsWith('/browse') || nextInstanceId === previousInstanceId) return
if (!nextInstanceId) {
resetInstanceContext()
return
}
installedProjectIds.value = null
hiddenInstanceProjectIdsInitialized.value = false
await Promise.all([instanceQuery.suspense().catch(handleError), refreshInstalledProjectIds()])
if (instance.value?.link?.project_id) {
await linkedInstanceProjectQuery.suspense().catch(handleError)
}
},
)
@@ -1,13 +0,0 @@
<template>{{ instance.name }} overview</template>
<script setup lang="ts">
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { GameInstance } from '@/helpers/types'
defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu>
offline: boolean
playing: boolean
installed: boolean
}>()
</script>
@@ -0,0 +1,141 @@
<template>
<StackedAdmonitions v-bind="$attrs" :items="stackItems" class="w-full">
<template #item="{ item, dismissible }">
<InstanceAdmonitionsSharedInstanceStale
v-if="item.kind === 'shared-instance-stale'"
:instance="instance"
@published="emit('published')"
/>
<InstanceAdmonitionsSharedInstanceUpdateAvailable
v-else-if="item.kind === 'shared-instance-update-available'"
:instance-name="instance.name"
@review="emit('review-update', $event)"
/>
<InstanceAdmonitionsSharedInstanceWrongAccount
v-else-if="item.kind === 'shared-instance-wrong-account'"
:expected-user-id="sharedInstanceExpectedUserId"
:role="sharedInstanceRole"
:signed-out="sharedInstanceSignedOut"
/>
<InstanceAdmonitionsSharedInstanceUnavailable
v-else-if="item.kind === 'shared-instance-unavailable'"
:reason="displayedSharedInstanceUnavailableReason"
:manager="sharedInstanceUnavailableManager"
:dismissible="dismissible"
@dismiss="sharedInstanceUnavailableDismissed = true"
@delete="emit('delete')"
/>
</template>
</StackedAdmonitions>
</template>
<script setup lang="ts">
import { StackedAdmonitions } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
import InstanceAdmonitionsSharedInstanceStale from './shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceUpdateAvailable from './shared-instance-update-available.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types.ts'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
instance: GameInstance
sharedInstanceUnavailableReason?: SharedInstanceUnavailableReason | null
sharedInstanceUnavailableManager?: string | null
sharedInstanceWrongAccount?: boolean
sharedInstanceExpectedUserId?: string | null
sharedInstanceRole?: SharedInstanceRole | null
sharedInstanceSignedOut?: boolean
sharedInstanceUpdateAvailable?: boolean
}>()
const emit = defineEmits<{
published: []
delete: []
'review-update': [event: MouseEvent]
}>()
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
const displayedSharedInstanceUnavailableReason = computed<SharedInstanceUnavailableReason | null>(
() =>
props.instance.quarantined ? 'quarantined' : (props.sharedInstanceUnavailableReason ?? null),
)
const sharedInstanceUnavailableDismissed = ref(false)
const showSharedInstancePublishAdmonition = computed(
() =>
!sharedInstanceWrongAccount.value &&
props.instance.install_stage === 'installed' &&
props.instance.shared_instance?.role === 'owner' &&
props.instance.shared_instance.status === 'stale',
)
const showSharedInstanceUpdateAdmonition = computed(
() =>
!sharedInstanceWrongAccount.value &&
!displayedSharedInstanceUnavailableReason.value &&
props.instance.install_stage === 'installed' &&
props.sharedInstanceRole === 'member' &&
props.sharedInstanceUpdateAvailable === true,
)
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
const items: InstanceAdmonitionItem[] = []
if (sharedInstanceWrongAccount.value) {
items.push({
id: 'shared-instance-wrong-account',
type: 'warning',
dismissible: false,
kind: 'shared-instance-wrong-account',
})
}
const unavailableReason = displayedSharedInstanceUnavailableReason.value
const sharedInstanceQuarantined = unavailableReason === 'quarantined'
if (
unavailableReason &&
(sharedInstanceQuarantined || !sharedInstanceUnavailableDismissed.value)
) {
items.push({
id: 'shared-instance-unavailable',
type: 'warning',
dismissible: !sharedInstanceQuarantined,
kind: 'shared-instance-unavailable',
})
}
if (showSharedInstancePublishAdmonition.value) {
items.push({
id: 'shared-instance-stale',
type: 'warning',
dismissible: false,
kind: 'shared-instance-stale',
})
}
if (showSharedInstanceUpdateAdmonition.value) {
items.push({
id: 'shared-instance-update-available',
type: 'info',
dismissible: false,
kind: 'shared-instance-update-available',
})
}
return items
})
watch(
() => [props.instance.id, displayedSharedInstanceUnavailableReason.value],
() => {
sharedInstanceUnavailableDismissed.value = false
},
)
</script>
@@ -0,0 +1,82 @@
import { defineMessages } from '@modrinth/ui'
export const instanceAdmonitionsMessages = defineMessages({
sharedInstanceChangesHeader: {
id: 'app.instance.admonitions.shared-instance.changes-header',
defaultMessage: "Your changes haven't been shared yet",
},
sharedInstanceChangesBody: {
id: 'app.instance.admonitions.shared-instance.changes-body',
defaultMessage: "Your local instance is ahead of the users you've shared it with.",
},
sharedInstancePublishButton: {
id: 'app.instance.admonitions.shared-instance.publish-button',
defaultMessage: 'Push update',
},
sharedInstancePublishingButton: {
id: 'app.instance.admonitions.shared-instance.publishing-button',
defaultMessage: 'Pushing...',
},
sharedInstanceReviewingButton: {
id: 'app.instance.admonitions.shared-instance.reviewing-button',
defaultMessage: 'Reviewing...',
},
sharedInstanceUpdateAvailableHeader: {
id: 'app.instance.admonitions.shared-instance.update-available-header',
defaultMessage: 'An update is available',
},
sharedInstanceUpdateAvailableBody: {
id: 'app.instance.admonitions.shared-instance.update-available-body',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
sharedInstanceReviewUpdateButton: {
id: 'app.instance.admonitions.shared-instance.review-update-button',
defaultMessage: 'Review update',
},
sharedInstanceReviewHeader: {
id: 'app.instance.admonitions.shared-instance.review-header',
defaultMessage: 'Review changes',
},
sharedInstanceReviewAdmonitionHeader: {
id: 'app.instance.admonitions.shared-instance.review-admonition-header',
defaultMessage: 'Push update',
},
sharedInstanceReviewDescription: {
id: 'app.instance.admonitions.shared-instance.review-description',
defaultMessage:
'Review the content changes that will be shared with everyone using this instance.',
},
sharedInstanceAddedLabel: {
id: 'app.instance.admonitions.shared-instance.added-label',
defaultMessage: 'Added',
},
sharedInstanceRemovedLabel: {
id: 'app.instance.admonitions.shared-instance.removed-label',
defaultMessage: 'Removed',
},
sharedInstanceWrongAccountHeader: {
id: 'app.instance.shared-instance-wrong-account.warning-header',
defaultMessage: 'You are using the wrong Modrinth account',
},
sharedInstanceSignedOutHeader: {
id: 'app.instance.shared-instance-wrong-account.signed-out-header',
defaultMessage: 'You need to sign in to Modrinth',
},
sharedInstanceWrongAccountSignInAs: {
id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
defaultMessage: 'Sign in as',
},
sharedInstanceWrongAccountUserBody: {
id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
defaultMessage: 'to receive updates for this shared instance.',
},
sharedInstanceWrongAccountOwnerBody: {
id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
},
sharedInstanceWrongAccountFallbackUsername: {
id: 'app.instance.shared-instance-wrong-account.fallback-username',
defaultMessage: 'the linked account',
},
})
@@ -0,0 +1,66 @@
<template>
<Admonition
type="info"
inline-actions
:header="formatMessage(messages.sharedInstanceChangesHeader)"
>
{{ formatMessage(messages.sharedInstanceChangesBody) }}
<template #actions>
<ButtonStyled color="blue">
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
<SpinnerIcon
v-if="isReviewingPublish || isPublishing"
class="animate-spin"
aria-hidden="true"
/>
<UploadIcon v-else aria-hidden="true" />
{{
isPublishing
? formatMessage(messages.sharedInstancePublishingButton)
: isReviewingPublish
? formatMessage(messages.sharedInstanceReviewingButton)
: formatMessage(messages.sharedInstancePublishButton)
}}
</button>
</ButtonStyled>
</template>
</Admonition>
<SharedInstancePublishModal
ref="publishModal"
:instance="instance"
@published="emit('published')"
@state-change="publishState = $event"
/>
</template>
<script setup lang="ts">
import { SpinnerIcon, UploadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
import type { GameInstance } from '@/helpers/types'
import { instanceAdmonitionsMessages as messages } from './messages'
defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
}>()
const { formatMessage } = useVIntl()
const publishModal = ref<InstanceType<typeof SharedInstancePublishModal>>()
const publishState = ref<'idle' | 'reviewing' | 'publishing'>('idle')
const isPublishing = computed(() => publishState.value === 'publishing')
const isReviewingPublish = computed(() => publishState.value === 'reviewing')
const isPublishButtonDisabled = computed(() => isPublishing.value || isReviewingPublish.value)
function reviewChanges(e?: MouseEvent) {
publishModal.value?.show(e)
}
</script>
@@ -0,0 +1,51 @@
<template>
<Admonition
type="warning"
:inline-actions="reason === 'quarantined'"
:header="formatMessage(sharedInstanceUnavailableTitleMessage(reason ?? null))"
:dismissible="dismissible"
@dismiss="emit('dismiss')"
>
{{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
<template v-if="reason === 'quarantined'" #actions>
<ButtonStyled color="orange">
<button class="!h-10" @click="emit('delete')">
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.deleteInstance) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { TrashIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import {
sharedInstanceUnavailableTitleMessage,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
defineProps<{
reason?: SharedInstanceUnavailableReason | null
manager?: string | null
dismissible?: boolean
}>()
const emit = defineEmits<{
dismiss: []
delete: []
}>()
const { formatMessage } = useVIntl()
const { formatSharedInstanceUnavailable } = useSharedInstanceErrors()
const messages = defineMessages({
deleteInstance: {
id: 'instance.locked.delete-button',
defaultMessage: 'Delete instance',
},
})
</script>
@@ -0,0 +1,34 @@
<template>
<Admonition
type="info"
inline-actions
:header="formatMessage(messages.sharedInstanceUpdateAvailableHeader)"
>
{{ formatMessage(messages.sharedInstanceUpdateAvailableBody, { name: instanceName }) }}
<template #actions>
<ButtonStyled color="blue">
<button class="!h-10" @click="emit('review', $event)">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.sharedInstanceReviewUpdateButton) }}
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
import { instanceAdmonitionsMessages as messages } from './messages'
defineProps<{
instanceName: string
}>()
const emit = defineEmits<{
review: [event: MouseEvent]
}>()
const { formatMessage } = useVIntl()
</script>
@@ -0,0 +1,78 @@
<template>
<Admonition type="warning" :header="formatMessage(headerMessage)">
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}</span>
<span
v-if="sharedInstanceExpectedUser"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-contrast"
>
<Avatar
:src="sharedInstanceExpectedUser.avatarUrl"
:alt="sharedInstanceExpectedUsername"
:tint-by="sharedInstanceExpectedUser.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceExpectedUsername }}</span>
</span>
<span v-else class="font-semibold">{{ sharedInstanceExpectedUsername }}</span>
<span>{{ formatMessage(bodyMessage) }}</span>
</span>
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, Avatar, useVIntl } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { get_user } from '@/helpers/cache'
import { instanceAdmonitionsMessages as messages } from './messages'
import type { SharedInstanceRole } from './types'
const props = defineProps<{
expectedUserId?: string | null
role?: SharedInstanceRole | null
signedOut?: boolean
}>()
const { formatMessage } = useVIntl()
const expectedUserId = computed(() => props.expectedUserId ?? null)
const expectedUserQuery = useQuery({
queryKey: computed(() => ['user', expectedUserId.value]),
queryFn: async () => {
if (!expectedUserId.value) return null
return await get_user(expectedUserId.value, 'bypass').catch(() => null)
},
enabled: () => !!expectedUserId.value,
staleTime: 30_000,
})
const sharedInstanceExpectedUser = computed(() => {
const user = expectedUserQuery.data.value
if (!user) return null
return {
username: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const sharedInstanceExpectedUsername = computed(
() =>
sharedInstanceExpectedUser.value?.username ||
formatMessage(messages.sharedInstanceWrongAccountFallbackUsername),
)
const headerMessage = computed(() =>
props.signedOut
? messages.sharedInstanceSignedOutHeader
: messages.sharedInstanceWrongAccountHeader,
)
const bodyMessage = computed(() =>
props.role === 'owner'
? messages.sharedInstanceWrongAccountOwnerBody
: messages.sharedInstanceWrongAccountUserBody,
)
</script>
@@ -0,0 +1,13 @@
import type { StackedAdmonitionItem } from '@modrinth/ui'
export type InstanceAdmonitionKind =
| 'shared-instance-stale'
| 'shared-instance-update-available'
| 'shared-instance-unavailable'
| 'shared-instance-wrong-account'
export type InstanceAdmonitionItem = StackedAdmonitionItem & {
kind: InstanceAdmonitionKind
}
export type SharedInstanceRole = 'owner' | 'member'
@@ -0,0 +1,409 @@
<template>
<PageHeader :title="instance.name">
<template #leading>
<Avatar :src="iconSrc" :alt="instance.name" size="64px" :tint-by="instance.id" />
</template>
<template v-if="instance.shared_instance || instance.quarantined" #badges>
<PageHeaderBadgeItem
v-if="instance.quarantined"
:icon="LockIcon"
aria-label="Locked instance information"
class="!border-orange !bg-highlight-orange !text-orange"
>
Locked
</PageHeaderBadgeItem>
<PageHeaderBadgeItem
v-else
:tooltip="sharedInstanceTooltip"
aria-label="Shared instance information"
class="!border-blue !bg-highlight-blue !text-blue"
>
Shared
<UnknownIcon class="block size-4 shrink-0 text-current" aria-hidden="true" />
</PageHeaderBadgeItem>
</template>
<template #metadata>
<div v-if="isServerInstance" class="flex flex-wrap items-center gap-2">
<InstanceHeaderServerMetadata
:loading-server-ping="loadingServerPing"
:players-online="playersOnline"
:status-online="statusOnline"
:recent-plays="recentPlays"
:ping="ping"
:minecraft-server="minecraftServer"
:linked-project-v3="linkedProjectV3"
:instance-id="instance.id"
/>
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
{{ sharedInstanceManagerLabel }}
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.name"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
:circle="sharedInstanceManager.type === 'user'"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
</PageHeaderMetadataItem>
</div>
<PageHeaderMetadata v-else>
<PageHeaderMetadataItem :icon="Gamepad2Icon" tooltip="Minecraft version">
Minecraft {{ instance.game_version }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="sharedInstanceManager?.type !== 'user'"
:icon="ServerLoaderIcon"
:icon-props="{ loader: loaderDisplayName }"
tooltip="Mod loader"
>
{{ loaderLabel }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem
v-if="showInstancePlayTime"
:icon="TimerIcon"
tooltip="Total playtime"
>
{{ playtimeLabel }}
</PageHeaderMetadataItem>
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
{{ sharedInstanceManagerLabel }}
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.name"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
:circle="sharedInstanceManager.type === 'user'"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled v-if="isInstalling" color="brand" size="large">
<button type="button" disabled>
{{ formatMessage(commonMessages.installingLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing" color="red" size="large">
<button type="button" :disabled="stopping" @click="emit('stop')">
<StopCircleIcon />
{{
stopping ? formatMessage(messages.stopping) : formatMessage(commonMessages.stopButton)
}}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.quarantined" color="brand" size="large">
<button v-tooltip="formatMessage(messages.lockedPlayTooltip)" type="button" disabled>
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="instance.install_stage !== 'installed'" color="brand" size="large">
<button type="button" @click="emit('repair')">
<DownloadIcon />
{{ formatMessage(messages.repair) }}
</button>
</ButtonStyled>
<JoinedButtons
v-else-if="!loading && isServerInstance"
:actions="serverPlayActions"
color="brand"
size="large"
/>
<ButtonStyled v-else-if="!loading" color="brand" size="large">
<button type="button" @click="emit('play')">
<PlayIcon />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button type="button" disabled>{{ formatMessage(messages.starting) }}</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button
v-tooltip="formatMessage(messages.instanceSettings)"
type="button"
:aria-label="formatMessage(messages.instanceSettings)"
@click="emit('settings')"
>
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu
:options="moreActions"
:tooltip="formatMessage(messages.moreActions)"
:aria-label="formatMessage(messages.moreActions)"
>
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
DownloadIcon,
ExternalIcon,
FolderOpenIcon,
LockIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
ReportIcon,
SettingsIcon,
StopCircleIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TimerIcon,
UnknownIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
formatLoaderLabel,
type JoinedButtonAction,
JoinedButtons,
LoaderIcon as ServerLoaderIcon,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
PageHeaderMetadata,
PageHeaderMetadataItem,
type ServerLoader,
TeleportOverflowMenu,
type TeleportOverflowMenuItem,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import InstanceHeaderServerMetadata from './instance-page-header-server-metadata.vue'
const messages = defineMessages({
createShortcut: {
id: 'instance.action.create-shortcut',
defaultMessage: 'Create shortcut',
},
exportModpack: {
id: 'instance.action.export-modpack',
defaultMessage: 'Export modpack',
},
instanceSettings: {
id: 'instance.action.settings',
defaultMessage: 'Instance settings',
},
launchInstance: {
id: 'instance.action.launch-instance',
defaultMessage: 'Launch instance',
},
moreActions: {
id: 'instance.action.more-actions',
defaultMessage: 'More actions',
},
neverPlayed: {
id: 'instance.playtime.never-played',
defaultMessage: 'Never played',
},
openFolder: {
id: 'instance.action.open-folder',
defaultMessage: 'Open folder',
},
repair: {
id: 'instance.action.repair',
defaultMessage: 'Repair',
},
lockedPlayTooltip: {
id: 'instance.locked.play-tooltip',
defaultMessage: 'This instance has been locked',
},
starting: {
id: 'instance.action.starting',
defaultMessage: 'Starting...',
},
stopping: {
id: 'instance.action.stopping',
defaultMessage: 'Stopping...',
},
sharedInstanceTooltip: {
id: 'instance.shared-instance.tooltip',
defaultMessage: "This instance's content is being managed by someone else.",
},
sharedInstanceOwnerTooltip: {
id: 'instance.shared-instance.owner-tooltip',
defaultMessage: "This instance's content is being shared to other users.",
},
})
const router = useRouter()
const props = withDefaults(
defineProps<{
instance: GameInstance
iconSrc?: string | null
isServerInstance?: boolean
showInstancePlayTime?: boolean
timePlayed?: number
playing?: boolean
loading?: boolean
stopping?: boolean
loadingServerPing?: boolean
playersOnline?: number
statusOnline?: boolean
recentPlays?: number
ping?: number
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
linkedProjectV3?: Labrinth.Projects.v3.Project
sharedInstanceManager?: {
type: 'user' | 'server'
name: string
avatarUrl?: string
tintBy: string
} | null
}>(),
{
iconSrc: null,
isServerInstance: false,
showInstancePlayTime: false,
timePlayed: 0,
playing: false,
loading: false,
stopping: false,
loadingServerPing: false,
playersOnline: undefined,
statusOnline: false,
recentPlays: undefined,
ping: undefined,
minecraftServer: undefined,
linkedProjectV3: undefined,
sharedInstanceManager: null,
},
)
const emit = defineEmits<{
repair: []
stop: []
play: []
playServer: []
settings: []
openFolder: []
export: []
createShortcut: []
report: [event?: MouseEvent]
}>()
const installingStages = [
'installing',
'pack_installing',
'pack_installed',
'not_installed',
'minecraft_installing',
]
const { formatMessage } = useVIntl()
const isInstalling = computed(() => installingStages.includes(props.instance.install_stage))
const loaderDisplayName = computed(() => formatLoaderLabel(props.instance.loader) as ServerLoader)
const loaderLabel = computed(() =>
[loaderDisplayName.value, props.instance.loader_version].filter(Boolean).join(' '),
)
const sharedInstanceTooltip = computed(() =>
formatMessage(
props.instance.shared_instance?.role === 'owner'
? messages.sharedInstanceOwnerTooltip
: messages.sharedInstanceTooltip,
),
)
const sharedInstanceManagerLabel = computed(() =>
props.sharedInstanceManager?.type === 'server' ? 'Linked to' : 'Managed by',
)
const sharedInstanceManagerAction = computed(() => {
const manager = props.sharedInstanceManager
if (manager?.type !== 'user') return undefined
return () => router.push(`/user/${encodeURIComponent(manager.name)}`)
})
const playtimeLabel = computed(() => {
if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed)
const hours = Math.floor(props.timePlayed / 3600)
if (hours >= 1) {
return `${hours} hour${hours > 1 ? 's' : ''}`
}
const minutes = Math.floor(props.timePlayed / 60)
if (minutes >= 1) {
return `${minutes} minute${minutes > 1 ? 's' : ''}`
}
const seconds = Math.floor(props.timePlayed)
return `${seconds} second${seconds > 1 ? 's' : ''}`
})
const serverPlayActions = computed<JoinedButtonAction[]>(() => [
{
id: 'join_server',
label: formatMessage(commonMessages.playButton),
icon: PlayIcon,
action: () => emit('playServer'),
},
{
id: 'launch_instance',
label: formatMessage(messages.launchInstance),
icon: PlayIcon,
action: () => emit('play'),
},
])
const moreActions = computed<TeleportOverflowMenuItem[]>(() => {
const actions: TeleportOverflowMenuItem[] = [
{
id: 'open-folder',
label: formatMessage(messages.openFolder),
icon: FolderOpenIcon,
action: () => emit('openFolder'),
},
]
if (!props.instance.quarantined) {
actions.push(
{
id: 'export-mrpack',
label: formatMessage(messages.exportModpack),
icon: PackageIcon,
action: () => emit('export'),
},
{
id: 'create-shortcut',
label: formatMessage(messages.createShortcut),
icon: ExternalIcon,
action: () => emit('createShortcut'),
},
)
}
if (props.instance.shared_instance?.role === 'member') {
actions.push(
{ divider: true },
{
id: 'report-shared-instance',
label: formatMessage(commonMessages.reportButton),
icon: ReportIcon,
color: 'red',
action: (event) => emit('report', event),
},
)
}
return actions
})
</script>
@@ -0,0 +1,63 @@
<template>
<div class="flex items-center flex-wrap gap-2">
<template v-if="loadingServerPing">
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerPing v-if="ping" :ping="ping" />
</template>
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
<div v-if="minecraftServer?.region || ping" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div v-if="linkedProjectV3" class="flex gap-1.5 items-center font-medium text-primary">
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instanceId"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary truncate"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
Avatar,
ServerOnlinePlayers,
ServerPing,
ServerRecentPlays,
ServerRegion,
} from '@modrinth/ui'
defineProps<{
loadingServerPing?: boolean
playersOnline?: number
statusOnline?: boolean
recentPlays?: number
ping?: number
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
linkedProjectV3?: Labrinth.Projects.v3.Project
instanceId?: string
}>()
</script>
@@ -0,0 +1,82 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
<IntlFormatted :message-id="messages.admonitionBody" :values="{ code: inviteCode }">
<template #monospace="{ children }">
<code class="font-mono"><component :is="() => children" /></code>
</template>
</IntlFormatted>
</Admonition>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<XIcon />
{{ formatMessage(messages.revokeButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const inviteCode = ref('')
const emit = defineEmits<{
revoke: [inviteCode: string]
}>()
function show(code: string) {
inviteCode.value = code
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('revoke', inviteCode.value)
}
const messages = defineMessages({
header: {
id: 'instance.settings.sharing.revoke-invite.header',
defaultMessage: 'Revoke invite',
},
admonitionHeader: {
id: 'instance.settings.sharing.revoke-invite.admonition-header',
defaultMessage: 'This action cannot be undone',
},
admonitionBody: {
id: 'instance.settings.sharing.revoke-invite.admonition-body',
defaultMessage:
'The invite link <monospace>{code}</monospace> will stop working immediately. People who already joined will keep access.',
},
revokeButton: {
id: 'instance.settings.sharing.revoke-invite.confirm',
defaultMessage: 'Revoke invite',
},
})
defineExpose({ show })
</script>
@@ -0,0 +1,450 @@
<script setup lang="ts">
import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Checkbox,
Chips,
defineMessages,
injectNotificationManager,
OverflowMenu,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, list, remove } from '@/helpers/instance'
import type { GameInstance } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const router = useRouter()
const queryClient = useQueryClient()
const deleteConfirmModal = ref()
const { instance } = injectInstanceSettings()
type ReleaseChannel = GameInstance['update_channel']
const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
const title = ref(instance.value.name)
const icon: Ref<string | undefined> = ref(instance.value.icon_path)
const groups = ref([...instance.value.groups])
const savingReleaseChannel = ref(false)
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.update_channel)
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
savingReleaseChannel.value ? [...releaseChannelOptions] : [],
)
const newCategoryInput = ref('')
const installing = computed(() => instance.value.install_stage !== 'installed')
async function duplicateInstance() {
await install_duplicate_instance(instance.value.id).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
const allInstances = ref((await list()) as GameInstance[])
const availableGroups = computed(() => [
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
])
function formatReleaseChannelLabel(channel: ReleaseChannel) {
switch (channel) {
case 'release':
return formatMessage(messages.updateChannelRelease)
case 'beta':
return formatMessage(messages.updateChannelBeta)
case 'alpha':
return formatMessage(messages.updateChannelAlpha)
}
}
function formatReleaseChannelDescription(channel: ReleaseChannel) {
switch (channel) {
case 'release':
return formatMessage(messages.updateChannelReleaseDescription)
case 'beta':
return formatMessage(messages.updateChannelBetaDescription)
case 'alpha':
return formatMessage(messages.updateChannelAlphaDescription)
}
}
watch(
() => [instance.value.id, instance.value.update_channel] as const,
() => {
if (!savingReleaseChannel.value) {
selectedReleaseChannel.value = instance.value.update_channel
}
},
)
watch(selectedReleaseChannel, async (channel, previousChannel) => {
const previousReleaseChannel = previousChannel ?? instance.value.update_channel
if (channel === instance.value.update_channel) return
savingReleaseChannel.value = true
const instanceId = instance.value.id
await edit(instanceId, { update_channel: channel })
.then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instanceId] }))
.catch((error) => {
selectedReleaseChannel.value = previousReleaseChannel
handleError(error)
})
savingReleaseChannel.value = false
})
async function resetIcon() {
try {
await edit_icon(instance.value.id, null)
icon.value = undefined
trackEvent('InstanceRemoveIcon')
} catch (error) {
handleError(error)
}
}
async function setIcon() {
const value = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
},
],
})
if (!value) return
try {
await edit_icon(instance.value.id, value)
icon.value = value
trackEvent('InstanceSetIcon')
} catch (error) {
handleError(error)
}
}
const editInstanceObject = computed(() => ({
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
}))
const toggleGroup = (group: string) => {
if (groups.value.includes(group)) {
groups.value = groups.value.filter((x) => x !== group)
} else {
groups.value.push(group)
}
}
const addCategory = () => {
const text = newCategoryInput.value.trim()
if (text.length > 0) {
groups.value.push(text.substring(0, 32))
newCategoryInput.value = ''
}
}
watch(
[title, groups, groups],
async () => {
if (removing.value) return
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
},
{ deep: true },
)
const removing = ref(false)
async function removeInstance() {
removing.value = true
const path = instance.value.id
trackEvent('InstanceRemove', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
await router.push({ path: '/' })
await remove(path).catch(handleError)
}
const messages = defineMessages({
name: {
id: 'instance.settings.tabs.general.name',
defaultMessage: 'Name',
},
libraryGroups: {
id: 'instance.settings.tabs.general.library-groups',
defaultMessage: 'Library groups',
},
libraryGroupsDescription: {
id: 'instance.settings.tabs.general.library-groups.description',
defaultMessage:
'Library groups allow you to organize your instances into different sections in your library.',
},
libraryGroupsEnterName: {
id: 'instance.settings.tabs.general.library-groups.enter-name',
defaultMessage: 'Enter group name',
},
libraryGroupsCreate: {
id: 'instance.settings.tabs.general.library-groups.create',
defaultMessage: 'Create new group',
},
editIcon: {
id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon',
},
selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon',
},
removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon',
},
duplicateInstance: {
id: 'instance.settings.tabs.general.duplicate-instance',
defaultMessage: 'Duplicate instance',
},
duplicateInstanceDescription: {
id: 'instance.settings.tabs.general.duplicate-instance.description',
defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.',
},
duplicateButtonTooltipInstalling: {
id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing',
defaultMessage: 'Cannot duplicate while installing.',
},
duplicateButton: {
id: 'instance.settings.tabs.general.duplicate-button',
defaultMessage: 'Duplicate',
},
updateChannel: {
id: 'instance.settings.tabs.general.update-channel',
defaultMessage: 'Update channel',
},
updateChannelReleaseDescription: {
id: 'instance.settings.tabs.general.update-channel.release.description',
defaultMessage: 'Only release versions will be shown as available updates.',
},
updateChannelBetaDescription: {
id: 'instance.settings.tabs.general.update-channel.beta.description',
defaultMessage: 'Release and beta versions will be shown as available updates.',
},
updateChannelAlphaDescription: {
id: 'instance.settings.tabs.general.update-channel.alpha.description',
defaultMessage: 'Release, beta, and alpha versions will be shown as available updates.',
},
updateChannelRelease: {
id: 'instance.settings.tabs.general.update-channel.release',
defaultMessage: 'Release',
},
updateChannelBeta: {
id: 'instance.settings.tabs.general.update-channel.beta',
defaultMessage: 'Beta',
},
updateChannelAlpha: {
id: 'instance.settings.tabs.general.update-channel.alpha',
defaultMessage: 'Alpha',
},
selectUpdateChannelAriaLabel: {
id: 'instance.settings.tabs.general.update-channel.select',
defaultMessage: 'Select update channel',
},
deleteInstance: {
id: 'instance.settings.tabs.general.delete',
defaultMessage: 'Delete instance',
},
deleteInstanceDescription: {
id: 'instance.settings.tabs.general.delete.description',
defaultMessage:
'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.',
},
deleteInstanceButton: {
id: 'instance.settings.tabs.general.delete.button',
defaultMessage: 'Delete instance',
},
deletingInstanceButton: {
id: 'instance.settings.tabs.general.deleting.button',
defaultMessage: 'Deleting...',
},
})
</script>
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeInstance" />
<div class="block">
<div class="float-end ml-10 relative group w-fit">
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Icon</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[
{
id: 'select',
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
action: () => resetIcon(),
shown: !!icon,
},
]"
>
<Avatar
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="transition-[filter] group-hover:brightness-75"
:tint-by="instance.id"
no-shadow
/>
<div
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
>
<EditIcon aria-hidden="true" class="h-10 w-10 text-primary" />
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</div>
</div>
</div>
<label for="instance-name" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.name) }}
</label>
<div class="flex">
<StyledInput
id="instance-name"
v-model="title"
autocomplete="off"
:maxlength="80"
wrapper-class="flex-grow"
/>
</div>
<template v-if="instance.install_stage == 'installed'">
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.duplicateInstance) }}
</h2>
<ButtonStyled>
<button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
class="w-max !shadow-none"
@click="duplicateInstance"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button>
</ButtonStyled>
<p class="m-0">
{{ formatMessage(messages.duplicateInstanceDescription) }}
</p>
</div>
</template>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.libraryGroups) }}
</h2>
<div class="flex flex-col gap-1">
<Checkbox
v-for="group in availableGroups"
:key="group"
:model-value="groups.includes(group)"
:label="group"
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<StyledInput
v-model="newCategoryInput"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
class="w-full max-w-[300px]"
@submit="() => addCategory"
/>
<ButtonStyled>
<button class="w-fit !shadow-none" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button>
</ButtonStyled>
</div>
</div>
<p class="m-0">
{{ formatMessage(messages.libraryGroupsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.updateChannel) }}
</h2>
<Chips
v-model="selectedReleaseChannel"
:items="releaseChannelOptions"
:format-label="formatReleaseChannelLabel"
:capitalize="false"
:disabled-items="releaseChannelDisabledItems"
:aria-label="formatMessage(messages.selectUpdateChannelAriaLabel)"
/>
<p class="m-0">
{{ formatReleaseChannelDescription(selectedReleaseChannel) }}
</p>
</div>
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<ButtonStyled color="red">
<button
aria-labelledby="delete-instance-label"
:disabled="removing"
class="w-fit !shadow-none"
@click="deleteConfirmModal.show()"
>
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</button>
</ButtonStyled>
<p class="m-0">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
</div>
</div>
</template>
<style scoped lang="scss">
.hovering-icon-shadow {
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
}
</style>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import {
Checkbox,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { edit } from '@/helpers/instance'
import { get } from '@/helpers/settings.ts'
import type { AppSettings, Hooks } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideHooks = ref(
!!instance.value.hooks.pre_launch ||
!!instance.value.hooks.wrapper ||
!!instance.value.hooks.post_exit,
)
const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
const editInstanceObject = computed(() => {
const editInstancePatch: {
hooks?: Hooks
} = {}
// When hooks are not overridden per-instance, we want to clear them
editInstancePatch.hooks = overrideHooks.value ? hooks.value : {}
return editInstancePatch
})
watch(
[overrideHooks, hooks],
async () => {
await edit(instance.value.id, editInstanceObject.value)
},
{ deep: true },
)
const messages = defineMessages({
hooks: {
id: 'instance.settings.tabs.hooks.title',
defaultMessage: 'Game launch hooks',
},
hooksDescription: {
id: 'instance.settings.tabs.hooks.description',
defaultMessage:
'Hooks allow advanced users to run certain system commands before and after launching the game.',
},
customHooks: {
id: 'instance.settings.tabs.hooks.custom-hooks',
defaultMessage: 'Custom launch hooks',
},
preLaunch: {
id: 'instance.settings.tabs.hooks.pre-launch',
defaultMessage: 'Pre-launch',
},
preLaunchDescription: {
id: 'instance.settings.tabs.hooks.pre-launch.description',
defaultMessage: 'Ran before the instance is launched.',
},
preLaunchEnter: {
id: 'instance.settings.tabs.hooks.pre-launch.enter',
defaultMessage: 'Enter pre-launch command...',
},
wrapper: {
id: 'instance.settings.tabs.hooks.wrapper',
defaultMessage: 'Wrapper',
},
wrapperDescription: {
id: 'instance.settings.tabs.hooks.wrapper.description',
defaultMessage: 'Wrapper command for launching Minecraft.',
},
wrapperEnter: {
id: 'instance.settings.tabs.hooks.wrapper.enter',
defaultMessage: 'Enter wrapper command...',
},
postExit: {
id: 'instance.settings.tabs.hooks.post-exit',
defaultMessage: 'Post-exit',
},
postExitDescription: {
id: 'instance.settings.tabs.hooks.post-exit.description',
defaultMessage: 'Ran after the game closes.',
},
postExitEnter: {
id: 'instance.settings.tabs.hooks.post-exit.enter',
defaultMessage: 'Enter post-exit command...',
},
})
</script>
<template>
<div>
<h2 class="m-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hooks) }}
</h2>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="my-2.5" />
<p class="m-0">
{{ formatMessage(messages.hooksDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunch) }}
</h2>
<StyledInput
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.preLaunchEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapper) }}
</h2>
<StyledInput
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.wrapperEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExit) }}
</h2>
<StyledInput
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
</div>
</template>
@@ -0,0 +1,212 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ChevronRightIcon,
CodeIcon,
CoffeeIcon,
InfoIcon,
MonitorIcon,
UsersIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
Avatar,
commonMessages,
defineMessage,
TabbedModal,
type TabbedModalTab,
useVIntl,
} from '@modrinth/ui'
import type { PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, nextTick, ref, watch } from 'vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import GeneralSettings from './general-settings.vue'
import HooksSettings from './hooks-settings.vue'
import InstallationSettings from './installation-settings.vue'
import { provideInstanceSettings } from './instance-settings-context.ts'
import JavaSettings from './java-settings.vue'
import SharingSettings from './sharing-settings.vue'
import WindowSettings from './window-settings.vue'
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const props = defineProps<{
instance: GameInstance
offline?: boolean
}>()
const emit = defineEmits<{
unlinked: []
}>()
const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked')
const instanceRef = computed(() => props.instance)
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function hide() {
tabbedModal.value?.hide()
}
provideInstanceSettings({
instance: instanceRef,
offline: props.offline,
isMinecraftServer,
onUnlinked: handleUnlinked,
closeModal: hide,
})
watch(
() => props.instance,
(instance) => {
isMinecraftServer.value = false
if (instance.link?.project_id) {
get_project_v3(instance.link.project_id, 'must_revalidate')
.then((project: Labrinth.Projects.v3.Project | undefined) => {
if (project?.minecraft_server != null) {
isMinecraftServer.value = true
}
})
.catch(() => {})
}
},
{ immediate: true },
)
const tabs = computed<TabbedModalTab[]>(() => [
{
name: defineMessage({
id: 'instance.settings.tabs.general',
defaultMessage: 'General',
}),
icon: InfoIcon,
content: GeneralSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.installation',
defaultMessage: 'Installation',
}),
icon: WrenchIcon,
content: InstallationSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.sharing',
defaultMessage: 'Sharing',
}),
icon: UsersIcon,
content: SharingSettings,
shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined,
},
{
name: defineMessage({
id: 'instance.settings.tabs.window',
defaultMessage: 'Window',
}),
icon: MonitorIcon,
content: WindowSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.java',
defaultMessage: 'Java and memory',
}),
icon: CoffeeIcon,
content: JavaSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.hooks',
defaultMessage: 'Launch hooks',
}),
icon: CodeIcon,
content: HooksSettings,
},
])
function getSupportedModpackLoaders() {
return get_loaders().then((value: PlatformTag[]) =>
value
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
)
}
// Preload
useQuery({
queryKey: ['instance-settings', 'loader-versions', 'fabric'],
queryFn: () => get_loader_versions('fabric'),
})
useQuery({
queryKey: ['instance-settings', 'loader-versions', 'forge'],
queryFn: () => get_loader_versions('forge'),
})
useQuery({
queryKey: ['instance-settings', 'loader-versions', 'quilt'],
queryFn: () => get_loader_versions('quilt'),
})
useQuery({
queryKey: ['instance-settings', 'loader-versions', 'neo'],
queryFn: () => get_loader_versions('neo'),
})
useQuery({
queryKey: ['instance-settings', 'game-versions'],
queryFn: get_game_versions,
})
useQuery({
queryKey: ['instance-settings', 'loaders', 'modpack'],
queryFn: getSupportedModpackLoaders,
})
useQuery({
queryKey: computed(() => ['linkedModpackInfo', props.instance.id]),
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
enabled: computed(() => !!props.instance.link?.project_id && !props.offline),
})
function show(tabIndex?: number) {
if (props.instance.link?.project_id) {
queryClient.prefetchQuery({
queryKey: ['linkedModpackInfo', props.instance.id],
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
})
}
tabbedModal.value?.show()
if (tabIndex !== undefined) {
nextTick(() => tabbedModal.value?.setTab(tabIndex))
}
}
defineExpose({ show, hide })
</script>
<template>
<TabbedModal
ref="tabbedModal"
:tabs="tabs"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
>
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
size="24px"
:tint-by="props.instance.id"
/>
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{
formatMessage(commonMessages.settingsLabel)
}}</span>
</span>
</template>
</TabbedModal>
</template>
@@ -0,0 +1,499 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
commonMessages,
defineMessages,
formatLoaderLabel,
injectFilePicker,
injectNotificationManager,
InstallationSettingsLayout,
provideInstallationSettings,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import {
install_existing_instance,
install_pack_to_existing_instance,
wait_for_install_job,
} from '@/helpers/install'
import {
edit,
get_linked_modpack_info,
unlink_shared_instance,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
import type { Manifest } from '../../../../helpers/types'
import { instanceKeys } from '../../query-options.ts'
import { injectInstanceSettings } from './instance-settings-context.ts'
import SharedInstanceInstallationSettingsControls from './shared-instance-installation-settings-controls.vue'
const { handleError } = injectNotificationManager()
const filePicker = injectFilePicker()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const debug = useDebugLogger('AppInstallationSettings')
const themeStore = useTheming()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const managedContentPolicy = useManagedContentPolicy(instance)
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
debug('metadata load: start', {
instanceId: instance.value.id,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
installStage: instance.value.install_stage,
})
function getSupportedModpackLoaders() {
return get_loaders().then((value: PlatformTag[]) =>
value
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
)
}
const fabricVersionsQuery = useQuery({
queryKey: ['instance-settings', 'loader-versions', 'fabric'],
queryFn: () => get_loader_versions('fabric') as Promise<Manifest>,
})
const forgeVersionsQuery = useQuery({
queryKey: ['instance-settings', 'loader-versions', 'forge'],
queryFn: () => get_loader_versions('forge') as Promise<Manifest>,
})
const quiltVersionsQuery = useQuery({
queryKey: ['instance-settings', 'loader-versions', 'quilt'],
queryFn: () => get_loader_versions('quilt') as Promise<Manifest>,
})
const neoforgeVersionsQuery = useQuery({
queryKey: ['instance-settings', 'loader-versions', 'neo'],
queryFn: () => get_loader_versions('neo') as Promise<Manifest>,
})
const gameVersionsQuery = useQuery({
queryKey: ['instance-settings', 'game-versions'],
queryFn: () => get_game_versions() as Promise<GameVersionTag[]>,
})
const loadersQuery = useQuery({
queryKey: ['instance-settings', 'loaders', 'modpack'],
queryFn: getSupportedModpackLoaders,
})
const metadataLoading = computed(() =>
[
fabricVersionsQuery,
forgeVersionsQuery,
quiltVersionsQuery,
neoforgeVersionsQuery,
gameVersionsQuery,
loadersQuery,
].some((query) => query.isLoading.value),
)
debug('metadata queries configured', {
instanceId: instance.value.id,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
})
const isModrinthLinkedModpack = computed(
() =>
instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_modpack' ||
(instance.value.link?.type === 'shared_instance' &&
!!instance.value.link.modpack_project_id &&
!!instance.value.link.modpack_version_id),
)
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
const canUnlinkSharedInstance = managedContentPolicy.canUnlink
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
queryFn: () => get_linked_modpack_info(instance.value.id, 'must_revalidate'),
enabled: computed(() => isModrinthLinkedModpack.value && !offline),
})
const modpackInfo = modpackInfoQuery.data
const repairing = ref(false)
const reinstalling = ref(false)
const unlinkingSharedInstance = ref(false)
const installationSettingsBusy = computed(
() =>
instance.value.quarantined ||
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
unlinkingSharedInstance.value ||
!!offline,
)
const installationSettingsBusyMessage = computed(() =>
instance.value.quarantined ? formatMessage(messages.locked) : null,
)
async function unlinkSharedInstance() {
unlinkingSharedInstance.value = true
try {
await unlink_shared_instance(instance.value.id)
await queryClient.invalidateQueries({
queryKey: instanceKeys.sharedMembers(instance.value.id),
})
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unlinkingSharedInstance.value = false
}
}
const messages = defineMessages({
loaderVersion: {
id: 'instance.settings.tabs.installation.loader-version',
defaultMessage: '{loader} version',
},
locked: {
id: 'instance.settings.tabs.installation.locked',
defaultMessage: 'Installation settings are unavailable while this instance is locked.',
},
})
function getManifest(loader: string) {
const map: Record<string, Manifest | undefined> = {
fabric: fabricVersionsQuery.data.value,
forge: forgeVersionsQuery.data.value,
quilt: quiltVersionsQuery.data.value,
neoforge: neoforgeVersionsQuery.data.value,
}
const manifest = map[loader]
debug('getManifest:', {
loader,
hasManifest: !!manifest,
gameVersions: manifest?.gameVersions?.length ?? 0,
})
return manifest
}
async function installLocalModpackFromPicker() {
const picked = await filePicker.pickModpackFile({ readFile: false })
if (!picked?.path) return false
const job = await install_pack_to_existing_instance(instance.value.id, {
type: 'fromFile',
path: picked.path,
}).catch(handleError)
if (!job) return false
const completed = await wait_for_install_job(job.job_id).catch(handleError)
return !!completed
}
provideInstanceBackup(instance)
provideInstallationSettings({
closeSettings: closeModal,
loading: computed(() => metadataLoading.value || modpackInfoQuery.isLoading.value),
installationInfo: computed(() => {
const rows = [
{
label: formatMessage(commonMessages.platformLabel),
value: formatLoaderLabel(instance.value.loader),
},
{
label: formatMessage(commonMessages.gameVersionLabel),
value: instance.value.game_version,
},
]
if (instance.value.loader !== 'vanilla' && instance.value.loader_version) {
rows.push({
label: formatMessage(messages.loaderVersion, {
loader: formatLoaderLabel(instance.value.loader),
}),
value: instance.value.loader_version,
})
}
return rows
}),
isLinked: computed(
() =>
isModrinthLinkedModpack.value ||
isImportedModpack.value ||
isSharedInstanceManagedModpack.value,
),
isBusy: installationSettingsBusy,
busyMessage: installationSettingsBusyMessage,
skipNonEssentialWarnings,
modpack: computed(() => {
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
return {
iconUrl: instance.value.icon_path,
title: instance.value.link.name ?? instance.value.name,
versionNumber: instance.value.link.version_number ?? undefined,
filename: instance.value.link.filename ?? undefined,
}
}
if (!modpackInfo.value) return null
return {
iconUrl: modpackInfo.value.project.icon_url,
title: modpackInfo.value.project.title,
link: `/project/${modpackInfo.value.project.slug ?? modpackInfo.value.project.id}`,
versionNumber: modpackInfo.value.version?.version_number,
}
}),
currentPlatform: computed(() => instance.value.loader),
currentGameVersion: computed(() => instance.value.game_version),
currentLoaderVersion: computed(() => instance.value.loader_version ?? ''),
availablePlatforms: computed(() => loadersQuery.data.value?.map((x) => x.name) ?? []),
resolveGameVersions(loader, showSnapshots) {
const versions = gameVersionsQuery.data.value ?? []
const filtered = versions.filter((item) => {
if (loader === 'vanilla') return true
const manifest = getManifest(loader)
return !!manifest?.gameVersions?.some((x) => item.version === x.id)
})
const result = (
showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')
).map((x) => ({ value: x.version, label: x.version }))
debug('resolveGameVersions:', {
loader,
showSnapshots,
totalVersions: versions.length,
filteredVersions: filtered.length,
resultVersions: result.length,
})
return result
},
resolveLoaderVersions(loader, gameVersion) {
if (loader === 'vanilla' || !gameVersion) {
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
return []
}
const manifest = getManifest(loader)
if (!manifest) {
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
return []
}
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
if (entry?.versionGroup) {
const result =
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
debug('resolveLoaderVersions: version group result', {
loader,
gameVersion,
versionGroup: entry.versionGroup,
count: result.length,
})
return result
}
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
if (placeholder) {
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
? placeholder.loaders
: []
debug('resolveLoaderVersions: placeholder result', {
loader,
gameVersion,
count: result.length,
})
return result
}
const result = entry?.loaders ?? []
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
return result
},
resolveHasSnapshots(loader) {
const versions = gameVersionsQuery.data.value ?? []
if (loader === 'vanilla') {
const result = versions.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots: vanilla', { loader, result })
return result
}
const manifest = getManifest(loader)
const supported = versions.filter(
(item) => !!manifest?.gameVersions?.some((x) => item.version === x.id),
)
const result = supported.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots:', {
loader,
totalVersions: versions.length,
supportedVersions: supported.length,
result,
})
return result
},
async save(platform, gameVersion, loaderVersionId) {
debug('save: called', {
instanceId: instance.value.id,
platform,
gameVersion,
loaderVersionId,
})
const editInstancePatch: Record<string, string | undefined> = {
loader: platform,
game_version: gameVersion,
}
if (platform !== 'vanilla' && loaderVersionId) {
editInstancePatch.loader_version = loaderVersionId
}
await edit(instance.value.id, editInstancePatch).catch(handleError)
debug('save: edit complete', { editInstancePatch })
},
afterSave: async () => {
debug('afterSave: installing', { instanceId: instance.value.id })
await install_existing_instance(instance.value.id, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('afterSave: done')
},
async repair() {
debug('repair: called', { instanceId: instance.value.id })
repairing.value = true
await install_existing_instance(instance.value.id, true).catch(handleError)
repairing.value = false
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('repair: done')
},
async reinstallModpack() {
debug('reinstallModpack: called', { instanceId: instance.value.id })
reinstalling.value = true
let shouldTrack = false
try {
if (isImportedModpack.value) {
shouldTrack = await installLocalModpackFromPicker()
} else {
await update_repair_modrinth(instance.value.id).catch(handleError)
shouldTrack = true
}
} finally {
reinstalling.value = false
}
if (shouldTrack) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
debug('reinstallModpack: done')
},
async swapModpack() {
debug('swapModpack: called', { instanceId: instance.value.id })
reinstalling.value = true
try {
const installed = await installLocalModpackFromPicker()
if (installed) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
} finally {
reinstalling.value = false
}
debug('swapModpack: done')
},
async unlinkModpack() {
debug('unlinkModpack: called', { instanceId: instance.value.id })
await edit(instance.value.id, {
link: null as unknown as undefined,
})
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.value.id],
})
onUnlinked()
debug('unlinkModpack: done')
},
getCachedModpackVersions: () => null,
async fetchModpackVersions() {
debug('fetchModpackVersions: called', {
projectId: instance.value.link?.project_id,
})
const versions = await get_project_versions(instance.value.link!.project_id!).catch(handleError)
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
return (versions ?? []) as Labrinth.Versions.v2.Version[]
},
async getVersionChangelog(versionId: string) {
debug('getVersionChangelog: called', { versionId })
return (await get_version(versionId, 'must_revalidate').catch(
() => null,
)) as Labrinth.Versions.v2.Version | null
},
async onModpackVersionConfirm(version) {
debug('onModpackVersionConfirm: called', {
versionId: version.id,
instanceId: instance.value.id,
})
await update_managed_modrinth_version(instance.value.id, version.id)
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.value.id],
})
debug('onModpackVersionConfirm: done')
},
updaterModalProps: computed(() => ({
isApp: true,
currentVersionId: modpackInfo.value?.update_version_id ?? instance.value.link?.version_id ?? '',
projectIconUrl: modpackInfo.value?.project?.icon_url,
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
currentGameVersion: instance.value.game_version,
currentLoader: instance.value.loader,
})),
isServer: false,
isApp: true,
showModpackVersionActions: computed(
() =>
isModrinthLinkedModpack.value &&
!isMinecraftServer.value &&
!isSharedInstanceManagedModpack.value,
),
isLocalFile: isImportedModpack,
isManagedModpack: isSharedInstanceManagedModpack,
managedModpackWarning: managedContentPolicy.managedModpackWarning,
repairing,
reinstalling,
})
</script>
<template>
<InstallationSettingsLayout>
<template #extra>
<SharedInstanceInstallationSettingsControls
:can-unlink="canUnlinkSharedInstance"
:busy="installationSettingsBusy"
:unlinking="unlinkingSharedInstance"
:unlink="unlinkSharedInstance"
/>
</template>
</InstallationSettingsLayout>
</template>
@@ -0,0 +1,15 @@
import { createContext } from '@modrinth/ui'
import type { ComputedRef, Ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
export interface InstanceSettingsContext {
instance: ComputedRef<GameInstance>
offline?: boolean
isMinecraftServer: Ref<boolean>
onUnlinked: () => void
closeModal?: () => void
}
export const [injectInstanceSettings, provideInstanceSettings] =
createContext<InstanceSettingsContext>('InstanceSettingsModal', 'instanceSettings')
@@ -0,0 +1,322 @@
<script setup lang="ts">
import {
CheckCircleIcon,
CoffeeIcon,
FolderSearchIcon,
RefreshCwIcon,
SearchIcon,
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, readonly, ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/instance'
import { get } from '@/helpers/settings.ts'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
const optimalJava = readonly(await get_optimal_jre_key(instance.value.id).catch(handleError))
const overrideJavaInstall = ref(!!instance.value.java_path)
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
const activePath = computed(() =>
overrideJavaInstall.value ? javaPath.value : (optimalJava?.path ?? ''),
)
watch(overrideJavaInstall, (enabled) => {
if (enabled && !javaPath.value) {
javaPath.value = optimalJava?.path ?? ''
}
})
const { testingJava, javaTestResult, testJavaInstallationDebounced, testJavaInstallation } =
useJavaTest()
const hoveringTest = ref(false)
let hasInitialized = false
watch(
activePath,
(newPath) => {
if (newPath && optimalJava?.parsed_version) {
if (!hasInitialized) {
testJavaInstallation(newPath, optimalJava?.parsed_version, false)
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, optimalJava?.parsed_version)
}
}
},
{ immediate: true },
)
const javaDetectionModal = ref<{ show: (version: number, current: object) => void } | null>(null)
async function handleBrowseJava() {
const result = await open({ multiple: false })
if (result) {
javaPath.value = result
}
}
function handleDetectJava() {
javaDetectionModal.value?.show(optimalJava?.parsed_version, { path: javaPath.value })
}
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref(
(instance.value.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
)
const overrideEnvVars = ref((instance.value.custom_env_vars?.length ?? 0) > 0)
const envVars = ref(
(instance.value.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('='))
.join(' '),
)
const overrideMemorySettings = ref(!!instance.value.memory)
const memory = ref(instance.value.memory ?? globalSettings.memory)
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
maxMemory: number
snapPoints: number[]
}
const editInstanceObject = computed(() => {
return {
java_path:
overrideJavaInstall.value && javaPath.value
? javaPath.value.replace('java.exe', 'javaw.exe')
: null,
extra_launch_args: overrideJavaArgs.value
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
: null,
custom_env_vars: overrideEnvVars.value
? envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
: null,
memory: overrideMemorySettings.value ? memory.value : null,
}
})
watch(
[
overrideJavaInstall,
javaPath,
overrideJavaArgs,
javaArgs,
overrideEnvVars,
envVars,
overrideMemorySettings,
memory,
],
async () => {
await edit(instance.value.id, editInstanceObject.value)
},
{ deep: true },
)
const messages = defineMessages({
javaInstallation: {
id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation',
},
customJavaInstallation: {
id: 'instance.settings.tabs.java.custom-java-installation',
defaultMessage: 'Custom Java installation',
},
javaPathPlaceholder: {
id: 'instance.settings.tabs.java.java-path-placeholder',
defaultMessage: '/path/to/java',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
},
customMemoryAllocation: {
id: 'instance.settings.tabs.java.custom-memory-allocation',
defaultMessage: 'Custom memory allocation',
},
javaArguments: {
id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments',
},
customJavaArguments: {
id: 'instance.settings.tabs.java.custom-java-arguments',
defaultMessage: 'Custom Java arguments',
},
enterJavaArguments: {
id: 'instance.settings.tabs.java.enter-java-arguments',
defaultMessage: 'Enter Java arguments...',
},
javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables',
},
customEnvironmentVariables: {
id: 'instance.settings.tabs.java.custom-environment-variables',
defaultMessage: 'Custom environment variables',
},
enterEnvironmentVariables: {
id: 'instance.settings.tabs.java.enter-environment-variables',
defaultMessage: 'Enter environmental variables...',
},
hooks: {
id: 'instance.settings.tabs.java.hooks',
defaultMessage: 'Hooks',
},
})
</script>
<template>
<div>
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox
v-model="overrideJavaInstall"
:label="formatMessage(messages.customJavaInstallation)"
class="mb-2"
/>
<div class="flex gap-4 p-4 bg-bg rounded-2xl">
<div class="flex gap-3 items-start flex-1 min-w-0">
<div
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
>
<CoffeeIcon />
</div>
<div class="flex flex-col gap-2 flex-1 min-w-0">
<span class="font-semibold leading-none mt-2"
>Java {{ optimalJava?.parsed_version }}</span
>
<div class="flex gap-2 items-center">
<StyledInput
:model-value="activePath"
:disabled="!overrideJavaInstall"
autocomplete="off"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
wrapper-class="flex-1 min-w-0"
@update:model-value="(val) => (javaPath = String(val))"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? javaTestResult === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
:disabled="!overrideJavaInstall || testingJava"
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="javaTestResult === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<div v-if="overrideJavaInstall" class="flex gap-2">
<ButtonStyled>
<button @click="handleDetectJava">
<SearchIcon />
Detect
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleBrowseJava">
<FolderSearchIcon />
Browse
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox
v-model="overrideMemorySettings"
:label="formatMessage(messages.customMemoryAllocation)"
class="mb-2"
/>
<Slider
id="max-memory"
v-model="memory.maximum"
:disabled="!overrideMemorySettings"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox
v-model="overrideJavaArgs"
:label="formatMessage(messages.customJavaArguments)"
class="my-2"
/>
<StyledInput
id="java-args"
v-model="javaArgs"
autocomplete="off"
:disabled="!overrideJavaArgs"
:placeholder="formatMessage(messages.enterJavaArguments)"
wrapper-class="w-full"
/>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox
v-model="overrideEnvVars"
:label="formatMessage(messages.customEnvironmentVariables)"
class="mb-2"
/>
<StyledInput
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
</div>
</template>
@@ -0,0 +1,195 @@
<template>
<div v-if="canUnpublish" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unpublishModal?.show()">
<SpinnerIcon v-if="unpublishing" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unpublishDescription) }}</span>
</div>
<div v-if="canUnlink" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.linkedTitle)
}}</span>
<div>
<ButtonStyled color="orange">
<button :disabled="busy" @click="unlinkModal?.show()">
<SpinnerIcon v-if="unlinking" class="animate-spin" />
<UnlinkIcon v-else class="size-5" />
{{ formatMessage(unlinking ? messages.unlinkingButton : messages.unlinkButton) }}
</button>
</ButtonStyled>
</div>
<span class="text-primary">{{ formatMessage(messages.unlinkDescription) }}</span>
</div>
<NewModal
ref="unpublishModal"
:header="formatMessage(messages.unpublishModalHeader)"
fade="warning"
max-width="500px"
>
<Admonition type="warning" :header="formatMessage(messages.unpublishModalAdmonitionHeader)">{{
formatMessage(messages.unpublishModalBody)
}}</Admonition>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unpublishModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy" @click="confirmUnpublish">
<UnlinkIcon />{{ formatMessage(messages.unpublishButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
<NewModal
ref="unlinkModal"
:header="formatMessage(messages.unlinkModalHeader)"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.unlinkModalAdmonitionHeader)">{{
formatMessage(messages.unlinkModalBody)
}}</Admonition>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before unlinking shared instance"
@update:buttons-disabled="backupBusy = $event"
/>
</div>
<template #actions
><div class="flex justify-end gap-2">
<ButtonStyled type="outlined"
><button class="!border" @click="unlinkModal?.hide()">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
><ButtonStyled color="orange"
><button :disabled="busy || backupBusy" @click="confirmUnlink">
<UnlinkIcon />{{ formatMessage(messages.unlinkButton) }}
</button></ButtonStyled
>
</div></template
>
</NewModal>
</template>
<script setup lang="ts">
import { SpinnerIcon, UnlinkIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
InlineBackupCreator,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
canUnpublish?: boolean
canUnlink?: boolean
busy: boolean
unpublishing?: boolean
unlinking?: boolean
unpublish?: () => Promise<void>
unlink?: () => Promise<void>
}>(),
{
canUnpublish: false,
canUnlink: false,
unpublishing: false,
unlinking: false,
unpublish: undefined,
unlink: undefined,
},
)
const { formatMessage } = useVIntl()
const unpublishModal = ref<InstanceType<typeof NewModal>>()
const unlinkModal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const backupBusy = ref(false)
async function confirmUnpublish() {
unpublishModal.value?.hide()
await props.unpublish?.()
}
async function confirmUnlink() {
unlinkModal.value?.hide()
await props.unlink?.()
}
const messages = defineMessages({
title: {
id: 'installation-settings.shared-instance.title',
defaultMessage: 'Unpublish instance',
},
linkedTitle: {
id: 'installation-settings.shared-instance.linked-title',
defaultMessage: 'Linked shared instance',
},
unpublishButton: {
id: 'installation-settings.shared-instance.unpublish-button',
defaultMessage: 'Unpublish shared instance',
},
unpublishingButton: {
id: 'installation-settings.shared-instance.unpublishing-button',
defaultMessage: 'Unpublishing...',
},
unpublishDescription: {
id: 'installation-settings.shared-instance.unpublish-description',
defaultMessage:
'Remove this shared instance from Modrinth and stop sending updates to anyone using it. Your local instance will not be affected.',
},
unlinkButton: {
id: 'installation-settings.shared-instance.unlink-button',
defaultMessage: 'Unlink shared instance',
},
unlinkingButton: {
id: 'installation-settings.shared-instance.unlinking-button',
defaultMessage: 'Unlinking...',
},
unlinkDescription: {
id: 'installation-settings.shared-instance.unlink-description',
defaultMessage: 'Disconnect this local instance from future shared updates.',
},
unpublishModalHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.header',
defaultMessage: 'Unpublish shared instance',
},
unpublishModalAdmonitionHeader: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-header',
defaultMessage: 'Unpublishing shared instance',
},
unpublishModalBody: {
id: 'installation-settings.unpublish-shared-instance.modal.admonition-body',
defaultMessage:
"This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device.",
},
unlinkModalHeader: {
id: 'installation-settings.unlink-shared-instance.modal.header',
defaultMessage: 'Unlink shared instance',
},
unlinkModalAdmonitionHeader: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-header',
defaultMessage: 'Unlinking shared instance',
},
unlinkModalBody: {
id: 'installation-settings.unlink-shared-instance.modal.admonition-body',
defaultMessage:
'This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected.',
},
})
</script>
@@ -0,0 +1,246 @@
<template>
<div class="flex flex-col gap-8">
<section class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.activeInvitesTitle) }}
</h3>
<p class="m-0 text-secondary">
{{ formatMessage(messages.activeInvitesDescription) }}
</p>
</div>
<Table :columns="inviteColumns" :data="activeInvites" row-key="id" table-min-width="36rem">
<template #empty-state>
<div class="flex h-40 items-center justify-center text-secondary">
<SpinnerIcon
v-if="activeInvitesQuery.isLoading.value"
class="animate-spin"
aria-hidden="true"
/>
<template v-else>
{{ formatMessage(messages.noActiveInvites) }}
</template>
</div>
</template>
<template #cell-id="{ row }">
<CopyCode
:text="`${config.siteUrl}/share/${encodeURIComponent(row.id)}`"
:display-text="`/${row.id}`"
/>
</template>
<template #cell-uses="{ row }">
<span class="font-medium text-primary">{{ row.uses }}</span>
<span> / {{ row.maxUses }}</span>
</template>
<template #cell-expiration="{ row }">
<span v-tooltip="formatDateTime(row.expiration)" class="whitespace-nowrap">
{{ formatRelativeTime(row.expiration) }}
</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.revokeInvite)"
:aria-label="
formatMessage(messages.revokeInviteWithCode, {
code: row.id,
})
"
:disabled="revokeInviteMutation.isPending.value || isBusy"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="revokeInviteModal?.show(row.id)"
>
<SpinnerIcon
v-if="
revokeInviteMutation.isPending.value &&
revokeInviteMutation.variables.value?.inviteId === row.id
"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
</Table>
</section>
<SharedInstanceInstallationSettingsControls
can-unpublish
:busy="isBusy"
:unpublishing="unpublishing"
:unpublish="unpublishSharedInstance"
/>
<ConfirmRevokeSharedInstanceInviteModal ref="revokeInviteModal" @revoke="revokeInvite" />
</div>
</template>
<script setup lang="ts">
import { SpinnerIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
CopyCode,
defineMessages,
Table,
type TableColumn,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { config } from '@/config'
import {
get_shared_instance_invites,
revoke_shared_instance_invite,
type SharedInstanceInvite,
unpublish_shared_instance,
} from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { instanceKeys } from '../../query-options.ts'
import ConfirmRevokeSharedInstanceInviteModal from './confirm-revoke-shared-instance-invite-modal.vue'
import { injectInstanceSettings } from './instance-settings-context.ts'
import SharedInstanceInstallationSettingsControls from './shared-instance-installation-settings-controls.vue'
const { instance, offline, onUnlinked } = injectInstanceSettings()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const unpublishing = ref(false)
const revokeInviteModal = ref<InstanceType<typeof ConfirmRevokeSharedInstanceInviteModal>>()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
type InviteTableColumn = 'id' | 'uses' | 'expiration' | 'actions'
type ActiveInvitesQueryKey = readonly ['sharedInstanceInvites', string]
const activeInvitesQueryKey = computed(
() => ['sharedInstanceInvites', instance.value.id] as const satisfies ActiveInvitesQueryKey,
)
const activeInvitesQuery = useQuery({
queryKey: activeInvitesQueryKey,
queryFn: async ({ queryKey }) => {
try {
return await get_shared_instance_invites(queryKey[1])
} catch (error) {
notifySharedInstanceError(error)
throw error
}
},
enabled: () => !!instance.value.id && !offline,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnReconnect: false,
refetchOnWindowFocus: false,
})
const activeInvites = computed(() => activeInvitesQuery.data.value ?? [])
const revokeInviteMutation = useMutation({
mutationFn: ({ instanceId, inviteId }: { instanceId: string; inviteId: string }) =>
revoke_shared_instance_invite(instanceId, inviteId),
onSuccess: (_data, { instanceId, inviteId }) => {
queryClient.setQueryData<SharedInstanceInvite[]>(
['sharedInstanceInvites', instanceId],
(invites = []) => invites.filter((invite) => invite.id !== inviteId),
)
},
onError: notifySharedInstanceError,
})
const isBusy = computed(
() =>
instance.value.install_stage !== 'installed' ||
unpublishing.value ||
revokeInviteMutation.isPending.value ||
!!offline,
)
const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
{
key: 'id',
label: formatMessage(messages.inviteCodeLabel),
width: 'clamp(11rem, 34%, 19rem)',
},
{
key: 'uses',
label: formatMessage(messages.usesLabel),
width: 'clamp(7rem, 18%, 10rem)',
},
{
key: 'expiration',
label: formatMessage(messages.expiresLabel),
width: 'clamp(9rem, 28%, 14rem)',
},
{
key: 'actions',
label: formatMessage(messages.actionsLabel),
align: 'right',
width: '5.5rem',
},
])
function revokeInvite(inviteId: string) {
if (revokeInviteMutation.isPending.value) return
revokeInviteMutation.mutate({ instanceId: instance.value.id, inviteId })
}
async function unpublishSharedInstance() {
unpublishing.value = true
try {
await unpublish_shared_instance(instance.value.id)
queryClient.setQueryData(instanceKeys.sharedMembers(instance.value.id), [])
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
notifySharedInstanceError(error)
} finally {
unpublishing.value = false
}
}
const messages = defineMessages({
activeInvitesTitle: {
id: 'instance.settings.sharing.active-invites.title',
defaultMessage: 'Active invites',
},
activeInvitesDescription: {
id: 'instance.settings.sharing.active-invites.description',
defaultMessage: 'Anyone with one of these invite links can join while it remains active.',
},
inviteCodeLabel: {
id: 'instance.settings.sharing.active-invites.code',
defaultMessage: 'Invite link',
},
usesLabel: {
id: 'instance.settings.sharing.active-invites.uses',
defaultMessage: 'Uses',
},
expiresLabel: {
id: 'instance.settings.sharing.active-invites.expires',
defaultMessage: 'Expires',
},
actionsLabel: {
id: 'instance.settings.sharing.active-invites.actions',
defaultMessage: 'Actions',
},
noActiveInvites: {
id: 'instance.settings.sharing.active-invites.empty',
defaultMessage: 'There are no active invites.',
},
revokeInvite: {
id: 'instance.settings.sharing.active-invites.revoke',
defaultMessage: 'Revoke invite',
},
revokeInviteWithCode: {
id: 'instance.settings.sharing.active-invites.revoke-with-code',
defaultMessage: 'Revoke invite {code}',
},
})
</script>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import {
Checkbox,
defineMessages,
injectNotificationManager,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { computed, type Ref, ref, watch } from 'vue'
import { edit } from '@/helpers/instance'
import { get } from '@/helpers/settings.ts'
import type { AppSettings } from '../../../../helpers/types'
import { injectInstanceSettings } from './instance-settings-context'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideWindowSettings = ref(
!!instance.value.game_resolution || !!instance.value.force_fullscreen,
)
const resolution: Ref<[number, number]> = ref(
instance.value.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
)
const fullscreenSetting: Ref<boolean> = ref(
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
)
const editInstanceObject = computed(() => {
if (!overrideWindowSettings.value) {
return {
force_fullscreen: null,
game_resolution: null,
}
}
return {
force_fullscreen: fullscreenSetting.value,
game_resolution: fullscreenSetting.value ? null : resolution.value,
}
})
watch(
[overrideWindowSettings, resolution, fullscreenSetting],
async () => {
await edit(instance.value.id, editInstanceObject.value)
},
{ deep: true },
)
const messages = defineMessages({
customWindowSettings: {
id: 'instance.settings.tabs.window.custom-window-settings',
defaultMessage: 'Custom window settings',
},
fullscreen: {
id: 'instance.settings.tabs.window.fullscreen',
defaultMessage: 'Fullscreen',
},
fullscreenDescription: {
id: 'instance.settings.tabs.window.fullscreen.description',
defaultMessage: 'Make the game start in full screen when launched (using options.txt).',
},
width: {
id: 'instance.settings.tabs.window.width',
defaultMessage: 'Width',
},
widthDescription: {
id: 'instance.settings.tabs.window.width.description',
defaultMessage: 'The width of the game window when launched.',
},
enterWidth: {
id: 'instance.settings.tabs.window.width.enter',
defaultMessage: 'Enter width...',
},
height: {
id: 'instance.settings.tabs.window.height',
defaultMessage: 'Height',
},
heightDescription: {
id: 'instance.settings.tabs.window.height.description',
defaultMessage: 'The height of the game window when launched.',
},
enterHeight: {
id: 'instance.settings.tabs.window.height.enter',
defaultMessage: 'Enter height...',
},
})
</script>
<template>
<div class="flex flex-col gap-6">
<Checkbox
v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)"
/>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreen) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle
id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:disabled="!overrideWindowSettings"
@update:model-value="
(e) => {
fullscreenSetting = e
}
"
/>
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.width) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<StyledInput
id="width"
v-model="resolution[0]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterWidth)"
/>
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.height) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<StyledInput
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
</div>
</template>
@@ -19,13 +19,11 @@
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance && !isSharedMember && !isQuarantined"
:enable-toggle="!isServerInstance && !isSharedMember && !isQuarantined"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="
props.isServerInstance || isSharedMember || isQuarantined
? undefined
: handleSwitchVersion
isServerInstance || isSharedMember || isQuarantined ? undefined : handleSwitchVersion
"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@@ -146,13 +144,15 @@ import {
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import type { CacheBehaviour } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme'
import { injectInstancePage } from '../instance-context'
import { instanceContentQueryOptions, instanceKeys } from '../query-options'
const messages = defineMessages({
shareTitle: {
id: 'app.instance.mods.share-title',
@@ -218,13 +218,11 @@ const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
const props = defineProps<{
instance: GameInstance
isServerInstance?: boolean
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
const instancePage = injectInstancePage()
const instance = instancePage.instance
const isServerInstance = instancePage.isServerInstance
const openSettings = () => instancePage.openSettings(1)
const managedContentPolicy = useManagedContentPolicy(computed(() => instance.value))
const {
isManagedModpack: isSharedMember,
isQuarantined,
@@ -232,18 +230,20 @@ const {
canUpdateContent: canUpdateProject,
} = managedContentPolicy
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
}
const loading = ref(!hasPreloadedContent(props.preloadedContent))
const contentQuery = useQuery(
computed(() => ({
...instanceContentQueryOptions(instancePage.instanceId.value),
enabled: !!instancePage.instanceId.value,
})),
)
const loading = ref(contentQuery.data.value === undefined)
const projects = ref<ContentItem[]>([])
const installingBuffer = ref<ContentItem[]>([])
const handledInstallRevision = ref(0)
watch(
() => installingItems.value.get(props.instance.id),
() => installingItems.value.get(instance.value.id),
(items) => {
if (items && items.length > 0) {
installingBuffer.value = [...items]
@@ -261,7 +261,7 @@ watch(projects, (newProjects) => {
})
const mergedProjects = computed<ContentItem[]>(() => {
const active = installingItems.value.get(props.instance.id)
const active = installingItems.value.get(instance.value.id)
const pending = active ?? installingBuffer.value
if (pending.length === 0) return projects.value
const pendingProjectIds = new Set(pending.map((p) => p.project?.id).filter(Boolean))
@@ -276,7 +276,7 @@ const mergedProjects = computed<ContentItem[]>(() => {
})
watch(
() => installFailureRevisionByInstance.value.get(props.instance.id) ?? 0,
() => installFailureRevisionByInstance.value.get(instance.value.id) ?? 0,
(revision, previousRevision) => {
if (revision === previousRevision) return
installingBuffer.value = []
@@ -292,14 +292,14 @@ const linkedModpackUpdateVersionId = ref<string | null>(null)
const localImportedModpackUnlinked = ref(false)
const localImportedModpackProject = computed<ContentModpackCardProject | null>(() => {
const link = props.instance.link
const link = instance.value.link
if (localImportedModpackUnlinked.value || link?.type !== 'imported_modpack') return null
return {
id: link.filename ?? props.instance.id,
slug: link.filename ?? props.instance.id,
title: link.name ?? props.instance.name,
icon_url: props.instance.icon_path ? convertFileSrc(props.instance.icon_path) : undefined,
id: link.filename ?? instance.value.id,
slug: link.filename ?? instance.value.id,
title: link.name ?? instance.value.name,
icon_url: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
description: '',
filename: link.filename ?? undefined,
}
@@ -310,7 +310,7 @@ const displayedModpackProject = computed(
)
watch(
() => props.instance.link,
() => instance.value.link,
() => {
localImportedModpackUnlinked.value = false
},
@@ -318,12 +318,12 @@ watch(
const isModpackUpdating = ref(false)
const isBulkOperating = ref(false)
const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed')
const isInstanceBusy = computed(() => instance.value?.install_stage !== 'installed')
const isPackLocked = computed(
() =>
props.instance.quarantined ||
props.instance?.link?.type === 'modrinth_modpack' ||
props.instance?.link?.type === 'server_project_modpack',
instance.value.quarantined ||
instance.value?.link?.type === 'modrinth_modpack' ||
instance.value?.link?.type === 'server_project_modpack',
)
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
@@ -337,15 +337,15 @@ const unknownFileWarningModal = ref<InstanceType<typeof UnknownFileWarningModal>
const unknownFileName = ref('')
let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
const modpackContentQueryKey = computed(() => instanceKeys.linkedContent(instance.value.id))
const modpackContentQuery = useQuery({
queryKey: modpackContentQueryKey,
queryFn: () => get_linked_modpack_content(props.instance.id),
queryFn: () => get_linked_modpack_content(instance.value.id),
enabled: computed(
() =>
!!props.instance?.id &&
!!props.instance?.link &&
props.instance.install_stage === 'installed',
!!instance.value?.id &&
!!instance.value?.link &&
instance.value.install_stage === 'installed',
),
})
@@ -523,15 +523,12 @@ async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: st
}
async function handleBrowseContent() {
if (!props.instance || props.instance.quarantined) return
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.id },
})
if (!instance.value || instance.value.quarantined) return
await instancePage.browseContent(instance.value.loader === 'vanilla' ? 'resourcepack' : 'mod')
}
async function handleUploadFiles() {
if (!props.instance || props.instance.quarantined) return
if (!instance.value || instance.value.quarantined) return
const files = await open({ multiple: true })
if (!files) return
const selectedFiles: Array<{ path: string; filename: string }> = []
@@ -566,7 +563,7 @@ async function handleUploadFiles() {
await Promise.all(
confirmedFiles.map(async ({ path, filename }) => {
try {
const installedPath = await add_project_from_path(props.instance.id, path)
const installedPath = await add_project_from_path(instance.value.id, path)
return { filename, installedPath }
} catch (error) {
handleError(error as Error)
@@ -637,7 +634,7 @@ async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) {
const originalFilePath = mod.file_path
try {
const newPath = await toggle_disable_project(props.instance.id, mod.file_path, desiredEnabled)
const newPath = await toggle_disable_project(instance.value.id, mod.file_path, desiredEnabled)
const newFileName = fileNameFromPath(newPath)
const enabled = !newPath.endsWith('.disabled')
mod.file_path = newPath
@@ -655,8 +652,8 @@ async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) {
})
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
@@ -678,12 +675,12 @@ async function removeMod(mod: ContentItem) {
try {
const removedPath = mod.file_path
await remove_project(props.instance.id, removedPath)
await remove_project(instance.value.id, removedPath)
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
@@ -709,7 +706,7 @@ function dependencyTargetsItem(dependency: Labrinth.Versions.v2.Dependency, item
}
async function getDeleteDependencyWarning(items: ContentItem[]) {
if (props.isServerInstance) return null
if (isServerInstance.value) return null
const deletingIds = new Set(items.map(getContentItemId))
const remainingItems = projects.value.filter((item) => !deletingIds.has(getContentItemId(item)))
@@ -787,12 +784,12 @@ async function bulkUpdateAllProjects(onProgress?: (status: BulkOperationStatus)
waiting: true,
})
unlisten = await instance_bulk_update_progress_listener((progress) => {
if (progress.instanceId !== props.instance.id) return
if (progress.instanceId !== instance.value.id) return
onProgress(formatBulkUpdateProgress(progress))
})
}
await update_all(props.instance.id)
await update_all(instance.value.id)
await refreshContentState('must_revalidate')
} catch (err) {
handleError(err as Error)
@@ -810,14 +807,14 @@ async function updateProject(mod: ContentItem) {
try {
const updateVersionId = mod.update_version_id!
await switch_project_version_with_dependencies(
props.instance.id,
instance.value.id,
mod.file_path,
updateVersionId,
)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
@@ -840,11 +837,11 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
const oldPath = mod.file_path
try {
await switch_project_version_with_dependencies(props.instance.id, oldPath, version.id)
await switch_project_version_with_dependencies(instance.value.id, oldPath, version.id)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
@@ -872,8 +869,8 @@ async function handleUpdate(id: string) {
currentVersionId: item.version.id,
currentVersionNumber: item.version.version_number,
updateVersionId: item.update_version_id,
instanceGameVersion: props.instance.game_version,
instanceLoader: props.instance.loader,
instanceGameVersion: instance.value.game_version,
instanceLoader: instance.value.loader,
})
updatingModpack.value = false
@@ -899,11 +896,11 @@ async function handleUpdate(id: string) {
updateVersionId: item.update_version_id,
},
instance: {
path: props.instance.id,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
link: props.instance.link,
path: instance.value.id,
name: instance.value.name,
gameVersion: instance.value.game_version,
loader: instance.value.loader,
link: instance.value.link,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
@@ -1027,7 +1024,7 @@ async function setModpackContentEnabled(items: ContentItem[], enabled: boolean)
}
async function handleModpackContent() {
if (!props.instance?.id) return
if (!instance.value?.id) return
if (modpackContentQuery.data.value?.length) {
modpackContentModal.value?.show(modpackContentQuery.data.value)
@@ -1047,12 +1044,12 @@ async function handleModpackContent() {
}
async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) {
if (!props.instance?.id) return
if (!instance.value?.id) return
const contentItems = await queryClient
.fetchQuery({
queryKey: modpackContentQueryKey.value,
queryFn: () => get_linked_modpack_content(props.instance.id, cacheBehaviour),
queryFn: () => get_linked_modpack_content(instance.value.id, cacheBehaviour),
})
.catch(handleError)
@@ -1067,7 +1064,7 @@ async function refreshContentState(cacheBehaviour?: CacheBehaviour) {
}
watch(
() => installRevisionByInstance.value.get(props.instance.id) ?? 0,
() => installRevisionByInstance.value.get(instance.value.id) ?? 0,
async (revision) => {
if (revision <= handledInstallRevision.value) return
handledInstallRevision.value = revision
@@ -1076,7 +1073,7 @@ watch(
)
async function handleModpackUpdate() {
if (!props.instance?.link?.project_id) return
if (!instance.value?.link?.project_id) return
const requestId = beginUpdateRequest()
@@ -1089,7 +1086,7 @@ async function handleModpackUpdate() {
await nextTick()
const initialVersionId =
linkedModpackUpdateVersionId.value ?? props.instance?.link?.version_id ?? undefined
linkedModpackUpdateVersionId.value ?? instance.value?.link?.version_id ?? undefined
debug('handleModpackUpdate: opening modpack updater modal', {
type: 'modpack',
initialVersionId,
@@ -1098,11 +1095,11 @@ async function handleModpackUpdate() {
linkedModpackVersion: linkedModpackVersion.value,
linkedModpackHasUpdate: linkedModpackHasUpdate.value,
instance: {
path: props.instance.id,
name: props.instance.name,
gameVersion: props.instance.game_version,
loader: props.instance.loader,
link: props.instance.link,
path: instance.value.id,
name: instance.value.name,
gameVersion: instance.value.game_version,
loader: instance.value.loader,
link: instance.value.link,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
@@ -1118,7 +1115,7 @@ async function handleModpackUpdate() {
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = await getUpdaterProjectVersions(props.instance.link.project_id, initialVersionId)
const versions = await getUpdaterProjectVersions(instance.value.link.project_id, initialVersionId)
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
@@ -1143,7 +1140,7 @@ async function handleModpackUpdate() {
: null,
versionCount: versions.length,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
currentLinkedVersionId: props.instance.link.version_id,
currentLinkedVersionId: instance.value.link.version_id,
})
updatingProjectVersions.value = versions
@@ -1195,14 +1192,14 @@ function resetUpdateState() {
async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
pendingModpackUpdateVersion.value = selectedVersion
const currentVersionId = props.instance?.link?.version_id
const currentVersionId = instance.value?.link?.version_id
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
: false
const shouldShowWarning =
isModpackUpdateDowngrade.value ||
versionChangesGameVersion(selectedVersion, props.instance.game_version)
versionChangesGameVersion(selectedVersion, instance.value.game_version)
if (skipNonEssentialWarnings.value || !shouldShowWarning) {
await handleModpackUpdateConfirm()
@@ -1213,7 +1210,7 @@ async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.
}
async function handleModpackUpdateConfirm() {
if (!pendingModpackUpdateVersion.value || !props.instance?.id) return
if (!pendingModpackUpdateVersion.value || !instance.value?.id) return
const version = pendingModpackUpdateVersion.value
pendingModpackUpdateVersion.value = null
@@ -1221,7 +1218,7 @@ async function handleModpackUpdateConfirm() {
contentUpdaterModal.value?.hide()
isModpackUpdating.value = true
try {
await update_managed_modrinth_version(props.instance.id, version.id)
await update_managed_modrinth_version(instance.value.id, version.id)
await initProjects()
} finally {
isModpackUpdating.value = false
@@ -1260,7 +1257,7 @@ async function handleModalUpdate(
}
async function unpairInstance() {
await edit(props.instance.id, {
await edit(instance.value.id, {
link: null as unknown as undefined,
})
linkedModpackProject.value = null
@@ -1312,7 +1309,7 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
options.push({
id: formatMessage(commonMessages.showFileButton),
icon: FolderOpenIcon,
action: () => highlightModInInstance(props.instance.id, item.file_path),
action: () => highlightModInInstance(instance.value.id, item.file_path),
})
if (item.project?.slug) {
@@ -1330,15 +1327,19 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
return options
}
async function initProjects(cacheBehaviour?: CacheBehaviour) {
if (!props.instance) return
async function initProjects(cacheBehaviour?: CacheBehaviour, staleTime = 0) {
if (!instance.value) return
const contentData = await loadInstanceContentData(props.instance.id, cacheBehaviour, handleError)
const contentData = await queryClient.fetchQuery({
...instanceContentQueryOptions(instance.value.id),
queryFn: () => loadInstanceContentData(instance.value.id, cacheBehaviour, handleError),
staleTime,
})
applyContentData(contentData)
}
function applyContentData(contentData: InstanceContentData) {
if (contentData.path !== props.instance.id) {
if (contentData.path !== instance.value.id) {
return false
}
@@ -1372,8 +1373,6 @@ function applyContentData(contentData: InstanceContentData) {
return true
}
provideInstanceBackup(() => props.instance)
provideContentManager({
items: mergedProjects,
loading,
@@ -1384,14 +1383,14 @@ provideContentManager({
project: linkedModpackProject.value,
projectLink: {
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`,
query: { i: props.instance.id },
query: { i: instance.value.id },
},
version: linkedModpackVersion.value ?? undefined,
versionLink:
linkedModpackProject.value && linkedModpackVersion.value
? {
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}/version/${linkedModpackVersion.value.id}`,
query: { i: props.instance.id },
query: { i: instance.value.id },
}
: undefined,
owner: linkedModpackOwner.value
@@ -1459,12 +1458,12 @@ provideContentManager({
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
updateModpack:
props.isServerInstance || isSharedMember.value || isQuarantined.value
isServerInstance.value || isSharedMember.value || isQuarantined.value
? undefined
: handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
openSettings: openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
shareItems: handleShareItems,
@@ -1478,7 +1477,7 @@ provideContentManager({
icon_url: null,
},
projectLink: item.project?.id
? { path: `/project/${item.project.id}`, query: { i: props.instance.id } }
? { path: `/project/${item.project.id}`, query: { i: instance.value.id } }
: undefined,
version: item.version ?? {
id: item.file_name,
@@ -1489,7 +1488,7 @@ provideContentManager({
item.project?.id && item.version?.id
? {
path: `/project/${item.project.id}/version/${item.version.id}`,
query: { i: props.instance.id },
query: { i: instance.value.id },
}
: undefined,
owner: item.owner
@@ -1504,7 +1503,7 @@ provideContentManager({
hideSwitchVersion: !canMutateContent(item) || !item.project?.id || !item.version?.id,
hasUpdate: canUpdateProject(item),
}),
filterPersistKey: props.instance.id,
filterPersistKey: instance.value.id,
})
type UnlistenFn = () => void
@@ -1513,7 +1512,7 @@ const initialContentReady = loadInitialContent()
void initialContentReady.then(restoreModpackContentModalState).catch(handleError)
function getInstallRevision() {
return installRevisionByInstance.value.get(props.instance.id) ?? 0
return installRevisionByInstance.value.get(instance.value.id) ?? 0
}
function loadInitialContent() {
@@ -1523,13 +1522,23 @@ function loadInitialContent() {
return initProjects('must_revalidate')
}
if (props.preloadedContent && applyContentData(props.preloadedContent)) {
return Promise.resolve()
}
return initProjects()
return initProjects(undefined, 30_000)
}
watch(
contentQuery.data,
(data) => {
if (data) applyContentData(data)
},
{ immediate: true },
)
watch(contentQuery.error, (error) => {
if (error) {
loading.value = false
handleError(error)
}
})
async function restoreModpackContentModalState() {
if (!savedModalState) return
@@ -1552,11 +1561,11 @@ let unlistenInstances: UnlistenFn | null = null
onMounted(() => {
void getCurrentWebview()
.onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop' || !props.instance) return
if (event.payload.type !== 'drop' || !instance.value) return
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(props.instance.id, file).catch(handleError)
await add_project_from_path(instance.value.id, file).catch(handleError)
}
await initProjects()
})
@@ -1572,10 +1581,10 @@ onMounted(() => {
void instance_listener(async (event: { event: string; instance_id: string }) => {
if (
props.instance &&
event.instance_id === props.instance.id &&
instance.value &&
event.instance_id === instance.value.id &&
event.event === 'synced' &&
props.instance.install_stage === 'installed' &&
instance.value.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
@@ -1593,7 +1602,7 @@ onMounted(() => {
})
watch(
() => props.instance?.install_stage,
() => instance.value?.install_stage,
async (newStage, oldStage) => {
if (oldStage !== 'installed' && newStage === 'installed') {
await refreshContentState('must_revalidate')
@@ -1604,7 +1613,7 @@ watch(
)
watch(
() => props.instance?.link,
() => instance.value?.link,
async (newInstanceLink, oldInstanceLink) => {
if (oldInstanceLink && !newInstanceLink) {
await initProjects('must_revalidate')
@@ -1613,7 +1622,7 @@ watch(
)
watch(
() => props.instance?.update_channel,
() => instance.value?.update_channel,
async (newValue, oldValue) => {
if (newValue !== oldValue) {
await initProjects('must_revalidate')
@@ -10,6 +10,7 @@ import {
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { invoke } from '@tauri-apps/api/core'
import {
mkdir,
@@ -22,21 +23,17 @@ import {
writeFile as writeFileBytes,
writeTextFile,
} from '@tauri-apps/plugin-fs'
import { onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { instance_listener } from '@/helpers/events'
import { get_full_path } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { highlightInFolder } from '@/helpers/utils'
const props = defineProps<{
instance: GameInstance
options: unknown
offline: boolean
playing: boolean
installed: boolean
isServerInstance: boolean
}>()
import { injectInstancePage } from '../instance-context'
import { instanceKeys } from '../query-options'
const instancePage = injectInstancePage()
const instanceId = instancePage.instanceId
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
@@ -53,7 +50,15 @@ const messages = defineMessages({
},
})
const instanceRoot = ref('')
const instanceRootQuery = useQuery(
computed(() => ({
queryKey: instanceKeys.rootPath(instancePage.instanceId.value),
queryFn: () => get_full_path(instancePage.instanceId.value),
enabled: !!instancePage.instanceId.value,
staleTime: Infinity,
})),
)
const instanceRoot = computed(() => instanceRootQuery.data.value ?? '')
const items = ref<FileItem[]>([])
/** True until the first directory read for the current instance path finishes (initial load only). */
const firstPaintPending = ref(true)
@@ -62,12 +67,7 @@ const error = ref<Error | null>(null)
const currentPath = ref('')
const editingFile = ref<EditingFile | null>(null)
debug('setup: start, instance.id =', props.instance.id)
instanceRoot.value = await get_full_path(props.instance.id)
debug('setup: instanceRoot =', instanceRoot.value)
await refresh()
debug('setup: refresh complete, items =', items.value.length, 'error =', error.value)
debug('setup: start, instance.id =', instanceId.value)
function resolvePath(relativePath: string): string {
return relativePath ? `${instanceRoot.value}/${relativePath}` : instanceRoot.value
@@ -113,21 +113,39 @@ async function listDirectory(dirPath: string): Promise<FileItem[]> {
return results.filter((item): item is FileItem => item !== null)
}
const directoryQuery = useQuery(
computed(() => ({
queryKey: instanceKeys.files(instancePage.instanceId.value, currentPath.value),
queryFn: () => listDirectory(currentPath.value),
enabled: !!instanceRoot.value,
staleTime: 30_000,
})),
)
watch(
directoryQuery.data,
(data) => {
if (!data) return
items.value = data
firstPaintPending.value = false
},
{ immediate: true },
)
watch(directoryQuery.isFetching, (fetching) => {
loading.value = fetching
})
watch(directoryQuery.error, (queryError) => {
error.value = queryError
if (queryError) items.value = []
})
await instanceRootQuery.suspense()
await directoryQuery.refetch()
firstPaintPending.value = false
async function refresh() {
debug('refresh: called, currentPath =', currentPath.value, 'instanceRoot =', instanceRoot.value)
loading.value = true
error.value = null
try {
items.value = await listDirectory(currentPath.value)
debug('refresh: success, items =', items.value.length)
} catch (e) {
debug('refresh: error =', e)
error.value = e instanceof Error ? e : new Error(String(e))
items.value = []
} finally {
loading.value = false
firstPaintPending.value = false
}
await directoryQuery.refetch()
}
function navigateTo(path: string) {
@@ -221,7 +239,7 @@ async function handleWriteFile(path: string, content: string) {
async function handleDownloadFile(path: string, _fileName: string) {
await invoke('plugin:files|file_save_as', {
instanceId: props.instance.id,
instanceId: instanceId.value,
filePath: path,
})
}
@@ -275,7 +293,7 @@ async function handleUploadFiles(files: File[]) {
async function handleExtractFile(path: string, override: boolean, dry: boolean) {
try {
return await invoke('plugin:files|file_extract_zip', {
instanceId: props.instance.id,
instanceId: instanceId.value,
filePath: path,
overrideConflicts: override,
dryRun: dry,
@@ -293,7 +311,7 @@ debug('setup: registering instance_listener')
const unlistenInstances = await instance_listener(
async (event: { event: string; instance_id: string }) => {
debug('instance_listener: event =', event.event, 'path =', event.instance_id)
if (event.instance_id === props.instance.id && event.event === 'synced') {
if (event.instance_id === instanceId.value && event.event === 'synced') {
debug('instance_listener: synced event matched, calling refresh')
await refresh()
}
@@ -305,16 +323,13 @@ onUnmounted(() => {
unlistenInstances()
})
watch(
() => props.instance.id,
async () => {
debug('watch instance.id: changed to', props.instance.id)
firstPaintPending.value = true
instanceRoot.value = await get_full_path(props.instance.id)
currentPath.value = ''
await refresh()
},
)
watch(instanceId, async () => {
debug('watch instance.id: changed to', instanceId.value)
firstPaintPending.value = true
currentPath.value = ''
await instanceRootQuery.refetch()
await refresh()
})
provideFileManager({
items,
@@ -1,9 +0,0 @@
import Files from './Files.vue'
import Index from './Index.vue'
import Logs from './Logs.vue'
import Mods from './Mods.vue'
import Overview from './Overview.vue'
import Share from './share/index.vue'
import Worlds from './Worlds.vue'
export { Files, Index, Logs, Mods, Overview, Share, Worlds }
@@ -0,0 +1,8 @@
import Content from './content/index.vue'
import Files from './files/index.vue'
import Index from './layout.vue'
import Logs from './logs/index.vue'
import Share from './share/index.vue'
import Worlds from './worlds/index.vue'
export { Content, Files, Index, Logs, Share, Worlds }
@@ -0,0 +1,27 @@
import type { Labrinth } from '@modrinth/api-client'
import { createContext } from '@modrinth/ui'
import type { ComputedRef, Ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
export interface InstancePageContext {
readonly instanceId: ComputedRef<string>
readonly instance: ComputedRef<GameInstance>
readonly linkedProject: ComputedRef<Labrinth.Projects.v3.Project | undefined>
readonly isServerInstance: ComputedRef<boolean>
readonly offline: Readonly<Ref<boolean>>
readonly playing: ComputedRef<boolean>
readonly loading: Readonly<Ref<boolean>>
readonly stopping: Readonly<Ref<boolean>>
refreshInstance: () => Promise<void>
refreshPlayState: () => Promise<void>
play: (source: string) => Promise<void>
stop: (source: string) => Promise<void>
playServer: () => Promise<void>
openSettings: (tab?: number) => void
browseContent: (projectType?: string) => Promise<void>
browseServers: () => Promise<void>
}
export const [injectInstancePage, provideInstancePage] =
createContext<InstancePageContext>('InstancePage')
@@ -11,7 +11,7 @@
ref="settingsModal"
:instance="instance"
:offline="offline"
@unlinked="fetchInstance"
@unlinked="refreshInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<SharedInstanceUpdateModal
@@ -65,32 +65,20 @@
:shared-instance-role="instance.shared_instance?.role"
:shared-instance-signed-out="sharedInstanceSignedOut"
:shared-instance-update-available="showSharedInstanceUpdateAdmonition"
@published="fetchInstance"
@published="refreshInstance"
@delete="requestInstanceDeletion"
@review-update="reviewSharedInstanceUpdate"
/>
</div>
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense
:key="instance.id"
@pending="subpagePending = true"
@resolve="subpagePending = false"
>
<component
:is="Component"
:instance="instance"
:options="options"
:offline="offline"
:playing="playing"
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
:open-settings="() => settingsModal?.show(1)"
v-bind="contentSubpageProps"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
<component :is="Component" />
</Suspense>
</template>
</RouterView>
@@ -102,45 +90,24 @@
<template #edit> <EditIcon /> Edit </template>
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
<template #open_folder> <FolderOpenIcon /> Open folder </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_names><EditIcon />Copy names</template>
<template #copy_slugs><HashIcon />Copy slugs</template>
<template #copy_links><GlobeIcon />Copy links</template>
<template #toggle><EditIcon />Toggle selected</template>
<template #disable><XIcon />Disable selected</template>
<template #enable><CheckCircleIcon />Enable selected</template>
<template #hide_show><EyeIcon />Show/Hide unselected</template>
<template #update_all
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
>
<template #filter_update><UpdatedIcon />Select Updatable</template>
</ContextMenu>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BoxesIcon,
CheckCircleIcon,
ClipboardCopyIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
HashIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
TerminalSquareIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import {
commonMessages,
injectAuth,
injectNotificationManager,
NavTabs,
useLoadingBarToken,
@@ -148,17 +115,15 @@ import {
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useOnline } from '@vueuse/core'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, type ComputedRef, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue'
import InstancePageHeader from '@/components/ui/instance-page-header/index.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
@@ -168,7 +133,6 @@ import {
} from '@/composables/instances/use-server-status-query'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
import {
getSharedInstanceUnavailableReason,
@@ -178,69 +142,126 @@ import {
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import {
can_current_user_use_shared_instances,
get,
get_full_path,
kill,
remove,
run,
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
import { get_full_path, kill, remove, run } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import type { ServerStatus } from '@/helpers/worlds'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
import InstanceAdmonitions from './components/admonitions/index.vue'
import InstancePageHeader from './components/page-header/index.vue'
import InstanceSettingsModal from './components/settings-modal/index.vue'
import { provideInstancePage } from './instance-context'
import {
instanceContentQueryOptions,
instanceDetailQueryOptions,
instanceKeys,
instanceLinkedProjectQueryOptions,
instanceProcessesQueryOptions,
} from './query-options'
import { createSharedInstanceContext, provideSharedInstance } from './shared-instance-context'
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const auth = injectAuth()
const queryClient = useQueryClient()
const route = useRoute()
const { formatMessage } = useVIntl()
const router = useRouter()
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
const themeStore = useTheming()
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
offline.value = true
})
window.addEventListener('online', () => {
offline.value = false
})
const initialInstanceId = String(displayedInstanceRoute.value.params.id ?? '')
const instance = ref<GameInstance | undefined>(
queryClient.getQueryData<GameInstance>(['instances', 'summary', initialInstanceId]),
const online = useOnline()
const offline = computed(() => !online.value)
const instanceId = computed(() => String(route.params.id ?? ''))
const instanceQuery = useQuery(
computed(() => ({
...instanceDetailQueryOptions(instanceId.value),
enabled: !!instanceId.value,
})),
)
useQuery(
computed(() => ({
...instanceContentQueryOptions(instanceId.value, (error) => handleError(error)),
enabled: !!instanceId.value,
})),
)
const instance = computed(() => instanceQuery.data.value)
const linkedProjectId = computed(() => instance.value?.link?.project_id ?? '')
const linkedProjectQuery = useQuery(
computed(() => ({
...instanceLinkedProjectQueryOptions(linkedProjectId.value),
enabled: !!linkedProjectId.value && !offline.value,
})),
)
const linkedProjectV3 = computed(() => linkedProjectQuery.data.value ?? undefined)
const isServerInstance = computed(() => linkedProjectV3.value?.minecraft_server != null)
const processesQuery = useQuery(
computed(() => ({
...instanceProcessesQueryOptions(instanceId.value),
enabled: !!instanceId.value,
})),
)
const playing = computed(() => (processesQuery.data.value?.length ?? 0) > 0)
async function ensureCriticalContent(targetInstanceId: string) {
await queryClient.ensureQueryData(
instanceContentQueryOptions(targetInstanceId, (error) => handleError(error)),
)
}
async function ensureCriticalInstanceData(targetInstanceId: string) {
await Promise.all([
queryClient.ensureQueryData(instanceDetailQueryOptions(targetInstanceId)),
ensureCriticalContent(targetInstanceId),
])
}
function isUnmanagedInstanceError(error: unknown) {
return error instanceof Error && error.message.includes('is not managed')
}
try {
await ensureCriticalInstanceData(instanceId.value)
} catch (error) {
if (isUnmanagedInstanceError(error)) await router.replace('/')
else handleError(error)
}
onBeforeRouteUpdate(async (to, from) => {
const targetInstanceId = String(to.params.id ?? '')
const currentInstanceId = String(from.params.id ?? '')
if (!targetInstanceId || targetInstanceId === currentInstanceId) return
try {
await ensureCriticalInstanceData(targetInstanceId)
} catch (error) {
if (isUnmanagedInstanceError(error)) return { path: '/' }
handleError(error)
return false
}
})
useRootBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedInstanceRoute.value.params.id ?? '')}`,
id: () => `instance:${instanceId.value}`,
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image',
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
alt: instance.value?.name,
tintBy: instance.value?.id ?? String(displayedInstanceRoute.value.params.id ?? ''),
tintBy: instance.value?.id ?? instanceId.value,
}),
to: () => `/instance/${encodeURIComponent(String(displayedInstanceRoute.value.params.id ?? ''))}`,
to: () => `/instance/${encodeURIComponent(instanceId.value)}`,
})
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
const checkingSharedInstanceLaunch = ref(false)
const subpagePending = ref(false)
@@ -250,16 +271,15 @@ const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const sharedInstanceUpdateModal = ref<InstanceType<typeof SharedInstanceUpdateModal>>()
const sharedInstanceReportModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const deleteConfirmModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
const selectedInstanceToDelete = ref<GameInstance | null>(null)
const hiddenSharedInstanceUpdateKey = ref<string | null>(null)
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
useLoadingBarToken(subpagePending)
useLoadingBarToken(computed(() => instanceQuery.isPending.value && !instance.value))
const isServerInstance = ref(false)
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const liveServerStatusOnline = ref(false)
@@ -270,9 +290,12 @@ const recentPlays = computed(
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError)
provideSharedInstanceState(sharedInstanceState)
const sharedInstanceState = createSharedInstanceContext(
instance,
offline,
notifySharedInstanceError,
)
provideSharedInstance(sharedInstanceState)
const {
actionsLocked: sharedInstanceActionsLocked,
expectedUserId: sharedInstanceExpectedUserId,
@@ -296,19 +319,6 @@ const showSharedInstanceUpdateAdmonition = computed(
sharedInstanceUpdateKey.value !== hiddenSharedInstanceUpdateKey.value,
)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (
nextRoute.path.startsWith('/instance') &&
(!instance.value || nextRoute.params.id === instance.value.id)
) {
displayedInstanceRoute.value = nextRoute
}
},
{ immediate: true },
)
function applyServerStatus(status: ServerStatus) {
playersOnline.value = status.players?.online
ping.value = status.ping
@@ -323,124 +333,66 @@ function resetServerStatus() {
loadingServerPing.value = false
}
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
async function fetchInstance() {
const requestedInstanceId = route.params.id as string
const requestedRouteName = route.name
const nextInstance = await get(requestedInstanceId).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
const contentPreloadPromise =
nextInstance && isContentSubpageRoute(requestedRouteName)
? loadInstanceContentData(nextInstance.id, undefined, handleError)
: Promise.resolve(null)
if (!offline.value && nextInstance?.link && nextInstance.link.project_id) {
try {
nextLinkedProjectV3 = await get_project_v3(nextInstance.link.project_id, 'must_revalidate')
if (nextLinkedProjectV3?.minecraft_server != null) {
nextIsServerInstance = true
}
} catch (error) {
handleError(error as Error)
}
}
let nextPreloadedContent = await contentPreloadPromise
let nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
if (nextInstance && isContentSubpageRoute(nextRoute.name) && !nextPreloadedContent) {
nextPreloadedContent = await loadInstanceContentData(nextInstance.id, undefined, handleError)
nextRoute = router.currentRoute.value
if (nextRoute.params.id !== requestedInstanceId) return
}
instance.value = nextInstance ?? undefined
if (nextInstance) {
queryClient.setQueryData(['instances', 'summary', nextInstance.id], nextInstance)
}
displayedInstanceRoute.value = nextRoute
sharedInstanceState.reset()
sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
resetServerStatus()
fetchDeferredData(nextInstance?.id)
if (nextInstance) {
queryClient.prefetchQuery({
queryKey: ['worlds', nextInstance.id],
queryFn: () => refreshWorlds(nextInstance.id),
staleTime: 30_000,
})
}
}
function fetchDeferredData(instanceId?: string) {
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (isServerInstance.value && serverAddress) {
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
applyServerStatus(cachedStatus)
} else {
playersOnline.value = undefined
ping.value = undefined
loadingServerPing.value = false
}
fetchCachedServerStatus(queryClient, serverAddress)
.then((status) => {
if (
activeInstanceId.value !== instanceId ||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
)
return
applyServerStatus(status)
})
.catch((error) => {
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
})
.finally(() => {
if (activeInstanceId.value !== instanceId) return
loadingServerPing.value = true
})
} else {
loadingServerPing.value = true
}
updatePlayState()
}
async function updatePlayState() {
if (!route.params.id) return
const runningProcesses = await get_by_instance_id(route.params.id as string).catch(handleError)
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
}
await fetchInstance()
const serverAddress = computed(() => linkedProjectV3.value?.minecraft_java_server?.address)
watch(
() => route.params.id,
async () => {
if (route.params.id && route.path.startsWith('/instance')) {
await fetchInstance()
[instanceId, serverAddress, isServerInstance],
([requestedInstanceId, address, serverInstance]) => {
resetServerStatus()
if (serverInstance && address) {
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
if (cachedStatus) {
applyServerStatus(cachedStatus)
} else {
playersOnline.value = undefined
ping.value = undefined
loadingServerPing.value = false
}
fetchCachedServerStatus(queryClient, address)
.then((status) => {
if (instanceId.value !== requestedInstanceId || serverAddress.value !== address) return
applyServerStatus(status)
})
.catch((error) => {
console.error(`Failed to fetch server status for ${address}:`, error)
})
.finally(() => {
if (instanceId.value !== requestedInstanceId) return
loadingServerPing.value = true
})
} else {
loadingServerPing.value = true
}
},
{ immediate: true },
)
const basePath = computed(
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
async function refreshInstance() {
await Promise.all([instanceQuery.refetch(), sharedInstanceState.refreshAvailability()])
}
async function refreshPlayState() {
await processesQuery.refetch()
}
watch(
instanceQuery.error,
(error) => {
if (!error) return
if (error.message.includes('is not managed')) void router.replace('/')
else handleError(error)
},
{ immediate: true },
)
watch(
linkedProjectQuery.error,
(error) => {
if (error) handleError(error)
},
{ immediate: true },
)
const basePath = computed(() => `/instance/${encodeURIComponent(instanceId.value)}`)
/**
* Per-route layout mode.
@@ -451,25 +403,10 @@ const basePath = computed(
* Used by tabs whose content (e.g. the log console) needs a bounded height to resolve `h-full`.
*/
const renderMode = computed<'scroll' | 'fixed'>(() =>
displayedInstanceRoute.value.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
)
const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const { data: canCurrentUserUseSharedInstances } = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', auth.user.value?.id]),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!auth.session_token.value && !!auth.user.value?.id,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const currentUserCanUseSharedInstances = computed(
() => !auth.session_token.value || canCurrentUserUseSharedInstances.value !== false,
)
const currentUserCanUseSharedInstances = sharedInstanceState.currentUserCanUseSharedInstances
const showShareTab = computed(() => {
const linkType = instance.value?.link?.type
@@ -534,12 +471,13 @@ const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const launchInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
const currentInstance = instance.value
loading.value = true
try {
await run(route.params.id as string)
playing.value = true
await run(currentInstance.id)
queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [true])
} catch (err) {
handleSevereError(err, { instanceId: route.params.id as string })
handleSevereError(err, { instanceId: currentInstance.id })
}
loading.value = false
@@ -555,7 +493,7 @@ async function handleSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
) {
notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
await fetchInstance()
await refreshInstance()
setSharedInstanceUnavailable(reason)
}
@@ -574,7 +512,7 @@ function reviewSharedInstanceUpdate(event: MouseEvent) {
currentInstance,
preview,
async () => {
await fetchInstance()
await refreshInstance()
},
event,
)
@@ -618,7 +556,7 @@ const startInstance = async (context: string) => {
if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
await fetchInstance()
await refreshInstance()
await launchInstance(context)
})
return
@@ -628,7 +566,7 @@ const startInstance = async (context: string) => {
if (updateToPlayModal.value?.hasUpdate) {
if (isSharedInstanceMember) {
updateToPlayModal.value.show(instance.value, null, async () => {
await fetchInstance()
await refreshInstance()
await launchInstance(context)
})
} else {
@@ -641,15 +579,16 @@ const startInstance = async (context: string) => {
}
const stopInstance = async (context: string) => {
const currentInstance = instance.value
if (!currentInstance) return
stopping.value = true
await kill(route.params.id as string).catch(handleError)
await kill(currentInstance.id).catch(handleError)
stopping.value = false
playing.value = false
queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [])
if (!instance.value) return
trackEvent('InstanceStop', {
loader: instance.value.loader,
game_version: instance.value.game_version,
loader: currentInstance.loader,
game_version: currentInstance.game_version,
source: context,
})
}
@@ -660,26 +599,48 @@ const handlePlayServer = async () => {
try {
await playServerProject(instance.value.link.project_id)
} finally {
await updatePlayState()
await refreshPlayState()
loading.value = false
}
}
function openSettings(tab?: number) {
settingsModal.value?.show(tab)
}
async function browseContent(projectType?: string) {
const currentInstance = instance.value
if (!currentInstance || currentInstance.quarantined) return
await router.push({
path: `/browse/${projectType ?? (currentInstance.loader === 'vanilla' ? 'resourcepack' : 'mod')}`,
query: { i: currentInstance.id },
})
}
async function browseServers() {
if (!instance.value || instance.value.quarantined) return
await router.push({
path: '/browse/server',
query: { i: instance.value.id, from: 'worlds' },
})
}
const repairInstance = async () => {
if (instance.value.quarantined) return
const currentInstance = instance.value
if (!currentInstance || currentInstance.quarantined) return
if (
instance.value.install_stage !== 'pack_installed' &&
(instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_modpack')
currentInstance.install_stage !== 'pack_installed' &&
(currentInstance.link?.type === 'modrinth_modpack' ||
currentInstance.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(instance.value.id, {
await install_pack_to_existing_instance(currentInstance.id, {
type: 'fromVersionId',
project_id: instance.value.link.project_id ?? instance.value.link.server_project_id ?? '',
version_id: instance.value.link.version_id ?? instance.value.link.content_version_id ?? '',
title: instance.value.name,
project_id: currentInstance.link.project_id ?? currentInstance.link.server_project_id ?? '',
version_id: currentInstance.link.version_id ?? currentInstance.link.content_version_id ?? '',
title: currentInstance.name,
}).catch(handleError)
} else {
await install_existing_instance(instance.value.id, false).catch(handleError)
await install_existing_instance(currentInstance.id, false).catch(handleError)
}
}
@@ -786,15 +747,10 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
await stopInstance('InstancePageContextMenu')
break
case 'add_content':
await router.push({
path: `/browse/${instance.value?.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: route.params.id },
})
await browseContent(instance.value?.loader === 'vanilla' ? 'datapack' : 'mod')
break
case 'edit':
await router.push({
path: `/instance/${encodeURIComponent(route.params.id as string)}/options`,
})
openSettings()
break
case 'open_folder':
if (instance.value) await showInstanceInFolder(instance.value.id)
@@ -809,41 +765,79 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
}
}
const unlistenInstances = await instance_listener(
async (event: { instance_id: string; event: string }) => {
if (event.instance_id !== route.params.id) return
let unlistenInstances: (() => void) | null = null
let unlistenProcesses: (() => void) | null = null
let instancePageAlive = true
provideInstancePage({
instanceId,
instance: instance as ComputedRef<GameInstance>,
linkedProject: linkedProjectV3,
isServerInstance,
offline,
playing,
loading,
stopping,
refreshInstance,
refreshPlayState,
play: startInstance,
stop: stopInstance,
playServer: handlePlayServer,
openSettings,
browseContent,
browseServers,
})
provideInstanceBackup(() => instance.value!)
function destroyInstanceConsole(targetInstanceId: string) {
void useInstanceConsole(targetInstanceId).destroy()
queryClient.removeQueries({ queryKey: instanceKeys.console(targetInstanceId), exact: true })
}
watch(instanceId, (currentInstanceId, previousInstanceId) => {
if (!previousInstanceId || previousInstanceId === currentInstanceId) return
destroyInstanceConsole(previousInstanceId)
})
onMounted(() => {
void instance_listener(async (event: { instance_id: string; event: string }) => {
if (event.instance_id !== instanceId.value) return
if (event.event === 'removed' || route.path === '/') {
if (route.path !== '/') {
await router.push({ path: '/' })
}
if (route.path !== '/') await router.push({ path: '/' })
return
}
instance.value = await get(route.params.id as string).catch((err) => {
if (String(err).includes('not managed')) {
router.push({ path: '/' })
return undefined
}
return handleError(err)
await queryClient.invalidateQueries({
queryKey: instanceKeys.detail(event.instance_id),
exact: true,
})
if (!instance.value?.link?.project_id) {
linkedProjectV3.value = undefined
isServerInstance.value = false
}
},
)
})
.then((unlisten) => {
if (instancePageAlive) unlistenInstances = unlisten
else unlisten()
})
.catch(handleError)
const unlistenProcesses = await process_listener((e: { event: string; instance_id: string }) => {
if (e.event === 'finished' && e.instance_id === route.params.id) {
playing.value = false
}
void process_listener((event: { event: string; instance_id: string }) => {
if (event.instance_id !== instanceId.value) return
if (event.event === 'finished') {
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [])
useInstanceConsole(event.instance_id).invalidate()
void queryClient.invalidateQueries({ queryKey: instanceKeys.logs(event.instance_id) })
} else if (event.event === 'launched') {
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [true])
}
})
.then((unlisten) => {
if (instancePageAlive) unlistenProcesses = unlisten
else unlisten()
})
.catch(handleError)
})
const icon = computed(() =>
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
const timePlayed = computed(() => {
return instance.value
? instance.value.recent_time_played + instance.value.submitted_time_played
@@ -851,210 +845,11 @@ const timePlayed = computed(() => {
})
onUnmounted(() => {
unlistenProcesses()
unlistenInstances()
const instanceId = displayedInstanceRoute.value.params.id
if (instanceId) {
const { destroy } = useInstanceConsole(instanceId)
destroy()
instancePageAlive = false
unlistenProcesses?.()
unlistenInstances?.()
if (instanceId.value) {
destroyInstanceConsole(instanceId.value)
}
})
</script>
<style scoped lang="scss">
.instance-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
Button {
width: 100%;
}
.button-group {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
.side-cards {
position: fixed;
width: 300px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 3.25rem);
max-height: calc(100vh - 3.25rem);
overflow-y: auto;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
background: transparent;
}
.card {
min-height: unset;
margin-bottom: 0;
}
}
.instance-nav {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 1rem;
gap: 0.5rem;
background: var(--color-raised-bg);
height: 100%;
}
.name {
font-size: 1.25rem;
color: var(--color-contrast);
overflow: hidden;
text-overflow: ellipsis;
}
.metadata {
text-transform: capitalize;
}
.instance-container {
display: flex;
flex-direction: row;
overflow: auto;
gap: 1rem;
min-height: 100%;
padding: 1rem;
}
.instance-info {
display: flex;
flex-direction: column;
width: 100%;
}
.badge {
display: flex;
align-items: center;
font-weight: bold;
width: fit-content;
color: var(--color-orange);
}
.pages-list {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
.btn {
font-size: 100%;
font-weight: 400;
background: inherit;
transition: all ease-in-out 0.1s;
width: 100%;
color: var(--color-primary);
box-shadow: none;
&.router-link-exact-active {
box-shadow: var(--shadow-inset-lg);
background: var(--color-button-bg);
color: var(--color-contrast);
}
&:hover {
background-color: var(--color-button-bg);
color: var(--color-contrast);
box-shadow: var(--shadow-inset-lg);
text-decoration: none;
}
svg {
width: 1.3rem;
height: 1.3rem;
}
}
}
.instance-nav {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: left;
padding: 1rem;
gap: 0.5rem;
height: min-content;
width: 100%;
}
.instance-button {
width: fit-content;
}
.actions {
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 0.5rem;
}
.content {
margin: 0 1rem 0.5rem 20rem;
width: calc(100% - 20rem);
display: flex;
flex-direction: column;
overflow: auto;
}
.stats {
grid-area: stats;
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: var(--gap-md);
.stat {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
gap: var(--gap-xs);
--stat-strong-size: 1.25rem;
strong {
font-size: var(--stat-strong-size);
}
p {
margin: 0;
}
svg {
height: var(--stat-strong-size);
width: var(--stat-strong-size);
}
}
.date {
margin-top: auto;
}
@media screen and (max-width: 750px) {
flex-direction: row;
column-gap: var(--gap-md);
margin-top: var(--gap-xs);
}
@media screen and (max-width: 600px) {
margin-top: 0;
.stat-label {
display: none;
}
}
}
</style>
@@ -11,51 +11,20 @@ import {
injectNotificationManager,
provideConsoleManager,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, onUnmounted, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { log_listener, process_listener } from '@/helpers/events.js'
import { delete_logs_by_filename, get_output_by_filename } from '@/helpers/logs.js'
import { injectInstancePage } from '../instance-context'
import { instanceKeys } from '../query-options'
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const route = useRoute()
const props = defineProps({
instance: {
type: Object,
default() {
return {}
},
},
options: {
type: Object,
default() {
return {}
},
},
offline: {
type: Boolean,
default() {
return false
},
},
playing: {
type: Boolean,
default() {
return false
},
},
installed: {
type: Boolean,
default() {
return false
},
},
})
const instanceId = computed(() => route.params.id)
const instancePage = injectInstancePage()
const instanceId = instancePage.instanceId
const {
liveConsole,
historicalConsole,
@@ -66,7 +35,17 @@ const {
clearLive,
} = useInstanceConsole(instanceId.value)
await hydrate()
const consoleHydrationQuery = useQuery({
queryKey: computed(() => instanceKeys.console(instanceId.value)),
queryFn: async () => {
await hydrate()
return true
},
staleTime: 0,
refetchOnMount: 'always',
})
await consoleHydrationQuery.suspense()
function buildLogList(rawLogs) {
return [
@@ -88,18 +67,29 @@ function buildLogList(rawLogs) {
}
const logs = ref(buildLogList([]))
void getHistoricalLogs()
.then((allLogs) => {
logs.value = buildLogList(allLogs)
})
.catch(handleError)
const historicalLogsQuery = useQuery({
queryKey: computed(() => instanceKeys.logs(instanceId.value)),
queryFn: getHistoricalLogs,
staleTime: 0,
})
watch(
historicalLogsQuery.data,
(allLogs) => {
if (allLogs) logs.value = buildLogList(allLogs)
},
{ immediate: true },
)
watch(historicalLogsQuery.error, (error) => {
if (error) handleError(error)
})
const selectedLogIndex = ref(0)
const isLive = computed(() => selectedLogIndex.value === 0)
const filteredLogs = computed(() =>
props.playing ? logs.value.filter((l) => l.live || l.name !== 'latest.log') : logs.value,
instancePage.playing.value
? logs.value.filter((l) => l.live || l.name !== 'latest.log')
: logs.value,
)
const logSources = computed(() =>
@@ -140,16 +130,16 @@ const selectedLog = computed(() => filteredLogs.value[selectedLogIndex.value])
const deleteDisabled = computed(() => {
const log = selectedLog.value
if (!log || log.live) return true
return log.filename === 'latest.log' && props.playing
return log.filename === 'latest.log' && instancePage.playing.value
})
async function deleteSelectedLog() {
const log = selectedLog.value
if (!log || log.live) return
await delete_logs_by_filename(props.instance.id, log.log_type, log.filename)
await delete_logs_by_filename(instanceId.value, log.log_type, log.filename)
invalidate()
const freshLogs = await getHistoricalLogs()
logs.value = buildLogList(freshLogs)
const { data } = await historicalLogsQuery.refetch()
if (data) logs.value = buildLogList(data)
selectedLogIndex.value = 0
}
@@ -166,7 +156,7 @@ provideConsoleManager({
onDelete: deleteSelectedLog,
deleteDisabled,
deleteDisabledTooltip: 'Cannot delete latest.log while the instance is running',
shareDisabled: computed(() => props.offline),
shareDisabled: instancePage.offline,
emptyStateType: 'instance',
crashAnalysis,
onDismissCrash: () => {
@@ -186,7 +176,7 @@ watch(selectedLogIndex, async (newIndex) => {
return
}
const output = await get_output_by_filename(props.instance.id, log.log_type, log.filename).catch(
const output = await get_output_by_filename(instanceId.value, log.log_type, log.filename).catch(
handleError,
)
if (output) {
@@ -197,7 +187,7 @@ watch(selectedLogIndex, async (newIndex) => {
selectedLogIndex.value = 0
if (!props.playing) {
if (!instancePage.playing.value) {
void analyseForCrash()
}
@@ -216,12 +206,13 @@ const unlistenProcesses = await process_listener(async (e) => {
if (e.event === 'launched') {
liveConsole.clear()
invalidate()
void historicalLogsQuery.refetch()
selectedLogIndex.value = 0
}
if (e.event === 'finished') {
invalidate()
const freshLogs = await getHistoricalLogs()
logs.value = buildLogList(freshLogs)
const { data } = await historicalLogsQuery.refetch()
if (data) logs.value = buildLogList(data)
void analyseForCrash()
}
})
@@ -0,0 +1,79 @@
import { queryOptions } from '@tanstack/vue-query'
import { get_project_v3 } from '@/helpers/cache.js'
import { get as getInstance } from '@/helpers/instance'
import { loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
import { refreshWorlds } from '@/helpers/worlds'
export const instanceKeys = {
all: ['instances'] as const,
detail: (instanceId: string) => [...instanceKeys.all, 'summary', instanceId] as const,
processes: (instanceId: string) => [...instanceKeys.all, 'processes', instanceId] as const,
content: (instanceId: string) => [...instanceKeys.all, 'content', instanceId] as const,
rootPath: (instanceId: string) => [...instanceKeys.detail(instanceId), 'root-path'] as const,
files: (instanceId: string, path: string) =>
[...instanceKeys.detail(instanceId), 'files', path] as const,
console: (instanceId: string) => [...instanceKeys.detail(instanceId), 'console'] as const,
logs: (instanceId: string) => [...instanceKeys.detail(instanceId), 'logs'] as const,
installedProjectIds: (instanceId: string, source: 'content' | 'worlds') =>
[...instanceKeys.detail(instanceId), 'installed-project-ids', source] as const,
linkedContent: (instanceId: string) => ['linkedModpackContent', instanceId] as const,
worlds: (instanceId: string) => ['worlds', instanceId] as const,
linkedProject: (projectId: string) => ['project', 'v3', projectId] as const,
sharedEligibility: (userId: string | null | undefined) =>
['shared-instance-eligibility', userId] as const,
sharedUpdatePreview: (instanceId: string, userId: string | null | undefined) =>
[...instanceKeys.detail(instanceId), 'shared-update-preview', userId] as const,
sharedMembers: (instanceId: string) => ['sharedInstanceUsers', instanceId] as const,
}
export function instanceDetailQueryOptions(instanceId: string) {
return queryOptions({
queryKey: instanceKeys.detail(instanceId),
queryFn: async () => {
const instance = await getInstance(instanceId)
if (!instance) throw new Error(`Instance ${instanceId} is not managed`)
return instance
},
staleTime: 30_000,
})
}
export function instanceProcessesQueryOptions(instanceId: string) {
return queryOptions({
queryKey: instanceKeys.processes(instanceId),
queryFn: async () => {
const processes = await get_by_instance_id(instanceId)
return Array.isArray(processes) ? processes : []
},
staleTime: 0,
})
}
export function instanceLinkedProjectQueryOptions(projectId: string) {
return queryOptions({
queryKey: instanceKeys.linkedProject(projectId),
queryFn: () => get_project_v3(projectId, 'must_revalidate'),
staleTime: 30_000,
})
}
export function instanceContentQueryOptions(
instanceId: string,
onError?: (error: Error) => unknown,
) {
return queryOptions({
queryKey: instanceKeys.content(instanceId),
queryFn: () => loadInstanceContentData(instanceId, undefined, onError),
staleTime: 30_000,
})
}
export function instanceWorldsQueryOptions(instanceId: string) {
return queryOptions({
queryKey: instanceKeys.worlds(instanceId),
queryFn: () => refreshWorlds(instanceId),
staleTime: 0,
})
}
@@ -55,20 +55,7 @@
<div v-else-if="membersTableLoading" class="h-64" aria-hidden="true" />
<SharedInstanceMembersTable
v-else-if="showMembersTable"
:rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked"
:invite-disabled="!hasRemainingUserSlots"
:invite-pending="inviteLink.pending.value"
:push-update-disabled="
instance.install_stage !== 'installed' || publishState !== 'idle' || !!offline
"
:push-update-pending="publishState !== 'idle'"
@invite="showInvitePlayers"
@remove="showRemoveMemberModal"
@push-update="reviewUpdate"
/>
<SharedInstanceMembersTable v-else-if="showMembersTable" />
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceUnavailable"
@@ -156,8 +143,8 @@ import {
type InvitePlayersUser,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, toRef, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
@@ -166,16 +153,16 @@ import {
isSharedInstancesApiError,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { can_current_user_use_shared_instances, edit } from '@/helpers/instance'
import { edit } from '@/helpers/instance'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
import {
sharedInstanceErrorMessages,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { provideInstanceBackup } from '@/providers/instance-backup'
import { injectSharedInstanceState } from '../use-shared-instance-state'
import { injectInstancePage } from '../instance-context'
import { injectSharedInstance } from '../shared-instance-context'
import { provideSharedInstanceManagement } from './shared-instance-management-context'
import SharedInstanceMembersTable from './shared-instance-members-table.vue'
import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue'
@@ -184,10 +171,7 @@ import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
import { useSharedInstanceMembers } from './use-shared-instance-members'
const props = defineProps<{
instance: GameInstance
offline?: boolean
}>()
const instancePage = injectInstancePage()
const auth = injectAuth()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
@@ -196,8 +180,9 @@ const {
notifySharedInstanceError,
notifySharedInstanceUnavailable,
} = useSharedInstanceErrors()
const sharedInstanceState = injectSharedInstanceState()
const instance = toRef(props, 'instance')
const sharedInstanceState = injectSharedInstance()
const instance = computed(() => instancePage.instance.value!)
const offline = instancePage.offline
const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null)
@@ -224,16 +209,7 @@ function notifyOperationError(error: unknown) {
}
}
const eligibilityQuery = useQuery({
queryKey: computed(() => ['shared-instance-eligibility', currentUserId.value]),
queryFn: can_current_user_use_shared_instances,
enabled: () => isSignedIn.value && !!currentUserId.value,
retry: false,
staleTime: Infinity,
refetchOnMount: 'always',
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const eligibilityQuery = sharedInstanceState.eligibilityQuery
const members = useSharedInstanceMembers({
instance,
@@ -257,7 +233,7 @@ const {
actionsLocked,
})
const inviteLink = useSharedInstanceInviteLink(
computed(() => props.instance.id),
computed(() => instance.value.id),
remainingUserSlots,
notifyOperationError,
)
@@ -286,7 +262,7 @@ const unableToConnect = computed(
const membersTableLoading = computed(
() =>
members.rows.value.length === 0 &&
!!props.instance.shared_instance &&
!!instance.value.shared_instance &&
(members.query.data.value === undefined || members.query.isFetching.value) &&
!sharedInstanceUnavailable.value &&
!sharedInstanceActionsLocked.value,
@@ -294,7 +270,7 @@ const membersTableLoading = computed(
const showMembersTable = computed(
() =>
members.rows.value.length > 0 ||
(!!props.instance.shared_instance &&
(!!instance.value.shared_instance &&
members.query.data.value !== undefined &&
!members.query.isFetching.value &&
!sharedInstanceUnavailable.value &&
@@ -302,13 +278,13 @@ const showMembersTable = computed(
)
const requiresUnlink = computed(
() =>
props.instance.link?.type === 'imported_modpack' &&
!props.instance.shared_instance &&
instance.value.link?.type === 'imported_modpack' &&
!instance.value.shared_instance &&
!importedModpackUnlinked.value,
)
const importedModpackBackupTip = computed(() =>
props.instance.link?.type === 'imported_modpack'
? (props.instance.link.name ?? props.instance.link.filename ?? undefined)
instance.value.link?.type === 'imported_modpack'
? (instance.value.link.name ?? instance.value.link.filename ?? undefined)
: undefined,
)
@@ -395,9 +371,9 @@ async function showInvitePlayers(event?: MouseEvent) {
}
async function unlinkImportedModpack() {
try {
await edit(props.instance.id, { link: null as unknown as undefined })
await edit(instance.value.id, { link: null as unknown as undefined })
importedModpackUnlinked.value = true
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] })
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
if (await inviteLink.ensure()) invitePlayersModal.value?.show()
} catch (error) {
notifyOperationError(error)
@@ -419,7 +395,7 @@ function userProfileLink(username: string) {
return !username || username.includes('@') ? undefined : `/user/${encodeURIComponent(username)}`
}
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn(`/instance/${encodeURIComponent(props.instance.id)}/share`, flow, {
await auth.requestSignIn(`/instance/${encodeURIComponent(instance.value.id)}/share`, flow, {
showModal: false,
})
return !!auth.session_token.value
@@ -428,6 +404,23 @@ function signInToShare(event?: MouseEvent) {
void accountRequiredModal.value?.show(event)
}
provideSharedInstanceManagement({
rows: members.rows,
actionsLocked: sharedInstanceActionsLocked,
inviteDisabled: computed(() => !hasRemainingUserSlots.value),
invitePending: inviteLink.pending,
pushUpdateDisabled: computed(
() =>
instance.value.install_stage !== 'installed' ||
publishState.value !== 'idle' ||
offline.value,
),
pushUpdatePending: computed(() => publishState.value !== 'idle'),
invite: (event) => void showInvitePlayers(event),
remove: showRemoveMemberModal,
pushUpdate: reviewUpdate,
})
watch(
[eligibilityQuery.error, members.query.error],
(errors) => {
@@ -443,7 +436,7 @@ watch([eligibilityQuery.data, members.query.data], ([eligibility, memberRows]) =
}
})
watch(
() => props.instance.id,
() => instance.value.id,
() => {
importedModpackUnlinked.value = false
},
@@ -455,6 +448,4 @@ watch(
},
{ immediate: true, flush: 'post' },
)
provideInstanceBackup(() => props.instance)
</script>
@@ -0,0 +1,19 @@
import { createContext } from '@modrinth/ui'
import type { ComputedRef, Ref } from 'vue'
import type { ShareRow } from './shared-instance-share-types'
export interface SharedInstanceManagementContext {
readonly rows: ComputedRef<ShareRow[]>
readonly actionsLocked: Ref<boolean>
readonly inviteDisabled: ComputedRef<boolean>
readonly invitePending: Ref<boolean>
readonly pushUpdateDisabled: ComputedRef<boolean>
readonly pushUpdatePending: ComputedRef<boolean>
invite: (event: MouseEvent) => void
remove: (row: ShareRow) => void
pushUpdate: (event: MouseEvent) => void
}
export const [injectSharedInstanceManagement, provideSharedInstanceManagement] =
createContext<SharedInstanceManagementContext>('InstanceSharePage')
@@ -15,7 +15,7 @@
<button
class="flex !h-10 shrink-0 items-center gap-2 !border"
:disabled="pushUpdateDisabled"
@click="emit('push-update', $event)"
@click="management.pushUpdate($event)"
>
<SpinnerIcon v-if="pushUpdatePending" class="animate-spin" aria-hidden="true" />
<UploadIcon v-else aria-hidden="true" />
@@ -26,7 +26,7 @@
<button
class="flex !h-10 shrink-0 items-center gap-2"
:disabled="invitePending || inviteDisabled"
@click="emit('invite', $event)"
@click="management.invite($event)"
>
<SpinnerIcon v-if="invitePending" class="animate-spin" aria-hidden="true" />
<UserPlusIcon v-else aria-hidden="true" />
@@ -125,7 +125,7 @@
v-tooltip="'Revoke access'"
:aria-label="`Revoke access for ${row.username}`"
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
@click="emit('remove', row)"
@click="management.remove(row)"
>
<XIcon aria-hidden="true" /></button
></ButtonStyled>
@@ -161,6 +161,7 @@ import {
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { injectSharedInstanceManagement } from './shared-instance-management-context'
import {
type MethodFilter,
methodLabels,
@@ -169,19 +170,15 @@ import {
type ShareTableColumn,
} from './shared-instance-share-types'
const props = defineProps<{
rows: ShareRow[]
actionsLocked?: boolean
inviteDisabled?: boolean
invitePending?: boolean
pushUpdateDisabled?: boolean
pushUpdatePending?: boolean
}>()
const emit = defineEmits<{
invite: [event: MouseEvent]
remove: [row: ShareRow]
'push-update': [event: MouseEvent]
}>()
const management = injectSharedInstanceManagement()
const {
rows,
actionsLocked,
inviteDisabled,
invitePending,
pushUpdateDisabled,
pushUpdatePending,
} = management
const search = ref('')
const methodFilter = ref<MethodFilter>('all')
const sortColumn = ref<string | undefined>('joined')
@@ -194,7 +191,7 @@ const methodFilterOptions: Array<{ id: ShareMethod; label: string }> = [
{ id: 'direct', label: methodLabels.direct },
{ id: 'link', label: methodLabels.link },
]
const hasMultipleMethods = computed(() => new Set(props.rows.map((row) => row.method)).size > 1)
const hasMultipleMethods = computed(() => new Set(rows.value.map((row) => row.method)).size > 1)
const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
const result: TableColumn<ShareTableColumn>[] = [
{
@@ -230,7 +227,7 @@ const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
cellClass: 'whitespace-nowrap !px-2',
},
]
if (!props.actionsLocked)
if (!actionsLocked.value)
result.push({
key: 'actions',
label: 'Actions',
@@ -243,7 +240,7 @@ const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
})
const filteredRows = computed(() => {
const query = search.value.trim().toLowerCase()
return props.rows.filter((row) => {
return rows.value.filter((row) => {
if (methodFilter.value !== 'all' && row.method !== methodFilter.value) return false
if (!query) return true
return [
@@ -12,13 +12,14 @@ import {
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { instanceKeys } from '../query-options'
import {
normalizeInviteKey,
SHARED_INSTANCE_USER_LIMIT,
type ShareRow,
} from './shared-instance-share-types'
type MembersQueryKey = readonly ['sharedInstanceUsers', string]
type MembersQueryKey = ReturnType<typeof instanceKeys.sharedMembers>
type OptimisticChange = {
queryKey: MembersQueryKey
@@ -48,7 +49,7 @@ export function useSharedInstanceMembers(options: {
onError: (error: unknown) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const)
const queryKey = computed(() => instanceKeys.sharedMembers(options.instance.value.id))
const invitingUserIds = new Set<string>()
const removingUserIds = new Set<string>()
const exclusiveMutationPending = ref(false)
@@ -0,0 +1,181 @@
import { createContext, injectAuth } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref, ref, watch } from 'vue'
import { useUserQuery } from '@/composables/users/use-user-query'
import {
getSharedInstanceUnavailableReason,
install_get_shared_instance_update_preview,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import { can_current_user_use_shared_instances } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { instanceKeys } from './query-options'
export type SharedInstanceManager =
| {
type: 'user'
name: string
avatarUrl?: string
tintBy: string
}
| {
type: 'server'
name: string
avatarUrl?: string
tintBy: string
}
export function createSharedInstanceContext(
instance: Ref<GameInstance | undefined>,
offline: Ref<boolean>,
notifyError: (error: unknown) => void,
) {
const auth = injectAuth()
const queryClient = useQueryClient()
const forcedUnavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
const wrongAccount = computed(() => {
if (auth.isReady && !auth.isReady.value) return false
if (!expectedUserId.value) return false
return auth.user.value?.id !== expectedUserId.value
})
const actionsLocked = computed(() => wrongAccount.value)
const signedOut = computed(() => !auth.session_token.value)
const managerUserId = computed(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.role === 'owner') {
return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
}
return attachment.manager_id ?? null
})
const managerUserQuery = useUserQuery(managerUserId)
const manager = computed<SharedInstanceManager | null>(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.server_manager_name) {
return {
type: 'server',
name: attachment.server_manager_name,
avatarUrl: attachment.server_manager_icon_url ?? undefined,
tintBy: attachment.server_manager_name,
}
}
const user = managerUserQuery.data.value
if (!user) return null
return {
type: 'user',
name: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const unavailableManager = computed(() => manager.value?.name ?? null)
const eligibilityQuery = useQuery({
queryKey: computed(() => instanceKeys.sharedEligibility(auth.user.value?.id)),
queryFn: can_current_user_use_shared_instances,
enabled: () => !!auth.session_token.value && !!auth.user.value?.id,
retry: false,
staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const currentUserCanUseSharedInstances = computed(
() => !auth.session_token.value || eligibilityQuery.data.value !== false,
)
const updatePreviewQuery = useQuery({
queryKey: computed(() =>
instanceKeys.sharedUpdatePreview(instance.value?.id ?? '', auth.user.value?.id),
),
queryFn: () => install_get_shared_instance_update_preview(instance.value!.id),
enabled: computed(
() =>
!!instance.value?.id &&
!!instance.value.shared_instance &&
!actionsLocked.value &&
!offline.value &&
(auth.isReady?.value ?? true) &&
!!auth.session_token.value &&
!!auth.user.value?.id,
),
retry: false,
staleTime: 30_000,
refetchOnWindowFocus: false,
})
watch(updatePreviewQuery.data, (preview) => {
if (preview !== undefined) forcedUnavailableReason.value = null
})
watch(updatePreviewQuery.error, (error) => {
if (!error) return
if (isSharedInstanceUnavailableError(error)) {
forcedUnavailableReason.value = getSharedInstanceUnavailableReason(error)
} else {
notifyError(error)
}
})
const unavailableReason = computed(() => forcedUnavailableReason.value)
const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
const updatePreview = computed(() =>
unavailableReason.value ? null : (updatePreviewQuery.data.value ?? null),
)
watch(
() => instance.value?.id,
() => {
forcedUnavailableReason.value = null
},
)
async function refreshAvailability() {
forcedUnavailableReason.value = null
if (!instance.value?.id) return
await queryClient.invalidateQueries({
queryKey: instanceKeys.sharedUpdatePreview(instance.value.id, auth.user.value?.id),
})
}
async function refreshUpdatePreview() {
forcedUnavailableReason.value = null
if (!instance.value?.id || !auth.user.value?.id) return null
const result = await updatePreviewQuery.refetch({ throwOnError: true })
return result.data ?? null
}
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
forcedUnavailableReason.value = reason
}
return {
actionsLocked,
shareActionsLocked,
unavailableReason,
unavailableManager,
manager,
updatePreview,
expectedUserId,
wrongAccount,
signedOut,
eligibilityQuery,
currentUserCanUseSharedInstances,
refreshAvailability,
refreshUpdatePreview,
setUnavailable,
}
}
export type SharedInstanceContext = ReturnType<typeof createSharedInstanceContext>
export const [injectSharedInstance, provideSharedInstance] = createContext<SharedInstanceContext>(
'InstancePage',
'sharedInstance',
)
@@ -1,228 +0,0 @@
import { injectAuth } from '@modrinth/ui'
import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue'
import { useUserQuery } from '@/composables/users/use-user-query'
import {
getSharedInstanceUnavailableReason,
install_get_shared_instance_update_preview,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import type { GameInstance } from '@/helpers/types'
export type SharedInstanceManager =
| {
type: 'user'
name: string
avatarUrl?: string
tintBy: string
}
| {
type: 'server'
name: string
avatarUrl?: string
tintBy: string
}
export function useSharedInstanceState(
instance: Ref<GameInstance | undefined>,
offline: Ref<boolean>,
notifyError: (error: unknown) => void,
) {
const auth = injectAuth()
const updatePreview =
ref<Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>>(null)
const updatePreviewLoaded = ref(false)
const unavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
const availabilityCheckKey = ref<string | null>(null)
const availabilityRefresh = ref(0)
let availabilityRequestId = 0
let availabilityRequest: {
key: string
promise: Promise<{
preview: Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>
error: unknown | null
}>
} | null = null
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
const wrongAccount = computed(() => {
if (auth.isReady && !auth.isReady.value) return false
if (!expectedUserId.value) return false
return auth.user.value?.id !== expectedUserId.value
})
const actionsLocked = computed(() => wrongAccount.value)
const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
const signedOut = computed(() => !auth.session_token.value)
const managerUserId = computed(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.role === 'owner') {
return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
}
return attachment.manager_id ?? null
})
const managerUserQuery = useUserQuery(managerUserId)
const manager = computed<SharedInstanceManager | null>(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.server_manager_name) {
return {
type: 'server',
name: attachment.server_manager_name,
avatarUrl: attachment.server_manager_icon_url ?? undefined,
tintBy: attachment.server_manager_name,
}
}
const user = managerUserQuery.data.value
if (!user) return null
return {
type: 'user',
name: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const unavailableManager = computed(() => manager.value?.name ?? null)
function reset() {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
unavailableReason.value = null
}
function refreshAvailability() {
availabilityCheckKey.value = null
updatePreviewLoaded.value = false
availabilityRefresh.value++
}
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
unavailableReason.value = reason
}
async function checkAvailability(instanceId: string, key: string, throwError = false) {
const requestId = ++availabilityRequestId
let request = availabilityRequest
if (!request || request.key !== key) {
const promise = install_get_shared_instance_update_preview(instanceId).then(
(preview) => ({ preview, error: null }),
(error: unknown) => ({ preview: null, error }),
)
request = { key, promise }
availabilityRequest = request
void promise.finally(() => {
if (availabilityRequest?.promise === promise) availabilityRequest = null
})
}
const result = await request.promise
if (!isCurrentRequest(requestId, instanceId, key)) return null
if (result.error !== null) {
updatePreviewLoaded.value = false
if (isSharedInstanceUnavailableError(result.error)) {
updatePreview.value = null
unavailableReason.value = getSharedInstanceUnavailableReason(result.error)
} else if (!throwError) {
notifyError(result.error)
}
if (throwError) throw result.error
return null
}
updatePreview.value = result.preview
updatePreviewLoaded.value = true
unavailableReason.value = null
return result.preview
}
async function refreshUpdatePreview() {
const instanceId = instance.value?.id
const userId = auth.user.value?.id
if (!instanceId || !userId) return null
const key = `${instanceId}:${userId}`
availabilityCheckKey.value = key
return await checkAvailability(instanceId, key, true)
}
function isCurrentRequest(requestId: number, instanceId: string, key: string) {
return (
requestId === availabilityRequestId &&
instance.value?.id === instanceId &&
availabilityCheckKey.value === key
)
}
watch(
() => ({
refresh: availabilityRefresh.value,
instanceId: instance.value?.id,
role: instance.value?.shared_instance?.role,
locked: actionsLocked.value,
offline: offline.value,
signedIn: !!auth.session_token.value,
userId: auth.user.value?.id ?? null,
authReady: auth.isReady?.value ?? true,
}),
async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => {
if (!instanceId || !role || locked || offline || !authReady || !signedIn || !userId) {
availabilityRequestId++
availabilityRequest = null
availabilityCheckKey.value = null
updatePreview.value = null
updatePreviewLoaded.value = false
if (instanceId && role) unavailableReason.value = null
return
}
const key = `${instanceId}:${userId}`
if (availabilityCheckKey.value === key) return
availabilityCheckKey.value = key
await checkAvailability(instanceId, key)
},
{ immediate: true },
)
return {
actionsLocked,
shareActionsLocked,
unavailableReason,
unavailableManager,
manager,
updatePreview,
expectedUserId,
wrongAccount,
signedOut,
reset,
refreshAvailability,
refreshUpdatePreview,
setUnavailable,
}
}
export type SharedInstanceState = ReturnType<typeof useSharedInstanceState>
const sharedInstanceStateKey: InjectionKey<SharedInstanceState> = Symbol('shared-instance-state')
export function provideSharedInstanceState(state: SharedInstanceState) {
provide(sharedInstanceStateKey, state)
}
export function injectSharedInstanceState() {
const state = inject(sharedInstanceStateKey)
if (!state) throw new Error('Shared instance state has not been provided.')
return state
}
@@ -42,12 +42,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
"
>
<button class="!h-10 flex items-center gap-2" @click="instancePage.browseServers">
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseServers) }}</span>
</button>
@@ -105,7 +100,7 @@
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
:shortcut-instance-id="instance.id"
@play="() => joinWorld(world)"
@stop="() => emit('stop')"
@stop="() => instancePage.stop('InstanceWorlds')"
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
@@ -134,12 +129,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="!h-10 flex items-center gap-2"
@click="
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
"
>
<button class="!h-10 flex items-center gap-2" @click="instancePage.browseServers">
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseServers) }}</span>
</button>
@@ -166,9 +156,8 @@ import {
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { platform } from '@tauri-apps/plugin-os'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWorldModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
@@ -178,7 +167,6 @@ import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3 } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
import {
delete_world,
@@ -194,7 +182,6 @@ import {
refreshServerData,
refreshServers,
refreshWorld,
refreshWorlds,
remove_server_from_instance,
resolveManagedServerWorld,
type ServerData,
@@ -209,6 +196,9 @@ import {
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { injectInstancePage } from '../instance-context'
import { instanceKeys, instanceWorldsQueryOptions } from '../query-options'
const messages = defineMessages({
searchWorldsPlaceholder: {
id: 'app.instance.worlds.search-worlds-placeholder',
@@ -256,7 +246,7 @@ const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const route = useRoute()
const router = useRouter()
const instancePage = injectInstancePage()
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
@@ -265,25 +255,12 @@ const removeWorldModal = ref<InstanceType<typeof ConfirmRemoveWorldModal>>()
const worldToRemove = ref<World | null>(null)
const emit = defineEmits<{
(event: 'play', world: World): void
(event: 'stop'): void
}>()
const instance = computed(() => instancePage.instance.value!)
const playing = instancePage.playing
const props = defineProps<{
instance: GameInstance
options: InstanceType<typeof ContextMenu> | null
offline: boolean
playing: boolean
installed: boolean
}>()
const instance = computed(() => props.instance)
const playing = computed(() => props.playing)
function play(world: World) {
if (props.instance.quarantined) return
emit('play', world)
function play() {
if (instance.value.quarantined) return
void instancePage.refreshPlayState()
}
const selectedFilters = ref<string[]>([])
@@ -319,11 +296,12 @@ const hadNoWorlds = ref(true)
const startingInstance = ref(false)
const worldPlaying = ref<World>()
const worldsQuery = useQuery({
queryKey: computed(() => ['worlds', instance.value.id]),
queryFn: () => refreshWorlds(instance.value.id),
staleTime: 30_000,
})
const worldsQuery = useQuery(
computed(() => ({
...instanceWorldsQueryOptions(instancePage.instanceId.value),
enabled: !!instancePage.instanceId.value,
})),
)
const worldsReadyPending = useReadyState(worldsQuery)
@@ -497,7 +475,7 @@ async function refreshAllWorlds() {
}
}
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] })
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.value.id) })
await refreshServers(
worlds.value,
serverData.value,
@@ -592,7 +570,7 @@ async function joinWorld(world: World) {
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
}
play(world)
play()
startingInstance.value = false
}