Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 58fa954848 Merge branch 'cal/peer-to-peer' into cal/invite-links-impl-rough
# Conflicts:
#	apps/app-frontend/src/locales/en-US/index.json
#	apps/app-frontend/src/pages/instance/Share.vue
#	packages/app-lib/src/api/instance.rs
#	packages/app-lib/src/api/instance/shared.rs
#	packages/ui/src/locales/en-US/index.json
2026-07-09 21:51:12 +01:00
Calum H. (IMB11) 10c3622670 dev: invite link modal test 2026-07-09 21:45:19 +01:00
Calum H. (IMB11) a1cf0087c1 fix: lint 2026-07-09 21:40:16 +01:00
Calum H. (IMB11) 37f3d38a8b fix: qa 2026-07-09 21:19:18 +01:00
Calum H. (IMB11) 98d4dbe359 fix: qa 2026-07-09 21:10:36 +01:00
Calum H. (IMB11) c52738f31e feat: qa 2026-07-09 20:30:58 +01:00
Calum H. (IMB11) 0a207310e3 feat: align app backend with shared instance service 2026-07-09 20:06:33 +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) d5b4f87acc feat: rough invite links impl temp 2026-07-08 17:46:30 +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
148 changed files with 12366 additions and 1029 deletions
+316 -25
View File
@@ -16,6 +16,7 @@ import {
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LinkIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
@@ -40,6 +41,7 @@ import {
defineMessages,
I18nDebugPanel,
LoadingBar,
NewModal,
NewsArticleCard,
NotificationPanel,
OverflowMenu,
@@ -49,6 +51,7 @@ import {
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
StyledInput,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
@@ -80,6 +83,7 @@ import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
@@ -94,7 +98,13 @@ import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { command_listener, notification_listener, warning_listener } from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import {
install_accept_shared_instance_invite,
install_create_modpack_instance,
install_get_modpack_preview,
install_get_shared_instance_preview,
install_shared_instance,
} from '@/helpers/install'
import { list, run } from '@/helpers/instance'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
@@ -147,6 +157,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
@@ -251,6 +262,7 @@ const {
const news = ref([])
const availableSurvey = ref(false)
const displayedServerInviteNotifications = new Set()
const displayedSharedInstanceInviteNotifications = new Set()
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -637,6 +649,10 @@ const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const manualInviteLinkModal = ref()
const manualInviteLink = ref('')
const manualInviteProcessing = ref(false)
const modrinthAccountRequiredModal = ref()
const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
@@ -647,8 +663,8 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
setupAuthProvider(credentials, async (_redirectPath, flow) => {
await signIn(flow)
})
async function validateSession(sessionToken) {
@@ -665,23 +681,34 @@ async function validateSession(sessionToken) {
}
async function fetchCredentials() {
// TODO: move all of this into it's own helper
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 +726,13 @@ async function signIn() {
}
async function logOut() {
await performLogOut()
}
async function performLogOut() {
credentialsRefreshId++
credentials.value = undefined
await logout().catch(handleError)
await fetchCredentials()
}
@@ -820,33 +854,242 @@ function openServerInviteInviterProfile(inviterName) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(inviterName)}`)
}
async function handleLiveNotification(notification) {
if (notification?.body?.type !== 'server_invite' || notification.read) return
if (displayedServerInviteNotifications.has(notification.id)) return
function getSharedInstanceInvite(notification) {
const body = notification.body
displayedServerInviteNotifications.add(notification.id)
return {
sharedInstanceId: body.shared_instance_id,
sharedInstanceName: body.shared_instance_name,
invitedById: body.invited_by,
invitedByUsername: body.invited_by_username,
invitedByAvatarUrl: body.invited_by_avatar_url,
instanceIconUrl: body.instance_icon_url,
}
}
const serverName =
typeof notification.body.server_name === 'string' ? notification.body.server_name : 'a server'
const inviterId = notification.body.invited_by
async function resolveSharedInstanceInvite(notification) {
const sharedInstanceInvite = getSharedInstanceInvite(notification)
const invitedBy =
typeof inviterId === 'string' ? await get_user(inviterId, 'bypass').catch(() => null) : null
!sharedInstanceInvite.invitedByUsername && typeof sharedInstanceInvite.invitedById === 'string'
? await get_user(sharedInstanceInvite.invitedById, '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),
},
return {
...sharedInstanceInvite,
invitedByUsername: sharedInstanceInvite.invitedByUsername ?? invitedBy?.username ?? null,
invitedByAvatarUrl: sharedInstanceInvite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
}
}
async function acceptSharedInstanceInviteNotification(
notification,
resolvedSharedInstanceInvite = null,
) {
try {
const sharedInstanceInvite =
resolvedSharedInstanceInvite ?? (await resolveSharedInstanceInvite(notification))
const preview = await install_get_shared_instance_preview(
sharedInstanceInvite.sharedInstanceId,
sharedInstanceInvite.sharedInstanceName,
)
if (sharedInstanceInvite.instanceIconUrl && !preview.iconUrl) {
preview.iconUrl = sharedInstanceInvite.instanceIconUrl
}
const showInstallModal = installToPlayModal.value?.showSharedInstance
if (!showInstallModal) {
throw new Error('Install to play modal is not available.')
}
showInstallModal(
{
preview,
invitedByUsername: sharedInstanceInvite.invitedByUsername,
},
async () => {
try {
await install_shared_instance(
sharedInstanceInvite.sharedInstanceId,
sharedInstanceInvite.sharedInstanceName,
sharedInstanceInvite.invitedById,
null,
null,
)
await markLiveNotificationRead(notification)
queryClient.invalidateQueries({ queryKey: ['instances'] })
} catch (error) {
handleError(error)
throw error
}
},
)
} catch (error) {
handleError(error)
}
}
async function declineSharedInstanceInviteNotification(notification) {
try {
getSharedInstanceInvite(notification)
await markLiveNotificationRead(notification)
} catch (error) {
handleError(error)
}
}
function waitForCredentialsReady() {
if (credentials.value !== undefined) return Promise.resolve()
return new Promise((resolve) => {
const stop = watch(credentials, (value) => {
if (value !== undefined) {
stop()
resolve()
}
})
})
}
async function requireModrinthAccountForInvite() {
await waitForCredentialsReady()
if (credentials.value?.session) return true
const modal = modrinthAccountRequiredModal.value
if (modal?.show) {
return await modal.show()
}
await signIn()
return !!credentials.value?.session
}
async function requestModrinthAuth(flow) {
await signIn(flow)
return !!credentials.value?.session
}
async function installSharedInstanceInviteFromDeepLink(inviteId) {
try {
if (!(await requireModrinthAccountForInvite())) return
const invitePreview = await install_accept_shared_instance_invite(inviteId)
const showInstallModal = installToPlayModal.value?.showSharedInstance
if (!showInstallModal) {
throw new Error('Install to play modal is not available.')
}
showInstallModal(
{
preview: invitePreview.preview,
},
async () => {
try {
await install_shared_instance(
invitePreview.sharedInstanceId,
invitePreview.preview.name,
invitePreview.managerId,
invitePreview.serverManagerName,
invitePreview.serverManagerIconUrl,
)
queryClient.invalidateQueries({ queryKey: ['instances'] })
} catch (error) {
handleError(error)
throw error
}
},
)
} catch (error) {
handleError(error)
}
}
function manualInviteId() {
const value = manualInviteLink.value.trim()
if (!value) return null
try {
const url = new URL(value)
const match = /^\/share\/([^/]+)\/?$/.exec(url.pathname)
return match ? decodeURIComponent(match[1]) : null
} catch {
return null
}
}
function showManualInviteLinkModal() {
manualInviteLink.value = ''
manualInviteLinkModal.value?.show()
}
async function processManualInviteLink() {
const inviteId = manualInviteId()
if (!inviteId) return
manualInviteProcessing.value = true
try {
await invoke('plugin:install|install_shared_instance_invite', { inviteId })
manualInviteLinkModal.value?.hide()
queryClient.invalidateQueries({ queryKey: ['instances'] })
} catch (error) {
handleError(error)
} finally {
manualInviteProcessing.value = false
}
}
async function handleLiveNotification(notification) {
if (!notification?.body || notification.read) return
if (notification.body.type === 'server_invite') {
if (displayedServerInviteNotifications.has(notification.id)) return
displayedServerInviteNotifications.add(notification.id)
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),
},
})
}
if (notification.body.type === 'shared_instance_invite') {
if (displayedSharedInstanceInviteNotifications.has(notification.id)) return
displayedSharedInstanceInviteNotifications.add(notification.id)
const sharedInstanceInvite = await resolveSharedInstanceInvite(notification)
addPopupNotification({
title: sharedInstanceInvite.sharedInstanceName,
autoCloseMs: null,
toast: {
type: 'instance-invite',
actorName: sharedInstanceInvite.invitedByUsername,
actorAvatarUrl: sharedInstanceInvite.invitedByAvatarUrl ?? undefined,
entityName: sharedInstanceInvite.sharedInstanceName,
entityIconUrl: sharedInstanceInvite.instanceIconUrl ?? undefined,
onAccept: () => acceptSharedInstanceInviteNotification(notification, sharedInstanceInvite),
onDecline: () => declineSharedInstanceInviteNotification(notification),
onOpenActor: () => openServerInviteInviterProfile(sharedInstanceInvite.invitedByUsername),
},
})
}
}
async function handleCommand(e) {
if (!e) return
@@ -877,6 +1120,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
} else if (e.event === 'InstallSharedInstanceInvite') {
await installSharedInstanceInviteFromDeepLink(e.invite_id)
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
@@ -1467,6 +1712,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<PlusIcon />
</NavButton>
<NavButton
v-tooltip.right="'Install from invite link'"
:to="showManualInviteLinkModal"
>
<LinkIcon />
</NavButton>
<div class="flex flex-grow"></div>
<NavButton
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
@@ -1736,6 +1987,46 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
/>
<ModrinthAccountRequiredModal
ref="modrinthAccountRequiredModal"
:request-auth="requestModrinthAuth"
/>
<NewModal ref="manualInviteLinkModal" 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="manualInviteLink"
placeholder="https://modrinth.com/share/..."
:disabled="manualInviteProcessing"
@keydown.enter="processManualInviteLink"
/>
<span v-if="manualInviteLink && !manualInviteId()" 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="manualInviteProcessing"
@click="manualInviteLinkModal?.hide()"
>
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!manualInviteId() || manualInviteProcessing"
@click="processManualInviteLink"
>
<LinkIcon />
Process
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<InstallToPlayModal ref="installToPlayModal" />
<UpdateToPlayModal ref="updateToPlayModal" />
</template>
@@ -39,7 +39,12 @@ const props = defineProps({
type: Boolean,
default: false,
},
list: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['select'])
const playing = ref(false)
const loading = ref(false)
@@ -133,21 +138,49 @@ defineExpose({
const currentEvent = ref(null)
const unlisten = await process_listener((e) => {
if (e.instance_id === props.instance.id) {
currentEvent.value = e.event
if (e.event === 'finished') {
playing.value = false
}
const unlisten = props.list
? () => {}
: await process_listener((e) => {
if (e.instance_id === props.instance.id) {
currentEvent.value = e.event
if (e.event === 'finished') {
playing.value = false
}
}
})
onMounted(() => {
if (!props.list) {
checkProcess()
}
})
onMounted(() => checkProcess())
onUnmounted(() => unlisten())
</script>
<template>
<template v-if="compact">
<RouterLink
v-if="list"
:to="`/instance/${encodeURIComponent(instance.id)}`"
class="card-shadow flex w-full min-w-0 cursor-pointer items-center gap-3 overflow-hidden rounded-2xl border border-solid border-surface-4 bg-surface-2 p-4 text-left no-underline transition-all hover:brightness-110"
@click="emit('select', instance)"
>
<Avatar
size="40px"
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : null"
:tint-by="instance.id"
alt="Instance icon"
/>
<div class="flex min-w-0 flex-col gap-1">
<p class="m-0 truncate text-base font-semibold leading-5 text-contrast">
{{ instance.name }}
</p>
<div class="flex min-w-0 items-center gap-1 text-sm font-medium text-secondary">
<GameIcon class="shrink-0" />
<span class="truncate capitalize">{{ instance.loader }} {{ instance.game_version }}</span>
</div>
</div>
</RouterLink>
<template v-else-if="compact">
<div
class="card-shadow grid grid-cols-[auto_1fr_auto] bg-bg-raised rounded-xl p-3 pl-4 gap-2 cursor-pointer hover:brightness-90 transition-all"
@click="seeInstance"
@@ -10,17 +10,22 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, onUnmounted, 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 {
acceptCachedFriend,
add_friend,
friends,
createPendingFriend,
friendsQueryKey,
type FriendWithUserData,
getFriendsWithUserData,
remove_friend,
transformFriends,
removeCachedFriend,
upsertCachedFriend,
} from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
@@ -28,6 +33,7 @@ const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const queryClient = useQueryClient()
const props = defineProps<{
credentials: ModrinthCredentials | null
@@ -35,36 +41,22 @@ const props = defineProps<{
}>()
const userCredentials = computed(() => props.credentials)
const friendsKey = computed(() => friendsQueryKey(userCredentials.value?.user_id))
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()
}
}
const friendsQuery = useQuery({
queryKey: friendsKey,
queryFn: () => getFriendsWithUserData(userCredentials.value),
enabled: () => !!userCredentials.value,
staleTime: 30_000,
})
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 userFriends = computed(() => friendsQuery.data.value ?? [])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
@@ -108,39 +100,144 @@ const incomingRequests = computed(() =>
.sort((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
async function loadFriends(timeout = false) {
loading.value = timeout
type FriendsMutationContext = {
queryKey: ReturnType<typeof friendsQueryKey>
previousFriends?: FriendWithUserData[]
}
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)
}
type AddFriendMutationVariables = {
userId: string
user: {
id: string
username: string
avatarUrl?: string | null
}
acceptExisting?: boolean
}
type RemoveFriendMutationVariables = {
userId: string
user: FriendWithUserData
}
const loading = computed(() => !!userCredentials.value && friendsQuery.isLoading.value)
const addFriendMutation = useMutation({
mutationFn: ({ userId }: AddFriendMutationVariables) => add_friend(userId),
onMutate: async ({ user, acceptExisting }): Promise<FriendsMutationContext> => {
const queryKey = friendsKey.value
await queryClient.cancelQueries({ queryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(queryKey)
queryClient.setQueryData<FriendWithUserData[]>(queryKey, (friends = []) =>
acceptExisting
? acceptCachedFriend(friends, user.id, user.username, userCredentials.value?.user_id)
: upsertCachedFriend(
friends,
createPendingFriend(user, userCredentials.value?.user_id),
userCredentials.value?.user_id,
),
)
return { queryKey, previousFriends }
},
onError: (error, _variables, context) => {
restoreFriendsQuery(context)
handleError(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? friendsKey.value })
},
})
const removeFriendMutation = useMutation({
mutationFn: ({ userId }: RemoveFriendMutationVariables) => remove_friend(userId),
onMutate: async ({ user, userId }): Promise<FriendsMutationContext> => {
const queryKey = friendsKey.value
await queryClient.cancelQueries({ queryKey })
const previousFriends = queryClient.getQueryData<FriendWithUserData[]>(queryKey)
queryClient.setQueryData<FriendWithUserData[]>(queryKey, (friends = []) =>
removeCachedFriend(friends, userId, user.username, userCredentials.value?.user_id),
)
return { queryKey, previousFriends }
},
onError: (error, _variables, context) => {
restoreFriendsQuery(context)
handleError(toError(error))
},
onSettled: (_data, _error, _variables, context) => {
void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? friendsKey.value })
},
})
function restoreFriendsQuery(context?: FriendsMutationContext) {
if (!context) return
if (context.previousFriends === undefined) {
queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
return
}
queryClient.setQueryData(context.queryKey, context.previousFriends)
}
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))
}
function addFriendFromModal() {
const target = username.value.trim()
if (!target) return
addFriendModal.value.hide()
addFriendMutation.mutate({
userId: target,
user: {
id: target,
username: target,
},
})
username.value = ''
}
function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
addFriendMutation.mutate({
userId: id,
user: {
id,
username: friend.username,
avatarUrl: friend.avatar,
},
acceptExisting: true,
})
}
}
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 removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
removeFriendMutation.mutate({
userId: id,
user: friend,
})
}
}
const unlisten = await friend_listener(() => loadFriends())
const unlisten = await friend_listener(() => {
void queryClient.invalidateQueries({ queryKey: friendsKey.value })
})
onUnmounted(() => {
unlisten()
})
@@ -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,109 @@
import { defineMessages } from '@modrinth/ui'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
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',
},
sharedInstanceUnavailableTitle: {
id: 'instance.shared-instance.unavailable.title',
defaultMessage: 'Shared instance no longer available',
},
sharedInstanceUnavailableText: {
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.",
},
sharedInstanceDeletedText: {
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.",
},
sharedInstanceAccessRevokedText: {
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.",
},
sharedInstanceUnavailableFallbackManager: {
id: 'instance.shared-instance.unavailable.manager-fallback',
defaultMessage: 'the instance manager',
},
sharedInstanceErrorTitle: {
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
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',
},
})
export function sharedInstanceUnavailableTextMessage(
reason: SharedInstanceUnavailableReason | null,
) {
if (reason === 'deleted') return instanceAdmonitionsMessages.sharedInstanceDeletedText
if (reason === 'access_revoked') {
return instanceAdmonitionsMessages.sharedInstanceAccessRevokedText
}
return instanceAdmonitionsMessages.sharedInstanceUnavailableText
}
@@ -0,0 +1,156 @@
<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,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getErrorMessage,
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
} from '@/helpers/install'
import { get_shared_instance_publish_preview, publish_shared_instance } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
instanceAdmonitionsMessages as messages,
sharedInstanceUnavailableTextMessage,
} from './instance-admonitions-messages'
const props = defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
}>()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
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)
function notifySharedInstanceUnavailable(reason: SharedInstanceUnavailableReason | null = null) {
addNotification({
type: 'warning',
title: formatMessage(messages.sharedInstanceUnavailableTitle),
text: formatMessage(sharedInstanceUnavailableTextMessage(reason), {
manager: formatMessage(messages.sharedInstanceUnavailableFallbackManager),
}),
})
}
function notifySharedInstanceError(error: unknown) {
addNotification({
type: 'error',
title: formatMessage(messages.sharedInstanceErrorTitle),
text: getErrorMessage(error),
})
}
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,37 @@
<template>
<Admonition
type="warning"
:header="formatMessage(messages.sharedInstanceUnavailableTitle)"
:dismissible="dismissible"
@dismiss="emit('dismiss')"
>
{{
formatMessage(sharedInstanceUnavailableTextMessage(reason ?? null), {
manager: manager || formatMessage(messages.sharedInstanceUnavailableFallbackManager),
})
}}
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, useVIntl } from '@modrinth/ui'
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
import {
instanceAdmonitionsMessages as messages,
sharedInstanceUnavailableTextMessage,
} from './instance-admonitions-messages'
defineProps<{
reason?: SharedInstanceUnavailableReason | null
manager?: string | null
dismissible?: boolean
}>()
const emit = defineEmits<{
dismiss: []
}>()
const { formatMessage } = useVIntl()
</script>
@@ -0,0 +1,78 @@
<template>
<Admonition type="warning" :header="formatMessage(headerMessage)">
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span>{{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}</span>
<span
v-if="sharedInstanceExpectedUser"
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-contrast"
>
<Avatar
:src="sharedInstanceExpectedUser.avatarUrl"
:alt="sharedInstanceExpectedUsername"
:tint-by="sharedInstanceExpectedUser.tintBy"
size="20px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceExpectedUsername }}</span>
</span>
<span v-else class="font-semibold">{{ sharedInstanceExpectedUsername }}</span>
<span>{{ formatMessage(bodyMessage) }}</span>
</span>
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, Avatar, useVIntl } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { get_user } from '@/helpers/cache'
import { instanceAdmonitionsMessages as messages } from './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'
@@ -29,6 +29,8 @@ import {
edit,
get_linked_modpack_info,
list,
unlink_shared_instance,
unpublish_shared_instance,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/instance'
@@ -111,9 +113,17 @@ 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 = computed(
() => instance.value.shared_instance?.role === 'member',
)
const canUnpublishSharedInstance = computed(() => instance.value.shared_instance?.role === 'owner')
const canUnlinkSharedInstance = computed(() => instance.value.shared_instance?.role === 'member')
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
@@ -124,6 +134,8 @@ const modpackInfo = modpackInfoQuery.data
const repairing = ref(false)
const reinstalling = ref(false)
const unpublishingSharedInstance = ref(false)
const unlinkingSharedInstance = ref(false)
const messages = defineMessages({
loaderVersion: {
@@ -208,12 +220,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,
@@ -396,6 +415,48 @@ provideInstallationSettings({
debug('unlinkModpack: done')
},
async unpublishSharedInstance() {
debug('unpublishSharedInstance: called', { instanceId: instance.value.id })
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()
debug('unpublishSharedInstance: done')
} catch (error) {
handleError(error)
debug('unpublishSharedInstance: failed', { error })
} finally {
unpublishingSharedInstance.value = false
}
},
async unlinkSharedInstance() {
debug('unlinkSharedInstance: called', { instanceId: instance.value.id })
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()
debug('unlinkSharedInstance: done')
} catch (error) {
handleError(error)
debug('unlinkSharedInstance: failed', { error })
} finally {
unlinkingSharedInstance.value = false
}
},
getCachedModpackVersions: () => null,
async fetchModpackVersions() {
debug('fetchModpackVersions: called', {
@@ -437,9 +498,17 @@ provideInstallationSettings({
isServer: false,
isApp: true,
showModpackVersionActions: computed(
() => isModrinthLinkedModpack.value && !isMinecraftServer.value,
() =>
isModrinthLinkedModpack.value &&
!isMinecraftServer.value &&
!isSharedInstanceManagedModpack.value,
),
isLocalFile: isImportedModpack,
isSharedInstanceManagedModpack,
canUnpublishSharedInstance,
unpublishingSharedInstance,
canUnlinkSharedInstance,
unlinkingSharedInstance,
repairing,
reinstalling,
})
@@ -1,6 +1,9 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true" no-padding>
<div
v-if="mode === 'server-project' && requiredContentProject"
class="flex flex-col gap-6 max-w-[500px] p-6 pb-2"
>
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
@@ -37,13 +40,70 @@
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
<BulletDivider /> {{ formatMessage(messages.modCount, { count: modCount }) }}
</template>
</span>
</div>
</div>
</div>
</div>
<div
v-else-if="mode === 'shared-instance' && sharedInstance"
class="flex flex-col gap-6 max-w-[500px] p-6 pb-2"
>
<Admonition type="warning" :header="formatMessage(messages.trustWarningHeader)">
{{ formatMessage(messages.trustWarningDescription) }}
</Admonition>
<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="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar
:src="sharedInstance.preview.iconUrl"
:alt="sharedInstance.preview.name"
size="48px"
/>
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">{{ sharedInstance.preview.name }}</span>
<span class="text-sm text-secondary">
{{ sharedInstanceLoaderDisplay }} {{ sharedInstance.preview.gameVersion }}
<template v-if="sharedInstance.preview.modCount">
<BulletDivider />
{{
formatMessage(messages.modCount, {
count: sharedInstance.preview.modCount,
})
}}
</template>
</span>
</div>
</div>
</div>
<div
v-if="sharedInstance.preview.externalFileCount"
class="flex items-center gap-2 text-orange"
>
<IssuesIcon class="h-5 w-5 flex-none" />
<span>{{
formatMessage(messages.externalFileWarning, {
count: sharedInstance.preview.externalFileCount,
})
}}</span>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
@@ -65,18 +125,20 @@
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="project?.name ?? ''"
:modpack-icon-url="project?.icon_url ?? undefined"
:header="contentModalHeader"
:modpack-name="contentModalName"
:modpack-icon-url="contentModalIconUrl"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import { DownloadIcon, EyeIcon, IssuesIcon, XIcon } from '@modrinth/assets'
import type { ContentItem } from '@modrinth/ui'
import {
Admonition,
Avatar,
BulletDivider,
ButtonStyled,
commonMessages,
defineMessages,
@@ -89,14 +151,21 @@ import { computed, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import type { SharedInstanceInstallPreview } from '@/helpers/install'
import { injectServerInstall } from '@/providers/server-install'
const modal = ref<InstanceType<typeof NewModal>>()
const mode = ref<'server-project' | 'shared-instance'>('server-project')
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const project = ref<Labrinth.Projects.v3.Project | null>(null)
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
const onInstallComplete = ref<() => void>(() => {})
const sharedInstance = ref<{
preview: SharedInstanceInstallPreview
invitedByUsername?: string | null
} | null>(null)
const onSharedInstanceInstall = ref<() => void | Promise<void>>(() => {})
const { formatMessage } = useVIntl()
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
@@ -111,6 +180,28 @@ const loaderDisplay = computed(() => {
})
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
const sharedInstanceLoaderDisplay = computed(() => {
const loader = sharedInstance.value?.preview.loader
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
const contentModalName = computed(() =>
mode.value === 'shared-instance'
? (sharedInstance.value?.preview.name ?? '')
: (project.value?.name ?? ''),
)
const contentModalIconUrl = computed(() =>
mode.value === 'shared-instance'
? (sharedInstance.value?.preview.iconUrl ?? undefined)
: (project.value?.icon_url ?? undefined),
)
const contentModalHeader = computed(() =>
mode.value === 'shared-instance' ? formatMessage(messages.sharedInstanceContent) : undefined,
)
type VersionDependency = Labrinth.Versions.v2.Dependency & {
version_id?: string
}
async function fetchData(versionId: string) {
// cache is making version null for some reason so bypassing for now
@@ -123,6 +214,15 @@ async function fetchData(versionId: string) {
async function handleAccept() {
hide()
if (mode.value === 'shared-instance') {
try {
await onSharedInstanceInstall.value()
} catch (error) {
console.error('Failed to install shared instance from InstallToPlayModal:', error)
}
return
}
const serverProjectId = project.value?.id
startInstallingServer(serverProjectId)
try {
@@ -142,6 +242,11 @@ function handleDecline() {
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
async function openViewContents() {
if (mode.value === 'shared-instance') {
await openSharedInstanceContents()
return
}
modpackContentModal.value?.showLoading()
try {
// Ensure version data is available — the useQuery may not have resolved yet
@@ -149,57 +254,7 @@ async function openViewContents() {
const version =
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
const deps = version?.dependencies ?? []
const projectIds = deps
.map((d: { project_id?: string }) => d.project_id)
.filter((id: string | undefined): id is string => !!id)
const versionIds = deps
.map((d: { version_id?: string }) => d.version_id)
.filter((id: string | undefined): id is string => !!id)
const projects: Labrinth.Projects.v2.Project[] =
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
const versions: Labrinth.Versions.v2.Version[] =
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
const projectMap = new Map(projects.map((p: Labrinth.Projects.v2.Project) => [p.id, p]))
const contentItems: ContentItem[] = deps.map(
(dep: Labrinth.Versions.v2.Dependency): ContentItem => {
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
// @ts-expect-error - version_id is missing from the type for some reason
const depVersion = dep.version_id
? // @ts-expect-error - version_id is missing from the type for some reason
versions.find((v: Labrinth.Versions.v2.Version) => v.id === dep.version_id)
: null
return {
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
project_type: depProject?.project_type ?? 'mod',
has_update: false,
update_version_id: null,
project: {
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
title: depProject?.title ?? dep.file_name ?? 'Unknown',
icon_url: depProject?.icon_url ?? undefined,
},
...(depVersion
? {
version: {
id: depVersion.id,
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
version_number: depVersion.version_number ?? undefined,
date_published: depVersion.date_published ?? undefined,
},
}
: {}),
}
},
)
const contentItems = await contentItemsFromDependencies(version?.dependencies ?? [])
modpackContentModal.value?.show(contentItems)
} catch (err) {
console.error('Failed to load modpack contents:', err)
@@ -207,17 +262,156 @@ async function openViewContents() {
}
}
async function openSharedInstanceContents() {
const preview = sharedInstance.value?.preview
if (!preview) return
modpackContentModal.value?.showLoading()
try {
const contentItems: ContentItem[] = [
...preview.externalFiles.map(sharedExternalFileContentItem),
...(await sharedInstanceModpackContentItems(preview)),
...(await contentItemsFromVersionIds(
preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
)),
]
modpackContentModal.value?.show(contentItems)
} catch (err) {
console.error('Failed to load shared instance contents:', err)
modpackContentModal.value?.show([])
}
}
async function sharedInstanceModpackContentItems(preview: SharedInstanceInstallPreview) {
if (!preview.modpackVersionId) return []
const version = await get_version(preview.modpackVersionId, 'must_revalidate')
return await contentItemsFromDependencies(version?.dependencies ?? [])
}
async function contentItemsFromDependencies(deps: Labrinth.Versions.v2.Dependency[]) {
const dependencies = deps as VersionDependency[]
const projectIds = unique(
dependencies.map((dependency) => dependency.project_id).filter((id): id is string => !!id),
)
const versionIds = unique(
dependencies.map((dependency) => dependency.version_id).filter((id): id is string => !!id),
)
const projects: Labrinth.Projects.v2.Project[] =
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
const versions: Labrinth.Versions.v2.Version[] =
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
const projectMap = new Map(projects.map((depProject) => [depProject.id, depProject]))
const versionMap = new Map(versions.map((depVersion) => [depVersion.id, depVersion]))
return dependencies.map((dependency): ContentItem => {
const depProject = dependency.project_id ? projectMap.get(dependency.project_id) : null
const depVersion = dependency.version_id ? versionMap.get(dependency.version_id) : null
const fileName =
depVersion?.files?.[0]?.filename ?? dependency.file_name ?? depProject?.title ?? 'Unknown'
const external = !depProject && !depVersion
return {
id: dependency.version_id ?? dependency.project_id ?? fileName,
file_name: fileName,
project_type: depProject?.project_type ?? 'mod',
has_update: false,
update_version_id: null,
external,
project: {
id: depProject?.id ?? dependency.project_id ?? fileName,
slug: depProject?.slug ?? dependency.project_id ?? fileName,
title: depProject?.title ?? dependency.file_name ?? fileName,
icon_url: depProject?.icon_url ?? undefined,
},
...(depVersion
? {
version: {
id: depVersion.id,
file_name: depVersion.files?.[0]?.filename ?? dependency.file_name ?? fileName,
version_number: depVersion.version_number ?? undefined,
date_published: depVersion.date_published ?? undefined,
},
}
: {}),
}
})
}
async function contentItemsFromVersionIds(versionIds: string[]) {
const uniqueVersionIds = unique(versionIds)
const versions: Labrinth.Versions.v2.Version[] =
uniqueVersionIds.length > 0 ? await get_version_many(uniqueVersionIds, 'must_revalidate') : []
const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
const projects: Labrinth.Projects.v2.Project[] =
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
const projectMap = new Map(projects.map((depProject) => [depProject.id, depProject]))
return versions.map((version): ContentItem => {
const depProject = projectMap.get(version.project_id)
const fileName = version.files?.[0]?.filename ?? depProject?.title ?? version.name ?? 'Unknown'
return {
id: version.id,
file_name: fileName,
project_type: depProject?.project_type ?? 'mod',
has_update: false,
update_version_id: null,
project: {
id: depProject?.id ?? version.project_id,
slug: depProject?.slug ?? version.project_id,
title: depProject?.title ?? version.name,
icon_url: depProject?.icon_url ?? undefined,
},
version: {
id: version.id,
file_name: fileName,
version_number: version.version_number ?? undefined,
date_published: version.date_published ?? undefined,
},
}
})
}
function sharedExternalFileContentItem(
file: SharedInstanceInstallPreview['externalFiles'][number],
): ContentItem {
return {
id: `external:${file.fileType}:${file.fileName}`,
file_name: file.fileName,
project_type: file.fileType,
has_update: false,
update_version_id: null,
external: true,
project: {
id: file.fileName,
slug: file.fileName,
title: file.fileName,
icon_url: null,
},
}
}
function unique<T>(values: T[]) {
return Array.from(new Set(values))
}
async function show(
projectVal: Labrinth.Projects.v3.Project,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
mode.value = 'server-project'
project.value = projectVal
sharedInstance.value = null
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
requiredContentProject.value = null
onInstallComplete.value = callback
onSharedInstanceInstall.value = () => {}
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
@@ -225,6 +419,27 @@ async function show(
modal.value?.show(e)
}
function showSharedInstance(
instance: {
preview: SharedInstanceInstallPreview
invitedByUsername?: string | null
},
install: () => void | Promise<void>,
e?: MouseEvent,
) {
mode.value = 'shared-instance'
project.value = null
modpackVersionId.value = null
modpackVersion.value = null
requiredContentProject.value = null
onInstallComplete.value = () => {}
sharedInstance.value = instance
onSharedInstanceInstall.value = install
hide_ads_window()
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
show_ads_window()
@@ -256,6 +471,24 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
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.}}',
},
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
@@ -270,5 +503,5 @@ const messages = defineMessages({
},
})
defineExpose({ show, hide })
defineExpose({ show, showSharedInstance, hide })
</script>
@@ -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>
@@ -9,7 +9,9 @@
:diffs="normalizedDiffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:show-report-button="true"
:added-label="addedLabel"
:removed-label="removedLabel"
:show-report-button="showReportButton"
@confirm="handleUpdate"
@cancel="handleDecline"
@report="handleReport"
@@ -24,6 +26,7 @@ import {
type ContentDiffItem,
ContentDiffModal,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -31,7 +34,15 @@ import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { wait_for_install_job } from '@/helpers/install'
import {
getErrorMessage,
getSharedInstanceUnavailableReason,
install_update_shared_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
type SharedInstanceUpdatePreview,
wait_for_install_job,
} from '@/helpers/install'
import { update_managed_modrinth_version } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { injectServerInstall } from '@/providers/server-install'
@@ -74,24 +85,53 @@ type ProjectInfo = {
}
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
const emit = defineEmits<{
cancel: []
complete: []
sharedInstanceUnavailable: [reason: SharedInstanceUnavailableReason | null]
}>()
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const mode = ref<'server-project' | 'shared-instance'>('server-project')
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
const diffs = ref<DependencyDiff[]>([])
const sharedInstancePreview = ref<SharedInstanceUpdatePreview | null>(null)
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
const normalizedDiffs = computed<ContentDiffItem[]>(() => {
if (mode.value === 'shared-instance') {
return (
sharedInstancePreview.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,
})) ?? []
)
}
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,
})),
}))
})
const showReportButton = computed(() => mode.value !== 'shared-instance')
const addedLabel = computed(() =>
mode.value === 'shared-instance' ? formatMessage(messages.sharedInstanceAddedLabel) : undefined,
)
const removedLabel = computed(() =>
mode.value === 'shared-instance' ? formatMessage(messages.sharedInstanceRemovedLabel) : undefined,
)
async function computeDependencyDiffs(
@@ -238,7 +278,30 @@ watch(
)
async function handleUpdate() {
hide()
if (mode.value === 'shared-instance') {
try {
if (instance.value) {
const job = await install_update_shared_instance(instance.value.id)
await wait_for_install_job(job.job_id)
await onUpdateComplete.value()
}
} catch (error) {
if (isSharedInstanceUnavailableError(error)) {
emit('sharedInstanceUnavailable', getSharedInstanceUnavailableReason(error))
return
}
addNotification({
type: 'error',
title: formatMessage(messages.sharedInstanceErrorTitle),
text: getErrorMessage(error),
})
} finally {
emit('complete')
}
return
}
const serverProjectId = instance.value?.link?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
@@ -251,6 +314,7 @@ async function handleUpdate() {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
emit('complete')
}
}
@@ -261,7 +325,7 @@ function handleReport() {
}
function handleDecline() {
hide()
emit('cancel')
}
function show(
@@ -270,12 +334,29 @@ function show(
callback: UpdateCompleteCallback = () => {},
e?: MouseEvent,
) {
mode.value = 'server-project'
instance.value = instanceVal
sharedInstancePreview.value = null
modpackVersionId.value = modpackVersionIdVal
onUpdateComplete.value = callback
diffModal.value?.show(e)
}
function showSharedInstance(
instanceVal: GameInstance,
preview: SharedInstanceUpdatePreview,
callback: UpdateCompleteCallback = () => {},
e?: MouseEvent,
) {
mode.value = 'shared-instance'
instance.value = instanceVal
sharedInstancePreview.value = preview
modpackVersionId.value = null
diffs.value = []
onUpdateComplete.value = callback
diffModal.value?.show(e)
}
function hide() {
diffModal.value?.hide()
}
@@ -292,7 +373,19 @@ 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.',
},
sharedInstanceAddedLabel: {
id: 'app.modal.update-to-play.shared-instance-added-label',
defaultMessage: 'Added',
},
sharedInstanceRemovedLabel: {
id: 'app.modal.update-to-play.shared-instance-removed-label',
defaultMessage: 'Removed',
},
sharedInstanceErrorTitle: {
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
})
@@ -301,5 +394,5 @@ const hasUpdate = computed(() => {
return modpackVersionId.value != null && modpackVersionId.value !== instance.value.link.version_id
})
defineExpose({ show, hide, hasUpdate })
defineExpose({ show, showSharedInstance, hide, hasUpdate })
</script>
@@ -59,6 +59,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({
@@ -187,6 +191,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])
}
+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))
}
function normalizeFriendKey(value: string) {
return value.trim().toLowerCase()
}
export async function transformFriends(
friends: UserFriend[],
credentials: ModrinthCredentials | null,
+160
View File
@@ -44,6 +44,108 @@ 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
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 const SHARED_INSTANCE_DELETED_ERROR_CODE = 'shared_instance_deleted'
export const SHARED_INSTANCE_ACCESS_REVOKED_ERROR_CODE = 'shared_instance_access_revoked'
export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked' | 'unknown'
function errorSearchText(error: unknown, seen = new Set<object>()): string {
if (error == null) return ''
if (typeof error === 'string') return error
if (typeof error === 'number' || typeof error === 'boolean') return String(error)
if (error instanceof Error) {
return [error.name, error.message, error.cause ? errorSearchText(error.cause, seen) : '']
.filter(Boolean)
.join(' ')
}
if (typeof error === 'object') {
if (seen.has(error)) return ''
seen.add(error)
const value = error as {
message?: unknown
error?: unknown
cause?: unknown
details?: unknown
}
return [value.message, value.error, value.cause, value.details, ...Object.values(error)]
.map((value) => errorSearchText(value, seen))
.filter(Boolean)
.join(' ')
}
return ''
}
export function isSharedInstanceUnavailableError(error: unknown) {
return getSharedInstanceUnavailableReason(error) !== null
}
export function getSharedInstanceUnavailableReason(
error: unknown,
): SharedInstanceUnavailableReason | null {
const text = errorSearchText(error)
if (text.includes(SHARED_INSTANCE_DELETED_ERROR_CODE)) return 'deleted'
if (text.includes(SHARED_INSTANCE_ACCESS_REVOKED_ERROR_CODE)) return 'access_revoked'
if (text.includes(SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE)) return 'unknown'
return null
}
export function getErrorMessage(error: unknown): string {
return errorSearchText(error) || 'Unknown error'
}
export type InstallJobStatus =
| 'queued'
| 'running'
@@ -91,6 +193,8 @@ export interface InstallJobSnapshot {
kind:
| 'create_instance'
| 'create_modpack_instance'
| 'create_shared_instance'
| 'update_shared_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
@@ -140,6 +244,62 @@ 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,
) {
return await invoke<InstallJobSnapshot>('plugin:install|install_shared_instance', {
sharedInstanceId,
name,
managerId,
serverManagerName,
serverManagerIconUrl,
})
}
export async function install_shared_instance_invite(inviteId: string) {
return await invoke<InstallJobSnapshot>('plugin:install|install_shared_instance_invite', {
inviteId,
})
}
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,
+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> {
+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 })
}
+172 -1
View File
@@ -35,6 +35,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"
},
@@ -269,6 +272,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."
},
@@ -320,6 +353,84 @@
"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.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"
},
@@ -374,6 +485,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"
},
@@ -392,20 +506,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"
@@ -962,6 +1091,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"
},
@@ -1025,6 +1178,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"
},
+414 -33
View File
@@ -12,7 +12,11 @@
:offline="offline"
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<UpdateToPlayModal
ref="updateToPlayModal"
:instance="instance"
@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,39 @@
</router-link>
</div>
</template>
<template v-if="sharedInstanceServerManager">
<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">
Linked to
<Avatar
:src="sharedInstanceServerManager.iconUrl"
:alt="sharedInstanceServerManager.name"
:tint-by="sharedInstanceServerManager.name"
size="24px"
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceServerManager.name }}</span>
</div>
</template>
<template v-else-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">
Managed by
<Avatar
:src="sharedInstanceManager.avatarUrl"
:alt="sharedInstanceManager.username"
:tint-by="sharedInstanceManager.tintBy"
size="24px"
circle
no-shadow
/>
<span class="min-w-0 truncate">{{ sharedInstanceManager.username }}</span>
</div>
</template>
</div>
</template>
<template #actions>
@@ -217,6 +266,19 @@
</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="
sharedInstanceManager?.username ?? sharedInstanceServerManager?.name
"
: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">
@@ -235,7 +297,7 @@
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
:open-settings="() => settingsModal?.show(1)"
v-bind="contentSubpageProps"
v-bind="instanceSubpageProps"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
@@ -288,6 +350,7 @@ import {
SettingsIcon,
StopCircleIcon,
TerminalSquareIcon,
UnknownIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
@@ -296,6 +359,8 @@ import {
Avatar,
ButtonStyled,
ContentPageHeader,
defineMessages,
injectAuth,
injectNotificationManager,
NavTabs,
OverflowMenu,
@@ -304,6 +369,7 @@ import {
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
@@ -315,6 +381,7 @@ 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 {
@@ -323,9 +390,18 @@ import {
} from '@/composables/instances/use-server-status-query'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { get_project_v3, get_user } 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 {
getErrorMessage,
getSharedInstanceUnavailableReason,
install_existing_instance,
install_get_shared_instance_update_preview,
install_pack_to_existing_instance,
isSharedInstanceUnavailableError,
type SharedInstanceUnavailableReason,
type SharedInstanceUpdatePreview,
} 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'
@@ -340,6 +416,7 @@ dayjs.extend(duration)
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
const auth = injectAuth()
const { playServerProject } = injectServerInstall()
const queryClient = useQueryClient()
const route = useRoute()
@@ -368,6 +445,46 @@ const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
sharedInstanceUnavailableTitle: {
id: 'instance.shared-instance.unavailable.title',
defaultMessage: 'Shared instance no longer available',
},
sharedInstanceUnavailableText: {
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.",
},
sharedInstanceDeletedText: {
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.",
},
sharedInstanceAccessRevokedText: {
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.",
},
sharedInstanceUnavailableFallbackManager: {
id: 'instance.shared-instance.unavailable.manager-fallback',
defaultMessage: 'the instance manager',
},
sharedInstanceErrorTitle: {
id: 'instance.shared-instance.error.title',
defaultMessage: 'Something has gone wrong',
},
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)
const isServerInstance = ref(false)
@@ -385,6 +502,102 @@ const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
const sharedInstanceUpdatePreview = ref<SharedInstanceUpdatePreview | null>(null)
const sharedInstanceUnavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
const sharedInstanceAvailabilityCheckKey = ref<string | null>(null)
type SharedInstanceUserProfile = {
id: string
username: string
avatar_url?: string
}
const sharedInstanceManagerUser = ref<SharedInstanceUserProfile | null>(null)
const sharedInstanceManagerUserId = computed(() => {
const attachment = instance.value?.shared_instance
if (!attachment) return null
if (attachment.role === 'owner') {
if (!sharedInstanceActionsLocked.value) return null
return attachment.linked_user_id ?? null
}
return attachment.manager_id ?? null
})
const sharedInstanceManager = computed(() => {
if (!instance.value?.shared_instance) return null
if (instance.value.shared_instance.role === 'owner') {
if (!sharedInstanceActionsLocked.value) return null
const linkedUserId = instance.value.shared_instance.linked_user_id
const linkedUser = sharedInstanceManagerUser.value
if (!linkedUserId || !linkedUser) return null
return {
username: linkedUser.username,
avatarUrl: linkedUser.avatar_url ?? undefined,
tintBy: linkedUser.id,
}
}
const user = sharedInstanceManagerUser.value
if (!user) return null
return {
username: user.username,
avatarUrl: user.avatar_url ?? undefined,
tintBy: user.id,
}
})
const sharedInstanceServerManager = computed(() => {
const attachment = instance.value?.shared_instance
if (!attachment?.server_manager_name) return null
return {
name: attachment.server_manager_name,
iconUrl: attachment.server_manager_icon_url ?? undefined,
}
})
const sharedInstanceExpectedUserId = computed(
() => instance.value?.shared_instance?.linked_user_id ?? null,
)
const sharedInstanceWrongAccount = computed(() => {
if (auth.isReady && !auth.isReady.value) return false
const expectedUserId = sharedInstanceExpectedUserId.value
if (!expectedUserId) return false
return auth.user.value?.id !== expectedUserId
})
const sharedInstanceActionsLocked = computed(() => sharedInstanceWrongAccount.value)
const sharedInstanceShareActionsLocked = computed(
() => sharedInstanceActionsLocked.value || !!sharedInstanceUnavailableReason.value,
)
const sharedInstanceSignedOut = computed(() => !auth.session_token.value)
const sharedInstanceTooltip = computed(() =>
formatMessage(
instance.value?.shared_instance?.role === 'owner'
? messages.sharedInstanceOwnerTooltip
: messages.sharedInstanceTooltip,
),
)
watch(
() => sharedInstanceManagerUserId.value,
async (userId) => {
sharedInstanceManagerUser.value = null
if (!userId) return
const user = await get_user(userId, 'bypass').catch(() => null)
if (sharedInstanceManagerUserId.value !== userId) return
sharedInstanceManagerUser.value = user
},
{ immediate: true },
)
watch(
() => router.currentRoute.value,
@@ -418,6 +631,9 @@ async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
sharedInstanceUpdatePreview.value = null
sharedInstanceUnavailableReason.value = null
sharedInstanceAvailabilityCheckKey.value = null
resetServerStatus()
const nextInstance = await get(route.params.id as string).catch(handleError)
@@ -442,6 +658,7 @@ async function fetchInstance() {
}
const nextPreloadedContent = await contentPreloadPromise
await ensureSharedInstanceExpectedUser(nextInstance)
instance.value = nextInstance ?? undefined
linkedProjectV3.value = nextLinkedProjectV3
@@ -460,6 +677,21 @@ async function fetchInstance() {
}
}
async function ensureSharedInstanceExpectedUser(nextInstance?: GameInstance | null) {
const userId = nextInstance?.shared_instance?.linked_user_id
if (!userId) return
try {
await queryClient.ensureQueryData({
queryKey: ['user', userId],
queryFn: () => get_user(userId, 'bypass').catch(() => null),
staleTime: 30_000,
})
} catch {
// Let InstanceAdmonitions' useQuery own any fallback state after the route renders.
}
}
function fetchDeferredData(instanceId?: string) {
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (isServerInstance.value && serverAddress) {
@@ -512,6 +744,70 @@ watch(
},
)
async function checkSharedInstanceAvailability(instanceId: string) {
try {
const preview = await install_get_shared_instance_update_preview(instanceId)
if (instance.value?.id !== instanceId) return
sharedInstanceUpdatePreview.value = preview
sharedInstanceUnavailableReason.value = null
} catch (error) {
if (instance.value?.id !== instanceId) return
if (isSharedInstanceUnavailableError(error)) {
sharedInstanceUpdatePreview.value = null
sharedInstanceUnavailableReason.value = getSharedInstanceUnavailableReason(error)
return
}
addNotification({
type: 'error',
title: formatMessage(messages.sharedInstanceErrorTitle),
text: getErrorMessage(error),
})
}
}
watch(
() => ({
instanceId: instance.value?.id,
role: instance.value?.shared_instance?.role,
locked: sharedInstanceActionsLocked.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
) {
const keepUnavailableReason =
!!instanceId && !role && sharedInstanceUnavailableReason.value !== null
sharedInstanceUpdatePreview.value = null
if (!keepUnavailableReason) {
sharedInstanceUnavailableReason.value = null
}
sharedInstanceAvailabilityCheckKey.value = null
return
}
const key = `${instanceId}:${userId ?? 'signed-out'}`
if (sharedInstanceAvailabilityCheckKey.value === key) return
sharedInstanceAvailabilityCheckKey.value = key
await checkSharedInstanceAvailability(instanceId)
},
{ immediate: true },
)
const basePath = computed(
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
)
@@ -531,29 +827,61 @@ const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
const instanceSubpageProps = computed(() => ({
...contentSubpageProps.value,
...(displayedInstanceRoute.value.name === 'InstanceShare'
? {
sharedInstanceActionsLocked: sharedInstanceShareActionsLocked.value,
sharedInstanceUnavailableReason: sharedInstanceUnavailableReason.value,
sharedInstanceUnavailableManager:
sharedInstanceManager.value?.username ?? sharedInstanceServerManager.value?.name,
}
: {}),
}))
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 +899,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 +909,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 +917,64 @@ const startInstance = async (context: string) => {
})
}
function sharedInstanceUnavailableTextMessage(reason: SharedInstanceUnavailableReason | null) {
if (reason === 'deleted') return messages.sharedInstanceDeletedText
if (reason === 'access_revoked') return messages.sharedInstanceAccessRevokedText
return messages.sharedInstanceUnavailableText
}
async function handleSharedInstanceUnavailable(
reason: SharedInstanceUnavailableReason | null = null,
) {
const manager =
sharedInstanceManager.value?.username ??
sharedInstanceServerManager.value?.name ??
formatMessage(messages.sharedInstanceUnavailableFallbackManager)
addNotification({
type: 'warning',
title: formatMessage(messages.sharedInstanceUnavailableTitle),
text: formatMessage(sharedInstanceUnavailableTextMessage(reason), { manager }),
})
await fetchInstance()
sharedInstanceUpdatePreview.value = null
sharedInstanceUnavailableReason.value = 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 && updateToPlayModal.value) {
updateToPlayModal.value.showSharedInstance(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)
+83 -17
View File
@@ -12,14 +12,23 @@
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)"
:action-disabled="isInstanceBusy"
@disable="confirmPendingModpackContentDisable"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
@@ -71,6 +80,7 @@ import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
import {
type BulkOperationStatus,
commonMessages,
ConfirmDisableModal,
ConfirmModpackUpdateModal,
ContentCardLayout as ContentPageLayout,
type ContentItem,
@@ -273,6 +283,7 @@ watch(
const isModpackUpdating = ref(false)
const isBulkOperating = ref(false)
const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed')
const isSharedMember = computed(() => props.instance.shared_instance?.role === 'member')
const isPackLocked = computed(
() =>
props.instance?.link?.type === 'modrinth_modpack' ||
@@ -284,6 +295,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 +375,33 @@ function hasContentOperation(item: ContentItem) {
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function isSharedInstanceManagedContent(item: ContentItem) {
return (
isSharedMember.value &&
(item.source_kind === 'shared_instance' ||
item.source_kind === 'modrinth_modpack' ||
item.source_kind === 'imported_modpack')
)
}
function canMutateContent(item: ContentItem) {
return !isSharedInstanceManagedContent(item)
}
function canDeleteContent(item: ContentItem) {
return canMutateContent(item)
}
function shouldWarnBeforeDelete(items: ContentItem[]) {
return items.some(isSharedInstanceManagedContent)
}
function shouldWarnBeforeDisable(items: ContentItem[]) {
return items.some(isSharedInstanceManagedContent)
}
function canUpdateProject(item: ContentItem) {
return !!item.file_path && !!item.has_update && !!item.update_version_id
return canMutateContent(item) && !!item.file_path && !!item.has_update && !!item.update_version_id
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
@@ -716,6 +754,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 +891,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 +919,32 @@ async function handleSwitchVersion(item: ContentItem) {
}
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
if (!enabled && shouldWarnBeforeDisable([item])) {
pendingModpackDisableItems.value = [item]
sharedDisableConfirmModal.value?.show()
return
}
await toggleDisableDebounced(item, enabled)
}
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
if (!enabled && shouldWarnBeforeDisable(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)))
}
@@ -1249,13 +1311,6 @@ provideAppBackup({
},
})
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')
}
provideContentManager({
items: mergedProjects,
loading,
@@ -1316,15 +1371,25 @@ 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,
getDeleteWarningMode: (items: ContentItem[]) =>
shouldWarnBeforeDelete(items) ? 'shared-instance' : 'default',
getDisableWarningMode: (items: ContentItem[]) =>
shouldWarnBeforeDisable(items) ? 'shared-instance' : 'default',
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
@@ -1333,14 +1398,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 +1435,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,
})
File diff suppressed because it is too large Load Diff
@@ -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.vue'
import Worlds from './Worlds.vue'
export { Files, Index, Logs, Mods, Overview, Worlds }
export { Files, Index, Logs, Mods, Overview, Share, Worlds }
@@ -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)
+9
View File
@@ -215,6 +215,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',
+22
View File
@@ -148,6 +148,12 @@ 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_shared_instance_invite",
"install_update_shared_instance",
"install_import_instance",
"install_duplicate_instance",
"install_existing_instance",
@@ -209,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",
])
@@ -250,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",
+76
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,12 @@ 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_shared_instance_invite,
install_update_shared_instance,
install_import_instance,
install_duplicate_instance,
install_existing_instance,
@@ -100,6 +110,72 @@ 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>,
) -> Result<InstallJobSnapshot> {
Ok(theseus::instance::install_shared_instance(
&shared_instance_id,
name,
manager_id,
server_manager_name,
server_manager_icon_url,
)
.await?)
}
#[tauri::command]
pub async fn install_shared_instance_invite(
invite_id: String,
) -> Result<InstallJobSnapshot> {
Ok(theseus::instance::install_shared_instance_invite(&invite_id).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,
+129 -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,36 @@ 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 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,
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 +237,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 +305,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 +384,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 +772,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(())
}
+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;
+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,22 @@
<script setup lang="ts">
definePageMeta({
layout: 'empty',
})
useSeoMeta({
robots: 'noindex',
})
const route = useRoute()
const deepLink = computed(() => {
const inviteId = encodeURIComponent(route.params.inviteId as string)
return `modrinth://share/${inviteId}`
})
</script>
<template>
<main class="grid min-h-screen place-items-center bg-bg px-4">
<a :href="deepLink" class="btn btn-primary">Open in Modrinth App</a>
</main>
</template>
+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/
@@ -1,6 +1,6 @@
{
"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 ",
"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.shared_instance_role AS \"shared_instance_role?: String\",\n link.shared_instance_manager_id AS \"shared_instance_manager_id?: 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 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_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\n ",
"describe": {
"columns": [
{
@@ -174,28 +174,58 @@
"type_info": "Text"
},
{
"name": "imported_name?: String",
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"name": "shared_instance_linked_user_id?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"name": "shared_sync_applied_update_id?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 43,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"ordinal": 44,
"type_info": "Null"
}
],
@@ -240,9 +270,15 @@
true,
true,
true,
true,
true,
false,
true,
true,
true,
null,
null
]
},
"hash": "e53cd252e1e9eebcb3fb9714d7b5ee588e751fdc3f18c48bc4cfd94504c37306"
"hash": "0751df4d528f5d0f2258c798d537187035b55e3fb15ea29532c1853e7a763aa2"
}
@@ -1,6 +1,6 @@
{
"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 ",
"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_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 = ?\n ",
"describe": {
"columns": [
{
@@ -174,28 +174,58 @@
"type_info": "Text"
},
{
"name": "imported_name?: String",
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"name": "shared_instance_linked_user_id?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"name": "shared_sync_applied_update_id?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 43,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"ordinal": 44,
"type_info": "Null"
}
],
@@ -240,9 +270,15 @@
true,
true,
true,
true,
true,
false,
true,
true,
true,
null,
null
]
},
"hash": "bcc93c48dd3c9ccbe196cfbe27e36b774d1b506e6cf5f2ab38469e9e3e6c9b6d"
"hash": "1956049580e792f822ec878ca4a5aaabacd7a574513ac4378ce2f134a1b44b40"
}
@@ -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,6 +1,6 @@
{
"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 ",
"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_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 ",
"describe": {
"columns": [
{
@@ -174,28 +174,58 @@
"type_info": "Text"
},
{
"name": "imported_name?: String",
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"name": "shared_instance_linked_user_id?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"name": "shared_sync_applied_update_id?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "launch_overrides?: String",
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 43,
"type_info": "Text"
},
{
"name": "launch_overrides?: String",
"ordinal": 44,
"type_info": "Null"
}
],
@@ -240,9 +270,15 @@
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
null
]
},
"hash": "f0d8d4f98f7fdf3d656a7811f232d429a2cc73bc2c752594fcad7da19967ff7b"
"hash": "33e5231e178e126da0abdfff353f003e6fb2fd825e90594ff820b48eba34c65d"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"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 ",
"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_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 '[]' 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 = ?\n ",
"describe": {
"columns": [
{
@@ -174,28 +174,58 @@
"type_info": "Text"
},
{
"name": "imported_name?: String",
"name": "shared_instance_role?: String",
"ordinal": 34,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"name": "shared_instance_manager_id?: String",
"ordinal": 35,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"name": "shared_instance_linked_user_id?: String",
"ordinal": 36,
"type_info": "Text"
},
{
"name": "groups!: String",
"name": "shared_sync_applied_update_id?: String",
"ordinal": 37,
"type_info": "Text"
},
{
"name": "shared_sync_latest_available_update_id?: String",
"ordinal": 38,
"type_info": "Text"
},
{
"name": "shared_sync_status?: String",
"ordinal": 39,
"type_info": "Text"
},
{
"name": "imported_name?: String",
"ordinal": 40,
"type_info": "Text"
},
{
"name": "imported_version_number?: String",
"ordinal": 41,
"type_info": "Text"
},
{
"name": "imported_filename?: String",
"ordinal": 42,
"type_info": "Text"
},
{
"name": "groups!: String",
"ordinal": 43,
"type_info": "Null"
},
{
"name": "launch_overrides?: String",
"ordinal": 38,
"ordinal": 44,
"type_info": "Null"
}
],
@@ -240,9 +270,15 @@
true,
true,
true,
true,
true,
false,
true,
true,
true,
null,
null
]
},
"hash": "474496fe9b4f0ae920ceef97176dac61544130300cdf2cfc8deb7fbf2efaf8c7"
"hash": "7055b32c58c7c46df91771195ae92c3fff9f7755306494a5ad38d1782b57a614"
}
@@ -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,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"
}
@@ -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_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_linked_user_id = excluded.shared_instance_linked_user_id\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "c321179e6961ec61be909ea902b688226e60f212e59e7be10630fda957895db8"
}
@@ -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"
}
@@ -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,2 @@
ALTER TABLE instance_links
ADD COLUMN shared_instance_access_token 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/");
+19
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,20 @@ pub use self::projects::{
pub use self::run::{
QuickPlayType, kill, run, try_update_playtime_by_instance_id,
};
pub(crate) use self::shared::mark_shared_instance_stale;
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, install_shared_instance_invite,
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()
}
@@ -59,8 +59,13 @@ pub async fn edit(
patch: EditInstance,
) -> crate::Result<InstanceMetadata> {
let state = State::get().await?;
let should_reconcile_shared_publish = patch.link.is_some();
crate::state::edit_instance(instance_id, patch, &state.pool).await?;
if should_reconcile_shared_publish {
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
}
let instance = crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
+96 -8
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, SharedInstanceRole, State};
use crate::util::fetch;
use modrinth_content_management::{
ContentType, ResolutionPreferences, ResolveContentPlan,
@@ -38,6 +38,7 @@ pub async fn update_all_projects(
&state,
)
.await?;
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
emit_loading(&loading_bar, 100.0, Some("Updated instance"))?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
@@ -51,6 +52,12 @@ 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,
@@ -58,6 +65,7 @@ pub async fn update_project(
)
.await?;
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
if !skip_send_event.unwrap_or(false) {
emit_instance(instance_id, InstancePayloadType::Edited).await?;
}
@@ -83,6 +91,7 @@ pub async fn add_project_from_version(
&state,
)
.await?;
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(project_path)
@@ -121,6 +130,16 @@ pub async fn install_project_with_dependencies(
.await
{
Ok(()) => {
if let Err(error) = super::shared::mark_shared_instance_stale(
&instance_id,
&state,
)
.await
{
tracing::error!(
"Failed to mark shared instance stale after content install: {error}"
);
}
if let Err(error) = emit_instance(
&instance_id,
InstancePayloadType::ContentInstallFinished {
@@ -181,6 +200,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())
})?;
@@ -192,6 +217,7 @@ pub async fn switch_project_version_with_dependencies(
&state,
)
.await?;
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
emit_instance(&metadata.instance.id, InstancePayloadType::Edited).await?;
Ok(path)
@@ -204,13 +230,18 @@ 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?;
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(project_path)
}
#[tracing::instrument]
@@ -220,6 +251,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,
@@ -227,6 +260,7 @@ pub async fn toggle_disable_project(
&state,
)
.await?;
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
Ok(res)
@@ -238,17 +272,71 @@ 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,
&state,
)
.await?;
super::shared::mark_shared_instance_stale(instance_id, &state).await?;
emit_instance(instance_id, InstancePayloadType::Edited).await?;
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 == SharedInstanceRole::Member)
{
return Ok(());
}
let source_kind =
crate::state::instances::commands::content_source_kind_for_project_path(
instance_id,
project_path,
state,
)
.await?;
if is_shared_instance_managed_source_kind(source_kind) {
return Err(crate::ErrorKind::InputError(
"Shared instance managed content cannot be changed directly."
.to_string(),
)
.into());
}
Ok(())
}
fn is_shared_instance_managed_source_kind(
source_kind: Option<ContentSourceKind>,
) -> bool {
matches!(
source_kind,
Some(
ContentSourceKind::SharedInstance
| ContentSourceKind::ModrinthModpack
| ContentSourceKind::ImportedModpack
)
)
}
#[tracing::instrument]
pub async fn update_managed_modrinth_version(
instance_id: &str,
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -14,6 +14,7 @@ pub mod process;
pub mod server_address;
pub mod settings;
pub mod tags;
pub mod users;
pub mod worlds;
pub mod data {
@@ -26,7 +27,8 @@ pub mod data {
JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
ModrinthCredentials, Organization, OwnerType, ProcessMetadata, Project,
ProjectType, ProjectV3, SearchResult, SearchResults, SearchResultsV3,
Settings, TeamMember, Theme, User, UserFriend, Version, WindowSize,
Settings, SharedInstanceAttachment, SharedInstanceRole, TeamMember,
Theme, User, UserFriend, Version, WindowSize,
};
pub use ariadne::users::UserStatus;
pub use modrinth_content_management::{
+13 -2
View File
@@ -1,8 +1,19 @@
use crate::state::ModrinthCredentials;
use serde::Deserialize;
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ModrinthAuthFlow {
SignIn,
SignUp,
}
#[tracing::instrument]
pub fn authenticate_begin_flow() -> &'static str {
crate::state::get_login_url()
pub fn authenticate_begin_flow(flow: ModrinthAuthFlow) -> &'static str {
match flow {
ModrinthAuthFlow::SignIn => crate::state::get_login_url(),
ModrinthAuthFlow::SignUp => crate::state::get_signup_url(),
}
}
#[tracing::instrument]
+32
View File
@@ -0,0 +1,32 @@
use crate::State;
use crate::util::fetch::fetch_json;
use reqwest::Method;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SearchUser {
pub id: String,
pub username: String,
pub avatar_url: Option<String>,
}
#[tracing::instrument]
pub async fn search_user(query: &str) -> crate::Result<Vec<SearchUser>> {
let state = State::get().await?;
let query = urlencoding::encode(query);
fetch_json(
Method::GET,
&format!(
"{}users/search?query={}",
env!("MODRINTH_API_URL_V3"),
query
),
None,
None,
Some("/v3/users/search"),
&state.api_semaphore,
&state.pool,
)
.await
}
+3
View File
@@ -234,6 +234,9 @@ pub enum CommandPayload {
server: Option<String>,
singleplayer_world: Option<String>,
},
InstallSharedInstanceInvite {
invite_id: String,
},
RunMRPack {
// run or install .mrpack
path: PathBuf,
+6 -3
View File
@@ -9,10 +9,13 @@ pub use model::{
InstallErrorView, InstallJavaStep, InstallJobKind, InstallJobSnapshot,
InstallJobStatus, InstallModpackPreview, InstallPhaseDetails,
InstallPhaseId, InstallPostInstallEdit, InstallProgress,
InstallProgressSecondary, InstallRequest,
InstallProgressSecondary, InstallRequest, SharedInstanceExternalFileData,
SharedInstanceInstallData, SharedInstanceInstallModpack,
};
pub use runner::{
cancel_job, create_instance, create_modpack_instance, dismiss_job,
duplicate_instance, get_job, import_instance, install_existing_instance,
cancel_job, create_instance, create_modpack_instance,
create_shared_instance, dismiss_job, duplicate_instance, get_job,
import_instance, install_existing_instance,
install_pack_to_existing_instance, list_jobs, retry_job,
update_shared_instance,
};
+62 -2
View File
@@ -64,6 +64,9 @@ pub enum InstallRequest {
#[serde(default)]
post_install_edit: Option<InstallPostInstallEdit>,
},
CreateSharedInstance {
data: SharedInstanceInstallData,
},
ImportInstance {
launcher_type: ImportLauncherType,
base_path: PathBuf,
@@ -82,6 +85,10 @@ pub enum InstallRequest {
#[serde(default)]
post_install_edit: Option<InstallPostInstallEdit>,
},
UpdateSharedInstance {
instance_id: String,
data: SharedInstanceInstallData,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
@@ -96,6 +103,45 @@ pub struct InstallPostInstallEdit {
pub link: Option<InstanceLink>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceInstallData {
pub shared_instance_id: String,
pub manager_id: Option<String>,
#[serde(default)]
pub server_manager_name: Option<String>,
#[serde(default)]
pub server_manager_icon_url: Option<String>,
#[serde(default)]
pub linked_user_id: Option<String>,
#[serde(default)]
pub access_token: Option<String>,
pub name: String,
pub version: i32,
pub modrinth_ids: Vec<String>,
#[serde(default)]
pub external_files: Vec<SharedInstanceExternalFileData>,
pub modpack: Option<SharedInstanceInstallModpack>,
pub game_version: String,
pub loader: ModLoader,
pub loader_version: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceExternalFileData {
pub file_name: String,
pub file_type: String,
pub url: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SharedInstanceInstallModpack {
pub project_id: String,
pub version_id: String,
pub title: String,
pub icon_url: Option<String>,
pub dependency_count: usize,
}
impl InstallRequest {
pub fn kind(&self) -> InstallJobKind {
match self {
@@ -103,6 +149,9 @@ impl InstallRequest {
Self::CreateModpackInstance { .. } => {
InstallJobKind::CreateModpackInstance
}
Self::CreateSharedInstance { .. } => {
InstallJobKind::CreateSharedInstance
}
Self::ImportInstance { .. } => InstallJobKind::ImportInstance,
Self::DuplicateInstance { .. } => InstallJobKind::DuplicateInstance,
Self::InstallExistingInstance { .. } => {
@@ -111,13 +160,17 @@ impl InstallRequest {
Self::InstallPackToExistingInstance { .. } => {
InstallJobKind::InstallPackToExistingInstance
}
Self::UpdateSharedInstance { .. } => {
InstallJobKind::UpdateSharedInstance
}
}
}
pub fn target(&self) -> InstallTarget {
match self {
Self::InstallExistingInstance { instance_id, .. }
| Self::InstallPackToExistingInstance { instance_id, .. } => {
| Self::InstallPackToExistingInstance { instance_id, .. }
| Self::UpdateSharedInstance { instance_id, .. } => {
InstallTarget::ExistingInstance {
instance_id: instance_id.clone(),
}
@@ -129,7 +182,8 @@ impl InstallRequest {
pub fn cleanup(&self) -> InstallCleanup {
match self {
Self::InstallExistingInstance { instance_id, .. }
| Self::InstallPackToExistingInstance { instance_id, .. } => {
| Self::InstallPackToExistingInstance { instance_id, .. }
| Self::UpdateSharedInstance { instance_id, .. } => {
InstallCleanup::RestoreExistingInstance {
instance_id: instance_id.clone(),
}
@@ -144,10 +198,12 @@ impl InstallRequest {
pub enum InstallJobKind {
CreateInstance,
CreateModpackInstance,
CreateSharedInstance,
ImportInstance,
DuplicateInstance,
InstallExistingInstance,
InstallPackToExistingInstance,
UpdateSharedInstance,
}
impl InstallJobKind {
@@ -155,24 +211,28 @@ impl InstallJobKind {
match self {
Self::CreateInstance => "create_instance",
Self::CreateModpackInstance => "create_modpack_instance",
Self::CreateSharedInstance => "create_shared_instance",
Self::ImportInstance => "import_instance",
Self::DuplicateInstance => "duplicate_instance",
Self::InstallExistingInstance => "install_existing_instance",
Self::InstallPackToExistingInstance => {
"install_pack_to_existing_instance"
}
Self::UpdateSharedInstance => "update_shared_instance",
}
}
pub fn from_stored_str(value: &str) -> Self {
match value {
"create_modpack_instance" => Self::CreateModpackInstance,
"create_shared_instance" => Self::CreateSharedInstance,
"import_instance" => Self::ImportInstance,
"duplicate_instance" => Self::DuplicateInstance,
"install_existing_instance" => Self::InstallExistingInstance,
"install_pack_to_existing_instance" => {
Self::InstallPackToExistingInstance
}
"update_shared_instance" => Self::UpdateSharedInstance,
_ => Self::CreateInstance,
}
}
+11 -1
View File
@@ -72,6 +72,15 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
}),
crate::api::pack::install_from::CreatePackLocation::FromFile { .. } => None,
},
InstallRequest::CreateSharedInstance { data } => {
Some(InstallJobDisplay {
title: data.name.clone(),
icon: data
.modpack
.as_ref()
.and_then(|modpack| modpack.icon_url.clone()),
})
}
InstallRequest::ImportInstance {
instance_folder, ..
} => Some(InstallJobDisplay {
@@ -80,7 +89,8 @@ fn display_from_request(state: &InstallJobState) -> Option<InstallJobDisplay> {
}),
InstallRequest::DuplicateInstance { .. }
| InstallRequest::InstallExistingInstance { .. }
| InstallRequest::InstallPackToExistingInstance { .. } => {
| InstallRequest::InstallPackToExistingInstance { .. }
| InstallRequest::UpdateSharedInstance { .. } => {
state.rollback.as_ref().map(|rollback| InstallJobDisplay {
title: rollback.instance.instance.name.clone(),
icon: rollback.instance.instance.icon_path.clone(),
File diff suppressed because it is too large Load Diff
+30 -1
View File
@@ -271,6 +271,7 @@ pub async fn install_minecraft_with_reporter(
};
let state = State::get().await?;
let previous_install_stage = instance.install_stage;
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
@@ -280,6 +281,7 @@ pub async fn install_minecraft_with_reporter(
.await?;
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
let result = async {
let instance_path = get_instance_full_path(&instance.path).await?;
if let Some(reporter) = &reporter {
reporter
@@ -598,7 +600,34 @@ pub async fn install_minecraft_with_reporter(
emit_loading(loading_bar, 1.0, Some("Finished installing"))?;
}
Ok(())
Ok::<(), crate::Error>(())
}
.await;
if result.is_err() {
if let Err(error) =
crate::state::instances::commands::set_instance_install_stage(
&instance.id,
previous_install_stage,
&state.pool,
)
.await
{
tracing::error!(
"Failed to restore install stage for instance {}: {error}",
instance.id
);
} else if let Err(error) =
emit_instance(&instance.id, InstancePayloadType::Edited).await
{
tracing::error!(
"Failed to emit restored install stage for instance {}: {error}",
instance.id
);
}
}
result
}
pub async fn install_minecraft_for_instance_id_with_reporter(
+5
View File
@@ -240,6 +240,11 @@ impl FriendsSocket {
}
async fn handle_notification(notification: Value) -> crate::Result<()> {
tracing::info!(
notification = %notification,
"Received websocket notification payload"
);
if notification
.get("body")
.and_then(|body| body.get("type"))
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use super::instances::ContentSourceKind;
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum InstanceInstallStage {
@@ -122,6 +124,7 @@ pub struct ContentFile {
pub metadata: Option<FileMetadata>,
pub update_version_id: Option<String>,
pub project_type: ProjectType,
pub source_kind: Option<ContentSourceKind>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -186,6 +189,18 @@ impl ProjectType {
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"mod" | "mods" => Some(ProjectType::Mod),
"datapack" | "datapacks" => Some(ProjectType::DataPack),
"resourcepack" | "resourcepacks" => Some(ProjectType::ResourcePack),
"shader" | "shaderpack" | "shaderpacks" => {
Some(ProjectType::ShaderPack)
}
_ => None,
}
}
pub fn get_folder(&self) -> &'static str {
match self {
ProjectType::Mod => "mods",
@@ -374,6 +374,56 @@ where
rows.into_iter().map(TryInto::try_into).collect()
}
pub(crate) async fn upsert_content_set_remote_ref(
remote_ref: &ContentSetRemoteRef,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let content_set_id = remote_ref.content_set_id.as_str();
let ref_type = remote_ref.ref_type.as_str();
let ref_id = remote_ref.ref_id.as_str();
sqlx::query!(
"
INSERT INTO instance_content_set_remote_refs (
content_set_id,
ref_type,
ref_id
)
VALUES (?, ?, ?)
ON CONFLICT (content_set_id, ref_type) DO UPDATE SET
ref_id = excluded.ref_id
",
content_set_id,
ref_type,
ref_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn delete_content_set_remote_ref(
content_set_id: &str,
ref_type: ContentSetRemoteRefType,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let ref_type = ref_type.as_str();
sqlx::query!(
"
DELETE FROM instance_content_set_remote_refs
WHERE content_set_id = ? AND ref_type = ?
",
content_set_id,
ref_type,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn get_content_set_sync_state<'e, E>(
content_set_id: &str,
exec: E,
@@ -396,6 +446,66 @@ where
row.map(TryInto::try_into).transpose()
}
pub(crate) async fn upsert_content_set_sync_state(
sync_state: &ContentSetSyncState,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let content_set_id = sync_state.content_set_id.as_str();
let provider = sync_state.provider.as_str();
let applied_update_id = sync_state.applied_update_id.as_deref();
let latest_available_update_id =
sync_state.latest_available_update_id.as_deref();
let checked_at = sync_state.checked_at.map(|value| value.timestamp());
let status = sync_state.status.as_str();
sqlx::query!(
"
INSERT INTO instance_content_set_sync_state (
content_set_id,
provider,
applied_update_id,
latest_available_update_id,
checked_at,
status
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (content_set_id) DO UPDATE SET
provider = excluded.provider,
applied_update_id = excluded.applied_update_id,
latest_available_update_id = excluded.latest_available_update_id,
checked_at = excluded.checked_at,
status = excluded.status
",
content_set_id,
provider,
applied_update_id,
latest_available_update_id,
checked_at,
status,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn delete_content_set_sync_state(
content_set_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
sqlx::query!(
"
DELETE FROM instance_content_set_sync_state
WHERE content_set_id = ?
",
content_set_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn get_instance_files<'e, E>(
instance_id: &str,
exec: E,
@@ -1011,16 +1121,12 @@ pub(crate) async fn upsert_content_update_check(
}
fn project_type_from_str(value: &str) -> crate::Result<ProjectType> {
match value {
"mod" => Ok(ProjectType::Mod),
"datapack" => Ok(ProjectType::DataPack),
"resourcepack" => Ok(ProjectType::ResourcePack),
"shader" | "shaderpack" => Ok(ProjectType::ShaderPack),
other => Err(crate::ErrorKind::InputError(format!(
"Unknown content project type {other}"
ProjectType::from_name(value).ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown content project type {value}"
))
.into()),
}
.into()
})
}
fn timestamp(value: i64) -> DateTime<Utc> {
@@ -1,9 +1,10 @@
#![allow(dead_code)]
use crate::state::instances::{
ContentSet, ContentSetStatus, ContentSourceKind, Instance,
InstanceLaunchContext, InstanceLaunchOverrides,
InstanceLaunchOverridesData, InstanceLink, playtime_to_storage,
ContentSet, ContentSetStatus, ContentSetSyncStatus, ContentSourceKind,
Instance, InstanceLaunchContext, InstanceLaunchOverrides,
InstanceLaunchOverridesData, InstanceLink, SharedInstanceAttachment,
SharedInstanceRole, playtime_to_storage,
};
use crate::state::{
InstanceInstallStage, LauncherFeatureVersion, ModLoader, ReleaseChannel,
@@ -73,6 +74,9 @@ pub(crate) struct InstanceLinkRow {
pub hosting_instance_ids: Option<String>,
pub hosting_active_instance_id: Option<String>,
pub shared_instance_id: Option<String>,
pub shared_instance_role: Option<String>,
pub shared_instance_manager_id: Option<String>,
pub shared_instance_linked_user_id: Option<String>,
pub imported_name: Option<String>,
pub imported_version_number: Option<String>,
pub imported_filename: Option<String>,
@@ -137,10 +141,8 @@ impl TryFrom<InstanceLinkRow> for InstanceLink {
filename: row.imported_filename,
}),
"shared_instance" => Ok(Self::SharedInstance {
shared_instance_id: parse_uuid(
row.shared_instance_id,
"shared_instance_id",
)?,
modpack_project_id: row.modrinth_project_id,
modpack_version_id: row.modrinth_version_id,
}),
other => Err(crate::ErrorKind::InputError(format!(
"Unknown instance link kind {other}"
@@ -161,6 +163,7 @@ pub(crate) struct InstanceMetadataRecord {
pub instance: Instance,
pub applied_content_set: ContentSet,
pub link: InstanceLink,
pub shared_instance: Option<SharedInstanceAttachment>,
pub groups: Vec<String>,
pub launch_overrides: InstanceLaunchOverrides,
}
@@ -207,6 +210,12 @@ struct InstanceMetadataRow {
hosting_instance_ids: Option<String>,
hosting_active_instance_id: Option<String>,
shared_instance_id: Option<String>,
shared_instance_role: Option<String>,
shared_instance_manager_id: Option<String>,
shared_instance_linked_user_id: Option<String>,
shared_sync_applied_update_id: Option<String>,
shared_sync_latest_available_update_id: Option<String>,
shared_sync_status: Option<String>,
imported_name: Option<String>,
imported_version_number: Option<String>,
imported_filename: Option<String>,
@@ -300,12 +309,26 @@ impl InstanceMetadataRow {
hosting_server_id: self.hosting_server_id,
hosting_instance_ids: self.hosting_instance_ids,
hosting_active_instance_id: self.hosting_active_instance_id,
shared_instance_id: self.shared_instance_id,
shared_instance_id: self.shared_instance_id.clone(),
shared_instance_role: self.shared_instance_role.clone(),
shared_instance_manager_id: self.shared_instance_manager_id.clone(),
shared_instance_linked_user_id: self
.shared_instance_linked_user_id
.clone(),
imported_name: self.imported_name,
imported_version_number: self.imported_version_number,
imported_filename: self.imported_filename,
}
.try_into()?;
let shared_instance = shared_instance_attachment(
self.shared_instance_id,
self.shared_instance_role,
self.shared_instance_manager_id,
self.shared_instance_linked_user_id,
self.shared_sync_status,
self.shared_sync_applied_update_id,
self.shared_sync_latest_available_update_id,
)?;
let groups = parse_groups(self.groups)?;
let launch_overrides =
launch_overrides_from_json(instance_id, self.launch_overrides)?;
@@ -314,6 +337,7 @@ impl InstanceMetadataRow {
instance,
applied_content_set,
link,
shared_instance,
groups,
launch_overrides,
})
@@ -460,6 +484,12 @@ pub(crate) async fn get_instance_metadata_by_id(
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.shared_instance_role AS "shared_instance_role?: String",
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
sync.status AS "shared_sync_status?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
@@ -479,6 +509,9 @@ pub(crate) async fn get_instance_metadata_by_id(
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_content_set_sync_state sync
ON sync.content_set_id = cs.id
AND sync.provider = 'shared_instance'
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
WHERE i.id = ?
@@ -488,7 +521,13 @@ pub(crate) async fn get_instance_metadata_by_id(
.fetch_optional(pool)
.await?;
row.map(InstanceMetadataRow::into_record).transpose()
let Some(row) = row else {
return Ok(None);
};
let record =
hydrate_shared_instance_fields(row.into_record()?, pool).await?;
Ok(Some(record))
}
pub(crate) async fn get_instance_metadata_many(
@@ -542,6 +581,12 @@ pub(crate) async fn get_instance_metadata_many(
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.shared_instance_role AS "shared_instance_role?: String",
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
sync.status AS "shared_sync_status?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
@@ -563,6 +608,9 @@ pub(crate) async fn get_instance_metadata_many(
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_content_set_sync_state sync
ON sync.content_set_id = cs.id
AND sync.provider = 'shared_instance'
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
ORDER BY requested.ord
@@ -572,9 +620,14 @@ pub(crate) async fn get_instance_metadata_many(
.fetch_all(pool)
.await?;
rows.into_iter()
.map(InstanceMetadataRow::into_record)
.collect()
let mut records = Vec::with_capacity(rows.len());
for row in rows {
records.push(
hydrate_shared_instance_fields(row.into_record()?, pool).await?,
);
}
Ok(records)
}
pub(crate) async fn list_instance_metadata(
@@ -618,6 +671,12 @@ pub(crate) async fn list_instance_metadata(
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.shared_instance_role AS "shared_instance_role?: String",
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
sync.status AS "shared_sync_status?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
@@ -637,6 +696,9 @@ pub(crate) async fn list_instance_metadata(
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_content_set_sync_state sync
ON sync.content_set_id = cs.id
AND sync.provider = 'shared_instance'
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
"#,
@@ -644,9 +706,14 @@ pub(crate) async fn list_instance_metadata(
.fetch_all(pool)
.await?;
rows.into_iter()
.map(InstanceMetadataRow::into_record)
.collect()
let mut records = Vec::with_capacity(rows.len());
for row in rows {
records.push(
hydrate_shared_instance_fields(row.into_record()?, pool).await?,
);
}
Ok(records)
}
pub(crate) async fn get_instance_launch_context(
@@ -691,6 +758,12 @@ pub(crate) async fn get_instance_launch_context(
json(link.hosting_instance_ids) AS "hosting_instance_ids?: String",
link.hosting_active_instance_id AS "hosting_active_instance_id?: String",
link.shared_instance_id AS "shared_instance_id?: String",
link.shared_instance_role AS "shared_instance_role?: String",
link.shared_instance_manager_id AS "shared_instance_manager_id?: String",
link.shared_instance_linked_user_id AS "shared_instance_linked_user_id?: String",
sync.applied_update_id AS "shared_sync_applied_update_id?: String",
sync.latest_available_update_id AS "shared_sync_latest_available_update_id?: String",
sync.status AS "shared_sync_status?: String",
link.imported_name AS "imported_name?: String",
link.imported_version_number AS "imported_version_number?: String",
link.imported_filename AS "imported_filename?: String",
@@ -702,6 +775,9 @@ pub(crate) async fn get_instance_launch_context(
AND cs.instance_id = i.id
LEFT JOIN instance_links link
ON link.instance_id = i.id
LEFT JOIN instance_content_set_sync_state sync
ON sync.content_set_id = cs.id
AND sync.provider = 'shared_instance'
LEFT JOIN instance_launch_overrides overrides
ON overrides.instance_id = i.id
WHERE i.id = ?
@@ -753,6 +829,9 @@ where
json(hosting_instance_ids) AS "hosting_instance_ids?: String",
hosting_active_instance_id,
shared_instance_id,
shared_instance_role,
shared_instance_manager_id,
shared_instance_linked_user_id,
imported_name,
imported_version_number,
imported_filename
@@ -949,7 +1028,6 @@ pub(crate) async fn upsert_instance_link(
let hosting_instance_ids = columns.hosting_instance_ids.as_deref();
let hosting_active_instance_id =
columns.hosting_active_instance_id.as_deref();
let shared_instance_id = columns.shared_instance_id.as_deref();
let imported_name = columns.imported_name.as_deref();
let imported_version_number = columns.imported_version_number.as_deref();
let imported_filename = columns.imported_filename.as_deref();
@@ -967,12 +1045,11 @@ pub(crate) async fn upsert_instance_link(
hosting_server_id,
hosting_instance_ids,
hosting_active_instance_id,
shared_instance_id,
imported_name,
imported_version_number,
imported_filename
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, jsonb(?), ?, ?, ?, ?)
ON CONFLICT (instance_id) DO UPDATE SET
link_kind = excluded.link_kind,
modrinth_project_id = excluded.modrinth_project_id,
@@ -983,7 +1060,6 @@ pub(crate) async fn upsert_instance_link(
hosting_server_id = excluded.hosting_server_id,
hosting_instance_ids = excluded.hosting_instance_ids,
hosting_active_instance_id = excluded.hosting_active_instance_id,
shared_instance_id = excluded.shared_instance_id,
imported_name = excluded.imported_name,
imported_version_number = excluded.imported_version_number,
imported_filename = excluded.imported_filename
@@ -998,7 +1074,6 @@ pub(crate) async fn upsert_instance_link(
hosting_server_id,
hosting_instance_ids,
hosting_active_instance_id,
shared_instance_id,
imported_name,
imported_version_number,
imported_filename,
@@ -1009,6 +1084,76 @@ pub(crate) async fn upsert_instance_link(
Ok(())
}
pub(crate) async fn set_shared_instance_attachment(
instance_id: &str,
attachment: Option<&SharedInstanceAttachment>,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let shared_instance_id = attachment.map(|value| value.id.to_string());
let shared_instance_role =
attachment.map(|value| value.role.as_str().to_string());
let shared_instance_manager_id =
attachment.and_then(|value| value.manager_id.as_deref());
let shared_instance_linked_user_id =
attachment.and_then(|value| value.linked_user_id.as_deref());
let shared_instance_access_token =
attachment.and_then(|value| value.access_token.as_deref());
let shared_instance_server_manager_name =
attachment.and_then(|value| value.server_manager_name.as_deref());
let shared_instance_server_manager_icon_url =
attachment.and_then(|value| value.server_manager_icon_url.as_deref());
sqlx::query!(
"
INSERT INTO instance_links (
instance_id,
link_kind,
shared_instance_id,
shared_instance_role,
shared_instance_manager_id,
shared_instance_linked_user_id
)
VALUES (?, 'unmanaged', ?, ?, ?, ?)
ON CONFLICT (instance_id) DO UPDATE SET
link_kind = CASE
WHEN excluded.shared_instance_id IS NULL
AND instance_links.link_kind = 'shared_instance'
THEN 'unmanaged'
ELSE instance_links.link_kind
END,
shared_instance_id = excluded.shared_instance_id,
shared_instance_role = excluded.shared_instance_role,
shared_instance_manager_id = excluded.shared_instance_manager_id,
shared_instance_linked_user_id = excluded.shared_instance_linked_user_id
",
instance_id,
shared_instance_id,
shared_instance_role,
shared_instance_manager_id,
shared_instance_linked_user_id,
)
.execute(&mut **tx)
.await?;
sqlx::query(
"
UPDATE instance_links
SET shared_instance_access_token = ?,
shared_instance_server_manager_name = ?,
shared_instance_server_manager_icon_url = ?
WHERE instance_id = ?
",
)
.bind(shared_instance_access_token)
.bind(shared_instance_server_manager_name)
.bind(shared_instance_server_manager_icon_url)
.bind(instance_id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn replace_instance_groups(
instance_id: &str,
groups: &[String],
@@ -1094,7 +1239,6 @@ struct InstanceLinkColumns {
hosting_server_id: Option<String>,
hosting_instance_ids: Option<String>,
hosting_active_instance_id: Option<String>,
shared_instance_id: Option<String>,
imported_name: Option<String>,
imported_version_number: Option<String>,
imported_filename: Option<String>,
@@ -1114,7 +1258,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1132,7 +1275,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1147,7 +1289,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1166,7 +1307,6 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1186,7 +1326,6 @@ fn instance_link_columns(
hosting_instance_ids: Some(serde_json::to_string(instance_ids)?),
hosting_active_instance_id: active_instance_id
.map(|value| value.to_string()),
shared_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
@@ -1207,28 +1346,27 @@ fn instance_link_columns(
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: None,
imported_name: name.clone(),
imported_version_number: version_number.clone(),
imported_filename: filename.clone(),
}),
InstanceLink::SharedInstance { shared_instance_id } => {
Ok(InstanceLinkColumns {
link_kind: "shared_instance",
modrinth_project_id: None,
modrinth_version_id: None,
server_project_id: None,
content_project_id: None,
content_version_id: None,
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
shared_instance_id: Some(shared_instance_id.to_string()),
imported_name: None,
imported_version_number: None,
imported_filename: None,
})
}
InstanceLink::SharedInstance {
modpack_project_id,
modpack_version_id,
} => Ok(InstanceLinkColumns {
link_kind: "shared_instance",
modrinth_project_id: modpack_project_id.clone(),
modrinth_version_id: modpack_version_id.clone(),
server_project_id: None,
content_project_id: None,
content_version_id: None,
hosting_server_id: None,
hosting_instance_ids: None,
hosting_active_instance_id: None,
imported_name: None,
imported_version_number: None,
imported_filename: None,
}),
}
}
@@ -1273,6 +1411,96 @@ fn launch_overrides_from_json(
}
}
async fn hydrate_shared_instance_fields(
mut record: InstanceMetadataRecord,
pool: &SqlitePool,
) -> crate::Result<InstanceMetadataRecord> {
if let Some(attachment) = record.shared_instance.as_mut() {
let (access_token, server_manager_name, server_manager_icon_url) =
shared_instance_fields(&record.instance.id, pool).await?;
attachment.access_token = access_token;
attachment.server_manager_name = server_manager_name;
attachment.server_manager_icon_url = server_manager_icon_url;
}
Ok(record)
}
async fn shared_instance_fields(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<(Option<String>, Option<String>, Option<String>)> {
Ok(
sqlx::query_as::<_, (Option<String>, Option<String>, Option<String>)>(
"
SELECT
shared_instance_access_token,
shared_instance_server_manager_name,
shared_instance_server_manager_icon_url
FROM instance_links
WHERE instance_id = ?
",
)
.bind(instance_id)
.fetch_optional(pool)
.await?
.unwrap_or((None, None, None)),
)
}
fn shared_instance_attachment(
shared_instance_id: Option<String>,
shared_instance_role: Option<String>,
shared_instance_manager_id: Option<String>,
shared_instance_linked_user_id: Option<String>,
shared_sync_status: Option<String>,
applied_update_id: Option<String>,
latest_available_update_id: Option<String>,
) -> crate::Result<Option<SharedInstanceAttachment>> {
let Some(id) = shared_instance_id else {
return Ok(None);
};
let role = match shared_instance_role {
Some(role) => SharedInstanceRole::from_stored_str(&role)?,
None => SharedInstanceRole::Member,
};
let status = match shared_sync_status {
Some(status) => ContentSetSyncStatus::from_str(&status)?,
None => ContentSetSyncStatus::Unknown,
};
Ok(Some(SharedInstanceAttachment {
id,
role,
manager_id: shared_instance_manager_id,
server_manager_name: None,
server_manager_icon_url: None,
linked_user_id: shared_instance_linked_user_id,
access_token: None,
status,
applied_version: optional_i32(applied_update_id, "applied_update_id")?,
latest_version: optional_i32(
latest_available_update_id,
"latest_available_update_id",
)?,
}))
}
fn optional_i32(
value: Option<String>,
column: &str,
) -> crate::Result<Option<i32>> {
value
.map(|value| {
value.parse().map_err(|err| {
crate::ErrorKind::InputError(format!("Invalid {column}: {err}"))
.into()
})
})
.transpose()
}
fn parse_uuid(value: Option<String>, column: &str) -> crate::Result<Uuid> {
let value = required(value, column)?;
@@ -724,6 +724,31 @@ pub(crate) async fn remove_project(
Ok(())
}
pub(crate) async fn content_source_kind_for_project_path(
instance_id: &str,
project_path: &str,
state: &State,
) -> crate::Result<Option<ContentSourceKind>> {
let scope = resolve_content_scope(instance_id, None, state).await?;
let Some(file) = content_rows::get_instance_file_by_relative_path(
&scope.instance.id,
project_path,
&state.pool,
)
.await?
else {
return Ok(None);
};
let entries =
content_rows::get_content_entries(&scope.content_set_id, &state.pool)
.await?;
Ok(entries.into_iter().find_map(|entry| {
(entry.file_id.as_deref() == Some(file.id.as_str()))
.then_some(entry.source_kind)
}))
}
pub(crate) async fn list_project_files(
instance_id: &str,
state: &State,
@@ -1,5 +1,6 @@
use crate::state::instances::{
ContentEntry, ContentSet, ContentSourceKind, InstanceFile,
SharedInstanceRole,
adapters::sqlite::{content_rows, instance_rows},
};
use crate::state::{
@@ -52,6 +53,7 @@ struct InstalledProject {
relative_path: String,
project_id: Option<String>,
version_id: Option<String>,
source_kind: ContentSourceKind,
enabled: bool,
}
@@ -281,8 +283,14 @@ async fn plan_bulk_update(
instance_id: &str,
state: &State,
) -> crate::Result<BulkUpdatePlan> {
let updateable_paths =
bulk_updateable_project_paths(instance_id, state).await?;
let shared_instance_member =
is_shared_instance_member(instance_id, state).await?;
let updateable_paths = bulk_updateable_project_paths(
instance_id,
shared_instance_member,
state,
)
.await?;
if updateable_paths.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
@@ -316,6 +324,32 @@ async fn plan_bulk_update(
})?;
let installed =
installed_projects(instance_id, &content_set, state).await?;
let updateable_paths = if shared_instance_member {
let managed_paths = installed
.iter()
.filter(|project| {
is_shared_instance_managed_source_kind(project.source_kind)
})
.map(|project| project.relative_path.clone())
.collect::<HashSet<_>>();
updateable_paths
.into_iter()
.filter(|path| !managed_paths.contains(path))
.collect::<HashSet<_>>()
} else {
updateable_paths
};
let updates = updates
.into_iter()
.filter(|update| updateable_paths.contains(&update.relative_path))
.collect::<Vec<_>>();
if updates.is_empty() {
return Ok(BulkUpdatePlan {
project_updates: Vec::new(),
dependency_additions: Vec::new(),
});
}
let installed_by_project = installed
.iter()
.filter_map(|project| {
@@ -402,6 +436,7 @@ async fn plan_bulk_update(
async fn bulk_updateable_project_paths(
instance_id: &str,
shared_instance_member: bool,
state: &State,
) -> crate::Result<HashSet<String>> {
let items = super::list_content::list_content(
@@ -412,7 +447,16 @@ async fn bulk_updateable_project_paths(
)
.await?;
Ok(items.into_iter().map(|item| item.file_path).collect())
Ok(items
.into_iter()
.filter(|item| {
!shared_instance_member
|| !item
.source_kind
.is_some_and(is_shared_instance_managed_source_kind)
})
.map(|item| item.file_path)
.collect())
}
async fn installed_projects(
@@ -457,10 +501,38 @@ fn installed_project_from_row(
relative_path: file.relative_path.clone(),
project_id: entry.project_id.clone(),
version_id: entry.version_id.clone(),
source_kind: entry.source_kind,
enabled: entry.enabled && file.enabled,
})
}
async fn is_shared_instance_member(
instance_id: &str,
state: &State,
) -> crate::Result<bool> {
let Some(metadata) =
instance_rows::get_instance_metadata_by_id(instance_id, &state.pool)
.await?
else {
return Ok(false);
};
Ok(metadata.shared_instance.is_some_and(|attachment| {
attachment.role == SharedInstanceRole::Member
}))
}
fn is_shared_instance_managed_source_kind(
source_kind: ContentSourceKind,
) -> bool {
matches!(
source_kind,
ContentSourceKind::SharedInstance
| ContentSourceKind::ModrinthModpack
| ContentSourceKind::ImportedModpack
)
}
async fn dependency_closure(
root_versions: Vec<Version>,
content_set: &ContentSet,
@@ -1,6 +1,6 @@
use crate::state::instances::{
ContentSet, Instance, InstanceLaunchOverrides, InstanceLink,
adapters::sqlite::instance_rows,
SharedInstanceAttachment, adapters::sqlite::instance_rows,
};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
@@ -10,6 +10,7 @@ pub struct InstanceMetadata {
pub instance: Instance,
pub applied_content_set: ContentSet,
pub link: InstanceLink,
pub shared_instance: Option<SharedInstanceAttachment>,
pub groups: Vec<String>,
pub launch_overrides: InstanceLaunchOverrides,
}
@@ -61,6 +62,7 @@ impl From<instance_rows::InstanceMetadataRecord> for InstanceMetadata {
instance: record.instance,
applied_content_set: record.applied_content_set,
link: record.link,
shared_instance: record.shared_instance,
groups: record.groups,
launch_overrides: record.launch_overrides,
}
@@ -528,6 +528,7 @@ pub(crate) async fn dependencies_to_content_items(
has_update: false,
update_version_id: None,
date_added: None,
source_kind: None,
})
})
.collect::<Vec<_>>();
@@ -738,6 +739,7 @@ async fn content_projects_for_scope(
size: file.size,
metadata: file_metadata_from_entry_or_cache(entry, metadata),
project_type,
source_kind: entry.map(|entry| entry.source_kind),
},
);
}
@@ -900,6 +902,7 @@ async fn content_files_to_content_items(
has_update: file.update_version_id.is_some(),
update_version_id: file.update_version_id.clone(),
date_added: modification_times[index].clone(),
source_kind: file.source_kind,
}
})
.collect::<Vec<_>>();
@@ -1089,6 +1092,10 @@ fn linked_modpack_ids(link: &InstanceLink) -> Option<(String, String)> {
version_id: Some(version_id),
..
} => Some((project_id.clone(), version_id.clone())),
InstanceLink::SharedInstance {
modpack_project_id: Some(project_id),
modpack_version_id: Some(version_id),
} => Some((project_id.clone(), version_id.clone())),
_ => None,
}
}
@@ -1103,6 +1110,10 @@ fn linked_modpack_source_kind(
InstanceLink::ServerProjectModpack { .. } => {
Some(ContentSourceKind::ServerProject)
}
InstanceLink::SharedInstance {
modpack_project_id: Some(_),
modpack_version_id: Some(_),
} => Some(ContentSourceKind::ModrinthModpack),
_ => None,
}
}
@@ -1348,12 +1359,7 @@ async fn get_modpack_identifiers(
}
fn project_type_from_api_name(project_type: &str) -> ProjectType {
match project_type {
"resourcepack" => ProjectType::ResourcePack,
"shader" => ProjectType::ShaderPack,
"datapack" => ProjectType::DataPack,
_ => ProjectType::Mod,
}
ProjectType::from_name(project_type).unwrap_or(ProjectType::Mod)
}
fn sort_content_items(items: &mut [ContentItem]) {
@@ -41,3 +41,9 @@ mod check_content_updates;
mod apply_content_update;
pub(crate) use self::apply_content_update::*;
mod shared_instance;
pub(crate) use self::shared_instance::{
attach_shared_instance, clear_shared_instance, mark_shared_instance_stale,
set_shared_instance_sync_status,
};
@@ -0,0 +1,184 @@
use crate::state::instances::adapters::sqlite::{content_rows, instance_rows};
use crate::state::instances::{
ContentSetRemoteRef, ContentSetRemoteRefType, ContentSetSyncProvider,
ContentSetSyncState, ContentSetSyncStatus, InstanceLink,
SharedInstanceAttachment, SharedInstanceRole,
};
use chrono::Utc;
use sqlx::SqlitePool;
pub(crate) async fn attach_shared_instance(
instance_id: &str,
shared_instance_id: &str,
role: SharedInstanceRole,
manager_id: Option<String>,
server_manager_name: Option<String>,
server_manager_icon_url: Option<String>,
linked_user_id: Option<String>,
access_token: Option<String>,
status: ContentSetSyncStatus,
applied_version: Option<i32>,
latest_version: Option<i32>,
pool: &SqlitePool,
) -> crate::Result<()> {
let metadata =
instance_rows::get_instance_metadata_by_id(instance_id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let content_set_id = metadata.applied_content_set.id;
let attachment = SharedInstanceAttachment {
id: shared_instance_id.to_string(),
role,
manager_id,
server_manager_name,
server_manager_icon_url,
linked_user_id,
access_token,
status,
applied_version,
latest_version,
};
let sync_state =
shared_sync_state(&content_set_id, &attachment, Some(Utc::now()));
let mut tx = pool.begin().await?;
instance_rows::set_shared_instance_attachment(
instance_id,
Some(&attachment),
&mut tx,
)
.await?;
content_rows::upsert_content_set_remote_ref(
&ContentSetRemoteRef {
content_set_id: content_set_id.clone(),
ref_type: ContentSetRemoteRefType::SharedContentSet,
ref_id: shared_instance_id.to_string(),
},
&mut tx,
)
.await?;
content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn clear_shared_instance(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
let Some(metadata) =
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
else {
return Ok(());
};
let content_set_id = metadata.applied_content_set.id;
let retained_modpack_link = match metadata.link {
InstanceLink::SharedInstance {
modpack_project_id: Some(project_id),
modpack_version_id: Some(version_id),
} => Some(InstanceLink::ModrinthModpack {
project_id,
version_id,
}),
_ => None,
};
let mut tx = pool.begin().await?;
instance_rows::set_shared_instance_attachment(instance_id, None, &mut tx)
.await?;
if let Some(link) = retained_modpack_link.as_ref() {
instance_rows::upsert_instance_link(instance_id, link, &mut tx).await?;
}
content_rows::delete_content_set_remote_ref(
&content_set_id,
ContentSetRemoteRefType::SharedContentSet,
&mut tx,
)
.await?;
content_rows::delete_content_set_sync_state(&content_set_id, &mut tx)
.await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn set_shared_instance_sync_status(
instance_id: &str,
status: ContentSetSyncStatus,
applied_version: Option<i32>,
latest_version: Option<i32>,
pool: &SqlitePool,
) -> crate::Result<()> {
let metadata =
instance_rows::get_instance_metadata_by_id(instance_id, pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let Some(mut attachment) = metadata.shared_instance else {
return Ok(());
};
attachment.status = status;
attachment.applied_version = applied_version;
attachment.latest_version = latest_version;
let sync_state = shared_sync_state(
&metadata.applied_content_set.id,
&attachment,
Some(Utc::now()),
);
let mut tx = pool.begin().await?;
content_rows::upsert_content_set_sync_state(&sync_state, &mut tx).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn mark_shared_instance_stale(
instance_id: &str,
pool: &SqlitePool,
) -> crate::Result<()> {
let Some(metadata) =
instance_rows::get_instance_metadata_by_id(instance_id, pool).await?
else {
return Ok(());
};
let Some(attachment) = metadata.shared_instance else {
return Ok(());
};
if attachment.role != SharedInstanceRole::Owner {
return Ok(());
}
set_shared_instance_sync_status(
instance_id,
ContentSetSyncStatus::Stale,
attachment.applied_version,
attachment.latest_version,
pool,
)
.await
}
fn shared_sync_state(
content_set_id: &str,
attachment: &SharedInstanceAttachment,
checked_at: Option<chrono::DateTime<Utc>>,
) -> ContentSetSyncState {
ContentSetSyncState {
content_set_id: content_set_id.to_string(),
provider: ContentSetSyncProvider::SharedInstance,
applied_update_id: attachment
.applied_version
.map(|value| value.to_string()),
latest_available_update_id: attachment
.latest_version
.map(|value| value.to_string()),
checked_at,
status: attachment.status,
}
}
@@ -1,3 +1,4 @@
use super::ContentSourceKind;
use crate::state::{Project, ProjectType, Version};
use serde::{Deserialize, Serialize};
@@ -15,6 +16,7 @@ pub struct ContentItem {
pub has_update: bool,
pub update_version_id: Option<String>,
pub date_added: Option<String>,
pub source_kind: Option<ContentSourceKind>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -10,6 +10,10 @@ pub use self::commands::{
AppliedContentSetPatch, CreateInstance, EditInstance,
InstanceLaunchOverridesPatch, InstanceMetadata,
};
pub(crate) use self::commands::{
attach_shared_instance, clear_shared_instance, mark_shared_instance_stale,
set_shared_instance_sync_status,
};
pub(crate) use self::commands::{
create_instance, edit_instance, get_instance, get_instances_metadata,
list_instances, refresh_all_instances, remove_instance,
@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::ContentSetSyncStatus;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstanceLink {
@@ -33,6 +35,47 @@ pub enum InstanceLink {
filename: Option<String>,
},
SharedInstance {
shared_instance_id: Uuid,
modpack_project_id: Option<String>,
modpack_version_id: Option<String>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SharedInstanceRole {
Owner,
Member,
}
impl SharedInstanceRole {
pub fn as_str(self) -> &'static str {
match self {
Self::Owner => "owner",
Self::Member => "member",
}
}
pub fn from_stored_str(value: &str) -> crate::Result<Self> {
match value {
"owner" => Ok(Self::Owner),
"member" => Ok(Self::Member),
other => Err(super::unknown_value("shared instance role", other)),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SharedInstanceAttachment {
pub id: String,
pub role: SharedInstanceRole,
pub manager_id: Option<String>,
#[serde(default)]
pub server_manager_name: Option<String>,
#[serde(default)]
pub server_manager_icon_url: Option<String>,
pub linked_user_id: Option<String>,
pub access_token: Option<String>,
pub status: ContentSetSyncStatus,
pub applied_version: Option<i32>,
pub latest_version: Option<i32>,
}
@@ -138,7 +138,25 @@ pub async fn init_watcher() -> crate::Result<FileWatcher> {
};
if let Some(event) = event {
let emit_instance_id = instance_id.clone();
let mark_shared_stale = matches!(
&event,
InstancePayloadType::Synced
);
tokio::spawn(async move {
if mark_shared_stale
&& let Ok(state) =
State::get().await
&& let Err(error) =
crate::api::instance::mark_shared_instance_stale(
&emit_instance_id,
&state,
)
.await
{
tracing::error!(
"Failed to mark shared instance stale after filesystem sync: {error}"
);
}
let _ = emit_instance(
&emit_instance_id,
event,
+4
View File
@@ -195,6 +195,10 @@ pub const fn get_login_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-in")
}
pub const fn get_signup_url() -> &'static str {
concat!(env!("MODRINTH_URL"), "auth/sign-up")
}
pub async fn finish_login_flow(
code: &str,
semaphore: &FetchSemaphore,
+42 -14
View File
@@ -9,25 +9,47 @@
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component :is="getSeverityIcon(type)" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
</slot>
<div class="col-start-2 flex min-w-0 flex-1 flex-col gap-2">
<div
class="col-start-2 min-w-0"
:class="
inlineActions && !showActionsUnderneath && $slots.actions
? 'flex flex-wrap items-start gap-x-4 gap-y-3'
: 'flex flex-1 flex-col gap-2'
"
>
<div
v-if="header || $slots.header || normalizedTimestamp"
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
class="flex min-w-0 flex-1 flex-col gap-2"
:class="
inlineActions && !showActionsUnderneath && $slots.actions
? 'admonition-inline-content'
: ''
"
>
<slot name="header">{{ header }}</slot>
<span
v-if="normalizedTimestamp"
v-tooltip="timestampTooltip"
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
<div
v-if="header || $slots.header || normalizedTimestamp"
class="flex flex-wrap items-center gap-2 text-lg font-semibold leading-6"
>
<ClockIcon class="size-4" />
{{ relativeTimeLabel }}
</span>
<slot name="header">{{ header }}</slot>
<span
v-if="normalizedTimestamp"
v-tooltip="timestampTooltip"
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
>
<ClockIcon class="size-4" />
{{ relativeTimeLabel }}
</span>
</div>
<div class="font-normal text-contrast/85 leading-tight">
<slot>{{ body }}</slot>
</div>
</div>
<div class="font-normal text-contrast/85 leading-tight">
<slot>{{ body }}</slot>
<div
v-if="inlineActions && !showActionsUnderneath && $slots.actions"
class="ml-auto flex shrink-0 items-center justify-end self-center"
>
<slot name="actions" />
</div>
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
<div v-else-if="showActionsUnderneath || $slots.actions" class="mt-2">
<slot name="actions" />
</div>
</div>
@@ -83,6 +105,7 @@ const props = withDefaults(
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation' | 'circle-warning'
header?: string
body?: string
inlineActions?: boolean
showActionsUnderneath?: boolean
dismissible?: boolean
progress?: number
@@ -95,6 +118,7 @@ const props = withDefaults(
type: 'info',
header: '',
body: '',
inlineActions: false,
showActionsUnderneath: false,
dismissible: false,
progress: undefined,
@@ -185,6 +209,10 @@ const progressFillClasses = {
</script>
<style scoped>
.admonition-inline-content {
min-width: min(100%, 16rem);
}
.admonition-progress--waiting {
animation: admonition-progress-waiting 1s linear infinite;
position: relative;
+41 -17
View File
@@ -6,23 +6,45 @@
:class="{ 'drop-shadow-xl border border-solid border-surface-4': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">
<RouterLink
v-for="(link, index) in filteredLinks"
v-show="link.shown ?? true"
:key="link.href"
ref="tabLinkElements"
:replace="replace"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
<template v-for="(link, index) in filteredLinks" :key="link.href">
<div
v-if="link.disabled"
v-show="link.shown ?? true"
ref="tabLinkElements"
v-tooltip="link.tooltip"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 opacity-60 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
:aria-disabled="true"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
>
<component :is="link.icon" v-if="link.icon" class="size-5 text-secondary" />
<span class="text-nowrap text-secondary">
{{ link.label }}
</span>
</div>
<RouterLink
v-else
v-show="link.shown ?? true"
ref="tabLinkElements"
:replace="replace"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="getIconClasses(index)"
/>
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
</template>
</template>
<template v-else>
@@ -67,6 +89,8 @@ interface Tab {
label: string
href: string
shown?: boolean
disabled?: boolean
tooltip?: string
icon?: Component
subpages?: string[]
onHover?: () => void
+1
View File
@@ -14,6 +14,7 @@ export * from './project'
export * from './search'
export * from './servers'
export * from './settings'
export * from './sharing'
export * from './skin'
export * from './user'
export * from './version'
@@ -1,111 +0,0 @@
<template>
<NewModal ref="modal" header="Install to play" :closable="true">
<div class="flex flex-col gap-4 max-w-[500px]">
<Admonition type="info" header="Shared server instance">
This server requires modded content to play. Accept to install the needed files from
Modrinth.
</Admonition>
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
<Avatar
v-if="sharedBy?.icon_url"
:src="sharedBy.icon_url"
:alt="sharedBy.name"
size="24px"
/>
<span>
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
shared this instance with you today.
</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-secondary">Shared instance</span>
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">{{ project.title }}</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
<template v-if="modCount"> · {{ modCount }} mods </template>
</span>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
Decline
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<CheckIcon />
Accept
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import type { Project } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { useVIntl } from '../../composables'
import { formatLoader } from '../../utils'
import Admonition from '../base/Admonition.vue'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import NewModal from './NewModal.vue'
const props = defineProps<{
project: Project
sharedBy?: {
name: string
icon_url?: string
}
modCount?: number
}>()
const emit = defineEmits<{
accept: []
decline: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const loaderDisplay = computed(() => {
const loader = props.project.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
function handleAccept() {
// TODO: Implement accept logic
emit('accept')
modal.value?.hide()
}
function handleDecline() {
emit('decline')
modal.value?.hide()
}
function show(e?: MouseEvent) {
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
+14 -1
View File
@@ -170,6 +170,7 @@ const props = withDefaults(
header?: string
hideHeader?: boolean
onHide?: () => void
onAfterHide?: () => void
onShow?: () => void
mergeHeader?: boolean
scrollable?: boolean
@@ -196,6 +197,7 @@ const props = withDefaults(
header: undefined,
hideHeader: false,
onHide: () => {},
onAfterHide: () => {},
onShow: () => {},
mergeHeader: false,
// TODO: migrate all modals to use scrollable and remove this prop
@@ -226,6 +228,7 @@ const visible = ref(false)
const stackDepth = ref(0)
const modalBodyRef = ref<HTMLElement | null>(null)
let previousFocusEl: Element | null = null
let hideTimeout: ReturnType<typeof setTimeout> | null = null
const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollContainer)
@@ -239,6 +242,10 @@ function getFocusableElements(): HTMLElement[] {
}
function show(event?: MouseEvent) {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
props.onShow?.()
const wasEmpty = modalStackSize() === 0
stackDepth.value = modalStackSize()
@@ -287,8 +294,10 @@ function hide() {
previousFocusEl.focus()
}
previousFocusEl = null
setTimeout(() => {
hideTimeout = setTimeout(() => {
open.value = false
hideTimeout = null
props.onAfterHide?.()
}, 300)
}
@@ -334,6 +343,10 @@ function resetMousePosition() {
}
onUnmounted(() => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
if (open.value) {
popModal()
window.removeEventListener('keydown', handleWindowKeyDown)
@@ -1,6 +1,5 @@
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as InstallToPlayModal } from './InstallToPlayModal.vue'
export { default as Modal } from './Modal.vue'
export { default as NewModal } from './NewModal.vue'
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'
@@ -18,6 +18,7 @@
<NotificationToast
v-if="item.toast"
:type="item.toast.type"
:action-loading="toastActionLoading(item.id)"
:actor-name="item.toast.actorName"
:actor-avatar-url="item.toast.actorAvatarUrl"
:entity-name="item.toast.entityName"
@@ -29,7 +30,7 @@
:progress-type="item.toast.progressType"
:progress-current="item.toast.progressCurrent"
:progress-total="item.toast.progressTotal"
@accept="handleToastAction(item, item.toast.onAccept)"
@accept="handleToastAccept(item, item.toast.onAccept)"
@decline="handleToastAction(item, item.toast.onDecline)"
@dismiss="handleToastAction(item, item.toast.onDismiss)"
@launch="handleToastAction(item, item.toast.onLaunch)"
@@ -167,7 +168,7 @@ import {
XCircleIcon,
XIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useModalStack } from '../../composables/modal-stack'
import {
@@ -189,11 +190,13 @@ const hasModalActive = computed(() => stackCount.value > 0)
const notificationGroupStyle = computed(() => ({
zIndex: hasModalActive.value ? 100 + stackCount.value * 10 + 8 : 200,
}))
const activeToastActions = ref<Record<string, 'accept'>>({})
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
const setNotificationTimer = (n: PopupNotification) =>
popupNotificationManager.setNotificationTimer(n)
const dismiss = (id: string | number) => popupNotificationManager.removeNotification(id)
const toastActionLoading = (id: string | number) => activeToastActions.value[String(id)] ?? null
function isDownloadNotification(item: PopupNotification) {
return (
@@ -262,6 +265,26 @@ async function handleToastAction(item: PopupNotification, action?: () => void |
await action?.()
}
async function handleToastAccept(item: PopupNotification, action?: () => void | Promise<void>) {
if (toastActionLoading(item.id) != null) return
const actionId = String(item.id)
popupNotificationManager.stopNotificationTimer(item)
activeToastActions.value = {
...activeToastActions.value,
[actionId]: 'accept',
}
try {
await action?.()
} finally {
activeToastActions.value = Object.fromEntries(
Object.entries(activeToastActions.value).filter(([key]) => key !== actionId),
)
popupNotificationManager.removeNotification(item.id)
}
}
function progressColorForType(type: PopupNotification['type']) {
if (type === 'error') {
return 'red'
@@ -4,11 +4,11 @@
>
<div v-if="isInviteNotification" class="flex w-full items-start gap-3">
<Avatar
:src="actorAvatarUrl"
:alt="actorLabel"
:tint-by="actorLabel"
:src="inviteAvatarUrl"
:alt="inviteAvatarLabel"
:tint-by="inviteAvatarLabel"
size="44px"
circle
:circle="inviteAvatarCircle"
no-shadow
class="border border-solid border-surface-5"
/>
@@ -35,20 +35,17 @@
>.
</template>
<template v-else>
<span class="inline-flex max-w-full items-center gap-[5px] align-[-4px]">
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
size="24px"
no-shadow
raised
:tint-by="entityLabel"
class="!rounded-[7px]"
/>
<span class="min-w-0 truncate font-semibold text-contrast">{{
entityLabel
}}</span> </span
>.
<Avatar
:src="entityIconUrl"
:alt="entityLabel"
:tint-by="entityLabel"
size="28px"
no-shadow
raised
class="mx-1 inline-block !rounded-lg align-middle"
/>
<span class="font-semibold text-contrast">{{ entityLabel }}</span>
<span> instance.</span>
</template>
</template>
</p>
@@ -65,10 +62,17 @@
</div>
<div class="flex items-center gap-2">
<ButtonStyled color="brand">
<button @click="$emit('accept')">Accept</button>
<button :disabled="actionLoading != null" @click="$emit('accept')">
<SpinnerIcon v-if="actionLoading === 'accept'" class="animate-spin" />
<CheckIcon v-else />
Accept
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="$emit('decline')">Decline</button>
<button :disabled="actionLoading != null" @click="$emit('decline')">
<XIcon />
Decline
</button>
</ButtonStyled>
</div>
</div>
@@ -171,7 +175,7 @@
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { CheckIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { type Component, computed, ref } from 'vue'
import { useFormatBytes, useFormatNumber } from '../../composables'
@@ -186,10 +190,12 @@ type NotificationToastType =
| 'instance-invite'
| 'instance-download'
| 'instance-ready'
type NotificationToastAction = 'accept'
const props = withDefaults(
defineProps<{
type: NotificationToastType
actionLoading?: NotificationToastAction | null
actorName?: string | null
actorAvatarUrl?: string | null
entityName?: string
@@ -206,6 +212,7 @@ const props = withDefaults(
actionIcon?: Component
}>(),
{
actionLoading: null,
actorName: null,
actorAvatarUrl: null,
entityName: '',
@@ -236,6 +243,9 @@ const isInviteNotification = computed(
const actorLabel = computed(() => props.actorName || 'Someone')
const entityLabel = computed(() => props.entityName || '')
const inviteAvatarUrl = computed(() => props.actorAvatarUrl)
const inviteAvatarLabel = computed(() => actorLabel.value)
const inviteAvatarCircle = computed(() => true)
const progressValue = computed(() => Math.max(0, Math.min(1, props.progress ?? 0)))
const progressPercent = computed(() => Math.round(progressValue.value * 100))
const isWaitingProgress = computed(() => props.type === 'instance-download' && props.waiting)
@@ -247,7 +257,7 @@ const inviteActionText = computed(() => {
return 'invited you to manage the server'
}
return 'invited you to play the instance'
return 'invited you to'
})
const resolvedStatusText = computed(() => {
@@ -0,0 +1,2 @@
export { default as InvitePlayersModal } from './invite-players-modal/index.vue'
export * from './invite-players-modal/types'
@@ -0,0 +1,724 @@
<template>
<NewModal
ref="modal"
:header="header"
width="min(34rem, calc(100vw - 2rem))"
max-width="34rem"
no-padding
>
<div class="flex max-h-[calc(100vh-8rem)] min-h-0 flex-col">
<div class="border-0 border-b border-solid border-surface-5 p-6">
<div class="flex items-start gap-2">
<Combobox
:key="searchInputKey"
:model-value="undefined"
:options="searchOptions"
:search-value="searchTarget"
:search-placeholder="searchPlaceholderLabel"
:placeholder="searchPlaceholderLabel"
:no-options-message="searchLookupMessage"
:min-search-length-to-open="searchMinimumLength"
:disable-search-filter="usesRemoteSearch"
class="min-w-0 flex-1"
searchable
show-search-icon
:show-chevron="false"
search-type="search"
search-name="modrinth-player-invite-search"
search-inputmode="search"
search-autocomplete="new-password"
search-autocorrect="off"
search-autocapitalize="none"
:search-spellcheck="false"
:search-input-attrs="passwordManagerIgnoreAttrs"
@search-input="handleSearchInput"
@select="handleSearchSelect"
>
<template #option="{ item, isSelected }">
<div class="flex min-w-0 items-center gap-2">
<Avatar
:src="findSearchUser(item.value)?.avatarUrl"
:alt="formatMessage(messages.avatarAlt, { username: item.label })"
:tint-by="item.label"
size="1.5rem"
circle
no-shadow
/>
<span
class="min-w-0 truncate font-semibold"
:class="isSelected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</template>
</Combobox>
<ButtonStyled color="brand">
<button
v-tooltip="searchInviteTooltip"
class="shrink-0"
:disabled="!canInviteSearchTarget"
@click="inviteSearchTarget"
>
<PlusIcon aria-hidden="true" />
{{ addButtonLabel }}
</button>
</ButtonStyled>
</div>
</div>
<div class="min-h-[11rem] overflow-y-auto bg-surface-2 px-6 py-4">
<div class="mb-2 text-base font-semibold text-primary">
{{ friendsHeading }}
</div>
<div
v-if="friends.length === 0"
class="flex min-h-32 items-center justify-center text-secondary"
>
{{ emptyFriendsLabel }}
</div>
<div v-else class="-mx-6 flex flex-col">
<InvitePlayersModalUserRow
v-for="friend in sortedFriends"
:key="friend.id"
:user="friend"
:avatar-alt="formatMessage(messages.avatarAlt, { username: friend.username })"
:added-label="addedButtonLabel"
:cancel-label="cancelButtonLabel"
:invite-label="inviteButtonLabel"
:requested-label="requestedButtonLabel"
:requested-tooltip="requestedTooltip(friend.username)"
:user-profile-link="userProfileLink"
@invite="inviteFriend"
@cancel="cancelInvite"
/>
</div>
</div>
<div v-if="link" class="border-0 border-t border-solid border-surface-5 p-6">
<div class="flex flex-col gap-2">
<div class="text-base font-semibold text-contrast">
{{ inviteLinkHeading }}
</div>
<button
type="button"
class="flex h-10 w-full cursor-pointer items-center justify-between gap-3 rounded-[14px] border-none bg-surface-2 px-4 text-left transition-all hover:brightness-110 active:scale-[0.98]"
@click="copyInviteLink"
>
<span class="min-w-0 truncate text-base font-semibold text-primary">
{{ link }}
</span>
<ClipboardCopyIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
</button>
<p v-if="link && linkExpiryDescription" class="m-0 text-base text-primary">
{{ linkExpiryDescription }}
<button
v-if="updateInviteLink"
type="button"
class="cursor-pointer border-0 bg-transparent p-0 text-base font-medium text-blue hover:underline"
@click="showEditInviteLink"
>
{{ formatMessage(messages.editInviteLink) }}
</button>
</p>
</div>
</div>
</div>
</NewModal>
<NewModal
ref="editInviteLinkModal"
:header="formatMessage(messages.editInviteLinkTitle)"
max-width="30rem"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.expiryLabel) }}</span>
<DatePicker
v-model="editExpiry"
:disabled="savingInviteLink"
:min-date="minimumExpiry"
:max-date="maximumExpiry"
date-format="Y-m-d H:i"
alt-format="F j, Y at h:i K"
enable-time
wrapper-class="w-full"
input-class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.maxUsesLabel) }}</span>
<StyledInput
v-model="editMaxUses"
type="number"
:min="1"
:max="2147483647"
:step="1"
:disabled="savingInviteLink"
/>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button :disabled="savingInviteLink" @click="editInviteLinkModal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canSaveInviteLink" @click="saveInviteLink">
<SpinnerIcon v-if="savingInviteLink" class="animate-spin" aria-hidden="true" />
<SaveIcon v-else aria-hidden="true" />
{{ formatMessage(messages.saveButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, PlusIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectNotificationManager } from '../../../providers'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
import DatePicker from '../../base/DatePicker.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
import InvitePlayersModalUserRow from './invite-players-modal-user-row.vue'
import type {
InviteLinkSettings,
InvitePlayersInvitePayload,
InvitePlayersSearchUser,
InvitePlayersUser,
InvitePlayersUserProfileLink,
InvitePlayersUserStatus,
} from './types'
const props = withDefaults(
defineProps<{
header?: string
friends?: InvitePlayersUser[]
suggestions?: InvitePlayersSearchUser[]
searchUsers?: (query: string) => Promise<InvitePlayersSearchUser[]>
link?: string
linkExpiresAt?: string | Date | null
linkMaxUses?: number
updateInviteLink?: (settings: InviteLinkSettings) => Promise<void>
friendsLabel?: string
searchPlaceholder?: string
addLabel?: string
inviteLabel?: string
addedLabel?: string
cancelLabel?: string
requestedLabel?: string
emptyFriendsLabel?: string
canInvite?: boolean
inviteDisabledMessage?: string
userProfileLink?: (username: string) => InvitePlayersUserProfileLink
}>(),
{
header: 'Share instance',
friends: () => [],
suggestions: () => [],
canInvite: true,
linkMaxUses: 10,
},
)
const emit = defineEmits<{
invite: [payload: InvitePlayersInvitePayload]
cancel: [user: InvitePlayersUser]
'copy-link': [link: string]
}>()
const { formatMessage } = useVIntl()
const notificationManager = injectNotificationManager(null)
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const editInviteLinkModal = ref<InstanceType<typeof NewModal> | null>(null)
const editExpiry = ref('')
const editMaxUses = ref<number>()
const minimumExpiry = ref(new Date())
const maximumExpiry = ref(new Date())
const savingInviteLink = ref(false)
const searchTarget = ref('')
const searchInputKey = ref(0)
const selectedSearchUser = ref<InvitePlayersSearchUser | null>(null)
const remoteSearchUsers = ref<InvitePlayersSearchUser[]>([])
const searchLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
const searchLookupRequestId = ref(0)
const friendOrder = ref(new Map<string, number>())
const searchMinimumLength = 1
const passwordManagerIgnoreAttrs = {
'data-1p-ignore': 'true',
'data-bwignore': 'true',
'data-form-type': 'other',
'data-lpignore': 'true',
'data-protonpass-ignore': 'true',
}
const messages = defineMessages({
friendsHeading: {
id: 'sharing.invite-players-modal.friends-heading',
defaultMessage: 'Your friends - {count}',
},
searchPlaceholder: {
id: 'sharing.invite-players-modal.search-placeholder',
defaultMessage: 'Enter Modrinth username',
},
addButton: {
id: 'sharing.invite-players-modal.add',
defaultMessage: 'Add',
},
inviteButton: {
id: 'sharing.invite-players-modal.invite',
defaultMessage: 'Invite',
},
addedButton: {
id: 'sharing.invite-players-modal.added',
defaultMessage: 'Added',
},
cancelButton: {
id: 'sharing.invite-players-modal.cancel',
defaultMessage: 'Cancel',
},
requestedButton: {
id: 'sharing.invite-players-modal.requested',
defaultMessage: 'Request sent',
},
requestedTooltip: {
id: 'sharing.invite-players-modal.requested-tooltip',
defaultMessage: '{username} needs to accept your friend request first',
},
noFriends: {
id: 'sharing.invite-players-modal.no-friends',
defaultMessage: 'No friends found.',
},
noSearchResults: {
id: 'sharing.invite-players-modal.no-search-results',
defaultMessage: 'No matching users found.',
},
searching: {
id: 'sharing.invite-players-modal.searching',
defaultMessage: 'Searching...',
},
alreadyInvited: {
id: 'sharing.invite-players-modal.already-invited',
defaultMessage: 'This user has already been invited.',
},
inviteLinkHeading: {
id: 'sharing.invite-players-modal.invite-link-heading',
defaultMessage: 'Or use an invite link',
},
inviteExpiryDescription: {
id: 'sharing.invite-players-modal.invite-expiry-description',
defaultMessage: 'Your invite link expires in {duration}.',
},
editInviteLink: {
id: 'sharing.invite-players-modal.edit-invite-link',
defaultMessage: 'Edit invite link.',
},
editInviteLinkTitle: {
id: 'sharing.invite-players-modal.edit-invite-link-title',
defaultMessage: 'Edit invite link',
},
expiryLabel: {
id: 'sharing.invite-players-modal.expiry-label',
defaultMessage: 'Expiry date',
},
maxUsesLabel: {
id: 'sharing.invite-players-modal.max-uses-label',
defaultMessage: 'Maximum uses',
},
cancelButton: {
id: 'sharing.invite-players-modal.cancel-button',
defaultMessage: 'Cancel',
},
saveButton: {
id: 'sharing.invite-players-modal.save-button',
defaultMessage: 'Save',
},
updateInviteLinkFailedTitle: {
id: 'sharing.invite-players-modal.update-invite-link-failed-title',
defaultMessage: 'Failed to update invite link',
},
linkCopiedTitle: {
id: 'sharing.invite-players-modal.link-copied-title',
defaultMessage: 'Link copied',
},
linkCopiedText: {
id: 'sharing.invite-players-modal.link-copied-text',
defaultMessage: 'The invite link has been copied to your clipboard.',
},
linkCopyFailedTitle: {
id: 'sharing.invite-players-modal.link-copy-failed-title',
defaultMessage: 'Failed to copy link',
},
avatarAlt: {
id: 'sharing.invite-players-modal.avatar-alt',
defaultMessage: "{username}'s avatar",
},
})
const normalizedSearchTarget = computed(() => searchTarget.value.trim())
const usesRemoteSearch = computed(() => !!props.searchUsers)
const friendsHeading = computed(
() =>
props.friendsLabel ??
formatMessage(messages.friendsHeading, {
count: props.friends.length,
}),
)
const searchPlaceholderLabel = computed(
() => props.searchPlaceholder ?? formatMessage(messages.searchPlaceholder),
)
const addButtonLabel = computed(() => props.addLabel ?? formatMessage(messages.addButton))
const inviteButtonLabel = computed(() => props.inviteLabel ?? formatMessage(messages.inviteButton))
const addedButtonLabel = computed(() => props.addedLabel ?? formatMessage(messages.addedButton))
const cancelButtonLabel = computed(() => props.cancelLabel ?? formatMessage(messages.cancelButton))
const requestedButtonLabel = computed(
() => props.requestedLabel ?? formatMessage(messages.requestedButton),
)
const requestedTooltip = (username: string) =>
formatMessage(messages.requestedTooltip, {
username,
})
const emptyFriendsLabel = computed(
() => props.emptyFriendsLabel ?? formatMessage(messages.noFriends),
)
const inviteLinkHeading = computed(() => formatMessage(messages.inviteLinkHeading))
const linkExpiryDescription = computed(() => {
if (!props.linkExpiresAt) return ''
const expiresAt = new Date(props.linkExpiresAt)
if (Number.isNaN(expiresAt.getTime())) return ''
const hours = Math.max(1, Math.ceil((expiresAt.getTime() - Date.now()) / 3_600_000))
const duration =
hours < 48 ? `${hours} ${hours === 1 ? 'hour' : 'hours'}` : `${Math.ceil(hours / 24)} days`
return formatMessage(messages.inviteExpiryDescription, { duration })
})
const canSaveInviteLink = computed(() => {
const expiry = parseLocalDate(editExpiry.value)
return (
!savingInviteLink.value &&
!!expiry &&
expiry >= minimumExpiry.value &&
expiry <= maximumExpiry.value &&
Number.isInteger(editMaxUses.value ?? 0) &&
(editMaxUses.value ?? 0) > 0 &&
(editMaxUses.value ?? 0) <= 2147483647
)
})
const inviteDisabledMessage = computed(
() => props.inviteDisabledMessage ?? formatMessage(messages.alreadyInvited),
)
const searchLookupMessage = computed(() =>
usesRemoteSearch.value && searchLookupStatus.value !== 'loaded'
? formatMessage(messages.searching)
: formatMessage(messages.noSearchResults),
)
const searchableUsers = computed(() => {
const users = new Map<string, InvitePlayersSearchUser>()
for (const user of [...remoteSearchUsers.value, ...props.suggestions]) {
users.set(user.id.toLowerCase(), user)
users.set(user.username.toLowerCase(), user)
if (user.email) users.set(user.email.toLowerCase(), user)
}
return [...new Set(users.values())]
})
const searchOptions = computed<ComboboxOption<string>[]>(() =>
searchableUsers.value.map((user) => ({
value: user.username,
label: user.username,
searchTerms: [user.username, user.id, user.email].filter(Boolean) as string[],
})),
)
const sortedFriends = computed(() =>
props.friends
.map((friend, index) => ({
friend,
order: friendOrder.value.get(friend.id) ?? friendOrder.value.size + index,
}))
.sort((a, b) => a.order - b.order)
.map(({ friend }) => friend),
)
const matchedSearchUser = computed(() => {
if (
selectedSearchUser.value &&
normalizeInviteKey(selectedSearchUser.value.username) ===
normalizeInviteKey(normalizedSearchTarget.value)
) {
return selectedSearchUser.value
}
return findSearchUser(normalizedSearchTarget.value)
})
const invitedUserKeys = computed(() => {
const keys = new Set<string>()
for (const friend of props.friends) {
if (friendStatus(friend) === 'available') continue
keys.add(normalizeInviteKey(friend.id))
keys.add(normalizeInviteKey(friend.username))
}
return keys
})
const searchTargetAlreadyInvited = computed(() => {
const user = matchedSearchUser.value
if (!user) return false
return (
invitedUserKeys.value.has(normalizeInviteKey(user.id)) ||
invitedUserKeys.value.has(normalizeInviteKey(user.username))
)
})
const canInviteSearchTarget = computed(
() =>
props.canInvite &&
normalizedSearchTarget.value.length >= searchMinimumLength &&
!!matchedSearchUser.value &&
!searchTargetAlreadyInvited.value &&
(!usesRemoteSearch.value ||
searchLookupStatus.value === 'loaded' ||
!!selectedSearchUser.value),
)
const searchInviteTooltip = computed(() => {
if (!props.canInvite) return inviteDisabledMessage.value
if (searchTargetAlreadyInvited.value) return formatMessage(messages.alreadyInvited)
return undefined
})
const searchTargetUsers = useDebounceFn(async (query: string, requestId: number) => {
const searchUsers = props.searchUsers
if (!searchUsers) return
try {
const users = await searchUsers(query)
if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value) return
remoteSearchUsers.value = users
} catch {
if (requestId !== searchLookupRequestId.value || query !== normalizedSearchTarget.value) return
remoteSearchUsers.value = []
} finally {
if (requestId === searchLookupRequestId.value && query === normalizedSearchTarget.value) {
searchLookupStatus.value = 'loaded'
}
}
}, 250)
function friendStatus(friend: InvitePlayersUser): InvitePlayersUserStatus {
return friend.status ?? 'available'
}
function friendStatusSort(friend: InvitePlayersUser) {
switch (friendStatus(friend)) {
case 'available':
return 0
case 'requested':
return 1
case 'pending':
return 2
case 'added':
return 3
}
}
function syncFriendOrder(friends: InvitePlayersUser[]) {
const nextOrder = new Map(friendOrder.value)
let orderChanged = false
let nextIndex = nextOrder.size
const unorderedFriends = friends.filter((friend) => !nextOrder.has(friend.id))
const orderedFriends = unorderedFriends
.map((friend, index) => ({ friend, index }))
.sort((a, b) => {
const statusSort = friendStatusSort(a.friend) - friendStatusSort(b.friend)
return statusSort || a.index - b.index
})
for (const { friend } of orderedFriends) {
nextOrder.set(friend.id, nextIndex)
nextIndex += 1
orderChanged = true
}
if (orderChanged) {
friendOrder.value = nextOrder
}
}
function normalizeInviteKey(value: string) {
return value.trim().toLowerCase()
}
function findSearchUser(value: string) {
const normalizedValue = normalizeInviteKey(value)
return searchableUsers.value.find(
(user) =>
normalizeInviteKey(user.username) === normalizedValue ||
normalizeInviteKey(user.id) === normalizedValue ||
(!!user.email && normalizeInviteKey(user.email) === normalizedValue),
)
}
function handleSearchInput(value: string) {
searchTarget.value = value
selectedSearchUser.value = null
remoteSearchUsers.value = []
searchLookupRequestId.value += 1
if (normalizedSearchTarget.value.length < searchMinimumLength) {
searchLookupStatus.value = 'idle'
return
}
if (!usesRemoteSearch.value) {
searchLookupStatus.value = 'loaded'
return
}
searchLookupStatus.value = 'loading'
void searchTargetUsers(normalizedSearchTarget.value, searchLookupRequestId.value)
}
function handleSearchSelect(option: ComboboxOption<string>) {
searchTarget.value = option.value
selectedSearchUser.value = findSearchUser(option.value) ?? null
searchLookupStatus.value = 'loaded'
}
function resetSearch() {
searchTarget.value = ''
searchInputKey.value += 1
selectedSearchUser.value = null
remoteSearchUsers.value = []
searchLookupStatus.value = 'idle'
searchLookupRequestId.value += 1
}
function inviteSearchTarget() {
if (!canInviteSearchTarget.value) return
const user = matchedSearchUser.value
if (!user) return
emit('invite', {
user: {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
},
source: 'search',
})
resetSearch()
}
function inviteFriend(friend: InvitePlayersUser) {
emit('invite', {
user: friend,
source: 'friend',
})
}
function cancelInvite(friend: InvitePlayersUser) {
emit('cancel', friend)
}
async function copyInviteLink() {
if (!props.link) return
emit('copy-link', props.link)
try {
await navigator.clipboard.writeText(props.link)
notificationManager?.addNotification({
type: 'success',
title: formatMessage(messages.linkCopiedTitle),
text: formatMessage(messages.linkCopiedText),
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
notificationManager?.addNotification({
type: 'error',
title: formatMessage(messages.linkCopyFailedTitle),
text: message,
})
}
}
function formatLocalDate(date: Date) {
const pad = (value: number) => value.toString().padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function parseLocalDate(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(value)
if (!match) return null
const [, year, month, day, hour, minute] = match
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute))
return Number.isNaN(date.getTime()) ? null : date
}
function showEditInviteLink() {
const now = new Date()
minimumExpiry.value = new Date(now.getTime() + 3_600_000)
minimumExpiry.value.setSeconds(0, 0)
minimumExpiry.value.setMinutes(minimumExpiry.value.getMinutes() + 1)
maximumExpiry.value = new Date(now.getTime() + 7 * 86_400_000)
maximumExpiry.value.setSeconds(0, 0)
const currentExpiry = props.linkExpiresAt ? new Date(props.linkExpiresAt) : maximumExpiry.value
const expiry =
Number.isNaN(currentExpiry.getTime()) || currentExpiry < minimumExpiry.value
? minimumExpiry.value
: currentExpiry > maximumExpiry.value
? maximumExpiry.value
: currentExpiry
editExpiry.value = formatLocalDate(expiry)
editMaxUses.value = props.linkMaxUses
editInviteLinkModal.value?.show()
}
async function saveInviteLink() {
const expiry = parseLocalDate(editExpiry.value)
if (!canSaveInviteLink.value || !expiry || !props.updateInviteLink) return
savingInviteLink.value = true
try {
await props.updateInviteLink({ expiresAt: expiry, maxUses: editMaxUses.value ?? 1 })
editInviteLinkModal.value?.hide()
} catch (error) {
notificationManager?.addNotification({
type: 'error',
title: formatMessage(messages.updateInviteLinkFailedTitle),
text: error instanceof Error ? error.message : String(error),
})
} finally {
savingInviteLink.value = false
}
}
function show(event?: MouseEvent) {
resetSearch()
modal.value?.show(event)
}
function hide() {
modal.value?.hide()
}
watch(() => props.friends, syncFriendOrder, { immediate: true })
defineExpose({ show, hide })
</script>
@@ -0,0 +1,101 @@
<template>
<div
class="flex h-10 items-center justify-between gap-3 px-6 transition-colors hover:bg-surface-3"
>
<div class="flex min-w-0 items-center gap-1.5">
<AutoLink
v-tooltip="user.username"
:to="profileLink"
:target="profileTarget"
class="inline-flex min-w-0 items-center gap-1.5"
:class="profileLink ? 'text-primary hover:underline' : ''"
>
<span class="relative flex shrink-0">
<Avatar
:src="user.avatarUrl"
:alt="avatarAlt"
:tint-by="user.username"
size="1.5rem"
circle
no-shadow
/>
<span
v-if="user.online"
class="absolute bottom-[1.5px] right-[-1.5px] size-[9px] rounded-full border-[1.5px] border-solid border-surface-2 bg-brand"
/>
</span>
<span class="min-w-0 truncate text-base font-medium">
{{ user.username }}
</span>
</AutoLink>
</div>
<ButtonStyled v-if="status === 'added'" type="standard" color-fill="none">
<button disabled>
<CheckIcon aria-hidden="true" />
{{ addedLabel }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="status === 'pending'" type="outlined">
<button @click="$emit('cancel', user)">
{{ cancelLabel }}
</button>
</ButtonStyled>
<span v-else-if="status === 'requested'" v-tooltip="requestedTooltip" class="inline-flex">
<ButtonStyled type="standard" color-fill="none">
<button disabled>
{{ requestedLabel }}
</button>
</ButtonStyled>
</span>
<ButtonStyled v-else color-fill="none">
<button @click="$emit('invite', user)">
{{ inviteLabel }}
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import { computed } from 'vue'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import type { InvitePlayersUser, InvitePlayersUserProfileLink } from './types'
const props = withDefaults(
defineProps<{
user: InvitePlayersUser
avatarAlt: string
addedLabel: string
cancelLabel: string
inviteLabel: string
requestedLabel: string
requestedTooltip: string
userProfileLink?: (username: string) => InvitePlayersUserProfileLink
}>(),
{
userProfileLink: undefined,
},
)
defineEmits<{
invite: [user: InvitePlayersUser]
cancel: [user: InvitePlayersUser]
}>()
const status = computed(() => props.user.status ?? 'available')
const profileLink = computed(() => getUserProfileLink(props.user.username))
const profileTarget = computed(() =>
typeof profileLink.value === 'string' && profileLink.value.startsWith('http')
? '_blank'
: undefined,
)
function getUserProfileLink(username: string): InvitePlayersUserProfileLink {
if (!username || username.includes('@')) return undefined
return props.userProfileLink?.(username) ?? `/user/${encodeURIComponent(username)}`
}
</script>
@@ -0,0 +1,32 @@
import type { RouteLocationRaw } from 'vue-router'
export type InvitePlayersUserStatus = 'available' | 'requested' | 'pending' | 'added'
export type InvitePlayersUserProfileLink =
| RouteLocationRaw
| (() => void | Promise<void>)
| undefined
export interface InvitePlayersUser {
id: string
username: string
avatarUrl?: string | null
status?: InvitePlayersUserStatus
online?: boolean
}
export interface InvitePlayersSearchUser {
id: string
username: string
avatarUrl?: string | null
email?: string
}
export interface InvitePlayersInvitePayload {
user: InvitePlayersUser
source: 'friend' | 'search'
}
export interface InviteLinkSettings {
expiresAt: Date
maxUses: number
}
@@ -67,7 +67,8 @@ const hasEnabledListener = computed(
const hasAnyActions = computed(() => {
// Check if there are listeners for actions
const hasListeners =
(hasDeleteListener.value && !props.hideDelete) ||
(hasDeleteListener.value &&
props.items.some((item) => !props.hideDelete && !item.hideDelete)) ||
hasUpdateListener.value ||
hasSwitchVersionListener.value ||
hasEnabledListener.value
@@ -273,7 +274,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-delete="hideDelete || item.hideDelete"
:hide-actions="!hasAnyActions"
:selected="isItemSelected(item.id)"
:class="[
@@ -326,13 +327,14 @@ function handleSort(column: ContentCardTableSortColumn) {
:has-update="item.hasUpdate"
:is-client-only="item.isClientOnly"
:client-warning="item.clientWarning"
:hide-switch-version="item.hideSwitchVersion"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:disabled-tooltip="item.disabledTooltip"
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-delete="hideDelete || item.hideDelete"
:hide-actions="!hasAnyActions"
:selected="isItemSelected(item.id)"
:class="[

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