diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 1f39661978..286e0aca14 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -16,6 +16,7 @@ import {
HomeIcon,
LeftArrowIcon,
LibraryIcon,
+ LinkIcon,
LogInIcon,
LogOutIcon,
NewspaperIcon,
@@ -85,6 +86,7 @@ import NavButton from '@/components/ui/NavButton.vue'
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
+import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
@@ -147,6 +149,7 @@ const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
const credentials = ref()
+let credentialsRefreshId = 0
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
@@ -637,6 +640,7 @@ const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
+const sharedInstanceInviteHandler = ref()
const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
@@ -647,8 +651,8 @@ watch(incompatibilityWarningModal, (modal) => {
}
})
-setupAuthProvider(credentials, async (_redirectPath) => {
- await signIn()
+setupAuthProvider(credentials, async (_redirectPath, flow) => {
+ await signIn(flow)
})
async function validateSession(sessionToken) {
@@ -665,23 +669,33 @@ async function validateSession(sessionToken) {
}
async function fetchCredentials() {
+ const refreshId = ++credentialsRefreshId
+ credentials.value = undefined
+
const creds = await getCreds().catch(handleError)
+ if (refreshId !== credentialsRefreshId) return
+
if (creds && creds.user_id) {
if (creds.session && !(await validateSession(creds.session))) {
+ if (refreshId !== credentialsRefreshId) return
+
await logout().catch(handleError)
+ if (refreshId !== credentialsRefreshId) return
+
credentials.value = null
return
}
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
+ if (refreshId !== credentialsRefreshId) return
}
credentials.value = creds ?? null
}
-async function signIn() {
+async function signIn(flow = 'sign-in') {
modrinthLoginFlowWaitModal.value.show()
try {
- await login()
+ await login(flow)
await fetchCredentials()
} catch (error) {
if (
@@ -699,6 +713,13 @@ async function signIn() {
}
async function logOut() {
+ await performLogOut()
+}
+
+async function performLogOut() {
+ credentialsRefreshId++
+ credentials.value = undefined
+
await logout().catch(handleError)
await fetchCredentials()
}
@@ -821,30 +842,35 @@ function openServerInviteInviterProfile(inviterName) {
}
async function handleLiveNotification(notification) {
- if (notification?.body?.type !== 'server_invite' || notification.read) return
- 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 || notification.read) return
+ if (await sharedInstanceInviteHandler.value?.handleNotification(notification)) 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),
+ },
+ })
+ }
+
}
async function handleCommand(e) {
@@ -877,6 +903,8 @@ async function handleCommand(e) {
} else {
await run(e.id).catch(handleError)
}
+ } else if (e.event === 'InstallSharedInstanceInvite') {
+ await sharedInstanceInviteHandler.value?.installFromInviteId(e.invite_id)
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
@@ -1467,6 +1495,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/Instance.vue b/apps/app-frontend/src/components/ui/Instance.vue
index 9fff27fa31..97dbca3039 100644
--- a/apps/app-frontend/src/components/ui/Instance.vue
+++ b/apps/app-frontend/src/components/ui/Instance.vue
@@ -142,7 +142,9 @@ const unlisten = await process_listener((e) => {
}
})
-onMounted(() => checkProcess())
+onMounted(() => {
+ checkProcess()
+})
onUnmounted(() => unlisten())
diff --git a/apps/app-frontend/src/components/ui/friends/FriendsList.vue b/apps/app-frontend/src/components/ui/friends/FriendsList.vue
index c622c078e4..8e57391ee0 100644
--- a/apps/app-frontend/src/components/ui/friends/FriendsList.vue
+++ b/apps/app-frontend/src/components/ui/friends/FriendsList.vue
@@ -10,18 +10,12 @@ import {
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
-import { computed, onUnmounted, ref, watch } from 'vue'
+import { computed, ref } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
+import { useFriends } from '@/composables/use-friends'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
-import { friend_listener } from '@/helpers/events'
-import {
- add_friend,
- friends,
- type FriendWithUserData,
- remove_friend,
- transformFriends,
-} from '@/helpers/friends.ts'
+import type { FriendWithUserData } from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
@@ -35,36 +29,23 @@ const props = defineProps<{
}>()
const userCredentials = computed(() => props.credentials)
+const {
+ friends: userFriends,
+ loading,
+ requestFriend,
+ acceptFriend,
+ removeFriend: removeFriendRecord,
+} = useFriends({
+ currentUserId: () => userCredentials.value?.user_id,
+ getCredentials: () => userCredentials.value,
+ onError: handleError,
+})
const search = ref('')
const friendInvitesModal = ref()
-
const username = ref('')
const addFriendModal = ref()
-async function addFriendFromModal() {
- addFriendModal.value.hide()
- await add_friend(username.value).catch(handleError)
- username.value = ''
- await loadFriends()
-}
-
-async function addFriend(friend: FriendWithUserData) {
- const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
- if (id) {
- await add_friend(id).catch(handleError)
- await loadFriends()
- }
-}
-
-async function removeFriend(friend: FriendWithUserData) {
- const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
- if (id) {
- await remove_friend(id).catch(handleError)
- await loadFriends()
- }
-}
-const userFriends = ref([])
const sortedFriends = computed(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
@@ -108,42 +89,22 @@ const incomingRequests = computed(() =>
.sort((a, b) => b.created.diff(a.created)),
)
-const loading = ref(true)
-async function loadFriends(timeout = false) {
- loading.value = timeout
+function addFriendFromModal() {
+ const target = username.value.trim()
+ if (!target) return
- try {
- const friendsList = await friends()
- userFriends.value = await transformFriends(friendsList, userCredentials.value)
- loading.value = false
- } catch (e) {
- console.error('Error loading friends', e)
- if (timeout) {
- setTimeout(() => loadFriends(), 15 * 1000)
- }
- }
+ addFriendModal.value.hide()
+ requestFriend({ id: target, username: target })
+ username.value = ''
}
-watch(
- userCredentials,
- () => {
- if (userCredentials.value === undefined) {
- userFriends.value = []
- loading.value = false
- } else if (userCredentials.value === null) {
- userFriends.value = []
- loading.value = false
- } else {
- loadFriends(true)
- }
- },
- { immediate: true },
-)
+function addFriend(friend: FriendWithUserData) {
+ acceptFriend(friend)
+}
-const unlisten = await friend_listener(() => loadFriends())
-onUnmounted(() => {
- unlisten()
-})
+function removeFriend(friend: FriendWithUserData) {
+ removeFriendRecord(friend)
+}
const messages = defineMessages({
addFriend: {
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue
new file mode 100644
index 0000000000..de3d43dd25
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/index.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts
new file mode 100644
index 0000000000..5191ae9b26
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-messages.ts
@@ -0,0 +1,69 @@
+import { defineMessages } from '@modrinth/ui'
+
+export const instanceAdmonitionsMessages = defineMessages({
+ sharedInstanceChangesHeader: {
+ id: 'app.instance.admonitions.shared-instance.changes-header',
+ defaultMessage: "Your changes haven't been shared yet",
+ },
+ sharedInstanceChangesBody: {
+ id: 'app.instance.admonitions.shared-instance.changes-body',
+ defaultMessage: "Your local instance is ahead of the users you've shared it with.",
+ },
+ sharedInstancePublishButton: {
+ id: 'app.instance.admonitions.shared-instance.publish-button',
+ defaultMessage: 'Push update',
+ },
+ sharedInstancePublishingButton: {
+ id: 'app.instance.admonitions.shared-instance.publishing-button',
+ defaultMessage: 'Pushing...',
+ },
+ sharedInstanceReviewingButton: {
+ id: 'app.instance.admonitions.shared-instance.reviewing-button',
+ defaultMessage: 'Reviewing...',
+ },
+ sharedInstanceReviewHeader: {
+ id: 'app.instance.admonitions.shared-instance.review-header',
+ defaultMessage: 'Review changes',
+ },
+ sharedInstanceReviewAdmonitionHeader: {
+ id: 'app.instance.admonitions.shared-instance.review-admonition-header',
+ defaultMessage: 'Push update',
+ },
+ sharedInstanceReviewDescription: {
+ id: 'app.instance.admonitions.shared-instance.review-description',
+ defaultMessage:
+ 'Review the content changes that will be shared with everyone using this instance.',
+ },
+ sharedInstanceAddedLabel: {
+ id: 'app.instance.admonitions.shared-instance.added-label',
+ defaultMessage: 'Added',
+ },
+ sharedInstanceRemovedLabel: {
+ id: 'app.instance.admonitions.shared-instance.removed-label',
+ defaultMessage: 'Removed',
+ },
+ sharedInstanceWrongAccountHeader: {
+ id: 'app.instance.shared-instance-wrong-account.warning-header',
+ defaultMessage: 'You are using the wrong Modrinth account',
+ },
+ sharedInstanceSignedOutHeader: {
+ id: 'app.instance.shared-instance-wrong-account.signed-out-header',
+ defaultMessage: 'You need to sign in to Modrinth',
+ },
+ sharedInstanceWrongAccountSignInAs: {
+ id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
+ defaultMessage: 'Sign in as',
+ },
+ sharedInstanceWrongAccountUserBody: {
+ id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
+ defaultMessage: 'to receive updates for this shared instance.',
+ },
+ sharedInstanceWrongAccountOwnerBody: {
+ id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
+ defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
+ },
+ sharedInstanceWrongAccountFallbackUsername: {
+ id: 'app.instance.shared-instance-wrong-account.fallback-username',
+ defaultMessage: 'the linked account',
+ },
+})
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue
new file mode 100644
index 0000000000..8618bd9631
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-stale.vue
@@ -0,0 +1,133 @@
+
+
+ {{ formatMessage(messages.sharedInstanceChangesBody) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue
new file mode 100644
index 0000000000..c2b5cf6335
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-unavailable.vue
@@ -0,0 +1,33 @@
+
+
+ {{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue
new file mode 100644
index 0000000000..aab1f2fa8f
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/instance-admonitions-shared-instance-wrong-account.vue
@@ -0,0 +1,78 @@
+
+
+
+ {{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}
+
+
+ {{ sharedInstanceExpectedUsername }}
+
+ {{ sharedInstanceExpectedUsername }}
+ {{ formatMessage(bodyMessage) }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts b/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts
new file mode 100644
index 0000000000..ea7eb81be1
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/instance/instance-admonitions/types.ts
@@ -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'
diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
index fa1a4d0312..a830befac4 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
+++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue
@@ -7,7 +7,6 @@ import {
injectFilePicker,
injectNotificationManager,
InstallationSettingsLayout,
- provideAppBackup,
provideInstallationSettings,
useDebugLogger,
useVIntl,
@@ -16,25 +15,27 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
+import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
+import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import {
- install_duplicate_instance,
install_existing_instance,
install_pack_to_existing_instance,
- installJobInstanceId,
wait_for_install_job,
} from '@/helpers/install'
import {
edit,
get_linked_modpack_info,
- list,
+ unlink_shared_instance,
+ unpublish_shared_instance,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { injectInstanceSettings } from '@/providers/instance-settings'
+import { provideInstanceBackup } from '@/providers/instance-backup'
import { useTheming } from '@/store/state'
import type { Manifest } from '../../../helpers/types'
@@ -47,6 +48,7 @@ const debug = useDebugLogger('AppInstallationSettings')
const themeStore = useTheming()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
+const managedContentPolicy = useManagedContentPolicy(instance)
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
@@ -111,9 +113,15 @@ debug('metadata queries configured', {
const isModrinthLinkedModpack = computed(
() =>
instance.value.link?.type === 'modrinth_modpack' ||
- instance.value.link?.type === 'server_project_modpack',
+ instance.value.link?.type === 'server_project_modpack' ||
+ (instance.value.link?.type === 'shared_instance' &&
+ !!instance.value.link.modpack_project_id &&
+ !!instance.value.link.modpack_version_id),
)
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
+const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
+const canUnpublishSharedInstance = managedContentPolicy.canUnpublish
+const canUnlinkSharedInstance = managedContentPolicy.canUnlink
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
@@ -124,6 +132,36 @@ const modpackInfo = modpackInfoQuery.data
const repairing = ref(false)
const reinstalling = ref(false)
+const unpublishingSharedInstance = ref(false)
+const unlinkingSharedInstance = ref(false)
+
+async function unpublishSharedInstance() {
+ unpublishingSharedInstance.value = true
+ try {
+ await unpublish_shared_instance(instance.value.id)
+ await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
+ await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
+ onUnlinked()
+ } catch (error) {
+ handleError(error)
+ } finally {
+ unpublishingSharedInstance.value = false
+ }
+}
+
+async function unlinkSharedInstance() {
+ unlinkingSharedInstance.value = true
+ try {
+ await unlink_shared_instance(instance.value.id)
+ await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
+ await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
+ onUnlinked()
+ } catch (error) {
+ handleError(error)
+ } finally {
+ unlinkingSharedInstance.value = false
+ }
+}
const messages = defineMessages({
loaderVersion: {
@@ -162,27 +200,7 @@ async function installLocalModpackFromPicker() {
return !!completed
}
-provideAppBackup({
- async createBackup() {
- debug('createBackup: start', {
- instanceId: instance.value.id,
- instanceName: instance.value.name,
- })
- const allInstances = await list()
- const prefix = `${instance.value.name} - Backup #`
- const existingNums = allInstances
- .filter((p) => p.name.startsWith(prefix))
- .map((p) => parseInt(p.name.slice(prefix.length), 10))
- .filter((n) => !isNaN(n))
- const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
- const job = await install_duplicate_instance(instance.value.id)
- const newInstanceId = installJobInstanceId(job)
- if (newInstanceId) {
- await edit(newInstanceId, { name: `${prefix}${nextNum}` })
- }
- debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
- },
-})
+provideInstanceBackup(instance)
provideInstallationSettings({
closeSettings: closeModal,
@@ -208,12 +226,19 @@ provideInstallationSettings({
}
return rows
}),
- isLinked: computed(() => isModrinthLinkedModpack.value || isImportedModpack.value),
+ isLinked: computed(
+ () =>
+ isModrinthLinkedModpack.value ||
+ isImportedModpack.value ||
+ isSharedInstanceManagedModpack.value,
+ ),
isBusy: computed(
() =>
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
+ unlinkingSharedInstance.value ||
+ unpublishingSharedInstance.value ||
!!offline,
),
skipNonEssentialWarnings,
@@ -452,14 +477,38 @@ provideInstallationSettings({
isServer: false,
isApp: true,
showModpackVersionActions: computed(
- () => isModrinthLinkedModpack.value && !isMinecraftServer.value,
+ () =>
+ isModrinthLinkedModpack.value &&
+ !isMinecraftServer.value &&
+ !isSharedInstanceManagedModpack.value,
),
isLocalFile: isImportedModpack,
+ isManagedModpack: isSharedInstanceManagedModpack,
+ managedModpackWarning: managedContentPolicy.managedModpackWarning,
repairing,
reinstalling,
})
-
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue
new file mode 100644
index 0000000000..349ecc8445
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+ {{ formatMessage(messages.signInHeading) }}
+
+
+ {{ formatMessage(messages.description) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
index ab5c3b7acb..4fd04b94f2 100644
--- a/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
+++ b/apps/app-frontend/src/components/ui/modal/UpdateToPlayModal.vue
@@ -77,6 +77,11 @@ const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise
+const emit = defineEmits<{
+ cancel: []
+ complete: []
+}>()
+
const diffModal = ref>()
const instance = ref(null)
const onUpdateComplete = ref(() => {})
@@ -84,15 +89,15 @@ const diffs = ref([])
const modpackVersionId = ref(null)
const modpackVersion = ref(null)
-const normalizedDiffs = computed(() =>
- diffs.value.map((diff) => ({
+const normalizedDiffs = computed(() => {
+ return diffs.value.map((diff) => ({
type: diff.type,
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
- })),
-)
+ }))
+})
async function computeDependencyDiffs(
currentDeps: Dependency[],
@@ -238,7 +243,6 @@ watch(
)
async function handleUpdate() {
- hide()
const serverProjectId = instance.value?.link?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
@@ -251,6 +255,7 @@ async function handleUpdate() {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
+ emit('complete')
}
}
@@ -261,7 +266,7 @@ function handleReport() {
}
function handleDecline() {
- hide()
+ emit('cancel')
}
function show(
@@ -292,7 +297,7 @@ const messages = defineMessages({
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
- 'An update is required to play {name}. Please update to the latest version to launch the game.',
+ 'An update is required to play {name}. Please update to latest version to launch the game.',
},
})
diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue
new file mode 100644
index 0000000000..247626d563
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue
@@ -0,0 +1,81 @@
+
+
+
{{ formatMessage(messages.title) }}
+
+
+
+
+
+
{{ formatMessage(messages.unpublishDescription) }}
+
+
+
{{ formatMessage(messages.linkedTitle) }}
+
+
+
+
+
+
{{ formatMessage(messages.unlinkDescription) }}
+
+
+
+ {{ formatMessage(messages.unpublishModalBody) }}
+
+
+
+
+
{{ formatMessage(messages.unlinkModalBody) }}
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
new file mode 100644
index 0000000000..bf5c7b4fec
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/SharedInstanceUpdateModal.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue
new file mode 100644
index 0000000000..fbfbe4b836
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/index.vue
@@ -0,0 +1,71 @@
+
+
+
+
+ {{ formatMessage(messages.trustWarningDescription) }}
+
+
+
+
+ {{ formatMessage(messages.externalFileWarning, { count: preview.externalFileCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue
new file mode 100644
index 0000000000..69ea7e3816
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/shared-instance-install-summary.vue
@@ -0,0 +1,44 @@
+
+
+
+ {{ formatMessage(messages.sharedInstance) }}
+
+
+
+
+
+
+
+ {{ preview.name }}
+
+ {{ loaderDisplay }} {{ preview.gameVersion }}
+
+
+ {{ formatMessage(messages.modCount, { count: preview.modCount }) }}
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts
new file mode 100644
index 0000000000..31b7fbaa53
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-install-modal/use-shared-instance-preview-content.ts
@@ -0,0 +1,127 @@
+import type { Labrinth } from '@modrinth/api-client'
+import type { ContentItem } from '@modrinth/ui'
+
+import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
+import type { SharedInstanceInstallPreview } from '@/helpers/install'
+
+type VersionDependency = Labrinth.Versions.v2.Dependency & { version_id?: string }
+
+export function useSharedInstancePreviewContent() {
+ async function load(preview: SharedInstanceInstallPreview): Promise {
+ return [
+ ...preview.externalFiles.map(externalFileContentItem),
+ ...(await modpackContentItems(preview)),
+ ...(await contentItemsFromVersionIds(
+ preview.contentVersionIds.filter((id) => id !== preview.modpackVersionId),
+ )),
+ ]
+ }
+
+ async function modpackContentItems(preview: SharedInstanceInstallPreview) {
+ if (!preview.modpackVersionId) return []
+ const version = await get_version(preview.modpackVersionId, 'must_revalidate')
+ return await contentItemsFromDependencies(version?.dependencies ?? [])
+ }
+
+ async function contentItemsFromDependencies(dependencies: Labrinth.Versions.v2.Dependency[]) {
+ const deps = dependencies as VersionDependency[]
+ const projectIds = unique(deps.map((dep) => dep.project_id).filter((id): id is string => !!id))
+ const versionIds = unique(deps.map((dep) => dep.version_id).filter((id): id is string => !!id))
+ const [projects, versions]: [
+ Labrinth.Projects.v2.Project[],
+ Labrinth.Versions.v2.Version[],
+ ] = await Promise.all([
+ projectIds.length ? get_project_many(projectIds, 'must_revalidate') : [],
+ versionIds.length ? get_version_many(versionIds, 'must_revalidate') : [],
+ ])
+ const projectMap = new Map(projects.map((project) => [project.id, project]))
+ const versionMap = new Map(versions.map((version) => [version.id, version]))
+
+ return deps.map((dependency): ContentItem => {
+ const project = dependency.project_id ? projectMap.get(dependency.project_id) : null
+ const version = dependency.version_id ? versionMap.get(dependency.version_id) : null
+ const fileName = version?.files?.[0]?.filename ?? dependency.file_name ?? project?.title ?? 'Unknown'
+ return contentItem(
+ version?.id ?? project?.id ?? fileName,
+ fileName,
+ project,
+ version,
+ !project && !version,
+ dependency.project_id ?? fileName,
+ dependency.file_name ?? fileName,
+ )
+ })
+ }
+
+ async function contentItemsFromVersionIds(versionIds: string[]) {
+ const versions: Labrinth.Versions.v2.Version[] = versionIds.length
+ ? await get_version_many(unique(versionIds), 'must_revalidate')
+ : []
+ const projectIds = unique(versions.map((version) => version.project_id).filter(Boolean))
+ const projects: Labrinth.Projects.v2.Project[] = projectIds.length
+ ? await get_project_many(projectIds, 'must_revalidate')
+ : []
+ const projectMap = new Map(projects.map((project) => [project.id, project]))
+ return versions.map((version): ContentItem => {
+ const project = projectMap.get(version.project_id)
+ const fileName = version.files?.[0]?.filename ?? project?.title ?? version.name ?? 'Unknown'
+ return contentItem(
+ version.id,
+ fileName,
+ project,
+ version,
+ false,
+ version.project_id,
+ version.name,
+ )
+ })
+ }
+
+ return { load }
+}
+
+function contentItem(
+ id: string,
+ fileName: string,
+ project?: Labrinth.Projects.v2.Project | null,
+ version?: Labrinth.Versions.v2.Version | null,
+ external = false,
+ fallbackProjectId = id,
+ fallbackTitle = fileName,
+ projectType = project?.project_type ?? 'mod',
+): ContentItem {
+ return {
+ id,
+ file_name: fileName,
+ project_type: projectType,
+ has_update: false,
+ update_version_id: null,
+ external,
+ project: {
+ id: project?.id ?? fallbackProjectId,
+ slug: project?.slug ?? fallbackProjectId,
+ title: project?.title ?? fallbackTitle,
+ icon_url: project?.icon_url ?? undefined,
+ },
+ ...(version
+ ? { version: { id: version.id, file_name: fileName, version_number: version.version_number ?? undefined, date_published: version.date_published ?? undefined } }
+ : {}),
+ }
+}
+
+function externalFileContentItem(file: SharedInstanceInstallPreview['externalFiles'][number]): ContentItem {
+ return contentItem(
+ `external:${file.fileType}:${file.fileName}`,
+ file.fileName,
+ null,
+ null,
+ true,
+ file.fileName,
+ file.fileName,
+ file.fileType,
+ )
+}
+
+function unique(values: T[]) {
+ return Array.from(new Set(values))
+}
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue
new file mode 100644
index 0000000000..376771d52a
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/index.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-link-modal.vue b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-link-modal.vue
new file mode 100644
index 0000000000..109eb09054
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-link-modal.vue
@@ -0,0 +1,62 @@
+
+
+
+ Invite link
+
+
+ Enter a valid Modrinth shared-instance invite link.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts
new file mode 100644
index 0000000000..95f0f704d2
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-parser.ts
@@ -0,0 +1,42 @@
+import type {
+ AppNotification,
+ SharedInstanceInvite,
+ SharedInstanceInviteNotificationBody,
+} from './shared-instance-invite-types'
+
+function optionalString(value: unknown) {
+ return typeof value === 'string' ? value : null
+}
+
+export function parseSharedInstanceInviteNotification(
+ notification: AppNotification,
+): SharedInstanceInvite | null {
+ if (notification.body?.type !== 'shared_instance_invite') return null
+
+ const body = notification.body as SharedInstanceInviteNotificationBody
+ const sharedInstanceId = optionalString(body.shared_instance_id)
+ const sharedInstanceName = optionalString(body.shared_instance_name)
+ if (!sharedInstanceId || !sharedInstanceName) return null
+
+ return {
+ sharedInstanceId,
+ sharedInstanceName,
+ invitedById: optionalString(body.invited_by),
+ invitedByUsername: optionalString(body.invited_by_username),
+ invitedByAvatarUrl: optionalString(body.invited_by_avatar_url),
+ instanceIconUrl: optionalString(body.instance_icon_url),
+ }
+}
+
+export function parseSharedInstanceInviteLink(value: string) {
+ const trimmedValue = value.trim()
+ if (!trimmedValue) return null
+
+ try {
+ const url = new URL(trimmedValue)
+ const match = /^\/share\/([^/]+)\/?$/.exec(url.pathname)
+ return match ? decodeURIComponent(match[1]) : null
+ } catch {
+ return null
+ }
+}
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts
new file mode 100644
index 0000000000..10a29095ea
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/shared-instance-invite-types.ts
@@ -0,0 +1,30 @@
+export type SharedInstanceInviteNotificationBody = {
+ type: 'shared_instance_invite'
+ shared_instance_id?: unknown
+ shared_instance_name?: unknown
+ invited_by?: unknown
+ invited_by_username?: unknown
+ invited_by_avatar_url?: unknown
+ instance_icon_url?: unknown
+}
+
+export type AppNotification = {
+ id: string | number
+ read?: boolean
+ body?: { type?: unknown } & Record
+}
+
+export type SharedInstanceInvite = {
+ sharedInstanceId: string
+ sharedInstanceName: string
+ invitedById: string | null
+ invitedByUsername: string | null
+ invitedByAvatarUrl: string | null
+ instanceIconUrl: string | null
+}
+
+export type SharedInstanceInviteHandler = {
+ handleNotification(notification: AppNotification): Promise
+ installFromInviteId(inviteId: string): Promise
+ showManualInviteLinkModal(event?: MouseEvent): void
+}
diff --git a/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts
new file mode 100644
index 0000000000..30d538d0a9
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/shared-instances/shared-instance-invite-handler/use-shared-instance-invite-handler.ts
@@ -0,0 +1,162 @@
+import {
+ injectAuth,
+ injectModrinthClient,
+ injectNotificationManager,
+ injectPopupNotificationManager,
+} from '@modrinth/ui'
+import { useQueryClient } from '@tanstack/vue-query'
+import { openUrl } from '@tauri-apps/plugin-opener'
+import { type Ref, watch } from 'vue'
+
+import { config } from '@/config'
+import { get_user } from '@/helpers/cache'
+import { toError } from '@/helpers/errors'
+import {
+ install_accept_shared_instance_invite,
+ install_get_shared_instance_preview,
+ install_shared_instance,
+} from '@/helpers/install'
+
+import { parseSharedInstanceInviteNotification } from './shared-instance-invite-parser'
+import type { AppNotification, SharedInstanceInvite } from './shared-instance-invite-types'
+
+type InstallModal = {
+ show(
+ preview: Awaited>,
+ install: () => Promise,
+ ): void
+}
+
+type AccountRequiredModal = {
+ show(event?: MouseEvent): Promise
+}
+
+export function useSharedInstanceInviteHandler(
+ installModal: Ref,
+ accountRequiredModal: Ref,
+) {
+ const auth = injectAuth()
+ const client = injectModrinthClient()
+ const { handleError } = injectNotificationManager()
+ const { addPopupNotification } = injectPopupNotificationManager()
+ const queryClient = useQueryClient()
+ const displayedNotifications = new Set()
+
+ async function markNotificationRead(notification: AppNotification) {
+ await client.labrinth.notifications_v2.markAsRead(String(notification.id))
+ }
+
+ async function resolveInvite(invite: SharedInstanceInvite) {
+ const invitedBy =
+ !invite.invitedByUsername && invite.invitedById
+ ? await get_user(invite.invitedById, 'bypass').catch(() => null)
+ : null
+
+ return {
+ ...invite,
+ invitedByUsername: invite.invitedByUsername ?? invitedBy?.username ?? null,
+ invitedByAvatarUrl: invite.invitedByAvatarUrl ?? invitedBy?.avatar_url ?? null,
+ }
+ }
+
+ function showInstall(
+ preview: Awaited>,
+ install: () => Promise,
+ ) {
+ if (!installModal.value) throw new Error('Shared instance install modal is not available.')
+ installModal.value.show(preview, install)
+ }
+
+ async function acceptNotification(notification: AppNotification, invite: SharedInstanceInvite) {
+ try {
+ const preview = await install_get_shared_instance_preview(
+ invite.sharedInstanceId,
+ invite.sharedInstanceName,
+ )
+ if (invite.instanceIconUrl) preview.iconUrl = invite.instanceIconUrl
+
+ showInstall(
+ preview,
+ async () => {
+ await install_shared_instance(
+ invite.sharedInstanceId,
+ invite.sharedInstanceName,
+ invite.invitedById,
+ null,
+ null,
+ invite.instanceIconUrl,
+ )
+ await markNotificationRead(notification)
+ await queryClient.invalidateQueries({ queryKey: ['instances'] })
+ },
+ )
+ } catch (error) {
+ handleError(toError(error))
+ }
+ }
+
+ async function handleNotification(notification: AppNotification) {
+ const parsedInvite = parseSharedInstanceInviteNotification(notification)
+ if (!parsedInvite) return false
+ if (displayedNotifications.has(notification.id)) return true
+
+ displayedNotifications.add(notification.id)
+ const invite = await resolveInvite(parsedInvite)
+ addPopupNotification({
+ title: invite.sharedInstanceName,
+ autoCloseMs: null,
+ toast: {
+ type: 'instance-invite',
+ actorName: invite.invitedByUsername,
+ actorAvatarUrl: invite.invitedByAvatarUrl ?? undefined,
+ entityName: invite.sharedInstanceName,
+ entityIconUrl: invite.instanceIconUrl ?? undefined,
+ onAccept: () => acceptNotification(notification, invite),
+ onDecline: () => markNotificationRead(notification).catch((error) => handleError(toError(error))),
+ onOpenActor: () => {
+ if (invite.invitedByUsername) {
+ openUrl(`${config.siteUrl}/user/${encodeURIComponent(invite.invitedByUsername)}`)
+ }
+ },
+ },
+ })
+ return true
+ }
+
+ async function requireAccount() {
+ if (!auth.isReady?.value) {
+ await new Promise((resolve) => {
+ const stop = watch(auth.isReady!, (ready) => {
+ if (ready) {
+ stop()
+ resolve()
+ }
+ })
+ })
+ }
+ if (auth.session_token.value) return true
+ return (await accountRequiredModal.value?.show()) ?? false
+ }
+
+ async function installFromInviteId(inviteId: string) {
+ try {
+ if (!(await requireAccount())) return
+ const invite = await install_accept_shared_instance_invite(inviteId)
+ showInstall(invite.preview, async () => {
+ await install_shared_instance(
+ invite.sharedInstanceId,
+ invite.preview.name,
+ invite.managerId,
+ invite.serverManagerName,
+ invite.serverManagerIconUrl,
+ invite.instanceIconUrl,
+ )
+ await queryClient.invalidateQueries({ queryKey: ['instances'] })
+ })
+ } catch (error) {
+ handleError(toError(error))
+ }
+ }
+
+ return { handleNotification, installFromInviteId }
+}
diff --git a/apps/app-frontend/src/composables/browse/install-job-notifications.ts b/apps/app-frontend/src/composables/browse/install-job-notifications.ts
index 8d7eaa832d..513c6a90ea 100644
--- a/apps/app-frontend/src/composables/browse/install-job-notifications.ts
+++ b/apps/app-frontend/src/composables/browse/install-job-notifications.ts
@@ -53,6 +53,10 @@ const messages = defineMessages({
id: 'app.action-bar.install.unknown-instance',
defaultMessage: 'Unknown instance',
},
+ updatingSharedContent: {
+ id: 'app.action-bar.install.updating-shared-content',
+ defaultMessage: 'Updating shared content',
+ },
})
const phaseMessages = defineMessages({
@@ -268,6 +272,9 @@ export async function useInstallJobNotifications(opts: {
version: job.details.major_version,
})
}
+ if (job.kind === 'update_shared_instance' && job.phase === 'downloading_content') {
+ return formatMessage(messages.updatingSharedContent)
+ }
return formatMessage(phaseMessages[job.phase])
}
diff --git a/apps/app-frontend/src/composables/instances/use-managed-content-policy.ts b/apps/app-frontend/src/composables/instances/use-managed-content-policy.ts
new file mode 100644
index 0000000000..3e56d400c4
--- /dev/null
+++ b/apps/app-frontend/src/composables/instances/use-managed-content-policy.ts
@@ -0,0 +1,110 @@
+import {
+ defineMessages,
+ type ContentActionWarning,
+ type ContentItem,
+ useVIntl,
+} from '@modrinth/ui'
+import { computed, type Ref } from 'vue'
+
+import type { GameInstance } from '@/helpers/types'
+
+const managedSourceKinds = new Set(['shared_instance', 'modrinth_modpack', 'imported_modpack'])
+
+export function useManagedContentPolicy(instance: Ref) {
+ const { formatMessage } = useVIntl()
+ const isManagedModpack = computed(() => instance.value.shared_instance?.role === 'member')
+ const canUnpublish = computed(() => instance.value.shared_instance?.role === 'owner')
+ const canUnlink = computed(() => instance.value.shared_instance?.role === 'member')
+ const managedModpackWarning = computed(() => ({
+ admonitionHeader: formatMessage(messages.warningHeader),
+ changeVersionBody: formatMessage(messages.changeVersionBody),
+ unlinkBody: formatMessage(messages.unlinkBody),
+ }))
+
+ function isManagedContent(item: ContentItem) {
+ return isManagedModpack.value && !!item.source_kind && managedSourceKinds.has(item.source_kind)
+ }
+
+ function canMutateContent(item: ContentItem) {
+ return !isManagedContent(item)
+ }
+
+ function canUpdateContent(item: ContentItem) {
+ return canMutateContent(item) && !!item.file_path && !!item.has_update && !!item.update_version_id
+ }
+
+ function deleteWarning(items: ContentItem[]): ContentActionWarning | null {
+ if (!items.some(isManagedContent)) return null
+ return {
+ admonitionHeader: formatMessage(messages.warningHeader),
+ admonitionBody: formatMessage(
+ items.length === 1 ? messages.deleteSingleBody : messages.deleteBulkBody,
+ ),
+ actionLabel: formatMessage(
+ items.length === 1 ? messages.deleteButton : messages.deleteManyButton,
+ { count: items.length },
+ ),
+ }
+ }
+
+ function disableWarning(items: ContentItem[]): ContentActionWarning | null {
+ if (!items.some(isManagedContent)) return null
+ return {
+ admonitionHeader: formatMessage(messages.warningHeader),
+ admonitionBody: formatMessage(
+ items.length === 1 ? messages.disableSingleBody : messages.disableBulkBody,
+ ),
+ actionLabel: formatMessage(
+ items.length === 1 ? messages.disableButton : messages.disableManyButton,
+ { count: items.length },
+ ),
+ }
+ }
+
+ return {
+ isManagedModpack,
+ canUnpublish,
+ canUnlink,
+ managedModpackWarning,
+ isManagedContent,
+ canMutateContent,
+ canUpdateContent,
+ deleteWarning,
+ disableWarning,
+ }
+}
+
+const messages = defineMessages({
+ warningHeader: {
+ id: 'content.shared-instance.warning-header',
+ defaultMessage: 'This is part of the shared instance',
+ },
+ changeVersionBody: {
+ id: 'content.shared-instance.change-version-body',
+ defaultMessage: 'Changing the version only changes your local copy. Future shared instance updates may restore or change it again.',
+ },
+ unlinkBody: {
+ id: 'content.shared-instance.unlink-body',
+ defaultMessage: 'Unlinking only changes your local copy. Future shared instance updates may restore or change it again.',
+ },
+ deleteSingleBody: {
+ id: 'content.shared-instance.delete-single-body',
+ defaultMessage: 'Deleting it only changes your local copy. Future shared instance updates may restore or change it again.',
+ },
+ deleteBulkBody: {
+ id: 'content.shared-instance.delete-bulk-body',
+ defaultMessage: 'Some selected projects are part of the shared instance. Deleting them only changes your local copy, and future shared instance updates may restore or change them again.',
+ },
+ deleteButton: { id: 'content.shared-instance.delete-button', defaultMessage: 'Delete anyway' },
+ deleteManyButton: { id: 'content.shared-instance.delete-many-button', defaultMessage: 'Delete {count, number} projects anyway' },
+ disableSingleBody: {
+ id: 'content.shared-instance.disable-single-body',
+ defaultMessage: 'Disabling it only changes your local copy. Future shared instance updates may re-enable, restore, or change it again.',
+ },
+ disableBulkBody: {
+ id: 'content.shared-instance.disable-bulk-body',
+ defaultMessage: 'Some selected projects are part of the shared instance. Disabling them only changes your local copy, and future shared instance updates may re-enable, restore, or change them again.',
+ },
+ disableButton: { id: 'content.shared-instance.disable-button', defaultMessage: 'Disable anyway' },
+ disableManyButton: { id: 'content.shared-instance.disable-many-button', defaultMessage: 'Disable {count, number} projects anyway' },
+})
diff --git a/apps/app-frontend/src/composables/use-friends.ts b/apps/app-frontend/src/composables/use-friends.ts
new file mode 100644
index 0000000000..650d3a9cbe
--- /dev/null
+++ b/apps/app-frontend/src/composables/use-friends.ts
@@ -0,0 +1,156 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { computed, onUnmounted, type MaybeRefOrGetter, toValue } from 'vue'
+
+import { toError } from '@/helpers/errors'
+import { friend_listener } from '@/helpers/events.js'
+import {
+ acceptCachedFriend,
+ add_friend,
+ createPendingFriend,
+ type FriendCacheUser,
+ type FriendWithUserData,
+ friendsQueryKey,
+ getFriendsWithUserData,
+ getFriendUserId,
+ matchesFriend,
+ remove_friend,
+ removeCachedFriend,
+ upsertCachedFriend,
+} from '@/helpers/friends'
+import type { ModrinthCredentials } from '@/helpers/mr_auth'
+
+type FriendsMutationContext = {
+ queryKey: ReturnType
+ previousFriends?: FriendWithUserData[]
+}
+
+type AddFriendMutationVariables = {
+ userId: string
+ user: FriendCacheUser
+ acceptExisting: boolean
+}
+
+type RemoveFriendMutationVariables = {
+ userId: string
+ user: FriendWithUserData
+}
+
+export function useFriends(options: {
+ currentUserId: MaybeRefOrGetter
+ getCredentials: () => ModrinthCredentials | null | Promise
+ enabled?: MaybeRefOrGetter
+ onError?: (error: Error) => void
+}) {
+ const queryClient = useQueryClient()
+ const queryKey = computed(() => friendsQueryKey(toValue(options.currentUserId)))
+ const query = useQuery({
+ queryKey,
+ queryFn: async () => getFriendsWithUserData(await options.getCredentials()),
+ enabled: () => !!toValue(options.currentUserId) && toValue(options.enabled ?? true),
+ staleTime: 30_000,
+ })
+ const friends = computed(() => query.data.value ?? [])
+
+ function restore(context?: FriendsMutationContext) {
+ if (!context) return
+ if (context.previousFriends === undefined) {
+ queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
+ return
+ }
+ queryClient.setQueryData(context.queryKey, context.previousFriends)
+ }
+
+ const addMutation = useMutation({
+ mutationFn: ({ userId }: AddFriendMutationVariables) => add_friend(userId),
+ onMutate: async ({ user, acceptExisting }): Promise => {
+ const activeQueryKey = queryKey.value
+ await queryClient.cancelQueries({ queryKey: activeQueryKey })
+ const previousFriends = queryClient.getQueryData(activeQueryKey)
+ const currentUserId = toValue(options.currentUserId)
+ queryClient.setQueryData(activeQueryKey, (cachedFriends = []) =>
+ acceptExisting
+ ? acceptCachedFriend(cachedFriends, user.id, user.username, currentUserId)
+ : upsertCachedFriend(
+ cachedFriends,
+ createPendingFriend(user, currentUserId),
+ currentUserId,
+ ),
+ )
+ return { queryKey: activeQueryKey, previousFriends }
+ },
+ onError: (error, _variables, context) => {
+ restore(context)
+ options.onError?.(toError(error))
+ },
+ onSettled: (_data, _error, _variables, context) => {
+ void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
+ },
+ })
+
+ const removeMutation = useMutation({
+ mutationFn: ({ userId }: RemoveFriendMutationVariables) => remove_friend(userId),
+ onMutate: async ({ user, userId }): Promise => {
+ const activeQueryKey = queryKey.value
+ await queryClient.cancelQueries({ queryKey: activeQueryKey })
+ const previousFriends = queryClient.getQueryData(activeQueryKey)
+ queryClient.setQueryData(activeQueryKey, (cachedFriends = []) =>
+ removeCachedFriend(
+ cachedFriends,
+ userId,
+ user.username,
+ toValue(options.currentUserId),
+ ),
+ )
+ return { queryKey: activeQueryKey, previousFriends }
+ },
+ onError: (error, _variables, context) => {
+ restore(context)
+ options.onError?.(toError(error))
+ },
+ onSettled: (_data, _error, _variables, context) => {
+ void queryClient.invalidateQueries({ queryKey: context?.queryKey ?? queryKey.value })
+ },
+ })
+
+ function requestFriend(user: FriendCacheUser, acceptExisting = false) {
+ addMutation.mutate({ userId: user.id, user, acceptExisting })
+ }
+
+ function acceptFriend(friend: FriendWithUserData) {
+ const userId = getFriendUserId(friend, toValue(options.currentUserId))
+ requestFriend(
+ { id: userId, username: friend.username, avatarUrl: friend.avatar },
+ true,
+ )
+ }
+
+ function removeFriend(friend: FriendWithUserData) {
+ const userId = getFriendUserId(friend, toValue(options.currentUserId))
+ removeMutation.mutate({ userId, user: friend })
+ }
+
+ function findFriend(id: string, username: string) {
+ return friends.value.find((friend) =>
+ matchesFriend(friend, id, username, toValue(options.currentUserId)),
+ )
+ }
+
+ let unlisten: (() => void) | undefined
+ void friend_listener(() => {
+ void queryClient.invalidateQueries({ queryKey: queryKey.value })
+ }).then((listener) => {
+ unlisten = listener
+ })
+ onUnmounted(() => unlisten?.())
+
+ return {
+ query,
+ queryKey,
+ friends,
+ loading: computed(() => !!toValue(options.currentUserId) && query.isLoading.value),
+ requestFriend,
+ acceptFriend,
+ removeFriend,
+ findFriend,
+ }
+}
diff --git a/apps/app-frontend/src/composables/users/use-user-query.ts b/apps/app-frontend/src/composables/users/use-user-query.ts
new file mode 100644
index 0000000000..6834a6721e
--- /dev/null
+++ b/apps/app-frontend/src/composables/users/use-user-query.ts
@@ -0,0 +1,17 @@
+import { useQuery } from '@tanstack/vue-query'
+import { computed, type Ref } from 'vue'
+
+import { get_user } from '@/helpers/cache.js'
+
+export function useUserQuery(userId: Ref) {
+ return useQuery({
+ queryKey: computed(() => ['user', userId.value]),
+ queryFn: async ({ queryKey }) => {
+ const id = queryKey[1]
+ if (typeof id !== 'string') return null
+ return await get_user(id, 'bypass').catch(() => null)
+ },
+ enabled: () => !!userId.value,
+ staleTime: 30_000,
+ })
+}
diff --git a/apps/app-frontend/src/helpers/errors.ts b/apps/app-frontend/src/helpers/errors.ts
new file mode 100644
index 0000000000..ab45f8550c
--- /dev/null
+++ b/apps/app-frontend/src/helpers/errors.ts
@@ -0,0 +1,11 @@
+export function toError(error: unknown) {
+ if (error instanceof Error) return error
+ if (typeof error === 'string') return new Error(error)
+ if (error && typeof error === 'object') {
+ const record = error as Record
+ 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))
+}
diff --git a/apps/app-frontend/src/helpers/friends.ts b/apps/app-frontend/src/helpers/friends.ts
index 7fd27b5a88..bba32aad9b 100644
--- a/apps/app-frontend/src/helpers/friends.ts
+++ b/apps/app-frontend/src/helpers/friends.ts
@@ -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 {
+ 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,
+ currentUserId?: string | null,
+) {
+ return friend.id === currentUserId && friend.friend_id ? friend.friend_id : friend.id
+}
+
+export function matchesFriend(
+ friend: FriendWithUserData,
+ id: string,
+ username: string,
+ currentUserId?: string | null,
+) {
+ const friendId = getFriendUserId(friend, currentUserId)
+ return (
+ normalizeFriendKey(friendId) === normalizeFriendKey(id) ||
+ normalizeFriendKey(friend.username) === normalizeFriendKey(username)
+ )
+}
+
+export function upsertCachedFriend(
+ friends: FriendWithUserData[],
+ friend: FriendWithUserData,
+ currentUserId?: string | null,
+) {
+ const existingFriend = friends.find((cachedFriend) =>
+ matchesFriend(cachedFriend, friend.id, friend.username, currentUserId),
+ )
+
+ if (!existingFriend) return [friend, ...friends]
+
+ return friends.map((cachedFriend) =>
+ matchesFriend(cachedFriend, friend.id, friend.username, currentUserId)
+ ? {
+ ...cachedFriend,
+ ...friend,
+ id: cachedFriend.id,
+ friend_id: cachedFriend.friend_id,
+ }
+ : cachedFriend,
+ )
+}
+
+export function acceptCachedFriend(
+ friends: FriendWithUserData[],
+ id: string,
+ username: string,
+ currentUserId?: string | null,
+) {
+ return friends.map((friend) =>
+ matchesFriend(friend, id, username, currentUserId)
+ ? {
+ ...friend,
+ accepted: true,
+ }
+ : friend,
+ )
+}
+
+export function removeCachedFriend(
+ friends: FriendWithUserData[],
+ id: string,
+ username: string,
+ currentUserId?: string | null,
+) {
+ return friends.filter((friend) => !matchesFriend(friend, id, username, currentUserId))
+}
+
+export function normalizeFriendKey(value: string) {
+ return value.trim().toLowerCase()
+}
+
export async function transformFriends(
friends: UserFriend[],
credentials: ModrinthCredentials | null,
diff --git a/apps/app-frontend/src/helpers/install.ts b/apps/app-frontend/src/helpers/install.ts
index 76fc45cfa4..f0b22ddf03 100644
--- a/apps/app-frontend/src/helpers/install.ts
+++ b/apps/app-frontend/src/helpers/install.ts
@@ -44,6 +44,84 @@ export interface InstallPostInstallEdit {
link?: InstanceLink | null
}
+export interface SharedInstanceInstallPreview {
+ name: string
+ iconUrl?: string | null
+ gameVersion: string
+ loader: InstanceLoader
+ modCount: number
+ externalFileCount: number
+ modpackVersionId?: string | null
+ contentVersionIds: string[]
+ externalFiles: SharedInstanceExternalFilePreview[]
+}
+
+export interface SharedInstanceInviteInstallPreview {
+ sharedInstanceId: string
+ managerId?: string | null
+ serverManagerName?: string | null
+ serverManagerIconUrl?: string | null
+ instanceIconUrl?: string | null
+ preview: SharedInstanceInstallPreview
+}
+
+export interface SharedInstanceExternalFilePreview {
+ fileName: string
+ fileType: string
+}
+
+export interface SharedInstanceUpdatePreview {
+ sharedInstanceId: string
+ currentVersion?: number | null
+ latestVersion: number
+ updateAvailable: boolean
+ diffs: SharedInstanceUpdateDiff[]
+}
+
+export interface SharedInstanceUpdateDiff {
+ type:
+ | 'added'
+ | 'removed'
+ | 'updated'
+ | 'modpack_linked'
+ | 'modpack_updated'
+ | 'modpack_unlinked'
+ | 'game_version_updated'
+ | 'loader_updated'
+ projectId?: string | null
+ projectName?: string | null
+ fileName?: string | null
+ currentVersionName?: string | null
+ newVersionName?: string | null
+ disabled?: boolean
+}
+
+export const SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE = 'shared_instance_unavailable'
+
+export type SharedInstanceUnavailableReason = 'deleted' | 'access_revoked'
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+export function isSharedInstanceUnavailableError(error: unknown) {
+ return getSharedInstanceUnavailableReason(error) !== null
+}
+
+export function getSharedInstanceUnavailableReason(
+ error: unknown,
+): SharedInstanceUnavailableReason | null {
+ if (!isRecord(error) || error.code !== SHARED_INSTANCE_UNAVAILABLE_ERROR_CODE) return null
+ return error.reason === 'deleted' || error.reason === 'access_revoked' ? error.reason : null
+}
+
+export function getErrorMessage(error: unknown): string {
+ if (typeof error === 'string') return error
+ if (error instanceof Error) return error.message || 'Unknown error'
+ if (isRecord(error) && typeof error.message === 'string') return error.message
+ return 'Unknown error'
+}
+
export type InstallJobStatus =
| 'queued'
| 'running'
@@ -89,6 +167,7 @@ export interface InstallErrorView {
code: string
phase?: InstallPhaseId | null
message: string
+ reason?: SharedInstanceUnavailableReason | null
api?: {
error: string
status?: number | null
@@ -121,6 +200,8 @@ export interface InstallJobSnapshot {
kind:
| 'create_instance'
| 'create_modpack_instance'
+ | 'create_shared_instance'
+ | 'update_shared_instance'
| 'import_instance'
| 'duplicate_instance'
| 'install_existing_instance'
@@ -171,6 +252,58 @@ export async function install_create_modpack_instance(
})
}
+export async function install_get_shared_instance_preview(sharedInstanceId: string, name: string) {
+ return await invoke(
+ 'plugin:install|install_get_shared_instance_preview',
+ {
+ sharedInstanceId,
+ name,
+ },
+ )
+}
+
+export async function install_accept_shared_instance_invite(inviteId: string) {
+ return await invoke(
+ 'plugin:install|install_accept_shared_instance_invite',
+ {
+ inviteId,
+ },
+ )
+}
+
+export async function install_get_shared_instance_update_preview(instanceId: string) {
+ return await invoke(
+ 'plugin:install|install_get_shared_instance_update_preview',
+ {
+ instanceId,
+ },
+ )
+}
+
+export async function install_shared_instance(
+ sharedInstanceId: string,
+ name: string,
+ managerId?: string | null,
+ serverManagerName?: string | null,
+ serverManagerIconUrl?: string | null,
+ instanceIconUrl?: string | null,
+) {
+ return await invoke('plugin:install|install_shared_instance', {
+ sharedInstanceId,
+ name,
+ managerId,
+ serverManagerName,
+ serverManagerIconUrl,
+ instanceIconUrl,
+ })
+}
+
+export async function install_update_shared_instance(instanceId: string) {
+ return await invoke('plugin:install|install_update_shared_instance', {
+ instanceId,
+ })
+}
+
export async function install_import_instance(
launcherType: string,
basePath: string,
@@ -248,7 +381,8 @@ export function isInstallJobFinished(status: InstallJobStatus) {
function settleInstallJob(job: InstallJobSnapshot) {
if (job.status === 'succeeded') return job
- throw new Error(job.error?.message ?? `Install job ${job.job_id} ${job.status}`)
+ if (job.error) throw job.error
+ throw new Error(`Install job ${job.job_id} ${job.status}`)
}
export async function wait_for_install_job(jobId: string) {
diff --git a/apps/app-frontend/src/helpers/instance.ts b/apps/app-frontend/src/helpers/instance.ts
index 4570973974..99dec09d0b 100644
--- a/apps/app-frontend/src/helpers/instance.ts
+++ b/apps/app-frontend/src/helpers/instance.ts
@@ -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 {
@@ -331,3 +332,82 @@ export async function edit(instanceId: string, editInstance: Partial {
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 {
+ return await invoke('plugin:instance|instance_share_get_users', { instanceId })
+}
+
+export async function invite_shared_instance_users(
+ instanceId: string,
+ userIds: string[],
+): Promise {
+ 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 {
+ return await invoke('plugin:instance|instance_share_create_invite_link', {
+ instanceId,
+ ...options,
+ })
+}
+
+export async function remove_shared_instance_users(
+ instanceId: string,
+ userIds: string[],
+): Promise {
+ return await invoke('plugin:instance|instance_share_remove_users', { instanceId, userIds })
+}
+
+export async function get_shared_instance_publish_preview(
+ instanceId: string,
+): Promise {
+ return await invoke('plugin:instance|instance_share_get_publish_preview', { instanceId })
+}
+
+export async function publish_shared_instance(
+ instanceId: string,
+): Promise {
+ return await invoke('plugin:instance|instance_share_publish', { instanceId })
+}
+
+export async function unlink_shared_instance(instanceId: string): Promise {
+ return await invoke('plugin:instance|instance_share_unlink', { instanceId })
+}
+
+export async function unpublish_shared_instance(instanceId: string): Promise {
+ return await invoke('plugin:instance|instance_share_unpublish', { instanceId })
+}
diff --git a/apps/app-frontend/src/helpers/mr_auth.ts b/apps/app-frontend/src/helpers/mr_auth.ts
index d28ad63db4..111b1f088c 100644
--- a/apps/app-frontend/src/helpers/mr_auth.ts
+++ b/apps/app-frontend/src/helpers/mr_auth.ts
@@ -12,8 +12,10 @@ export type ModrinthCredentials = {
active: boolean
}
-export async function login(): Promise {
- return await invoke('plugin:mr-auth|modrinth_login')
+export type ModrinthAuthFlow = 'sign-in' | 'sign-up'
+
+export async function login(flow: ModrinthAuthFlow = 'sign-in'): Promise {
+ return await invoke('plugin:mr-auth|modrinth_login', { flow })
}
export async function logout(): Promise {
diff --git a/apps/app-frontend/src/helpers/shared-instance-errors.ts b/apps/app-frontend/src/helpers/shared-instance-errors.ts
new file mode 100644
index 0000000000..e0ac2e925a
--- /dev/null
+++ b/apps/app-frontend/src/helpers/shared-instance-errors.ts
@@ -0,0 +1,83 @@
+import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
+
+import {
+ getErrorMessage,
+ type SharedInstanceUnavailableReason,
+} from '@/helpers/install'
+
+export const sharedInstanceErrorMessages = defineMessages({
+ unavailableTitle: {
+ id: 'instance.shared-instance.unavailable.title',
+ defaultMessage: 'Shared instance no longer available',
+ },
+ unavailableText: {
+ id: 'instance.shared-instance.unavailable.text-v2',
+ defaultMessage:
+ "Your local instance is still available, but it is no longer linked and won't receive updates.",
+ },
+ deletedText: {
+ id: 'instance.shared-instance.unavailable.deleted-text-v2',
+ defaultMessage:
+ "The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates.",
+ },
+ accessRevokedText: {
+ id: 'instance.shared-instance.unavailable.access-revoked-text-v2',
+ defaultMessage:
+ "Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates.",
+ },
+ unavailableFallbackManager: {
+ id: 'instance.shared-instance.unavailable.manager-fallback',
+ defaultMessage: 'the instance manager',
+ },
+ errorTitle: {
+ id: 'instance.shared-instance.error.title',
+ defaultMessage: 'Something has gone wrong',
+ },
+})
+
+export function sharedInstanceUnavailableTextMessage(
+ reason: SharedInstanceUnavailableReason | null,
+) {
+ if (reason === 'deleted') return sharedInstanceErrorMessages.deletedText
+ if (reason === 'access_revoked') return sharedInstanceErrorMessages.accessRevokedText
+ return sharedInstanceErrorMessages.unavailableText
+}
+
+export function useSharedInstanceErrors() {
+ const { formatMessage } = useVIntl()
+ const { addNotification } = injectNotificationManager()
+
+ function formatSharedInstanceUnavailable(
+ reason: SharedInstanceUnavailableReason | null = null,
+ manager?: string | null,
+ ) {
+ return formatMessage(sharedInstanceUnavailableTextMessage(reason), {
+ manager: manager || formatMessage(sharedInstanceErrorMessages.unavailableFallbackManager),
+ })
+ }
+
+ function notifySharedInstanceUnavailable(
+ reason: SharedInstanceUnavailableReason | null = null,
+ manager?: string | null,
+ ) {
+ addNotification({
+ type: 'warning',
+ title: formatMessage(sharedInstanceErrorMessages.unavailableTitle),
+ text: formatSharedInstanceUnavailable(reason, manager),
+ })
+ }
+
+ function notifySharedInstanceError(error: unknown) {
+ addNotification({
+ type: 'error',
+ title: formatMessage(sharedInstanceErrorMessages.errorTitle),
+ text: getErrorMessage(error),
+ })
+ }
+
+ return {
+ formatSharedInstanceUnavailable,
+ notifySharedInstanceError,
+ notifySharedInstanceUnavailable,
+ }
+}
diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts
index 761226eb91..43dacaeefd 100644
--- a/apps/app-frontend/src/helpers/types.d.ts
+++ b/apps/app-frontend/src/helpers/types.d.ts
@@ -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
diff --git a/apps/app-frontend/src/helpers/users.ts b/apps/app-frontend/src/helpers/users.ts
new file mode 100644
index 0000000000..042b0d4cbb
--- /dev/null
+++ b/apps/app-frontend/src/helpers/users.ts
@@ -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 {
+ return await invoke('plugin:users|search_user', { query })
+}
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index b6a092b1d4..678adeb4a7 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -89,6 +89,9 @@
"app.action-bar.install.unknown-instance": {
"message": "Unknown instance"
},
+ "app.action-bar.install.updating-shared-content": {
+ "message": "Updating shared content"
+ },
"app.action-bar.installs": {
"message": "Installs"
},
@@ -323,6 +326,36 @@
"app.install.phase.running_loader_processors": {
"message": "Running loader processors"
},
+ "app.instance.admonitions.shared-instance.added-label": {
+ "message": "Added"
+ },
+ "app.instance.admonitions.shared-instance.changes-body": {
+ "message": "Your local instance is ahead of the users you've shared it with."
+ },
+ "app.instance.admonitions.shared-instance.changes-header": {
+ "message": "Your changes haven't been shared yet"
+ },
+ "app.instance.admonitions.shared-instance.publish-button": {
+ "message": "Push update"
+ },
+ "app.instance.admonitions.shared-instance.publishing-button": {
+ "message": "Pushing..."
+ },
+ "app.instance.admonitions.shared-instance.removed-label": {
+ "message": "Removed"
+ },
+ "app.instance.admonitions.shared-instance.review-admonition-header": {
+ "message": "Push update"
+ },
+ "app.instance.admonitions.shared-instance.review-description": {
+ "message": "Review the content changes that will be shared with everyone using this instance."
+ },
+ "app.instance.admonitions.shared-instance.review-header": {
+ "message": "Review changes"
+ },
+ "app.instance.admonitions.shared-instance.reviewing-button": {
+ "message": "Reviewing..."
+ },
"app.instance.confirm-delete.admonition-body": {
"message": "All data for your instance will be permanently deleted, including your worlds, configs, and all installed content."
},
@@ -374,6 +407,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"
},
@@ -428,6 +539,9 @@
"app.modal.install-to-play.content-required": {
"message": "Content required"
},
+ "app.modal.install-to-play.external-file-warning": {
+ "message": "{count, plural, one {This instance includes # file that is not from Modrinth.} other {This instance includes # files that are not from Modrinth.}}"
+ },
"app.modal.install-to-play.header": {
"message": "Install to play"
},
@@ -446,20 +560,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"
@@ -1016,6 +1145,30 @@
"instance.settings.tabs.window.width.enter": {
"message": "Enter width..."
},
+ "instance.shared-instance.error.title": {
+ "message": "Something has gone wrong"
+ },
+ "instance.shared-instance.owner-tooltip": {
+ "message": "This instance's content is being shared to other users."
+ },
+ "instance.shared-instance.tooltip": {
+ "message": "This instance's content is being managed by someone else."
+ },
+ "instance.shared-instance.unavailable.access-revoked-text-v2": {
+ "message": "Your access to the shared instance was revoked. Your local instance is still available, but it is no longer linked and won't receive updates."
+ },
+ "instance.shared-instance.unavailable.deleted-text-v2": {
+ "message": "The shared instance was deleted. Your local instance is still available, but it is no longer linked and won't receive updates."
+ },
+ "instance.shared-instance.unavailable.manager-fallback": {
+ "message": "the instance manager"
+ },
+ "instance.shared-instance.unavailable.text-v2": {
+ "message": "Your local instance is still available, but it is no longer linked and won't receive updates."
+ },
+ "instance.shared-instance.unavailable.title": {
+ "message": "Shared instance no longer available"
+ },
"instance.worlds.a_minecraft_server": {
"message": "A Minecraft Server"
},
@@ -1079,6 +1232,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? Get support"
+ },
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue
index 6828483c59..3f2b80a903 100644
--- a/apps/app-frontend/src/pages/instance/Index.vue
+++ b/apps/app-frontend/src/pages/instance/Index.vue
@@ -12,7 +12,14 @@
:offline="offline"
@unlinked="fetchInstance"
/>
-
+
+
{{ instance.name }}
+
+
+ Shared
+
+
+
@@ -89,6 +108,23 @@
+
+
+
+
+
+ {{ sharedInstanceManager.type === 'server' ? 'Linked to' : 'Managed by' }}
+
+
{{ sharedInstanceManager.name }}
+
+
@@ -217,6 +253,17 @@
+
@@ -288,6 +335,7 @@ import {
SettingsIcon,
StopCircleIcon,
TerminalSquareIcon,
+ UnknownIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
@@ -296,6 +344,7 @@ import {
Avatar,
ButtonStyled,
ContentPageHeader,
+ defineMessages,
injectNotificationManager,
NavTabs,
OverflowMenu,
@@ -304,6 +353,7 @@ import {
ServerRecentPlays,
ServerRegion,
useLoadingBarToken,
+ useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
@@ -315,7 +365,9 @@ 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 SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import {
fetchCachedServerStatus,
@@ -325,10 +377,15 @@ import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
-import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
+import {
+ install_existing_instance,
+ install_pack_to_existing_instance,
+ type SharedInstanceUnavailableReason,
+} from '@/helpers/install'
import { get, get_full_path, kill, run } from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get_by_instance_id } from '@/helpers/process'
+import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
@@ -336,6 +393,11 @@ import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
+import {
+ provideSharedInstanceState,
+ useSharedInstanceState,
+} from './use-shared-instance-state'
+
dayjs.extend(duration)
dayjs.extend(relativeTime)
@@ -367,6 +429,21 @@ const subpagePending = ref(false)
const stopping = ref(false)
const exportModal = ref>()
const updateToPlayModal = ref>()
+const sharedInstanceUpdateModal = ref>()
+
+const { formatMessage } = useVIntl()
+const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
+
+const messages = defineMessages({
+ sharedInstanceTooltip: {
+ id: 'instance.shared-instance.tooltip',
+ defaultMessage: "This instance's content is being managed by someone else.",
+ },
+ sharedInstanceOwnerTooltip: {
+ id: 'instance.shared-instance.owner-tooltip',
+ defaultMessage: "This instance's content is being shared to other users.",
+ },
+})
useLoadingBarToken(subpagePending)
@@ -385,6 +462,26 @@ const playersOnline = ref(undefined)
const ping = ref(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref()
+const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError)
+provideSharedInstanceState(sharedInstanceState)
+const {
+ actionsLocked: sharedInstanceActionsLocked,
+ expectedUserId: sharedInstanceExpectedUserId,
+ manager: sharedInstanceManager,
+ setUnavailable: setSharedInstanceUnavailable,
+ signedOut: sharedInstanceSignedOut,
+ unavailableManager: sharedInstanceUnavailableManager,
+ unavailableReason: sharedInstanceUnavailableReason,
+ updatePreview: sharedInstanceUpdatePreview,
+ wrongAccount: sharedInstanceWrongAccount,
+} = sharedInstanceState
+const sharedInstanceTooltip = computed(() =>
+ formatMessage(
+ instance.value?.shared_instance?.role === 'owner'
+ ? messages.sharedInstanceOwnerTooltip
+ : messages.sharedInstanceTooltip,
+ ),
+)
watch(
() => router.currentRoute.value,
@@ -418,6 +515,7 @@ async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
+ sharedInstanceState.reset()
resetServerStatus()
const nextInstance = await get(route.params.id as string).catch(handleError)
@@ -442,8 +540,8 @@ async function fetchInstance() {
}
const nextPreloadedContent = await contentPreloadPromise
-
instance.value = nextInstance ?? undefined
+ sharedInstanceState.refreshAvailability()
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
@@ -531,29 +629,50 @@ const isFixedRender = computed(() => renderMode.value === 'fixed')
const contentSubpageProps = computed(() =>
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
)
+const showShareTab = computed(() => {
+ const linkType = instance.value?.link?.type
-const tabs = computed(() => [
- {
- label: 'Content',
- href: `${basePath.value}`,
- icon: BoxesIcon,
- },
- {
- label: 'Files',
- href: `${basePath.value}/files`,
- icon: FolderOpenIcon,
- },
- {
- label: 'Worlds',
- href: `${basePath.value}/worlds`,
- icon: GlobeIcon,
- },
- {
- label: 'Logs',
- href: `${basePath.value}/logs`,
- icon: TerminalSquareIcon,
- },
-])
+ return (
+ instance.value?.shared_instance?.role !== 'member' &&
+ linkType !== 'server_project' &&
+ linkType !== 'server_project_modpack'
+ )
+})
+
+const tabs = computed(() => {
+ const instanceTabs = [
+ {
+ label: 'Content',
+ href: `${basePath.value}`,
+ icon: BoxesIcon,
+ },
+ {
+ label: 'Files',
+ href: `${basePath.value}/files`,
+ icon: FolderOpenIcon,
+ },
+ {
+ label: 'Worlds',
+ href: `${basePath.value}/worlds`,
+ icon: GlobeIcon,
+ },
+ {
+ label: 'Logs',
+ href: `${basePath.value}/logs`,
+ icon: TerminalSquareIcon,
+ },
+ ]
+
+ if (showShareTab.value) {
+ instanceTabs.push({
+ label: 'Share',
+ href: `${basePath.value}/share`,
+ icon: UserPlusIcon,
+ })
+ }
+
+ return instanceTabs
+})
if (instance.value) {
breadcrumbs.setName(
@@ -571,13 +690,7 @@ if (instance.value) {
const options = ref | 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 +700,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 +708,49 @@ const startInstance = async (context: string) => {
})
}
+async function handleSharedInstanceUnavailable(
+ reason: SharedInstanceUnavailableReason | null = null,
+) {
+ notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
+ await fetchInstance()
+ setSharedInstanceUnavailable(reason)
+}
+
+const startInstance = async (context: string) => {
+ if (!instance.value) return
+ if (loading.value || playing.value) return
+
+ const isSharedInstanceMember = instance.value.shared_instance?.role === 'member'
+ const canCheckSharedInstanceUpdate =
+ isSharedInstanceMember && !sharedInstanceActionsLocked.value && !offline.value
+
+ if (canCheckSharedInstanceUpdate) {
+ const preview = sharedInstanceUpdatePreview.value
+
+ if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
+ sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
+ await fetchInstance()
+ await launchInstance(context)
+ })
+ return
+ }
+ }
+
+ if (updateToPlayModal.value?.hasUpdate) {
+ if (isSharedInstanceMember) {
+ updateToPlayModal.value.show(instance.value, null, async () => {
+ await fetchInstance()
+ await launchInstance(context)
+ })
+ } else {
+ updateToPlayModal.value.show(instance.value)
+ }
+ return
+ }
+
+ await launchInstance(context)
+}
+
const stopInstance = async (context: string) => {
stopping.value = true
await kill(route.params.id as string).catch(handleError)
diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue
index 485d3065de..d72fdab7e8 100644
--- a/apps/app-frontend/src/pages/instance/Mods.vue
+++ b/apps/app-frontend/src/pages/instance/Mods.vue
@@ -12,14 +12,24 @@
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
- :enable-toggle="!props.isServerInstance"
+ :enable-toggle="!props.isServerInstance && !isSharedMember"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
- :switch-version="handleSwitchVersion"
+ :switch-version="
+ props.isServerInstance || isSharedMember ? undefined : handleSwitchVersion
+ "
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
+
void
preloadedContent?: InstanceContentData | null
}>()
+const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
+const {
+ isManagedModpack: isSharedMember,
+ canMutateContent,
+ canUpdateContent: canUpdateProject,
+} = managedContentPolicy
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
return contentData?.path === props.instance.id
@@ -284,6 +300,8 @@ const exportModal = ref(null)
const contentUpdaterModal = ref | null>()
const modpackContentModal = ref | null>()
const modpackUpdateConfirmModal = ref | null>()
+const sharedDisableConfirmModal = ref | null>()
+const pendingModpackDisableItems = ref([])
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
const modpackContentQuery = useQuery({
@@ -362,8 +380,8 @@ function hasContentOperation(item: ContentItem) {
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
-function canUpdateProject(item: ContentItem) {
- return !!item.file_path && !!item.has_update && !!item.update_version_id
+function canDeleteContent(item: ContentItem) {
+ return canMutateContent(item)
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
@@ -716,6 +734,7 @@ async function updateProject(mod: ContentItem) {
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
+ if (!canMutateContent(mod)) return
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
@@ -852,6 +871,7 @@ async function handleUpdate(id: string) {
}
async function handleSwitchVersion(item: ContentItem) {
+ if (!canMutateContent(item)) return
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
@@ -879,10 +899,32 @@ async function handleSwitchVersion(item: ContentItem) {
}
async function handleModpackContentToggle(item: ContentItem, enabled: boolean) {
+ if (!enabled && managedContentPolicy.disableWarning([item])) {
+ pendingModpackDisableItems.value = [item]
+ sharedDisableConfirmModal.value?.show()
+ return
+ }
+
await toggleDisableDebounced(item, enabled)
}
async function handleModpackContentBulkToggle(items: ContentItem[], enabled: boolean) {
+ if (!enabled && managedContentPolicy.disableWarning(items)) {
+ pendingModpackDisableItems.value = items
+ sharedDisableConfirmModal.value?.show()
+ return
+ }
+
+ await setModpackContentEnabled(items, enabled)
+}
+
+async function confirmPendingModpackContentDisable() {
+ const items = [...pendingModpackDisableItems.value]
+ pendingModpackDisableItems.value = []
+ await setModpackContentEnabled(items, false)
+}
+
+async function setModpackContentEnabled(items: ContentItem[], enabled: boolean) {
await Promise.all(items.map((item) => toggleDisableMod(item, enabled)))
}
@@ -1232,29 +1274,7 @@ function applyContentData(contentData: InstanceContentData) {
return true
}
-provideAppBackup({
- async createBackup() {
- const allInstances = await list()
- const prefix = `${props.instance.name} - Backup #`
- const existingNums = allInstances
- .filter((p) => p.name.startsWith(prefix))
- .map((p) => parseInt(p.name.slice(prefix.length), 10))
- .filter((n) => !isNaN(n))
- const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
- const job = await install_duplicate_instance(props.instance.id)
- const newInstanceId = installJobInstanceId(job)
- if (newInstanceId) {
- await edit(newInstanceId, { name: `${prefix}${nextNum}` })
- }
- },
-})
-
-const CONTENT_HINT_KEY = 'content-tab-modpack-hint-dismissed'
-const showContentHint = ref(localStorage.getItem(CONTENT_HINT_KEY) === null)
-function dismissContentHint() {
- showContentHint.value = false
- localStorage.setItem(CONTENT_HINT_KEY, 'true')
-}
+provideInstanceBackup(() => props.instance)
provideContentManager({
items: mergedProjects,
@@ -1316,15 +1336,23 @@ provideContentManager({
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: (items: ContentItem[]) =>
Promise.all(
- items.filter((item) => !item.enabled).map((item) => toggleDisableMod(item, true)),
+ items
+ .filter((item) => canMutateContent(item) && !item.enabled)
+ .map((item) => toggleDisableMod(item, true)),
).then(() => {}),
bulkDisableItems: (items: ContentItem[]) =>
Promise.all(
- items.filter((item) => item.enabled).map((item) => toggleDisableMod(item, false)),
+ items
+ .filter((item) => canMutateContent(item) && item.enabled)
+ .map((item) => toggleDisableMod(item, false)),
).then(() => {}),
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
- Promise.all(items.map((item) => removeMod(item))).then(() => {}),
+ Promise.all(items.filter(canMutateContent).map((item) => removeMod(item))).then(() => {}),
+ canDeleteItem: canDeleteContent,
+ canToggleItem: canMutateContent,
+ getDeleteWarning: managedContentPolicy.deleteWarning,
+ getDisableWarning: managedContentPolicy.disableWarning,
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
@@ -1333,14 +1361,12 @@ provideContentManager({
updateItem: handleUpdate,
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
- updateModpack: props.isServerInstance ? undefined : handleModpackUpdate,
+ updateModpack: props.isServerInstance || isSharedMember.value ? undefined : handleModpackUpdate,
viewModpackContent: handleModpackContent,
unlinkModpack: unpairInstance,
openSettings: props.openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
- showContentHint,
- dismissContentHint,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
@@ -1372,8 +1398,11 @@ provideContentManager({
link: () => openUrl(`https://modrinth.com/${item.owner!.type}/${item.owner!.id}`),
}
: undefined,
- enabled: item.enabled,
+ enabled: canMutateContent(item) ? item.enabled : undefined,
installing: item.installing,
+ hideDelete: !canDeleteContent(item),
+ hideSwitchVersion: !canMutateContent(item),
+ hasUpdate: canUpdateProject(item),
}),
filterPersistKey: props.instance.id,
})
diff --git a/apps/app-frontend/src/pages/instance/index.js b/apps/app-frontend/src/pages/instance/index.js
index b96705e883..0e6a0e7ec2 100644
--- a/apps/app-frontend/src/pages/instance/index.js
+++ b/apps/app-frontend/src/pages/instance/index.js
@@ -3,6 +3,7 @@ import Index from './Index.vue'
import Logs from './Logs.vue'
import Mods from './Mods.vue'
import Overview from './Overview.vue'
+import Share from './share/index.vue'
import Worlds from './Worlds.vue'
-export { Files, Index, Logs, Mods, Overview, Worlds }
+export { Files, Index, Logs, Mods, Overview, Share, Worlds }
diff --git a/apps/app-frontend/src/pages/instance/share/index.vue b/apps/app-frontend/src/pages/instance/share/index.vue
new file mode 100644
index 0000000000..463217236d
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/index.vue
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.lockedEmptyDescriptionPrefix) }}
+
+
+ {{ linkedAccount.username }}
+
+ {{ formatMessage(messages.linkedAccountFallback) }}
+ {{ formatMessage(messages.lockedEmptyDescriptionSuffix) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue
new file mode 100644
index 0000000000..49a9748ca2
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-members-table.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No users match your filters.
+
+
+
+
+ {{ row.username }}
+
+
+
+
+ {{ formatRelativeTime(row.lastPlayedAt) }}
+ Never
+
+
+ Pending
+ {{ formatRelativeTime(row.joinedAt) }}
+
+
+
+
+
+ {{ methodLabels[row.method] }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue
new file mode 100644
index 0000000000..b4d9f72d1d
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-remove-member-modal.vue
@@ -0,0 +1,58 @@
+
+
+
+
{{ formatMessage(messages.warning, { username }) }}
+
+
+
+ {{ username }}
+ {{ row ? methodLabels[row.method] : '' }}
+
+
+
+
{{ formatMessage(messages.effectsLabel) }}
+
+ - {{ formatMessage(effect) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue b/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue
new file mode 100644
index 0000000000..ab555c587a
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-share-empty-state.vue
@@ -0,0 +1,13 @@
+
+
+ {{ heading }}
+ {{ description }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts b/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts
new file mode 100644
index 0000000000..a6e478ba4e
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/shared-instance-share-types.ts
@@ -0,0 +1,20 @@
+export type ShareMethod = 'direct' | 'link'
+export type MethodFilter = ShareMethod | 'all'
+export type ShareTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method' | 'actions'
+
+export type ShareRow = {
+ id: string
+ username: string
+ avatarUrl?: string
+ lastPlayedAt: Date | null
+ joinedAt: Date | null
+ method: ShareMethod
+ pending?: boolean
+}
+
+export const methodLabels: Record = {
+ direct: 'Direct invite',
+ link: 'Share link',
+}
+
+export { normalizeInviteKey } from '@modrinth/ui'
diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-candidates.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-candidates.ts
new file mode 100644
index 0000000000..9a69db0a4c
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-candidates.ts
@@ -0,0 +1,105 @@
+import {
+ injectNotificationManager,
+ type InvitePlayersSearchUser,
+ type InvitePlayersUser,
+} from '@modrinth/ui'
+import { computed, type Ref } from 'vue'
+
+import { useFriends } from '@/composables/use-friends'
+import { getFriendUserId } from '@/helpers/friends.ts'
+import { get as getCredentials } from '@/helpers/mr_auth.ts'
+import { search_user } from '@/helpers/users.ts'
+
+import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
+
+export function useSharedInstanceInviteCandidates(options: {
+ rows: Ref
+ currentUserId: Ref
+ isSignedIn: Ref
+ actionsLocked: Ref
+}) {
+ const { handleError } = injectNotificationManager()
+ const friendsState = useFriends({
+ currentUserId: options.currentUserId,
+ getCredentials,
+ enabled: computed(
+ () =>
+ options.isSignedIn.value && !!options.currentUserId.value && !options.actionsLocked.value,
+ ),
+ onError: handleError,
+ })
+ const friends = friendsState.friends
+ const invitedRows = computed(() => {
+ const invited = new Map()
+ for (const row of options.rows.value) {
+ invited.set(normalizeInviteKey(row.id), row)
+ invited.set(normalizeInviteKey(row.username), row)
+ }
+ return invited
+ })
+ const inviteFriends = computed(() =>
+ friends.value
+ .filter((friend) => friend.username && friend.accepted)
+ .sort((a, b) => Number(b.online) - Number(a.online))
+ .map((friend) => {
+ const id = getFriendUserId(friend, options.currentUserId.value)
+ const invited =
+ invitedRows.value.get(normalizeInviteKey(id)) ??
+ invitedRows.value.get(normalizeInviteKey(friend.username))
+ return {
+ id,
+ username: friend.username,
+ avatarUrl: friend.avatar,
+ online: friend.online,
+ status: invited ? (invited.pending ? 'pending' : 'added') : 'available',
+ }
+ }),
+ )
+ const candidateKeys = computed(() => {
+ const keys = new Set()
+ for (const friend of inviteFriends.value) {
+ keys.add(normalizeInviteKey(friend.id))
+ keys.add(normalizeInviteKey(friend.username))
+ }
+ return keys
+ })
+
+ async function search(query: string): Promise {
+ if (options.actionsLocked.value) return []
+ const credentials = await getCredentials()
+ const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
+ return (await search_user(query))
+ .filter((user) => user.id !== ownUserId)
+ .filter((user) => {
+ const id = normalizeInviteKey(user.id)
+ const username = normalizeInviteKey(user.username)
+ return (
+ !candidateKeys.value.has(id) &&
+ !candidateKeys.value.has(username) &&
+ !invitedRows.value.has(id) &&
+ !invitedRows.value.has(username)
+ )
+ })
+ .map((user) => ({
+ id: user.id,
+ username: user.username,
+ avatarUrl: user.avatar_url || undefined,
+ }))
+ }
+
+ async function requestFriend(user: InvitePlayersUser) {
+ if (options.actionsLocked.value) return
+ const credentials = await getCredentials()
+ const ownUserId = options.currentUserId.value ?? credentials?.user_id ?? null
+ if (ownUserId && normalizeInviteKey(user.id) === normalizeInviteKey(ownUserId)) return
+ if (!friendsState.findFriend(user.id, user.username)) {
+ friendsState.requestFriend({
+ id: user.id,
+ username: user.username,
+ avatarUrl: user.avatarUrl,
+ })
+ }
+ }
+
+ return { inviteFriends, search, requestFriend }
+}
diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts
new file mode 100644
index 0000000000..7a7d0a77a9
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-invite-link.ts
@@ -0,0 +1,63 @@
+import type { InviteLinkSettings } from '@modrinth/ui'
+import { computed, type Ref, ref, watch } from 'vue'
+
+import { config } from '@/config'
+import { toError } from '@/helpers/errors'
+import { create_shared_instance_invite_link } from '@/helpers/instance'
+
+export function useSharedInstanceInviteLink(
+ instanceId: Ref,
+ onError: (error: unknown) => void,
+) {
+ const details = ref>>()
+ const pending = ref(false)
+ const link = computed(() =>
+ details.value
+ ? `${config.siteUrl}/share/${encodeURIComponent(details.value.inviteId)}`
+ : undefined,
+ )
+
+ async function ensure() {
+ if (details.value) return true
+ if (pending.value) return false
+
+ pending.value = true
+ try {
+ details.value = await create_shared_instance_invite_link(instanceId.value)
+ return true
+ } catch (error) {
+ onError(error)
+ return false
+ } finally {
+ pending.value = false
+ }
+ }
+
+ async function update(settings: InviteLinkSettings) {
+ if (!details.value) return
+
+ pending.value = true
+ try {
+ const maxAgeSeconds = Math.max(
+ 1,
+ Math.min(604800, Math.floor((settings.expiresAt.getTime() - Date.now()) / 1000)),
+ )
+ details.value = await create_shared_instance_invite_link(instanceId.value, {
+ maxAgeSeconds,
+ maxUses: settings.maxUses,
+ replaceInviteId: details.value.inviteId,
+ })
+ } catch (error) {
+ throw toError(error)
+ } finally {
+ pending.value = false
+ }
+ }
+
+ watch(instanceId, () => {
+ details.value = undefined
+ pending.value = false
+ })
+
+ return { details, pending, link, ensure, update }
+}
diff --git a/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts
new file mode 100644
index 0000000000..a8b148db2a
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/share/use-shared-instance-members.ts
@@ -0,0 +1,298 @@
+import type { InvitePlayersUser } from '@modrinth/ui'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { computed, type Ref, ref, watch } from 'vue'
+
+import { get_user_many } from '@/helpers/cache.js'
+import { toError } from '@/helpers/errors'
+import {
+ get_shared_instance_users,
+ invite_shared_instance_users,
+ remove_shared_instance_users,
+ type SharedInstanceUser,
+ type SharedInstanceUsers,
+} from '@/helpers/instance'
+import type { GameInstance } from '@/helpers/types'
+
+import { normalizeInviteKey, type ShareRow } from './shared-instance-share-types'
+
+type MutationContext = {
+ queryKey: readonly ['sharedInstanceUsers', string]
+ previousRows?: ShareRow[]
+ previousPendingRows: Record
+}
+
+export function useSharedInstanceMembers(options: {
+ instance: Ref
+ currentUserId: Ref
+ isSignedIn: Ref
+ actionsLocked: Ref
+ onError: (error: unknown) => void
+ onHydrationError: (error: Error) => void
+}) {
+ const queryClient = useQueryClient()
+ const queryKey = computed(
+ () => ['sharedInstanceUsers', options.instance.value.id] as const,
+ )
+ const pendingRows = ref>(loadPendingRows(options.instance.value.id))
+
+ async function usersToRows(users: SharedInstanceUsers): Promise {
+ const excludedIds = new Set(
+ [options.instance.value.shared_instance?.manager_id, options.currentUserId.value].filter(
+ (id): id is string => !!id,
+ ),
+ )
+ const usersToDisplay = userEntries(users).filter((user) => !excludedIds.has(user.id))
+ if (usersToDisplay.length === 0) return []
+
+ const profiles = (await get_user_many(usersToDisplay.map((user) => user.id))) as Array<{
+ id: string
+ username?: string
+ avatar_url?: string | null
+ }>
+
+ return usersToDisplay.map((user) => {
+ const profile = profiles.find((candidate) => candidate.id === user.id)
+ const joinedAt = parseDate(user.joined_at)
+ return {
+ id: user.id,
+ username: profile?.username ?? user.id,
+ avatarUrl: profile?.avatar_url ?? undefined,
+ lastPlayedAt: parseDate(user.last_played),
+ joinedAt,
+ method: user.join_type === 'link' ? 'link' : 'direct',
+ pending: !joinedAt,
+ }
+ })
+ }
+
+ async function loadRows() {
+ if (options.actionsLocked.value) return []
+ const loadedRows = await usersToRows(
+ await get_shared_instance_users(options.instance.value.id),
+ )
+ removePendingRows(loadedRows.map((row) => row.id))
+ return loadedRows
+ }
+
+ const query = useQuery({
+ queryKey,
+ queryFn: loadRows,
+ enabled: () =>
+ options.isSignedIn.value && !!options.instance.value.id && !options.actionsLocked.value,
+ staleTime: Infinity,
+ })
+ const remoteRows = computed(
+ () => query.data.value ?? queryClient.getQueryData(queryKey.value) ?? [],
+ )
+ const rows = computed(() => {
+ if (options.actionsLocked.value) return remoteRows.value
+ const remoteIds = new Set(remoteRows.value.map((row) => normalizeInviteKey(row.id)))
+ return [
+ ...Object.values(pendingRows.value).filter(
+ (row) => !remoteIds.has(normalizeInviteKey(row.id)),
+ ),
+ ...remoteRows.value,
+ ]
+ })
+
+ const inviteMutation = useMutation({
+ mutationFn: async (user: InvitePlayersUser) => {
+ if (options.actionsLocked.value) return
+ return await invite_shared_instance_users(options.instance.value.id, [user.id])
+ },
+ onMutate: async (user): Promise => {
+ const currentKey = queryKey.value
+ await queryClient.cancelQueries({ queryKey: currentKey })
+ const context = {
+ queryKey: currentKey,
+ previousRows: queryClient.getQueryData(currentKey),
+ previousPendingRows: pendingRows.value,
+ }
+ setPendingRow(user)
+ return context
+ },
+ onError: (error, user, context) => {
+ removePendingRow(user.id)
+ restore(context)
+ options.onError(error)
+ },
+ onSuccess: async (users, user) => {
+ try {
+ if (users) {
+ queryClient.setQueryData(queryKey.value, await usersToRows(users))
+ } else {
+ upsert(inviteUserToRow(user))
+ }
+ } catch (error) {
+ options.onHydrationError(toError(error))
+ upsert(inviteUserToRow(user))
+ await queryClient.invalidateQueries({ queryKey: queryKey.value })
+ }
+ },
+ })
+
+ const removeMutation = useMutation({
+ mutationFn: async (id: string) => {
+ if (options.actionsLocked.value) {
+ return { user_ids: [], users: [], tokens: 0 } satisfies SharedInstanceUsers
+ }
+ return await remove_shared_instance_users(options.instance.value.id, [id])
+ },
+ onMutate: async (id): Promise => {
+ const currentKey = queryKey.value
+ await queryClient.cancelQueries({ queryKey: currentKey })
+ const context = {
+ queryKey: currentKey,
+ previousRows: queryClient.getQueryData(currentKey),
+ previousPendingRows: pendingRows.value,
+ }
+ queryClient.setQueryData(currentKey, (currentRows = []) =>
+ currentRows.filter((row) => normalizeInviteKey(row.id) !== normalizeInviteKey(id)),
+ )
+ removePendingRow(id)
+ return context
+ },
+ onError: (error, _id, context) => {
+ restore(context)
+ options.onError(error)
+ },
+ onSuccess: async (users) => {
+ try {
+ queryClient.setQueryData(queryKey.value, await usersToRows(users))
+ } catch (error) {
+ options.onHydrationError(toError(error))
+ await queryClient.invalidateQueries({ queryKey: queryKey.value })
+ }
+ },
+ })
+
+ function find(id: string, username: string) {
+ const normalizedId = normalizeInviteKey(id)
+ const normalizedUsername = normalizeInviteKey(username)
+ return rows.value.find(
+ (row) =>
+ normalizeInviteKey(row.id) === normalizedId ||
+ normalizeInviteKey(row.username) === normalizedUsername,
+ )
+ }
+
+ function invite(user: InvitePlayersUser) {
+ if (!options.actionsLocked.value && !find(user.id, user.username)) inviteMutation.mutate(user)
+ }
+
+ function remove(id: string) {
+ if (!options.actionsLocked.value) removeMutation.mutate(id)
+ }
+
+ function setPendingRow(user: InvitePlayersUser) {
+ pendingRows.value = { ...pendingRows.value, [user.id]: inviteUserToRow(user) }
+ savePendingRows()
+ }
+
+ function removePendingRow(id: string) {
+ const normalizedId = normalizeInviteKey(id)
+ pendingRows.value = Object.fromEntries(
+ Object.entries(pendingRows.value).filter(
+ ([pendingId]) => normalizeInviteKey(pendingId) !== normalizedId,
+ ),
+ )
+ savePendingRows()
+ }
+
+ function removePendingRows(ids: string[]) {
+ if (ids.length === 0) return
+ const normalizedIds = new Set(ids.map(normalizeInviteKey))
+ pendingRows.value = Object.fromEntries(
+ Object.entries(pendingRows.value).filter(
+ ([id]) => !normalizedIds.has(normalizeInviteKey(id)),
+ ),
+ )
+ savePendingRows()
+ }
+
+ function upsert(row: ShareRow) {
+ queryClient.setQueryData(queryKey.value, (currentRows = []) => [
+ row,
+ ...currentRows.filter(
+ (existing) => normalizeInviteKey(existing.id) !== normalizeInviteKey(row.id),
+ ),
+ ])
+ }
+
+ function restore(context?: MutationContext) {
+ if (!context) return
+ if (context.previousRows === undefined) {
+ queryClient.removeQueries({ queryKey: context.queryKey, exact: true })
+ } else {
+ queryClient.setQueryData(context.queryKey, context.previousRows)
+ }
+ pendingRows.value = context.previousPendingRows
+ savePendingRows()
+ }
+
+ function savePendingRows() {
+ if (typeof localStorage === 'undefined') return
+ const key = pendingRowsStorageKey(options.instance.value.id)
+ const storedRows = Object.values(pendingRows.value)
+ if (storedRows.length === 0) localStorage.removeItem(key)
+ else localStorage.setItem(key, JSON.stringify(storedRows))
+ }
+
+ watch(
+ () => options.instance.value.id,
+ (instanceId) => {
+ pendingRows.value = loadPendingRows(instanceId)
+ },
+ )
+
+ return { rows, query, find, invite, remove }
+}
+
+function userEntries(users: SharedInstanceUsers): SharedInstanceUser[] {
+ if (users.users?.length > 0) return users.users
+ return users.user_ids.map((id) => ({
+ id,
+ joined_at: null,
+ join_type: 'invite',
+ last_played: null,
+ }))
+}
+
+function parseDate(value?: string | null) {
+ if (!value) return null
+ const date = new Date(value)
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+function inviteUserToRow(user: InvitePlayersUser): ShareRow {
+ return {
+ id: user.id,
+ username: user.username,
+ avatarUrl: user.avatarUrl ?? undefined,
+ lastPlayedAt: null,
+ joinedAt: null,
+ method: 'direct',
+ pending: true,
+ }
+}
+
+function loadPendingRows(instanceId: string): Record {
+ if (typeof localStorage === 'undefined') return {}
+ try {
+ const stored = localStorage.getItem(pendingRowsStorageKey(instanceId))
+ if (!stored) return {}
+ const rows = JSON.parse(stored) as ShareRow[]
+ return Object.fromEntries(
+ rows.map((row) => [
+ row.id,
+ { ...row, lastPlayedAt: null, joinedAt: null, pending: true } satisfies ShareRow,
+ ]),
+ )
+ } catch {
+ return {}
+ }
+}
+
+function pendingRowsStorageKey(instanceId: string) {
+ return `modrinth:shared-instance-pending-users:${instanceId}`
+}
diff --git a/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts b/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts
new file mode 100644
index 0000000000..e730352d9d
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/use-shared-instance-state.ts
@@ -0,0 +1,193 @@
+import { injectAuth } from '@modrinth/ui'
+import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue'
+
+import { useUserQuery } from '@/composables/users/use-user-query'
+import {
+ getSharedInstanceUnavailableReason,
+ install_get_shared_instance_update_preview,
+ isSharedInstanceUnavailableError,
+ type SharedInstanceUnavailableReason,
+} from '@/helpers/install'
+import type { GameInstance } from '@/helpers/types'
+
+export type SharedInstanceManager =
+ | {
+ type: 'user'
+ name: string
+ avatarUrl?: string
+ tintBy: string
+ }
+ | {
+ type: 'server'
+ name: string
+ avatarUrl?: string
+ tintBy: string
+ }
+
+export function useSharedInstanceState(
+ instance: Ref,
+ offline: Ref,
+ notifyError: (error: unknown) => void,
+) {
+ const auth = injectAuth()
+ const updatePreview = ref<
+ Awaited>
+ >(null)
+ const unavailableReason = ref(null)
+ const availabilityCheckKey = ref(null)
+ const availabilityRefresh = ref(0)
+ let availabilityRequestId = 0
+
+ const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
+ const wrongAccount = computed(() => {
+ if (auth.isReady && !auth.isReady.value) return false
+ if (!expectedUserId.value) return false
+ return auth.user.value?.id !== expectedUserId.value
+ })
+ const actionsLocked = computed(() => wrongAccount.value)
+ const shareActionsLocked = computed(
+ () => actionsLocked.value || unavailableReason.value !== null,
+ )
+ const signedOut = computed(() => !auth.session_token.value)
+ const managerUserId = computed(() => {
+ const attachment = instance.value?.shared_instance
+ if (!attachment) return null
+ if (attachment.role === 'owner') {
+ return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
+ }
+ return attachment.manager_id ?? null
+ })
+ const managerUserQuery = useUserQuery(managerUserId)
+ const manager = computed(() => {
+ const attachment = instance.value?.shared_instance
+ if (!attachment) return null
+
+ if (attachment.server_manager_name) {
+ return {
+ type: 'server',
+ name: attachment.server_manager_name,
+ avatarUrl: attachment.server_manager_icon_url ?? undefined,
+ tintBy: attachment.server_manager_name,
+ }
+ }
+
+ const user = managerUserQuery.data.value
+ if (!user) return null
+ return {
+ type: 'user',
+ name: user.username,
+ avatarUrl: user.avatar_url ?? undefined,
+ tintBy: user.id,
+ }
+ })
+ const unavailableManager = computed(() => manager.value?.name ?? null)
+
+ function reset() {
+ availabilityRequestId++
+ availabilityCheckKey.value = null
+ updatePreview.value = null
+ unavailableReason.value = null
+ }
+
+ function refreshAvailability() {
+ availabilityRefresh.value++
+ }
+
+ function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
+ availabilityRequestId++
+ availabilityCheckKey.value = null
+ updatePreview.value = null
+ unavailableReason.value = reason
+ }
+
+ async function checkAvailability(instanceId: string, key: string) {
+ const requestId = ++availabilityRequestId
+ try {
+ const preview = await install_get_shared_instance_update_preview(instanceId)
+ if (!isCurrentRequest(requestId, instanceId, key)) return
+ updatePreview.value = preview
+ unavailableReason.value = null
+ } catch (error) {
+ if (!isCurrentRequest(requestId, instanceId, key)) return
+ if (isSharedInstanceUnavailableError(error)) {
+ updatePreview.value = null
+ unavailableReason.value = getSharedInstanceUnavailableReason(error)
+ return
+ }
+ notifyError(error)
+ }
+ }
+
+ function isCurrentRequest(requestId: number, instanceId: string, key: string) {
+ return (
+ requestId === availabilityRequestId &&
+ instance.value?.id === instanceId &&
+ availabilityCheckKey.value === key
+ )
+ }
+
+ watch(
+ () => ({
+ refresh: availabilityRefresh.value,
+ instanceId: instance.value?.id,
+ role: instance.value?.shared_instance?.role,
+ locked: actionsLocked.value,
+ offline: offline.value,
+ signedIn: !!auth.session_token.value,
+ userId: auth.user.value?.id ?? null,
+ authReady: auth.isReady?.value ?? true,
+ }),
+ async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => {
+ if (
+ !instanceId ||
+ role !== 'member' ||
+ locked ||
+ offline ||
+ !authReady ||
+ !signedIn ||
+ !userId
+ ) {
+ availabilityRequestId++
+ availabilityCheckKey.value = null
+ updatePreview.value = null
+ if (instanceId && role) unavailableReason.value = null
+ return
+ }
+
+ const key = `${instanceId}:${userId}`
+ if (availabilityCheckKey.value === key) return
+ availabilityCheckKey.value = key
+ await checkAvailability(instanceId, key)
+ },
+ { immediate: true },
+ )
+
+ return {
+ actionsLocked,
+ shareActionsLocked,
+ unavailableReason,
+ unavailableManager,
+ manager,
+ updatePreview,
+ expectedUserId,
+ wrongAccount,
+ signedOut,
+ reset,
+ refreshAvailability,
+ setUnavailable,
+ }
+}
+
+export type SharedInstanceState = ReturnType
+
+const sharedInstanceStateKey: InjectionKey = Symbol('shared-instance-state')
+
+export function provideSharedInstanceState(state: SharedInstanceState) {
+ provide(sharedInstanceStateKey, state)
+}
+
+export function injectSharedInstanceState() {
+ const state = inject(sharedInstanceStateKey)
+ if (!state) throw new Error('Shared instance state has not been provided.')
+ return state
+}
diff --git a/apps/app-frontend/src/providers/instance-backup.ts b/apps/app-frontend/src/providers/instance-backup.ts
new file mode 100644
index 0000000000..dee36ebf5d
--- /dev/null
+++ b/apps/app-frontend/src/providers/instance-backup.ts
@@ -0,0 +1,25 @@
+import { provideAppBackup } from '@modrinth/ui'
+import { type MaybeRefOrGetter, toValue } from 'vue'
+
+import { install_duplicate_instance, installJobInstanceId } from '@/helpers/install'
+import { edit, list } from '@/helpers/instance'
+import type { GameInstance } from '@/helpers/types'
+
+export function provideInstanceBackup(instance: MaybeRefOrGetter) {
+ provideAppBackup({
+ async createBackup() {
+ const source = toValue(instance)
+ const prefix = `${source.name} - Backup #`
+ const existingNumbers = (await list())
+ .filter((candidate) => candidate.name.startsWith(prefix))
+ .map((candidate) => Number.parseInt(candidate.name.slice(prefix.length), 10))
+ .filter(Number.isFinite)
+ const nextNumber = existingNumbers.length ? Math.max(...existingNumbers) + 1 : 1
+ const job = await install_duplicate_instance(source.id)
+ const backupInstanceId = installJobInstanceId(job)
+ if (backupInstanceId) {
+ await edit(backupInstanceId, { name: `${prefix}${nextNumber}` })
+ }
+ },
+ })
+}
diff --git a/apps/app-frontend/src/providers/setup/auth.ts b/apps/app-frontend/src/providers/setup/auth.ts
index 8832c09c6a..6556b7dd8f 100644
--- a/apps/app-frontend/src/providers/setup/auth.ts
+++ b/apps/app-frontend/src/providers/setup/auth.ts
@@ -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,
- requestSignIn: (redirectPath: string) => void | Promise,
+ requestSignIn: (redirectPath: string, flow?: AuthFlow) => void | Promise,
) {
const sessionToken = ref(null)
const user = ref(null)
diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js
index 5919805754..2baa1edd06 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -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',
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 8a8fe00c79..5b4c07c9c6 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -148,6 +148,11 @@ fn main() {
"install_get_modpack_preview",
"install_create_instance",
"install_create_modpack_instance",
+ "install_get_shared_instance_preview",
+ "install_accept_shared_instance_invite",
+ "install_get_shared_instance_update_preview",
+ "install_shared_instance",
+ "install_update_shared_instance",
"install_import_instance",
"install_duplicate_instance",
"install_existing_instance",
@@ -210,6 +215,14 @@ fn main() {
"instance_kill",
"instance_edit",
"instance_edit_icon",
+ "instance_share_get_users",
+ "instance_share_invite_users",
+ "instance_share_create_invite_link",
+ "instance_share_remove_users",
+ "instance_share_get_publish_preview",
+ "instance_share_publish",
+ "instance_share_unlink",
+ "instance_share_unpublish",
"instance_export_mrpack",
"instance_get_pack_export_candidates",
])
@@ -251,6 +264,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
+ .plugin(
+ "users",
+ InlinedPlugin::new()
+ .commands(&["search_user"])
+ .default_permission(
+ DefaultPermissionRule::AllowAllCommands,
+ ),
+ )
.plugin(
"utils",
InlinedPlugin::new()
diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json
index 2cb2521766..4a30955f70 100644
--- a/apps/app/capabilities/plugins.json
+++ b/apps/app/capabilities/plugins.json
@@ -107,6 +107,7 @@
"settings:default",
"shortcuts:default",
"tags:default",
+ "users:default",
"utils:default",
"ads:default",
"friends:default",
diff --git a/apps/app/src/api/install.rs b/apps/app/src/api/install.rs
index 96d2f0481b..22dcbef219 100644
--- a/apps/app/src/api/install.rs
+++ b/apps/app/src/api/install.rs
@@ -6,6 +6,10 @@ use theseus::data::ModLoader;
use theseus::install::{
InstallJobSnapshot, InstallModpackPreview, InstallPostInstallEdit,
};
+use theseus::instance::{
+ SharedInstanceInstallPreview, SharedInstanceInviteInstallPreview,
+ SharedInstanceUpdatePreview,
+};
use theseus::pack::import::ImportLauncherType;
use theseus::pack::install_from::CreatePackLocation;
use uuid::Uuid;
@@ -16,6 +20,11 @@ pub fn init() -> tauri::plugin::TauriPlugin {
install_get_modpack_preview,
install_create_instance,
install_create_modpack_instance,
+ install_get_shared_instance_preview,
+ install_accept_shared_instance_invite,
+ install_get_shared_instance_update_preview,
+ install_shared_instance,
+ install_update_shared_instance,
install_import_instance,
install_duplicate_instance,
install_existing_instance,
@@ -101,6 +110,67 @@ pub async fn install_create_modpack_instance(
.await?)
}
+#[tauri::command]
+pub async fn install_get_shared_instance_preview(
+ shared_instance_id: String,
+ name: String,
+) -> Result {
+ 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 {
+ 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