feat: instance sharing thru shared-instances service (#6569)

* feat: implement instance share page + search_users backend call

* feat: invite players modal

* feat: use tanstack queries for friends sync across app pages

* feat: base shared instances implementation

* fix: admon style

* feat: impl instance admonitions like server panel

* fix: impl get + del usage

* feat: support modpack links

* feat: invite notif accepting

* fix: lint + fmt

* feat: impl install to play

* feat: impl usage of UpdateToPlayModal

* feat: warnings on deleting/disabling shared-instance version content

* fix: send instance name

* feat: align with backend

* feat: shared instances qa

* feat: wrong account protection

* feat: qa

* fix: smartly apply updates

* fix: install bug

* fix: 401/404 differentiation

* fix: fmt+prepr

* feat: qa

* feat: qa

* fix: signing out messes up revoke/deleted checks

* feat: qa

* fix: fmt + lint

* feat: lock content if part of shared instance

* fix: lint

* [do not merge] feat: rough invite links impl temp (#6666)

* fix: wrong cmd

* feat: invite page

* fix: server-manager DTO mismatch

* fix: drop anonymous invite link acceptance

* refactor: structured shared-instance unavailable errors

* refactor: centralise error presentations

* refactor: dedupe shared instance diff detection

* fix: logging in reqwests

* refactor: move app.vue shared instances into handler

* refactor: break up Share.vue

* refactor: split up shared instances state outside of instance index

* refactor: dedicated shared instances install/update modals + split up page

* refactor: centralized managed content

* refactor: split up install shared to own runner + shared.rs split up

* refactor: dedupe sql for instance metadata enrichmnt

* refactor: friends composable + dedupe friends logic across usages

* chore: reduced unused code

* fix: align with backend

* fix: lint

* fix: file sha changes

* fix: invite links not working due to icon signed

* feat: qa

* feat: reporting frontend dummy

* fix: try use header

* remove: file hash field

* fix: pin box

* feat: malware warning for shared instances

* fix: cache rule

* feat: config files syncing

* feat: disable config sharing

* fix: header

* fix: use mark ready

* fix: dont cause push update for configs

* fix: lint

* feat: sharing page in settings

* feat: move config + change flow

* fix: qa

* fix: lint prepr

* feat: proxy file upload thru shared instances backend

* fix: use collapisible

* fix: push config

* fix: config

* feat: swap out sign in modal for new one

* fix: report flow

* fix: exclude configs.zip from external warnings

* fix: nuxi init

* fix: config bundle downloading

* fix: error notif

* fix: polling

* fix: qa

* fix: lint + prepr

* feat: shared instances moderation frontend + hook up report flow

* fix: report copy

* fix: lint

* fix: lint

* fix: modrinth ids being undefined

* feat: instance quarantining

* fix: prepr + fmt

* fix: quarantined -> locked terminology

* fix: missing endpoint impls + fmt

* fix: missing api in build.rs

* fix: share tab jittery

* fix: fmt

*PT bug

* fix: invites count as users even if pending

* fix: prepr

* fix: invite page owner in users list

* fix: lint

* fix: qa

* fix: lint

* fix: members stale not clearing

* fix: invite use joined_at field

* fix: lint

* fix: qa

---------

Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
+280 -51
View File
@@ -4,7 +4,8 @@
:class="['p-6 pr-2 pb-4', { 'shrink-0': isFixedRender }]"
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
>
<ExportModal ref="exportModal" :instance="instance" />
<ExportModal v-if="!instance.quarantined" ref="exportModal" :instance="instance" />
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="deleteSelectedInstance" />
<InstanceSettingsModal
:key="instance.id"
ref="settingsModal"
@@ -13,6 +14,15 @@
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<SharedInstanceUpdateModal
ref="sharedInstanceUpdateModal"
@shared-instance-unavailable="handleSharedInstanceUnavailable"
@report="(event) => reportSharedInstance(event, true)"
/>
<SharedInstanceInstallModal
ref="sharedInstanceReportModal"
@reported="handleSharedInstanceReported"
/>
<InstancePageHeader
:instance="instance"
:icon-src="icon"
@@ -29,18 +39,32 @@
:ping="ping"
:minecraft-server="minecraftServer"
:linked-project-v3="linkedProjectV3"
:shared-instance-manager="sharedInstanceManager"
@repair="() => repairInstance()"
@stop="() => stopInstance('InstancePage')"
@play="() => startInstance('InstancePage')"
@play-server="() => handlePlayServer()"
@settings="() => settingsModal?.show()"
@open-folder="() => instance && showInstanceInFolder(instance.id)"
@export="() => exportModal?.show()"
@export="() => !instance.quarantined && exportModal?.show()"
@create-shortcut="() => createShortcut()"
@report="reportSharedInstance"
/>
</div>
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
<NavTabs :links="tabs" />
<InstanceAdmonitions
class="mt-4"
:instance="instance"
:shared-instance-unavailable-reason="sharedInstanceUnavailableReason"
:shared-instance-unavailable-manager="sharedInstanceUnavailableManager"
:shared-instance-wrong-account="sharedInstanceWrongAccount"
:shared-instance-expected-user-id="sharedInstanceExpectedUserId"
:shared-instance-role="instance.shared_instance?.role"
:shared-instance-signed-out="sharedInstanceSignedOut"
@published="fetchInstance"
@delete="requestInstanceDeletion"
/>
</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">
@@ -107,10 +131,11 @@ import {
StopCircleIcon,
TerminalSquareIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
@@ -119,9 +144,13 @@ import { 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'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
@@ -130,10 +159,25 @@ 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 { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { get, get_full_path, kill, run } from '@/helpers/instance'
import {
getSharedInstanceUnavailableReason,
install_existing_instance,
install_get_shared_instance_preview,
install_pack_to_existing_instance,
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 { 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'
@@ -141,10 +185,13 @@ import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
const { playServerProject } = injectServerInstall()
const auth = injectAuth()
const queryClient = useQueryClient()
const route = useRoute()
@@ -167,17 +214,23 @@ const instance = ref<GameInstance>()
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
const checkingSharedInstanceLaunch = ref(false)
const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
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 selectedInstanceToDelete = ref<GameInstance | null>(null)
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
useLoadingBarToken(subpagePending)
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)
@@ -189,11 +242,27 @@ 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 {
actionsLocked: sharedInstanceActionsLocked,
expectedUserId: sharedInstanceExpectedUserId,
manager: sharedInstanceManager,
refreshUpdatePreview: refreshSharedInstanceUpdatePreview,
setUnavailable: setSharedInstanceUnavailable,
signedOut: sharedInstanceSignedOut,
unavailableManager: sharedInstanceUnavailableManager,
unavailableReason: sharedInstanceUnavailableReason,
wrongAccount: sharedInstanceWrongAccount,
} = sharedInstanceState
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/instance')) {
if (
nextRoute.path.startsWith('/instance') &&
(!instance.value || nextRoute.params.id === instance.value.id)
) {
displayedInstanceRoute.value = nextRoute
}
},
@@ -219,17 +288,15 @@ function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
}
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
resetServerStatus()
const requestedInstanceId = route.params.id as string
const requestedRouteName = route.name
const nextInstance = await get(route.params.id as string).catch(handleError)
const nextInstance = await get(requestedInstanceId).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
let nextIsServerInstance = false
const contentPreloadPromise =
nextInstance && isContentSubpageRoute()
nextInstance && isContentSubpageRoute(requestedRouteName)
? loadInstanceContentData(nextInstance.id, undefined, handleError)
: Promise.resolve(null)
@@ -245,13 +312,25 @@ async function fetchInstance() {
}
}
const nextPreloadedContent = await contentPreloadPromise
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
displayedInstanceRoute.value = nextRoute
sharedInstanceState.reset()
sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
resetServerStatus()
fetchDeferredData(nextInstance?.id)
@@ -335,29 +414,78 @@ 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 showShareTab = computed(() => {
const linkType = instance.value?.link?.type
const tabs = computed(() => [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
return (
currentUserCanUseSharedInstances.value &&
!instance.value?.quarantined &&
instance.value?.shared_instance?.role !== 'member' &&
linkType !== 'server_project' &&
linkType !== 'server_project_modpack'
)
})
const tabs = computed(() => {
const instanceTabs = [
{
label: 'Content',
href: `${basePath.value}`,
icon: BoxesIcon,
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
]
if (showShareTab.value) {
instanceTabs.push({
label: 'Share',
href: `${basePath.value}/share`,
icon: UserPlusIcon,
})
}
return instanceTabs
})
watch(
() => ({
quarantined: instance.value?.quarantined ?? false,
routeName: router.currentRoute.value.name,
}),
({ quarantined, routeName }) => {
if (quarantined && routeName === 'InstanceShare') {
void router.replace(basePath.value)
}
},
{
label: 'Files',
href: `${basePath.value}/files`,
icon: FolderOpenIcon,
},
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
icon: GlobeIcon,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
icon: TerminalSquareIcon,
},
])
{ immediate: true },
)
if (instance.value) {
breadcrumbs.setName(
@@ -375,13 +503,8 @@ if (instance.value) {
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const startInstance = async (context: string) => {
if (!instance.value) return
if (updateToPlayModal.value?.hasUpdate) {
updateToPlayModal.value.show(instance.value)
return
}
const launchInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
loading.value = true
try {
await run(route.params.id as string)
@@ -391,6 +514,7 @@ const startInstance = async (context: string) => {
}
loading.value = false
if (!instance.value) return
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -398,6 +522,65 @@ const startInstance = async (context: string) => {
})
}
async function handleSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
) {
notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
await fetchInstance()
setSharedInstanceUnavailable(reason)
}
const startInstance = async (context: string) => {
if (!instance.value || instance.value.quarantined) return
if (checkingSharedInstanceLaunch.value || loading.value || playing.value) return
const instanceId = instance.value.id
const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
const canCheckSharedInstanceUpdate =
!!instance.value.shared_instance && !sharedInstanceActionsLocked.value && !offline.value
if (canCheckSharedInstanceUpdate) {
let preview: Awaited<ReturnType<typeof refreshSharedInstanceUpdatePreview>>
checkingSharedInstanceLaunch.value = true
try {
preview = await refreshSharedInstanceUpdatePreview()
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
await handleSharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
} else {
notifySharedInstanceError(error)
}
return
} finally {
checkingSharedInstanceLaunch.value = false
}
if (instance.value?.id !== instanceId) return
if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
await fetchInstance()
await launchInstance(context)
})
return
}
}
if (updateToPlayModal.value?.hasUpdate) {
if (isSharedInstanceMember) {
updateToPlayModal.value.show(instance.value, null, async () => {
await fetchInstance()
await launchInstance(context)
})
} else {
updateToPlayModal.value.show(instance.value)
}
return
}
await launchInstance(context)
}
const stopInstance = async (context: string) => {
stopping.value = true
await kill(route.params.id as string).catch(handleError)
@@ -413,7 +596,7 @@ const stopInstance = async (context: string) => {
}
const handlePlayServer = async () => {
if (!instance.value?.link?.project_id) return
if (!instance.value?.link?.project_id || instance.value.quarantined) return
loading.value = true
try {
await playServerProject(instance.value.link.project_id)
@@ -424,6 +607,7 @@ const handlePlayServer = async () => {
}
const repairInstance = async () => {
if (instance.value.quarantined) return
if (
instance.value.install_stage !== 'pack_installed' &&
(instance.value.link?.type === 'modrinth_modpack' ||
@@ -441,7 +625,7 @@ const repairInstance = async () => {
}
const createShortcut = async () => {
if (!instance.value) return
if (!instance.value || instance.value.quarantined) return
try {
const shortcutPath = await createInstanceShortcut(instance.value.name, instance.value.id)
if (!shortcutPath) return
@@ -459,10 +643,51 @@ const createShortcut = async () => {
}
}
async function reportSharedInstance(event?: MouseEvent, closeUpdateModal = false) {
const reportInstance = instance.value
const sharedInstance = reportInstance?.shared_instance
if (!reportInstance || sharedInstance?.role !== 'member') return
try {
const preview = await install_get_shared_instance_preview(
sharedInstance.id,
reportInstance.name,
)
if (instance.value?.id !== reportInstance.id) return
if (closeUpdateModal) sharedInstanceUpdateModal.value?.hide()
sharedInstanceReportModal.value?.showReport(preview, event)
} catch (error) {
handleError(error as Error)
}
}
function handleSharedInstanceReported(deleteInstance: boolean) {
if (!deleteInstance || !instance.value) return
requestInstanceDeletion()
}
function requestInstanceDeletion() {
if (!instance.value) return
selectedInstanceToDelete.value = instance.value
deleteConfirmModal.value?.show()
}
async function deleteSelectedInstance() {
const selectedInstance = selectedInstanceToDelete.value
selectedInstanceToDelete.value = null
if (!selectedInstance) return
trackEvent('InstanceRemove', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
})
await router.push({ path: '/' })
await remove(selectedInstance.id).catch(handleError)
}
const handleRightClick = (event: MouseEvent) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
...(instance.value?.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
{ name: 'edit' },
{ name: 'open_folder' },
{ name: 'copy_path' },
@@ -480,10 +705,14 @@ const handleRightClick = (event: MouseEvent) => {
...baseOptions,
]
: [
{
name: 'play',
color: 'primary',
},
...(instance.value?.quarantined
? []
: [
{
name: 'play',
color: 'primary',
},
]),
...baseOptions,
],
)