Compare commits

...
54 Commits
Author SHA1 Message Date
Calum H. (IMB11) 6f9e9e0de7 feat: server play frontend draft 2026-07-13 14:30:42 +01:00
Calum H. (IMB11) ad969a346a fix: align with backend 2026-07-10 22:08:00 +01:00
Calum H. (IMB11) 877afa7278 chore: reduced unused code 2026-07-10 17:00:20 +01:00
Calum H. (IMB11) fe6772874a refactor: friends composable + dedupe friends logic across usages 2026-07-10 16:56:35 +01:00
Calum H. (IMB11) 93a1fc29f0 refactor: dedupe sql for instance metadata enrichmnt 2026-07-10 16:42:34 +01:00
Calum H. (IMB11) 47cd7508cf refactor: split up install shared to own runner + shared.rs split up 2026-07-10 16:27:53 +01:00
Calum H. (IMB11) 63a60b292c refactor: centralized managed content 2026-07-10 16:17:56 +01:00
Calum H. (IMB11) 913dde963b refactor: dedicated shared instances install/update modals + split up page 2026-07-10 15:58:24 +01:00
Calum H. (IMB11) b50d1d07de refactor: split up shared instances state outside of instance index 2026-07-10 15:44:38 +01:00
Calum H. (IMB11) d1ca6f4933 refactor: break up Share.vue 2026-07-10 15:36:15 +01:00
Calum H. (IMB11) 13588fc33d refactor: move app.vue shared instances into handler 2026-07-10 15:26:37 +01:00
Calum H. (IMB11) d8633df1b3 fix: logging in reqwests 2026-07-10 15:02:40 +01:00
Calum H. (IMB11) 9bdb1ef5bd refactor: dedupe shared instance diff detection 2026-07-10 14:11:39 +01:00
Calum H. (IMB11) 70cc6c3a62 refactor: centralise error presentations 2026-07-10 13:58:13 +01:00
Calum H. (IMB11) e8ea8729f3 Merge remote-tracking branch 'origin/main' into cal/peer-to-peer
# Conflicts:
#	apps/app-frontend/src/helpers/install.ts
#	packages/app-lib/src/install/mod.rs
#	packages/app-lib/src/install/model.rs
#	packages/app-lib/src/install/recovery.rs
#	packages/app-lib/src/install/runner.rs
#	packages/ui/src/components/notifications/NotificationToast.vue
2026-07-10 13:45:14 +01:00
Calum H. (IMB11) ed87b627cf refactor: structured shared-instance unavailable errors 2026-07-10 13:41:55 +01:00
Calum H. (IMB11) dcfa5725ba fix: drop anonymous invite link acceptance 2026-07-10 13:29:09 +01:00
Calum H. (IMB11) 157208768c fix: server-manager DTO mismatch 2026-07-10 13:19:03 +01:00
Calum H. (IMB11) de1bc58b01 feat: invite page 2026-07-10 13:11:13 +01:00
Calum H. (IMB11) adc1094cd5 fix: wrong cmd 2026-07-09 22:13:26 +01:00
Calum H.andGitHub 6da553bc48 [do not merge] feat: rough invite links impl temp (#6666) 2026-07-09 21:52:25 +01:00
Calum H. (IMB11) 1067894a82 fix: lint 2026-07-08 18:19:54 +01:00
Calum H. (IMB11) dbea158e16 feat: lock content if part of shared instance 2026-07-08 18:19:42 +01:00
Calum H. (IMB11) b945871320 Merge remote-tracking branch 'origin/main' into cal/peer-to-peer 2026-07-08 17:52:50 +01:00
Calum H. (IMB11) 5511662a94 fix: fmt + lint 2026-07-08 17:48:38 +01:00
Calum H. (IMB11) a562bf9e8d feat: qa 2026-07-08 16:53:07 +01:00
Calum H. (IMB11) a1876eb3ec fix: signing out messes up revoke/deleted checks 2026-07-08 16:25:36 +01:00
Calum H. (IMB11) 17852f959b feat: qa 2026-07-08 16:15:29 +01:00
Calum H. (IMB11) 20ed8e22cf feat: qa 2026-07-08 12:10:07 +01:00
Calum H. (IMB11) a34f5b18c9 fix: fmt+prepr 2026-07-07 16:19:17 +01:00
Calum H. (IMB11) 92f8eb40d5 fix: 401/404 differentiation 2026-07-07 16:12:20 +01:00
Calum H. (IMB11) 38f02ea9a0 fix: install bug 2026-07-07 15:22:16 +01:00
Calum H. (IMB11) 44ce6bfc33 fix: smartly apply updates 2026-07-07 14:33:36 +01:00
Calum H. (IMB11) a1ea6903f5 feat: qa 2026-07-07 14:15:05 +01:00
Calum H. (IMB11) d88d555329 feat: wrong account protection 2026-07-07 13:43:45 +01:00
Calum H. (IMB11) 0723ef0aa5 feat: shared instances qa 2026-07-07 12:40:36 +01:00
Calum H. (IMB11) 5b86bb8c4a Merge remote-tracking branch 'origin/main' into cal/peer-to-peer 2026-07-07 09:33:37 +01:00
Calum H. (IMB11) 4f5f3bf1b1 feat: align with backend 2026-07-06 08:58:02 +01:00
Calum H. (IMB11) 2dbc982175 Merge remote-tracking branch 'origin' into cal/peer-to-peer 2026-07-03 17:29:27 +01:00
Calum H. (IMB11) 50e776ba4b fix: send instance name 2026-07-03 17:29:20 +01:00
Calum H. (IMB11) 1286480a8a Merge remote-tracking branch 'origin/main' into cal/peer-to-peer 2026-07-02 17:49:34 +01:00
Calum H. (IMB11) 70f08275af feat: warnings on deleting/disabling shared-instance version content 2026-07-02 14:23:32 +01:00
Calum H. (IMB11) 2da493e641 feat: impl usage of UpdateToPlayModal 2026-07-02 10:30:05 +01:00
Calum H. (IMB11) 2351195d70 feat: impl install to play 2026-07-02 09:56:17 +01:00
Calum H. (IMB11) 7726b2a5a3 fix: lint + fmt 2026-07-02 09:45:25 +01:00
Calum H. (IMB11) fa286a7174 feat: invite notif accepting 2026-07-02 09:36:44 +01:00
Calum H. (IMB11) d6bd6a2c60 feat: support modpack links 2026-07-02 09:09:01 +01:00
Calum H. (IMB11) 60fa3b7609 fix: impl get + del usage 2026-07-01 19:54:33 +01:00
Calum H. (IMB11) 52c38f7506 feat: impl instance admonitions like server panel 2026-06-30 16:39:52 +01:00
Calum H. (IMB11) 4296e3b51f fix: admon style 2026-06-30 16:22:06 +01:00
Calum H. (IMB11) 23ce9bc129 feat: base shared instances implementation 2026-06-30 16:11:40 +01:00
Calum H. (IMB11) e9c141298d feat: use tanstack queries for friends sync across app pages 2026-06-30 14:01:37 +01:00
Calum H. (IMB11) 7c7f3584ac feat: invite players modal 2026-06-30 12:54:42 +01:00
Calum H. (IMB11) 0ec70d29b9 feat: implement instance share page + search_users backend call 2026-06-30 12:39:45 +01:00
206 changed files with 14439 additions and 2187 deletions
+59 -25
View File
@@ -16,6 +16,7 @@ import {
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LinkIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
@@ -85,6 +86,7 @@ import NavButton from '@/components/ui/NavButton.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
@@ -147,6 +149,7 @@ const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
@@ -637,6 +640,7 @@ const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
@@ -647,8 +651,8 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
setupAuthProvider(credentials, async (_redirectPath, flow) => {
await signIn(flow)
})
async function validateSession(sessionToken) {
@@ -665,23 +669,33 @@ async function validateSession(sessionToken) {
}
async function fetchCredentials() {
const refreshId = ++credentialsRefreshId
credentials.value = undefined
const creds = await getCreds().catch(handleError)
if (refreshId !== credentialsRefreshId) return
if (creds && creds.user_id) {
if (creds.session && !(await validateSession(creds.session))) {
if (refreshId !== credentialsRefreshId) return
await logout().catch(handleError)
if (refreshId !== credentialsRefreshId) return
credentials.value = null
return
}
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
if (refreshId !== credentialsRefreshId) return
}
credentials.value = creds ?? null
}
async function signIn() {
async function signIn(flow = 'sign-in') {
modrinthLoginFlowWaitModal.value.show()
try {
await login()
await login(flow)
await fetchCredentials()
} catch (error) {
if (
@@ -699,6 +713,13 @@ async function signIn() {
}
async function logOut() {
await performLogOut()
}
async function performLogOut() {
credentialsRefreshId++
credentials.value = undefined
await logout().catch(handleError)
await fetchCredentials()
}
@@ -821,30 +842,34 @@ function openServerInviteInviterProfile(inviterName) {
}
async function handleLiveNotification(notification) {
if (notification?.body?.type !== 'server_invite' || notification.read) return
if (displayedServerInviteNotifications.has(notification.id)) return
if (!notification?.body || notification.read) return
if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) return
displayedServerInviteNotifications.add(notification.id)
if (notification.body.type === 'server_invite') {
if (displayedServerInviteNotifications.has(notification.id)) return
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
displayedServerInviteNotifications.add(notification.id)
addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
addPopupNotification({
title: serverName,
autoCloseMs: null,
toast: {
type: 'server-invite',
actorName: invitedBy?.username ?? null,
actorAvatarUrl: invitedBy?.avatar_url ?? null,
entityName: serverName,
onAccept: () => acceptServerInviteNotification(notification),
onDecline: () => declineServerInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(invitedBy?.username ?? null),
},
})
}
}
async function handleCommand(e) {
@@ -877,6 +902,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
} else if (e.event === 'InstallSharedInstanceInvite') {
await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
@@ -1467,6 +1494,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<PlusIcon />
</NavButton>
<NavButton
v-tooltip.right="'Install from invite link'"
:to="(event) => sharedInstanceInviteHandler?.showManualInviteLinkModal(event)"
>
<LinkIcon />
</NavButton>
<div class="flex flex-grow"></div>
<NavButton
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
@@ -1736,6 +1769,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
/>
<SharedInstanceInviteHandler ref="sharedInstanceInviteHandler" />
<InstallToPlayModal ref="installToPlayModal" />
<UpdateToPlayModal ref="updateToPlayModal" />
</template>
@@ -142,7 +142,9 @@ const unlisten = await process_listener((e) => {
}
})
onMounted(() => checkProcess())
onMounted(() => {
checkProcess()
})
onUnmounted(() => unlisten())
</script>
@@ -10,18 +10,12 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { friend_listener } from '@/helpers/events'
import {
add_friend,
friends,
type FriendWithUserData,
remove_friend,
transformFriends,
} from '@/helpers/friends.ts'
import { useFriends } from '@/composables/use-friends'
import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
@@ -35,36 +29,23 @@ const props = defineProps<{
}>()
const userCredentials = computed(() => props.credentials)
const {
friends: userFriends,
loading,
requestFriend,
acceptFriend,
removeFriend: removeFriendRecord,
} = useFriends({
currentUserId: () => userCredentials.value?.user_id,
getCredentials: () => userCredentials.value,
onError: handleError,
})
const search = ref('')
const friendInvitesModal = ref()
const username = ref('')
const addFriendModal = ref()
async function addFriendFromModal() {
addFriendModal.value.hide()
await add_friend(username.value).catch(handleError)
username.value = ''
await loadFriends()
}
async function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await add_friend(id).catch(handleError)
await loadFriends()
}
}
async function removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await remove_friend(id).catch(handleError)
await loadFriends()
}
}
const userFriends = ref<FriendWithUserData[]>([])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
@@ -108,42 +89,22 @@ const incomingRequests = computed(() =>
.sort((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
async function loadFriends(timeout = false) {
loading.value = timeout
function addFriendFromModal() {
const target = username.value.trim()
if (!target) return
try {
const friendsList = await friends()
userFriends.value = await transformFriends(friendsList, userCredentials.value)
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000)
}
}
addFriendModal.value.hide()
requestFriend({ id: target, username: target })
username.value = ''
}
watch(
userCredentials,
() => {
if (userCredentials.value === undefined) {
userFriends.value = []
loading.value = false
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
} else {
loadFriends(true)
}
},
{ immediate: true },
)
function addFriend(friend: FriendWithUserData) {
acceptFriend(friend)
}
const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
function removeFriend(friend: FriendWithUserData) {
removeFriendRecord(friend)
}
const messages = defineMessages({
addFriend: {
@@ -0,0 +1,105 @@
<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')"
/>
<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="sharedInstanceUnavailableReason"
:manager="sharedInstanceUnavailableManager"
:dismissible="dismissible"
@dismiss="sharedInstanceUnavailableDismissed = true"
/>
</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 './instance-admonitions-shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<{
instance: GameInstance
sharedInstanceUnavailableReason?: SharedInstanceUnavailableReason | null
sharedInstanceUnavailableManager?: string | null
sharedInstanceWrongAccount?: boolean
sharedInstanceExpectedUserId?: string | null
sharedInstanceRole?: SharedInstanceRole | null
sharedInstanceSignedOut?: boolean
}>()
const emit = defineEmits<{
published: []
}>()
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
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 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',
})
}
if (props.sharedInstanceUnavailableReason && !sharedInstanceUnavailableDismissed.value) {
items.push({
id: 'shared-instance-unavailable',
type: 'warning',
dismissible: true,
kind: 'shared-instance-unavailable',
})
}
if (showSharedInstancePublishAdmonition.value) {
items.push({
id: 'shared-instance-stale',
type: 'warning',
dismissible: false,
kind: 'shared-instance-stale',
})
}
return items
})
watch(
() => [props.instance.id, props.sharedInstanceUnavailableReason],
() => {
sharedInstanceUnavailableDismissed.value = false
},
)
</script>
@@ -0,0 +1,69 @@
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...',
},
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,133 @@
<template>
<Admonition
type="warning"
inline-actions
:header="formatMessage(messages.sharedInstanceChangesHeader)"
>
{{ formatMessage(messages.sharedInstanceChangesBody) }}
<template #actions>
<ButtonStyled color="orange">
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
<UploadIcon aria-hidden="true" />
{{
isPublishing
? formatMessage(messages.sharedInstancePublishingButton)
: isReviewingPublish
? formatMessage(messages.sharedInstanceReviewingButton)
: formatMessage(messages.sharedInstancePublishButton)
}}
</button>
</ButtonStyled>
</template>
</Admonition>
<ContentDiffModal
ref="publishReviewModal"
:header="formatMessage(messages.sharedInstanceReviewHeader)"
:admonition-header="formatMessage(messages.sharedInstanceReviewAdmonitionHeader)"
:description="formatMessage(messages.sharedInstanceReviewDescription)"
:diffs="publishDiffs"
:confirm-label="formatMessage(messages.sharedInstancePublishButton)"
:confirm-icon="UploadIcon"
:added-label="formatMessage(messages.sharedInstanceAddedLabel)"
:removed-label="formatMessage(messages.sharedInstanceRemovedLabel)"
@confirm="publishChanges"
/>
</template>
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
type ContentDiffItem,
ContentDiffModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { get_shared_instance_publish_preview, publish_shared_instance } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
const props = defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
}>()
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const isPublishing = ref(false)
const isReviewingPublish = ref(false)
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const publishDiffs = ref<ContentDiffItem[]>([])
const isPublishButtonDisabled = computed(() => isPublishing.value || isReviewingPublish.value)
async function reviewChanges(e?: MouseEvent) {
if (isPublishButtonDisabled.value) return
isReviewingPublish.value = true
try {
const preview = await get_shared_instance_publish_preview(props.instance.id)
if (!preview) {
notifySharedInstanceUnavailable()
emit('published')
return
}
if (preview.diffs.length === 0) {
emit('published')
return
}
publishDiffs.value = preview.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
disabled: diff.disabled,
}))
publishReviewModal.value?.show(e)
} catch (err) {
if (isSharedInstanceUnavailableError(err)) {
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(err))
emit('published')
return
}
notifySharedInstanceError(err)
} finally {
isReviewingPublish.value = false
}
}
async function publishChanges() {
if (isPublishing.value) return
isPublishing.value = true
try {
await publish_shared_instance(props.instance.id)
emit('published')
} catch (err) {
if (isSharedInstanceUnavailableError(err)) {
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(err))
emit('published')
return
}
notifySharedInstanceError(err)
} finally {
isPublishing.value = false
}
}
</script>
@@ -0,0 +1,33 @@
<template>
<Admonition
type="warning"
:header="formatMessage(sharedInstanceErrorMessages.unavailableTitle)"
:dismissible="dismissible"
@dismiss="emit('dismiss')"
>
{{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, useVIntl } from '@modrinth/ui'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import {
sharedInstanceErrorMessages,
useSharedInstanceErrors,
} from '@/helpers/shared-instance-errors'
defineProps<{
reason?: SharedInstanceUnavailableReason | null
manager?: string | null
dismissible?: boolean
}>()
const emit = defineEmits<{
dismiss: []
}>()
const { formatMessage } = useVIntl()
const { formatSharedInstanceUnavailable } = useSharedInstanceErrors()
</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 './instance-admonitions-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,12 @@
import type { StackedAdmonitionItem } from '@modrinth/ui'
export type InstanceAdmonitionKind =
| 'shared-instance-stale'
| 'shared-instance-unavailable'
| 'shared-instance-wrong-account'
export type InstanceAdmonitionItem = StackedAdmonitionItem & {
kind: InstanceAdmonitionKind
}
export type SharedInstanceRole = 'owner' | 'member'
@@ -7,7 +7,6 @@ import {
injectFilePicker,
injectNotificationManager,
InstallationSettingsLayout,
provideAppBackup,
provideInstallationSettings,
useDebugLogger,
useVIntl,
@@ -16,24 +15,26 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.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_duplicate_instance,
install_existing_instance,
install_pack_to_existing_instance,
installJobInstanceId,
wait_for_install_job,
} from '@/helpers/install'
import {
edit,
get_linked_modpack_info,
list,
unlink_shared_instance,
unpublish_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 { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
@@ -47,6 +48,7 @@ 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'),
)
@@ -111,9 +113,15 @@ debug('metadata queries configured', {
const isModrinthLinkedModpack = computed(
() =>
instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_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 canUnpublishSharedInstance = managedContentPolicy.canUnpublish
const canUnlinkSharedInstance = managedContentPolicy.canUnlink
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
@@ -124,6 +132,36 @@ const modpackInfo = modpackInfoQuery.data
const repairing = ref(false)
const reinstalling = ref(false)
const unpublishingSharedInstance = ref(false)
const unlinkingSharedInstance = ref(false)
async function unpublishSharedInstance() {
unpublishingSharedInstance.value = true
try {
await unpublish_shared_instance(instance.value.id)
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unpublishingSharedInstance.value = false
}
}
async function unlinkSharedInstance() {
unlinkingSharedInstance.value = true
try {
await unlink_shared_instance(instance.value.id)
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
onUnlinked()
} catch (error) {
handleError(error)
} finally {
unlinkingSharedInstance.value = false
}
}
const messages = defineMessages({
loaderVersion: {
@@ -162,27 +200,7 @@ async function installLocalModpackFromPicker() {
return !!completed
}
provideAppBackup({
async createBackup() {
debug('createBackup: start', {
instanceId: instance.value.id,
instanceName: instance.value.name,
})
const allInstances = await list()
const prefix = `${instance.value.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(instance.value.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
},
})
provideInstanceBackup(instance)
provideInstallationSettings({
closeSettings: closeModal,
@@ -208,12 +226,19 @@ provideInstallationSettings({
}
return rows
}),
isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value),
isLinked: computed(
() =>
isModrinthLinkedModpack.value ||
isImportedModpack.value ||
isSharedInstanceManagedModpack.value,
),
isBusy: computed(
() =>
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
unlinkingSharedInstance.value ||
unpublishingSharedInstance.value ||
!!offline,
),
skipNonEssentialWarnings,
@@ -452,14 +477,38 @@ provideInstallationSettings({
isServer: false,
isApp: true,
showModpackVersionActions: computed(
() => isModrinthLinkedModpack.value && !isMinecraftServer.value,
() =>
isModrinthLinkedModpack.value &&
!isMinecraftServer.value &&
!isSharedInstanceManagedModpack.value,
),
isLocalFile: isImportedModpack,
isManagedModpack: isSharedInstanceManagedModpack,
managedModpackWarning: managedContentPolicy.managedModpackWarning,
repairing,
reinstalling,
})
</script>
<template>
<InstallationSettingsLayout />
<InstallationSettingsLayout>
<template #extra>
<SharedInstanceInstallationSettingsControls
:can-unpublish="canUnpublishSharedInstance"
:can-unlink="canUnlinkSharedInstance"
:busy="
instance.install_stage !== 'installed' ||
repairing ||
reinstalling ||
unpublishingSharedInstance ||
unlinkingSharedInstance ||
!!offline
"
:unpublishing="unpublishingSharedInstance"
:unlinking="unlinkingSharedInstance"
:unpublish="unpublishSharedInstance"
:unlink="unlinkSharedInstance"
/>
</template>
</InstallationSettingsLayout>
</template>
@@ -0,0 +1,152 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
:on-hide="() => finish(false)"
no-padding
max-width="548px"
width="100%"
>
<div class="flex w-full flex-col gap-6 p-6">
<div class="flex flex-col gap-2 px-3">
<h2 class="m-0 text-xl font-semibold leading-7 text-contrast">
{{ formatMessage(messages.signInHeading) }}
</h2>
<p class="m-0 text-base leading-6 text-primary">
{{ formatMessage(messages.description) }}
</p>
</div>
<div class="flex flex-col gap-6">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button
class="w-full !shadow-none"
type="button"
:disabled="authenticating !== null"
@click="authenticate('sign-up')"
>
<SpinnerIcon
v-if="authenticating === 'sign-up'"
aria-hidden="true"
class="animate-spin"
/>
<UserPlusIcon v-else aria-hidden="true" />
{{ formatMessage(messages.createAccountButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
class="w-full"
type="button"
:disabled="authenticating !== null"
@click="authenticate('sign-in')"
>
<SpinnerIcon
v-if="authenticating === 'sign-in'"
aria-hidden="true"
class="animate-spin"
/>
<LogInIcon v-else aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
<p class="m-0 text-center text-base font-medium leading-6 text-primary">
<IntlFormatted :message-id="messages.supportPrompt">
<template #support="{ children }">
<button
type="button"
class="inline cursor-pointer border-0 bg-transparent p-0 text-base font-medium leading-6 text-blue hover:underline"
@click="openSupport"
>
<component :is="() => children" />
</button>
</template>
</IntlFormatted>
</p>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { LogInIcon, SpinnerIcon, UserPlusIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, IntlFormatted, NewModal, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth'
const props = defineProps<{
requestAuth: (flow: ModrinthAuthFlow) => Promise<boolean>
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const authenticating = ref<ModrinthAuthFlow | null>(null)
let resolveShow: ((signedIn: boolean) => void) | undefined
function show(event?: MouseEvent) {
resolveShow?.(false)
const modalInstance = modal.value
if (!modalInstance) return Promise.resolve(false)
return new Promise<boolean>((resolve) => {
resolveShow = resolve
modalInstance.show(event)
})
}
function finish(signedIn: boolean) {
resolveShow?.(signedIn)
resolveShow = undefined
}
async function authenticate(flow: ModrinthAuthFlow) {
authenticating.value = flow
try {
if (await props.requestAuth(flow)) {
finish(true)
modal.value?.hide()
}
} finally {
authenticating.value = null
}
}
function openSupport() {
openUrl('https://support.modrinth.com')
}
const messages = defineMessages({
header: {
id: 'modal.modrinth-account-required.header',
defaultMessage: 'Account required',
},
signInHeading: {
id: 'modal.modrinth-account-required.sign-in-heading',
defaultMessage: 'Sign in to a Modrinth account',
},
description: {
id: 'modal.modrinth-account-required.description',
defaultMessage:
"You'll need to sign into your Modrinth account before you can use this feature.",
},
createAccountButton: {
id: 'modal.modrinth-account-required.create-account-button',
defaultMessage: 'Create an account',
},
signInButton: {
id: 'modal.modrinth-account-required.sign-in-button',
defaultMessage: 'Sign in to Modrinth',
},
supportPrompt: {
id: 'modal.modrinth-account-required.support-prompt',
defaultMessage: 'Having trouble signing in? <support>Get support</support>',
},
})
defineExpose({ show })
</script>
@@ -77,6 +77,11 @@ const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
cancel: []
complete: []
}>()
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
@@ -84,15 +89,15 @@ const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
const normalizedDiffs = computed<ContentDiffItem[]>(() => {
return diffs.value.map((diff) => ({
type: diff.type,
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
})),
)
}))
})
async function computeDependencyDiffs(
currentDeps: Dependency[],
@@ -238,7 +243,6 @@ watch(
)
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.link?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
@@ -251,6 +255,7 @@ async function handleUpdate() {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
emit('complete')
}
}
@@ -261,7 +266,7 @@ function handleReport() {
}
function handleDecline() {
hide()
emit('cancel')
}
function show(
@@ -292,7 +297,7 @@ const messages = defineMessages({
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to the latest version to launch the game.',
'An update is required to play {name}. Please update to latest version to launch the game.',
},
})
@@ -0,0 +1,181 @@
<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
><button @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
><button @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 = defineProps<{
canUnpublish: boolean
canUnlink: boolean
busy: boolean
unpublishing: boolean
unlinking: boolean
unpublish: () => Promise<void>
unlink: () => Promise<void>
}>()
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: 'Shared 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: 'Stop sharing this instance and remove it from Modrinth.',
},
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,120 @@
<template>
<ContentDiffModal
ref="modal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="instance ? formatMessage(messages.description, { name: instance.name }) : ''"
:diffs="diffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:added-label="formatMessage(messages.addedLabel)"
:removed-label="formatMessage(messages.removedLabel)"
:show-report-button="false"
@confirm="update"
@cancel="emit('cancel')"
/>
</template>
<script setup lang="ts">
import { DownloadIcon } from '@modrinth/assets'
import {
commonMessages,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
install_update_shared_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
type SharedInstanceUpdatePreview,
wait_for_install_job,
} from '@/helpers/install'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
cancel: []
complete: []
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
const modal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const preview = ref<SharedInstanceUpdatePreview | null>(null)
const onComplete = ref<UpdateCompleteCallback>(() => {})
const { formatMessage } = useVIntl()
const { notifySharedInstanceError } = useSharedInstanceErrors()
const diffs = computed<ContentDiffItem[]>(
() =>
preview.value?.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
disabled: diff.disabled,
})) ?? [],
)
async function update() {
try {
if (instance.value) {
const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id)
await onComplete.value()
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
emit('sharedInstanceUnavailable', getSharedInstanceUnavailableReason(error))
return
}
notifySharedInstanceError(error)
} finally {
emit('complete')
}
}
function show(
instanceValue: GameInstance,
previewValue: SharedInstanceUpdatePreview,
callback: UpdateCompleteCallback = () => {},
event?: MouseEvent,
) {
instance.value = instanceValue
preview.value = previewValue
onComplete.value = callback
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
updateToPlay: { id: 'app.modal.update-to-play.header', defaultMessage: 'Update to play' },
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
description: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to latest version to launch the game.',
},
addedLabel: {
id: 'app.modal.update-to-play.shared-instance-added-label',
defaultMessage: 'Added',
},
removedLabel: {
id: 'app.modal.update-to-play.shared-instance-removed-label',
defaultMessage: 'Removed',
},
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,121 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true" no-padding>
<div v-if="preview" class="flex flex-col gap-6 max-w-[500px] p-6 pb-2">
<Admonition type="warning" :header="formatMessage(messages.trustWarningHeader)">
{{ formatMessage(messages.trustWarningDescription) }}
</Admonition>
<SharedInstanceInstallSummary :preview="preview" @view-contents="openViewContents" />
<div v-if="preview.externalFileCount" class="flex items-center gap-2 text-orange">
<IssuesIcon class="h-5 w-5 flex-none" />
<span>{{
formatMessage(messages.externalFileWarning, { count: preview.externalFileCount })
}}</span>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled
><button @click="hide">
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
>
<ButtonStyled color="brand"
><button @click="accept">
<DownloadIcon />{{ formatMessage(messages.installButton) }}
</button></ButtonStyled
>
</div>
</template>
</NewModal>
<ModpackContentModal
ref="contentModal"
:header="formatMessage(messages.sharedInstanceContent)"
:modpack-name="preview?.name ?? ''"
:modpack-icon-url="preview?.iconUrl ?? undefined"
/>
</template>
<script setup lang="ts">
import { DownloadIcon, IssuesIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
ModpackContentModal,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import SharedInstanceInstallSummary from './shared-instance-install-summary.vue'
import { useSharedInstancePreviewContent } from './use-shared-instance-preview-content'
const modal = ref<InstanceType<typeof NewModal>>()
const contentModal = ref<InstanceType<typeof ModpackContentModal>>()
const preview = ref<SharedInstanceInstallPreview | null>(null)
const install = ref<() => void | Promise<void>>(() => {})
const { formatMessage } = useVIntl()
const { load } = useSharedInstancePreviewContent()
async function accept() {
hide()
try {
await install.value()
} catch (error) {
console.error('Failed to install shared instance:', error)
}
}
async function openViewContents() {
if (!preview.value) return
contentModal.value?.showLoading()
try {
contentModal.value?.show(await load(preview.value))
} catch (error) {
console.error('Failed to load shared instance contents:', error)
contentModal.value?.show([])
}
}
function show(
previewValue: SharedInstanceInstallPreview,
installValue: () => void | Promise<void>,
event?: MouseEvent,
) {
preview.value = previewValue
install.value = installValue
hide_ads_window()
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
show_ads_window()
}
const messages = defineMessages({
installToPlay: { id: 'app.modal.install-to-play.header', defaultMessage: 'Install to play' },
sharedInstanceContent: {
id: 'app.modal.install-to-play.shared-instance-content',
defaultMessage: 'Shared instance content',
},
trustWarningHeader: {
id: 'app.modal.install-to-play.trust-warning-header',
defaultMessage: 'Do you trust this user?',
},
trustWarningDescription: {
id: 'app.modal.install-to-play.trust-warning-description',
defaultMessage:
'A shared instance will install files on your computer and may include content not from Modrinth.',
},
externalFileWarning: {
id: 'app.modal.install-to-play.external-file-warning',
defaultMessage:
'{count, plural, one {This instance includes # file that is not from Modrinth.} other {This instance includes # files that are not from Modrinth.}}',
},
installButton: { id: 'app.modal.install-to-play.install-button', defaultMessage: 'Install' },
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1,59 @@
<template>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{ formatMessage(messages.sharedInstance) }}</span>
<ButtonStyled type="transparent">
<button @click="emit('viewContents')">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar :src="preview.iconUrl" :alt="preview.name" size="48px" />
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">{{ preview.name }}</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ preview.gameVersion }}
<template v-if="preview.modCount">
<BulletDivider />
{{ formatMessage(messages.modCount, { count: preview.modCount }) }}
</template>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { EyeIcon } from '@modrinth/assets'
import {
Avatar,
BulletDivider,
ButtonStyled,
defineMessages,
formatLoader,
useVIntl,
} from '@modrinth/ui'
import { computed } from 'vue'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
const props = defineProps<{ preview: SharedInstanceInstallPreview }>()
const emit = defineEmits<{ viewContents: [] }>()
const { formatMessage } = useVIntl()
const loaderDisplay = computed(() =>
props.preview.loader ? formatLoader(formatMessage, props.preview.loader) : '',
)
const messages = defineMessages({
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
viewContents: { id: 'app.modal.install-to-play.view-contents', defaultMessage: 'View contents' },
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
},
})
</script>
@@ -0,0 +1,135 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem } from '@modrinth/ui'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
export function useSharedInstancePreviewContent() {
async function load(preview: SharedInstanceInstallPreview): Promise<ContentItem[]> {
return [
...preview.externalFiles.map(externalFileContentItem),
...(await modpackContentItems(preview)),
...(await contentItemsFromVersionIds(
preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
)),
]
}
async function modpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
}
async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
const deps = dependencies as VersionDependency[]
const projectIds = unique(deps.map((dep) => dep.project_id).filter((id): id is string => !!id))
const versionIds = unique(deps.map((dep) => dep.version_id).filter((id): id is string => !!id))
const [projects, versions]: [Labrinth.Projects.v2.Project[], Labrinth.Versions.v2.Version[]] =
await Promise.all([
projectIds.length ? get_project_many(projectIds, 'must_revalidate') : [],
versionIds.length ? get_version_many(versionIds, 'must_revalidate') : [],
])
const projectMap = new Map(projects.map((project) => [project.id, project]))
const versionMap = new Map(versions.map((version) => [version.id, version]))
return deps.map((dependency): ContentItem => {
const project = dependency.project_id ? projectMap.get(dependency.project_id) : null
const version = dependency.version_id ? versionMap.get(dependency.version_id) : null
const fileName =
version?.files?.[0]?.filename ?? dependency.file_name ?? project?.title ?? 'Unknown'
return contentItem(
version?.id ?? project?.id ?? fileName,
fileName,
project,
version,
!project && !version,
dependency.project_id ?? fileName,
dependency.file_name ?? fileName,
)
})
}
async function contentItemsFromVersionIds(versionIds: string[]) {
const versions: Labrinth.Versions.v2.Version[] = versionIds.length
? await get_version_many(unique(versionIds), 'must_revalidate')
: []
const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
const projects: Labrinth.Projects.v2.Project[] = projectIds.length
? await get_project_many(projectIds, 'must_revalidate')
: []
const projectMap = new Map(projects.map((project) => [project.id, project]))
return versions.map((version): ContentItem => {
const project = projectMap.get(version.project_id)
const fileName = version.files?.[0]?.filename ?? project?.title ?? version.name ?? 'Unknown'
return contentItem(
version.id,
fileName,
project,
version,
false,
version.project_id,
version.name,
)
})
}
return { load }
}
function contentItem(
id: string,
fileName: string,
project?: Labrinth.Projects.v2.Project | null,
version?: Labrinth.Versions.v2.Version | null,
external = false,
fallbackProjectId = id,
fallbackTitle = fileName,
projectType = project?.project_type ?? 'mod',
): ContentItem {
return {
id,
file_name: fileName,
project_type: projectType,
has_update: false,
update_version_id: null,
external,
project: {
id: project?.id ?? fallbackProjectId,
slug: project?.slug ?? fallbackProjectId,
title: project?.title ?? fallbackTitle,
icon_url: project?.icon_url ?? undefined,
},
...(version
? {
version: {
id: version.id,
file_name: fileName,
version_number: version.version_number ?? undefined,
date_published: version.date_published ?? undefined,
},
}
: {}),
}
}
function externalFileContentItem(
file: SharedInstanceInstallPreview['externalFiles'][number],
): ContentItem {
return contentItem(
`external:${file.fileType}:${file.fileName}`,
file.fileName,
null,
null,
true,
file.fileName,
file.fileName,
file.fileType,
)
}
function unique<T>(values: T[]) {
return Array.from(new Set(values))
}
@@ -0,0 +1,43 @@
<template>
<SharedInstanceInviteLinkModal ref="inviteLinkModal" :process-invite="installFromInviteId" />
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
<SharedInstanceInstallModal ref="installModal" />
</template>
<script setup lang="ts">
import { injectAuth } from '@modrinth/ui'
import { nextTick, ref } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
import type { ModrinthAuthFlow } from '@/helpers/mr_auth'
import SharedInstanceInviteLinkModal from './shared-instance-invite-link-modal.vue'
import type { SharedInstanceInviteHandler } from './shared-instance-invite-types'
import { useSharedInstanceInviteHandler } from './use-shared-instance-invite-handler'
const auth = injectAuth()
const inviteLinkModal = ref<InstanceType<typeof SharedInstanceInviteLinkModal>>()
const installModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const { handleNotification, installFromInviteId } = useSharedInstanceInviteHandler(
installModal,
accountRequiredModal,
)
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn('', flow)
await nextTick()
return !!auth.session_token.value
}
function showManualInviteLinkModal(event?: MouseEvent) {
inviteLinkModal.value?.show(event)
}
defineExpose<SharedInstanceInviteHandler>({
handleNotification,
installFromInviteId,
showManualInviteLinkModal,
})
</script>
@@ -0,0 +1,62 @@
<template>
<NewModal ref="modal" header="Install from invite link" max-width="32rem">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Invite link</span>
<StyledInput
v-model="inviteLink"
placeholder="https://modrinth.com/share/..."
:disabled="processing"
@keydown.enter="process"
/>
<span v-if="inviteLink && !inviteId" class="text-sm text-red">
Enter a valid Modrinth shared-instance invite link.
</span>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button :disabled="processing" @click="modal?.hide()"><XIcon /> Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!inviteId || processing" @click="process">
<LinkIcon />
Process
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { LinkIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { parseSharedInstanceInviteLink } from './shared-instance-invite-parser'
const props = defineProps<{ processInvite: (inviteId: string) => Promise<void> }>()
const modal = ref<InstanceType<typeof NewModal>>()
const inviteLink = ref('')
const processing = ref(false)
const inviteId = computed(() => parseSharedInstanceInviteLink(inviteLink.value))
function show(event?: MouseEvent) {
inviteLink.value = ''
modal.value?.show(event)
}
async function process() {
if (!inviteId.value) return
processing.value = true
modal.value?.hide()
try {
await props.processInvite(inviteId.value)
} finally {
processing.value = false
}
}
defineExpose({ show })
</script>
@@ -0,0 +1,42 @@
import type {
AppNotification,
SharedInstanceInvite,
SharedInstanceInviteNotificationBody,
} from './shared-instance-invite-types'
function optionalString(value: unknown) {
return typeof value === 'string' ? value : null
}
export function parseSharedInstanceInviteNotification(
notification: AppNotification,
): SharedInstanceInvite | null {
if (notification.body?.type !== 'shared_instance_invite') return null
const body = notification.body as SharedInstanceInviteNotificationBody
const sharedInstanceId = optionalString(body.shared_instance_id)
const sharedInstanceName = optionalString(body.shared_instance_name)
if (!sharedInstanceId || !sharedInstanceName) return null
return {
sharedInstanceId,
sharedInstanceName,
invitedById: optionalString(body.invited_by),
invitedByUsername: optionalString(body.invited_by_username),
invitedByAvatarUrl: optionalString(body.invited_by_avatar_url),
instanceIconUrl: optionalString(body.instance_icon_url),
}
}
export function parseSharedInstanceInviteLink(value: string) {
const trimmedValue = value.trim()
if (!trimmedValue) return null
try {
const url = new URL(trimmedValue)
const match = /^\/share\/([^/]+)\/?$/.exec(url.pathname)
return match ? decodeURIComponent(match[1]) : null
} catch {
return null
}
}
@@ -0,0 +1,30 @@
export type SharedInstanceInviteNotificationBody = {
type: 'shared_instance_invite'
shared_instance_id?: unknown
shared_instance_name?: unknown
invited_by?: unknown
invited_by_username?: unknown
invited_by_avatar_url?: unknown
instance_icon_url?: unknown
}
export type AppNotification = {
id: string | number
read?: boolean
body?: { type?: unknown } & Record<string, unknown>
}
export type SharedInstanceInvite = {
sharedInstanceId: string
sharedInstanceName: string
invitedById: string | null
invitedByUsername: string | null
invitedByAvatarUrl: string | null
instanceIconUrl: string | null
}
export type SharedInstanceInviteHandler = {
handleNotification(notification: AppNotification): Promise<boolean>
installFromInviteId(inviteId: string): Promise<void>
showManualInviteLinkModal(event?: MouseEvent): void
}
@@ -0,0 +1,160 @@
import {
injectAuth,
injectModrinthClient,
injectNotificationManager,
injectPopupNotificationManager,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type Ref, watch } from 'vue'
import { config } from '@/config'
import { get_user } from '@/helpers/cache'
import { toError } from '@/helpers/errors'
import {
install_accept_shared_instance_invite,
install_get_shared_instance_preview,
install_shared_instance,
} from '@/helpers/install'
import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
type InstallModal = {
show(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
): void
}
type AccountRequiredModal = {
show(event?: MouseEvent): Promise<boolean>
}
export function useSharedInstanceInviteHandler(
installModal: Ref<InstallModal | undefined>,
accountRequiredModal: Ref<AccountRequiredModal | undefined>,
) {
const auth = injectAuth()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const { addPopupNotification } = injectPopupNotificationManager()
const queryClient = useQueryClient()
const displayedNotifications = new Set<string | number>()
async function markNotificationRead(notification: AppNotification) {
await client.labrinth.notifications_v2.markAsRead(String(notification.id))
}
async function resolveInvite(invite: SharedInstanceInvite) {
const invitedBy =
!invite.invitedByUsername && invite.invitedById
? await get_user(invite.invitedById, 'bypass').catch(() => null)
: null
return {
...invite,
invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
}
}
function showInstall(
preview: Awaited<ReturnType<typeof install_get_shared_instance_preview>>,
install: () => Promise<void>,
) {
if (!installModal.value) throw new Error('Shared instance install modal is not available.')
installModal.value.show(preview, install)
}
async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
try {
const preview = await install_get_shared_instance_preview(
invite.sharedInstanceId,
invite.sharedInstanceName,
)
if (invite.instanceIconUrl) preview.iconUrl = invite.instanceIconUrl
showInstall(preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.sharedInstanceName,
invite.invitedById,
null,
null,
invite.instanceIconUrl,
)
await markNotificationRead(notification)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
} catch (error) {
handleError(toError(error))
}
}
async function handleNotification(notification: AppNotification) {
const parsedInvite = parseSharedInstanceInviteNotification(notification)
if (!parsedInvite) return false
if (displayedNotifications.has(notification.id)) return true
displayedNotifications.add(notification.id)
const invite = await resolveInvite(parsedInvite)
addPopupNotification({
title: invite.sharedInstanceName,
autoCloseMs: null,
toast: {
type: 'instance-invite',
actorName: invite.invitedByUsername,
actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
entityName: invite.sharedInstanceName,
entityIconUrl: invite.instanceIconUrl ?? undefined,
onAccept: () => acceptNotification(notification, invite),
onDecline: () =>
markNotificationRead(notification).catch((error) => handleError(toError(error))),
onOpenActor: () => {
if (invite.invitedByUsername) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
}
},
},
})
return true
}
async function requireAccount() {
if (!auth.isReady?.value) {
await new Promise<void>((resolve) => {
const stop = watch(auth.isReady!, (ready) => {
if (ready) {
stop()
resolve()
}
})
})
}
if (auth.session_token.value) return true
return (await accountRequiredModal.value?.show()) ?? false
}
async function installFromInviteId(inviteId: string) {
try {
if (!(await requireAccount())) return
const invite = await install_accept_shared_instance_invite(inviteId)
showInstall(invite.preview, async () => {
await install_shared_instance(
invite.sharedInstanceId,
invite.preview.name,
invite.managerId,
invite.serverManagerName,
invite.serverManagerIconUrl,
invite.instanceIconUrl,
)
await queryClient.invalidateQueries({ queryKey: ['instances'] })
})
} catch (error) {
handleError(toError(error))
}
}
return { handleNotification, installFromInviteId }
}
@@ -53,6 +53,10 @@ const messages = defineMessages({
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
},
updatingSharedContent: {
id: 'app.action-bar.install.updating-shared-content',
defaultMessage: 'Updating shared content',
},
})
const phaseMessages = defineMessages({
@@ -268,6 +272,9 @@ export async function useInstallJobNotifications(opts: {
version: job.details.major_version,
})
}
if (job.kind === 'update_shared_instance' && job.phase === 'downloading_content') {
return formatMessage(messages.updatingSharedContent)
}
return formatMessage(phaseMessages[job.phase])
}
@@ -0,0 +1,119 @@
import { type ContentActionWarning, type ContentItem, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, type Ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
const managedSourceKinds = new Set(['shared_instance', 'modrinth_modpack', 'imported_modpack'])
export function useManagedContentPolicy(instance: Ref<GameInstance>) {
const { formatMessage } = useVIntl()
const isManagedModpack = computed(() => instance.value.shared_instance?.role === 'member')
const canUnpublish = computed(() => instance.value.shared_instance?.role === 'owner')
const canUnlink = computed(() => instance.value.shared_instance?.role === 'member')
const managedModpackWarning = computed(() => ({
admonitionHeader: formatMessage(messages.warningHeader),
changeVersionBody: formatMessage(messages.changeVersionBody),
unlinkBody: formatMessage(messages.unlinkBody),
}))
function isManagedContent(item: ContentItem) {
return isManagedModpack.value && !!item.source_kind && managedSourceKinds.has(item.source_kind)
}
function canMutateContent(item: ContentItem) {
return !isManagedContent(item)
}
function canUpdateContent(item: ContentItem) {
return (
canMutateContent(item) && !!item.file_path && !!item.has_update && !!item.update_version_id
)
}
function deleteWarning(items: ContentItem[]): ContentActionWarning | null {
if (!items.some(isManagedContent)) return null
return {
admonitionHeader: formatMessage(messages.warningHeader),
admonitionBody: formatMessage(
items.length === 1 ? messages.deleteSingleBody : messages.deleteBulkBody,
),
actionLabel: formatMessage(
items.length === 1 ? messages.deleteButton : messages.deleteManyButton,
{ count: items.length },
),
}
}
function disableWarning(items: ContentItem[]): ContentActionWarning | null {
if (!items.some(isManagedContent)) return null
return {
admonitionHeader: formatMessage(messages.warningHeader),
admonitionBody: formatMessage(
items.length === 1 ? messages.disableSingleBody : messages.disableBulkBody,
),
actionLabel: formatMessage(
items.length === 1 ? messages.disableButton : messages.disableManyButton,
{ count: items.length },
),
}
}
return {
isManagedModpack,
canUnpublish,
canUnlink,
managedModpackWarning,
isManagedContent,
canMutateContent,
canUpdateContent,
deleteWarning,
disableWarning,
}
}
const messages = defineMessages({
warningHeader: {
id: 'content.shared-instance.warning-header',
defaultMessage: 'This is part of the shared instance',
},
changeVersionBody: {
id: 'content.shared-instance.change-version-body',
defaultMessage:
'Changing the version only changes your local copy. Future shared instance updates may restore or change it again.',
},
unlinkBody: {
id: 'content.shared-instance.unlink-body',
defaultMessage:
'Unlinking only changes your local copy. Future shared instance updates may restore or change it again.',
},
deleteSingleBody: {
id: 'content.shared-instance.delete-single-body',
defaultMessage:
'Deleting it only changes your local copy. Future shared instance updates may restore or change it again.',
},
deleteBulkBody: {
id: 'content.shared-instance.delete-bulk-body',
defaultMessage:
'Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again.',
},
deleteButton: { id: 'content.shared-instance.delete-button', defaultMessage: 'Delete anyway' },
deleteManyButton: {
id: 'content.shared-instance.delete-many-button',
defaultMessage: 'Delete {count, number} projects anyway',
},
disableSingleBody: {
id: 'content.shared-instance.disable-single-body',
defaultMessage:
'Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again.',
},
disableBulkBody: {
id: 'content.shared-instance.disable-bulk-body',
defaultMessage:
'Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again.',
},
disableButton: { id: 'content.shared-instance.disable-button', defaultMessage: 'Disable anyway' },
disableManyButton: {
id: 'content.shared-instance.disable-many-button',
defaultMessage: 'Disable {count, number} projects anyway',
},
})
@@ -0,0 +1,148 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, onUnmounted, toValue } from 'vue'
import { toError } from '@/helpers/errors'
import { friend_listener } from '@/helpers/events.js'
import {
acceptCachedFriend,
add_friend,
createPendingFriend,
type FriendCacheUser,
friendsQueryKey,
type FriendWithUserData,
getFriendsWithUserData,
getFriendUserId,
matchesFriend,
remove_friend,
removeCachedFriend,
upsertCachedFriend,
} from '@/helpers/friends'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
type FriendsMutationContext = {
queryKey: ReturnType<typeof friendsQueryKey>
previousFriends?: FriendWithUserData[]
}
type AddFriendMutationVariables = {
userId: string
user: FriendCacheUser
acceptExisting: boolean
}
type RemoveFriendMutationVariables = {
userId: string
user: FriendWithUserData
}
export function useFriends(options: {
currentUserId: MaybeRefOrGetter<string | null | undefined>
getCredentials: () => ModrinthCredentials | null | Promise<ModrinthCredentials | null>
enabled?: MaybeRefOrGetter<boolean>
onError?: (error: Error) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => friendsQueryKey(toValue(options.currentUserId)))
const query = useQuery({
queryKey,
queryFn: async () => getFriendsWithUserData(await options.getCredentials()),
enabled: () => !!toValue(options.currentUserId) && toValue(options.enabled ?? true),
staleTime: 30_000,
})
const friends = computed(() => query.data.value ?? [])
function restore(context?: FriendsMutationContext) {
if (!context) return
if (context.previousFriends === undefined) {
queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
return
}
queryClient.setQueryData(context.queryKey, context.previousFriends)
}
const addMutation = useMutation({
mutationFn: ({ userId }: AddFriendMutationVariables) => add_friend(userId),
onMutate: async ({ user, acceptExisting }): Promise<FriendsMutationContext> => {
const activeQueryKey = queryKey.value
await queryClient.cancelQueries({ queryKey: activeQueryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(activeQueryKey)
const currentUserId = toValue(options.currentUserId)
queryClient.setQueryData<FriendWithUserData[]>(activeQueryKey, (cachedFriends = []) =>
acceptExisting
? acceptCachedFriend(cachedFriends, user.id, user.username, currentUserId)
: upsertCachedFriend(
cachedFriends,
createPendingFriend(user, currentUserId),
currentUserId,
),
)
return { queryKey: activeQueryKey, previousFriends }
},
onError: (error, _variables, context) => {
restore(context)
options.onError?.(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
},
})
const removeMutation = useMutation({
mutationFn: ({ userId }: RemoveFriendMutationVariables) => remove_friend(userId),
onMutate: async ({ user, userId }): Promise<FriendsMutationContext> => {
const activeQueryKey = queryKey.value
await queryClient.cancelQueries({ queryKey: activeQueryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(activeQueryKey)
queryClient.setQueryData<FriendWithUserData[]>(activeQueryKey, (cachedFriends = []) =>
removeCachedFriend(cachedFriends, userId, user.username, toValue(options.currentUserId)),
)
return { queryKey: activeQueryKey, previousFriends }
},
onError: (error, _variables, context) => {
restore(context)
options.onError?.(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
},
})
function requestFriend(user: FriendCacheUser, acceptExisting = false) {
addMutation.mutate({ userId: user.id, user, acceptExisting })
}
function acceptFriend(friend: FriendWithUserData) {
const userId = getFriendUserId(friend, toValue(options.currentUserId))
requestFriend({ id: userId, username: friend.username, avatarUrl: friend.avatar }, true)
}
function removeFriend(friend: FriendWithUserData) {
const userId = getFriendUserId(friend, toValue(options.currentUserId))
removeMutation.mutate({ userId, user: friend })
}
function findFriend(id: string, username: string) {
return friends.value.find((friend) =>
matchesFriend(friend, id, username, toValue(options.currentUserId)),
)
}
let unlisten: (() => void) | undefined
void friend_listener(() => {
void queryClient.invalidateQueries({ queryKey: queryKey.value })
}).then((listener) => {
unlisten = listener
})
onUnmounted(() => unlisten?.())
return {
query,
queryKey,
friends,
loading: computed(() => !!toValue(options.currentUserId) && query.isLoading.value),
requestFriend,
acceptFriend,
removeFriend,
findFriend,
}
}
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/vue-query'
import { computed, type Ref } from 'vue'
import { get_user } from '@/helpers/cache.js'
export function useUserQuery(userId: Ref<string | null | undefined>) {
return useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: async ({ queryKey }) => {
const id = queryKey[1]
if (typeof id !== 'string') return null
return await get_user(id, 'bypass').catch(() => null)
},
enabled: () => !!userId.value,
staleTime: 30_000,
})
}
+11
View File
@@ -0,0 +1,11 @@
export function toError(error: unknown) {
if (error instanceof Error) return error
if (typeof error === 'string') return new Error(error)
if (error && typeof error === 'object') {
const record = error as Record<string, unknown>
const message = record.message ?? record.error
if (typeof message === 'string') return new Error(message)
return new Error(JSON.stringify(error))
}
return new Error(String(error))
}
+107
View File
@@ -6,6 +6,8 @@ import dayjs from 'dayjs'
import { get_user_many } from '@/helpers/cache'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
export const friendsQueryKey = (userId?: string | null) => ['friends', userId ?? null] as const
export type UserStatus = {
user_id: string
instance_name: string | null
@@ -46,6 +48,111 @@ export type FriendWithUserData = {
online: boolean
avatar: string
}
export type FriendCacheUser = {
id: string
username: string
avatarUrl?: string | null
}
export async function getFriendsWithUserData(
credentials: ModrinthCredentials | null,
): Promise<FriendWithUserData[]> {
if (!credentials) return []
const friendsList = await friends()
return await transformFriends(friendsList, credentials)
}
export function createPendingFriend(
user: FriendCacheUser,
currentUserId?: string | null,
): FriendWithUserData {
return {
id: user.id,
friend_id: currentUserId ?? null,
status: null,
last_updated: null,
created: dayjs(),
username: user.username,
accepted: false,
online: false,
avatar: user.avatarUrl ?? '',
}
}
export function getFriendUserId(
friend: Pick<FriendWithUserData, 'id' | 'friend_id'>,
currentUserId?: string | null,
) {
return friend.id === currentUserId && friend.friend_id ? friend.friend_id : friend.id
}
export function matchesFriend(
friend: FriendWithUserData,
id: string,
username: string,
currentUserId?: string | null,
) {
const friendId = getFriendUserId(friend, currentUserId)
return (
normalizeFriendKey(friendId) === normalizeFriendKey(id) ||
normalizeFriendKey(friend.username) === normalizeFriendKey(username)
)
}
export function upsertCachedFriend(
friends: FriendWithUserData[],
friend: FriendWithUserData,
currentUserId?: string | null,
) {
const existingFriend = friends.find((cachedFriend) =>
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId),
)
if (!existingFriend) return [friend, ...friends]
return friends.map((cachedFriend) =>
matchesFriend(cachedFriend, friend.id, friend.username, currentUserId)
? {
...cachedFriend,
...friend,
id: cachedFriend.id,
friend_id: cachedFriend.friend_id,
}
: cachedFriend,
)
}
export function acceptCachedFriend(
friends: FriendWithUserData[],
id: string,
username: string,
currentUserId?: string | null,
) {
return friends.map((friend) =>
matchesFriend(friend, id, username, currentUserId)
? {
...friend,
accepted: true,
}
: friend,
)
}
export function removeCachedFriend(
friends: FriendWithUserData[],
id: string,
username: string,
currentUserId?: string | null,
) {
return friends.filter((friend) => !matchesFriend(friend, id, username, currentUserId))
}
export function normalizeFriendKey(value: string) {
return value.trim().toLowerCase()
}
export async function transformFriends(
friends: UserFriend[],
credentials: ModrinthCredentials | null,
+135 -1
View File
@@ -44,6 +44,84 @@ export interface InstallPostInstallEdit {
link?: InstanceLink | null
}
export interface SharedInstanceInstallPreview {
name: string
iconUrl?: string | null
gameVersion: string
loader: InstanceLoader
modCount: number
externalFileCount: number
modpackVersionId?: string | null
contentVersionIds: string[]
externalFiles: SharedInstanceExternalFilePreview[]
}
export interface SharedInstanceInviteInstallPreview {
sharedInstanceId: string
managerId?: string | null
serverManagerName?: string | null
serverManagerIconUrl?: string | null
instanceIconUrl?: string | null
preview: SharedInstanceInstallPreview
}
export interface SharedInstanceExternalFilePreview {
fileName: string
fileType: string
}
export interface SharedInstanceUpdatePreview {
sharedInstanceId: string
currentVersion?: number | null
latestVersion: number
updateAvailable: boolean
diffs: SharedInstanceUpdateDiff[]
}
export interface SharedInstanceUpdateDiff {
type:
| 'added'
| 'removed'
| 'updated'
| 'modpack_linked'
| 'modpack_updated'
| 'modpack_unlinked'
| 'game_version_updated'
| 'loader_updated'
projectId?: string | null
projectName?: string | null
fileName?: string | null
currentVersionName?: string | null
newVersionName?: string | null
disabled?: boolean
}
export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null
}
export function getSharedInstanceUnavailableReason(
error: unknown,
): SharedInstanceUnavailableReason | null {
if (!isRecord(error) || error.code !== SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE) return null
return error.reason === 'deleted' || error.reason === 'access_revoked' ? error.reason : null
}
export function getErrorMessage(error: unknown): string {
if (typeof error === 'string') return error
if (error instanceof Error) return error.message || 'Unknown error'
if (isRecord(error) && typeof error.message === 'string') return error.message
return 'Unknown error'
}
export type InstallJobStatus =
| 'queued'
| 'running'
@@ -89,6 +167,7 @@ export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
reason?: SharedInstanceUnavailableReason | null
api?: {
error: string
status?: number | null
@@ -121,6 +200,8 @@ export interface InstallJobSnapshot {
kind:
| 'create_instance'
| 'create_modpack_instance'
| 'create_shared_instance'
| 'update_shared_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
@@ -171,6 +252,58 @@ export async function install_create_modpack_instance(
})
}
export async function install_get_shared_instance_preview(sharedInstanceId: string, name: string) {
return await invoke<SharedInstanceInstallPreview>(
'plugin:install|install_get_shared_instance_preview',
{
sharedInstanceId,
name,
},
)
}
export async function install_accept_shared_instance_invite(inviteId: string) {
return await invoke<SharedInstanceInviteInstallPreview>(
'plugin:install|install_accept_shared_instance_invite',
{
inviteId,
},
)
}
export async function install_get_shared_instance_update_preview(instanceId: string) {
return await invoke<SharedInstanceUpdatePreview | null>(
'plugin:install|install_get_shared_instance_update_preview',
{
instanceId,
},
)
}
export async function install_shared_instance(
sharedInstanceId: string,
name: string,
managerId?: string | null,
serverManagerName?: string | null,
serverManagerIconUrl?: string | null,
instanceIconUrl?: string | null,
) {
return await invoke<InstallJobSnapshot>('plugin:install|install_shared_instance', {
sharedInstanceId,
name,
managerId,
serverManagerName,
serverManagerIconUrl,
instanceIconUrl,
})
}
export async function install_update_shared_instance(instanceId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_update_shared_instance', {
instanceId,
})
}
export async function install_import_instance(
launcherType: string,
basePath: string,
@@ -248,7 +381,8 @@ export function isInstallJobFinished(status: InstallJobStatus) {
function settleInstallJob(job: InstallJobSnapshot) {
if (job.status === 'succeeded') return job
throw new Error(job.error?.message ?? `Install job ${job.job_id} ${job.status}`)
if (job.error) throw job.error
throw new Error(`Install job ${job.job_id} ${job.status}`)
}
export async function wait_for_install_job(jobId: string) {
+81 -1
View File
@@ -7,13 +7,14 @@ import type { Labrinth } from '@modrinth/api-client'
import type { ContentItem, ContentOwner } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import type { InstallJobSnapshot } from './install'
import type { InstallJobSnapshot, SharedInstanceUpdateDiff } from './install'
import type {
CacheBehaviour,
ContentFile,
ContentFileProjectType,
GameInstance,
InstanceLoader,
SharedInstanceAttachment,
} from './types'
export async function remove(instanceId: string): Promise<void> {
@@ -331,3 +332,82 @@ export async function edit(instanceId: string, editInstance: Partial<GameInstanc
export async function edit_icon(instanceId: string, iconPath: string | null): Promise<void> {
return await invoke('plugin:instance|instance_edit_icon', { instanceId, iconPath })
}
export type SharedInstanceUsers = {
user_ids: string[]
users: SharedInstanceUser[]
tokens: number
}
export type SharedInstanceJoinType = 'owner' | 'invite' | 'link'
export type SharedInstanceUser = {
id: string
joined_at?: string | null
join_type: SharedInstanceJoinType
last_played?: string | null
}
export interface SharedInstancePublishPreview {
sharedInstanceId: string
latestVersion: number
diffs: SharedInstanceUpdateDiff[]
}
export interface SharedInstanceInviteLink {
inviteId: string
expiresAt: string
maxUses: number
}
export async function get_shared_instance_users(instanceId: string): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_get_users', { instanceId })
}
export async function invite_shared_instance_users(
instanceId: string,
userIds: string[],
): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_invite_users', { instanceId, userIds })
}
export async function create_shared_instance_invite_link(
instanceId: string,
options: {
maxAgeSeconds?: number
maxUses?: number
replaceInviteId?: string
} = {},
): Promise<SharedInstanceInviteLink> {
return await invoke('plugin:instance|instance_share_create_invite_link', {
instanceId,
...options,
})
}
export async function remove_shared_instance_users(
instanceId: string,
userIds: string[],
): Promise<SharedInstanceUsers> {
return await invoke('plugin:instance|instance_share_remove_users', { instanceId, userIds })
}
export async function get_shared_instance_publish_preview(
instanceId: string,
): Promise<SharedInstancePublishPreview | null> {
return await invoke('plugin:instance|instance_share_get_publish_preview', { instanceId })
}
export async function publish_shared_instance(
instanceId: string,
): Promise<SharedInstanceAttachment> {
return await invoke('plugin:instance|instance_share_publish', { instanceId })
}
export async function unlink_shared_instance(instanceId: string): Promise<void> {
return await invoke('plugin:instance|instance_share_unlink', { instanceId })
}
export async function unpublish_shared_instance(instanceId: string): Promise<void> {
return await invoke('plugin:instance|instance_share_unpublish', { instanceId })
}
+4 -2
View File
@@ -12,8 +12,10 @@ export type ModrinthCredentials = {
active: boolean
}
export async function login(): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login')
export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise<ModrinthCredentials> {
return await invoke('plugin:mr-auth|modrinth_login', { flow })
}
export async function logout(): Promise<void> {
@@ -0,0 +1,80 @@
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { getErrorMessage, type SharedInstanceUnavailableReason } from '@/helpers/install'
export const sharedInstanceErrorMessages = defineMessages({
unavailableTitle: {
id: 'instance.shared-instance.unavailable.title',
defaultMessage: 'Shared instance no longer available',
},
unavailableText: {
id: 'instance.shared-instance.unavailable.text-v2',
defaultMessage:
"Your local instance is still available, but it is no longer linked and won't receive updates.",
},
deletedText: {
id: 'instance.shared-instance.unavailable.deleted-text-v2',
defaultMessage:
"The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates.",
},
accessRevokedText: {
id: 'instance.shared-instance.unavailable.access-revoked-text-v2',
defaultMessage:
"Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates.",
},
unavailableFallbackManager: {
id: 'instance.shared-instance.unavailable.manager-fallback',
defaultMessage: 'the instance manager',
},
errorTitle: {
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
})
export function sharedInstanceUnavailableTextMessage(
reason: SharedInstanceUnavailableReason | null,
) {
if (reason === 'deleted') return sharedInstanceErrorMessages.deletedText
if (reason === 'access_revoked') return sharedInstanceErrorMessages.accessRevokedText
return sharedInstanceErrorMessages.unavailableText
}
export function useSharedInstanceErrors() {
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
function formatSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
manager?: string | null,
) {
return formatMessage(sharedInstanceUnavailableTextMessage(reason), {
manager: manager || formatMessage(sharedInstanceErrorMessages.unavailableFallbackManager),
})
}
function notifySharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
manager?: string | null,
) {
addNotification({
type: 'warning',
title: formatMessage(sharedInstanceErrorMessages.unavailableTitle),
text: formatSharedInstanceUnavailable(reason, manager),
})
}
function notifySharedInstanceError(error: unknown) {
addNotification({
type: 'error',
title: formatMessage(sharedInstanceErrorMessages.errorTitle),
text: getErrorMessage(error),
})
}
return {
formatSharedInstanceUnavailable,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
}
}
+31 -1
View File
@@ -17,6 +17,7 @@ export type GameInstance = {
groups: string[]
link?: InstanceLink | null
shared_instance?: SharedInstanceAttachment | null
update_channel: ReleaseChannel
created: Date
@@ -86,18 +87,47 @@ export type InstanceLink = InstanceLinkIdentity &
}
| {
type: 'shared_instance'
shared_instance_id: string
modpack_project_id?: ModrinthId | null
modpack_version_id?: ModrinthId | null
}
)
export type SharedInstanceAttachment = {
id: string
role: 'owner' | 'member'
manager_id?: string | null
server_manager_name?: string | null
server_manager_icon_url?: string | null
linked_user_id?: string | null
status:
| 'unknown'
| 'up_to_date'
| 'update_available'
| 'applying'
| 'stale'
| 'not_ready'
| 'error'
applied_version?: number | null
latest_version?: number | null
}
export type Instance = GameInstance
type ReleaseChannel = 'release' | 'beta' | 'alpha'
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
export type ContentSourceKind =
| 'local'
| 'modrinth_modpack'
| 'server_project'
| 'modrinth_hosting'
| 'imported_modpack'
| 'shared_instance'
type ContentFile = {
enabled: boolean
source_kind?: ContentSourceKind | null
metadata?: {
project_id: string
version_id: string
+11
View File
@@ -0,0 +1,11 @@
import { invoke } from '@tauri-apps/api/core'
export type SearchUser = {
id: string
username: string
avatar_url: string | null
}
export async function search_user(query: string): Promise<SearchUser[]> {
return await invoke<SearchUser[]>('plugin:users|search_user', { query })
}
+253 -1
View File
@@ -89,6 +89,9 @@
"app.action-bar.install.unknown-instance": {
"message": "Unknown instance"
},
"app.action-bar.install.updating-shared-content": {
"message": "Updating shared content"
},
"app.action-bar.installs": {
"message": "Installs"
},
@@ -323,6 +326,36 @@
"app.install.phase.running_loader_processors": {
"message": "Running loader processors"
},
"app.instance.admonitions.shared-instance.added-label": {
"message": "Added"
},
"app.instance.admonitions.shared-instance.changes-body": {
"message": "Your local instance is ahead of the users you've shared it with."
},
"app.instance.admonitions.shared-instance.changes-header": {
"message": "Your changes haven't been shared yet"
},
"app.instance.admonitions.shared-instance.publish-button": {
"message": "Push update"
},
"app.instance.admonitions.shared-instance.publishing-button": {
"message": "Pushing..."
},
"app.instance.admonitions.shared-instance.removed-label": {
"message": "Removed"
},
"app.instance.admonitions.shared-instance.review-admonition-header": {
"message": "Push update"
},
"app.instance.admonitions.shared-instance.review-description": {
"message": "Review the content changes that will be shared with everyone using this instance."
},
"app.instance.admonitions.shared-instance.review-header": {
"message": "Review changes"
},
"app.instance.admonitions.shared-instance.reviewing-button": {
"message": "Reviewing..."
},
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
@@ -374,6 +407,90 @@
"app.instance.mods.successfully-uploaded": {
"message": "Successfully uploaded"
},
"app.instance.share.empty.description": {
"message": "You can share this instance with your friends!"
},
"app.instance.share.empty.heading": {
"message": "No friends invited"
},
"app.instance.share.empty.invite-friends-button": {
"message": "Invite friends"
},
"app.instance.share.invite-modal.heading": {
"message": "Share {name}"
},
"app.instance.share.locked.empty-description-prefix": {
"message": "You need to sign in as"
},
"app.instance.share.locked.empty-description-suffix": {
"message": "to access this page."
},
"app.instance.share.locked.linked-account-fallback": {
"message": "the linked account"
},
"app.instance.share.locked.signed-out-heading": {
"message": "Not signed in"
},
"app.instance.share.locked.switch-account-button": {
"message": "Switch account"
},
"app.instance.share.locked.wrong-account-heading": {
"message": "Wrong account"
},
"app.instance.share.remove-user-modal.effect-access": {
"message": "They will no longer receive updates for this shared instance"
},
"app.instance.share.remove-user-modal.effect-installed-copy": {
"message": "Any copy they already installed will stay on their device"
},
"app.instance.share.remove-user-modal.effect-invite-again": {
"message": "You can invite them again later"
},
"app.instance.share.remove-user-modal.effect-last-user": {
"message": "If this is the last user, sharing will be turned off for this instance"
},
"app.instance.share.remove-user-modal.effects-label": {
"message": "What happens?"
},
"app.instance.share.remove-user-modal.header": {
"message": "Revoke access"
},
"app.instance.share.remove-user-modal.remove-button": {
"message": "Revoke access"
},
"app.instance.share.remove-user-modal.user-avatar-alt": {
"message": "{username}'s avatar"
},
"app.instance.share.remove-user-modal.warning-body": {
"message": "If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates."
},
"app.instance.share.sign-in.button": {
"message": "Sign in"
},
"app.instance.share.unlink.body": {
"message": "You must unlink this modpack to share your instance"
},
"app.instance.share.unlink.header": {
"message": "Sharing requires unlinking"
},
"app.instance.shared-instance-wrong-account.fallback-username": {
"message": "the linked account"
},
"app.instance.shared-instance-wrong-account.owner-admonition-body-v2": {
"message": "to manage this shared instance. You won't be able to push updates to users."
},
"app.instance.shared-instance-wrong-account.sign-in-as-label": {
"message": "Sign in as"
},
"app.instance.shared-instance-wrong-account.signed-out-header": {
"message": "You need to sign in to Modrinth"
},
"app.instance.shared-instance-wrong-account.user-admonition-body-v2": {
"message": "to receive updates for this shared instance."
},
"app.instance.shared-instance-wrong-account.warning-header": {
"message": "You are using the wrong Modrinth account"
},
"app.instance.worlds.add-server": {
"message": "Add server"
},
@@ -428,6 +545,9 @@
"app.modal.install-to-play.content-required": {
"message": "Content required"
},
"app.modal.install-to-play.external-file-warning": {
"message": "{count, plural, one {This instance includes # file that is not from Modrinth.} other {This instance includes # files that are not from Modrinth.}}"
},
"app.modal.install-to-play.header": {
"message": "Install to play"
},
@@ -446,20 +566,35 @@
"app.modal.install-to-play.shared-instance": {
"message": "Shared instance"
},
"app.modal.install-to-play.shared-instance-content": {
"message": "Shared instance content"
},
"app.modal.install-to-play.shared-server-instance": {
"message": "Shared server instance"
},
"app.modal.install-to-play.trust-warning-description": {
"message": "A shared instance will install files on your computer and may include content not from Modrinth."
},
"app.modal.install-to-play.trust-warning-header": {
"message": "Do you trust this user?"
},
"app.modal.install-to-play.view-contents": {
"message": "View contents"
},
"app.modal.update-to-play.header": {
"message": "Update to play"
},
"app.modal.update-to-play.shared-instance-added-label": {
"message": "Added"
},
"app.modal.update-to-play.shared-instance-removed-label": {
"message": "Removed"
},
"app.modal.update-to-play.update-required": {
"message": "Update required"
},
"app.modal.update-to-play.update-required-description": {
"message": "An update is required to play {name}. Please update to the latest version to launch the game."
"message": "An update is required to play {name}. Please update to latest version to launch the game."
},
"app.project.install-button.already-installed": {
"message": "This project is already installed"
@@ -707,6 +842,39 @@
"app.world.world-item.players-online": {
"message": "{count} online"
},
"content.shared-instance.change-version-body": {
"message": "Changing the version only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.delete-bulk-body": {
"message": "Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again."
},
"content.shared-instance.delete-button": {
"message": "Delete anyway"
},
"content.shared-instance.delete-many-button": {
"message": "Delete {count, number} projects anyway"
},
"content.shared-instance.delete-single-body": {
"message": "Deleting it only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.disable-bulk-body": {
"message": "Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again."
},
"content.shared-instance.disable-button": {
"message": "Disable anyway"
},
"content.shared-instance.disable-many-button": {
"message": "Disable {count, number} projects anyway"
},
"content.shared-instance.disable-single-body": {
"message": "Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again."
},
"content.shared-instance.unlink-body": {
"message": "Unlinking only changes your local copy. Future shared instance updates may restore or change it again."
},
"content.shared-instance.warning-header": {
"message": "This is part of the shared instance"
},
"friends.action.add-friend": {
"message": "Add a friend"
},
@@ -770,6 +938,48 @@
"friends.sign-in-to-add-friends": {
"message": "<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!"
},
"installation-settings.shared-instance.linked-title": {
"message": "Linked shared instance"
},
"installation-settings.shared-instance.title": {
"message": "Shared instance"
},
"installation-settings.shared-instance.unlink-button": {
"message": "Unlink shared instance"
},
"installation-settings.shared-instance.unlink-description": {
"message": "Disconnect this local instance from future shared updates."
},
"installation-settings.shared-instance.unlinking-button": {
"message": "Unlinking..."
},
"installation-settings.shared-instance.unpublish-button": {
"message": "Unpublish shared instance"
},
"installation-settings.shared-instance.unpublish-description": {
"message": "Stop sharing this instance and remove it from Modrinth."
},
"installation-settings.shared-instance.unpublishing-button": {
"message": "Unpublishing..."
},
"installation-settings.unlink-shared-instance.modal.admonition-body": {
"message": "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."
},
"installation-settings.unlink-shared-instance.modal.admonition-header": {
"message": "Unlinking shared instance"
},
"installation-settings.unlink-shared-instance.modal.header": {
"message": "Unlink shared instance"
},
"installation-settings.unpublish-shared-instance.modal.admonition-body": {
"message": "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."
},
"installation-settings.unpublish-shared-instance.modal.admonition-header": {
"message": "Unpublishing shared instance"
},
"installation-settings.unpublish-shared-instance.modal.header": {
"message": "Unpublish shared instance"
},
"instance.add-server.add-and-play": {
"message": "Add and play"
},
@@ -1016,6 +1226,30 @@
"instance.settings.tabs.window.width.enter": {
"message": "Enter width..."
},
"instance.shared-instance.error.title": {
"message": "Something has gone wrong"
},
"instance.shared-instance.owner-tooltip": {
"message": "This instance's content is being shared to other users."
},
"instance.shared-instance.tooltip": {
"message": "This instance's content is being managed by someone else."
},
"instance.shared-instance.unavailable.access-revoked-text-v2": {
"message": "Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates."
},
"instance.shared-instance.unavailable.deleted-text-v2": {
"message": "The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates."
},
"instance.shared-instance.unavailable.manager-fallback": {
"message": "the instance manager"
},
"instance.shared-instance.unavailable.text-v2": {
"message": "Your local instance is still available, but it is no longer linked and won't receive updates."
},
"instance.shared-instance.unavailable.title": {
"message": "Shared instance no longer available"
},
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
@@ -1079,6 +1313,24 @@
"minecraft-account.sign-in": {
"message": "Sign in to Minecraft"
},
"modal.modrinth-account-required.create-account-button": {
"message": "Create an account"
},
"modal.modrinth-account-required.description": {
"message": "You'll need to sign into your Modrinth account before you can use this feature."
},
"modal.modrinth-account-required.header": {
"message": "Account required"
},
"modal.modrinth-account-required.sign-in-button": {
"message": "Sign in to Modrinth"
},
"modal.modrinth-account-required.sign-in-heading": {
"message": "Sign in to a Modrinth account"
},
"modal.modrinth-account-required.support-prompt": {
"message": "Having trouble signing in? <support>Get support</support>"
},
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
@@ -9,6 +9,7 @@
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
:show-play-tab="themeStore.getFeatureFlag('ServerPlayFrontend')"
:auth-user="authUser"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { ServersManagePlayPage } from '@modrinth/ui'
import { useRoute, useRouter } from 'vue-router'
import { useTheming } from '@/store/theme'
const route = useRoute()
const router = useRouter()
const themeStore = useTheming()
if (!themeStore.getFeatureFlag('ServerPlayFrontend')) {
await router.replace(`/hosting/manage/${String(route.params.id)}`)
}
</script>
<template>
<ServersManagePlayPage />
</template>
@@ -4,5 +4,6 @@ import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Overview from './Overview.vue'
import Play from './Play.vue'
export { Access, Backups, Content, Files, Index, Overview }
export { Access, Backups, Content, Files, Index, Overview, Play }
+182 -31
View File
@@ -13,6 +13,10 @@
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<SharedInstanceUpdateModal
ref="sharedInstanceUpdateModal"
@shared-instance-unavailable="handleSharedInstanceUnavailable"
/>
<ContentPageHeader>
<template #icon>
<Avatar
@@ -25,6 +29,18 @@
<template #title>
{{ instance.name }}
</template>
<template v-if="instance.shared_instance" #title-suffix>
<div
class="inline-flex h-7 items-center gap-1 rounded-full border border-solid border-blue bg-highlight-blue px-2.5 !text-base !font-normal leading-none text-blue"
>
Shared
<UnknownIcon
v-tooltip="sharedInstanceTooltip"
class="size-4 cursor-help"
aria-label="Shared instance information"
/>
</div>
</template>
<template #stats>
<div class="flex items-center flex-wrap gap-2">
<template v-if="!isServerInstance">
@@ -89,6 +105,23 @@
</router-link>
</div>
</template>
<template v-if="sharedInstanceManager">
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex min-w-0 items-center gap-[5px] font-medium">
{{ sharedInstanceManager.type === 'server' ? 'Linked to' : 'Managed by' }}
<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>
</div>
</template>
</div>
</template>
<template #actions>
@@ -217,6 +250,17 @@
</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"
/>
</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">
@@ -288,6 +332,7 @@ import {
SettingsIcon,
StopCircleIcon,
TerminalSquareIcon,
UnknownIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
@@ -296,6 +341,7 @@ import {
Avatar,
ButtonStyled,
ContentPageHeader,
defineMessages,
injectNotificationManager,
NavTabs,
OverflowMenu,
@@ -304,6 +350,7 @@ import {
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
@@ -315,8 +362,10 @@ 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 InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
@@ -325,10 +374,15 @@ 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 {
install_existing_instance,
install_pack_to_existing_instance,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import { get, get_full_path, kill, 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'
@@ -336,6 +390,8 @@ 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(duration)
dayjs.extend(relativeTime)
@@ -367,6 +423,21 @@ 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 { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const messages = defineMessages({
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.",
},
})
useLoadingBarToken(subpagePending)
@@ -385,6 +456,26 @@ 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,
setUnavailable: setSharedInstanceUnavailable,
signedOut: sharedInstanceSignedOut,
unavailableManager: sharedInstanceUnavailableManager,
unavailableReason: sharedInstanceUnavailableReason,
updatePreview: sharedInstanceUpdatePreview,
wrongAccount: sharedInstanceWrongAccount,
} = sharedInstanceState
const sharedInstanceTooltip = computed(() =>
formatMessage(
instance.value?.shared_instance?.role === 'owner'
? messages.sharedInstanceOwnerTooltip
: messages.sharedInstanceTooltip,
),
)
watch(
() => router.currentRoute.value,
@@ -418,6 +509,7 @@ async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
sharedInstanceState.reset()
resetServerStatus()
const nextInstance = await get(route.params.id as string).catch(handleError)
@@ -442,8 +534,8 @@ async function fetchInstance() {
}
const nextPreloadedContent = await contentPreloadPromise
instance.value = nextInstance ?? undefined
sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
@@ -531,29 +623,50 @@ const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const showShareTab = computed(() => {
const linkType = instance.value?.link?.type
const tabs = computed(() => [
{
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,
},
])
return (
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
})
if (instance.value) {
breadcrumbs.setName(
@@ -571,13 +684,7 @@ 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) => {
loading.value = true
try {
await run(route.params.id as string)
@@ -587,6 +694,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,
@@ -594,6 +702,49 @@ 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) return
if (loading.value || playing.value) return
const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
const canCheckSharedInstanceUpdate =
isSharedInstanceMember && !sharedInstanceActionsLocked.value && !offline.value
if (canCheckSharedInstanceUpdate) {
const preview = sharedInstanceUpdatePreview.value
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)
+66 -37
View File
@@ -12,14 +12,24 @@
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:enable-toggle="!props.isServerInstance && !isSharedMember"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
:switch-version="handleSwitchVersion"
:switch-version="
props.isServerInstance || isSharedMember ? undefined : handleSwitchVersion
"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmDisableModal
ref="sharedDisableConfirmModal"
:count="pendingModpackDisableItems.length"
:item-type="formatMessage(messages.contentTypeProject)"
:warning="managedContentPolicy.disableWarning(pendingModpackDisableItems)"
:action-disabled="isInstanceBusy"
@disable="confirmPendingModpackContentDisable"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
@@ -71,6 +81,7 @@ import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
import {
type BulkOperationStatus,
commonMessages,
ConfirmDisableModal,
ConfirmModpackUpdateModal,
ContentCardLayout as ContentPageLayout,
type ContentItem,
@@ -84,7 +95,6 @@ import {
ModpackContentModal,
type ModpackContentModalState,
type OverflowMenuOption,
provideAppBackup,
provideContentManager,
ReadyTransition,
useDebugLogger,
@@ -101,6 +111,7 @@ import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import {
@@ -108,12 +119,10 @@ import {
instance_listener,
type InstanceBulkUpdateProgress,
} from '@/helpers/events.js'
import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
import {
add_project_from_path,
edit,
get_linked_modpack_content,
list,
remove_project,
switch_project_version_with_dependencies,
toggle_disable_project,
@@ -124,6 +133,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
import type { CacheBehaviour, GameInstance } 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'
const messages = defineMessages({
@@ -185,6 +195,12 @@ const props = defineProps<{
openSettings?: () => void
preloadedContent?: InstanceContentData | null
}>()
const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
const {
isManagedModpack: isSharedMember,
canMutateContent,
canUpdateContent: canUpdateProject,
} = managedContentPolicy
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
@@ -284,6 +300,8 @@ const exportModal = ref(null)
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
const sharedDisableConfirmModal = ref<InstanceType<typeof ConfirmDisableModal> | null>()
const pendingModpackDisableItems = ref<ContentItem[]>([])
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
const modpackContentQuery = useQuery({
@@ -362,8 +380,8 @@ function hasContentOperation(item: ContentItem) {
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function canUpdateProject(item: ContentItem) {
return !!item.file_path && !!item.has_update && !!item.update_version_id
function canDeleteContent(item: ContentItem) {
return canMutateContent(item)
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
@@ -716,6 +734,7 @@ async function updateProject(mod: ContentItem) {
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
if (!canMutateContent(mod)) return
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
@@ -852,6 +871,7 @@ async function handleUpdate(id: string) {
}
async function handleSwitchVersion(item: ContentItem) {
if (!canMutateContent(item)) return
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
@@ -879,10 +899,32 @@ async function handleSwitchVersion(item: ContentItem) {
}
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning([item])) {
pendingModpackDisableItems.value = [item]
sharedDisableConfirmModal.value?.show()
return
}
await toggleDisableDebounced(item, enabled)
}
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning(items)) {
pendingModpackDisableItems.value = items
sharedDisableConfirmModal.value?.show()
return
}
await setModpackContentEnabled(items, enabled)
}
async function confirmPendingModpackContentDisable() {
const items = [...pendingModpackDisableItems.value]
pendingModpackDisableItems.value = []
await setModpackContentEnabled(items, false)
}
async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) {
await Promise.all(items.map((item) => toggleDisableMod(item, enabled)))
}
@@ -1232,29 +1274,7 @@ function applyContentData(contentData: InstanceContentData) {
return true
}
provideAppBackup({
async createBackup() {
const allInstances = await list()
const prefix = `${props.instance.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(props.instance.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
},
})
const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed'
const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null)
function dismissContentHint() {
showContentHint.value = false
localStorage.setItem(CONTENT_HINT_KEY, 'true')
}
provideInstanceBackup(() => props.instance)
provideContentManager({
items: mergedProjects,
@@ -1316,15 +1336,23 @@ provideContentManager({
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)),
items
.filter((item) => canMutateContent(item) && !item.enabled)
.map((item) => toggleDisableMod(item, true)),
).then(() => {}),
bulkDisableItems: (items: ContentItem[]) =>
Promise.all(
items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)),
items
.filter((item) => canMutateContent(item) && item.enabled)
.map((item) => toggleDisableMod(item, false)),
).then(() => {}),
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}),
canDeleteItem: canDeleteContent,
canToggleItem: canMutateContent,
getDeleteWarning: managedContentPolicy.deleteWarning,
getDisableWarning: managedContentPolicy.disableWarning,
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
@@ -1333,14 +1361,12 @@ provideContentManager({
updateItem: handleUpdate,
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
updateModpack: props.isServerInstance ? undefined : handleModpackUpdate,
updateModpack: props.isServerInstance || isSharedMember.value ? undefined : handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
showContentHint,
dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
@@ -1372,8 +1398,11 @@ provideContentManager({
link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`),
}
: undefined,
enabled: item.enabled,
enabled: canMutateContent(item) ? item.enabled : undefined,
installing: item.installing,
hideDelete: !canDeleteContent(item),
hideSwitchVersion: !canMutateContent(item),
hasUpdate: canUpdateProject(item),
}),
filterPersistKey: props.instance.id,
})
@@ -3,6 +3,7 @@ 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, Worlds }
export { Files, Index, Logs, Mods, Overview, Share, Worlds }
@@ -0,0 +1,356 @@
<template>
<div class="flex flex-col gap-4">
<ModrinthAccountRequiredModal ref="accountRequiredModal" :request-auth="requestAuth" />
<InvitePlayersModal
ref="invitePlayersModal"
:header="formatMessage(messages.shareModalHeader, { name: instance.name })"
:friends="inviteFriends"
:search-users="searchInviteUsers"
:link="inviteLink.link.value"
:link-expires-at="inviteLink.details.value?.expiresAt"
:link-max-uses="inviteLink.details.value?.maxUses"
:update-invite-link="inviteLink.update"
:user-profile-link="userProfileLink"
@invite="invitePlayer"
@cancel="cancelInvite"
/>
<ConfirmUnlinkModal
ref="unlinkModal"
:warning="{
header: formatMessage(messages.unlinkForShareHeader),
body: formatMessage(messages.unlinkForShareBody),
}"
:backup-tip="importedModpackBackupTip"
@unlink="unlinkImportedModpack"
/>
<SharedInstanceRemoveMemberModal
ref="removeMemberModal"
:row="pendingRemovalRow"
:member-count="members.rows.value.length"
@confirm="removeMember"
@clear="pendingRemovalRow = null"
/>
<SharedPlayersTable
v-if="members.rows.value.length > 0"
:rows="members.rows.value"
:actions-locked="sharedInstanceActionsLocked"
:invite-pending="inviteLink.pending.value"
:user-profile-link="userProfileLink"
@invite="showInvitePlayers"
@remove="showRemoveMemberModal"
/>
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceUnavailable"
:heading="formatMessage(sharedInstanceErrorMessages.unavailableTitle)"
:description="
formatSharedInstanceUnavailable(
sharedInstanceUnavailableReason,
sharedInstanceUnavailableManager,
)
"
/>
<SharedInstanceShareEmptyState
v-else-if="sharedInstanceActionsLocked"
:heading="formatMessage(lockedEmptyHeading)"
>
<template #description>
<span class="flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.lockedEmptyDescriptionPrefix) }}</span>
<span
v-if="linkedAccount"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-primary"
>
<Avatar
:src="linkedAccount.avatarUrl"
:alt="linkedAccount.username"
:tint-by="linkedAccount.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ linkedAccount.username }}</span>
</span>
<span v-else class="font-semibold text-primary">{{
formatMessage(messages.linkedAccountFallback)
}}</span>
<span>{{ formatMessage(messages.lockedEmptyDescriptionSuffix) }}</span>
</span>
</template>
<template #actions>
<ButtonStyled color="brand"
><button class="!h-10" @click="signInToShare">
<LogInIcon aria-hidden="true" />{{ formatMessage(lockedActionButton) }}
</button></ButtonStyled
>
</template>
</SharedInstanceShareEmptyState>
<SharedInstanceShareEmptyState
v-else
:heading="formatMessage(messages.noFriendsInvitedHeading)"
:description="formatMessage(messages.noFriendsInvitedDescription)"
>
<template #actions>
<ButtonStyled color="brand"
><button
class="!h-10"
:disabled="inviteLink.pending.value"
@click="showInvitePlayers($event)"
>
<SpinnerIcon
v-if="inviteLink.pending.value"
class="animate-spin"
aria-hidden="true"
/><UserPlusIcon v-else aria-hidden="true" />{{
formatMessage(messages.inviteFriendsButton)
}}
</button></ButtonStyled
>
</template>
</SharedInstanceShareEmptyState>
</div>
</template>
<script setup lang="ts">
import { LogInIcon, SpinnerIcon, UserPlusIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
ConfirmUnlinkModal,
defineMessages,
injectAuth,
injectNotificationManager,
type InvitePlayersInvitePayload,
InvitePlayersModal,
type InvitePlayersUser,
SharedPlayersTable,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, toRef, watch } from 'vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
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 SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue'
import type { ShareRow } from './shared-instance-share-types'
import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-candidates'
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
import { useSharedInstanceMembers } from './use-shared-instance-members'
const props = defineProps<{ instance: GameInstance }>()
const auth = injectAuth()
const queryClient = useQueryClient()
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const {
formatSharedInstanceUnavailable,
notifySharedInstanceError,
notifySharedInstanceUnavailable,
} = useSharedInstanceErrors()
const sharedInstanceState = injectSharedInstanceState()
const instance = toRef(props, 'instance')
const actionsLocked = sharedInstanceState.shareActionsLocked
const sharedInstanceActionsLocked = actionsLocked
const currentUserId = computed(() => auth.user.value?.id ?? null)
const isSignedIn = computed(() => !!auth.session_token.value)
const accountRequiredModal = ref<InstanceType<typeof ModrinthAccountRequiredModal>>()
const invitePlayersModal = ref<InstanceType<typeof InvitePlayersModal>>()
const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const removeMemberModal = ref<InstanceType<typeof SharedInstanceRemoveMemberModal>>()
const pendingRemovalRow = ref<ShareRow | null>(null)
const importedModpackUnlinked = ref(false)
function notifyOperationError(error: unknown) {
if (isSharedInstanceUnavailableError(error)) {
notifySharedInstanceUnavailable(
getSharedInstanceUnavailableReason(error),
sharedInstanceState.unavailableManager.value,
)
} else {
notifySharedInstanceError(error instanceof Error ? error : new Error(String(error)))
}
}
const members = useSharedInstanceMembers({
instance,
currentUserId,
isSignedIn,
actionsLocked,
onError: notifyOperationError,
onHydrationError: handleError,
})
const {
inviteFriends,
search: searchInviteUsers,
requestFriend,
} = useSharedInstanceInviteCandidates({
rows: members.rows,
currentUserId,
isSignedIn,
actionsLocked,
})
const inviteLink = useSharedInstanceInviteLink(
computed(() => props.instance.id),
notifyOperationError,
)
const linkedAccount = computed(() => {
const manager = sharedInstanceState.manager.value
return manager?.type === 'user'
? { username: manager.name, avatarUrl: manager.avatarUrl, tintBy: manager.tintBy }
: null
})
const lockedEmptyHeading = computed(() =>
isSignedIn.value ? messages.lockedWrongAccountHeading : messages.lockedSignedOutHeading,
)
const lockedActionButton = computed(() =>
isSignedIn.value ? messages.switchAccountButton : messages.signInButton,
)
const sharedInstanceUnavailableReason = sharedInstanceState.unavailableReason
const sharedInstanceUnavailable = computed(() => !!sharedInstanceUnavailableReason.value)
const sharedInstanceUnavailableManager = sharedInstanceState.unavailableManager
const requiresUnlink = computed(
() =>
props.instance.link?.type === 'imported_modpack' &&
!props.instance.shared_instance &&
!importedModpackUnlinked.value,
)
const importedModpackBackupTip = computed(() =>
props.instance.link?.type === 'imported_modpack'
? (props.instance.link.name ?? props.instance.link.filename ?? undefined)
: undefined,
)
const messages = defineMessages({
signInButton: { id: 'app.instance.share.sign-in.button', defaultMessage: 'Sign in' },
noFriendsInvitedHeading: {
id: 'app.instance.share.empty.heading',
defaultMessage: 'No friends invited',
},
noFriendsInvitedDescription: {
id: 'app.instance.share.empty.description',
defaultMessage: 'You can share this instance with your friends!',
},
inviteFriendsButton: {
id: 'app.instance.share.empty.invite-friends-button',
defaultMessage: 'Invite friends',
},
shareModalHeader: {
id: 'app.instance.share.invite-modal.heading',
defaultMessage: 'Share {name}',
},
lockedWrongAccountHeading: {
id: 'app.instance.share.locked.wrong-account-heading',
defaultMessage: 'Wrong account',
},
lockedSignedOutHeading: {
id: 'app.instance.share.locked.signed-out-heading',
defaultMessage: 'Not signed in',
},
lockedEmptyDescriptionPrefix: {
id: 'app.instance.share.locked.empty-description-prefix',
defaultMessage: 'You need to sign in as',
},
lockedEmptyDescriptionSuffix: {
id: 'app.instance.share.locked.empty-description-suffix',
defaultMessage: 'to access this page.',
},
linkedAccountFallback: {
id: 'app.instance.share.locked.linked-account-fallback',
defaultMessage: 'the linked account',
},
switchAccountButton: {
id: 'app.instance.share.locked.switch-account-button',
defaultMessage: 'Switch account',
},
unlinkForShareHeader: {
id: 'app.instance.share.unlink.header',
defaultMessage: 'Sharing requires unlinking',
},
unlinkForShareBody: {
id: 'app.instance.share.unlink.body',
defaultMessage: 'You must unlink this modpack to share your instance',
},
})
function invitePlayer(payload: InvitePlayersInvitePayload) {
if (actionsLocked.value) return
if (payload.source === 'search') void requestFriend(payload.user)
members.invite(payload.user)
}
function cancelInvite(user: InvitePlayersUser) {
const row = members.find(user.id, user.username)
if (row) members.remove(row.id)
}
async function showInvitePlayers(event?: MouseEvent) {
if (actionsLocked.value) return
if (!isSignedIn.value) return signInToShare(event)
if (requiresUnlink.value) return unlinkModal.value?.show()
if (await inviteLink.ensure()) invitePlayersModal.value?.show(event)
}
async function unlinkImportedModpack() {
try {
await edit(props.instance.id, { link: null as unknown as undefined })
importedModpackUnlinked.value = true
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] })
if (await inviteLink.ensure()) invitePlayersModal.value?.show()
} catch (error) {
notifyOperationError(error)
}
}
function showRemoveMemberModal(row: ShareRow) {
if (!actionsLocked.value) {
pendingRemovalRow.value = row
removeMemberModal.value?.show()
}
}
function removeMember(row: ShareRow) {
members.remove(row.id)
}
function userProfileLink(username: string) {
return !username || username.includes('@')
? undefined
: () => openUrl(`https://modrinth.com/user/${encodeURIComponent(username)}`)
}
async function requestAuth(flow: ModrinthAuthFlow) {
await auth.requestSignIn(`/instance/${encodeURIComponent(props.instance.id)}/share`, flow)
return !!auth.session_token.value
}
function signInToShare(event?: MouseEvent) {
void accountRequiredModal.value?.show(event)
}
watch(
() => props.instance.id,
() => {
importedModpackUnlinked.value = false
},
)
watch(
[() => auth.isReady.value, isSignedIn, actionsLocked],
([ready, signedIn, locked]) => {
if (ready && !signedIn && !locked) signInToShare()
},
{ immediate: true, flush: 'post' },
)
provideInstanceBackup(() => props.instance)
</script>
@@ -0,0 +1,122 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
max-width="470px"
@after-hide="emit('clear')"
>
<div class="flex flex-col gap-4">
<Admonition type="warning">{{ formatMessage(messages.warning, { username }) }}</Admonition>
<div class="flex min-w-0 items-center gap-2 rounded-[20px] bg-surface-2 p-3">
<Avatar
:src="row?.avatarUrl"
:alt="formatMessage(messages.avatarAlt, { username })"
:tint-by="username"
size="40px"
circle
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="min-w-0 truncate font-medium text-contrast">{{ username }}</span>
<span class="truncate text-sm text-secondary">{{
row ? methodLabels[row.method] : ''
}}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.effectsLabel) }}</span>
<ul class="m-0 list-disc pl-6 text-primary">
<li v-for="effect in effects" :key="effect.id" class="leading-6 marker:text-secondary">
{{ formatMessage(effect) }}
</li>
</ul>
</div>
<div class="flex justify-end gap-2 pt-1">
<ButtonStyled type="outlined"
><button class="!border !border-surface-5" @click="modal?.hide()">
<XIcon aria-hidden="true" />{{ formatMessage(commonMessages.cancelButton) }}
</button></ButtonStyled
>
<ButtonStyled color="orange"
><button :disabled="!row" @click="confirm">
<UserXIcon aria-hidden="true" />{{ formatMessage(messages.removeButton) }}
</button></ButtonStyled
>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { UserXIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { methodLabels, type ShareRow } from './shared-instance-share-types'
const props = defineProps<{ row: ShareRow | null; memberCount: number }>()
const emit = defineEmits<{ confirm: [row: ShareRow]; clear: [] }>()
const modal = ref<InstanceType<typeof NewModal>>()
const { formatMessage } = useVIntl()
const username = computed(() => props.row?.username ?? '')
const messages = defineMessages({
header: { id: 'app.instance.share.remove-user-modal.header', defaultMessage: 'Revoke access' },
warning: {
id: 'app.instance.share.remove-user-modal.warning-body',
defaultMessage:
"If you revoke {username}'s access to this shared instance, you'll need to invite them again before they can receive updates.",
},
avatarAlt: {
id: 'app.instance.share.remove-user-modal.user-avatar-alt',
defaultMessage: "{username}'s avatar",
},
effectsLabel: {
id: 'app.instance.share.remove-user-modal.effects-label',
defaultMessage: 'What happens?',
},
effectAccess: {
id: 'app.instance.share.remove-user-modal.effect-access',
defaultMessage: 'They will no longer receive updates for this shared instance',
},
effectInstalledCopy: {
id: 'app.instance.share.remove-user-modal.effect-installed-copy',
defaultMessage: 'Any copy they already installed will stay on their device',
},
effectInviteAgain: {
id: 'app.instance.share.remove-user-modal.effect-invite-again',
defaultMessage: 'You can invite them again later',
},
effectLastUser: {
id: 'app.instance.share.remove-user-modal.effect-last-user',
defaultMessage: 'If this is the last user, sharing will be turned off for this instance',
},
removeButton: {
id: 'app.instance.share.remove-user-modal.remove-button',
defaultMessage: 'Revoke access',
},
})
const effects = computed(() => {
const result = [messages.effectAccess, messages.effectInstalledCopy, messages.effectInviteAgain]
if (props.memberCount === 1) result.push(messages.effectLastUser)
return result
})
function show(event?: MouseEvent) {
modal.value?.show(event)
}
function confirm() {
if (props.row) {
modal.value?.hide()
emit('confirm', props.row)
}
}
defineExpose({ show })
</script>
@@ -0,0 +1,15 @@
<template>
<EmptyState type="empty-inbox">
<template #heading>{{ heading }}</template>
<template #description
><slot name="description">{{ description }}</slot></template
>
<template v-if="$slots.actions" #actions><slot name="actions" /></template>
</EmptyState>
</template>
<script setup lang="ts">
import { EmptyState } from '@modrinth/ui'
defineProps<{ heading: string; description?: string }>()
</script>
@@ -0,0 +1,8 @@
export {
type SharedPlayersTableMethodFilter as MethodFilter,
sharedPlayersTableMethodLabels as methodLabels,
normalizeInviteKey,
type SharedPlayersTableMethod as ShareMethod,
type SharedPlayersTableRow as ShareRow,
type SharedPlayersTableColumn as ShareTableColumn,
} from '@modrinth/ui'
@@ -0,0 +1,105 @@
import {
injectNotificationManager,
type InvitePlayersSearchUser,
type InvitePlayersUser,
} from '@modrinth/ui'
import { computed, type Ref } from 'vue'
import { useFriends } from '@/composables/use-friends'
import { getFriendUserId } from '@/helpers/friends.ts'
import { get as getCredentials } from '@/helpers/mr_auth.ts'
import { search_user } from '@/helpers/users.ts'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
export function useSharedInstanceInviteCandidates(options: {
rows: Ref<ShareRow[]>
currentUserId: Ref<string | null>
isSignedIn: Ref<boolean>
actionsLocked: Ref<boolean>
}) {
const { handleError } = injectNotificationManager()
const friendsState = useFriends({
currentUserId: options.currentUserId,
getCredentials,
enabled: computed(
() =>
options.isSignedIn.value && !!options.currentUserId.value && !options.actionsLocked.value,
),
onError: handleError,
})
const friends = friendsState.friends
const invitedRows = computed(() => {
const invited = new Map<string, ShareRow>()
for (const row of options.rows.value) {
invited.set(normalizeInviteKey(row.id), row)
invited.set(normalizeInviteKey(row.username), row)
}
return invited
})
const inviteFriends = computed<InvitePlayersUser[]>(() =>
friends.value
.filter((friend) => friend.username && friend.accepted)
.sort((a, b) => Number(b.online) - Number(a.online))
.map((friend) => {
const id = getFriendUserId(friend, options.currentUserId.value)
const invited =
invitedRows.value.get(normalizeInviteKey(id)) ??
invitedRows.value.get(normalizeInviteKey(friend.username))
return {
id,
username: friend.username,
avatarUrl: friend.avatar,
online: friend.online,
status: invited ? (invited.pending ? 'pending' : 'added') : 'available',
}
}),
)
const candidateKeys = computed(() => {
const keys = new Set<string>()
for (const friend of inviteFriends.value) {
keys.add(normalizeInviteKey(friend.id))
keys.add(normalizeInviteKey(friend.username))
}
return keys
})
async function search(query: string): Promise<InvitePlayersSearchUser[]> {
if (options.actionsLocked.value) return []
const credentials = await getCredentials()
const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
return (await search_user(query))
.filter((user) => user.id !== ownUserId)
.filter((user) => {
const id = normalizeInviteKey(user.id)
const username = normalizeInviteKey(user.username)
return (
!candidateKeys.value.has(id) &&
!candidateKeys.value.has(username) &&
!invitedRows.value.has(id) &&
!invitedRows.value.has(username)
)
})
.map((user) => ({
id: user.id,
username: user.username,
avatarUrl: user.avatar_url || undefined,
}))
}
async function requestFriend(user: InvitePlayersUser) {
if (options.actionsLocked.value) return
const credentials = await getCredentials()
const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
if (ownUserId && normalizeInviteKey(user.id) === normalizeInviteKey(ownUserId)) return
if (!friendsState.findFriend(user.id, user.username)) {
friendsState.requestFriend({
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
})
}
}
return { inviteFriends, search, requestFriend }
}
@@ -0,0 +1,63 @@
import type { InviteLinkSettings } from '@modrinth/ui'
import { computed, type Ref, ref, watch } from 'vue'
import { config } from '@/config'
import { toError } from '@/helpers/errors'
import { create_shared_instance_invite_link } from '@/helpers/instance'
export function useSharedInstanceInviteLink(
instanceId: Ref<string>,
onError: (error: unknown) => void,
) {
const details = ref<Awaited<ReturnType<typeof create_shared_instance_invite_link>>>()
const pending = ref(false)
const link = computed(() =>
details.value
? `${config.siteUrl}/share/${encodeURIComponent(details.value.inviteId)}`
: undefined,
)
async function ensure() {
if (details.value) return true
if (pending.value) return false
pending.value = true
try {
details.value = await create_shared_instance_invite_link(instanceId.value)
return true
} catch (error) {
onError(error)
return false
} finally {
pending.value = false
}
}
async function update(settings: InviteLinkSettings) {
if (!details.value) return
pending.value = true
try {
const maxAgeSeconds = Math.max(
1,
Math.min(604800, Math.floor((settings.expiresAt.getTime() - Date.now()) / 1000)),
)
details.value = await create_shared_instance_invite_link(instanceId.value, {
maxAgeSeconds,
maxUses: settings.maxUses,
replaceInviteId: details.value.inviteId,
})
} catch (error) {
throw toError(error)
} finally {
pending.value = false
}
}
watch(instanceId, () => {
details.value = undefined
pending.value = false
})
return { details, pending, link, ensure, update }
}
@@ -0,0 +1,294 @@
import type { InvitePlayersUser } from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref, ref, watch } from 'vue'
import { get_user_many } from '@/helpers/cache.js'
import { toError } from '@/helpers/errors'
import {
get_shared_instance_users,
invite_shared_instance_users,
remove_shared_instance_users,
type SharedInstanceUser,
type SharedInstanceUsers,
} from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
type MutationContext = {
queryKey: readonly ['sharedInstanceUsers', string]
previousRows?: ShareRow[]
previousPendingRows: Record<string, ShareRow>
}
export function useSharedInstanceMembers(options: {
instance: Ref<GameInstance>
currentUserId: Ref<string | null>
isSignedIn: Ref<boolean>
actionsLocked: Ref<boolean>
onError: (error: unknown) => void
onHydrationError: (error: Error) => void
}) {
const queryClient = useQueryClient()
const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const)
const pendingRows = ref<Record<string, ShareRow>>(loadPendingRows(options.instance.value.id))
async function usersToRows(users: SharedInstanceUsers): Promise<ShareRow[]> {
const excludedIds = new Set(
[options.instance.value.shared_instance?.manager_id, options.currentUserId.value].filter(
(id): id is string => !!id,
),
)
const usersToDisplay = userEntries(users).filter((user) => !excludedIds.has(user.id))
if (usersToDisplay.length === 0) return []
const profiles = (await get_user_many(usersToDisplay.map((user) => user.id))) as Array<{
id: string
username?: string
avatar_url?: string | null
}>
return usersToDisplay.map((user) => {
const profile = profiles.find((candidate) => candidate.id === user.id)
const joinedAt = parseDate(user.joined_at)
return {
id: user.id,
username: profile?.username ?? user.id,
avatarUrl: profile?.avatar_url ?? undefined,
lastPlayedAt: parseDate(user.last_played),
joinedAt,
method: user.join_type === 'link' ? 'link' : 'direct',
pending: !joinedAt,
}
})
}
async function loadRows() {
if (options.actionsLocked.value) return []
const loadedRows = await usersToRows(await get_shared_instance_users(options.instance.value.id))
removePendingRows(loadedRows.map((row) => row.id))
return loadedRows
}
const query = useQuery({
queryKey,
queryFn: loadRows,
enabled: () =>
options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value,
staleTime: Infinity,
})
const remoteRows = computed(
() => query.data.value ?? queryClient.getQueryData<ShareRow[]>(queryKey.value) ?? [],
)
const rows = computed(() => {
if (options.actionsLocked.value) return remoteRows.value
const remoteIds = new Set(remoteRows.value.map((row) => normalizeInviteKey(row.id)))
return [
...Object.values(pendingRows.value).filter(
(row) => !remoteIds.has(normalizeInviteKey(row.id)),
),
...remoteRows.value,
]
})
const inviteMutation = useMutation({
mutationFn: async (user: InvitePlayersUser) => {
if (options.actionsLocked.value) return
return await invite_shared_instance_users(options.instance.value.id, [user.id])
},
onMutate: async (user): Promise<MutationContext> => {
const currentKey = queryKey.value
await queryClient.cancelQueries({ queryKey: currentKey })
const context = {
queryKey: currentKey,
previousRows: queryClient.getQueryData<ShareRow[]>(currentKey),
previousPendingRows: pendingRows.value,
}
setPendingRow(user)
return context
},
onError: (error, user, context) => {
removePendingRow(user.id)
restore(context)
options.onError(error)
},
onSuccess: async (users, user) => {
try {
if (users) {
queryClient.setQueryData<ShareRow[]>(queryKey.value, await usersToRows(users))
} else {
upsert(inviteUserToRow(user))
}
} catch (error) {
options.onHydrationError(toError(error))
upsert(inviteUserToRow(user))
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
},
})
const removeMutation = useMutation({
mutationFn: async (id: string) => {
if (options.actionsLocked.value) {
return { user_ids: [], users: [], tokens: 0 } satisfies SharedInstanceUsers
}
return await remove_shared_instance_users(options.instance.value.id, [id])
},
onMutate: async (id): Promise<MutationContext> => {
const currentKey = queryKey.value
await queryClient.cancelQueries({ queryKey: currentKey })
const context = {
queryKey: currentKey,
previousRows: queryClient.getQueryData<ShareRow[]>(currentKey),
previousPendingRows: pendingRows.value,
}
queryClient.setQueryData<ShareRow[]>(currentKey, (currentRows = []) =>
currentRows.filter((row) => normalizeInviteKey(row.id) !== normalizeInviteKey(id)),
)
removePendingRow(id)
return context
},
onError: (error, _id, context) => {
restore(context)
options.onError(error)
},
onSuccess: async (users) => {
try {
queryClient.setQueryData<ShareRow[]>(queryKey.value, await usersToRows(users))
} catch (error) {
options.onHydrationError(toError(error))
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
},
})
function find(id: string, username: string) {
const normalizedId = normalizeInviteKey(id)
const normalizedUsername = normalizeInviteKey(username)
return rows.value.find(
(row) =>
normalizeInviteKey(row.id) === normalizedId ||
normalizeInviteKey(row.username) === normalizedUsername,
)
}
function invite(user: InvitePlayersUser) {
if (!options.actionsLocked.value && !find(user.id, user.username)) inviteMutation.mutate(user)
}
function remove(id: string) {
if (!options.actionsLocked.value) removeMutation.mutate(id)
}
function setPendingRow(user: InvitePlayersUser) {
pendingRows.value = { ...pendingRows.value, [user.id]: inviteUserToRow(user) }
savePendingRows()
}
function removePendingRow(id: string) {
const normalizedId = normalizeInviteKey(id)
pendingRows.value = Object.fromEntries(
Object.entries(pendingRows.value).filter(
([pendingId]) => normalizeInviteKey(pendingId) !== normalizedId,
),
)
savePendingRows()
}
function removePendingRows(ids: string[]) {
if (ids.length === 0) return
const normalizedIds = new Set(ids.map(normalizeInviteKey))
pendingRows.value = Object.fromEntries(
Object.entries(pendingRows.value).filter(
([id]) => !normalizedIds.has(normalizeInviteKey(id)),
),
)
savePendingRows()
}
function upsert(row: ShareRow) {
queryClient.setQueryData<ShareRow[]>(queryKey.value, (currentRows = []) => [
row,
...currentRows.filter(
(existing) => normalizeInviteKey(existing.id) !== normalizeInviteKey(row.id),
),
])
}
function restore(context?: MutationContext) {
if (!context) return
if (context.previousRows === undefined) {
queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
} else {
queryClient.setQueryData(context.queryKey, context.previousRows)
}
pendingRows.value = context.previousPendingRows
savePendingRows()
}
function savePendingRows() {
if (typeof localStorage === 'undefined') return
const key = pendingRowsStorageKey(options.instance.value.id)
const storedRows = Object.values(pendingRows.value)
if (storedRows.length === 0) localStorage.removeItem(key)
else localStorage.setItem(key, JSON.stringify(storedRows))
}
watch(
() => options.instance.value.id,
(instanceId) => {
pendingRows.value = loadPendingRows(instanceId)
},
)
return { rows, query, find, invite, remove }
}
function userEntries(users: SharedInstanceUsers): SharedInstanceUser[] {
if (users.users?.length > 0) return users.users
return users.user_ids.map((id) => ({
id,
joined_at: null,
join_type: 'invite',
last_played: null,
}))
}
function parseDate(value?: string | null) {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function inviteUserToRow(user: InvitePlayersUser): ShareRow {
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl ?? undefined,
lastPlayedAt: null,
joinedAt: null,
method: 'direct',
pending: true,
}
}
function loadPendingRows(instanceId: string): Record<string, ShareRow> {
if (typeof localStorage === 'undefined') return {}
try {
const stored = localStorage.getItem(pendingRowsStorageKey(instanceId))
if (!stored) return {}
const rows = JSON.parse(stored) as ShareRow[]
return Object.fromEntries(
rows.map((row) => [
row.id,
{ ...row, lastPlayedAt: null, joinedAt: null, pending: true } satisfies ShareRow,
]),
)
} catch {
return {}
}
}
function pendingRowsStorageKey(instanceId: string) {
return `modrinth:shared-instance-pending-users:${instanceId}`
}
@@ -0,0 +1,190 @@
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 unavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
const availabilityCheckKey = ref<string | null>(null)
const availabilityRefresh = ref(0)
let availabilityRequestId = 0
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++
availabilityCheckKey.value = null
updatePreview.value = null
unavailableReason.value = null
}
function refreshAvailability() {
availabilityRefresh.value++
}
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
availabilityRequestId++
availabilityCheckKey.value = null
updatePreview.value = null
unavailableReason.value = reason
}
async function checkAvailability(instanceId: string, key: string) {
const requestId = ++availabilityRequestId
try {
const preview = await install_get_shared_instance_update_preview(instanceId)
if (!isCurrentRequest(requestId, instanceId, key)) return
updatePreview.value = preview
unavailableReason.value = null
} catch (error) {
if (!isCurrentRequest(requestId, instanceId, key)) return
if (isSharedInstanceUnavailableError(error)) {
updatePreview.value = null
unavailableReason.value = getSharedInstanceUnavailableReason(error)
return
}
notifyError(error)
}
}
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 !== 'member' ||
locked ||
offline ||
!authReady ||
!signedIn ||
!userId
) {
availabilityRequestId++
availabilityCheckKey.value = null
updatePreview.value = null
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,
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
}
@@ -0,0 +1,25 @@
import { provideAppBackup } from '@modrinth/ui'
import { type MaybeRefOrGetter, toValue } from 'vue'
import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
import { edit, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
export function provideInstanceBackup(instance: MaybeRefOrGetter<GameInstance>) {
provideAppBackup({
async createBackup() {
const source = toValue(instance)
const prefix = `${source.name} - Backup #`
const existingNumbers = (await list())
.filter((candidate) => candidate.name.startsWith(prefix))
.map((candidate) => Number.parseInt(candidate.name.slice(prefix.length), 10))
.filter(Number.isFinite)
const nextNumber = existingNumbers.length ? Math.max(...existingNumbers) + 1 : 1
const job = await install_duplicate_instance(source.id)
const backupInstanceId = installJobInstanceId(job)
if (backupInstanceId) {
await edit(backupInstanceId, { name: `${prefix}${nextNumber}` })
}
},
})
}
@@ -1,5 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { type AuthFlow, type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
import { computed, type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
@@ -9,7 +9,7 @@ type AppCredentials = {
export function setupAuthProvider(
credentials: Ref<AppCredentials | null | undefined>,
requestSignIn: (redirectPath: string) => void | Promise<void>,
requestSignIn: (redirectPath: string, flow?: AuthFlow) => void | Promise<void>,
) {
const sessionToken = ref<string | null>(null)
const user = ref<AuthUser | null>(null)
+17
View File
@@ -41,6 +41,14 @@ export default new createRouter({
name: 'ServerManage',
component: Hosting.Index,
children: [
{
path: 'play',
name: 'ServerManagePlay',
component: Hosting.Play,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: '',
name: 'ServerManageOverview',
@@ -215,6 +223,15 @@ export default new createRouter({
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
},
},
{
path: 'share',
name: 'InstanceShare',
component: Instance.Share,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }],
},
},
{
path: '',
name: 'Mods',
+1
View File
@@ -17,6 +17,7 @@ export const DEFAULT_FEATURE_FLAGS = {
i18n_debug: false,
show_instance_play_time: true,
advanced_filters_collapsed: true,
ServerPlayFrontend: false,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
+21
View File
@@ -148,6 +148,11 @@ fn main() {
"install_get_modpack_preview",
"install_create_instance",
"install_create_modpack_instance",
"install_get_shared_instance_preview",
"install_accept_shared_instance_invite",
"install_get_shared_instance_update_preview",
"install_shared_instance",
"install_update_shared_instance",
"install_import_instance",
"install_duplicate_instance",
"install_existing_instance",
@@ -210,6 +215,14 @@ fn main() {
"instance_kill",
"instance_edit",
"instance_edit_icon",
"instance_share_get_users",
"instance_share_invite_users",
"instance_share_create_invite_link",
"instance_share_remove_users",
"instance_share_get_publish_preview",
"instance_share_publish",
"instance_share_unlink",
"instance_share_unpublish",
"instance_export_mrpack",
"instance_get_pack_export_candidates",
])
@@ -251,6 +264,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"users",
InlinedPlugin::new()
.commands(&["search_user"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"utils",
InlinedPlugin::new()
+1
View File
@@ -107,6 +107,7 @@
"settings:default",
"shortcuts:default",
"tags:default",
"users:default",
"utils:default",
"ads:default",
"friends:default",
+70
View File
@@ -6,6 +6,10 @@ use theseus::data::ModLoader;
use theseus::install::{
InstallJobSnapshot, InstallModpackPreview, InstallPostInstallEdit,
};
use theseus::instance::{
SharedInstanceInstallPreview, SharedInstanceInviteInstallPreview,
SharedInstanceUpdatePreview,
};
use theseus::pack::import::ImportLauncherType;
use theseus::pack::install_from::CreatePackLocation;
use uuid::Uuid;
@@ -16,6 +20,11 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
install_get_modpack_preview,
install_create_instance,
install_create_modpack_instance,
install_get_shared_instance_preview,
install_accept_shared_instance_invite,
install_get_shared_instance_update_preview,
install_shared_instance,
install_update_shared_instance,
install_import_instance,
install_duplicate_instance,
install_existing_instance,
@@ -101,6 +110,67 @@ pub async fn install_create_modpack_instance(
.await?)
}
#[tauri::command]
pub async fn install_get_shared_instance_preview(
shared_instance_id: String,
name: String,
) -> Result<SharedInstanceInstallPreview> {
Ok(theseus::instance::get_shared_instance_install_preview(
&shared_instance_id,
name,
)
.await?)
}
#[tauri::command]
pub async fn install_accept_shared_instance_invite(
invite_id: String,
) -> Result<SharedInstanceInviteInstallPreview> {
Ok(
theseus::instance::accept_shared_instance_invite_for_install(
&invite_id,
)
.await?,
)
}
#[tauri::command]
pub async fn install_get_shared_instance_update_preview(
instance_id: String,
) -> Result<Option<SharedInstanceUpdatePreview>> {
Ok(
theseus::instance::get_shared_instance_update_preview(&instance_id)
.await?,
)
}
#[tauri::command]
pub async fn install_shared_instance(
shared_instance_id: String,
name: String,
manager_id: Option<String>,
server_manager_name: Option<String>,
server_manager_icon_url: Option<String>,
instance_icon_url: Option<String>,
) -> Result<InstallJobSnapshot> {
Ok(theseus::instance::install_shared_instance(
&shared_instance_id,
name,
manager_id,
server_manager_name,
server_manager_icon_url,
instance_icon_url,
)
.await?)
}
#[tauri::command]
pub async fn install_update_shared_instance(
instance_id: String,
) -> Result<InstallJobSnapshot> {
Ok(theseus::instance::update_shared_instance(&instance_id).await?)
}
#[tauri::command]
pub async fn install_import_instance(
launcher_type: ImportLauncherType,
+133 -19
View File
@@ -10,6 +10,8 @@ use theseus::data::{
EditInstance as CoreEditInstance, InstanceInstallCandidate,
InstanceInstallTarget, InstanceLaunchOverridesPatch,
InstanceLink as CoreInstanceLink, InstanceMetadata, LinkedModpackInfo,
SharedInstanceAttachment as CoreSharedInstanceAttachment,
SharedInstanceRole,
};
use theseus::instance::InstallProjectWithDependenciesRequest;
use theseus::instance::QuickPlayType;
@@ -49,6 +51,14 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_kill,
instance_edit,
instance_edit_icon,
instance_share_get_users,
instance_share_invite_users,
instance_share_create_invite_link,
instance_share_remove_users,
instance_share_get_publish_preview,
instance_share_publish,
instance_share_unlink,
instance_share_unpublish,
instance_export_mrpack,
instance_get_pack_export_candidates,
])
@@ -69,6 +79,7 @@ pub struct Instance {
pub loader_version: Option<String>,
pub groups: Vec<String>,
pub link: Option<InstanceLink>,
pub shared_instance: Option<SharedInstanceAttachment>,
pub update_channel: ReleaseChannel,
pub created: chrono::DateTime<chrono::Utc>,
pub modified: chrono::DateTime<chrono::Utc>,
@@ -114,10 +125,40 @@ pub enum InstanceLink {
active_instance_id: Option<String>,
},
SharedInstance {
shared_instance_id: String,
modpack_project_id: Option<String>,
modpack_version_id: Option<String>,
},
}
#[derive(Serialize, Debug, Clone)]
pub struct SharedInstanceAttachment {
pub id: String,
pub role: SharedInstanceRole,
pub manager_id: Option<String>,
pub server_manager_name: Option<String>,
pub server_manager_icon_url: Option<String>,
pub linked_user_id: Option<String>,
pub status: String,
pub applied_version: Option<i32>,
pub latest_version: Option<i32>,
}
impl From<CoreSharedInstanceAttachment> for SharedInstanceAttachment {
fn from(attachment: CoreSharedInstanceAttachment) -> Self {
Self {
id: attachment.id.to_string(),
role: attachment.role,
manager_id: attachment.manager_id,
server_manager_name: attachment.server_manager_name,
server_manager_icon_url: attachment.server_manager_icon_url,
linked_user_id: attachment.linked_user_id,
status: attachment.status.as_str().to_string(),
applied_version: attachment.applied_version,
latest_version: attachment.latest_version,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EditInstance {
pub name: Option<String>,
@@ -200,6 +241,7 @@ impl From<InstanceMetadata> for Instance {
loader_version: metadata.applied_content_set.loader_version,
groups: metadata.groups,
link: InstanceLink::from_core(metadata.link),
shared_instance: metadata.shared_instance.map(Into::into),
update_channel: metadata.instance.update_channel,
created: metadata.instance.created,
modified: metadata.instance.modified,
@@ -267,11 +309,13 @@ impl InstanceLink {
.collect(),
active_instance_id: active_instance_id.map(|id| id.to_string()),
}),
CoreInstanceLink::SharedInstance { shared_instance_id } => {
Some(Self::SharedInstance {
shared_instance_id: shared_instance_id.to_string(),
})
}
CoreInstanceLink::SharedInstance {
modpack_project_id,
modpack_version_id,
} => Some(Self::SharedInstance {
modpack_project_id,
modpack_version_id,
}),
}
}
@@ -344,19 +388,13 @@ impl InstanceLink {
})
.transpose()?,
}),
Self::SharedInstance { shared_instance_id } => {
Ok(CoreInstanceLink::SharedInstance {
shared_instance_id: shared_instance_id.parse().map_err(
|err| {
theseus::Error::from(
theseus::ErrorKind::InputError(format!(
"Invalid shared instance id: {err}"
)),
)
},
)?,
})
}
Self::SharedInstance {
modpack_project_id,
modpack_version_id,
} => Ok(CoreInstanceLink::SharedInstance {
modpack_project_id,
modpack_version_id,
}),
}
}
}
@@ -738,3 +776,79 @@ pub async fn instance_edit_icon(
theseus::instance::edit_icon(instance_id, icon_path).await?;
Ok(())
}
#[tauri::command]
pub async fn instance_share_get_users(
instance_id: &str,
) -> Result<theseus::instance::SharedInstanceUsers> {
Ok(theseus::instance::get_shared_instance_users(instance_id).await?)
}
#[tauri::command]
pub async fn instance_share_invite_users(
instance_id: &str,
user_ids: Vec<String>,
) -> Result<theseus::instance::SharedInstanceUsers> {
Ok(
theseus::instance::invite_shared_instance_users(instance_id, user_ids)
.await?,
)
}
#[tauri::command]
pub async fn instance_share_create_invite_link(
instance_id: &str,
max_age_seconds: Option<i32>,
max_uses: Option<i32>,
replace_invite_id: Option<String>,
) -> Result<theseus::instance::SharedInstanceInviteLink> {
Ok(theseus::instance::create_shared_instance_invite_link(
instance_id,
max_age_seconds,
max_uses,
replace_invite_id,
)
.await?)
}
#[tauri::command]
pub async fn instance_share_remove_users(
instance_id: &str,
user_ids: Vec<String>,
) -> Result<theseus::instance::SharedInstanceUsers> {
Ok(
theseus::instance::remove_shared_instance_users(instance_id, user_ids)
.await?,
)
}
#[tauri::command]
pub async fn instance_share_get_publish_preview(
instance_id: &str,
) -> Result<Option<theseus::instance::SharedInstancePublishPreview>> {
Ok(
theseus::instance::get_shared_instance_publish_preview(instance_id)
.await?,
)
}
#[tauri::command]
pub async fn instance_share_publish(
instance_id: &str,
) -> Result<SharedInstanceAttachment> {
Ok(theseus::instance::publish_shared_instance(instance_id)
.await?
.into())
}
#[tauri::command]
pub async fn instance_share_unlink(instance_id: &str) -> Result<()> {
theseus::instance::unlink_shared_instance(instance_id).await?;
Ok(())
}
#[tauri::command]
pub async fn instance_share_unpublish(instance_id: &str) -> Result<()> {
theseus::instance::unpublish_shared_instance(instance_id).await?;
Ok(())
}
+13 -1
View File
@@ -15,6 +15,7 @@ pub mod process;
pub mod settings;
pub mod shortcuts;
pub mod tags;
pub mod users;
pub mod utils;
pub mod ads;
@@ -85,9 +86,20 @@ macro_rules! impl_serialize {
TheseusSerializableError::Theseus(theseus_error) => {
$crate::error::display_tracing_error(theseus_error);
let mut state = serializer.serialize_struct("Theseus", 2)?;
let unavailable_reason = match theseus_error.raw.as_ref() {
theseus::ErrorKind::SharedInstanceUnavailable(reason) => Some(reason),
_ => None,
};
let mut state = serializer.serialize_struct(
"Theseus",
if unavailable_reason.is_some() { 4 } else { 2 },
)?;
state.serialize_field("field_name", "Theseus")?;
state.serialize_field("message", &theseus_error.to_string())?;
if let Some(reason) = unavailable_reason {
state.serialize_field("code", "shared_instance_unavailable")?;
state.serialize_field("reason", reason)?;
}
state.end()
}
$(
+2 -1
View File
@@ -22,6 +22,7 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
#[tauri::command]
pub async fn modrinth_login<R: Runtime>(
app: tauri::AppHandle<R>,
flow: mr_auth::ModrinthAuthFlow,
) -> Result<ModrinthCredentials> {
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
@@ -32,7 +33,7 @@ pub async fn modrinth_login<R: Runtime>(
let auth_request_uri = format!(
"{}?launcher=true&ipver={}&port={}",
mr_auth::authenticate_begin_flow(),
mr_auth::authenticate_begin_flow(flow),
if auth_code_recv_socket.is_ipv4() {
"4"
} else {
+13
View File
@@ -0,0 +1,13 @@
use crate::api::Result;
use theseus::users::SearchUser;
#[tauri::command]
pub async fn search_user(query: &str) -> Result<Vec<SearchUser>> {
Ok(theseus::users::search_user(query).await?)
}
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("users")
.invoke_handler(tauri::generate_handler![search_user])
.build()
}
+1
View File
@@ -243,6 +243,7 @@ fn main() {
.plugin(api::settings::init())
.plugin(api::shortcuts::init())
.plugin(api::tags::init())
.plugin(api::users::init())
.plugin(api::utils::init())
.plugin(api::cache::init())
.plugin(api::files::init())
@@ -0,0 +1,290 @@
<template>
<div v-if="open" class="fixed inset-0 z-[100] flex items-center justify-center">
<div
:class="visible ? 'opacity-100' : 'opacity-0'"
class="fullscreen-overlay fixed inset-0 cursor-pointer backdrop-blur-xl transition-opacity duration-300 ease-out"
@click="hide"
/>
<div
class="relative z-[1] max-h-screen w-full max-w-[28rem] overflow-y-auto px-4 py-10 transition-all duration-300 ease-out"
:class="visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
>
<div class="flex w-full flex-col items-center gap-6">
<div class="flex w-full flex-col gap-6">
<div
v-if="countdown > 0"
class="relative mx-auto flex size-[120px] items-center justify-center"
>
<svg class="absolute size-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle
class="stroke-surface-4"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
/>
<circle
class="stroke-brand transition-[stroke-dashoffset] duration-[50ms] ease-linear"
cx="50"
cy="50"
r="45"
fill="none"
stroke-width="6"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
stroke-linecap="round"
/>
</svg>
<span class="z-[1] text-5xl font-bold text-contrast">{{ countdown }}</span>
</div>
<h2 class="m-0 text-center text-3xl font-bold text-contrast">
{{ formatMessage(messages.openingApp) }}
</h2>
<div
class="flex flex-col items-center gap-4 rounded-3xl border border-solid border-surface-5 bg-surface-3 p-6"
>
<div class="flex w-full items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar :src="instance.icon" :alt="instance.name" size="48px" />
<div class="flex min-w-0 flex-col gap-1">
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
<span class="flex items-center gap-1.5 text-sm font-medium leading-4 text-primary">
<span>{{ formatMessage(messages.invitedBy) }}</span>
<Avatar
:src="instance.inviterAvatar"
:alt="instance.inviterName"
size="16px"
circle
no-shadow
/>
<span>{{ instance.inviterName }}</span>
</span>
<span class="text-sm text-secondary">
{{
formatMessage(messages.instanceDetails, {
gameVersion: instance.gameVersion,
loaderVersion: instance.loaderVersion,
})
}}
</span>
</div>
</div>
<div class="flex flex-col gap-3 text-left">
<span class="font-semibold text-contrast">{{
formatMessage(messages.whyUseApp)
}}</span>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 text-base">
<div
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitJoin) }}</span>
</div>
<div class="flex items-center gap-2 text-base">
<div
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitInstall) }}</span>
</div>
<div class="flex items-center gap-2 text-base">
<div
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
>
<CheckIcon />
</div>
<span>{{ formatMessage(messages.benefitUpdate) }}</span>
</div>
</div>
</div>
</div>
</div>
<span v-if="countdown > 0" class="text-center text-secondary">{{
formatMessage(messages.openingAutomatically)
}}</span>
<div v-else class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<ButtonStyled>
<button class="flex-1" type="button" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<a
class="flex-1"
href="https://modrinth.com/app"
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
{{ formatMessage(messages.getApp) }}
</a>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onUnmounted, ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
openingApp: {
id: 'modal.shared-instance.open-in-app.title',
defaultMessage: 'Opening Modrinth App',
},
invitedBy: {
id: 'modal.shared-instance.open-in-app.invited-by',
defaultMessage: 'Invited by',
},
whyUseApp: {
id: 'modal.shared-instance.open-in-app.why-use',
defaultMessage: 'Why use the Modrinth App',
},
instanceDetails: {
id: 'modal.shared-instance.open-in-app.instance-details',
defaultMessage: 'Minecraft {gameVersion} · Loader {loaderVersion}',
},
benefitJoin: {
id: 'modal.shared-instance.open-in-app.benefit.join',
defaultMessage: 'Join the shared instance in one click',
},
benefitInstall: {
id: 'modal.shared-instance.open-in-app.benefit.install',
defaultMessage: 'Automatically install the required game and loader',
},
benefitUpdate: {
id: 'modal.shared-instance.open-in-app.benefit.update',
defaultMessage: 'Keep shared content updated when the instance changes',
},
openingAutomatically: {
id: 'modal.shared-instance.open-in-app.opening-automatically',
defaultMessage: 'The Modrinth App will open automatically...',
},
getApp: {
id: 'modal.shared-instance.open-in-app.get-app',
defaultMessage: 'Get Modrinth App',
},
})
export interface SharedInstanceInviteModalData {
inviteId: string
name: string
icon?: string | null
inviterName: string
inviterAvatar?: string | null
gameVersion: string
loaderVersion: string
}
const open = ref(false)
const visible = ref(false)
const countdown = ref(3)
const countdownProgress = ref(1)
const instance = ref<SharedInstanceInviteModalData>({
inviteId: '',
name: '',
icon: null,
inviterName: '',
inviterAvatar: null,
gameVersion: '',
loaderVersion: '',
})
let countdownInterval: ReturnType<typeof setInterval> | null = null
let progressInterval: ReturnType<typeof setInterval> | null = null
let showTimeout: ReturnType<typeof setTimeout> | null = null
let hideTimeout: ReturnType<typeof setTimeout> | null = null
const circumference = 2 * Math.PI * 45
const strokeDashoffset = computed(() => circumference * (1 - countdownProgress.value))
const appLink = computed(() => `modrinth://share/${encodeURIComponent(instance.value.inviteId)}`)
function startCountdown() {
countdown.value = 3
countdownProgress.value = 1
const totalDuration = 3000
const progressUpdateInterval = 16
const progressDecrement = progressUpdateInterval / totalDuration
progressInterval = setInterval(() => {
countdownProgress.value = Math.max(0, countdownProgress.value - progressDecrement)
}, progressUpdateInterval)
countdownInterval = setInterval(() => {
countdown.value--
if (countdown.value <= 0) stopCountdown()
}, 1000)
}
function stopCountdown() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
if (progressInterval) {
clearInterval(progressInterval)
progressInterval = null
}
}
async function show(options: { instance: SharedInstanceInviteModalData }) {
instance.value = options.instance
open.value = true
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
await nextTick()
window.open(appLink.value, '_self')
showTimeout = setTimeout(() => {
visible.value = true
startCountdown()
}, 50)
}
function hide() {
visible.value = false
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
stopCountdown()
if (showTimeout) clearTimeout(showTimeout)
hideTimeout = setTimeout(() => {
open.value = false
}, 300)
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') hide()
}
onUnmounted(() => {
document.body.style.overflow = ''
window.removeEventListener('keydown', handleKeyDown)
stopCountdown()
if (showTimeout) clearTimeout(showTimeout)
if (hideTimeout) clearTimeout(hideTimeout)
})
defineExpose({ show, hide, open })
</script>
<style scoped>
.fullscreen-overlay {
background: linear-gradient(to bottom, rgb(66 131 92 / 23%) 0%, rgb(17 35 43 / 40%) 97%);
}
</style>
@@ -59,6 +59,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
versionDevInfoCollapsed: true,
alwaysShowVersionDevInfo: false,
advancedFiltersCollapsed: true,
ServerPlayFrontend: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -2672,6 +2672,33 @@
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"modal.shared-instance.open-in-app.benefit.install": {
"message": "Automatically install the required game and loader"
},
"modal.shared-instance.open-in-app.benefit.join": {
"message": "Join the shared instance in one click"
},
"modal.shared-instance.open-in-app.benefit.update": {
"message": "Keep shared content updated when the instance changes"
},
"modal.shared-instance.open-in-app.get-app": {
"message": "Get Modrinth App"
},
"modal.shared-instance.open-in-app.instance-details": {
"message": "Minecraft {gameVersion} · Loader {loaderVersion}"
},
"modal.shared-instance.open-in-app.invited-by": {
"message": "Invited by"
},
"modal.shared-instance.open-in-app.opening-automatically": {
"message": "The Modrinth App will open automatically..."
},
"modal.shared-instance.open-in-app.title": {
"message": "Opening Modrinth App"
},
"modal.shared-instance.open-in-app.why-use": {
"message": "Why use the Modrinth App"
},
"moderation.exclude-technical-review": {
"message": "Exclude TR"
},
@@ -4847,6 +4874,33 @@
"settings.sidebar.label.display": {
"message": "Display"
},
"shared-instance.invite.accept": {
"message": "Accept invite"
},
"shared-instance.invite.inviter.unknown": {
"message": "A Modrinth user"
},
"shared-instance.invite.joined-count": {
"message": "{count, plural, one {# person has} other {# people have}} already joined"
},
"shared-instance.invite.loading": {
"message": "Loading invite…"
},
"shared-instance.invite.title": {
"message": "{name} has invited you to play"
},
"shared-instance.invite.trust.description": {
"message": "This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust."
},
"shared-instance.invite.trust.heading": {
"message": "Do you know this user?"
},
"shared-instance.invite.unavailable.description": {
"message": "It may have expired, reached its use limit, or been removed by the person who shared it."
},
"shared-instance.invite.unavailable.title": {
"message": "This invite isn't available"
},
"ui.latest-news-row.latest-news": {
"message": "Latest news from Modrinth"
},
@@ -5,6 +5,7 @@
:resolve-viewer="resolveViewer"
:show-copy-id-action="flags.developerMode"
:show-advanced-debug-info="flags.advancedDebugInfo"
:show-play-tab="flags.ServerPlayFrontend"
:stripe-publishable-key="config.public.stripePublishableKey as string"
:site-url="config.public.siteUrl as string"
:products="products"
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { injectModrinthServerContext, ServersManagePlayPage } from '@modrinth/ui'
const { server } = injectModrinthServerContext()
const flags = useFeatureFlags()
const route = useNativeRoute()
if (!flags.value.ServerPlayFrontend) {
await navigateTo(`/hosting/manage/${String(route.params.id)}`, { replace: true })
}
useHead({
title: computed(() => `Play - ${server.value?.name ?? 'Server'} - Modrinth`),
})
</script>
<template>
<ServersManagePlayPage />
</template>
@@ -0,0 +1,254 @@
<template>
<main
class="relative isolate min-h-screen overflow-hidden bg-surface-1 px-4 pb-16 pt-36 sm:pt-52"
>
<div
class="absolute left-1/2 top-0 -z-10 h-[23.3125rem] w-full -translate-x-1/2 overflow-hidden rounded-none !rounded-b-none sm:top-10 sm:w-[min(70rem,100%)] sm:rounded-[2.375rem]"
aria-hidden="true"
>
<img
:src="InviteBackgroundIllustration"
class="absolute inset-0 size-full scale-[2] object-cover object-[center_25%]"
alt=""
/>
<img
v-if="instanceIcon"
:src="instanceIcon"
class="absolute -inset-1/2 size-[200%] object-cover opacity-90 blur-[160px] saturate-200 [mix-blend-mode:color]"
alt=""
/>
<div
class="absolute inset-0 bg-[linear-gradient(to_bottom,transparent_20%,var(--surface-1)_100%)]"
/>
</div>
<SharedInstanceInviteOpenInAppModal ref="openInAppModal" />
<div v-if="isPending" class="relative z-10 flex flex-col items-center gap-4 text-secondary">
<LoaderCircleIcon class="size-10 animate-spin" />
<span>{{ formatMessage(messages.loadingInvite) }}</span>
</div>
<div
v-else-if="error || !invite"
class="relative z-10 mx-auto flex max-w-md flex-col items-center gap-5 text-center"
>
<CircleAlertIcon class="size-12 text-secondary" />
<div class="flex flex-col gap-2">
<h1 class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.unavailableTitle) }}
</h1>
<p class="m-0 text-primary">
{{ formatMessage(messages.unavailableDescription) }}
</p>
</div>
</div>
<div
v-else
class="relative z-10 mx-auto flex w-full max-w-[26.25rem] flex-col items-center gap-7"
>
<div class="invite-avatar relative size-[120px] shrink-0 rounded-full">
<Avatar
:src="inviterAvatar"
:alt="inviterName"
size="120px"
circle
class="!border-0 !shadow-none"
/>
</div>
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex flex-col items-center gap-2.5">
<h1 class="m-0 text-2xl font-semibold leading-8 text-contrast">
{{ formatMessage(messages.invitationTitle, { name: inviterName }) }}
</h1>
<div
class="flex w-max max-w-[calc(100vw-2rem)] flex-nowrap items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-lg leading-6 text-primary"
>
<span
class="flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap rounded-xl font-semibold text-contrast"
>
<Avatar :src="instanceIcon" :alt="invite.instance_name" size="32px" no-shadow />
<span class="min-w-0 truncate whitespace-nowrap">{{ invite.instance_name }}</span>
</span>
</div>
</div>
<div
v-if="instanceUsers.length"
class="flex items-center gap-2 rounded-full border border-solid border-surface-3 bg-surface-2 px-4 py-2 text-primary [box-shadow:0_1px_1px_rgb(0_0_0/12%)]"
>
<div class="flex shrink-0 items-center">
<Avatar
v-for="(user, index) in visibleInstanceUsers"
:key="user.id"
:src="user.avatar"
:alt="user.name"
:tint-by="user.id"
size="28px"
circle
no-shadow
class="!border-2 !border-surface-2"
:class="{ '-mr-1.5': index < visibleInstanceUsers.length - 1 || hiddenUserCount > 0 }"
/>
<div
v-if="hiddenUserCount"
class="flex size-7 items-center justify-center rounded-full border-2 border-solid border-surface-2 bg-surface-3 text-xs font-medium leading-4 text-primary"
>
+{{ hiddenUserCount }}
</div>
</div>
{{ formatMessage(messages.joinedCount, { count: instanceUsers.length }) }}
</div>
</div>
<ButtonStyled color="brand" size="large">
<button type="button" @click="acceptInvite">
<UserPlusIcon />
{{ formatMessage(messages.acceptInvite) }}
</button>
</ButtonStyled>
<div class="flex w-full flex-col gap-2.5">
<span class="pl-3 font-medium text-primary">{{
formatMessage(messages.knowThisUser)
}}</span>
<Admonition type="neutral" class="shadow-card !text-primary">
<template #icon="{ iconClass }">
<CircleAlertIcon :class="[...iconClass, '!text-orange']" />
</template>
{{ formatMessage(messages.trustWarning) }}
</Admonition>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import {
CircleAlertIcon,
InviteBackgroundIllustration,
LoaderCircleIcon,
UserPlusIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
ButtonStyled,
defineMessages,
injectModrinthClient,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, onServerPrefetch, useTemplateRef } from 'vue'
import SharedInstanceInviteOpenInAppModal from '~/components/ui/SharedInstanceInviteOpenInAppModal.vue'
useSeoMeta({
robots: 'noindex',
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
loadingInvite: {
id: 'shared-instance.invite.loading',
defaultMessage: 'Loading invite…',
},
unavailableTitle: {
id: 'shared-instance.invite.unavailable.title',
defaultMessage: "This invite isn't available",
},
unavailableDescription: {
id: 'shared-instance.invite.unavailable.description',
defaultMessage:
'It may have expired, reached its use limit, or been removed by the person who shared it.',
},
invitationTitle: {
id: 'shared-instance.invite.title',
defaultMessage: '{name} has invited you to play',
},
unknownInviter: {
id: 'shared-instance.invite.inviter.unknown',
defaultMessage: 'A Modrinth user',
},
joinedCount: {
id: 'shared-instance.invite.joined-count',
defaultMessage: '{count, plural, one {# person has} other {# people have}} already joined',
},
acceptInvite: {
id: 'shared-instance.invite.accept',
defaultMessage: 'Accept invite',
},
knowThisUser: {
id: 'shared-instance.invite.trust.heading',
defaultMessage: 'Do you know this user?',
},
trustWarning: {
id: 'shared-instance.invite.trust.description',
defaultMessage:
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
},
})
const route = useRoute()
const client = injectModrinthClient()
const inviteId = computed(() => String(route.params.inviteId))
const openInAppModal = useTemplateRef('openInAppModal')
const {
data: invite,
error,
isPending,
suspense,
} = useQuery({
queryKey: computed(() => ['shared-instance-invite', inviteId.value]),
queryFn: () => client.sharedinstances.invites_v1.get(inviteId.value),
retry: false,
})
const instanceIcon = computed(() => invite.value?.instance_icon ?? null)
const instanceUsers = computed(() => invite.value?.instance_users ?? [])
const visibleInstanceUsers = computed(() => instanceUsers.value.slice(0, 4))
const hiddenUserCount = computed(() =>
Math.max(0, instanceUsers.value.length - visibleInstanceUsers.value.length),
)
onServerPrefetch(() => suspense())
const inviter = computed(
() =>
invite.value?.managers.find((manager) => manager.type === 'user') ?? invite.value?.managers[0],
)
const inviterName = computed(() => inviter.value?.name ?? formatMessage(messages.unknownInviter))
const inviterAvatar = computed(() => {
if (!inviter.value) return null
return inviter.value.type === 'user' ? inviter.value.avatar : inviter.value.icon
})
function acceptInvite() {
if (!invite.value) return
openInAppModal.value?.show({
instance: {
inviteId: inviteId.value,
name: invite.value.instance_name,
icon: invite.value.instance_icon,
inviterName: inviterName.value,
inviterAvatar: inviterAvatar.value,
gameVersion: invite.value.game_version,
loaderVersion: invite.value.loader_version,
},
})
}
</script>
<style scoped>
.invite-avatar {
box-shadow:
0 0 0 8px rgb(255 255 255 / 5%),
0 24px 48px rgb(0 0 0 / 3%),
0 10px 18px rgb(0 0 0 / 3%),
0 5px 8px rgb(0 0 0 / 4%),
0 2px 4px rgb(0 0 0 / 4%);
}
</style>
@@ -43,6 +43,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
public readonly launchermeta!: InferredClientModules['launchermeta']
public readonly paper!: InferredClientModules['paper']
public readonly purpur!: InferredClientModules['purpur']
public readonly sharedinstances!: InferredClientModules['sharedinstances']
constructor(config: ClientConfig) {
super()
+2
View File
@@ -57,6 +57,7 @@ import { MclogsInsightsV1Module } from './mclogs/insights/v1'
import { MclogsLogsV1Module } from './mclogs/logs/v1'
import { PaperVersionsV3Module } from './paper/v3'
import { PurpurVersionsV2Module } from './purpur/v2'
import { SharedInstancesInvitesV1Module } from './shared-instances/invites/v1'
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
@@ -128,6 +129,7 @@ export const MODULE_REGISTRY = {
labrinth_versions_v3: LabrinthVersionsV3Module,
paper_versions_v3: PaperVersionsV3Module,
purpur_versions_v2: PurpurVersionsV2Module,
sharedinstances_invites_v1: SharedInstancesInvitesV1Module,
} as const satisfies Record<string, ModuleConstructor>
export type ModuleID = keyof typeof MODULE_REGISTRY
@@ -0,0 +1,23 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { SharedInstances } from '../types'
const SHARED_INSTANCES_BASE_URL = 'https://shared-instances.modrinth.com'
export class SharedInstancesInvitesV1Module extends AbstractModule {
public getModuleID(): string {
return 'sharedinstances_invites_v1'
}
public async get(inviteId: string): Promise<SharedInstances.Invites.v1.Invite> {
return this.client.request<SharedInstances.Invites.v1.Invite>(
`/invites/${encodeURIComponent(inviteId)}`,
{
api: SHARED_INSTANCES_BASE_URL,
version: 1,
method: 'GET',
skipAuth: true,
retry: false,
},
)
}
}
@@ -0,0 +1,36 @@
export namespace SharedInstances {
export namespace Invites {
export namespace v1 {
export type UserManager = {
id: string
name: string
type: 'user'
avatar?: string | null
}
export type ServerManager = {
name: string
type: 'server'
icon?: string | null
}
export type Manager = UserManager | ServerManager
export type InviteUser = {
id: string
name: string
avatar?: string | null
}
export type Invite = {
instance_id: string
instance_name: string
instance_icon?: string | null
game_version: string
loader_version: string
managers: Manager[]
instance_users?: InviteUser[]
}
}
}
}
+1
View File
@@ -6,3 +6,4 @@ export * from './launcher-meta/types'
export * from './mclogs/types'
export * from './paper/types'
export * from './purpur/types'
export * from './shared-instances/types'
+1
View File
@@ -1,5 +1,6 @@
MODRINTH_URL=http://localhost:3000/
MODRINTH_API_BASE_URL=http://localhost:8000/
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/
MODRINTH_API_URL=http://127.0.0.1:8000/v2/
MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
+1
View File
@@ -1,5 +1,6 @@
MODRINTH_URL=https://modrinth.com/
MODRINTH_API_BASE_URL=https://api.modrinth.com/
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/
MODRINTH_ARCHON_BASE_URL=https://archon.modrinth.com/
MODRINTH_API_URL=https://api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
@@ -1,5 +1,6 @@
MODRINTH_URL=https://modrinth.com/
MODRINTH_API_BASE_URL=https://api.modrinth.com/
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com/
MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/
MODRINTH_API_URL=https://api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
+1
View File
@@ -1,5 +1,6 @@
MODRINTH_URL=https://staging.modrinth.com/
MODRINTH_API_BASE_URL=https://staging-api.modrinth.com/
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com/
MODRINTH_ARCHON_BASE_URL=https://staging-archon.modrinth.com/
MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
@@ -0,0 +1,296 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_name?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_icon_url?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "shared_instance_linked_user_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_applied_update_id?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 43,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 44,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 45,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 46,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
null,
null
]
},
"hash": "0a708a6410e4d7d4cbdb07fa6ea32383be454526177686a0685a988af747db0f"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_content_set_remote_refs (\n\t\t\tcontent_set_id,\n\t\t\tref_type,\n\t\t\tref_id\n\t\t)\n\t\tVALUES (?, ?, ?)\n\t\tON CONFLICT (content_set_id, ref_type) DO UPDATE SET\n\t\t\tref_id = excluded.ref_id\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "2bf4029dd68e983460b8a0b06e1209885ef8e87ad2a8666a9cf1c851a2948cbc"
}
@@ -1,248 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n '[]' AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 37,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
null,
null
]
},
"hash": "474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_content_set_sync_state (\n\t\t\tcontent_set_id,\n\t\t\tprovider,\n\t\t\tapplied_update_id,\n\t\t\tlatest_available_update_id,\n\t\t\tchecked_at,\n\t\t\tstatus\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (content_set_id) DO UPDATE SET\n\t\t\tprovider = excluded.provider,\n\t\t\tapplied_update_id = excluded.applied_update_id,\n\t\t\tlatest_available_update_id = excluded.latest_available_update_id,\n\t\t\tchecked_at = excluded.checked_at,\n\t\t\tstatus = excluded.status\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "6231cfa8f3b21c0fd502e16f823881fd9c44bccaedb9b06585ff8f76146c6e57"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ",
"query": "\n SELECT\n instance_id,\n link_kind,\n modrinth_project_id,\n modrinth_version_id,\n server_project_id,\n content_project_id,\n content_version_id,\n hosting_server_id,\n json(hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n hosting_active_instance_id,\n shared_instance_id,\n shared_instance_role,\n shared_instance_manager_id,\n shared_instance_linked_user_id,\n imported_name,\n imported_version_number,\n imported_filename\n FROM instance_links\n WHERE instance_id = ?\n ",
"describe": {
"columns": [
{
@@ -59,19 +59,34 @@
"type_info": "Text"
},
{
"name": "imported_name",
"name": "shared_instance_role",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "imported_version_number",
"name": "shared_instance_manager_id",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "imported_filename",
"name": "shared_instance_linked_user_id",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "imported_name",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "imported_version_number",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "imported_filename",
"ordinal": 16,
"type_info": "Text"
}
],
"parameters": {
@@ -91,8 +106,11 @@
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "26d23094e9d76e5595d5e5a0cd6bc631c45e6090bf40b26e25024de434fe0281"
"hash": "6db40d30b3beca48327edfa92dfba2c8b9074904d1012079660de316a20e7dfa"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tDELETE FROM instance_content_set_remote_refs\n\t\tWHERE content_set_id = ? AND ref_type = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "76988815779ec3c1def6712dcb4156fd72100b0cf97b4ecb11045c6c4f550652"
}
@@ -0,0 +1,296 @@
{
"db_name": "SQLite",
"query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n \n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n \n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n \n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_name?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_icon_url?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "shared_instance_linked_user_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_applied_update_id?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 43,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 44,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 45,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 46,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
null,
null
]
},
"hash": "88a0520cb21aaea25bf12a5ad07e7f0614e912494f850e77dfc502b1afe7c42a"
}
@@ -0,0 +1,296 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: String\",\n link.shared_instance_server_manager_name AS \"shared_instance_server_manager_name?: String\",\n link.shared_instance_server_manager_icon_url AS \"shared_instance_server_manager_icon_url?: String\",\n link.shared_instance_linked_user_id AS \"shared_instance_linked_user_id?: String\",\n sync.applied_update_id AS \"shared_sync_applied_update_id?: String\",\n sync.latest_available_update_id AS \"shared_sync_latest_available_update_id?: String\",\n sync.status AS \"shared_sync_status?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_content_set_sync_state sync\n ON sync.content_set_id = cs.id\n AND sync.provider = 'shared_instance'\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE 1 = ?",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Text"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_name?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "shared_instance_server_manager_icon_url?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "shared_instance_linked_user_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_applied_update_id?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 43,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 44,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 45,
"type_info": "Text"
},
{
"name": "launch_overrides?: String",
"ordinal": 46,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
null
]
},
"hash": "aaf6e6a3de2dd9cb1c9db5dd9de2a6590b89c4f72449fdd34a7f140e2a11bf04"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tshared_instance_id,\n\t\t\tshared_instance_role,\n\t\t\tshared_instance_manager_id,\n\t\t\tshared_instance_server_manager_name,\n\t\t\tshared_instance_server_manager_icon_url,\n\t\t\tshared_instance_linked_user_id\n\t\t)\n\t\tVALUES (?, 'unmanaged', ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = CASE\n\t\t\t\tWHEN excluded.shared_instance_id IS NULL\n\t\t\t\t\tAND instance_links.link_kind = 'shared_instance'\n\t\t\t\t\tTHEN 'unmanaged'\n\t\t\t\tELSE instance_links.link_kind\n\t\t\tEND,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\tshared_instance_role = excluded.shared_instance_role,\n\t\t\tshared_instance_manager_id = excluded.shared_instance_manager_id,\n\t\t\tshared_instance_server_manager_name = excluded.shared_instance_server_manager_name,\n\t\t\tshared_instance_server_manager_icon_url = excluded.shared_instance_server_manager_icon_url,\n\t\t\tshared_instance_linked_user_id = excluded.shared_instance_linked_user_id\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "b9ce6ff27e3b4d62ff0ae2bed0d00cae0b4b758c0674ac81659ee9d86667f19c"
}
@@ -1,248 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n WHERE i.id = ?\n ",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 37,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
null,
null
]
},
"hash": "bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tDELETE FROM instance_content_set_sync_state\n\t\tWHERE content_set_id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "becbba7c6dfb4b22520cc1887718af544c08d3e274a930a68daf3e94e3612243"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "bf813910a5b0db8563a689088eedf95c67b5ceda04aa1b0eb782b581e23ec06b"
}
@@ -1,248 +0,0 @@
{
"db_name": "SQLite",
"query": "\n WITH requested AS (\n SELECT value AS id, key AS ord\n FROM json_each(?)\n )\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM requested\n INNER JOIN instances i\n ON i.id = requested.id\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ORDER BY requested.ord\n ",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Null"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 37,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
null,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
null,
null
]
},
"hash": "e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO instance_links (\n\t\t\tinstance_id,\n\t\t\tlink_kind,\n\t\t\tmodrinth_project_id,\n\t\t\tmodrinth_version_id,\n\t\t\tserver_project_id,\n\t\t\tcontent_project_id,\n\t\t\tcontent_version_id,\n\t\t\thosting_server_id,\n\t\t\thosting_instance_ids,\n\t\t\thosting_active_instance_id,\n\t\t\tshared_instance_id,\n\t\t\timported_name,\n\t\t\timported_version_number,\n\t\t\timported_filename\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?)\n\t\tON CONFLICT (instance_id) DO UPDATE SET\n\t\t\tlink_kind = excluded.link_kind,\n\t\t\tmodrinth_project_id = excluded.modrinth_project_id,\n\t\t\tmodrinth_version_id = excluded.modrinth_version_id,\n\t\t\tserver_project_id = excluded.server_project_id,\n\t\t\tcontent_project_id = excluded.content_project_id,\n\t\t\tcontent_version_id = excluded.content_version_id,\n\t\t\thosting_server_id = excluded.hosting_server_id,\n\t\t\thosting_instance_ids = excluded.hosting_instance_ids,\n\t\t\thosting_active_instance_id = excluded.hosting_active_instance_id,\n\t\t\tshared_instance_id = excluded.shared_instance_id,\n\t\t\timported_name = excluded.imported_name,\n\t\t\timported_version_number = excluded.imported_version_number,\n\t\t\timported_filename = excluded.imported_filename\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "eca3e44d4ba62e218289fa058dd563cebedb6b8add6c936c551a857783645ff8"
}
@@ -1,248 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n i.id AS \"id!: String\",\n i.path AS \"path!: String\",\n i.applied_content_set_id AS \"applied_content_set_id?: String\",\n i.install_stage AS \"install_stage!: String\",\n i.launcher_feature_version AS \"launcher_feature_version!: String\",\n i.update_channel AS \"update_channel!: String\",\n i.name AS \"name!: String\",\n i.icon_path AS \"icon_path?: String\",\n i.created AS \"created!: i64\",\n i.modified AS \"modified!: i64\",\n i.last_played AS \"last_played?: i64\",\n i.submitted_time_played AS \"submitted_time_played!: i64\",\n i.recent_time_played AS \"recent_time_played!: i64\",\n cs.id AS \"content_set_id?: String\",\n cs.instance_id AS \"content_set_instance_id?: String\",\n cs.name AS \"content_set_name?: String\",\n cs.source_kind AS \"content_set_source_kind?: String\",\n cs.status AS \"content_set_status?: String\",\n cs.game_version AS \"content_set_game_version?: String\",\n cs.protocol_version AS \"content_set_protocol_version?: i64\",\n cs.loader AS \"content_set_loader?: String\",\n cs.loader_version AS \"content_set_loader_version?: String\",\n cs.created AS \"content_set_created?: i64\",\n cs.modified AS \"content_set_modified?: i64\",\n COALESCE(link.link_kind, 'unmanaged') AS \"link_kind!: String\",\n link.modrinth_project_id AS \"modrinth_project_id?: String\",\n link.modrinth_version_id AS \"modrinth_version_id?: String\",\n link.server_project_id AS \"server_project_id?: String\",\n link.content_project_id AS \"content_project_id?: String\",\n link.content_version_id AS \"content_version_id?: String\",\n link.hosting_server_id AS \"hosting_server_id?: String\",\n json(link.hosting_instance_ids) AS \"hosting_instance_ids?: String\",\n link.hosting_active_instance_id AS \"hosting_active_instance_id?: String\",\n link.shared_instance_id AS \"shared_instance_id?: String\",\n link.imported_name AS \"imported_name?: String\",\n link.imported_version_number AS \"imported_version_number?: String\",\n link.imported_filename AS \"imported_filename?: String\",\n COALESCE((\n SELECT json_group_array(group_name)\n FROM (\n SELECT group_name\n FROM instance_groups\n WHERE instance_id = i.id\n ORDER BY group_name\n )\n ), '[]') AS \"groups!: String\",\n json(overrides.overrides) AS \"launch_overrides?: String\"\n FROM instances i\n LEFT JOIN instance_content_sets cs\n ON cs.id = i.applied_content_set_id\n AND cs.instance_id = i.id\n LEFT JOIN instance_links link\n ON link.instance_id = i.id\n LEFT JOIN instance_launch_overrides overrides\n ON overrides.instance_id = i.id\n ",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "path!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "applied_content_set_id?: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "install_stage!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "launcher_feature_version!: String",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "update_channel!: String",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "name!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "icon_path?: String",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "created!: i64",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "modified!: i64",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "last_played?: i64",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "submitted_time_played!: i64",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "recent_time_played!: i64",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "content_set_id?: String",
"ordinal": 13,
"type_info": "Text"
},
{
"name": "content_set_instance_id?: String",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "content_set_name?: String",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "content_set_source_kind?: String",
"ordinal": 16,
"type_info": "Text"
},
{
"name": "content_set_status?: String",
"ordinal": 17,
"type_info": "Text"
},
{
"name": "content_set_game_version?: String",
"ordinal": 18,
"type_info": "Text"
},
{
"name": "content_set_protocol_version?: i64",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "content_set_loader?: String",
"ordinal": 20,
"type_info": "Text"
},
{
"name": "content_set_loader_version?: String",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "content_set_created?: i64",
"ordinal": 22,
"type_info": "Integer"
},
{
"name": "content_set_modified?: i64",
"ordinal": 23,
"type_info": "Integer"
},
{
"name": "link_kind!: String",
"ordinal": 24,
"type_info": "Text"
},
{
"name": "modrinth_project_id?: String",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "modrinth_version_id?: String",
"ordinal": 26,
"type_info": "Text"
},
{
"name": "server_project_id?: String",
"ordinal": 27,
"type_info": "Text"
},
{
"name": "content_project_id?: String",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "content_version_id?: String",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "hosting_server_id?: String",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "hosting_instance_ids?: String",
"ordinal": 31,
"type_info": "Null"
},
{
"name": "hosting_active_instance_id?: String",
"ordinal": 32,
"type_info": "Text"
},
{
"name": "shared_instance_id?: String",
"ordinal": 33,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"type_info": "Null"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
false,
false,
false,
false,
true,
false,
false,
true,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
true,
true,
true,
true,
true,
null,
true,
true,
true,
true,
true,
false,
null
]
},
"hash": "f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b"
}
@@ -0,0 +1,2 @@
ALTER TABLE instance_links
ADD COLUMN shared_instance_role TEXT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE instance_links
ADD COLUMN shared_instance_manager_id TEXT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE instance_links
ADD COLUMN shared_instance_linked_user_id TEXT NULL;
@@ -0,0 +1,5 @@
ALTER TABLE instance_links
ADD COLUMN shared_instance_server_manager_name TEXT NULL;
ALTER TABLE instance_links
ADD COLUMN shared_instance_server_manager_icon_url TEXT NULL;
+20
View File
@@ -30,6 +30,26 @@ pub async fn handle_url(sublink: &str) -> crate::Result<CommandPayload> {
Some(("server", id)) => {
CommandPayload::InstallServer { id: id.to_string() }
}
// /share/{invite_id}
Some(("share", raw)) => {
let (raw, _) = raw.split_once('?').unwrap_or((raw, ""));
match decode(raw) {
Ok(decoded) => CommandPayload::InstallSharedInstanceInvite {
invite_id: decoded.to_string(),
},
Err(e) => {
emit_warning(&format!(
"Invalid UTF-8 in shared instance invite path: {e}"
))
.await?;
return Err(crate::ErrorKind::InputError(format!(
"Invalid UTF-8 in shared instance invite path: {e}"
))
.into());
}
}
}
// /launch/instance/{id} - Launches an instance
Some(("launch", rest)) if rest.starts_with("instance/") => {
let raw = rest.trim_start_matches("instance/");
+17
View File
@@ -1,6 +1,7 @@
//! Theseus instance management interface
mod content;
mod content_set_diff;
mod export_mrpack;
mod get;
mod install;
@@ -8,6 +9,7 @@ mod lifecycle;
mod paths;
mod projects;
mod run;
mod shared;
pub use self::content::{
get_content_items, get_dependencies_as_content_items,
@@ -33,3 +35,18 @@ pub use self::projects::{
pub use self::run::{
QuickPlayType, kill, run, try_update_playtime_by_instance_id,
};
pub use self::shared::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInviteInstallPreview, SharedInstanceInviteLink,
SharedInstanceJoinType, SharedInstancePublishPreview,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers,
accept_pending_shared_instance_invite,
accept_shared_instance_invite_for_install,
create_shared_instance_invite_link, decline_pending_shared_instance_invite,
get_shared_instance_install_preview, get_shared_instance_publish_preview,
get_shared_instance_update_preview, get_shared_instance_users,
install_shared_instance, invite_shared_instance_users,
publish_shared_instance, remove_shared_instance_users,
unlink_shared_instance, unpublish_shared_instance, update_shared_instance,
};
@@ -0,0 +1,152 @@
use crate::state::Version;
use std::collections::{HashMap, HashSet};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ContentSetDiffKind {
Added,
Removed,
Updated,
}
#[derive(Clone, Debug)]
pub(crate) enum ContentSetDiffEntry {
Project {
kind: ContentSetDiffKind,
project_id: String,
current_version_name: Option<String>,
new_version_name: Option<String>,
disabled: bool,
},
ExternalFile {
kind: ContentSetDiffKind,
file_name: String,
disabled: bool,
},
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ContentSetDiffOptions {
pub removed_disabled_project_ids: HashSet<String>,
pub removed_disabled_external_files: HashSet<String>,
pub common_external_files_are_updated: bool,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ContentSetSnapshot {
pub versions: Vec<ContentSetSnapshotVersion>,
pub external_files: HashSet<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct ContentSetSnapshotVersion {
pub project_id: String,
pub version_id: String,
pub version_name: String,
}
impl From<Version> for ContentSetSnapshotVersion {
fn from(version: Version) -> Self {
Self {
project_id: version.project_id,
version_id: version.id,
version_name: version.version_number,
}
}
}
pub(crate) fn diff_content_sets(
current: &ContentSetSnapshot,
latest: &ContentSetSnapshot,
options: &ContentSetDiffOptions,
) -> Vec<ContentSetDiffEntry> {
let current_versions = versions_by_project(current);
let latest_versions = versions_by_project(latest);
let project_ids = current_versions
.keys()
.chain(latest_versions.keys())
.copied()
.collect::<HashSet<_>>();
let mut diffs = Vec::new();
for project_id in project_ids {
let current = current_versions.get(project_id);
let latest = latest_versions.get(project_id);
match (current, latest) {
(None, Some(latest)) => {
diffs.push(ContentSetDiffEntry::Project {
kind: ContentSetDiffKind::Added,
project_id: project_id.to_string(),
current_version_name: None,
new_version_name: Some(latest.version_name.clone()),
disabled: false,
});
}
(Some(current), None) => {
diffs.push(ContentSetDiffEntry::Project {
kind: ContentSetDiffKind::Removed,
project_id: project_id.to_string(),
current_version_name: Some(current.version_name.clone()),
new_version_name: None,
disabled: options
.removed_disabled_project_ids
.contains(project_id),
});
}
(Some(current), Some(latest))
if current.version_id != latest.version_id =>
{
diffs.push(ContentSetDiffEntry::Project {
kind: ContentSetDiffKind::Updated,
project_id: project_id.to_string(),
current_version_name: Some(current.version_name.clone()),
new_version_name: Some(latest.version_name.clone()),
disabled: false,
});
}
_ => {}
}
}
for file_name in latest.external_files.difference(&current.external_files) {
diffs.push(ContentSetDiffEntry::ExternalFile {
kind: ContentSetDiffKind::Added,
file_name: file_name.clone(),
disabled: false,
});
}
if options.common_external_files_are_updated {
for file_name in
latest.external_files.intersection(&current.external_files)
{
diffs.push(ContentSetDiffEntry::ExternalFile {
kind: ContentSetDiffKind::Updated,
file_name: file_name.clone(),
disabled: false,
});
}
}
for file_name in current.external_files.difference(&latest.external_files) {
diffs.push(ContentSetDiffEntry::ExternalFile {
kind: ContentSetDiffKind::Removed,
file_name: file_name.clone(),
disabled: options
.removed_disabled_external_files
.contains(file_name),
});
}
diffs
}
fn versions_by_project(
snapshot: &ContentSetSnapshot,
) -> HashMap<&str, &ContentSetSnapshotVersion> {
snapshot
.versions
.iter()
.map(|version| (version.project_id.as_str(), version))
.collect()
}
+16 -1
View File
@@ -101,12 +101,27 @@ pub async fn edit_icon(
crate::state::edit_instance(
instance_id,
EditInstance {
icon_path: Some(icon_path),
icon_path: Some(icon_path.clone()),
..EditInstance::default()
},
&state.pool,
)
.await?;
if let Err(error) = super::shared::sync_shared_instance_icon(
instance_id,
icon_path.as_deref(),
&state,
)
.await
{
tracing::warn!(
instance_id,
error = %error,
"Failed to sync shared instance icon"
);
}
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
Ok(())
+66 -9
View File
@@ -1,7 +1,7 @@
use crate::event::emit::{emit_instance, emit_loading, init_loading};
use crate::event::{InstancePayloadType, LoadingBarType};
use crate::state::instances::adapters::sqlite::instance_rows;
use crate::state::{ProjectType, State};
use crate::state::{ContentSourceKind, ProjectType, State};
use crate::util::fetch;
use modrinth_content_management::{
ContentType, ResolutionPreferences, ResolveContentPlan,
@@ -51,13 +51,18 @@ pub async fn update_project(
skip_send_event: Option<bool>,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(
instance_id,
project_path,
&state,
)
.await?;
let path = crate::state::instances::commands::update_project(
instance_id,
project_path,
&state,
)
.await?;
if !skip_send_event.unwrap_or(false) {
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
@@ -181,6 +186,12 @@ pub async fn switch_project_version_with_dependencies(
version_id: &str,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(
instance_id,
project_path,
&state,
)
.await?;
let metadata = super::get::get(instance_id).await?.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
@@ -204,13 +215,17 @@ pub async fn add_project_from_path(
project_type: Option<ProjectType>,
) -> crate::Result<String> {
let state = State::get().await?;
crate::state::instances::commands::add_project_from_path(
instance_id,
path,
project_type,
&state,
)
.await
let project_path =
crate::state::instances::commands::add_project_from_path(
instance_id,
path,
project_type,
&state,
)
.await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(project_path)
}
#[tracing::instrument]
@@ -220,6 +235,8 @@ pub async fn toggle_disable_project(
desired_enabled: Option<bool>,
) -> crate::Result<String> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(instance_id, project, &state)
.await?;
let res = crate::state::instances::commands::toggle_disable_project(
instance_id,
project,
@@ -238,6 +255,8 @@ pub async fn remove_project(
project: &str,
) -> crate::Result<()> {
let state = State::get().await?;
ensure_shared_instance_can_modify_project(instance_id, project, &state)
.await?;
crate::state::instances::commands::remove_project(
instance_id,
project,
@@ -249,6 +268,44 @@ pub async fn remove_project(
Ok(())
}
async fn ensure_shared_instance_can_modify_project(
instance_id: &str,
project_path: &str,
state: &State,
) -> crate::Result<()> {
let metadata = crate::state::instances::commands::get_instance_metadata(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
if !metadata
.shared_instance
.is_some_and(|attachment| attachment.role.is_member())
{
return Ok(());
}
let source_kind =
crate::state::instances::commands::content_source_kind_for_project_path(
instance_id,
project_path,
state,
)
.await?;
if source_kind.is_some_and(ContentSourceKind::is_shared_instance_managed) {
return Err(crate::ErrorKind::InputError(
"Shared instance managed content cannot be changed directly."
.to_string(),
)
.into());
}
Ok(())
}
#[tracing::instrument]
pub async fn update_managed_modrinth_version(
instance_id: &str,

Some files were not shown because too many files have changed in this diff Show More